diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 2d8cd5c9..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.credo.exs b/.credo.exs index 65ae29d7..4e0b9aa6 100644 --- a/.credo.exs +++ b/.credo.exs @@ -2,6 +2,10 @@ configs: [ %{ name: "default", + # ex_slop is a credo PLUGIN (registers its whole check bundle), not a + # check — listing it under checks.enabled was silently ignored + # ("Ignoring an undefined check: ExSlop"). + plugins: [{ExSlop, []}], files: %{ included: ["lib/", "test/"], excluded: [~r"/_build/", ~r"/deps/"] diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..4364368b --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# .githooks/pre-push — Mob repo pre-push gate. +# +# Activated per-clone with: git config core.hooksPath .githooks +# See RELEASE.md for the full release flow + when this hook runs. +# +# Two tiers of check: +# +# ALWAYS (cheap, sub-10s): +# mix format --check-formatted +# mix credo --strict +# mix compile --warnings-as-errors +# +# ONLY WHEN mix.exs CHANGED IN THIS PUSH (release preflight): +# mix test --exclude macos_only --exclude requires_zig +# +# Intentionally NOT in the always-tier: the full test suite. It's a +# 30-60s wait per push and that's exactly what teaches people to reach +# for --no-verify. CI runs the suite on every push regardless; this +# hook is here to catch the 80% of breaks that the fast checks find. +set -euo pipefail + +# Suppress noisy OTP-28 regex warning so the hook output is clean. +export ELIXIR_ERL_OPTIONS="-elixir ansi_enabled true" + +cheap_checks() { + echo "[pre-push] format..." + mix format --check-formatted + + echo "[pre-push] credo..." + mix credo --strict + + echo "[pre-push] compile (--warnings-as-errors)..." + mix compile --warnings-as-errors +} + +release_preflight() { + echo "[pre-push] mix.exs changed → running release preflight..." + mix test --exclude macos_only --exclude requires_zig + + # mix mob.security_scan only exists in mob_dev. Gate on availability + # so the same hook script can live unchanged in mob / mob_dev / mob_new. + if mix help mob.security_scan >/dev/null 2>&1; then + echo "[pre-push] mob.security_scan..." + mix mob.security_scan + fi +} + +# git invokes pre-push with no args but pipes +# +# on stdin, one line per ref being pushed. We diff the local sha +# against the remote sha to see what mix.exs looks like in this push. +zero=0000000000000000000000000000000000000000 +mix_exs_changed=false + +while read -r local_ref local_sha remote_ref remote_sha; do + # Branch deletion (local_sha all zeros) → nothing to diff. + [ "$local_sha" = "$zero" ] && continue + + if [ "$remote_sha" = "$zero" ]; then + # New branch on the remote — diff against origin/master so we + # don't try to scan an open-ended commit range. + range="origin/master..$local_sha" + else + range="$remote_sha..$local_sha" + fi + + if git diff --name-only "$range" 2>/dev/null | grep -qx 'mix.exs'; then + mix_exs_changed=true + fi +done + +cheap_checks + +if [ "$mix_exs_changed" = "true" ]; then + release_preflight +fi + +echo "[pre-push] ✓ all checks passed" diff --git a/.github/workflows/onboarding.yml b/.github/workflows/onboarding.yml deleted file mode 100644 index d59336d1..00000000 --- a/.github/workflows/onboarding.yml +++ /dev/null @@ -1,247 +0,0 @@ -name: Onboarding Integration Tests - -on: - push: - branches: [main] - paths: - - 'lib/**' - - 'priv/templates/**' - - 'test/onboarding/**' - - '.github/workflows/onboarding.yml' - pull_request: - paths: - - 'lib/**' - - 'priv/templates/**' - - 'test/onboarding/**' - schedule: - # Nightly at 06:00 UTC — catches regressions from upstream changes - - cron: '0 6 * * *' - workflow_dispatch: - inputs: - scope: - description: 'Test scope (generator | pre_device | all)' - required: false - default: 'generator' - -concurrency: - group: onboarding-${{ github.ref }} - cancel-in-progress: true - -jobs: - # ── Fast gate: generator + pre-device (no simulator required) ──────────────── - pre-device: - name: "Pre-device (${{ matrix.env }} / Elixir ${{ matrix.elixir }})" - strategy: - fail-fast: false - matrix: - include: - # Primary: Nix — highest-risk environment based on user reports - - run: A - env: nix - elixir: "1.18" - otp: "27" - priority: critical - - # Latest toolchain via mise - - run: B - env: mise - elixir: "1.19" - otp: "28" - priority: critical - - # Minimum versions via mise - - run: C - env: mise - elixir: "1.18" - otp: "27" - priority: standard - - # asdf - - run: D - env: asdf - elixir: "1.19" - otp: "28" - priority: standard - - runs-on: macos-15 # Apple Silicon; required for arm64 Android images - - steps: - - uses: actions/checkout@v4 - - # ── Nix setup (Run A) ────────────────────────────────────────────────── - - name: Install Nix - if: matrix.env == 'nix' - uses: cachix/install-nix-action@v26 - with: - nix_path: nixpkgs=channel:nixos-24.05 - - - name: Enter Nix dev shell and verify elixir version - if: matrix.env == 'nix' - run: | - nix develop ./test/onboarding/nix#default --command elixir --version - # Ensure Nix elixir is 1.18.x - nix develop ./test/onboarding/nix#default --command \ - elixir -e 'v = System.version(); [maj, min | _] = String.split(v, "."); if String.to_integer(min) < 18, do: exit(1)' - - # ── mise setup (Runs B, C) ───────────────────────────────────────────── - - name: Install mise - if: matrix.env == 'mise' - run: | - curl https://mise.run | sh - echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH - - - name: Configure mise versions - if: matrix.env == 'mise' - run: | - mise use --global elixir@${{ matrix.elixir }} - mise use --global erlang@${{ matrix.otp }} - mise install - elixir --version - - # ── asdf setup (Run D) ──────────────────────────────────────────────── - - name: Install asdf - if: matrix.env == 'asdf' - uses: asdf-vm/actions/setup@v3 - - - name: Configure asdf versions - if: matrix.env == 'asdf' - run: | - asdf plugin add elixir https://github.com/asdf-vm/asdf-elixir.git || true - asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git || true - asdf install elixir ${{ matrix.elixir }} - asdf install erlang ${{ matrix.otp }} - echo "elixir ${{ matrix.elixir }}" >> ~/.tool-versions - echo "erlang ${{ matrix.otp }}" >> ~/.tool-versions - - # ── Android SDK ─────────────────────────────────────────────────────── - - name: Set up Android SDK - uses: android-actions/setup-android@v3 - - - name: Install Android SDK components - run: | - sdkmanager "platform-tools" "emulator" "build-tools;34.0.0" - sdkmanager "system-images;android-28;google_apis;arm64-v8a" - sdkmanager "system-images;android-35;google_apis;arm64-v8a" - sdkmanager "platforms;android-34" "platforms;android-35" - - # ── Install Hex and mob archive ─────────────────────────────────────── - - name: Install Hex - run: mix local.hex --force - - - name: Cache mob archive - uses: actions/cache@v4 - with: - path: ~/.mix/archives - key: mob-archive-${{ hashFiles('mix.exs') }} - - # ── Run tests ───────────────────────────────────────────────────────── - - name: Run generator + pre-device tests - run: | - mix mob.onboarding_test --only generator --env ${{ matrix.env }} - mix mob.onboarding_test --only pre_device --env ${{ matrix.env }} - timeout-minutes: 20 - env: - MIX_ENV: test - # On Nix: ensure system curl is first in PATH to avoid SSL issues - PATH: /usr/bin:/bin:${{ env.PATH }} - - # ── Artifacts on failure ────────────────────────────────────────────── - - name: Upload failure logs - if: failure() - uses: actions/upload-artifact@v4 - with: - name: onboarding-pre-device-run-${{ matrix.run }} - path: /tmp/mob_onboarding_*/logs/ - retention-days: 7 - - # ── Full device tests: iOS + Android simulators/emulators ──────────────────── - with-devices: - name: "With devices (${{ matrix.ios }} + ${{ matrix.android }})" - needs: pre-device # Only run after the fast gate passes - if: | - github.event_name == 'schedule' || - github.event_name == 'workflow_dispatch' && github.event.inputs.scope == 'all' || - github.ref == 'refs/heads/main' - - strategy: - fail-fast: false - matrix: - include: - # Minimum versions — most likely to reveal compatibility issues - - run: ios-min - ios: ios_min - android: android_min - - # Maximum versions — latest OS features, fresh APIs - - run: ios-max - ios: ios_max - android: android_max - - runs-on: macos-15 - - steps: - - uses: actions/checkout@v4 - - - name: Install mise + Elixir 1.19 - run: | - curl https://mise.run | sh - echo "$HOME/.local/share/mise/shims" >> $GITHUB_PATH - mise use --global elixir@1.19 - mise use --global erlang@28 - mise install - - - name: Install Hex + mob archive - run: mix local.hex --force - - - name: Set up Android SDK - uses: android-actions/setup-android@v3 - - - name: Install Android system images - run: | - sdkmanager "platform-tools" "emulator" "build-tools;34.0.0" - sdkmanager "system-images;android-28;google_apis;arm64-v8a" - sdkmanager "system-images;android-35;google_apis;arm64-v8a" - sdkmanager "platforms;android-34" "platforms;android-35" - - - name: Download iOS 16 runtime (ios-min only) - if: matrix.ios == 'ios_min' - run: | - xcrun simctl runtime add "com.apple.CoreSimulator.SimRuntime.iOS-16-0" || true - timeout-minutes: 15 - - - name: Run full onboarding test (ios=${{ matrix.ios }}, android=${{ matrix.android }}) - run: mix mob.onboarding_test --all --env mise - timeout-minutes: 45 - env: - MIX_ENV: test - MOB_TEST_IOS_SLOT: ${{ matrix.ios }} - MOB_TEST_ANDROID_SLOT: ${{ matrix.android }} - - - name: Upload failure logs and workspace - if: failure() - uses: actions/upload-artifact@v4 - with: - name: onboarding-device-${{ matrix.run }} - path: | - /tmp/mob_onboarding_*/logs/ - /tmp/mob_onboarding_*/mob_failure_test/ - retention-days: 7 - - # ── Summary ─────────────────────────────────────────────────────────────────── - summary: - name: Onboarding gate - needs: [pre-device, with-devices] - if: always() - runs-on: ubuntu-latest - steps: - - name: Check results - run: | - if [[ "${{ needs.pre-device.result }}" != "success" ]]; then - echo "::error::Pre-device tests failed" - exit 1 - fi - if [[ "${{ needs.with-devices.result }}" == "failure" ]]; then - echo "::error::Device tests failed" - exit 1 - fi - echo "All onboarding tests passed" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..c1ea2594 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,156 @@ +name: release + +# Trigger model: mix.exs is the source of truth for the version. Bump the +# `version: "X.Y.Z"` line, commit, push — the workflow detects the change, +# tags it, creates the GitHub Release, and publishes to Hex. +# +# Re-running manually (e.g. to back-fill a Hex publish that was skipped +# because the tag pre-existed) is via the Actions tab's "Run workflow" +# button (workflow_dispatch). Each step is independently idempotent: +# tag-already-exists, release-already-exists, and version-already-on-Hex +# are each detected per-step, so re-runs only do work that's actually +# missing. +on: + push: + branches: [master] + paths: ['mix.exs'] + workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write # required to create the GitHub Release AND push the tag + +jobs: + release: + name: Release from mix.exs + runs-on: ubuntu-latest + env: + # Reads the repo secret. Steps that need it gate on + # `env.HEX_API_KEY != ''` so a missing secret skips cleanly instead + # of failing with an auth error. + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history for changelog generation + + - name: Configure git identity (for the tag push below) + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Extract version from mix.exs + id: version + run: | + # Pull the `version: "X.Y.Z"` from the project/0 keyword list. + # Tolerates leading whitespace, expects a literal double-quoted + # string (Elixir-mix convention). + version=$(grep -E '^\s*version:\s*"' mix.exs | head -1 | sed 's/.*"\([^"]*\)".*/\1/') + if [ -z "$version" ]; then + # `version: @version` idiom — read the module attribute instead. + version=$(grep -E '^\s*@version\s+"' mix.exs | head -1 | sed 's/.*"\([^"]*\)".*/\1/') + fi + if [ -z "$version" ]; then + echo "::error::Could not extract version from mix.exs" + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Detected version: $version" + + # ── Tag (idempotent: skip if exists) ──────────────────────────── + - name: Create + push tag (if missing) + run: | + tag="${{ steps.version.outputs.version }}" + if git rev-parse "refs/tags/$tag" >/dev/null 2>&1; then + echo "::notice::Tag $tag already exists — skipping tag creation" + else + git tag "$tag" + git push origin "$tag" + echo "Created and pushed tag $tag" + fi + + # ── GitHub Release (idempotent: skip if exists) ───────────────── + - name: Check if GitHub Release exists + id: release_check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag="${{ steps.version.outputs.version }}" + if gh release view "$tag" -R "${{ github.repository }}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "::notice::GitHub Release $tag already exists — skipping release creation" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Extract CHANGELOG section + if: steps.release_check.outputs.exists == 'false' + id: changelog + run: | + if [ -f CHANGELOG.md ]; then + awk -v tag="${{ steps.version.outputs.version }}" ' + $0 ~ "^## \\[" tag "\\]" || $0 ~ "^## " tag "( |$)" { in_section=1; next } + in_section && /^## / { exit } + in_section { print } + ' CHANGELOG.md > /tmp/release-body.md + if [ -s /tmp/release-body.md ]; then + echo "has_body=true" >> "$GITHUB_OUTPUT" + else + echo "has_body=false" >> "$GITHUB_OUTPUT" + fi + else + echo "has_body=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub Release + if: steps.release_check.outputs.exists == 'false' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.version }} + name: ${{ steps.version.outputs.version }} + body_path: ${{ steps.changelog.outputs.has_body == 'true' && '/tmp/release-body.md' || '' }} + generate_release_notes: ${{ steps.changelog.outputs.has_body != 'true' }} + + # ── Hex publish (idempotent: skip if version already on Hex) ──── + - name: Set up BEAM + if: env.HEX_API_KEY != '' + uses: erlef/setup-beam@v1 + with: + elixir-version: '1.19' + otp-version: '28' + + - name: Check if version is already on Hex + if: env.HEX_API_KEY != '' + id: hex_check + run: | + # `mix hex.info ` exits 0 if the version is published, + # non-zero otherwise. The package name is the project's `app:` value + # in mix.exs — same heuristic as for the version extract. + pkg=$(grep -E '^\s*app:\s*:' mix.exs | head -1 | sed 's/.*:\s*:\([a-z_][a-z0-9_]*\).*/\1/') + vsn="${{ steps.version.outputs.version }}" + if mix hex.info "$pkg" "$vsn" 2>/dev/null | grep -q "Config:"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "::notice::Hex package $pkg $vsn already published — skipping mix hex.publish" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "package=$pkg" >> "$GITHUB_OUTPUT" + fi + + - name: mix hex.publish + if: env.HEX_API_KEY != '' && steps.hex_check.outputs.exists == 'false' + # `--yes` skips the interactive confirm. `mix hex.publish` (no + # subcommand) ships both the package archive AND docs in one call. + run: | + mix deps.get + mix hex.publish --yes + + - name: Skip Hex publish notice (no API key) + if: env.HEX_API_KEY == '' + run: | + echo "::notice::HEX_API_KEY secret is not set on this repository." + echo "::notice::Skipping Hex publish. Add the secret at" + echo "::notice::https://github.com/${{ github.repository }}/settings/secrets/actions" + echo "::notice::and re-run the workflow (Actions tab → Run workflow) to publish." diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..15e80dbc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,126 @@ +name: tests + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }} + runs-on: ubuntu-latest + env: + MIX_ENV: test + strategy: + fail-fast: false + matrix: + include: + - elixir: '1.19' + otp: '28' + steps: + - uses: actions/checkout@v4 + + - uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + + - name: Cache deps + _build + uses: actions/cache@v4 + with: + path: | + deps + _build + key: mix-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }} + restore-keys: mix-${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}- + + - run: mix deps.get + - run: mix deps.compile + - run: mix compile --warnings-as-errors + + - name: Format check + run: mix format --check-formatted + + - name: Credo (strict) + run: mix credo --strict + + - name: erlfmt check (src/) + # erlfmt is `only: :dev` in mix.exs, so the test-env dep tree + # doesn't include it. Override MIX_ENV for just this step instead + # of broadening the dep's `only:` (keeps the test env minimal). + env: + MIX_ENV: dev + run: | + mix deps.get + mix erlfmt --check src/ + + - name: Tests + run: mix test + + native_lint: + name: Native formatters (clang-format + swiftlint) + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: clang-format (iOS Objective-C + Android JNI headers) + # xcrun ships clang-format with the Xcode CLT, which the macos-15 + # runner already has — no install step. + run: | + xcrun clang-format --dry-run -Werror \ + ios/mob_nif.m \ + android/jni/mob_beam.h + + - name: Install swiftlint + run: brew install swiftlint + + - name: swiftlint + # swiftlint emits warnings for some pre-existing patterns + # (force_cast in MobRootView.swift). Treat the run as + # informational until those are triaged; flip to --strict later. + continue-on-error: true + run: swiftlint lint --reporter github-actions-logging ios/ + + security_scan: + name: Security scan (mix_audit) + needs: test + runs-on: ubuntu-latest + if: always() + # Informational: a finding shouldn't block the workflow gate while we + # establish a baseline. Flip to fail-on-vuln once we've triaged what's + # already in the dep tree. + continue-on-error: true + env: + MIX_ENV: test + steps: + - uses: actions/checkout@v4 + + - uses: erlef/setup-beam@v1 + with: + elixir-version: '1.19' + otp-version: '28' + + - name: Cache deps + _build (reuse test job's key) + uses: actions/cache@v4 + with: + path: | + deps + _build + key: mix-${{ runner.os }}-1.19-28-${{ hashFiles('**/mix.lock') }} + restore-keys: mix-${{ runner.os }}-1.19-28- + + - run: mix deps.get + - run: mix deps.compile + + # mix_audit scans mix.lock against the Erlef advisory feed. The + # `app.start +` prefix is load-bearing: plain `mix deps.audit` fails + # with `YamlElixir.read_from_file/1 is undefined` because mix_audit + # doesn't ensure_all_started yaml_elixir on its own; app.start + # starts the host app and yaml_elixir gets pulled in transitively. + - name: Audit deps + run: mix do app.start + deps.audit diff --git a/.gitignore b/.gitignore index 1a11a1b5..efc6a417 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,4 @@ ios/*.o user_issues.md /docs -*.DS_Store* \ No newline at end of file +*.DS_Store*.playwright-mcp/ diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 00000000..88f2f41a --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,48 @@ +included: + - ios + +# Mob's iOS renderer is a single SwiftUI file by design — it bridges the BEAM +# diff stream to a switch-on-node-type renderer. The "errors" SwiftLint flags +# below are stylistic, not bugs; tuning them lets the actual signal through. + +identifier_name: + # Allow short idiomatic names (a/b for tuple components, n/i for indices, + # dx/dy/dt for deltas, op for operation, cb for callback, pt/r/g/h for + # geometry helpers, ms for milliseconds, t for time/temp). Min length lower + # than 3 catches genuinely opaque names like `_a` while letting these stay. + min_length: + warning: 1 + error: 1 + # Existing 40-char max stays as warning — keeps catching genuinely runaway + # names without escalating. + +# MobRootView.swift's renderer uses a big switch over node types. Splitting +# it would scatter the prop-mapping logic; the function reads top-to-bottom +# and each case is small. Same for the file as a whole. +cyclomatic_complexity: + warning: 40 + error: 60 + +file_length: + warning: 2000 + error: 3000 + +function_body_length: + warning: 100 + error: 200 + +# Force-cast in the bridging code is intentional: the BEAM-side type is +# guaranteed by the encoder. Treat as warning, not error. +force_cast: warning + +# SwiftUI initializers like Button(action:, label:) and modifiers like +# onScrollGeometryChange(for:of:) take two closures by API design. Apple's +# own examples use trailing-closure syntax. Suppress. +disabled_rules: + - multiple_closures_with_trailing_closure + +# 130-char limit (default 120). The renderer has a few SwiftUI chains that +# read better on one line; wrapping just to satisfy 120 hurts readability. +line_length: + warning: 130 + error: 200 diff --git a/.tool-versions b/.tool-versions index 66dfbbd7..b4ba75b8 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -elixir 1.20.0-rc.4-otp-29 -erlang 29.0-rc3 +elixir 1.20.0-otp-29 +erlang 29.0 zig 0.17.0-dev.269+ebff43698 diff --git a/AGENTS.md b/AGENTS.md index ecaf01ca..dd86082b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,8 @@ Mob is three coordinated repos. **Know which one to edit before you change anyth | Repo | Path | What lives here | Edit when | |---|---|---|---| | **mob** | `~/code/mob` | Runtime library: `Mob.Screen`, `Mob.App`, `Mob.Renderer`, `Mob.Dist`, `Mob.Test`, the iOS Swift / Android Kotlin native bridges, the NIF | UI behavior, on-device runtime, native bridge changes | -| **mob_dev** | `~/code/mob_dev` | Mix tasks: `mob.deploy`, `mob.connect`, `mob.devices`, `mob.emulators`, `mob.provision`, `mob.doctor`, `mob.battery_bench_*`. Device discovery (`MobDev.Discovery.{Android,IOS}`). Native build orchestration (`MobDev.NativeBuild`). OTP tarball download/cache (`MobDev.OtpDownloader`). | Build/deploy mechanics, device handling, dev tooling | -| **mob_new** | `~/code/mob_new` | Project generator. Hex archive (`mix archive.install hex mob_new`). Templates in `priv/templates/mob.new/`. Generates both native Mob UI projects and Phoenix LiveView wrappers. | Generator output for new projects | +| **mob_dev** | `~/code/mob_dev` | Mix tasks: `mob.deploy`, `mob.connect`, `mob.devices`, `mob.emulators`, `mob.provision`, `mob.doctor`, `mob.battery_bench_*`. Igniter installers (`mob.add_nif`, `mob.enable`, `mob.adopt`). Device discovery (`MobDev.Discovery.{Android,IOS}`). Native build orchestration (`MobDev.NativeBuild`). OTP tarball download/cache (`MobDev.OtpDownloader`). | Build/deploy mechanics, device handling, dev tooling, **Igniter tasks that mutate an existing project** | +| **mob_new** | `~/code/mob_new` | Project generator. Hex archive (`mix archive.install hex mob_new`). Templates in `priv/templates/mob.new/`. Generates both native Mob UI projects and Phoenix LiveView wrappers. | Greenfield generator output. **Must stay self-contained** (`ArchiveSelfContainedTest`) — no hex-dep modules reachable from archive code, so Igniter-based tasks live in mob_dev, not here | Cross-repo changes are common — fixing one user-visible behavior often needs the runtime patched in `mob`, the build retooled in `mob_dev`, **and** the @@ -190,6 +190,18 @@ These are the things we've burned ourselves on. Following them isn't optional. current tarballs have it. See `mob/crypto_plan.md` for the rebuild process when bumping OpenSSL. +13. **Igniter-based tasks live in mob_dev, never in the mob_new archive.** + mob_new ships as a self-contained Mix archive; `ArchiveSelfContainedTest` + pins that no hex-dep modules are reachable from archive code (an archive + bundles only its own beams, so a call into a hex dep crashes every + installed user with `UndefinedFunctionError`). Igniter is a hex dep, so any + `Igniter.Mix.Task` (`mob.add_nif`, `mob.enable`, `mob.adopt`) belongs in + mob_dev — a normal project dependency where Igniter is on the path. A task + that needs mob_new's *templates* (e.g. `mob.adopt --android/--ios`) reads + them from the installed mob_new archive via `:code.priv_dir(:mob_new)` + rather than duplicating them. See + `mob_dev/decisions/2026-06-19-mob-adopt-lives-in-mob_dev.md`. + ## Where to look | Question | File | @@ -217,6 +229,89 @@ These are the things we've burned ourselves on. Following them isn't optional. that can't happen. Validate at system boundaries (user input, external APIs). - **Don't add features beyond what was requested.** A bug fix doesn't need surrounding cleanup; a one-shot doesn't need a helper. +- **Write UI the LiveView way.** The `~MOB` sigil supports `@assigns` shorthand + and `:if` / `:for` control attributes (``), and + `Mob.Socket` has `assign/2,3`, `update/3`, `assign_new/3`. See + `guides/components.md` → Control flow. + +## Don't write this slop + +LLMs reach for the same anti-patterns over and over. The list below is the +shape of code our `mix credo --strict` (via `ex_slop`) refuses to merge — but +catching it post-hoc costs a round-trip. Don't write it in the first place. + +**Error handling** +- No blanket `rescue _ -> nil` or `rescue _e -> {:error, "..."}`. Rescue the + specific exception or let it crash. +- No `rescue e -> Logger.error(...); :error` — that logs the bug into oblivion. + Either reraise or return a typed error tuple the caller can match on. +- No `try/rescue` around functions that don't raise (`Map.get`, `Enum.find`, + `String.split`). Look up whether the function actually raises before wrapping it. + +**Database access** +- Filter in SQL, not in Elixir: `from(u in User, where: u.active)` — + not `Repo.all(User) |> Enum.filter(& &1.active)`. +- No N+1 in `Enum.map`: don't `Enum.map(ids, &Repo.get(...))`. Use `Repo.all(from … where: id in ^ids)`. +- Don't write a GenServer whose entire job is `Map.get`/`Map.put` on state — + use ETS, Agent, or a struct passed by value. + +**Maps** +- Pick one key type per map. Don't `Map.get(m, :key) || Map.get(m, "key")` — + normalize once at the boundary. +- Iterate the map directly. Not `Map.keys(m) |> Enum.map(fn k -> m[k] end)`. + +**Enum / list idioms** — use the function that exists: +- `Enum.reject(&is_nil/1)` not `Enum.filter(&(&1 != nil))` +- `Enum.empty?(x)` not `length(x) == 0` +- `List.last(x)` / `Enum.at(x, -1)` not `Enum.at(x, length(x) - 1)` +- `Map.new/2` not `Enum.reduce(%{}, fn ..., &Map.put/3)` +- `Enum.into(list, %{})` only if you actually have a Collectable target; + for a plain literal target it's just `Map.new`. +- `Enum.filter` not `Enum.flat_map(fn x -> if cond, do: [x], else: [] end)` +- `Enum.sum` not a hand-rolled reduce with `+` +- `Enum.max` / `Kernel.max` not `if a > b, do: a, else: b` +- `Enum.sort(list, :desc)` not `Enum.sort(list) |> Enum.reverse()` +- `Enum.min(list)` not `Enum.sort(list) |> Enum.at(0)` +- `Enum.map_join(list, sep, &f/1)` not `Enum.map(list, &f/1) |> Enum.join(sep)` + +**`with` blocks** +- No identity `else` clause. `with :ok <- foo() do :ok end` — drop the + `else err -> err` part. + +**Strings** +- `String.length(s)` not `length(String.graphemes(s))`. +- For counting specific ASCII chars, prefer `:binary.matches/2` over graphemes. +- No manual string reverse via graphemes + reverse + join — use `String.reverse/1`. + +**Paths** +- `Application.app_dir(:my_app, "priv/...")` over `Path.expand("...priv...", __DIR__)`. + The Mix-task code in `mob_dev` is an exception — it needs cwd-relative paths + for the *user's* project. + +**Docs and comments** +- No "This module provides functionality for..." moduledoc. State *why* it + exists or what's surprising; if there's nothing to say, omit it. +- No obvious comments (`# Fetch the user` above `Repo.get(User, id)`). +- No narrator comments (`# We need to...`, `# Here we...`). +- No step comments (`# Step 1: Do X`, `# Step 2: Do Y`) — function names cover that. +- No `@doc false` on a `defp` — private already means undocumented. +- Boilerplate `## Parameters / ## Returns` sections are noise unless the + parameters are non-obvious. + +**Code shape** +- Don't shadow `Kernel` functions with local variables named `length`, `min`, + `max`, `node`, etc. +- Don't rebind a parameter inside the function body. Pick a new name. +- Don't write `x = foo(); x` at the end of a function — just `foo()`. +- Don't extract `[a, b] = list` only to immediately rebuild `[a, b]`. +- Use the same name for the same parameter across all clauses of a function. + +> **Periodic check:** `ex_slop` and the related (but heavier) [`credence`](https://hex.pm/packages/credence) +> linter add new AI-pattern checks regularly. Both ecosystems are young — +> when something here feels stale or you spot a new ExSlop release, skim +> the changelogs and update this section. Credence has ~70 rules ExSlop +> doesn't port yet; if any get backported (or if `credence` becomes worth +> wiring in alongside Credo), revisit `mob/CLAUDE.md` and the deps lists. ## Keep this file up to date diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..2b606a39 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,589 @@ +# Changelog + +All notable changes to **mob** are documented here. + +Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/spec/v2.0.0.html). + +Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob). + +--- + +## [0.7.20] - 2026-07-11 + +### Changed +- **iOS `screenshot/3` can now be opted into release builds.** The iOS test + harness is stripped from release (`#if !MOB_RELEASE`) because its + synthetic-input NIFs (`tap`, `type_text`, …) use private UIKit/IOKit selectors + the App Store auto-rejects. `screenshot/3` uses only public APIs + (`UIGraphicsImageRenderer` + `drawViewHierarchy`) but was collateral, so a + shipped app couldn't be screenshotted — an agent driving it over dist couldn't + see the screen to error-correct (it returned `:not_loaded`). `screenshot/3` and + its registration are carved into `#if !MOB_RELEASE || defined(MOB_ENABLE_SCREENSHOT)`. + Default behaviour is unchanged (still stripped); a host opts in with + `-DMOB_ENABLE_SCREENSHOT`, plumbed from `mob_dev`'s `ios_release_screenshot: true` + config. The private synthetic-input NIFs stay strictly `#if !MOB_RELEASE` and + can never ship — a release build can SEE the screen but never DRIVE it. Opt-in by + design: screenshot captures the app's own window with no OS prompt or indicator, + so shipping a remotely-triggerable capture must be a conscious choice. (#71) + +## [0.7.19] - 2026-07-10 + +### Fixed +- **`Mob.Motion` iOS `accel` now matches Android's units and sign.** The iOS NIF + emitted CoreMotion's `userAcceleration + gravity` verbatim — in G (~1.0), not + the documented m/s² (~9.81), and in iOS's own convention where the gravity + vector points down (the up-axis reads −g at rest), the opposite of Android's + specific-force convention (+g on the up-axis). iOS `accel` was therefore off + from Android by both a scale factor and a sign, so a tilt- or shake-driven UI + barely moved on iOS and moved backwards when it did. Now emits + `(userAcceleration − gravity) × 9.80665`, which is Android's `a_coord − + g_field` exactly — +g up at rest, m/s², correct for both the static tilt term + and the dynamic linear term. `gyro` (rad/s) and `mag` (µT) already matched and + are unchanged. The `accel` convention is now a documented contract in the + `Mob.Motion` moduledoc. (#70) + +## [0.7.18] - 2026-07-07 + +### Added +- **`Mob.Audio` output probes — "is sound actually coming out right now."** The + audio analog of `screenshot`. `Mob.Audio.output_status/0` → + `%{volume, muted, route, other_audio}` (cheap, no permission; catches the + common silence causes — muted, zero volume, dead route — via iOS + `AVAudioSession` / Android `AudioManager`). `Mob.Audio.output_level/1` → + `{rms_db, peak_db} | :silent | {:error, reason}`, the actual signal energy of + `Mob.Audio`'s own player (iOS `AVAudioPlayer` metering; Android `Visualizer` + on the player's session, needs runtime `RECORD_AUDIO`); `source: :mix` returns + `{:error, :unsupported_on_platform}`. (#54) +- **`Mob.Audio` input-level metering — the agent "ears" (MOB-35).** + `Mob.Audio.start_input_metering/1`, `input_level/0`, `stop_input_metering/1`; + `input_level/0` returns `{rms, peak} | :silent | {:error, reason}` — the same + shape as `output_level`, so mic and output read through one unified metering + contract. NIF declared in `mob_nif.erl`; pure `decode_level/1` host-tested. + (#67) + +--- + +## [0.7.17] - 2026-07-04 + +### Added +- **Keep-awake / idle-timer (`Mob.Device.keep_awake/1`).** `keep_awake(true)` + prevents the screen auto-dimming/locking (for video, reading, navigation, or + any watch-without-touch screen); `false` releases it. No permission on either + platform. iOS: `UIApplication.isIdleTimerDisabled`; Android: the window's + `FLAG_KEEP_SCREEN_ON` (the Kotlin bridge ships via mob_new 0.4.19+). The flag + is app-scoped and cleared by the OS on background — re-assert on resume. + Device-verified both directions on moto g power (2021) — `dumpsys` shows the + `KEEP_SCREEN_ON` window flag toggle, and the screen actually sleeps with it + off / stays lit with it on — and iPhone SE (3rd gen). (MOB-20, #66) + +## [0.7.16] - 2026-07-04 + +### Added +- **Network / connectivity state (`Mob.Device.network_state/0`).** Returns + `%{online, transport, expensive, validated, constrained}`: online/offline, the + active transport (`:wifi | :cellular | :wired | :other | :none`), whether the + link is metered/`expensive`, plus two single-platform signals that report the + atom `:unavailable` where the OS can't answer (never a misleading `false`) — + `validated` (Android `NET_CAPABILITY_VALIDATED`, a real-internet probe; + `false` on a captive portal) and `constrained` (iOS Low Data Mode). Adds + `online?/0` and a `:network` subscribe category delivering + `{:mob_device, :connectivity_changed, state}` on change. iOS `NWPathMonitor`; + Android `ConnectivityManager.NetworkCallback` (Kotlin bridge ships via mob_new + 0.4.18+). Device-verified on iOS simulator and moto g power (2021). (MOB-14, #62) + +### Documentation +- Getting-started: fix an undefined `tap/1` in the "first screen" example (#63), + and make the `0xAARRGGBB` color format explicit vs CSS hex (#64). + +## [0.7.15] - 2026-07-04 + +### Added +- **Torch / flashlight support (`Mob.Torch`).** `Mob.Torch.on/1`, `off/1`, and + `set/2` toggle the rear-camera torch — a lightweight core capability that needs + no camera capture session and no permission. On a device with no flash unit + (tablets, the iOS simulator) it's a no-op, not an error. On/off only for now + (iOS brightness levels / Android per-torch strength are a follow-up). iOS: + `AVCaptureDevice.torchMode`; Android: `CameraManager.setTorchMode` (the Kotlin + bridge ships via mob_new 0.4.17+). Device-verified on moto g power (2021) and + iPhone SE (3rd gen). (MOB-15, #61) + +## [0.7.14] - 2026-07-04 + +### Added +- **Magnetometer / compass support in `Mob.Motion`.** Request `:magnetometer` + in the sensor list and the `{:motion, _}` message additionally carries `mag` + (calibrated field, µT) and `heading` (degrees from magnetic north). The keys + are present **exactly when you requested `:magnetometer`**, on both platforms, + and each is `nil` when there's no reading (device has no magnetometer, or the + heading hasn't fused yet) — so a compass app matches on `nil` rather than + hitting a missing key, and accel/gyro-only consumers get the byte-identical + 3-key map with no extra sensor cost. iOS uses the `XMagneticNorthZVertical` + reference frame (`CMMotionManager`); Android fuses `TYPE_MAGNETIC_FIELD` + + `TYPE_ROTATION_VECTOR` (`SensorManager`), registered only on request. + Magnetic north only (true north needs location + declination — layer + `Mob.Location`). Device-verified on moto g + iPhone SE. (MOB-6, #59) + +## [0.7.13] - 2026-07-02 + +### Documentation +- **Clarified the tag-composite warning and `Mob.Component` vs `Mob.Composite` + in the Components guide.** The `~MOB: is not in the Mob tag whitelist` + warning is now documented as expected for a *registered* composite (registration + is a runtime action the compile-time sigil can't see); an unregistered tag + rendering nothing is the real failure to look for. A new callout separates + `Mob.Component` (the existing native-view behaviour, whose `render/1` returns a + native props map) from `Mob.Composite` (pure-Elixir tag expanders returning a + `~MOB` tree via `expand/3`), and the planned "sub-component event isolation" + note no longer reuses the `Mob.Component` name. (#53, #58) + +## [0.7.12] - 2026-06-30 + +### Fixed +- **`~MOB` now raises a clear error when `@foo` is used without `assigns` in + scope.** The `@foo` → `assigns.foo` shorthand (0.7.11) only works inside a + `render(assigns)`; used in an ordinary helper function (positional args — the + idiomatic composite pattern) it compiled to a cryptic "undefined variable + assigns". The sigil now guards with `Macro.Env.has_var?(caller, {:assigns, + nil})` (the same check Phoenix's `~H` uses) and raises a `CompileError` naming + the fix (`{title}` instead of `@title`). Only `@`-using templates trigger it — + a static `~MOB()` in a positional-arg helper still compiles. + (MOB-5, #56) + +### Documentation +- **Worked component-authoring examples in the Components guide.** The + "Defining your own components" section now carries two complete, runnable + screens — a function composite and a tag composite — spelling out the + tag→atom rule and where `on_*` event-target auto-injection applies (a + composite tag's own props vs a plain widget in its children). The `@assigns` + section documents that `@foo` only works where `assigns` is in scope and + steers helpers to positional `{var}`. (#56, #57) + +## [0.7.11] - 2026-06-27 + +### Added +- **LiveView-style authoring in the `~MOB` sigil.** Three HEEx idioms now work + in templates: + - `@foo` shorthand — inside any `{...}` expression `@foo` rewrites to + `assigns.foo` (attribute values, `{expr}` children, and the control + attributes below), including nested access like `@user.name`. + - `:if={expr}` — renders an element only when the expression is truthy; a + falsy `:if` drops the element from its parent's children. + - `:for={x <- list}` — repeats an element per item and splices into the + parent. Combined with `:if`, the `:if` becomes a comprehension filter + (LiveView semantics). `:if`/`:for` require a `{expr}` value; only those two + control attributes are recognised. +- **`Mob.Socket.update/3` and `assign_new/3`**, mirroring + `Phoenix.LiveView`. `update/3` applies a function to an existing assign + (`KeyError` if absent); `assign_new/3` lazily sets an assign only when absent. +- New `guides/components.md` "Control flow" section documents all of the above. + (#52) + +## [0.7.10] - 2026-06-26 + +### Added +- **`baseline` row alignment on iOS.** A `:row` with `align: "baseline"` now + maps to SwiftUI's `.lastTextBaseline` instead of silently falling through to + center. (Android `Row` has no row-level baseline alignment, so it still + centers there.) + +## [0.7.9] - 2026-06-26 + +### Fixed +- **Non-glass `:box` fill ignored `corner_radius` on iOS.** `mobBoxBackground` + filled the solid (non-glass) background as a plain rectangle, so only the + separately-stroked border was rounded while the fill kept square corners + (visible on solid-color boxes; bordered light cards hid it). Clip the fill to + the corner shape with `in: shape`, matching the glass branches. Thanks to the + reporter who diagnosed it. + +## [0.7.8] - 2026-06-25 + +### Added +- **`Mob.Device.open_settings/1`.** Opens an OS settings screen for the app: + `:app` (the app details / permissions page, both platforms), `:notifications`, + or `:exact_alarm` (Android special-access screens; iOS falls back to the app + page). The go-to when a permission was permanently denied and the user must + re-enable it by hand. An unknown target returns `{:error, :invalid}` without + touching the NIF. On Android the bridge call is optional, so an app whose + scaffolded `MobBridge.kt` predates `openSettings` no-ops instead of crashing + (add `MobBridge.openSettings/1` to wire it up). (#50) + +## [0.7.7] - 2026-06-24 + +### Fixed +- **Boot crash on all apps (regression in 0.7.6).** `device_orientation/0` and + `device_lock_orientation/1` were added to `mob_nif`'s native NIF tables and + `-export` in 0.7.6 but not to its `-nifs([])` attribute. `load_nif/2` rejects a + library that registers a NIF not declared in `-nifs`, so `on_load` failed, + `mob_nif` was purged, and every app crashed at boot with `{undef, {mob_nif, + log, 1}}` on the first boot step (iOS and Android). Added the two functions to + `-nifs([])`. A new source-level test (`test/mob/nif_declaration_test.exs`) + asserts every NIF in the iOS/Android tables is declared in `-nifs([])`, so this + class of mismatch — invisible to host tests, since NIFs don't load on the host — + can't ship again. Upgrade from 0.7.6 immediately. + +## [0.7.6] - 2026-06-24 + +### Added +- **Device orientation: detect + lock (`Mob.Device`).** New `orientation/0` + query, an `{:mob_device, :orientation_changed, orientation}` event under the + existing `:display` subscription category, and `lock_orientation/1` / + `unlock_orientation/0` to force (or release) a specific orientation regardless + of the OS auto-rotate setting. Values: `:portrait`, `:portrait_upside_down`, + `:landscape` (either side), `:landscape_left`, `:landscape_right`. Use case: a + screen that must be landscape (e.g. a wide keyboard) locks on enter, unlocks + on leave. + + iOS reads the foreground window scene's interface orientation, observes + `UIDeviceOrientationDidChangeNotification`, and drives rotation via + `requestGeometryUpdate` (iOS 16+); the lock holds once the app shell's root + view controller reports `mob_locked_orientation_mask()` from + `-supportedInterfaceOrientations` (companion shell change). Android locks via + `MobBridge.orientationLock/1` → `Activity.setRequestedOrientation`, with change + delivery from `MainActivity.onConfigurationChanged` (companion `mob_new` + changes). Android `orientation/0` returns the last reported orientation + (partial, consistent with the other Android device queries). + +### Fixed +- **iOS canvas now delivers finger-drag (`on_drag`) — at parity with Android.** + The SwiftUI `MobCanvasView` rendered draw ops but attached no drag recognizer, + so a canvas's `on_drag` handle (wired through the NIF to `node.onDrag`) was + never invoked — continuous finger-drag was dead on iOS, while Android's + `MobCanvas` had `detectDragGestures`. Added a canvas-scoped + `DragGesture(minimumDistance: 0)` that calls `node.onDrag` with + began/dragging/ended phases; the gesture's local-space location is already in + canvas logical units (the frame is sized to the declared width/height), so no + rescale is needed. Verified on a physical iPhone (iOS 26.5): a finger-drawing + screen with a color picker and thickness control routes drags and renders + strokes correctly. + +--- + +## [0.7.4] - 2026-06-20 + +### Fixed +- **Tap-handle registry is now double-buffered (Android + iOS) — high-frequency + events no longer drop during a render.** `clear_taps` reset the handle count + to 0 and re-registered every handler in tree order, so a drag/scroll firing + from the UI thread *while* a render rebuilt the table saw a transiently-small + count and a half-built table and got dropped — worse the later a widget + registered (e.g. a `Canvas` after a row of `Button`s). `register_tap` now + builds into the inactive table while readers keep resolving the last committed + one; `set_root` swaps them atomically under `tap_mutex`. A concurrent event + always sees a complete table on either side of the swap. No API change. + Verified on-device (moto, finger-drag canvas). + +--- + +## [0.7.3] - 2026-06-19 + +### Removed (BREAKING) +- **`Mob.Background` is no longer in core — it moved to the opt-in + `mob_background` plugin.** Background-execution keep-alive (iOS silent + AVAudioEngine / Android `dataSync` foreground service) and its + `background_keep_alive`/`background_stop` NIFs are removed from `:mob_nif`. + Apps that call `Mob.Background.keep_alive/0` must add + `{:mob_background, "~> 0.1"}`, enable it in `mob.exs` + (`config :mob, :plugins, [:mob_background]`), and call + `MobBackground.keep_alive/0` instead. Most apps never used it; the default is + now that an app ships **no** foreground service unless it opts in — which is + also what Google Play wants (an unused `dataSync` FGS is a policy rejection). + Verified on Android (physical + emulator) and the iOS simulator via + mob_plugin_demo. + +--- + +## [0.7.2] - 2026-06-19 + +### Added +- **`Mob.ScreenCase`** — the blessed way to unit-test a `Mob.Screen` in-BEAM, + with an optional device backend. Provides `mount_screen/3`, + `render_event`/`render_info`, tree queries (`find`/`find_all`/`text`), + `assert_renderable/2`, and `navigated_to/1`. On `:beam` it runs in + milliseconds; the same assertions run against real hardware via `:device`. + `navigated_to/1` returns the destination module on both backends. (#44) + +--- + +## [0.7.1] - 2026-06-16 + +### Added +- **Collocated screen templates**: a `Mob.Screen` with a sibling `.mob.heex` + and no inline `render/1` gets `render/1` compiled from that template + (`@external_resource`, so editing the template recompiles the screen). An + inline `render/1` still wins. Opt-in and additive. (#22) +- **`Mob.Files.pick/2` type filtering**: `:types` now limits what the document + picker offers — extension strings (`"livemd"`), MIME strings (`"application/pdf"`, + `"text/*"`), semantic atoms (`:images`, `:video`, `:audio`, `:pdf`, `:text`), + explicit `{:extension|:mime|:uti, value}` tuples, or `:any` (default). + iOS filters strictly via `UTType` (extensions resolve even for unregistered + custom types); Android SAF filters by MIME only, so `Mob.Files.accept/2` + + `matches?/2` enforce the filter on results for consistent cross-platform + semantics. Backward-compatible — the default `:any` preserves the previous + "offer everything" behavior. See `decisions/2026-06-16-files-pick-type-filter.md`. + +--- + +## [0.7.0] - 2026-06-12 — the plugin-extraction major (BREAKING) + +### Added +- **Pure-Elixir composite components** (`Mob.Composite`): UI kits register tag-name expanders (the manifest `ui_components` `expand:` form, or `Mob.Composite.register/2`) and `` expands to built-in widget trees in a new FIRST render pass — fixpoint with a depth guard, crash-isolated. `on_*` props written as bare strings/atoms are auto-injected as `{screen_pid, tag}` (no more threading `self()`). Hot-pushable. See `decisions/2026-06-11-composite-expansion-pass.md`. +- **Route-bound navigation params** (`Mob.Nav.Registry.register/3` + `lookup_route/1`): a registered route can carry a params map merged under push params into `mount/3` — the enabler for data-driven plugins (mob_ash registers `/ash/post` as `{MobAsh.ListScreen, %{resource: …}}`). Screen-manifest entries take an optional `:params`. +- **Style packages, tokens-only tier** (MOB_STYLES.md implemented in part): the runtime manifest carries `styles`/`default_style`; boot applies the default style's theme (`Mob.Plugins.apply_default_style/0`). The five preset themes ship in the `mob_themes` package. +- **Boot-time plugin NIF loading** (`mob_notify_set_screen_pid` seam, `host_requirements` printing, `composites` boot registration) — the plugin-system core wiring landed across this cycle; see MOB_PLUGINS.md. + +### Removed (BREAKING — each capability moves to its plugin package) +- `Mob.Camera` → `mob_camera` (the `camera_preview` node stays in core) +- `Mob.Location` → `mob_location` +- `Mob.Notify` → `mob_notify` (delivery plumbing — delegate, push-token forward, launch handoff — stays in core; pairs with the server-side `mob_push`) +- `Mob.Photos` → `mob_photos` +- `Mob.Biometric` → `mob_biometric` +- `Mob.Scanner` → `mob_scanner` (requires `mob_camera` for the `:camera` permission) +- `Mob.Bt` → `mob_bluetooth` (Wave 1) +- Themes `Obsidian`/`ObsidianGlass`/`Citrus`/`Birch`/`Material3` → `mob_themes` (light/dark/adaptive remain the neutral baseline) +No deprecation shims (see plugin_extraction_plan.md for the policy rationale). Migration: add the package dep + activate in `mob.exs`; module names change (`Mob.Camera` → `MobCamera`, `Mob.Theme.Citrus` → `MobThemes.Citrus`, …). + +## [0.6.26] + +### Added +- **Plugin documentation, shipped with the package.** A "Writing a Plugin" authoring guide (`guides/plugins.md`: scaffold → implement → sign → activate → deploy, per tier, with a worked-examples index) plus the manifest reference (`MOB_PLUGINS.md`) and security/trust doc (`MOB_PLUGIN_SECURITY.md`) are wired into ex_doc/HexDocs (a Plugins extras group + a `Mob.Plugins` module group). The reference now documents **cross-plugin conflict detection** (every guarded shared resource + the completeness guarantee) and the **runtime plugin manifest** + its build-time auto-regen. +- **`Mob.Plugins` runtime hardening.** Notification dispatch is crash-isolated — a handler or predicate that raises is logged and skipped instead of taking down the host screen GenServer (mirrors the lifecycle dispatcher). A malformed settings schema (missing `:default`/`:type`) logs + falls back instead of crashing reads/writes, and `register_screens` rejects a `nil` module/blank route at registration rather than deferring the error to navigation. +- **Custom fonts (app-level + plugin).** mob's `font:` prop (documented but only half-built) now works end-to-end: `mix mob.deploy --native` bundles `priv/fonts/*.ttf|otf` and plugin `assets.fonts` into the platform bundle — iOS into the `.app` + `Info.plist` `UIAppFonts` (feeding SwiftUI `Font.custom`), Android into `res/font/` (uncompressed; the renderer loads it by resource id, fixing the previous `Typeface.create` stub that only handled system families). Visually confirmed on Android: a plugin-shipped font renders distinct from the system font. +- **Plugin tiers 3 (multi-screen) and 4 (embedded sub-app).** See `decisions/2026-06-06-plugin-tiers-3-4.md`. Both are pure-Elixir and runtime-wired off a generated runtime manifest (`priv/generated/mob_plugins.exs`, written by `mix mob.regen_plugin_manifest`) that the new `Mob.Plugins` module reads at boot. **Tier 3:** plugins ship whole `Mob.Screen` modules (static `:screens` or spec-v2 `:screens_generator` codegen run under the host-config audit), registered as navigable routes in `Mob.Nav.Registry`; plus `:migrations` (build-copied into the host migrations dir, namespaced + version-preserving, run by the host's `Ecto.Migrator`) and `:assets`. **Tier 4:** `:lifecycle` (`on_start` + supervised children + `on_resume`/`on_background` via `Mob.Plugins.Supervisor`/`Lifecycle` and `Mob.Device`), `:settings` (`Mob.Plugins.get_setting/2`/`put_setting/3` on `Mob.State`, schema-validated, with an `editor_screen`), and `:notifications` (`Mob.Plugins.dispatch_notification/1` first-match routing). Device-verified on a physical iPhone (SE) and Android (Moto G): static + generated screens register, a plugin migration creates its table on device, and tier-4 on_start / supervised worker / settings / notification routing all work. `Mob.Plugins.boot` captures the host OTP app name at compile time via `use Mob.App` (a mob release boots without `Application.start`, so `Application.get_application/1` is nil at runtime). + +### Changed +- **Location fully extracted to the standalone `mob_location` plugin (Wave 2).** See `plugin_extraction_plan.md` and `decisions/2026-06-05-mob-location-extraction.md`. `Mob.Location` (`get_once`/`start`/`stop`), the iOS `CLLocationManager` NIFs + delegates, the Android `FusedLocationProviderClient` Zig NIF + `mob_deliver_location`, and the hardcoded `"location"` branch of `nif_request_permission` are removed from core (`lib/mob/location.ex`, `ios/mob_nif.m`, `android/jni/mob_nif.zig`, `src/mob_nif.erl`). `mob_location` is a cross-platform tier-1 plugin: it ships an Objective-C iOS NIF (`lang: :objc`) and an Android Zig NIF (`lang: :zig`, via `MobLocationBridge`), registers the `:location` capability through the extensible permission registry (iOS `mob_register_permission_handler`, Android `MobPermissionProvider`), and declares its Android permissions + iOS plist key + `play-services-location` + `CoreLocation` framework in its manifest (mob_dev merges these into the host at build time). **Breaking:** core no longer provides any location surface and there is intentionally no compatibility shim. Apps that used `Mob.Location.*` should add `{:mob_location, "~> 0.1"}` (or `path:`/`github:`) and call `MobLocation.*`. The same location surface was removed from the `mob_new` generated-app templates. Device-verified on a physical iPhone (SE) and Android (Moto G) both before and after the core strip — `MobLocation` round-trips real fixes through the plugin alone, and `:mob_nif.location_get_once/0` now raises `UndefinedFunctionError`. + +### Fixed +- **iOS: stop capping the literal super-carrier at 10 MB.** `mob_beam.m` appended a hardcoded `-MIscs 10` after the configured flags; since allocator flags are last-wins, it silently overrode the 0.6.24 `-MIscs 128` default (and any `mob_beam_flags` override), so the literal area was always 10 MB. A large app (e.g. embedded Livebook) plus a notebook's `Mix.install` filled it and the VM aborted with `literal_alloc: Cannot allocate ...`. Removed the hardcoded cap; the `-MIscs 128` default now takes effect (iOS accepts a 128 MB reservation). Verified on a physical iPhone: `emu_args` shows a single `-MIscs 128` and `Mix.install` returns `:ok`. + +## [0.6.25] + +### Added +- **"Open with" — receive a file another app opens into yours.** New `Mob.Files.take_opened_document/0` returns `%{path, name, mime, size}` (or `:none`) for a file handed to the app (e.g. a notebook emailed and tapped), parallel to `Mob.Files.pick/2`'s `{:files, :picked, …}`. Call it from your root screen's `mount/3`; a file opened while already running arrives as `{:files, :opened, item}` (iOS). New NIF `take_opened_document` plus C-export `mob_set_opened_document` on both platforms (iOS `application:openURL:options:` → `mob_handle_opened_url`; Android `MainActivity` reads the ACTION_VIEW/SEND intent → `MobBridge.setOpenedDocument`). The app declares the document type (iOS `CFBundleDocumentTypes`, Android ``) and forwards the open. Verified end-to-end: a `.livemd` opened into the embedded-Livebook app opens as a notebook on a physical iPhone and a physical Android (Moto G). + +## [0.6.24] + +### Fixed +- **iOS: enlarge the BEAM literal super-carrier to 128 MB (`-MIscs 128` default flag).** iOS can't reserve the OTP default 1 GB literal virtual area and falls back to ~10 MB. A large app such as an embedded Livebook plus a notebook's `Mix.install` fills that 10 MB and the VM aborts with `literal_alloc: Cannot allocate N bytes (of type "literal")`. The iOS native launcher's default flags now request a 128 MB literal carrier — a virtual `MAP_NORESERVE` reservation (commits physical only on use) that iOS accepts where 1 GB fails. Apps no longer need a per-app `beam_flags:` override for this. iOS-only; Android keeps its normal large carrier. A runtime `mob_beam_flags` override still wins. Verified on a physical iPhone: embedded Livebook serves and `Mix.install([{:short_uuid, "~> 0.1"}])` returns `:ok`. + +## [0.6.23] + +### Added +- **Element positions without a screenshot.** `element_frames/0` NIF surfaced as `Mob.Test.element_frames/1` (`%{id => {x,y,w,h}}`), `frame/2`, and `tap_id/2` (drive by id at real coordinates). Any rendered node given an `:id` reports its live on-screen frame (logical points iOS / dp Android) to a registry the agent reads over dist — a compact structured map instead of image bytes, with no accessibility activation. The renderer also sets the `:id` as the element's accessibility identifier (iOS `accessibilityIdentifier`, Android Compose `testTag`), so the same tags are visible to XCUITest/Espresso. Opt-in per element: untagged nodes cost nothing (the tracking modifier only attaches when an `:id` is present). iOS records the full element frame via a `GeometryReader` background; Android via `Modifier.onGloballyPositioned`. Verified on iOS sim, Android device, and a physical iPhone. The Android Kotlin side lives in the `mob_new` `MobBridge.kt.eex` template. +- **In-process screenshot + scroll control over dist (no adb/xcrun).** Three test-harness NIFs (`screenshot/3`, `scroll_info/1`, `scroll_to/3`) surfaced as `Mob.Test.screenshot/2`, `scroll_info/2`, `scroll_to/4`, and `screenshot_tour/3`. A remotely-connected agent gets pixels and deterministic scroll entirely over Erlang distribution — the capability Sloppy Joe and WireTap need to drive a device an agent can only reach over dist. Capture is in-process (iOS `UIGraphicsImageRenderer` + `drawViewHierarchy`; Android `PixelCopy` against the activity window). Scroll views are addressed by their `:id` prop; `scroll_info` reports `kind: :pixel` (iOS `UIScrollView`, Android `verticalScroll`) or `:index` (Android `LazyColumn`, where y is an item index and viewport is the visible-item count). Captures the app's own surface only — `FLAG_SECURE`/secure fields render blank, and a backgrounded app returns `{:error, :no_window}`. The Android Kotlin side (`screenshot`/`scrollInfo`/`scrollTo`) lives in the `mob_new` `MobBridge.kt.eex` template; existing apps pick it up on regeneration. Debug-only (iOS `#if !MOB_RELEASE`). See `decisions/2026-05-29-bridge-nif-screenshot-scroll.md`. + +### Changed +- **`Mob.Bt` fully extracted to the standalone `mob_bluetooth` plugin (Wave 1 complete).** See `plugin_extraction_plan.md`. Session A moved the Elixir wrappers (`Mob.Bt`, `Mob.Bt.Hfp`, `Mob.Bt.Hid`, `Mob.Bt.Spp`) out of core; Session B now removes the native side too — the Bluetooth Zig NIF from `android/jni/mob_nif.zig` and the iOS unsupported-stubs from `ios/mob_nif.m`. `mob_bluetooth` is now a tier-1 plugin: it ships its own Zig NIF, JNI thunks, and `MobBluetoothBridge` Kotlin, and declares its Android permissions + iOS plist keys in its manifest (mob_dev merges these into the host app at build time). **Breaking:** core no longer provides any Bluetooth surface and there is intentionally no compatibility shim. Apps that used `Mob.Bt.*` should add `{:mob_bluetooth, "~> 0.1"}` (or `path:`/`github:`) and rename references to `MobBluetooth.*`. HID input and SCO PCM streaming were never implemented and are not part of the plugin (HID is platform-blocked on Android; see the plugin's docs). + +## [0.6.22] + +### Added +- **`Mob.Certs`** — load CA certificates from a PEM bundle into Erlang's `:public_key` cacert store. Android's system trust store lives behind a Java API that `:public_key.cacerts_load/0` (no-arg) can't reach, so the first TLS call from Req / Mint / Finch crashes with `no_cacerts_found` (or `FunctionClauseError` in some OTP versions). Apps bundle a PEM (conventional source: copy `castore`'s `cacerts.pem` into `priv/` at build time) and call `Mob.Certs.load_cacerts!(Application.app_dir(:my_app, "priv/cacerts.pem"))` once at boot. iOS and the Android emulator aren't affected; calling unconditionally is harmless there. Verified end-to-end on a Moto G Power 5G 2024 (Android 14): `Mix.install([{:req, "~> 0.5"}])` then `Req.get!("https://geocoding-api.open-meteo.com/v1/search?name=Vancouver")` returns `200`. +- **`mob_beam.zig` exports `MOB_NATIVE_LIB_DIR`** before BEAM start — the absolute path of the app's nativeLibraryDir, which the APK install hash makes unpredictable at compile time. Apps that bundle runtime binaries (escript, rebar3, etc.) as `lib*.so` need this to set `MIX_REBAR3` and locate the bundled escripts. +- **Optional ERTS-extras symlinks (`escript` / `erlexec` / `erl` / `beam.smp`)** in `mob_beam.zig`. Silent-skips when the lib isn't in nativeLibDir, so non-opting-in apps see no behaviour change. Apps that drop `lib.so` into `android/app/src/main/jniLibs//` get a working `BINDIR/` — enough for runtime `Mix.install` of rebar3-built deps (telemetry, jose, jiffy, …) to bootstrap a fresh VM. `erl` and `erlexec` both target the same `liberlexec.so` because they are the same binary (erlexec doesn't switch on `argv[0]`). + +### Changed +- **`extra_applications: [:logger, :public_key]`** — Elixir 1.19+ strips unused OTP applications from the code path; `Mob.Certs` calls `:public_key.cacerts_load/1` at runtime, so its `.beam` must be in the path even though mob doesn't *start* `:public_key` itself. + +### Fixed +- **`mix.exs`** — collapsed duplicate `before_closing_body_tag/1` clauses introduced in 0.6.20. The mermaid clause's `_` catchall shadowed an older language-elixir highlighter clause, leaving it as dead code (and emitting compile warnings). The unified clause emits both scripts; the duplicate `docs/0` keyword entry was removed. + +### Docs +- `common_fixes.md` — new section documenting the Android cacerts symptom (`no_cacerts_found` / `FunctionClauseError`) and the load-PEM-at-boot fix; also the bundled-OTP-extras pattern (wrapper script, rebar3 module-name derivation, `$ROOTDIR/bin/*.boot` materialization) for apps that opt into runtime rebar3. + +## [0.6.21] + +### Added +- **`Mob.DNS.resolve/1` now works on Android.** `nif_resolve_ipv4` (`android/jni/mob_nif.zig`) calls Bionic's `getaddrinfo` in-process and seeds `:inet_db`'s `:file` table, mirroring the iOS NIF added in #32. Physical Android devices return `:nxdomain` from BEAM's default DNS path (forking `inet_gethost` as a port program) even when the same app's in-process HTTPS stack resolves the hostname fine — the emulator masks this. Verified end-to-end on a Moto G Power 5G 2024 (Android 14): `Mob.DNS.resolve("repo.hex.pm")` returns the right IP, `:inet.getaddr/2` then succeeds via the seeded entry, and `Mix.install([{:dep, "~> ..."}])` from a notebook setup cell resolves, fetches, and compiles on-device. Bionic `addrinfo` / `sockaddr_in` / `getaddrinfo` / `freeaddrinfo` / `EAI_*` bindings added to `android/jni/mob_zig.zig`. Suspected root cause is `libnetd_client.so`'s netd routing not surviving execve; the NIF sidesteps it by running in the app's own process. + +### Changed +- **`Mob.DNS` moduledoc** — dropped the "Android isn't affected" claim. Added a background-app caveat: Android App Standby blocks *all* outbound network from a backgrounded mob app (TCP-by-IP, not just DNS — surfaces as `:closed` / `:timeout` on any socket attempt). Fix is a foreground service or keep the app foregrounded; not a mob bug. + +### Docs +- `common_fixes.md` — new section documenting the `:nxdomain` symptom on physical Android, the foreground-app caveat, and the fix. + +## [0.6.18] + +### Changed +- **`RUSTLER_NIF_LIB_PATH` → `RUSTLER_BEAM_LIBRARY_PATH`** in `mob_beam.zig`'s host setenv block. Matches the env var name filmor chose for the alternative upstream rustler PR (rusterlium/rustler#733), which is what'll land upstream instead of our #726. End-to-end tested on physical arm64 Android with filmor's branch: Mob sets the env var → rustler reads it → Rust NIF resolves and executes. Mob users on rustler 0.37 Hex release (no patch) see no change; users on the GenericJam fork OR on whatever rustler version eventually ships #733 get matching behaviour. + +## [0.6.17] + +### Added +- **`Mob.Audio.play_at/4`** — sample-accurate scheduled audio playback. Takes an absolute local wall-clock target (`System.system_time(:millisecond)` ms-since-epoch) and hands it to the audio *hardware* clock for firing, rather than waking the BEAM via `Process.send_after`. The hardware-clock path eliminates timer-wheel + scheduler jitter from the end-to-end sync error, leaving per-device first-sample latency (~30–80 ms, calibratable) as the dominant remaining term. iOS only in this release; Android still falls through to the existing `MediaPlayer` path (port to AAudio is pending). +- iOS: `nif_audio_play_at(Path, OptsJson, AtWallMs)` backed by a dedicated `AVAudioEngine` + `AVAudioPlayerNode`. The wall-time target is converted to an `AVAudioTime` `hostTime` via `mach_absolute_time` + `mach_timebase_info`, then handed to `-[AVAudioPlayerNode scheduleBuffer:atTime:options:completionHandler:]`. Past targets schedule ASAP. Multiple `play_at` calls accumulate on the player's timeline — use `audio_stop_playback` to flush. +- `audio_set_volume` and `audio_stop_playback` now also reach the scheduled-engine player so cross-API mixing behaves sanely. + +### Use case +- Distributed orchestra / multi-device musical performance where every phone must start the same sample at the same wall-clock instant. Pair with an NTP-style server-clock-sync helper on the caller side; this API takes the converted local-clock target. + +## [0.6.16] + +### Added +- **`mob_beam.zig` exports `RUSTLER_NIF_LIB_PATH` before BEAM start.** Calls `dladdr(&mob_start_beam)` to discover the absolute path of the host `.so` (e.g. `lib.so`) and `setenv()`s it as `RUSTLER_NIF_LIB_PATH`. Pairs with the matching upstream rustler change (rusterlium/rustler#726): rustler's `DlsymNifFiller::new()` on Android reads the env var first, falls back to its existing dladdr-self probe when unset. End result: rustler-based Rust NIFs statically linked into Mob's main `.so` now resolve `enif_*` symbols correctly on Bionic without any per-app patching. Existing rustler users on Android who *don't* run inside Mob see no change — the dladdr fallback covers them. +- **`mob_zig.zig` exposes `dladdr` + `DlInfo`** to other Zig consumers under `jni.dladdr` / `jni.DlInfo`. Hand-declared to match the libc/Bionic surface; same hand-declared FFI policy as the rest of `mob_zig.zig` (we don't use `@cImport` here). + +### Notes +- The setenv runs unconditionally — even apps that don't ship a rustler NIF get the env var set. Harmless. The env var only affects rustler's own startup logic when a rustler-built NIF loads. +- Verified end-to-end on a physical arm64 Android device (moto g power 2021): host sets path → rustler reads env var → `dlopen(path, RTLD_NOW | RTLD_NOLOAD)` → `dlsym` all `enif_*` exports → Rust NIF `greet/0` executes and returns `"Hello from Rust!"` to BEAM. + +## [0.6.15] + +### Added +- `text_field` now accepts a `secure: true` prop. iOS renders the field + as a SwiftUI `SecureField` (masked input) instead of the plain + `TextField`. The prop flows through the existing renderer + passthrough; cleartext still reaches the BEAM via `on_change` so apps + can hash/store the value as normal. Android consumes the same prop + via `PasswordVisualTransformation` once `mob_new`'s `MobBridge.kt.eex` + template is updated in a companion PR — until then the prop is a + graceful no-op on Android (renders as a regular field), no breakage. + + Reveal-toggle ("eye" button) is intentionally deferred — its + interaction with SwiftUI focus retention requires a `ZStack`-and-opacity + rebuild of `MobTextField` and warrants its own change. + +### Fixed +- iOS: `Mob.App.start/0` now switches `:inet_db` to file-only lookup and seeds `localhost` before any user code runs — BEAM's default `:native` lookup tries to `execve` the `inet_gethost` port program, which the iOS sandbox refuses, crashing the first `Node.connect` / `:erpc.call` / `gen_tcp.connect/3` with `:badarg`. Apps no longer need to set the lookup chain themselves; `Mob.DNS.configure_pure_beam/1` still composes on top for outbound DNS. See `guides/dns_on_ios.md`. +- iOS: `Column` now honours `fill_height: true`. The `.column` case in `MobRootView` only set `maxWidth`, so a `Column` with `fill_height: true` would collapse to its children's natural height — breaking the canonical `` header/flex/footer pattern. Now sets `maxHeight: .infinity` when the prop is set and switches alignment to `.topLeading` so children anchor at the top when the column flexes. Default (no `fill_height`) behavior is unchanged. + +### Docs +- Plugin system design corpus: `MOB_PLUGINS.md` (capability-plugin manifest, tiers 0-4, spec-v2 code-generated plugins), `MOB_STYLES.md` (style preset system, namespaced cherry-pick, stable per-primitive prop contract), `MOB_PLUGIN_SECURITY.md` (three-layer trust model, dev-mode escape hatches, `:acknowledge_unsafe_plugins`), `plugin_extraction_plan.md` (Phase 0 → Phase 3 + risk register + kickoff checklist). Locks scope to Elixir-first, BEAM-native, Gen-AI-enabled; parks full-language non-BEAM frontends at speculative `plugin_spec_version: 3`. Companion `agent_briefs/rustler_env_var_test.md` covers filmor's env-var-based fix in `rusterlium/rustler#726`. + +## [0.6.14] + +### Added +- **`:mob_nif.set_theme/1` — push resolved theme palette to native.** Lets a Compose `MaterialTheme` wrapper follow runtime `Mob.Theme.set(...)` calls instead of being baked into MainActivity at compile time. Otherwise Material 3 system chrome (NavigationBar, Button, etc.) stays at the default light scheme while the BEAM-side primitives switch to whatever theme is active — a visible mismatch when an app uses Obsidian / ObsidianGlass. +- **`Mob.Theme.resolved_palette/1`** — exposes the "semantic token → theme map → palette → ARGB int" resolution path that the renderer uses internally. The native side gets concrete integers it can hand to `Color(...)` directly. + +### Notes +- iOS implements the NIF as a no-op for symmetry — SwiftUI in `MobRootView.swift` renders every surface via mob primitives with explicit color props, so there's no system chrome that needs the push. +- The Android `MobBridge.setTheme(String)` Java hook is looked up via `cacheOptional`, so older templates that predate this load fine; the NIF just returns `:ok` without dispatching when the method isn't on the bridge. +- The mob_new generator templates that wire `MaterialTheme` ↔ `setTheme` in newly-generated apps will follow in a separate release; existing apps adopt manually (a `MutableState` in MobBridge.kt + `MaterialTheme(colorScheme = …)` wrap in MainActivity.kt). + +## [0.6.13] + +### Changed +- **Liquid Glass uses `Glass.clear` instead of `Glass.regular`.** On dark surfaces with little behind a card to refract, `.regular` reads as a frosted plate rather than glass. `.clear` is the right variant for the floating-card look the theme is meant to evoke — what's beneath shows through, the card looks like it's hovering. Only affects iOS 26+ (the `.ultraThinMaterial` fallback for older iOS is unchanged). + +## [0.6.12] + +### Added +- **`Mob.Theme` — `glass` flag for translucent surfaces.** New `glass: false` field on the theme struct. When set, `Mob.Renderer` tags every `Box` node that has a `background:` with `glass: true`, and the iOS side swaps the solid fill for `.glassEffect(.regular, in: shape)` on iOS 26+ (real Liquid Glass) or `.ultraThinMaterial` on iOS 17–25 (closest fallback that ships in older SDKs). Other nodes pass through untouched. Opt in via a preset or by passing `glass: true` to `Mob.Theme.build/1`. +- **`Mob.Theme.ObsidianGlass`** — Obsidian palette + `glass: true` for the common "make the whole app glassy" case. Switch at runtime with `Mob.Theme.set(Mob.Theme.ObsidianGlass)`; revert with `Mob.Theme.set(Mob.Theme.Obsidian)`. +- **`Mob.Theme.flags_map/1`** — companion to `color_map/1` / `spacing_map/1` / `radius_map/1`. Returns `%{glass: bool}` for now; future flag-style toggles will land here. + +### Notes +- Android receives the flag but ignores it for now — Compose Material 3 doesn't ship a first-class glassy surface yet; boxes fall back to solid. Compose-side support is a follow-up. + +## [0.6.11] + +### Fixed +- **`~MOB` sigil no longer double-encodes non-ASCII bytes in template source.** The NimbleParsec parser used `ascii_string/2` for string attribute values (`text="..."`) and brace content (`text={...}`); its `integer`-typed body re-encoded each source byte ≥128 as a Latin-1 codepoint then UTF-8. Net effect: `–` (E2 80 93) emerged as `Â`+pad+`O` (C3 A2 C2 80 C2 93) — mojibake on screen. Swapped both call sites to `utf8_string/2`, which matches by codepoint and round-trips multi-byte sequences (em-dash, en-dash, middle dot, smart quotes, accents, emoji) byte-for-byte. Workaround that's now unnecessary: binding the non-ASCII string to a variable outside the sigil and referencing it via `text={var}`. + +## [0.6.10] + +### Added +- **iOS BEAM startup honours `MOB_NODE_SUFFIX` env var.** The simulator branch already auto-derived a unique node-name suffix from `SIMULATOR_UDID` so concurrent sims didn't collide in Mac's EPMD, but there was no manual override path — the Android-side `MOB_NODE_SUFFIX` convention was iOS-blind. Now both branches (simulator + physical device) read `MOB_NODE_SUFFIX` with priority: explicit env → SIMULATOR_UDID-derived (sim only) → none. Pairs with `mob_dev 0.5.10`'s `mix mob.deploy --node-suffix X` flag (forwarded to simctl via the `SIMCTL_CHILD_*` mechanism). +- Resolves the `Protocol 'inet_tcp': register/listen error: no_reg_reply_from_epmd` symptom seen when running multiple iOS sims of the same app concurrently for visual-comparison work (e.g. cross-platform theme parity). + +## [0.6.9] + +### Fixed +- **CI pipeline unblocked.** The 0.6.8 push failed two CI gates and never + reached Hex; this release ships the same code with the gates green: + - `android/jni/mob_beam.h` reformatted to satisfy `xcrun clang-format + --dry-run -Werror` (the camera-frame delivery declaration was split + across three lines in a style clang-format wanted on two). + - `decimal` bumped 2.4.0 → 3.1.0 (transitive via `ecto_sqlite3` / + `jason`) to clear advisory **GHSA-rhv4-8758-jx7v** — unbounded + exponent in `Decimal.new/1` enables an unauthenticated DoS, affects + `< 3.0.0`. `jason` bumped 1.4.4 → 1.4.5 since older Jason capped + `decimal` to `~> 1.0 or ~> 2.0`. + +No source-level changes since 0.6.8 — same `Mob.Camera.start_frame_stream/2` +Android implementation and `Mob.Canvas` viewport docs, now actually on Hex. + +## [0.6.8] + +### Added +- **`Mob.Camera.start_frame_stream/2` now works on Android.** The + Camera2 + CameraX `ImageAnalysis` use case is wired through to BEAM + as `{:camera, :frame, %{bytes, width, height, format, timestamp_ms, + dropped}}` messages. Previously this NIF returned `:unsupported` on + Android — iOS-only. The Android implementation supports the same + `format: :rgb_f32` the iOS side does (`:bgra_u8` planned for a + follow-up). +- **`Mob.Canvas` moduledoc** documents the viewport-scaling contract: + the `width`/`height` props are logical viewport units, NOT pixels. + The renderer scales draw-op coordinates against the actual on-screen + pixel size. New tests in `test/mob/canvas_test.exs` pin the + contract so future readers don't regress to interpreting them as + raw pixels. + +### Notes +- Combined with `mob_dev 0.5.9`'s `mix mob.enable tflite` and the + `nx_tflite_mob 0.0.3` Hex package, the cross-platform live YOLO + demo (`mob_yolo_demo`) now runs end-to-end with only Hex deps. + Measured perf: 24 ms iPhone SE A15 via Core ML → ANE; 75–117 ms + Moto G Power 5G (Dimensity / BXM-8-256) via NNAPI / `mtk-gpu_shim`. +## [0.6.7] + +### Added +- `guides/mobile_surface_matrix.md` — comprehensive audit of mob's mobile capability surface vs. React Native + Expo SDK reference. Tables across UI components, gestures/input, device/system, storage, camera/audio, connectivity, sensors, location, notifications, background tasks, auth/payment, ML/Vision, maps, accessibility, iOS-only, Android-only, plus an "architecturally not present" section. Per-row status (✅ / 🟡 / ❌ / ⛔) with iOS + Android indicators. Hand-maintained from inspection of `lib/mob/` and `src/mob_nif.erl`. Sets realistic expectations and surfaces plugin candidates. +- README link + hexdocs entry so the matrix is discoverable for new users. +- `RELEASE.md` "Tests + docs for new functionality" section now includes a `mix docs` preview step and clarifies that hexdocs publishing is automatic via `mix hex.publish` (rides along from the previously-unreleased doc improvement). +- `MOB_PLUGINS.md` — plugin manifest schema spec covering five plugin tiers (pure Elixir helper through embedded sub-app), worked examples per tier, install + activation flow, schema reference, validation rules, hot-push compatibility table, plugin_spec_version forward-compat. References from the matrix's ❌ rows as plugin candidates. + +## [0.6.6] + +### Added +- `RELEASE.md` — canonical release-process documentation covering the + mix.exs-driven trigger model, the patch-bump-default-with-mandatory- + permission rule, CHANGELOG conventions, when a bump is warranted (new + functionality, bug fixes, doc improvements, dep bumps) vs. when it + isn't (CI tweaks, hook changes, internal refactors), the + tests-and-docs-with-new-functionality non-negotiables, and the + per-step idempotency of `release.yml`. Linked from `mob_dev` and + `mob_new` CLAUDE.md by URL so the canonical process is one file. +- `.githooks/pre-push` — committed pre-push hook that runs the cheap + preflight (format + credo + warnings-as-errors) on every push and + the full release preflight (test suite + `mob.security_scan` where + present) only when `mix.exs` changed. Activate per-clone with + `git config core.hooksPath .githooks`. +- `CLAUDE.md` "Release flow" section linking to the new docs. + +## [0.6.5] + +### Fixed +- HexDocs source links pointed at the non-existent `main` branch — corrected to `master` so each `` glyph next to a heading now opens the actual source file in the GitHub repo. +- `mob_nif.zig` called the variadic `enif_make_list/2` (not exposed in `mob_erts.zig`) from the BT paired-list finisher; the Android arm64 build failed at link. Switched to the non-variadic `enif_make_list_from_array(env, &empty, 0)`. + +### Added +- `.github/workflows/test.yml` — runs `mix test`, `mix format --check-formatted`, `mix credo --strict`, `mix erlfmt --check src/`, `xcrun clang-format`, `swiftlint`, and `mix deps.audit` on push to master and on every PR. +- `.github/workflows/release.yml` — on tag push, creates a GitHub Release whose body is the matching `## [X.Y.Z]` section from this changelog (falls back to auto-generated commit notes if the tag has no section). +- `PLAN.md` — three-layer CI + integration-test plan covering the gap between unit tests and on-device verification. + +## [0.6.4] + +### Added +- `Mob.GpuView` / `Mob.UI.gpu_view/1` — Metal fragment-shader surface on iOS. Host owns the vertex shader (full-screen quad with `v_uv`); user supplies an MSL fragment shader plus a list of uniforms packed at natural alignment into fragment-buffer slot 0. SwiftUI `MobGpuView` wraps an `MTKView` with a hash-keyed shader cache and a translucent red overlay for compile errors. iOS-only in this release; the Android GLES 3.0 backend ships in mob_new 0.3.1. +- `` tag whitelisted for both `priv/tags/ios.txt` and `priv/tags/android.txt`. + +## [0.6.3] + +### Fixed +- iOS camera sensor delivered frames in landscape-right by default — `Mob.Camera.start_frame_stream/2` was feeding 90°-rotated pixels to ML models, dropping classification accuracy enough that a jar appeared as "laptop 24%" instead of "cup 96%". `AVCaptureConnection.videoRotationAngle = 90` (iOS 17+) / `videoOrientation = .portrait` (older) is now set on both the preview layer and the data-output connection, so what the user sees and what the model sees are the same upright frame. + +## [0.6.2] + +### Added +- `Mob.Camera.start_frame_stream/2` and `stop_frame_stream/1` — push-driven per-frame delivery as `{:camera, :frame, %{bytes, width, height, format, timestamp_ms, dropped}}`. Defaults to 640×640 `rgb_f32` for direct Nx hand-off; caller-overridable width/height/format/facing and a software `throttle_ms` gate. + +### Changed +- iOS camera now uses a single shared `AVCaptureSession` for preview and frame stream. The previous two-session design silently dropped frames because iOS allows only one active session per physical camera. + +## [0.6.1] and earlier + +Earlier releases predate this changelog; consult the [tag list](https://github.com/genericjam/mob/tags) and the per-tag commit messages for history. diff --git a/CLAUDE.md b/CLAUDE.md index 736b8973..828bddd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,30 @@ but if in doubt, ask. --- +## Tests cover everything, not just runtime code + +Every behavior in this repo gets a test — including build helpers +and any CLI surface that lives here (less common in `mob` than in +`mob_dev`, but the discipline is the same). Runtime modules like +`Mob.Screen`, `Mob.Renderer`, `Mob.Sigil` get the obvious +unit/integration coverage. **Beyond runtime:** + +- NIF stub modules (`mob_nif.erl`, when it gains more surface): + pure helpers extracted from the C/Zig side get Elixir tests. +- Sigil compile-time AST transforms: test the generated AST, + not just runtime behavior. This caught the + `Mob.Sigil.wrap_child/1` per-call-site warning regression this + session. +- Build-time helpers (driver_tab generators, native build glue + when it lives here): same rule. + +The goal is **find bugs in CI before users hit them.** A bug found +by a test takes minutes to fix; one found by a user takes a +bug-report-to-fix cycle plus damage to confidence. When you touch +something untested, either add coverage or note it as a follow-up. + +--- + ## Pre-commit checklist Before committing changes, run **all** in this order: @@ -54,7 +78,7 @@ Before committing changes, run **all** in this order: ```bash mix test # full suite must pass (call out any pre-existing flake explicitly) mix format # apply Elixir formatting -mix credo --strict # **whole tree, not just changed files** — pre-existing issues are tracked separately, but new ones (including in tests) must be fixed +mix credo --strict # **whole tree, not just changed files** — includes ExSlop (catches AI-generated patterns: blanket rescue, narrator docs, etc). Pre-existing issues are tracked separately, but new ones (including in tests) must be fixed mix erlfmt --check src/ # Erlang formatting (src/mob_nif.erl) xcrun clang-format --dry-run -Werror \ ios/*.m ios/*.c \ @@ -75,6 +99,29 @@ manually with a screenshot or `Mob.Test` interaction before committing. --- +## Release flow + +See [`RELEASE.md`](RELEASE.md) for the canonical release process — +trigger model (mix.exs is the source of truth), version-bump rules +(patch default, always ask, never auto-bump), CHANGELOG conventions, +local preflight, and the per-step idempotency of `release.yml`. + +**Pre-push hook**: `.githooks/pre-push` runs `mix format +--check-formatted`, `mix credo --strict`, and `mix compile +--warnings-as-errors` on every push (~5-10 s). When the push touches +`mix.exs` it additionally runs the full test suite as the release +preflight. The hook is committed in the repo; activate it once per +clone or worktree with: + +```bash +git config core.hooksPath .githooks +``` + +git stores `core.hooksPath` locally per-clone, so every worktree +needs the same one-liner. + +--- + ## Native App Test Harness — Vision ### What mob is (beyond the UI framework) @@ -310,8 +357,13 @@ Devices are assigned dist ports by index to avoid conflicts: - Device 1 (iOS sim): port 9101 iOS dist port is passed via `SIMCTL_CHILD_MOB_DIST_PORT` env var; `mob_beam.m` reads -`MOB_DIST_PORT` at startup. Android dist port is passed as an intent extra (`mob_dist_port`); -**`MainActivity.java` does NOT yet read this — multi-Android support is pending.** +`MOB_DIST_PORT` at startup. Android dist port is passed as the `mob_dist_port` intent +extra (set by `MobDev.Discovery.Android.restart_app/4`); the generated app's +`MainActivity.kt` reads it (`intent.extras.getInt("mob_dist_port")`) and exports it as +the `MOB_DIST_PORT` env var, which `mob_beam` consumes at startup — so each Android +device honors its per-device port and multi-Android no longer collides. Override with +`mix mob.deploy --dist-port ` (e.g. to dodge a port another app is squatting in the +shared Mac EPMD); pair it with `adb forward tcp: tcp:`. Both iOS and Android end up registered in the same Mac EPMD. `mix mob.connect` sets up all tunnels automatically. @@ -360,7 +412,7 @@ app code just calls `Mob.Dist.ensure_started(node: :"my_app_android@127.0.0.1", ERTS helper binaries (`erl_child_setup`, `inet_gethost`, `epmd`) cannot be exec'd from the app data directory (SELinux `app_data_file` blocks `execute_no_trans`). They are packaged in the APK as `lib*.so` in `jniLibs/arm64-v8a/` (gets `apk_data_file` label, which allows exec). -`mob_beam.c` symlinks `BINDIR/` → `/lib.so` before `erl_start`. +`mob_beam.zig` symlinks `BINDIR/` → `/lib.so` before `erl_start`. ## Agent round-trip workflow @@ -548,7 +600,8 @@ User alias "Nova" = macOS + Nix-managed toolchain throughout. - `ios/mob_nif.m` — iOS NIF implementation (SwiftUI bridge + test harness) - `android/jni/mob_nif.c` — Android NIF implementation (JNI bridge) - `ios/mob_beam.m` — iOS BEAM launcher -- `android/jni/mob_beam.c` — Android BEAM launcher +- `android/jni/mob_beam.zig` — Android BEAM launcher (Phase 6b iter 2 — was `.c`) +- `android/jni/mob_zig.zig` — Hand-declared JNI / libc / Android FFI bindings used by mob_beam.zig ## Transport-handler reentrancy: spawn before calling back into the GenServer @@ -628,22 +681,12 @@ The cookie defaults to `:mob_secret` (set by `Mob.Dist.ensure_started` in your app's `on_start/0`). `--name` (long names) is required when the device node uses a numeric host like `@10.0.0.120`. -### Multi-Android limitation (mob_dev current behaviour) +### Multi-Android — node naming (FIXED 2026-05-28 in mob_dev, commit `7497f4b`) -`mob_dev` derives the Android dist node name from the device's IP, -which is identical (`10.0.2.x`) for every emulator. Two emulators -both try to register `your_app_android_emulator36x5x10x0` in EPMD -and the second fails with `eaddrinuse`. Symptom in -`mix mob.connect` output: - -``` -sdk_gphone64_arm64: timed out waiting for your_app_android_emulator36x5x10x0@127.0.0.1 -``` - -Workarounds: -1. Only have one emulator running. -2. Pick the emulator you care about and verify the other side via - `adb logcat`. +`mob_dev` now derives the Android dist node-name suffix from the device +**serial** (matching what `Mob.Dist` registers), not the IP. Two emulators +get distinct suffixes (`emulator_5554` / `emulator_5556`) and no longer +collide in EPMD. See `mob_dev/decisions/2026-05-28-android-node-name-by-serial.md`. ### Fixing adb-forward port mismatch @@ -704,3 +747,27 @@ Write small named functions in `.IexHelpers`, push with `mix mob.deploy`, call by RPC. That keeps the Mac-side script minimal and debuggable, and the helpers double as documentation of the operations you actually need. + +--- + +## Decision log + +Non-obvious decisions — tradeoffs, workarounds, conventions, "why we chose X +over Y" — go in `decisions/`, **one file per decision**: + + decisions/YYYY-MM-DD-short-slug.md + +Each file is a lightweight ADR: + + # + - Date: YYYY-MM-DD + - Status: accepted | superseded by <file> | proposed + ## Context — what prompted this + ## Decision — what we chose + ## Consequences — tradeoffs, follow-ups + +**Append new files; never edit existing ones.** If a decision changes, add a +new file and mark the old one `Status: superseded by <new-file>`. One file per +decision keeps the log conflict-free across parallel agents/worktrees — the +date-sorted directory listing is the index. Record a decision the moment you +make a non-obvious call, not later. diff --git a/MOB_PLUGINS.md b/MOB_PLUGINS.md new file mode 100644 index 00000000..51bdc86a --- /dev/null +++ b/MOB_PLUGINS.md @@ -0,0 +1,1032 @@ +# Mob plugins — manifest schema + +Mob plugins are regular Hex packages with a `priv/mob_plugin.exs` data +file. The data file declares what the plugin contributes (NIFs, UI +components, screens, permissions, etc.) and mob_dev's compile step +autolinks those contributions into the host app's build. + +This doc covers: + +- The five plugin tiers and what each one ships +- The manifest schema, annotated with concrete examples +- Install + activation flow +- Validation + compatibility rules + +For the surrounding ecosystem questions (why Hex, why a manifest, +plugin authoring via `mix mob.new_plugin`), see `RELEASE.md` and the +relevant guides. + +## Plugin tiers + +Plugins range from "10 lines of helper code" to "embedded chat app." +The manifest scales — small plugins use 3 fields, big plugins use a +dozen. Every section below the required header is optional; you only +write what you need. + +| Tier | Example | What it ships | Hot-pushable? | +|--|--|--|--| +| 0 | `mob_color_palette` | Pure Elixir module, no native, no manifest | Yes (regular Hex pkg) | +| 1 | `mob_haptic_extras` | NIF + Elixir wrapper | No (native rebuild) | +| 2 | `mob_signature_pad` | + new `<SignaturePad>` component | No | +| 3 | `mob_in_app_purchase` | + `Mob.Screen` modules, migrations, assets | No | +| 4 | `mob_chat_kit` | + lifecycle hooks, settings, notification handlers | No | + +A tier-0 plugin doesn't need this spec at all — it's just a Hex +package depending on `:mob`. The manifest matters from tier 1 +upward. + +## Minimum viable manifest (tier 1) + +```elixir +# priv/mob_plugin.exs +%{ + name: :mob_haptic_extras, + mob_version: "~> 0.6", + plugin_spec_version: 1 +} +``` + +Three required fields, that's it. A manifest this small means "this +plugin's contributions are entirely in the lib/ folder, no native +code, no permissions." Functionally equivalent to a tier-0 plugin +but allows mob_dev to print it in `mix mob.plugins` output and +enforce the `mob_version` constraint at compile time. + +Add fields below as you need them. Every section is independently +optional. + +## Tier 1 — functional plugin + +A NIF + Elixir wrapper + per-platform helper code that doesn't touch +the render tree. The canonical example is the `mob_bluetooth` plugin +(extracted from core in Wave 1): + +```elixir +%{ + name: :mob_bluetooth, + mob_version: "~> 0.6", + plugin_spec_version: 1, + description: "Bluetooth Classic peripheral (HFP / SPP / HID)", + + # Static-linked NIFs. `:module` is the NIF's Erlang module name (a valid + # C token: `[a-z][a-z0-9_]*`), NOT an Elixir module — ERL_NIF_INIT uses + # it as BOTH the registered module name AND the static-init symbol prefix + # (`<module>_nif_init`), so an Elixir module like `MobBluetooth.Nif` + # would yield an invalid C symbol. The plugin ships a small Erlang stub + # (e.g. `src/mob_bluetooth_nif.erl`) that calls `erlang:load_nif/2`; an + # Elixir wrapper can then `defdelegate` into it. + # + # `:native_dir` is the per-NIF native source directory. mob_dev's build + # appends these entries to the existing :static_nifs list (the generated + # driver table references `<module>_nif_init` over C ABI regardless of + # source language). + # + # `:lang` selects the compile path (default `:c`): + # - `:c` → `<native_dir>/<module>.c`, compiled with + # `-DSTATIC_ERLANG_NIF_LIBNAME=<module>` (the ERL_NIF_INIT macro + # emits the init symbol). Fed to build.zig via `-Dplugin_c_nifs`. + # - `:zig` → `<native_dir>/<module>.zig`, compiled via `addZigObject` and + # fed via `-Dplugin_zig_nifs`. The source names its own + # `export fn <module>_nif_init()` (no libname flag) and reaches + # mob-core bindings through the named imports `@import("erts")` + # / `@import("jni")` that build.zig wires for plugin zig objects. + # See mob_dev `decisions/2026-05-28-zig-plugin-nifs.md`. + nifs: [ + %{module: :mob_bluetooth_nif, native_dir: "priv/native/jni", lang: :zig} + ], + + android: %{ + # Merged into android/app/build.gradle's dependencies block. + gradle_deps: [], + + # Merged into AndroidManifest.xml. REQUIRES explicit user opt-in + # via `config :mob, :plugins` — mob_dev refuses to merge these + # silently for plugins that haven't been activated. + permissions: [ + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_SCAN" + ], + + # The plugin's own Kotlin bridge class, in its OWN package (NOT the app's + # MobBridge). mob_dev copies it into the app source tree before + # `gradle assembleDebug` so the app's Kotlin sourceSet compiles it. + bridge_kt: "priv/native/android/MobBluetoothBridge.kt", + + # Fully-qualified name of that Kotlin class. mob_dev generates a + # `MobPluginBootstrap.registerAll/0` (called from MainActivity.onCreate) + # that invokes `<bridge_class>.register()` at startup; the plugin's + # `nativeRegister(env, cls)` JNI thunk caches its own jclass + method IDs + # from the `cls` arg (no FindClass / classloader problem). This is how a + # plugin-owned Kotlin class becomes callable from its NIF. + bridge_class: "io.mob.bluetooth.MobBluetoothBridge", + + # Plain JNI-thunk C (Java_<pkg>_<Class>_*) compiled alongside beam_jni.c + # via `-Dplugin_jni_sources` (no NIF-init libname — these aren't NIFs). + # Holds nativeRegister + the nativeDeliver* thunks that call the plugin + # NIF's `mob_deliver_*` exports. + jni_source: "priv/native/jni/mob_bluetooth_jni.c" + }, + + ios: %{ + # Swift files compiled with the project's existing swiftc invocation. + swift_files: ["priv/native/ios/MobBluetooth.swift"], + + # Info.plist keys to merge. iOS rejects builds without these for + # the matching permission categories — same opt-in gate as Android. + plist_keys: %{ + "NSBluetoothAlwaysUsageDescription" => + "Required by mob_bluetooth — replace this string in your Info.plist" + }, + + # System frameworks linked at the static-link step. + frameworks: ["CoreBluetooth"] + } +} +``` + +Notes: + +- `:gradle_deps` accept any string Gradle would understand (`group:artifact:version`). +- `:plist_keys` strings are placeholders — the user must replace them + in their `ios/Info.plist`. App Store review rejects apps with the + default text; this is intentional friction so the user provides a + real explanation. +- iOS or Android can be omitted. iOS-only and Android-only plugins + are valid. The validator warns (does not error) when one is missing + so users discover the gap. + +## Tier 2 — visual plugin + +Adds new render-tree node types. Same shape as tier 1 plus a +`:ui_components` section: + +```elixir +%{ + name: :mob_charts, + mob_version: "~> 0.6", + plugin_spec_version: 1, + description: "Line / bar / pie chart components", + + android: %{ + gradle_deps: ["com.github.PhilJay:MPAndroidChart:v3.1.0"] + }, + + ui_components: [ + %{ + # PascalCase tag for the ~MOB sigil: <Chart data={@series} /> + tag: "Chart", + + # Snake-case atom for the render tree: %{type: :chart, ...} + atom: :chart, + + # Props the component accepts. Documentation + (eventually) + # compile-time validation. Optional today; required if you + # want `mix mob.routes` and similar tools to know the shape. + props: [:data, :type, :color, :width, :height], + + ios: %{ + # SwiftUI View struct in priv/native/ios/. mob's renderer + # dispatches `case .chart:` → `MobChartView(node: node)`. + view_module: "MobChartView" + }, + + android: %{ + # @Composable function in priv/native/android/. mob's + # renderer dispatches `"chart" -> MobChart(node, m)`. + composable: "MobChart" + } + }, + + %{ + tag: "Sparkline", + atom: :sparkline, + props: [:data, :color], + ios: %{view_module: "MobSparklineView"}, + android: %{composable: "MobSparkline"} + } + ] +} +``` + +A visual plugin can omit one platform if the component is genuinely +platform-specific (e.g., an iOS-only Live Activity widget). The +validator warns when a `ui_components` entry has only one platform — +silent UX bugs on the missing side are the #1 React Native plugin +pain point. + +**Visual plugins are NOT hot-pushable.** Adding a new node type +requires recompiling the native shell. The dev loop is "edit Elixir +→ rebuild app → reinstall," not "edit Elixir → `mix mob.push`." +The manifest validator surfaces this distinction. + +## Tier 3 — multi-screen plugin + +Plugins that ship entire screens (effectively mini-applications +embedded in the host). Adds `:screens`, `:migrations`, `:assets`: + +```elixir +%{ + name: :mob_in_app_purchase, + mob_version: "~> 0.6", + plugin_spec_version: 1, + description: "StoreKit / Play Billing IAP flow", + + # ── tier-1 capability bits ── + nifs: [%{module: :mob_iap_nif, native_dir: "priv/native/jni"}], + android: %{ + gradle_deps: ["com.android.billingclient:billing:6.1.0"], + bridge_kt: "priv/native/android/MobIapBridge.kt", + jni_source: "priv/native/android/jni/iap.c" + }, + ios: %{ + swift_files: ["priv/native/ios/MobIap.swift"], + frameworks: ["StoreKit"] + }, + + # ── tier-3 additions ── + + # Mob.Screen modules the plugin contributes. Host can push them + # via `Mob.UI.push_screen(MobIap.CatalogScreen)`. The plugin's + # README explains the intended navigation patterns. + screens: [ + %{module: MobIap.CatalogScreen, default_route: "/iap/catalog"}, + %{module: MobIap.CartScreen, default_route: "/iap/cart"}, + %{module: MobIap.ConfirmationScreen, default_route: "/iap/confirm"} + ], + + # Ecto migrations the plugin ships. The repo_namespace prefixes + # table names so plugins from different vendors don't collide. + # Host app's migrator picks them up at boot. + migrations: %{ + repo_namespace: "mob_iap_", + migrations_dir: "priv/repo/migrations" + }, + + # Asset bundles to merge into the host app's bundle. + # Fonts get registered automatically on iOS (UIAppFonts) and + # Android (assets/fonts/). Images are addressable from Mob.UI + # via "plugin://mob_iap/<filename>" path syntax. + assets: %{ + fonts: ["priv/assets/iap-icons.ttf"], + images: ["priv/assets/store-badge.png"] + } +} +``` + +The `screens:` section is declarative — it tells the host these +modules exist and provides suggested routes. The host app *chooses* +whether and where to wire them into its navigation. This avoids the +React-Native problem of plugins silently grabbing routes. + +## Tier 4 — embedded sub-app + +Tier 3 plus lifecycle hooks, settings, background workers, push +notifications. The line between "plugin" and "embedded application" +gets thin here — but as long as the plugin lives under the host's +supervisor (no independent OTP app), it's still a plugin. + +```elixir +%{ + name: :mob_chat_kit, + mob_version: "~> 0.6", + plugin_spec_version: 1, + description: "Embeddable chat (channels, messages, attachments)", + + # ... tier 1/2/3 fields ... + + lifecycle: %{ + # Called from Mob.App.on_start/0 after the host's own setup. + # Returns :ok or {:error, reason} — error bubbles to host. + on_start: {MobChatKit, :start, []}, + + # Children added to the host's supervisor tree. Same shape as + # Supervisor.child_spec. Started after on_start succeeds. + supervised: [ + MobChatKit.MessageSync, + {MobChatKit.PresenceTracker, []} + ], + + # Optional OS-level callbacks. Called when the app foregrounds + # or backgrounds. Plugin can flush pending state, pause workers, etc. + on_resume: {MobChatKit, :on_resume, []}, + on_background: {MobChatKit, :on_background, []} + }, + + settings: %{ + # User-facing settings the plugin exposes. Persisted via + # Mob.State, namespaced per plugin. Defaults are + # used until the user opens the editor_screen and saves. + schema: [ + %{key: :sound_on_message, type: :boolean, default: true}, + %{key: :default_channel, type: :string, default: "#general"}, + %{key: :sync_interval_seconds, type: :integer, default: 30} + ], + + # Mob.Screen module the host can push to let users edit. The + # plugin owns the screen's UX; the host just provides the + # entry point. + editor_screen: MobChatKit.SettingsScreen + }, + + notifications: %{ + # Push notification handler. The host's notification dispatcher + # checks each plugin's handler in registration order; first + # match wins. `match` is either a function or a map prefix. + handlers: [ + %{ + match: %{type: "chat_message"}, + handler: {MobChatKit.Notifications, :handle_message, 1} + } + ] + } +} +``` + +`:settings.schema` typed entries get free runtime validation via +`Mob.State`. The plugin reads its own settings with +`Mob.Plugins.get_setting(:mob_chat_kit, :default_channel)`. + +> **Status (2026-06-06):** tiers 3 and 4 are **built and device-verified** +> (iPhone + Android). The wiring is pure-Elixir off a generated runtime +> manifest read by `Mob.Plugins` at boot; see +> `decisions/2026-06-06-plugin-tiers-3-4.md`. Device-verified: static + +> generated screens, migrations (table created on device), `plugin://` images, +> notification routing, tier-4 lifecycle/settings/supervised workers, and +> **custom fonts** — `assets.fonts` are build-bundled (iOS `.app` + `UIAppFonts`, +> Android `res/font` uncompressed) and used via the `font:` prop, visually +> confirmed on Android (a plugin-shipped serif font rendered distinct from the +> system font). This also makes app-level `priv/fonts/` custom fonts (documented +> above) actually work for the first time. Merged to masters; `mix mob.new_plugin` +> scaffolds all five tiers (0–4), cross-plugin conflict detection + a runtime- +> manifest auto-regen guard the multi-plugin case (see "Cross-plugin conflict +> detection" below), and a second plugin migration is device-verified composing +> alongside the first. + +## Code-generated plugins (spec version 2+) + +Some plugins need to derive their contributions from the *host app's* +configuration at compile time, not declare them statically. The +canonical case is an Ash integration: define `N` Ash resources in +the host app, and a `mob_ash` plugin generates `N × 3` screens (list, +detail, form) plus any matching UI components — all baked into the +build, not runtime. + +For these plugins the static `:screens` list isn't enough. Spec +version 2 adds the `:screens_generator` field that returns the same +shape at compile time: + +```elixir +%{ + name: :mob_ash, + mob_version: "~> 0.6", + plugin_spec_version: 2, # bumped — requires v2 + description: "Generate Mob screens from Ash resources", + + # Either static (tier 3) or generated (this section), not both. + # Generator is {Module, :function, args}; mob_dev calls it during + # the compile step and uses the returned list as if it had been + # declared statically. + screens_generator: {MobAsh.ScreenGenerator, :generate, []}, + + ui_components: [ + %{tag: "AshForm", atom: :ash_form, props: [:resource, :action, :record]}, + %{tag: "AshList", atom: :ash_list, props: [:resource, :filter, :sort]}, + %{tag: "AshField", atom: :ash_field, props: [:attribute, :record]} + ], + + ios: %{swift_files: ["priv/native/ios/MobAshForm.swift", ...]}, + android: %{composable_files: [...]} +} +``` + +The generator function returns a list with the same shape as +`:screens`: + +```elixir +defmodule MobAsh.ScreenGenerator do + def generate do + # Read host app's Ash domain registration. + domains = MobDev.Plugin.host_config(:my_app, :ash_domains, []) + + for domain <- domains, + resource <- domain.resources(), + screen <- [:list, :detail, :form] do + module = generated_module_name(resource, screen) + route = generated_route(resource, screen) + + # Actually create the module at compile time via Module.create/3. + create_screen_module(module, resource, screen) + + %{module: module, default_route: route} + end + end +end +``` + +`MobDev.Plugin.host_config/3` is the explicit, audited API for +generators to read the host's `config :my_app, ...` during compile. +Calls outside this surface (e.g. reading `mob.exs` directly, +introspecting other plugins) require `:host_config_keys` declared +in the manifest so the audit can verify what the generator touches. + +### Other generator fields + +Spec version 2 adds matching generator forms for any section that +benefits from dynamic computation: + +- `:nifs_generator` — useful when the NIF set depends on host config + (e.g., conditionally include a feature) +- `:ui_components_generator` — for plugins that synthesize components + from a schema (form-builders, data-bound widgets) + +A plugin can mix static and generator forms across different +sections — static `:nifs` + generated `:screens` is fine. + +### Why generators at compile time, not runtime + +Mob plugins are statically merged for App Store / Play Store +compatibility. Runtime plugin registration would require dynamic +module loading which our build posture forbids. Compile-time +generators produce real modules that ship in the binary the same as +hand-written ones. Hot-push works for any pure-Elixir generated +modules (same rule as static screens); native-touching generators +require a rebuild. + +### What a host app looks like + +The Ash integration story for an end user: + +```elixir +# mix.exs +{:mob_ash, "~> 0.1"} + +# mob.exs +config :mob, :plugins, [:mob_ash] + +# my_app.ex (the host's Ash domain) +config :my_app, :ash_domains, [MyApp.Blog, MyApp.Auth] + +# That's it. Compile produces: +# MobAsh.Generated.Blog.Post.ListScreen +# MobAsh.Generated.Blog.Post.DetailScreen +# MobAsh.Generated.Blog.Post.FormScreen +# MobAsh.Generated.Auth.User.ListScreen +# ... etc, all baked into the build. +``` + +Adding a resource to the Ash domain regenerates its screen set on +next compile. Removing one removes the screens. The host's +`App.navigation/1` can either wire them up by convention or pick a +subset. + +### The contract is generic — Ash is one example + +The `:screens_generator` + `host_config/3` API doesn't know about +Ash. Any host-side registry of resource-like things can drive +screen generation. A `mob_ecto` sketch shows the same pattern +without Ash as a dependency: + +```elixir +%{ + name: :mob_ecto, + mob_version: "~> 0.6", + plugin_spec_version: 2, + description: "Generate Mob screens from Ecto schemas", + + screens_generator: {MobEcto.ScreenGenerator, :generate, []}, + + ui_components: [ + %{tag: "EctoForm", atom: :ecto_form, props: [:schema, :changeset]}, + %{tag: "EctoList", atom: :ecto_list, props: [:schema, :query]}, + %{tag: "EctoField", atom: :ecto_field, props: [:field, :record]} + ] +} +``` + +The host registers its schemas the same way Ash domains are +registered: + +```elixir +# my_app.ex +config :my_app, :ecto_schemas, [MyApp.Blog.Post, MyApp.Auth.User] +``` + +And the generator iterates schemas instead of resources: + +```elixir +defmodule MobEcto.ScreenGenerator do + def generate do + schemas = MobDev.Plugin.host_config(:my_app, :ecto_schemas, []) + + for schema <- schemas, + screen <- [:list, :detail, :form] do + module = generated_module_name(schema, screen) + route = generated_route(schema, screen) + create_screen_module(module, schema, screen) + %{module: module, default_route: route} + end + end +end +``` + +mob_ash and mob_ecto have identical contracts with mob_dev — they +differ only in how they introspect the host's resource definitions. +The same pattern fits Phoenix schemas, Memento tables, or any +custom host-side registry. + +### Working with Ash beyond the basics + +If a host app wants to share resource code between the Phoenix +server and the mob_ash generator — the same `User` attributes, +validations, or calculations on both sides — the recommended path +is **Spark Fragments**, the existing Ash mechanism for composable +DSL fragments: + +```elixir +defmodule Shared.User.Attributes do + use Spark.Dsl.Fragment, of: Ash.Resource + + attributes do + attribute :email, :string + attribute :name, :string + end +end + +# server-side resource +defmodule MyApp.Auth.User do + use Ash.Resource, fragments: [Shared.User.Attributes] + # + server-only actions, policies, data layer +end + +# mobile-side resource (read by mob_ash's generator) +defmodule MyApp.Mobile.User do + use Ash.Resource, fragments: [Shared.User.Attributes] + # + mobile-safe action subset +end +``` + +Per-action exposure granularity ("expose only `:read` and `:create` +to mobile") is a host-app concern, expressed by which actions live +on the mobile-side resource module. mob_dev does not need a DSL +for this — the generator sees whatever resources the host registers +in `config :my_app, :ash_domains` and generates screens for their +declared actions. + +This keeps mob_dev's contract Ash-agnostic while giving Ash users +a clean path for the server/mobile code-sharing question without +mob_dev needing to know anything about it. + +## Install + activation flow + +Two-step opt-in by design. + +### Step 1 — install (`deps + mix deps.get`) + +Standard Hex flow. The plugin is now resolvable; mob_dev sees it on +the next compile. + +```elixir +# mix.exs +defp deps do + [ + {:mob, "~> 0.6"}, + {:mob_haptic_extras, "~> 0.1"} + ] +end +``` + +```bash +mix deps.get +``` + +After this, `mix mob.plugins` lists the plugin as **installed but not +activated**. Its native code is NOT merged into the build. Its +permissions are NOT added to your manifest. This is deliberate — a +silent `mix deps.get` should never modify your app's permission set. + +### Step 2 — activation (explicit consent in `mob.exs`) + +```elixir +# mob.exs +config :mob, :plugins, [ + :mob_haptic_extras, + :mob_bluetooth +] +``` + +Now mob_dev's compile step merges contributions. If `mob_bluetooth` +declares `BLUETOOTH_CONNECT` + `BLUETOOTH_SCAN`, those permissions +get added to `AndroidManifest.xml` only after the plugin is in this +list. mob_dev prints the diff at compile time so you see exactly +what's being added. + +If you've added a plugin to `deps` but not to `config :mob, +:plugins`, the next compile prints: + +``` +[mob] :mob_bluetooth is installed but not activated. Add it to + `config :mob, :plugins` in mob.exs to enable its contributions + (NIFs, permissions, native code). +``` + +### Convenience — `mix mob.add_plugin <name>` + +Wraps both steps + runs the plugin's interactive setup (if any): + +```bash +mix mob.add_plugin mob_chat_kit +``` + +Does: add to `deps`, run `mix deps.get`, add to `config :mob, +:plugins`, walk the plugin's `setup:` prompts (e.g., "Register +MobChatKit.MessageListScreen in your App.navigation/1? [Y/n]"). For +tier 1-2 plugins the prompts are usually empty. For tier 3-4 plugins +they're where the plugin author guides integration. + +Standard flow always works — `mix mob.add_plugin` is convenience, +not a required entry point. + +## Schema reference + +Top-level required: + +- `:name` — atom matching the package name. Convention: `mob_` prefix. +- `:mob_version` — string, semver requirement (`"~> 0.6"`). +- `:plugin_spec_version` — integer. Current: `1`. Bumped when this + schema makes breaking changes; old plugins keep working against + old spec versions. + +Top-level optional: + +- `:description` — short string for `mix mob.plugins` output. +- `:host_requirements` — list of strings: manual host-app steps the build + can't automate (e.g. an AndroidManifest fragment like mob_screencast's + `<service android:foregroundServiceType="mediaProjection">` or a capture + `FileProvider`). Every `mix mob.deploy --native` of the host prints them + as a warning block, so a missing manual step can't fail silently at first + feature use. Declare one entry per step, with the exact XML/snippet the + host author must add. + +Capability sections (any combination): + +- `:nifs` — list of NIF declarations. See tier 1 example. +- `:android` — map of Android-specific contributions: + - `:gradle_deps` (list of strings) + - `:permissions` (list of strings — opt-in via activation) + - `:bridge_kt` (path to Kotlin file) + - `:jni_source` (path to C/Zig file) + - `:min_sdk` (integer, optional override) +- `:ios` — map of iOS-specific contributions: + - `:swift_files` (list of paths) + - `:plist_keys` (map — opt-in via activation) + - `:frameworks` (list of strings) + - `:min_version` (string, optional override) + +Visual sections: + +- `:ui_components` — list of component maps. Each entry: + - `:tag` (PascalCase string for the sigil) + - `:atom` (snake_case atom for the render tree) + - `:props` (list of atom keys, optional) + - `:ios` (map: `:view_module` SwiftUI struct name) + - `:android` (map: `:composable` function name) + +Multi-screen sections: + +- `:screens` — list of `%{module, default_route}` maps +- `:migrations` — `%{repo_namespace, migrations_dir}` map +- `:assets` — `%{fonts, images}` map + +Sub-app sections: + +- `:lifecycle` — `%{on_start, supervised, on_resume, on_background}` map +- `:settings` — `%{schema, editor_screen}` map +- `:notifications` — `%{handlers}` map + +Setup section (tier 3+): + +- `:setup` — list of interactive prompts that `mix mob.add_plugin` + walks through. Optional; mostly for tier-3/4 plugins. + +## Validation rules + +`mix mob.validate_plugin` (run from a plugin project) checks: + +- Required top-level fields present +- `mob_version` is a valid version requirement +- Every path in the manifest exists on disk +- Files declared as `bridge_kt` / `jni_source` / `swift_files` / + `view_module` / `composable` exist and parse +- `ui_components` entries with only one platform (warning, not error) +- `permissions` and `plist_keys` declared (warning + manual review + recommended before publishing) +- `mob_version` satisfied by the version of `:mob` in deps + +Compile-time validation (run by mob_dev when activating plugins): + +- Plugin's `mob_version` requirement satisfied by the installed mob +- All plugins in `config :mob, :plugins` are present in `deps` +- **Cross-plugin conflict detection** (see below) + +Both stages fail loud — never silent. + +### Cross-plugin conflict detection + +Anyone can ship a plugin, and a host can activate any combination — so when two +plugins both contribute into the same shared namespace, mob_dev must catch it at +build time rather than let one silently win on device. `cross_validate` (in +`MobDev.Plugin.Validator`) runs over the activated set and **fails the build** +when two plugins clash on any of: + +| Shared resource | Manifest field | +|--|--| +| Screen route | `screens.default_route` | +| Component atom | `ui_components.atom` | +| iOS native view key | `ui_components.ios.view_module` | +| Android native view key | `ui_components.android.composable` | +| Migration namespace | `migrations.repo_namespace` | +| NIF module | `nifs.module` | +| iOS Swift source basename | `ios.swift_files` | +| Android JNI source basename | `android.jni_source` | +| Android bridge class | `android.bridge_class` | +| iOS Info.plist key | `ios.plist_keys` | +| Supervised worker | `lifecycle.supervised` | +| Notification match | `notifications.handlers[].match` | + +A clash on any of these is a build error naming the resource, the value, and how +many plugins declared it. Resources that are *inherently* safe — settings (keyed +per-plugin), `plugin://` images (namespaced per-plugin), Android permissions / +iOS frameworks (set-unioned) — compose without a check. + +A note on what counts as a clash: the check is **cross-plugin**, so a single +plugin legitimately declaring the same value twice is fine — e.g. a +cross-platform NIF that ships one iOS (`lang: :objc`) and one Android +(`lang: :zig`) entry for the same `:module` is *not* a collision; two *different* +plugins claiming that module is. Detection only flags identical values, not +semantic overlap (two notification predicates that could both match the same +payload aren't comparable in general — keep matches disjoint). + +**Completeness guarantee.** Every field that lands in a shared namespace is +classified in `Validator.conflict_surface/0`, and a test (`conflict_surface_test`) +asserts that classification covers *every* merge gatherer. Adding a new +shared-resource field to the schema without classifying its conflict behavior +fails CI — so the guarantee that multiples compose can't silently rot as the +schema grows. A property-based fuzzer (`merge_fuzz_test`) additionally checks the +detection is sound and complete across random N-plugin combinations. + +### Runtime plugin manifest + +Tiers 3 and 4 are pure-Elixir and **runtime-wired**: the host needs to know, +while running, which screens / lifecycle hooks / settings / notification handlers +the activated plugins declared. mob_dev bakes that into a generated terms file, +`priv/generated/mob_plugins.exs`, which the core `Mob.Plugins` module reads once +at boot. It is **derived state, not hand-maintained** — `mix mob.deploy --native` +regenerates it from the activated plugins' current manifests on every build (you +can also run `mix mob.regen_plugin_manifest` directly, or `--check` it in CI). +Because it regenerates unconditionally, changing a plugin's tier-3/4 sections +can't ship a stale manifest. Tier-0/1/2 plugins contribute nothing to it. + +## Versioning and forward compatibility + +`:plugin_spec_version` is the escape hatch for evolving the schema +without breaking existing plugins. + +- Today: spec version 1. All examples above target spec 1. +- If the schema needs a breaking change (e.g., renaming `:ui_components` + to `:components`), bump to spec 2 and have mob_dev support both. +- Plugins declare which spec they target; mob_dev validates against + that spec; old plugins keep compiling unchanged. + +Bumping spec version means giving plugin authors a migration window +before deprecating the old spec. + +## Hot-push compatibility + +| Plugin tier | Hot-pushable? | Why | +|--|--|--| +| 0 (regular Hex pkg) | Yes | Pure Elixir; `.beam` ships via `mix mob.push` | +| 1 (NIFs) | No | Native code requires APK/IPA rebuild | +| 2 (visual component) | No | Same | +| 3 (multi-screen) | Partial — Elixir code in screens IS hot-pushable; native code IS NOT | +| 4 (sub-app) | Partial — same | + +The manifest validator computes `hot_pushable` automatically from +which sections are populated. Plugin docs should make this explicit +so users understand why some changes need a rebuild. + +## Why this design + +A few choices to flag: + +- **Manifest is data, not code.** The plugin doesn't `register_plugin` + at runtime; mob_dev reads the data at compile time. Static, + inspectable, validatable. Closer to `mix.exs`'s `project/0` than + to Phoenix's runtime route registration. +- **Two-step activation (deps + config).** Borrowed from how iOS + entitlements work — a framework supporting capability X doesn't + mean your app uses X; that requires explicit declaration. Mitigates + the supply-chain risk of silent permission merges. +- **Schema scales with tier, not exhaustive everywhere.** A tier-1 + plugin doesn't fill out `:lifecycle` or `:settings`. The schema + doesn't make small plugins look big. +- **Hex is the substrate.** Versioning, dep resolution, security + posture, hexdocs publication — all free. Local `path:` deps work + the same way for development. +- **Static-link required, no dlopen.** Mob's App-Store-compatible + build pins this. Plugins follow the same rule; the build embeds + plugin NIFs into the host's `libpigeon.so`. Restrictive vs. React + Native; necessary for App Store shipping. + +## Requirements raised by third-party UI-kit evaluation + +Evaluating whether an established web component library (Mishka +Chelekom — shadcn-style Phoenix/Tailwind generator) could be brought +to Mob surfaced two gaps in the current spec. Both are now resolved +(2026-05-27) — see `decisions/2026-05-27-pure-elixir-composite-tier.md` +and `decisions/2026-05-27-ui-kit-distribution-model.md`. The resolution +for each is noted inline below. + +### 1. Pure-Elixir composite components have no tier + +`:ui_components` (tier 2) assumes **native backing** — every entry +maps `tag`/`atom` → a SwiftUI `view_module` and an Android +`composable`. There is currently no slot for a **pure-Elixir +composite**: a tag that expands to a *built-in widget tree* with no +native code (e.g. `<MishkaCombobox/>` → `Column` + `TextField` + +`List`). This is the headline ask from any UI-kit author who doesn't +write Swift/Kotlin. + +What exists today: + +- **Tier 0** already gives function-call composites — + `def combobox(opts), do: ~MOB"..."` invoked via the sigil's + `{combobox(...)}` child slot. Pure Elixir, hot-pushable, ships as a + plain Hex package with no manifest. A UI kit can ship its + presentational + simple-interactive components this way **now**. +- What's missing is **tag syntax** (`<MishkaCombobox/>`) and a + manifest declaration for it. + +Reserved shape — a third form alongside the native one: + +```elixir +ui_components: [ + %{tag: "MishkaCombobox", atom: :mishka_combobox, + expand: {Mishka.Combobox, :expand}} # pure-Elixir, no :ios/:android — HONORED since 2026-06-11 +] +``` + +This implies a **third expansion pass in core**, run before +`Mob.List.expand` / `Mob.Component.expand` in `Mob.Screen.do_render/3` +(so a composite can itself emit `<List>` / `native_view` for the later +passes), recursing to a fixpoint with a depth guard. Because the pass +runs in the screen process and is handed the screen pid (like +`Mob.List.expand` is), it can **auto-inject event targets** — the +author writes `on_select="combo_select"` and the pass wires +`{screen_pid, :combo_select}`, removing the need to thread `self()` +through every component. Hot-pushable (pure Elixir; same rule as +tier 0). + +**Resolution (updated 2026-06-11):** the `expand:` field is **honored**. +`Mob.Composite` runs as the FIRST expansion pass in `Mob.Screen`'s +render pipeline (before `Mob.List.expand` / `Mob.Component.expand`, so a +composite may itself emit `<List>` or `Mob.UI.native_view`), recursing +to a fixpoint with a depth guard. The expander contract is +`expand(props, children, ctx)`; `on_*` props written as bare +strings/atoms are AUTO-INJECTED as `{screen_pid, tag}` — no `self()` +threading. Registration is the manifest `expand:` form (validated: +native backing XOR expand; expand-only plugins are hot-pushable) or +`Mob.Composite.register/2` at runtime for plain Hex kits with no +manifest. Tier-0 function composites (`{combobox(...)}`) remain fully +supported — the tag form is ergonomics on top. Worked example: +`mob_plugin_demo/plugins/mob_demo_kit` (<DemoCard>/<DemoCombobox>, +device-verified). The original deferral ("no core churn in Phase 1, +design against a concrete consumer") resolved when both conditions +flipped — see decisions/2026-06-11-composite-expansion-pass.md. +Known wart: the `~MOB` sigil warns once per call site for tags outside +its compile-time whitelist; composite tags compile fine through the +PascalCase→snake_case fallback. Follow-up: app-extendable tag list. + +### 2. Generator vs. dependency — distribution model + +Mishka-class kits are **shadcn-style generators**: a dev-only tool +(`mix mishka.ui.gen.component`, built on Igniter) that emits component +**source the user owns and edits** into their project; components are +free, the paid tier is templates + support, not components. The plugin +system here is **dependency-shaped** (Hex dep + two-step activation). + +These are different products. A faithful UI kit for Mob may be a +**generator** (`mix mob.gen.component`) rather than a plugin at all — +or the two coexist (a generator scaffolds owned source *from* a plugin +package). Decide which model a UI kit targets before committing a +vendor to it; it determines whether a kit is a plugin in the first +place, and it's the vendor's entire identity as a tool author. +(Igniter is shared ground — Mishka is built on it and Mob's build +migration is heading there — so the generator path is not foreign +territory.) + +**Resolution:** two lanes, kept separate. The **plugin (dependency) +lane** — Hex dep + two-step activation — is what this spec covers and +is in scope for the plugin epic; it's for native-backed, +capability-bearing, or centrally-maintained components. The +**generator lane** — `mix mob.gen.component`, Igniter-based, emitting +owned-source presentational components — is a separate tool tracked +with the Igniter build-migration work, **not** part of the plugin +epic. The two can coexist. For Mishka specifically, the faithful port +is the generator lane. + +## Future: full-language plugins + +This section parks an idea that's coherent but explicitly out of +scope for the current spec. Mob's lane is Elixir-first / BEAM-native +(see `plugin_extraction_plan.md` "Scope"). A determined plugin author +who wants to write entire screens in Python, Lua, JS, or any other +language-with-an-embedded-interpreter could in principle build that +on top of the plugin system — but the framework doesn't ship the +glue. + +### What would be needed + +A new manifest concept — a **screen dispatcher**: + +```elixir +%{ + name: :mob_python_app, + mob_version: "~> 0.6", + plugin_spec_version: 3, # speculative — not part of v2 + + requires: [:mob_pythonx], + + screen_dispatcher: %{ + kind: :python, + module: MobPythonApp.Dispatcher, + callbacks: [ + mount: 3, + render: 1, + handle_event: 3 + ] + } +} +``` + +A screen registered with `kind: :python` would route its lifecycle +callbacks through the dispatcher instead of expecting an Elixir +module. The dispatcher resolves them however it wants — calling +into the embedded Python interpreter, in this case. + +The user's authoring story would then look like: + +```python +# app/screens/home.py +import mob + +@mob.screen("home") +class HomeScreen: + def mount(self, params, session): + return {"count": 0} + + def render(self, assigns): + return mob.ui.column([ + mob.ui.text(f"Count: {assigns['count']}"), + mob.ui.button("Tap", on_tap=("incr", None)) + ]) + + def handle_event(self, name, _, assigns): + if name == "incr": + return {"count": assigns["count"] + 1} +``` + +### Why it's parked + +- **Lane discipline.** Mob's value rests on Elixir + BEAM + ergonomics. Diverting design effort into "Python frontends are + equally first-class" weakens the core lane without obviously + reaching parity with React Native / Flutter / native SDKs in their + own lanes. +- **The hooks are conceptually clear; the implementation is + bottomless.** Sketching the screen-dispatcher takes a paragraph. + Making it actually pleasant (debugging, hot-reload across the + language seam, error attribution, asset bundling, IDE support) is + multi-month framework work. Worth doing only if the demand is + clear. +- **The hybrid model captures the win without the cost.** Apps that + use Mob screens in Elixir but call into Rust (via `mob_rustler`) + or Python (via `mob_pythonx`) for specific concerns — ML, perf- + sensitive paths, scripting layers — get most of the benefit + without forcing the entire screen surface through an interpreter. + See `plugin_extraction_plan.md` "Scope" for the recommended + hybrid pattern. + +### What stays open + +- The plugin spec versioning leaves room. If a plugin author builds + a full Python (or Lua, JS, etc.) frontend on top of the current + spec, the framework can codify the screen-dispatcher concept in a + later spec bump without breaking anyone. +- The BEAM-native path is unaffected. Gleam, LFE, Hamler, or any + BEAM language can already author Mob screens today — Mob's API is + just BEAM modules, the sigil is the only Elixir-flavoured part. A + `mob_gleam` ergonomic-wrappers plugin is a perfectly reasonable + community project that needs no framework changes. + +The door stays open. Walking through it is on the ambitious +plugin author, not the framework. diff --git a/MOB_PLUGIN_SECURITY.md b/MOB_PLUGIN_SECURITY.md new file mode 100644 index 00000000..f9a816c7 --- /dev/null +++ b/MOB_PLUGIN_SECURITY.md @@ -0,0 +1,470 @@ +# Mob plugin security — trust model + +This doc covers how Mob handles the supply-chain risk introduced by +the plugin system (`MOB_PLUGINS.md`, `MOB_STYLES.md`). Plugins ship +native code that statically links into the host's process and runs +with the host's permissions. That's powerful — and same-process +trust requires a coherent vetting story. + +The model has three layers: + +1. **What Mob's build already prevents structurally** (no runtime + plugin loading, no `dlopen`, two-step opt-in). +2. **What the framework adds explicitly** (capability enforcement, + manifest signing, audit tooling, source-hash pinning). +3. **What's left to the ecosystem** (a curated allowlist, a concerns + feed, community vetting). + +This is design-stage. Implementation tasks track in +`plugin_extraction_plan.md` Phase 2. + +## Threat model — what we're defending against + +1. **Malicious plugin author.** Publishes a useful-looking plugin that + exfiltrates data, mines crypto, or escalates permissions. Same + threat as a malicious npm package. +2. **Compromised legitimate plugin.** A previously-trusted plugin's + maintainer credentials or repo gets compromised; a new release + smuggles in malicious code. The Solar Winds / event-stream model. +3. **Typosquatting.** `mob_blutooth` vs `mob_bluetooth`. User installs + the wrong one. +4. **Capability creep.** Plugin starts as "color palette helper," + later versions add network calls, file system writes, undeclared + NIFs. The shape of what the plugin does drifts after the user + trusted it. +5. **Transitive risk.** Plugin A depends on plugin B; B is malicious + but A's manifest looked fine. + +Not in scope: defending against the host app itself being malicious +(that's the OS's problem), or against the user explicitly granting +permissions they shouldn't (that's a UX problem, not a security one). + +## Layer 1 — structural protections (already in place) + +These come from the framework architecture, not the security layer +proper. They're listed here so the security model knows what it +doesn't have to re-solve. + +**No runtime plugin loading.** Mob plugins are merged at compile +time. There is no `Plugin.load(:url)` API. The host's binary is the +sum of declared + activated plugins at build time. + +**No `dlopen` of NIFs.** Mob's App Store / Play Store posture +requires statically linked NIFs. Plugin NIFs are physically embedded +in `libpigeon.so` (Android) or the iOS binary. We cannot execute +native code that wasn't compiled into the build. This eliminates a +huge class of post-install supply-chain attacks that JS plugin systems +face. + +**Manifest is data, not code.** Plugin manifests are `.exs` files +evaluated by mob_dev at compile time in a constrained context — they +must reduce to a plain map. A plugin cannot use its manifest +evaluation to `Code.eval` arbitrary code into the host's build. + +**Two-step opt-in.** `mix deps.get` installs but does not activate. +A silent dependency update cannot change the host's permissions, +gradle deps, or render tree. Activation requires explicit +`config :mob, :plugins, [...]` (or `:styles`). The diff is printed +at compile time so the user sees what merged. + +**Permission opt-in.** A plugin's declared permissions (Android +manifest entries, iOS plist keys) are merged only after the user +activates the plugin. Same gate as iOS entitlements. + +## Layer 2 — framework-provided vetting + +These are the additions the framework should provide to address the +threat model. + +### Capability enforcement at compile time + +The manifest declares what the plugin contributes (NIFs, permissions, +iOS frameworks, Gradle deps). The compile step should *refuse* to +merge anything not declared. + +Concretely: + +- A plugin that doesn't declare `:ios.frameworks` containing + `"CoreBluetooth"` cannot have its native code link against + `CoreBluetooth` symbols. The linker fails at build time, not at + runtime. +- A plugin that doesn't declare `:android.permissions` containing + `"android.permission.INTERNET"` cannot have its Kotlin/C code that + was discovered to reach for network resources slip through unnoticed. + The linker / dexer fails. +- A plugin that doesn't declare `:nifs` at all cannot ship a `.c` + file that gets compiled into `libpigeon.so`. The build only + compiles sources listed in `:nifs[*].native_dir`. + +The principle: **the manifest is the entire contract**. Anything the +plugin's source tries to do that isn't manifest-declared either fails +to link (preferred) or is flagged by `mix mob.audit_plugins`. + +### Manifest signing + +Each plugin manifest should be cryptographically signed by the +plugin's author. Mob_dev verifies the signature before activating. + +The signed envelope covers: + +- The manifest's contents (sha256 of the canonical encoding) +- A hash of every file path the manifest references (Swift, Kotlin, + C, Zig, plist keys, gradle deps) +- The plugin's `name`, `version`, `mob_version` + +A plugin's signature is bound to a public key the author registers +once with the mob project. First-install workflow: + +```bash +mix mob.trust_plugin mob_bluetooth +``` + +…prompts the user with the plugin's public-key fingerprint, the +maintainer's mob.dev profile URL (if any), and the manifest's +declared capabilities. User says yes / no. The fingerprint is +recorded in `mob.exs` so subsequent versions of the same plugin are +silently trusted — but a *different key signing the same plugin name* +is flagged as a key rotation event requiring re-confirmation. + +This catches threat 2 (compromised maintainer credentials republishing +under same name) — the signing key change is visible. It also catches +threat 4 (capability creep) — the new manifest needs to be re-trusted +when its declared capability set grows. + +### Source-hash pinning + +`mix.lock` already pins package versions. Extend to pin a sha256 of +the plugin's `priv/native/` tree, so a Hex package republished with +the same version number but altered native code (a Hex registry +compromise, or a malicious overwrite) is detected by mix. + +### `mix mob.audit_plugins` + +A new task that scans every activated plugin's Elixir + native source +for patterns Mob considers risky: + +- **Code-injection vectors:** `Code.eval_string`, `Code.eval_file`, + `:erlang.binary_to_term/2` with the safe-mode bit clear, dynamic + module-name construction in `apply/3`. +- **Undeclared FFI access:** any NIF call that doesn't appear in + the manifest's `:nifs` list, any iOS framework reference outside + the declared `:ios.frameworks`. +- **Undeclared I/O:** file system access outside the app sandbox, + network calls when `:permissions` doesn't include `INTERNET`, + process spawning (`System.cmd`, `Port.open`). +- **Anti-tamper sniffing:** code that checks for sandboxing, debugger + attachment, or unusual env vars — patterns more common in malware + than legitimate plugins. + +The task produces a report with per-finding severity. Some findings +are advisory (a legitimate plugin might legitimately use `:os.cmd`); +others should block activation by default and require explicit +opt-in. The default ruleset is conservative; the host app can declare +exemptions: + +```elixir +# mob.exs +config :mob, :plugin_audit, [ + exemptions: %{ + mob_chat_kit: [:network_calls], # known to use HTTP for API + mob_bluetooth: [:undeclared_ffi] # legitimate Core Bluetooth use + } +] +``` + +Exemptions are visible in the build output — no silent allow-listing. + +### Vetting status in `mix mob.plugins` + +Annotate each installed plugin with its current vetting state: + +``` +$ mix mob.plugins +mob_bluetooth 0.3.1 activated signed (key 9a3c…) audit ✓ +mob_chat_kit 1.0.0 activated signed (key f1e8…) audit ⚠ exemptions +mob_demo_xyz 0.1.0 installed unsigned audit ✗ blocking +``` + +States: + +- **unsigned** — plugin has no signature. Allowed for `path:` deps + (local dev) but warned for Hex deps. User can choose to trust + manually. +- **signed** — manifest signature verifies. Key fingerprint shown. +- **trusted** — plugin's key has been explicitly trusted via + `mix mob.trust_plugin`. Subsequent updates with same key pass + silently. +- **audit ✓ / ⚠ / ✗** — outcome of `mix mob.audit_plugins`. + ✗ blocks activation by default. + +## Development mode — author your own without fighting the framework + +Security that gets in the way of plugin authors is security that +gets globally disabled. The framework provides explicit modes so +iteration on your own (or a forked) plugin is friction-free, and the +production path is loud but never blocked. + +### The three modes + +```elixir +# mob.exs + +# Default — production-grade. All activated plugins must be signed +# and pass the audit. Path deps and git refs require per-plugin +# exemptions below. +config :mob, :plugin_security, :strict + +# Permissive — same checks run; findings warn instead of block. +# For evaluating new plugins before committing trust. +config :mob, :plugin_security, :permissive + +# Dev — path deps and git refs accepted unsigned. Audit still +# reports but never blocks. Prints a per-build banner so the state +# is never forgotten. +config :mob, :plugin_security, :dev +``` + +### Per-plugin escape hatches, available in any mode + +```elixir +config :mob, :unsafe_plugins, [ + {:my_wip_plugin, allow: [:unsigned]}, + {:friend_fork_of_thing, allow: [:unsigned, :git_ref]}, + {:experimental_thing, allow: [:undeclared_network]} +] +``` + +Per-plugin is the more honest interface: you list which packages get +which exemptions and why (the inline comment is the "why"). Global +`:dev` is a convenience for the case where everything is local. + +`:unsafe_plugins` works in any security mode — it's how you say "yes, +I know this one specific plugin is unsigned, I'm fine with that, here's +why in a comment." Reviewers see the list in code review. + +### Git refs + +A plugin pulled via `{:plugin_x, git: "github.com/y/z", ref: "..."}` +is treated as unsigned by default. Git refs are accepted in `:dev` +mode without further configuration; in `:strict` / `:permissive` they +need `allow: [:git_ref]` in `:unsafe_plugins`. This catches the +typosquat-by-fork pattern (`yourorg/popular-plugin` vs +`y0urorg/popular-plugin`) — you can still use the fork, you just have +to acknowledge it explicitly. + +### Building a release with unverified plugins + +This is the core philosophical point. **You can do it.** The framework +cannot tell the difference between "developer shipping their own +hand-written plugin" and "developer shipping an unvetted third-party +plugin." Both are open-source — you're allowed to ship either. + +What the framework does instead is **bang gongs loudly**: + +1. **A persistent banner on every build** (debug AND release) listing + every plugin that's unsigned, unaudited, or git-ref'd. The banner + does not go away until those plugins are signed or removed. + +2. **Release builds add a one-time acknowledgement requirement.** + The first time you build a release with unverified plugins, mob_dev + prints the banner and refuses to proceed. To proceed, add to + `mob.exs`: + + ```elixir + # I have personally reviewed the unverified plugins listed above. + # They are either my own code, a fork I maintain, or a third-party + # plugin I've read end-to-end. I accept responsibility for any + # security implications. + config :mob, :acknowledge_unsafe_plugins, true + ``` + + That config line lives in committed source. Reviewers see it. + `mix mob.audit_plugins` calls it out. The acknowledgement doesn't + make the banner stop — it just unblocks the build. + +3. **Acknowledgement is global, not per-plugin.** Adding it means + "yes, I've reviewed all of these." Re-adding a new unverified + plugin doesn't auto-extend the acknowledgement; the build refuses + again until the user re-acknowledges, which forces them to + re-read the list. + +4. **`mix mob.audit_plugins` continues to print findings.** Even + acknowledged, even in dev mode, the audit task runs and reports. + The user can read the findings; the framework doesn't suppress + them. + +The framework treats this like a seatbelt. We tell you you should +wear one. We make it really clear when you're not. We don't lock the +ignition. + +### Why not a CLI flag? + +Because CLI flags vanish after the build. `mix release --i-know-best` +is invisible after the fact — a reviewer reading the repo can't tell +the release was built with reduced trust. Committed config is the +durable record: the codebase itself shows the decision. + +### Why not a hard block in prod? + +Two reasons: + +- **Self-hosted plugins are legitimate.** A developer authoring their + own plugin to extract a feature out of core has a perfectly valid + reason to ship a release with an "unsigned" (unpublished) plugin. + Refusing this is paternalistic. +- **Forks are legitimate.** A developer fixing a bug in a third-party + plugin and shipping a release from their fork is doing the right + thing — that's how open source moves forward. Refusing this blocks + the patch path. + +We're trying to be the home of the hackers. Hackers know what they're +doing; they just need to be reminded loudly when they're stepping +outside the well-lit path. + +### What the prod-build banner looks like + +``` +========================================================================= +[mob] Plugin trust report for release build +========================================================================= + + Signed + audited: + mob_bluetooth 0.3.1 trusted key 9a3c… + mob_camera 0.2.0 trusted key f1e8… + + Unverified — proceeding because :acknowledge_unsafe_plugins is set: + my_wip_plugin path:plugins/my_wip_plugin (unsigned) + friend_fork_of_thing git:github.com/x/y#branch (unsigned, git_ref) + + These plugins ship with the release. Their behavior is your + responsibility. See MOB_PLUGIN_SECURITY.md for the trust model. + +========================================================================= +``` + +The banner is unavoidable. It appears in every build's output, in CI +logs, in the developer's terminal. Anyone who looks at the build log +sees exactly what shipped and on what trust basis. Loud and visible +is the substitute for restrictive. + +## Layer 3 — ecosystem + +What the framework can't unilaterally provide; needs community +infrastructure. + +### Curated allowlist + +The Mob project (or a community maintainer) curates a list of +"mob-vetted" plugins — plugins that have been read, the author known, +the manifest reviewed. Lives at `https://mob.dev/plugins-vetted.json` +(or wherever) and is fetched by `mix mob.doctor` once a week, +cached locally. + +`mix mob.plugins` shows the vetted status: + +``` +mob_bluetooth 0.3.1 activated signed audit ✓ vetted (2026-03) +mob_random_xyz 0.1.0 activated signed audit ✓ not vetted +``` + +Not vetted ≠ bad. It just means nobody from the Mob project has +reviewed it. Users decide what threshold matters. + +### Concerns feed + +A separate feed at `https://mob.dev/plugin-concerns.json` reporting: + +- Known CVEs in specific plugin versions +- Maintainer ownership changes +- Plugins removed for malicious behavior +- Recommended upgrade paths + +`mix mob.doctor` and `mix mob.audit_plugins` both consult this +feed and surface concerns in their output. Same model as `npm audit` ++ the GitHub advisory database. + +### Reputation signals (downstream of curation) + +For each plugin, `mix mob.plugins --verbose` can show: + +- Hex download count (last 30 days) +- Time since last release +- Number of open issues / mean time to close +- Whether the package's GitHub repo is archived +- Maintainer's other published packages + +These don't decide trust on their own — they're context. Pair with +the curated list for the actual call. + +## Putting the layers together + +A user activating a new plugin walks through: + +1. `mix mob.add_plugin mob_bluetooth` + - Hex resolves + downloads the package. + - Mob verifies the manifest signature (Layer 2). + - Mob checks the audit ruleset (Layer 2). + - Mob checks the curated allowlist (Layer 3). + - Mob checks the concerns feed (Layer 3). +2. Mob prints a one-screen summary: + + ``` + mob_bluetooth 0.3.1 + Author: alice@example.com (key 9a3c…12ef) + Capabilities: BLUETOOTH_CONNECT, BLUETOOTH_SCAN, CoreBluetooth + Audit: ✓ no findings + Vetted: yes (reviewed 2026-03-12) + Concerns: none + + Activate? [y/N] + ``` + +3. User confirms; mob_dev merges contributions, prints the resulting + permission diff to `AndroidManifest.xml` and `Info.plist`. + +A plugin failing any layer can still be activated — but the user has +to add the per-plugin entry to `:unsafe_plugins` (see the +"Development mode" section above) so the decision is visible in +committed code. + +## Phasing the implementation + +Per `plugin_extraction_plan.md` Phase 2: + +1. **First (blocks Phase 3):** capability enforcement at compile + time, manifest signing format, `mix mob.audit_plugins` with the + default ruleset. These are framework-internal and have to be + stable before real extractions ship. +2. **Second (parallel with Phase 3):** the curated allowlist + infrastructure, concerns feed, reputation signals. Can iterate. +3. **Third (post-extractions, ongoing):** trust-key rotation policy, + plugin author guides ("how to publish a vetted plugin"), + periodic re-audit of vetted plugins. + +The order matters because waves of extraction shouldn't happen until +the *signed manifest* format and the *audit ruleset* are stable — +otherwise we're shipping plugins without the chain of custody we want +end users to rely on. + +## What we explicitly don't promise + +- **Not a sandbox.** Plugins run in-process with full BEAM/native + access. We can't isolate them at runtime the way browser extensions + are isolated. The protection is compile-time (no surprises in the + binary), not runtime. +- **Not a gatekept registry.** Anyone can publish a `mob_*` Hex + package. The curated allowlist is opt-in trust, not gatekept entry. +- **Not protection against the developer themselves.** Releasing with + unverified plugins is allowed — it's open source, you're allowed to + ship your own code. The framework makes the situation visible, not + impossible. The seatbelt model: we tell you you should wear one, + we make it really clear when you're not, we don't lock the + ignition. See "Development mode" above for the mechanics. + +The goal is "informed consent at activation time, structural +prevention of post-install drift, persistent visible warnings when +the user steps outside the well-lit path" — not unbreakable +sandboxing, not paternalistic refusal to build. + +This framework is meant for hackers. Hackers are smart enough to read +the warnings and decide for themselves. diff --git a/MOB_STYLES.md b/MOB_STYLES.md new file mode 100644 index 00000000..c3218ee7 --- /dev/null +++ b/MOB_STYLES.md @@ -0,0 +1,492 @@ +# Mob styles — manifest schema + +Mob styles are Hex packages that ship a coherent visual identity — a +palette of theme tokens plus per-component native renderers that +implement that look. They're packaged like plugins (Hex, manifest, +compile-time merge) but operate on a different axis: instead of +**adding** capabilities to an app, they **substitute** the look of the +built-in primitives. + +Examples: `mob_m3` (Material Design 3), `mob_cupertino` (Apple HIG), +`mob_liquid_glass` (the depth-and-blur look), `mob_rn_compat` (React +Native default look). Any number of these can be installed +simultaneously, and the app picks which to use — per app, per screen, +or per element. + +This doc covers: + +- The relationship to `MOB_PLUGINS.md` (sibling, separate API surface) +- The cascade model — how the active style is resolved at render time +- The manifest schema, annotated with concrete examples +- The native dispatch table — how iOS/Android route to the right view +- The prop contract — what each baseline primitive exposes that styles + must support +- Validation + compatibility rules + +For the surrounding ecosystem (Hex packaging, mob_dev compile-step +internals, hot-push compatibility), see `MOB_PLUGINS.md` — the +infrastructure is shared. + +## Implementation status (2026-06-11) + +The **tokens-only tier is IMPLEMENTED and device-verified**: the +four-field `priv/mob_style.exs` manifest (loaded + validated by +`MobDev.Style`), activation via `config :mob, :styles` + +`config :mob, :default_style` in `mob.exs`, the styles riding the plugin +runtime manifest, and core applying the default style's theme at boot +(`Mob.Plugins.apply_default_style/0`; a misconfigured style fails the +BUILD, a broken theme module logs and renders baseline). First package: +`mob_themes` (Obsidian/ObsidianGlass/Citrus/Birch/Material3). + +**NOT yet implemented** (the mob_m3 tier): the cascade, per-element +`style:` props, the `_style` node field, and the namespaced native +dispatch table — everything from "The cascade" onward describes design, +not shipped behavior. Precedence note learned in practice: +`:default_style` is a DEFAULT — app code calling `Mob.Theme.set/1` +(e.g. restoring a persisted user choice) outranks it, so hosts should +only override when the user explicitly chose. + +## Why a separate surface from `MOB_PLUGINS.md` + +Plugins **add**. Styles **substitute**. The two have different +activation semantics, validation rules, and override mechanics: + +| | Plugins (`MOB_PLUGINS.md`) | Styles (this doc) | +|--|--|--| +| Operates on | App capabilities | Visual identity | +| Activation | `config :mob, :plugins, [:a, :b]` (list, additive) | `config :mob, :styles, [:a, :b]` + `:default_style` | +| Naming | Adds new tags like `<Chart>` | Overrides built-in tags like `<Toggle>` | +| Multiple active | Independent — stack freely | Coexist — disambiguated by package name | +| Per-element use | N/A | `<Toggle style={:mob_m3} />` | +| Manifest file | `priv/mob_plugin.exs` | `priv/mob_style.exs` | + +The infrastructure underneath (Hex resolution, native-code merge, +`mob_version` constraint, hot-push computation, validator) is shared. +A single package can ship both manifests if it wants to (e.g., a +"Material 3 + Material Icons" package contributing both a style and an +icon-set capability). + +## The cascade + +At render time, each node resolves to **one active style** (or to the +neutral baseline if none applies). The resolution order, highest +precedence first: + +1. **Per-element prop** — `<Toggle style={:mob_cupertino} />` wins for + that node only. +2. **Nearest ancestor's `style:` prop** — `<Screen style={:mob_m3}> … + </Screen>` applies to all descendants that don't override. +3. **`config :mob, :default_style`** — the app-wide default, set in + `mob.exs`. +4. **Built-in neutral baseline** — when no style is active. Generic + prop-driven primitives with neutral defaults. + +The cascade is computed in Elixir before the render tree ships to the +native side, so each node arrives at the renderer with its effective +style already attached (a `_style` field on the serialized node). The +native dispatch table is a flat lookup: `(style_name, atom) → view`. + +## Multiple installed + cherry-pick + +The motivating tension: if styles were exclusive (one slot), installing +`mob_m3` would force every component to use M3's choices — including +M3's `<Picker>`, even if the developer prefers the Cupertino picker +shipped by a different style. + +The fix is namespacing the native registry by package name. With both +`mob_m3` and `mob_cupertino` installed: + +```elixir +# mob.exs +config :mob, :styles, [:mob_m3, :mob_cupertino] +config :mob, :default_style, :mob_m3 +``` + +The app-wide look is M3, but a developer can opt into Cupertino at any +scope: + +```elixir +~MOB""" +<Column> + <Button text="M3 button" /> {/* uses :mob_m3 */} + <Toggle style={:mob_cupertino} checked={...}/> {/* opts into :mob_cupertino */} + + <Section style={:mob_cupertino}> + <Picker .../> {/* picks up Cupertino picker */} + <Slider .../> {/* Cupertino slider — inherited from Section */} + </Section> +</Column> +""" +``` + +Cherry-picking is per-prop, not per-package — you can mix freely. The +package-name namespace prevents "piggy-backed component" conflicts +because both styles can coexist in the native dispatch table without +shadowing each other. + +## The neutral baseline (no style activated) + +If `config :mob, :default_style` is nil/unset and no node uses a +`style:` prop, the renderer falls through to the built-in neutral +baseline. This path: + +- Uses `Mob.Theme.default()` — neutral grays, sane spacing, no + Material/Cupertino opinion. +- Renders each component via its baseline native view (`MobToggle`, + `MobTextField`, `MobButton` etc.) which is prop-driven enough that a + developer can hand-style anything via per-component props: + +```elixir +~MOB""" +<Button background={0xFF336699} text_color={0xFFFFFFFF} + corner_radius={8} padding={:space_md} text="Custom"> +""" +``` + +The neutral baseline is the no-dependencies starting point. A +developer who wants total control bypasses styles entirely and +hand-encodes each surface — the per-component prop surface is the +escape hatch. + +## Minimum viable manifest + +```elixir +# priv/mob_style.exs +%{ + name: :mob_m3, + mob_version: "~> 0.6", + style_spec_version: 1, + description: "Material Design 3 (Material You)", + + # Theme struct module. Provides color / spacing / radius / type-scale + # tokens consumed by every component when this style is active. + theme: Mob.Theme.Material3 +} +``` + +Four required fields. A style this small means "tokens only, no +per-component native overrides" — useful for repalette-only styles +(e.g., a brand pack that swaps colors but keeps shapes). The baseline +native primitives are used for rendering. + +## Tier — tokens + native overrides + +The canonical case: a style ships its theme struct **and** custom +native views for each primitive it wants to restyle visually. + +```elixir +%{ + name: :mob_m3, + mob_version: "~> 0.6", + style_spec_version: 1, + description: "Material Design 3 (Material You)", + + theme: Mob.Theme.Material3, + + # Per-component native overrides. Each entry maps a built-in + # primitive atom to a platform-specific view. mob_dev's compile + # step adds them to the renderer's dispatch table under the key + # `<style_name>:<atom>` — so :mob_m3's toggle is registered as + # "mob_m3:toggle", not "toggle". + component_views: [ + %{ + atom: :toggle, + ios: %{view_module: "MobM3Toggle"}, + android: %{composable: "MobM3Toggle"} + }, + %{ + atom: :text_field, + ios: %{view_module: "MobM3TextField"}, + android: %{composable: "MobM3TextField"} + }, + %{ + atom: :button, + ios: %{view_module: "MobM3Button"}, + android: %{composable: "MobM3Button"} + } + ], + + # Native sources to compile and link. Same shape as the plugin + # manifest's :ios / :android sections. + ios: %{ + swift_files: [ + "priv/native/ios/MobM3Toggle.swift", + "priv/native/ios/MobM3TextField.swift", + "priv/native/ios/MobM3Button.swift" + ] + }, + android: %{ + composable_files: [ + "priv/native/android/MobM3Toggle.kt", + "priv/native/android/MobM3TextField.kt", + "priv/native/android/MobM3Button.kt" + ] + } +} +``` + +A style can override any subset of primitives — `mob_m3` might +override `Toggle` and `TextField` but use the baseline `Button` if it's +visually close enough. The renderer falls through to the baseline view +for any primitive the active style doesn't declare. + +## Install + activation flow + +Two-step opt-in, mirroring plugins. + +### Step 1 — install + +```elixir +# mix.exs +defp deps do + [ + {:mob, "~> 0.6"}, + {:mob_m3, "~> 0.1"}, + {:mob_cupertino, "~> 0.1"} + ] +end +``` + +```bash +mix deps.get +``` + +After this, `mix mob.styles` lists both as **installed but not +activated**. The native code is NOT merged. The renderer doesn't know +about them. + +### Step 2 — activation in `mob.exs` + +```elixir +# mob.exs +config :mob, :styles, [:mob_m3, :mob_cupertino] +config :mob, :default_style, :mob_m3 +``` + +Now mob_dev's compile step: +- Adds each style's native sources to the iOS/Android build +- Registers each style's `component_views` in the renderer dispatch + table under `<style_name>:<atom>` +- Makes `:default_style` the fallback when a node has no `style:` prop + and no styled ancestor + +If a style is in `deps` but not in `config :mob, :styles`, compile +warns: + +``` +[mob] :mob_cupertino is installed but not activated. Add it to + `config :mob, :styles` in mob.exs to enable, then set + `config :mob, :default_style` to make it the app-wide default. +``` + +### Convenience — `mix mob.add_style <name>` + +```bash +mix mob.add_style mob_m3 # adds to deps + :styles +mix mob.set_default_style mob_m3 # sets :default_style +``` + +Standard flow always works; the convenience tasks are not required. + +## Native dispatch + +The renderer keeps a flat dispatch table keyed by `(style_name, atom)`: + +``` +("mob_m3", :toggle) -> MobM3Toggle +("mob_m3", :text_field) -> MobM3TextField +("mob_cupertino", :toggle) -> MobCupertinoToggle +(<baseline>, :toggle) -> MobToggle # always present +(<baseline>, :text_field) -> MobTextField # always present +``` + +`<baseline>` is the framework's built-in fallback row, populated at +compile time regardless of which styles are active. Every primitive +has a baseline row, so the renderer always has somewhere to dispatch +when no style applies. + +At render time: + +1. Elixir-side `Mob.Renderer.prepare/4` computes the effective style + for each node (per-element prop → ancestor → default). +2. The node serializes with an effective `_style` field + (string — `"mob_m3"` or `nil` for baseline). +3. The native code reads `_style` + `atom` and dispatches: + +```swift +// iOS pseudocode +let key = (node.styleName, node.nodeType) +let view = componentViews[key] ?? baselineViews[node.nodeType]! +``` + +```kotlin +// Android pseudocode +val view = componentViews[node.styleName to node.atom] + ?: baselineViews[node.atom]!! +``` + +The fallback to baseline handles two cases cleanly: (a) a style that +doesn't override the primitive in question, (b) a node with no style +attached. + +## The prop contract + +Every primitive — baseline and style-provided — implements the same +**prop contract** for that component. The contract is the framework's +API surface; bumping it is breaking-change territory. + +For `Toggle`, the v1 prop contract is roughly: + +``` +checked : bool — current on/off state +on_change : handle — fired on toggle +text : string? — optional embedded label +track_on_color : color — track fill when on +track_off_color : color — track fill when off +thumb_color : color — thumb fill (both states by default) +thumb_size : dp — thumb diameter +track_width : dp — overall track width (auto if nil) +animation_ms : int — transition duration +accessibility_id : string? — for Mob.Test +``` + +A baseline `MobToggle` consumes these with neutral defaults (gray +track, white thumb, 250ms animation). A `MobM3Toggle` consumes the +same props with M3 defaults (primary-colored track, specific thumb +size from M3 spec, 200ms animation curve from M3 motion spec). The +contract is identical; only the visual defaults differ. + +**This is what makes per-component overrides work without escape +hatches.** The user can write: + +```elixir +~MOB""" +<Toggle checked={@val} thumb_color={:tertiary} animation_ms={400} /> +""" +``` + +…and it works whether `:default_style` is `nil`, `:mob_m3`, or +`:mob_cupertino`. Every style-provided primitive accepts the full +contract; the user can hand-tune any prop on top of any style. + +Each component's contract lives in `lib/mob/ui.ex` as the component's +`@props` attribute and is enforced by `mix mob.validate_style` against +the manifest's declared overrides. New props are additive; +removed/renamed props bump `style_spec_version`. + +## User-app inline overrides + +A user can ship their own component view without authoring a Hex +package. Drop the Swift/Kotlin file in the app's `ios/` or +`android/app/src/main/java/.../` directory and register it in +`mob.exs`: + +```elixir +# mob.exs +config :mob, :component_views, %{ + toggle: %{ios: "MyApp.CustomToggle", android: "MyApp.CustomToggle"} +} +``` + +Mob treats this as an unnamed inline style — the user's overrides +take precedence over both `:default_style` and any inherited +`style:` prop, but per-element `style:` props still win. Think of it +as a `style: :user` slot that's always implicit and always last. + +Same mechanism handles "I activated `:mob_m3` but want one specific +behavior different" — override the relevant slot in +`:component_views` and your app keeps M3 everywhere else. + +## Schema reference + +Top-level required: + +- `:name` — atom matching the package name. Convention: `mob_` prefix. +- `:mob_version` — string, semver requirement (`"~> 0.6"`). +- `:style_spec_version` — integer. Current: `1`. Independent of + `:plugin_spec_version`. + +Top-level optional: + +- `:description` — short string for `mix mob.styles` output. +- `:theme` — module name. Required if the style provides tokens (almost + always true). Module must export `theme/0` returning a `%Mob.Theme{}` + struct. + +Component overrides (any combination): + +- `:component_views` — list of override maps. Each entry: + - `:atom` — built-in primitive atom (`:toggle`, `:text_field`, etc.) + - `:ios` — `%{view_module: "ClassName"}` (SwiftUI View struct) + - `:android` — `%{composable: "FunctionName"}` (@Composable Kotlin function) + +Native sections (mirroring plugin manifest): + +- `:ios` — `%{swift_files, frameworks, min_version}` +- `:android` — `%{composable_files, gradle_deps, min_sdk}` + +A style can omit `:ios` or `:android` if it's platform-specific (warns, +doesn't error — same UX as plugins). + +## Validation rules + +`mix mob.validate_style` (run from a style project) checks: + +- Required top-level fields present +- `theme:` module exports `theme/0` returning a `%Mob.Theme{}` +- Every `component_views` `:atom` is a known baseline primitive +- Every file path referenced exists and parses +- Native views consume the full prop contract for their primitive + +Compile-time validation (run by mob_dev when activating styles): + +- Every style in `config :mob, :styles` is present in `deps` +- `:default_style` (if set) is in `:styles` +- `mob_version` requirement satisfied by installed mob +- No two styles with the same `:name` (Hex prevents this anyway) + +Conflicts between styles are **not** validation errors — that's the +point of the namespace. Two styles can both override `:toggle`; the +renderer disambiguates at dispatch time. + +## Versioning and forward compatibility + +`:style_spec_version` is independent of `:plugin_spec_version`. The +prop contract per primitive is also versioned — bumping a +component's prop contract version is a breaking change for every +style that overrides it. + +`mix mob.styles` shows the spec version + per-component contract +versions a style targets and which it would be incompatible with. + +## Hot-push compatibility + +Styles override native code, so adding or changing a style requires a +native rebuild. They are **not hot-pushable**. Style swaps via the +`style:` prop at runtime ARE possible (the renderer respects the prop +on every render), so an app can dynamically theme itself across +already-compiled styles without rebuilding. + +## Why this design + +Choices worth flagging: + +- **Plural + namespaced, not exclusive.** Earlier draft assumed one + active style; that traps developers when one style ships a great + toggle and another ships a great picker. Namespacing by package + name + cherry-picking per element is what makes both available. +- **Cascade in Elixir, dispatch in native.** Style resolution + (per-element → ancestor → default) is centralised in + `Mob.Renderer.prepare/4`. The native side just looks up + `(style_name, atom)` in a flat table. Keeps native code dumb. +- **Prop contract is the framework's API.** Every style implements the + same contract per primitive, so per-element prop overrides work + uniformly across styles. New props are additive; removals are + breaking. +- **User-app inline overrides via `config :mob, :component_views`.** + Same mechanism as plugins, lower barrier — drop a Swift file in your + app, point at it, done. Style packages and inline overrides share + one code path; styles are just the published, versioned form. +- **Separate manifest from plugins.** Same Hex/build infrastructure, + different conceptual axis. Readers of a `mob_style.exs` shouldn't + have to mentally filter out plugin fields and vice versa. diff --git a/PLAN.md b/PLAN.md index 39bdcb42..67766848 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2252,3 +2252,169 @@ If batch 5+ benchmarks show meaningful overhead, add per-category enable so subscribers only register OS observers they actually use. For batches 1–4 this isn't worth the API surface — the cost is dominated by the OS firing the notification, which happens regardless of whether we observe. + +--- + +## CI & integration testing + +### Status quo (2026-05-15) + +- **mob**: `.github/workflows/onboarding.yml` exists. Runs the `test/onboarding/` + suite (generator tests + simulator/emulator-driven device tests) on push to + main, PRs touching `lib/**` / `priv/templates/**` / `test/onboarding/**`, + and nightly cron. **Does not run the plain `mix test` suite.** Local + developer-run only. +- **mob_dev**: no CI. ~1,338 tests run locally only. +- **mob_new**: no CI. ~237 tests run locally only. + +### The gap this session surfaced + +A round-trip deploy of `mandelbrot_demo` to the iPhone + Android emulator +caught **five latent bugs across two recently-merged PRs** in one pass: + + * `mob#9` (Bluetooth Classic, HeroesLament) — `enif_make_list` not bound in + `mob_erts.zig`; Android arm64 link failure. + * `mob_new#4` (BT templates, HeroesLament) — missing `}` before the BT JNI + thunks; every subsequent `JNIEXPORT void JNICALL` rejected by clang. + * `mob_new#4` — duplicate Kotlin imports (`IntentFilter`, + `ConcurrentHashMap`, `AtomicInteger`); kotlinc "Conflicting import". + * GpuView Android template — missing `androidx.compose.foundation.layout.fillMaxSize` + import; kotlinc unresolved reference. + * GpuView Android template — orphan comment in the import block tripped + ktlint's `import-ordering` rule. + +All five passed `mix test` and `mix credo --strict` on both repos. They only +surface when the toolchain actually runs — and currently the toolchain only +runs on a developer's laptop during a manual `mix mob.deploy`. By the time +five things had piled up, the diff to bisect was non-trivial. + +The pattern: **mob's test suite intentionally doesn't compile native code** +(Zig / Kotlin / C). Generator tests render templates and grep for strings, +which catches refactor drift but not syntactic regressions. + +### Three-layer plan + +#### Layer 1: per-repo unit-test CI (≈1–2 hours total) + +Add a `.github/workflows/test.yml` to each of `mob`, `mob_dev`, `mob_new` +that runs on push and PR: + +```yaml +- erlef/setup-beam@v1 # OTP + Elixir +- mix deps.get +- mix compile --warnings-as-errors +- mix format --check-formatted +- mix credo --strict +- mix test +- (mob only) mix erlfmt --check src/ +- (mob only) xcrun clang-format --dry-run -Werror … +``` + +Sharing details: +- `actions/cache` on `deps/` and `_build/test/` keyed by `mix.lock`. +- Run on `ubuntu-latest` for everything except the iOS/Swift bits (which + need macOS); the existing onboarding workflow already pays the macos-15 + premium for full device tests — we don't need to for the Elixir suite. +- mob_new tests do `mix phx.new lv_test` under the hood (40+ sec/run); CI + time ~3 minutes per run. Acceptable. + +This catches: every regression the local `mix test` would catch, plus +contributors who don't run the formatters / credo locally. + +Does **not** catch the 5 bugs above — those needed an actual compile. + +#### Layer 2: native-build smoke test (≈4–8 hours) + +A separate job that runs less frequently (PR only or nightly cron) and +actually compiles the generated project: + +```yaml +# After test.yml passes: +- mix mob.new ci_smoke --local +- mix mob.install +- cd ci_smoke && mix mob.deploy --native --android --device emulator-XXXX +- # Boot Android emulator via reactivecircus/android-emulator-runner +- # Use mob.connect + Mob.Test.screen/1 to assert the home screen mounts +``` + +This catches the Bluetooth / GpuView class of bug because Gradle / kotlinc / +zig actually run. **Costs roughly 10 minutes per run** (Android emulator +boot is the dominant cost) — too expensive for every push, but worth +running on PR to `master` and nightly. + +The existing onboarding workflow's `with-devices` job is structurally close +to this; could be extended rather than building from scratch. + +#### Layer 3: behavioural integration (already partial) + +`test/onboarding/failure_modes_test.exs` + the `with-devices` matrix +already exercise multi-device deploys against a real simulator/emulator. +The current scope is install/deploy/doctor — not per-component rendering +behaviour. + +Future work: add Mandelbrot-style "render this thing, screenshot it, +assert the pixel-hash matches a baseline" tests for each native component +(`<CameraPreview>`, `<WebView>`, `<GpuView>`, `<Canvas>`). Mostly a +question of writing a baseline harness; the screenshot+assert infrastructure +exists in `Mob.Test`. Probably 1–2 days for the first three components, +then ~1 hour per additional component. + +### Effort summary + +| Layer | Effort | Coverage | Priority | +|---|---|---|---| +| 1 — unit-test CI on 3 repos | 1–2 hours | Elixir-level regressions, formatter drift | **High** — biggest signal-per-hour win | +| 2 — native-build smoke (Android + iOS) | 4–8 hours | Native compile bugs (this session's 5) | Medium — recurring source of "merged but broken" | +| 3 — per-component screenshot diffs | 1–2 days | Renderer / native-bridge regressions | Lower — pays off once Layer 2 exists | + +### Open questions before starting + +- **Required vs informational checks?** Layer 1 should probably block PR + merge. Layer 2 cycle time (~10 min) makes it borderline; might be + "informational" with a clear failure summary in the PR. +- **Where do mob_dev / mob_new tests run for cost?** Both can stay on + ubuntu-latest; the only macOS-required bits are iOS simulator + Xcode + toolchain, which Layer 2 needs. +- **Caching strategy.** `deps/` is straightforward. `_build/test/` for + Elixir is cheap to recompute (~30s) so cache is nice but not essential. + Android SDK download is slow (~2 min cold); cache that aggressively in + Layer 2. + +## MobBridge.kt / MobBridge.swift duplication (drift hazard) + +Today each Mob app carries its own copy of `MobBridge.kt` (and the iOS +equivalent). They're scaffolded once and then diverge — `nxeigen_probe`'s +is 3068 lines; `mob_lv_test`'s is 1657. When Mob adds a feature that +needs Kotlin support (e.g., the camera frame stream wiring earlier this +month) every app has to be patched independently. When a Kotlin-side bug +is fixed in one app (e.g., the canvas viewport-scaling fix that landed +in nxeigen_probe — see `Mob.Canvas` `@moduledoc` and +`guides/troubleshooting.md`) the fix doesn't propagate. + +This is sustainable while there are ~2 Mob apps. It will become a real +problem at ~10. + +**Options:** + +* **Ship MobBridge as an AAR / Swift package** the apps depend on. + Per-app `MobBridge.kt` becomes a thin shim that just registers + app-specific things (the app's package name for JNI, app-specific + intent filters, etc.). The bulk of the renderer / UI / Compose code + is library-managed. +* **Generate MobBridge from a single Elixir source** during + `mix mob.deploy --native`. Like Phoenix's `mix phx.gen.*`, but as + a regenerate-on-every-build step rather than a one-shot scaffold. + Apps wouldn't edit MobBridge by hand at all. +* **Status quo with strong cross-app diff tooling** — a + `mix mob.audit_bridge` task that diffs all known MobBridge.kt's and + flags drift. Cheap to implement but doesn't fix the root cause. + +The AAR / Swift-package path is cleanest but has a real engineering +cost (Compose-in-library packaging on Android is finicky; SwiftPM +target setup is finicky). The generator path is the smallest +incremental change from today's scaffold. + +**First known bug caused by this duplication:** Canvas viewport +scaling (pixel-vs-logical-units). See `Mob.Canvas` `@moduledoc` +section "Implementing the renderer" for the per-app fix recipe; the +duplication issue is the meta-problem. diff --git a/README.md b/README.md index 2bf08d87..3df4ffb4 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,27 @@ # Mob +<img src="https://raw.githubusercontent.com/GenericJam/mob/master/logo.svg" width="15%" alt="Mob Logo"> + BEAM-on-device mobile framework for Elixir. OTP runs inside your iOS and Android apps — embedded directly in the app bundle, no server required. Screens are GenServers; the UI is rendered by Compose and SwiftUI via a thin NIF. [![Hex.pm](https://img.shields.io/hexpm/v/mob.svg)](https://hex.pm/packages/mob) [![Docs](https://img.shields.io/badge/docs-hexdocs-blue.svg)](https://hexdocs.pm/mob) -> **Status:** Early development. Android emulator and iOS simulator confirmed working. Not yet ready for production use. +> **Status:** Early development. Confirmed on the iOS simulator, the Android emulator, and real iOS and Android devices. ## What it is -``` -Your Elixir app (GenServers, OTP supervision, pattern matching, pipes) - ↓ - Mob.Screen (GenServer — your logic lives here) - ↓ - Mob.Renderer (component tree → JSON → NIF call) - ↓ -Compose (Android) SwiftUI (iOS) ← native rendering, native gestures +```mermaid +flowchart TD + A["Your Elixir app<br/>(GenServers, OTP supervision, pattern matching, pipes)"] + B["Mob.Screen<br/>(GenServer — your logic lives here)"] + C["Mob.Renderer<br/>(component tree → JSON → NIF call)"] + D1["Compose (Android)<br/>native rendering, gestures"] + D2["SwiftUI (iOS)<br/>native rendering, gestures"] + + A --> B --> C + C --> D1 + C --> D2 ``` You write Elixir. The native layer handles rendering. The BEAM node runs on the device — connect your dev machine to the running app over Erlang distribution, inspect state, and hot-push new bytecode without a restart. @@ -27,7 +32,7 @@ Add to `mix.exs`: ```elixir def deps do - [{:mob, "~> 0.5"}] + [{:mob, "~> 0.7"}] end ``` @@ -68,7 +73,7 @@ end ```elixir defmodule MyApp do - use Mob.App, theme: Mob.Theme.Obsidian + use Mob.App, theme: Mob.Theme.Dark def navigation(_platform) do stack(:home, root: MyApp.CounterScreen) @@ -101,42 +106,109 @@ tab_bar([ ```elixir # Named theme -use Mob.App, theme: Mob.Theme.Obsidian +use Mob.App, theme: Mob.Theme.Dark # Override individual tokens -use Mob.App, theme: {Mob.Theme.Obsidian, primary: :rose_500} +use Mob.App, theme: {Mob.Theme.Dark, primary: :rose_500} # From scratch use Mob.App, theme: [primary: :emerald_500, background: :gray_950] # Runtime switch (accessibility, user preference) -Mob.Theme.set(Mob.Theme.Citrus) +Mob.Theme.set(MobThemes.Citrus) +``` + +Core ships `Mob.Theme.Light`, `Mob.Theme.Dark`, and `Mob.Theme.Adaptive` +(follows the system light/dark setting). The preset themes — +`MobThemes.Obsidian`, `MobThemes.ObsidianGlass`, `MobThemes.Citrus`, +`MobThemes.Birch`, `MobThemes.Material3` — live in the +[`mob_themes`](https://hex.pm/packages/mob_themes) style package: + +```elixir +# mix.exs +{:mob_themes, "~> 0.1"} + +# mob.exs +config :mob, :styles, [:mob_themes] +config :mob, :default_style, :mob_themes # boots into MobThemes.Obsidian ``` -Built-in themes: `Mob.Theme.Obsidian` (dark violet), `Mob.Theme.Citrus` (warm charcoal + lime), `Mob.Theme.Birch` (warm parchment). +See the [Theming guide](https://hexdocs.pm/mob/theming.html) for details. ## Device APIs All async — call the function, handle the result in `handle_info/2`: ```elixir -# Haptic feedback (synchronous — no handle_info needed) +# Haptic feedback (core; synchronous — no handle_info needed) Mob.Haptic.trigger(socket, :success) -# Camera -Mob.Camera.capture_photo(socket) +# Camera (mob_camera plugin) +MobCamera.capture_photo(socket) def handle_info({:camera, :photo, %{path: path}}, socket), do: ... -# Location -Mob.Location.start(socket, accuracy: :high) +# Location (mob_location plugin) +MobLocation.start(socket, accuracy: :high) def handle_info({:location, %{lat: lat, lon: lon}}, socket), do: ... -# Push notifications -Mob.Notify.register_push(socket) +# Push notifications (mob_notify plugin) +MobNotify.register_push(socket) +def handle_info({:push_token, :ios, token}, socket), do: ... +``` + +Some capabilities ship as first-party plugins rather than in core — see the +[First-Party Packages catalog](guides/packages.md) for the full set. Activating +one is two lines: + +```elixir +# mix.exs +{:mob_camera, "~> 0.1"} + +# mob.exs +config :mob, :plugins, [:mob_camera] +``` + +In core: `Mob.Clipboard`, `Mob.Share`, `Mob.Files`, `Mob.Audio`, `Mob.Motion`, +`Mob.Permissions`. As plugins: `MobCamera` (`mob_camera`), `MobLocation` +(`mob_location`), `MobNotify` (`mob_notify`), `MobPhotos` (`mob_photos`), +`MobBiometric` (`mob_biometric`), `MobScanner` (`mob_scanner` — also needs +`mob_camera`), `MobBluetooth` (`mob_bluetooth`). + +For a full audit of what mob covers vs. what's missing vs. what's +out of scope (compared against React Native + Expo SDK capabilities), +see the [Mobile Surface Matrix](https://hexdocs.pm/mob/mobile_surface_matrix.html). +Set realistic expectations before starting an app; spot plugin +candidates if you want to fill a gap. + +## Background execution + +The BEAM runs on the device, but it does **not** keep running once the app is +backgrounded. iOS suspends the whole process within seconds — schedulers stop, +GenServers freeze, and any distribution / socket connections drop. Android does +the same unless you run a foreground service (the persistent-notification kind). +This is an OS constraint every mobile runtime lives with, not a Mob limitation. + +So a server can't push straight into a long-lived GenServer — the OS has to wake +you first, via APNs (iOS) or FCM (Android). The shape is: + +```elixir +# Register for a push token; your server stores it and sends through APNs/FCM. +# MobNotify ships in the mob_notify plugin; see the mob_push package for the +# server side. +MobNotify.register_push(socket) def handle_info({:push_token, :ios, token}, socket), do: ... + +# React to the OS suspending / resuming the app. A push wakes the app, the BEAM +# resumes, your handler runs in a short window, then the OS suspends you again. +Mob.Device.subscribe([:app]) +def handle_info({:mob_device, :did_enter_background}, socket), do: ... +def handle_info({:mob_device, :will_enter_foreground}, socket), do: ... ``` -Also: `Mob.Clipboard`, `Mob.Share`, `Mob.Photos`, `Mob.Files`, `Mob.Audio`, `Mob.Motion`, `Mob.Biometric`, `Mob.Scanner`, `Mob.Permissions`. +`Mob.Device.foreground?/0` reports the current state. For true always-on (e.g. a +live connection held open), an Android foreground service is the only path; iOS +will not allow it. Otherwise treat the device as push-driven: server → APNs/FCM → +OS wakes app → BEAM handles the event → BEAM suspends again. ## What's in the box @@ -154,9 +226,10 @@ The pre-built OTP runtime that ships with each app includes: - **Erlang distribution** — `mix mob.connect` opens an IEx session on-device. Hot-push individual modules with `nl/1`. -Native APIs surfaced via `Mob.*` modules (above) cover camera, -location, audio, files, biometrics, push, clipboard, share, scanner, -motion sensors, permissions. +Native APIs (above) cover audio, files, clipboard, share, motion +sensors, and permissions in core, with camera, location, push, +photos, biometrics, and scanning available as first-party capability +plugins. The OTP runtime tarball is ~80 MB compressed; sliced per-arch by App Thinning (iOS) and App Bundle (Android) so each user only @@ -203,8 +276,21 @@ Full documentation at [hexdocs.pm/mob](https://hexdocs.pm/mob), including: - [Theming](https://hexdocs.pm/mob/theming.html) - [Navigation](https://hexdocs.pm/mob/navigation.html) - [Device Capabilities](https://hexdocs.pm/mob/device_capabilities.html) +- [DNS on iOS](https://hexdocs.pm/mob/dns_on_ios.html) — required reading if your app makes HTTPS calls; one-line fix for a non-obvious iOS-only failure mode - [Testing](https://hexdocs.pm/mob/testing.html) +## Development + +Clone, then run once: + +```bash +mix setup +``` + +That fetches deps and activates the repo's git hooks (`.githooks/pre-push`): +`mix format --check`, `mix credo --strict` (incl. ExSlop), and `mix compile --warnings-as-errors` run on every push, plus the full test +suite when `mix.exs` changes — the same gate CI enforces before publishing. + ## License MIT diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..f316b941 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,211 @@ +# Release flow + +Canonical release process for the Mob repos (`mob`, `mob_dev`, +`mob_new`). `mob_dev` and `mob_new` reference this file rather than +duplicating it; each adds a short per-repo notes section in its own +CLAUDE.md. + +## Trigger model + +`mix.exs` is the single source of truth for the version. The `release` +GitHub Actions workflow fires when: + +- A push to `master` modifies `mix.exs` +- `workflow_dispatch` is invoked manually (Actions tab → "Run + workflow") + +Any other push to `master` is ignored by the release workflow. Each +step in the workflow (tag, GitHub Release, Hex publish) is +independently idempotent — re-running back-fills only what's missing. + +## Version bump rule + +**Default: patch (`0.x.y → 0.x.(y+1)`).** Always ask before bumping +any version — never auto-bump as part of a feature commit. Reach for +minor only when: + +- A public API broke or was removed +- Several substantive features land in one cut +- A build-system migration or framework architectural shift is + complete + +When unsure, propose patch and confirm with the user. Cheaper to +upgrade after agreement than to downgrade after a commit lands. + +## When a bump is warranted + +A version bump isn't only for code changes. Cut a patch release any +time the published artifact would meaningfully differ: + +- **New functionality** — any added public function, new component + attribute, new template, new Mix task. Must ship with **tests** that + exercise the new behaviour and **docs** in the right place + (module/function `@doc`, guides under `guides/`, or template + comments for generator changes). +- **Bug fix** affecting behaviour visible to downstream apps. +- **Doc improvements** — module docstring rewrites, guide additions, + README clarifications. HexDocs is built from the published Hex + release, so doc-only changes without a bump never reach + hexdocs.pm. If a contributor improved how the library is documented, + the bump is what makes that improvement visible. +- **Dependency bump** that downstream consumers should pick up + (security advisory, transitive runtime fix). + +NOT warranted on their own: + +- CI workflow tweaks (`.github/workflows/*`) +- Pre-push hook changes (`.githooks/*`) +- Internal test refactors that don't change behaviour +- Worktree cleanup, gitignore edits + +When in doubt: if the next person to pull from Hex would benefit from +having this change, bump. If it only affects contributors working in +the repo directly, don't. + +## Tests + docs for new functionality + +Two non-negotiables for anything that ships: + +1. **Tests cover the new behaviour.** A unit test asserting the new + public API works as advertised. For renderer changes, + `test/mob/renderer_test.exs` is the canonical pattern; for + generator-template additions, assert on the rendered output via + `MobNew.ProjectGeneratorTest`. Tests that exist but don't fail + when the feature is broken don't count. +2. **Docs land in the right place.** Module-level `@moduledoc` for + the WHY a module exists, function-level `@doc` for any non-obvious + public function. Cross-cutting topics belong in `guides/` (e.g. + `guides/styling.md`, `guides/security.md`). Generator templates + document inline via comments since they ship verbatim into user + apps. + +After writing or substantially editing `@moduledoc` / `@doc` strings, +run `mix docs` locally and open `doc/index.html` to confirm +rendering. Common gotchas: heredoc strings need a blank line before +code fences; ExDoc resolves `Mob.Foo` references but not +`Mob.Foo.bar` without backticks; broken module refs render as `nil` +instead of a link; tables need an empty line above to render. +Local preview catches these before they reach hexdocs.pm. + +Publishing to hexdocs.pm is **automatic** — `release.yml`'s +`mix hex.publish --yes` step ships package + docs in one call. There +is no separate "publish docs" step. A correct version bump (and only +that) is what makes new docs visible at hexdocs.pm/<package>/<version>. + +The pre-push hook does NOT enforce these — they require human +judgement (a test asserting `1 + 1 == 2` technically exists, an +empty `@doc ""` technically has docs). But they're table stakes for +any commit that warrants a version bump. + +## Step-by-step + +### 1. Update `CHANGELOG.md` + +Add a new `## [X.Y.Z]` section at the top (below the `---` +separator), with `### Added` / `### Changed` / `### Fixed` / +`### Removed` subsections as needed. The release workflow extracts +this section verbatim into the GitHub Release body, so write it for a +reader who hasn't been in the room. + +### 2. Bump `mix.exs` + +Edit the `version: "X.Y.Z"` line in the `project/0` keyword list. +Nothing else moves the workflow trigger. + +### 3. Run the local preflight + +```bash +mix format --check-formatted +mix credo --strict +mix compile --warnings-as-errors +mix test --exclude macos_only --exclude requires_zig +``` + +These are the same checks `test.yml` runs in CI. Catching them +locally saves a 3-5 min CI round-trip per fix iteration. The +pre-push hook (`.githooks/pre-push`) runs the cheap checks +automatically; the `mix test` step is only required when `mix.exs` +changed in the push (i.e., you're actually cutting a release). + +Per-repo extras: + +- **`mob_dev`**: also run `mix mob.security_scan` — it's the only + repo that ships the scanner. The `hex_deps` layer applies to + mob_dev itself; the gradle / swift / bundled_runtime layers no-op + (mob_dev has no native surface). +- **`mob_new`**: generator tests need `MOB_DIR=/Users/kevin/code/mob` + when running from a worktree; the path resolver looks for `mob` + alongside the project. + +### 4. Commit + push + +One commit per release. The commit message convention: + +``` +Bump to X.Y.Z — <one-line description> + +<more detail if useful> +``` + +Push to `master`. The release workflow fires automatically because +`mix.exs` changed. + +### 5. Watch the workflow + +```bash +gh run watch -R GenericJam/<repo> +``` + +A successful run does three things in order, each independently +idempotent: + +1. Creates and pushes tag `X.Y.Z` (skipped if it already exists) +2. Creates the GitHub Release `X.Y.Z` with the CHANGELOG section as + body (skipped if it already exists) +3. Publishes to Hex via `mix hex.publish --yes` (skipped if `mix + hex.info <pkg> <vsn>` already finds the version) + +If a step fails partway through (network, transient Hex 503, etc.) +re-run the workflow via `workflow_dispatch` — only the missing steps +will execute. + +## Pre-push hook + +`.githooks/pre-push` runs the **cheap** preflight on every push: + +``` +mix format --check-formatted +mix credo --strict +mix compile --warnings-as-errors +``` + +Sub-10-second total. If `mix.exs` changed in the push, it additionally +runs the full test suite (the "release preflight"). Tests are NOT run +on every push — that's a CI responsibility, and forcing local 30-60s +test runs is what drives people to `--no-verify` (anti-pattern). + +**One-time setup** after cloning the repo (or creating a new worktree): + +```bash +git config core.hooksPath .githooks +``` + +git stores this locally per-clone, so each worktree needs it too. To +intentionally bypass on a specific push (rare — be honest about why): + +```bash +git push --no-verify +``` + +## OTP tarball releases (mob_dev only) + +The OTP runtime tarballs at `github.com/GenericJam/mob/releases/tag/otp-<hash>` +are a **separate, manual** release flow — not driven by `mix.exs` +version bumps. See `scripts/release/` in `mob_dev` for the build + +publish scripts. The version-bump flow above only ships the Elixir +package; OTP tarball rebuilds are operator steps run when the OTP +source revision or cross-compile flags change. + +When you bump `@otp_hash` in `mob_dev/lib/mob_dev/otp_downloader.ex` +to point at a new tarball release, the version bump that ships that +change to Hex still follows the standard flow above. diff --git a/RELEASE_PLAN_0.7.md b/RELEASE_PLAN_0.7.md new file mode 100644 index 00000000..fa7a1a2f --- /dev/null +++ b/RELEASE_PLAN_0.7.md @@ -0,0 +1,67 @@ +# 0.7.0 release plan — the plugin-extraction breaking major + +Status: PREPARED, not executed. Versions are drafted UNRELEASED; nothing +is on Hex; per RELEASE.md no bump happens without explicit permission. + +## What ships + +| Package | From | Contents | +|---|---|---| +| mob 0.7.0 | 0.6.26 | plugin runtime (tiers 0-4, spec-v2, composites, styles), BREAKING capability strips (see CHANGELOG) | +| mob_dev 0.6.0 | 0.5.17 | plugin/style build infra, doctor checks, driver_tab auto-regen, dep-detection fix | +| mob_new 0.x bump | — | stripped templates, dotfile fix, baseline switcher | +| mob_bluetooth, mob_location, mob_camera, mob_photos, mob_biometric, mob_notify, mob_scanner 0.1.0 | new | the extracted capability plugins | +| mob_themes 0.1.0 | new | five preset themes (style package) | +| mob_ash 0.1.0 | new | Ash-driven generated screens (spec-v2) | +| mob_push 0.2.x | 0.2.1 | already published; contract fixtures landed (patch bump optional) | + +## Sequence (order matters — deps point down the list) + +1. **Kevin: green-light versions** (suggested above; RELEASE.md rule). +2. **Push masters to origin**: mob, mob_dev, mob_new (currently local-only; + plugin repos already pushed). Pre-push hooks run the full preflight. +3. **Kevin: flip plugin repos public** (currently private: + location/bluetooth/camera/screencast/notify/photos/biometric/scanner/ + themes/ash) and **set HEX_API_KEY** on every repo that should publish + (Settings → Secrets → Actions). +4. **Publish mob 0.7.0**: bump mix.exs on master, push — release.yml tags, + creates the GitHub Release, publishes (preflight: full suite runs via + the pre-push hook because mix.exs changed). +5. **Publish mob_dev + mob_new** (their generated apps/templates reference + the new plugin packages by name only — no hard dep on them). +6. **Flip the plugin/style packages' `{:mob, path: "../mob"}` → + `{:mob, "~> 0.7"}`** (one commit each; the mix.exs change triggers each + repo's release.yml on push → Hex). The `{:mob_dev, path:, only: :test}` + dev dep can stay (test-only deps don't ship in the package) or flip to + `"~> 0.6"` once mob_dev is up. +7. **Publish order within the packages**: mob_camera before mob_scanner + (scanner's docs/README direct users to activate camera). Others are + independent. +8. **Post-publish smoke**: `mix mob.new smoke_app` from the PUBLISHED + archive + add one Hex plugin + `mix mob.doctor` + an Android build. + +## Known-open items deliberately NOT blocking + +- Stale-BEAM overlay on device (`Documents/otp/<app>` keeps hot-pushed + beams across installs; stripped modules stay loadable in the DEV loop — + fresh user installs are unaffected). Fix tracked: prune the overlay on + `--native` deploy. +- Live push end-to-end (real APNs/FCM creds + mob_push) — mob_notify's + EXTRACTION.md Stage 4 remainder. +- iPhone dist-probe for the latest build (scanner/ash/kit/themes are + installed on the SE; pure-Elixir, Android-verified; the probe needs the + unplug/relaunch dance). +- Native style tier (cascade), plugin native-view capability, generator + lane (`mix mob.gen.component`), Android biometric un-degrade + (androidx.biometric 1.2.x) — all post-release features. +- code_to_cloud: migrate `Mob.Theme.ObsidianGlass` → `MobThemes.ObsidianGlass` + when it bumps mob. + +## Manual-step checklist for Kevin (the irreducible bits) + +- [ ] Version green-light (mob 0.7.0 + the table above) +- [ ] `git push` permission for mob / mob_dev / mob_new masters +- [ ] Repos public (10 ×) — or publish from private (Hex doesn't care; + docs links will 404 until public) +- [ ] HEX_API_KEY secret per publishing repo +- [ ] (optional) reserve the package names on Hex beforehand diff --git a/agent_briefs/rustler_env_var_test.md b/agent_briefs/rustler_env_var_test.md new file mode 100644 index 00000000..88ab437b --- /dev/null +++ b/agent_briefs/rustler_env_var_test.md @@ -0,0 +1,223 @@ +# Agent brief: test the env-var approach for rustler's Bionic dlsym fix + +## Goal + +Implement and test the env-var-based approach for resolving `enif_*` +symbols on Android Bionic, as proposed by filmor (rustler maintainer) +in PR https://github.com/rusterlium/rustler/pull/726. + +Success means we have a deployable end-to-end demonstration that the +proposed mechanism works in Mob's static-link deployment model. Report +the result back so we can push it as the new PR commit. + +## Background — read first + +You need to understand three things before touching code. + +1. **The bug.** On Android Bionic (any version), `dlopen(NULL)` returns + the app process's link namespace, which does NOT include symbols + statically linked into a sibling `.so` even when that `.so` was + loaded via `System.loadLibrary(..., RTLD_NOW | RTLD_GLOBAL)`. This + is by-design Android linker behavior — they made the call for + compat reasons in M and never reverted. See + https://github.com/android/ndk/issues/201 for the canonical + explanation by the Android linker maintainer (`dimitry-`). + +2. **Why rustler hits this.** Rustler's runtime initialization uses + `nif_filler` which does `dlopen(NULL)` + `dlsym(handle, "enif_*")` + to populate its callback table. On Bionic that returns `NULL` + for symbols statically linked into Mob's `libpigeon.so`, so + rustler-based NIFs fail at runtime on Android. + +3. **The current PR's approach (what we're replacing).** Uses + `dladdr` on a known-in-rustler symbol to identify the `.so` that + contains rustler, then `dlopen(self_path, RTLD_NOW | RTLD_NOLOAD)` + to get an explicit handle to that `.so`. Works for Mob's + static-link-everything model. Filmor's concern: hard-codes the + assumption that `enif_*` is in the same `.so` as rustler. Won't + generalize to setups with a separately-linked BEAM. + +## The deal filmor offered + +He proposed: **Mob sets an env var containing the path of the `.so` +that contains the `enif_*` symbols. Rustler's `DlsymNifFiller` reads +that env var and uses it directly. Existing `dladdr` logic stays as +the fallback for setups that don't set the env var.** + +We own the Mob side (setting the env var). He owns the rustler side +(extending `DlsymNifFiller`). + +Read the full PR conversation for context: +https://github.com/rusterlium/rustler/pull/726 + +## What to build and test + +### Part A — Mob side: discover path + set env var + +Goal: by the time rustler's NIF init runs, the env var (we'll call it +`RUSTLER_NIF_LIB_PATH`) holds the absolute path of `libpigeon.so`. + +**Files to inspect first:** + +- `/Users/kevin/code/mob/android/app/src/main/java/com/example/<app>/BeamForegroundService.kt` + — where `System.loadLibrary("pigeon")` is called. The `.so` is + fully loaded by the time this returns. +- `/Users/kevin/code/mob/android/jni/mob_beam.zig` (or `mob_beam.c` + in older copies) — the C/Zig BEAM launcher. Already calls + `setenv()` for `MOB_BUNDLE_OTP`, `MOB_DIST_PORT`, + `MOB_NODE_SUFFIX`. The new env var goes here too. +- `/Users/kevin/code/mob/android/jni/mob_beam.h` — declarations + for the launcher API. + +**On the Kotlin side, the path resolves to:** + +```kotlin +val nifLibPath = "${applicationInfo.nativeLibraryDir}/libpigeon.so" +``` + +Verify this path exists on a real device before relying on it. The +nativeLibraryDir is typically: +- `/data/app/~~<hash>==/<package>-<hash>==/lib/arm64` for installed apps +- `/data/app/<package>-N/lib/arm64` on older Android + +Either pass this path through JNI into the launcher, or set the env +var directly from Kotlin via `android.system.Os.setenv()` (available +since API 21; Mob's min-SDK is well above this). + +The simplest implementation: set it from Kotlin *before* anything +NIF-related runs (which means before `Mob.Dist.ensure_started/1` +indirectly triggers the rustler NIF on_load). + +```kotlin +android.system.Os.setenv("RUSTLER_NIF_LIB_PATH", nifLibPath, true) +``` + +Verify the env var is visible from C (or to BEAM) by adding a one-shot +`Log.i(...)` from the launcher reading `getenv("RUSTLER_NIF_LIB_PATH")`. + +### Part B — Rustler fork: read the env var in DlsymNifFiller + +Goal: the existing `GenericJam/rustler:genericjam-android-rtld-default` +fork's `DlsymNifFiller` reads `RUSTLER_NIF_LIB_PATH` first; if unset, +falls back to current `dladdr` logic. + +**Locate `DlsymNifFiller` in the fork:** + +```bash +cd /Users/kevin/code/rustler # or wherever the fork is checked out +grep -rn 'DlsymNifFiller\|dladdr\|nif_filler' rustler_sys/ rustler/ 2>&1 | head +``` + +The change is roughly: + +```rust +// Before (current PR approach): +// resolve self_path via dladdr, then dlopen(self_path, RTLD_NOW | RTLD_NOLOAD) +// +// After (env-var approach): +let handle = match std::env::var("RUSTLER_NIF_LIB_PATH") { + Ok(path) if !path.is_empty() => { + // Caller (Mob, etc.) explicitly told us where enif_* lives. + let c_path = std::ffi::CString::new(path)?; + unsafe { libc::dlopen(c_path.as_ptr(), libc::RTLD_NOW | libc::RTLD_NOLOAD) } + } + _ => { + // Fall back to dladdr-based self-resolution for setups that + // don't set the env var (most pre-existing rustler users). + existing_dladdr_logic() + } +}; +``` + +Preserve the existing `dladdr` path as the else-branch. Backwards +compatibility is the point — current users keep working without +setting the env var. + +Update the fork's commit and push to the `genericjam-android-rtld-default` +branch (or a new branch — your call). + +### Part C — End-to-end test on a physical Android device + +Goal: prove the chain works. A Mob app with a rustler-based NIF loads +on a real Bionic device and resolves `enif_*` symbols. + +**Test app to use:** the existing `/Users/kevin/code/nif_race` project +already has a Rust NIF demo. Should work as the test harness. + +**Procedure:** + +1. Patch `nif_race/mix.exs` to point at the local rustler fork: + ```elixir + {:rustler, path: "/Users/kevin/code/rustler", override: true} + ``` +2. `mix mob.deploy --native --device <physical-android-serial>` +3. Watch logcat: + ```bash + adb -s <serial> logcat | grep -E 'rustler|MobBeam|nif_filler|RUSTLER_NIF_LIB_PATH' + ``` +4. Expected log lines: + - `RUSTLER_NIF_LIB_PATH=/data/app/.../libpigeon.so` (from your debug log) + - rustler successfully loading without dlsym failures + - The nif_race app reaching its main screen +5. Hit the test button in the nif_race UI; verify the Rust NIF runs. + +**If anything fails:** +- `adb shell ls /data/app/<package>/lib/arm64/libpigeon.so` to confirm the path exists +- Check `getenv("RUSTLER_NIF_LIB_PATH")` from C is non-NULL by adding a `__android_log_print` call in mob_beam +- `nm -D libpigeon.so | grep enif_` to confirm `enif_*` symbols are exported + +### Part D — Report results + +Document the result back in this brief or a follow-up file: + +- Confirmation that the env-var approach works (or doesn't) +- The exact path nativeLibraryDir resolved to on the test device + (paste-able evidence) +- Any deviations from the proposed approach (different env var name, + different code path, etc.) +- A draft PR description for the rustler PR rewrite, in + hand-written-by-Kevin style. Short and direct. No AI prose. +- A draft of the matching Mob-side commit message + +If it doesn't work: document specifically *why* — failure logs, +symbol resolution output, anything that distinguishes "env var +mechanism is wrong" from "implementation detail is wrong." + +## Constraints + +- **Do not push the rustler-fork changes to upstream rusterlium/rustler.** + Push to the user's own fork only. The PR description rewrite happens + later, after Kevin has reviewed the test results. +- **Do not respond to filmor or modify the existing PR.** That is + Kevin's interaction surface. Report back; Kevin handles the + upstream conversation. +- **Don't write the actual response prose for filmor.** filmor has + explicitly objected to AI-generated PR comments. You can sketch + technical content. Kevin will hand-write the actual response. +- **Stick to the proposed approach.** Don't expand scope to the + combined-staticlib refactor or any other alternative — that's a + separate decision Kevin will make once this test is done. + +## Success criteria + +- [ ] `applicationInfo.nativeLibraryDir` confirmed to resolve to a + real existing path on at least one physical Android device. +- [ ] `RUSTLER_NIF_LIB_PATH` env var set before rustler's NIF + initialization runs. +- [ ] Rustler fork's `DlsymNifFiller` patched to read the env var; + `dladdr` fallback preserved. +- [ ] nif_race (or equivalent rustler-using Mob app) deploys to a + physical Android device and the Rust NIF resolves + executes + successfully. +- [ ] Logcat evidence captured showing the env var being read and + symbol resolution succeeding. +- [ ] Test summary documented for Kevin to use when updating the + PR. + +## Out of scope + +- Combined staticlib pattern (filmor's preferred long-term approach) +- iOS-side anything (this bug is Android-Bionic-specific) +- Mob_dev plumbing changes beyond what's needed for the env var +- Documentation updates to mob/common_fixes.md or guides/ + (Kevin will land those separately if the approach works) diff --git a/android/jni/driver_tab_android.c b/android/jni/driver_tab_android.c deleted file mode 100644 index 4d08cd00..00000000 --- a/android/jni/driver_tab_android.c +++ /dev/null @@ -1,72 +0,0 @@ -// driver_tab_android.c — Reference snapshot of the static NIF table. -// -// As of mob 0.5.18 + mob_dev 0.4.x, the source of truth for an app's static -// NIF table lives in the app's mob.exs `:static_nifs` config and is generated -// to priv/generated/driver_tab_android.c via `mix mob.regen_driver_tab`. This -// file remains as a fallback that build templates use when the generated file -// is absent (i.e. the project hasn't been migrated yet). -// -// Keep this file in sync with `MobDev.StaticNifs.default_nifs/0` so the -// fallback matches the generator's default output. -// -// Link BEFORE libbeam.a to override the built-in driver_tab. - -#include <stddef.h> - -typedef struct { void* de; int flags; } ErtsStaticDriver; -#define THE_NON_VALUE ((unsigned long)0) -typedef struct { - void* (*nif_init)(void); - int is_builtin; - unsigned long nif_mod; - void* entry; -} ErtsStaticNif; - -typedef struct { void* de; int flags; } ErlDrvEntryStub; -extern ErlDrvEntryStub inet_driver_entry; -extern ErlDrvEntryStub ram_file_driver_entry; - -ErtsStaticDriver driver_tab[] = { - {&inet_driver_entry, 0}, - {&ram_file_driver_entry, 0}, - {NULL, 0} -}; - -void erts_init_static_drivers(void) {} - -void *prim_tty_nif_init(void); -void *erl_tracer_nif_init(void); -void *prim_buffer_nif_init(void); -void *prim_file_nif_init(void); -void *zlib_nif_init(void); -void *zstd_nif_init(void); -void *prim_socket_nif_init(void); -void *prim_net_nif_init(void); -void *asn1rt_nif_nif_init(void); - -// crypto.c's ERL_NIF_INIT(crypto,...) generates: crypto_nif_init. -// Built into libpigeon.so via crypto.a + libcrypto.a (OpenSSL). -// Without this entry, the BEAM falls through to dlopen("crypto.so") -// which fails because Android's RTLD_LOCAL hides libpigeon.so's -// enif_* symbols from the dlopen'd library. With it, the BEAM -// resolves crypto via dlsym(RTLD_DEFAULT) and load_nif uses the -// static path — no dlopen, real OpenSSL. -void *crypto_nif_init(void); - -// mob_nif.c's ERL_NIF_INIT(mob_nif,...) generates: mob_nif_nif_init -void *mob_nif_nif_init(void); - -ErtsStaticNif erts_static_nif_tab[] = { - {prim_tty_nif_init, 0, THE_NON_VALUE, NULL}, - {erl_tracer_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_buffer_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_file_nif_init, 0, THE_NON_VALUE, NULL}, - {zlib_nif_init, 0, THE_NON_VALUE, NULL}, - {zstd_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_socket_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_net_nif_init, 0, THE_NON_VALUE, NULL}, - {asn1rt_nif_nif_init, 1, THE_NON_VALUE, NULL}, - {crypto_nif_init, 1, THE_NON_VALUE, NULL}, - {mob_nif_nif_init, 0, THE_NON_VALUE, NULL}, - {NULL, 0, THE_NON_VALUE, NULL} -}; diff --git a/android/jni/driver_tab_android.zig b/android/jni/driver_tab_android.zig new file mode 100644 index 00000000..96868476 --- /dev/null +++ b/android/jni/driver_tab_android.zig @@ -0,0 +1,87 @@ +//! driver_tab_android.zig — Reference snapshot of the static NIF table (Zig rewrite). +//! +//! Phase 6a of the build-system migration: hand-coded Zig sibling to +//! driver_tab_android.c, matching it byte-for-byte semantically. +//! Validates that Zig's `export` keyword produces the C-ABI symbols +//! libbeam.a expects (`erts_static_nif_tab`, `driver_tab`, +//! `erts_init_static_drivers`). +//! +//! Link BEFORE libbeam.a so this overrides BEAM's built-in empty +//! `erts_static_nif_tab[]` and `driver_tab[]`. + +// ── ABI types ────────────────────────────────────────────────────────────── + +const ErtsStaticDriver = extern struct { + de: ?*anyopaque, + flags: c_int, +}; + +const ErtsStaticNif = extern struct { + nif_init: ?*const fn () callconv(.c) ?*anyopaque, + is_builtin: c_int, + nif_mod: c_ulong, + entry: ?*anyopaque, +}; + +const ErlDrvEntryStub = extern struct { + de: ?*anyopaque, + flags: c_int, +}; + +const THE_NON_VALUE: c_ulong = 0; + +// ── External driver entry refs (from libbeam.a / OTP) ────────────────────── + +extern var inet_driver_entry: ErlDrvEntryStub; +extern var ram_file_driver_entry: ErlDrvEntryStub; + +// ── External NIF init refs ───────────────────────────────────────────────── + +extern fn prim_tty_nif_init() callconv(.c) ?*anyopaque; +extern fn erl_tracer_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_buffer_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_file_nif_init() callconv(.c) ?*anyopaque; +extern fn zlib_nif_init() callconv(.c) ?*anyopaque; +extern fn zstd_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_socket_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_net_nif_init() callconv(.c) ?*anyopaque; +extern fn asn1rt_nif_nif_init() callconv(.c) ?*anyopaque; + +// crypto.c's ERL_NIF_INIT(crypto, ...) generates crypto_nif_init. +// Built into libpigeon.so via crypto.a + libcrypto.a (OpenSSL). +// Without this entry, the BEAM falls through to dlopen("crypto.so") which +// fails because Android's RTLD_LOCAL hides libpigeon.so's enif_* symbols +// from the dlopen'd library. With it, the BEAM resolves crypto via +// dlsym(RTLD_DEFAULT) and load_nif uses the static path — no dlopen, +// real OpenSSL. +extern fn crypto_nif_init() callconv(.c) ?*anyopaque; + +// mob_nif.c's ERL_NIF_INIT(mob_nif, ...) generates mob_nif_nif_init. +extern fn mob_nif_nif_init() callconv(.c) ?*anyopaque; + +// ── Static driver table ──────────────────────────────────────────────────── + +export var driver_tab: [3]ErtsStaticDriver = .{ + .{ .de = &inet_driver_entry, .flags = 0 }, + .{ .de = &ram_file_driver_entry, .flags = 0 }, + .{ .de = null, .flags = 0 }, +}; + +export fn erts_init_static_drivers() callconv(.c) void {} + +// ── Static NIF table ─────────────────────────────────────────────────────── + +export var erts_static_nif_tab = [_]ErtsStaticNif{ + .{ .nif_init = prim_tty_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = erl_tracer_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_buffer_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_file_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = zlib_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = zstd_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_socket_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_net_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = asn1rt_nif_nif_init, .is_builtin = 1, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = crypto_nif_init, .is_builtin = 1, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = mob_nif_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = null, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, +}; diff --git a/android/jni/mob_beam.c b/android/jni/mob_beam.c deleted file mode 100644 index 7f089284..00000000 --- a/android/jni/mob_beam.c +++ /dev/null @@ -1,536 +0,0 @@ -// mob_beam.c — Mob BEAM launcher and JNI bridge initialisation. -// Extracted from the per-app beam_jni.c stub so app code stays minimal. - -#include <jni.h> -#include <android/log.h> -#include <stdlib.h> -#include <string.h> -#include <time.h> -#include <errno.h> -#include <unistd.h> -#include <sys/stat.h> -#include <dirent.h> -#include <pthread.h> -#include <dlfcn.h> -#include <stdint.h> -#include "mob_beam.h" - -#define LOG_TAG "MobBeam" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) -#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) - -// ── BEAM stdout/stderr → logcat ────────────────────────────────────────── -// Without this, anything the BEAM writes to stderr (including ** crash -// reports from Logger and the boot script's :application.start/2 errors) -// is silently dropped on Android. Wire stdout + stderr to a pipe and read -// them on a detached thread, emitting each line under the "BEAMout" tag. -// One-shot: called once from mob_init_bridge before any BEAM code runs. -// -// See beam_crash.md (Incident #1) for the case that motivated this. - -static void* mob_beam_log_reader(void* arg) { - int fd = (int)(intptr_t)arg; - char buf[4096]; - char line[4096]; - int line_pos = 0; - ssize_t n; - while ((n = read(fd, buf, sizeof(buf))) > 0) { - for (ssize_t i = 0; i < n; i++) { - char c = buf[i]; - if (c == '\n' || line_pos >= (int)sizeof(line) - 1) { - line[line_pos] = '\0'; - if (line_pos > 0) { - __android_log_write(ANDROID_LOG_INFO, "BEAMout", line); - } - line_pos = 0; - } else if (c != '\r') { - line[line_pos++] = c; - } - } - } - return NULL; -} - -static void mob_capture_beam_stdio(void) { - int pipe_fds[2]; - if (pipe(pipe_fds) != 0) { - LOGE("mob_capture_beam_stdio: pipe() failed: %s", strerror(errno)); - return; - } - if (dup2(pipe_fds[1], STDOUT_FILENO) < 0) - LOGE("mob_capture_beam_stdio: dup2 stdout failed: %s", strerror(errno)); - if (dup2(pipe_fds[1], STDERR_FILENO) < 0) - LOGE("mob_capture_beam_stdio: dup2 stderr failed: %s", strerror(errno)); - close(pipe_fds[1]); - - pthread_t tid; - if (pthread_create(&tid, NULL, mob_beam_log_reader, - (void*)(intptr_t)pipe_fds[0]) != 0) { - LOGE("mob_capture_beam_stdio: pthread_create failed: %s", strerror(errno)); - close(pipe_fds[0]); - return; - } - pthread_detach(tid); - - // Disable buffering so output reaches the pipe immediately, not on - // exit (which we never reach for a long-running BEAM). - setvbuf(stdout, NULL, _IONBF, 0); - setvbuf(stderr, NULL, _IONBF, 0); - LOGI("mob_capture_beam_stdio: piping stdout/stderr to logcat (tag: BEAMout)"); -} - -#define ERTS_VSN "erts-17.0" - -// Declared in mob_nif.c — caches MobBridge methods on the main thread. -extern void _mob_ui_cache_class_impl(JNIEnv* env, const char* bridge_class); - -// Native lib dir and app files dir — populated in mob_init_bridge, used in mob_start_beam. -static char s_native_lib_dir[512] = {0}; -static char s_files_dir[512] = {0}; - -void mob_ui_cache_class(JNIEnv* env, const char* bridge_class) { - _mob_ui_cache_class_impl(env, bridge_class); -} - -// Declared in mob_nif.c — the cached Bridge.cls global ref. -extern void _mob_bridge_init_activity(JNIEnv* env, jobject activity); - -void mob_init_bridge(JNIEnv* env, jobject activity) { - // Capture BEAM stdio first so any startup errors (NIF load failures, - // application:start/2 crashes) land in logcat instead of /dev/null. - mob_capture_beam_stdio(); - - g_activity = (*env)->NewGlobalRef(env, activity); - _mob_bridge_init_activity(env, g_activity); - - // Get nativeLibraryDir so mob_start_beam can symlink ERTS executables there. - // Files in the native lib dir carry the apk_data_file SELinux label which - // allows execve() from untrusted_app, unlike files in app_data_file. - jclass ctx_cls = (*env)->FindClass(env, "android/content/Context"); - jmethodID get_app_info = (*env)->GetMethodID(env, ctx_cls, "getApplicationInfo", - "()Landroid/content/pm/ApplicationInfo;"); - jobject app_info = (*env)->CallObjectMethod(env, activity, get_app_info); - jclass app_info_cls = (*env)->FindClass(env, "android/content/pm/ApplicationInfo"); - jfieldID fid = (*env)->GetFieldID(env, app_info_cls, "nativeLibraryDir", - "Ljava/lang/String;"); - jstring jdir = (*env)->GetObjectField(env, app_info, fid); - const char* dir = (*env)->GetStringUTFChars(env, jdir, NULL); - snprintf(s_native_lib_dir, sizeof(s_native_lib_dir), "%s", dir); - (*env)->ReleaseStringUTFChars(env, jdir, dir); - LOGI("mob_init_bridge: native lib dir = %s", s_native_lib_dir); - - // Get filesDir for OTP root path (app-specific, avoids hardcoding package name). - jmethodID get_files_dir = (*env)->GetMethodID(env, ctx_cls, "getFilesDir", "()Ljava/io/File;"); - jobject files_dir_obj = (*env)->CallObjectMethod(env, activity, get_files_dir); - jclass file_cls = (*env)->FindClass(env, "java/io/File"); - jmethodID get_path = (*env)->GetMethodID(env, file_cls, "getPath", "()Ljava/lang/String;"); - jstring jfiles_path = (*env)->CallObjectMethod(env, files_dir_obj, get_path); - const char* files_path = (*env)->GetStringUTFChars(env, jfiles_path, NULL); - snprintf(s_files_dir, sizeof(s_files_dir), "%s", files_path); - (*env)->ReleaseStringUTFChars(env, jfiles_path, files_path); - LOGI("mob_init_bridge: files dir = %s", s_files_dir); -} - -void mob_start_beam(const char* app_module) { -#ifdef NO_BEAM - // Config A: baseline measurement — stock Android activity, BEAM never launched. - LOGI("mob_start_beam: NO_BEAM defined, skipping BEAM launch (battery baseline)"); - return; -#endif - // Re-dlopen ourselves with RTLD_GLOBAL so the BEAM's enif_* symbols - // (statically linked into this library) are visible when the BEAM - // later dlopens a NIF library (e.g. crypto.so). Without this, Android - // loads libpigeon.so with RTLD_LOCAL by default, hiding enif_* from - // dlopen'd children — crypto.so on_load fails with - // `cannot locate symbol enif_get_tuple`. - { - char self_path[600]; - snprintf(self_path, sizeof(self_path), "%s/lib%s.so", - s_native_lib_dir, app_module); - if (!dlopen(self_path, RTLD_NOW | RTLD_GLOBAL)) { - LOGE("mob_start_beam: dlopen self with RTLD_GLOBAL failed: %s", dlerror()); - } else { - LOGI("mob_start_beam: re-dlopened self RTLD_GLOBAL: %s", self_path); - } - } - mob_set_startup_phase("Setting up BEAM environment…"); - // Build all paths dynamically from s_files_dir (set in mob_init_bridge). - char otp_root[560]; - snprintf(otp_root, sizeof(otp_root), "%s/otp", s_files_dir); - - char bindir[600]; - snprintf(bindir, sizeof(bindir), "%s/" ERTS_VSN "/bin", otp_root); - - char beams_dir[600]; - snprintf(beams_dir, sizeof(beams_dir), "%s/%s", otp_root, app_module); - - char elixir_dir[600]; - snprintf(elixir_dir, sizeof(elixir_dir), "%s/lib/elixir/ebin", otp_root); - - char logger_dir[600]; - snprintf(logger_dir, sizeof(logger_dir), "%s/lib/logger/ebin", otp_root); - - char eex_dir[600]; - snprintf(eex_dir, sizeof(eex_dir), "%s/lib/eex/ebin", otp_root); - - char crash_dump[560]; - snprintf(crash_dump, sizeof(crash_dump), "%s/erl_crash.dump", s_files_dir); - - setenv("BINDIR", bindir, 1); - setenv("ROOTDIR", otp_root, 1); - setenv("PROGNAME", "erl", 1); - setenv("EMU", "beam", 1); - setenv("HOME", s_files_dir, 1); - setenv("MOB_DATA_DIR", s_files_dir, 1); - - // MOB_BEAMS_DIR — the directory where app BEAMs (and priv/) are deployed. - // - // Problem: Ecto.Migrator uses :code.priv_dir(app) to locate migration .exs - // files. :code.priv_dir/1 works by looking up the app's OTP lib structure - // ($OTP_ROOT/lib/APP-VERSION/ebin/). Mob apps are deployed to a flat -pa - // directory (e.g. files/otp/my_app/*.beam), not an OTP lib structure, so - // :code.priv_dir/1 returns {error, bad_name} and Ecto silently reports - // "Migrations already up" without running anything. - // - // Fix: deployer.ex pushes priv/ alongside the BEAMs into beams_dir/priv/. - // App code reads MOB_BEAMS_DIR at startup and passes the explicit path to - // Ecto.Migrator.run/4 instead of relying on :code.priv_dir/1. This env var - // is the only reliable way to communicate beams_dir to Elixir code since it - // is computed here from getFilesDir() at runtime (the path includes the - // Android user ID which is not predictable at compile time). - setenv("MOB_BEAMS_DIR", beams_dir, 1); - setenv("ERL_CRASH_DUMP", crash_dump, 1); - setenv("ERL_CRASH_DUMP_SECONDS", "30", 1); - - char eval_expr[280]; - snprintf(eval_expr, sizeof(eval_expr), "%s:start().", app_module); - - // Compile-time default BEAM tuning flags. - // Selected by -D flag: BEAM_UNTUNED, BEAM_SBWT_ONLY, BEAM_FULL_NERVES, - // or BEAM_USE_CUSTOM_FLAGS (includes mob_beam_flags.h from battery bench). - // These are overridden at runtime if beams_dir/mob_beam_flags exists. -#ifdef BEAM_USE_CUSTOM_FLAGS -#include "mob_beam_flags.h" - static const char* s_default_flags[] = { BEAM_EXTRA_FLAGS NULL }; -#elif defined(BEAM_UNTUNED) - static const char* s_default_flags[] = { NULL }; -#elif defined(BEAM_SBWT_ONLY) - static const char* s_default_flags[] = { - "-sbwt", "none", "-sbwtdcpu", "none", "-sbwtdio", "none", NULL - }; -#else - // Default and BEAM_FULL_NERVES both use full Nerves-style tuning. - static const char* s_default_flags[] = { - "-S", "1:1", "-SDcpu", "1:1", "-SDio", "1", "-A", "1", - "-sbwt", "none", "-sbwtdcpu", "none", "-sbwtdio", "none", NULL - }; -#endif - - // Runtime override: read whitespace-separated flags from beams_dir/mob_beam_flags. - // Written by `mix mob.deploy --schedulers N` or `--beam-flags "..."`. - static char s_flags_buf[512] = {0}; - static const char* s_runtime_flags[64] = {NULL}; - static int s_runtime_flag_count = 0; - { - char flags_path[640]; - snprintf(flags_path, sizeof(flags_path), "%s/mob_beam_flags", beams_dir); - FILE *f = fopen(flags_path, "r"); - if (f) { - size_t n = fread(s_flags_buf, 1, sizeof(s_flags_buf) - 1, f); - fclose(f); - s_flags_buf[n] = '\0'; - s_runtime_flag_count = 0; - char *p = s_flags_buf; - while (*p && s_runtime_flag_count < 63) { - while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++; - if (!*p) break; - s_runtime_flags[s_runtime_flag_count++] = p; - while (*p && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') p++; - if (*p) *p++ = '\0'; - } - s_runtime_flags[s_runtime_flag_count] = NULL; - LOGI("mob_start_beam: loaded %d runtime flags from %s", s_runtime_flag_count, flags_path); - } - } - - const char** selected_flags = (s_runtime_flag_count > 0) - ? s_runtime_flags - : s_default_flags; - - char boot_path[580]; - snprintf(boot_path, sizeof(boot_path), "%s/releases/29/start_clean", otp_root); - - static const char* args[128]; - int ac = 0; - args[ac++] = "beam"; - for (int i = 0; selected_flags[i]; i++) args[ac++] = selected_flags[i]; - args[ac++] = "--"; - args[ac++] = "-root"; args[ac++] = otp_root; - args[ac++] = "-bindir"; args[ac++] = bindir; - args[ac++] = "-progname"; args[ac++] = "erl"; - args[ac++] = "--"; - args[ac++] = "-noshell"; - args[ac++] = "-noinput"; - args[ac++] = "-boot"; args[ac++] = boot_path; - args[ac++] = "-pa"; args[ac++] = elixir_dir; - args[ac++] = "-pa"; args[ac++] = logger_dir; - args[ac++] = "-pa"; args[ac++] = eex_dir; - args[ac++] = "-pa"; args[ac++] = beams_dir; - args[ac++] = "-eval"; args[ac++] = eval_expr; - args[ac] = NULL; - - // ── Cold-start race condition fix ──────────────────────────────────────── - // - // DO NOT REMOVE THIS BLOCK. - // - // Problem: on a cold start (first launch after install or after the process - // was killed), calling erl_start() too early causes a SIGABRT deep inside - // ERTS pthread initialisation. The crash looks like: - // - // FORTIFY: pthread_mutex_lock called on a destroyed mutex - // backtrace: - // #00 abort - // #01 pthread_mutex_lock (FORTIFY wrapper) - // #02 ... (ERTS internal thread pool setup) - // #03 erl_start - // - // Root cause: Android's hwui (hardware-accelerated UI renderer) creates its - // own native thread pool during the very first layout/draw pass. That - // initialisation uses pthread mutexes that it allocates and later destroys. - // ERTS also calls into pthreads during erl_start(). If erl_start() runs - // concurrently with hwui's first-draw setup, the two pthread paths race on - // the same internal libc state and the FORTIFY mutex check fires → SIGABRT. - // - // The race only reproduces on cold start because: - // • On warm start hwui's thread pool already exists → no race. - // • The window-focus event is the earliest point at which Android - // guarantees the first layout/draw pass has completed, so hwui's - // pthread state is stable. - // - // Fix: poll Activity.hasWindowFocus() every 50 ms before calling erl_start(). - // hasWindowFocus() returns true only after the window has been drawn and - // given input focus, which is *after* hwui finishes its thread-pool setup. - // We wait up to 3 seconds (covers slow emulators and heavily loaded devices) - // and fall through anyway so a stuck window never blocks BEAM forever. - // - // Why this lives here instead of in MainActivity.kt: - // Putting the delay in Kotlin would mean every app built on Mob needs to - // replicate and maintain the fix. Centralising it in mob_beam.c means - // app code can stay a simple `Thread({ nativeStartBeam() }).start()`. - // - // JNI threading notes: - // • beam-main is created via `new Thread()` in Kotlin, so it is already - // attached to the JVM when this function runs. Calling - // AttachCurrentThread on an already-attached thread is a no-op, but - // calling DetachCurrentThread on a Java-created thread makes ART abort. - // • We therefore call GetEnv first. If the thread is already attached - // (needs_detach == 0) we skip both Attach and Detach. Only a purely - // native thread that was never attached would set needs_detach == 1. - if (g_jvm && g_activity) { - mob_set_startup_phase("Waiting for window focus…"); - JNIEnv* env2 = NULL; - int needs_detach = ((*g_jvm)->GetEnv(g_jvm, (void**)&env2, JNI_VERSION_1_6) != JNI_OK); - if (needs_detach) - (*g_jvm)->AttachCurrentThread(g_jvm, &env2, NULL); - - jclass act_cls = (*env2)->GetObjectClass(env2, g_activity); - jmethodID has_focus = (*env2)->GetMethodID(env2, act_cls, "hasWindowFocus", "()Z"); - int waited = 0; - const int max_wait = 3000; /* ms — fall through if focus never arrives */ - while (!(*env2)->CallBooleanMethod(env2, g_activity, has_focus) && waited < max_wait) { - struct timespec ts = {0, 50000000}; /* 50 ms */ - nanosleep(&ts, NULL); - waited += 50; - } - /* Only detach if we attached above — detaching a Java thread aborts ART. */ - if (needs_detach) - (*g_jvm)->DetachCurrentThread(g_jvm); - if (waited >= max_wait) - LOGI("mob_start_beam: focus timeout (%d ms) — starting BEAM anyway", waited); - else if (waited) - LOGI("mob_start_beam: waited %d ms for window focus", waited); - } - // ── end cold-start race condition fix ──────────────────────────────────── - - mob_set_startup_phase("Starting BEAM…"); - LOGI("mob_start_beam: starting BEAM with module=%s, argc=%d", app_module, ac); - - // Symlink ERTS executables from BINDIR to the native lib dir. - // - // When installed via `adb install`, nativeLibraryDir contains the .so files - // and the symlink approach works (apk_data_file SELinux label allows execve). - // - // When installed via Play Store (split APKs), Android does NOT extract .so - // files to nativeLibraryDir on modern devices — they stay inside the split APK - // zip. In that case MobBridge.extractBeamHelpersFromSplitApk() copies the - // binaries directly into erts/bin/ before this point. We detect that scenario - // by checking whether the nativeLibDir target exists: if it doesn't, skip the - // unlink+symlink so we don't clobber the already-extracted real file. - if (s_native_lib_dir[0]) { - static const char* const exes[] = { - "erl_child_setup", "inet_gethost", "epmd", NULL - }; - static const char* const libs[] = { - "liberl_child_setup.so", "libinet_gethost.so", "libepmd.so", NULL - }; - char bin_path[512], lib_path[512]; - for (int i = 0; exes[i]; i++) { - snprintf(bin_path, sizeof(bin_path), - "%s/" ERTS_VSN "/bin/%s", otp_root, exes[i]); - snprintf(lib_path, sizeof(lib_path), - "%s/%s", s_native_lib_dir, libs[i]); - struct stat lib_st; - if (stat(lib_path, &lib_st) == 0) { - // nativeLibDir has the file (adb install) — use symlink - unlink(bin_path); - if (symlink(lib_path, bin_path) == 0) { - LOGI("mob_start_beam: symlink %s -> %s", exes[i], lib_path); - } else { - LOGE("mob_start_beam: symlink %s failed: %s", exes[i], strerror(errno)); - } - } else { - // nativeLibDir empty (Play Store split APK) — MobBridge should have - // extracted the binary directly to bin_path; leave it in place. - struct stat bin_st; - if (stat(bin_path, &bin_st) == 0) { - LOGI("mob_start_beam: symlink %s (extracted from split APK)", exes[i]); - } else { - LOGE("mob_start_beam: symlink %s missing from both nativeLibDir and bin/", exes[i]); - } - } - } - } - - // Symlink sqlite3_nif.so into the exqlite OTP lib structure so that - // code:priv_dir(:exqlite) resolves correctly. - // - // The OTP code server registers lib_dirs by scanning $OTP_ROOT/lib/*/ebin - // at boot. For code:lib_dir(:exqlite) to work, exqlite must live at - // $OTP_ROOT/lib/exqlite-VERSION/ — a flat -pa dir is NOT sufficient. - // The deployer creates $OTP_ROOT/lib/exqlite-VERSION/{ebin,priv}; we - // create the sqlite3_nif.so symlink inside priv/ at runtime so the path - // (which contains the APK install hash) is always up-to-date. - if (s_native_lib_dir[0]) { - char nif_target[560]; - snprintf(nif_target, sizeof(nif_target), "%s/libsqlite3_nif.so", s_native_lib_dir); - - // Scan $OTP_ROOT/lib/ for exqlite-* and symlink the NIF in its priv/. - char lib_path[600]; - snprintf(lib_path, sizeof(lib_path), "%s/lib", otp_root); - DIR *d = opendir(lib_path); - int found = 0; - if (d) { - struct dirent *entry; - while ((entry = readdir(d)) != NULL) { - if (strncmp(entry->d_name, "exqlite-", 8) == 0) { - char exqlite_priv[700]; - snprintf(exqlite_priv, sizeof(exqlite_priv), - "%s/%s/priv", lib_path, entry->d_name); - mkdir(exqlite_priv, 0755); - char nif_link[760]; - snprintf(nif_link, sizeof(nif_link), - "%s/sqlite3_nif.so", exqlite_priv); - struct stat nif_lib_st; - if (stat(nif_target, &nif_lib_st) == 0) { - // nativeLibDir has the NIF (adb install) — use symlink - unlink(nif_link); - if (symlink(nif_target, nif_link) == 0) { - LOGI("mob_start_beam: symlink exqlite NIF -> %s", nif_target); - found = 1; - } else { - LOGE("mob_start_beam: symlink exqlite NIF failed: %s", strerror(errno)); - } - } else { - // nativeLibDir empty — MobBridge extracted NIF directly to nif_link - struct stat nif_file_st; - if (stat(nif_link, &nif_file_st) == 0) { - LOGI("mob_start_beam: exqlite NIF extracted from split APK"); - found = 1; - } else { - LOGE("mob_start_beam: exqlite NIF missing from both nativeLibDir and priv/"); - } - } - break; - } - } - closedir(d); - } - - if (!found) { - // Fallback: symlink into flat beams_dir/priv/ for backward compatibility - // while the deployer hasn't yet created the versioned lib structure. - char priv_dir[660]; - snprintf(priv_dir, sizeof(priv_dir), "%s/priv", beams_dir); - mkdir(priv_dir, 0755); - char nif_link[720]; - snprintf(nif_link, sizeof(nif_link), "%s/sqlite3_nif.so", priv_dir); - struct stat nif_lib_fb_st; - if (stat(nif_target, &nif_lib_fb_st) == 0) { - unlink(nif_link); - if (symlink(nif_target, nif_link) == 0) { - LOGI("mob_start_beam: symlink sqlite3_nif.so (fallback) -> %s", nif_target); - } else { - LOGE("mob_start_beam: symlink sqlite3_nif (fallback) failed: %s", strerror(errno)); - } - } else { - struct stat nif_fb_file_st; - if (stat(nif_link, &nif_fb_file_st) == 0) { - LOGI("mob_start_beam: sqlite3_nif.so (fallback) extracted from split APK"); - } else { - LOGE("mob_start_beam: sqlite3_nif.so (fallback) missing — NIF load will fail"); - } - } - } - } - - // Symlink libpythonx.so into the pythonx OTP lib structure for the - // same reason as exqlite above. Pythonx's NIF on_load does - // path = :filename.join(:code.priv_dir(:pythonx), 'libpythonx') - // :erlang.load_nif(path, 0) - // For dlopen to resolve enif_* (defined in the main app native lib) - // the .so has to live in the app's namespace — i.e. nativeLibraryDir. - // mob_dev's NativeBuild already places libpythonx.so in jniLibs, so - // the APK installer extracts it to nativeLibraryDir at install time. - // We just symlink into the OTP lib priv/ to make :code.priv_dir - // return a path that dlopen can follow. - if (s_native_lib_dir[0]) { - char pyx_target[560]; - snprintf(pyx_target, sizeof(pyx_target), "%s/libpythonx.so", s_native_lib_dir); - - struct stat pyx_target_st; - if (stat(pyx_target, &pyx_target_st) == 0) { - char lib_path[600]; - snprintf(lib_path, sizeof(lib_path), "%s/lib", otp_root); - DIR *d2 = opendir(lib_path); - if (d2) { - struct dirent *entry; - while ((entry = readdir(d2)) != NULL) { - if (strncmp(entry->d_name, "pythonx-", 8) == 0) { - char pyx_priv[700]; - snprintf(pyx_priv, sizeof(pyx_priv), "%s/%s/priv", - lib_path, entry->d_name); - mkdir(pyx_priv, 0755); - char pyx_link[760]; - snprintf(pyx_link, sizeof(pyx_link), "%s/libpythonx.so", - pyx_priv); - unlink(pyx_link); - if (symlink(pyx_target, pyx_link) == 0) { - LOGI("mob_start_beam: symlink pythonx NIF -> %s", - pyx_target); - } else { - LOGE("mob_start_beam: symlink pythonx NIF failed: %s", - strerror(errno)); - } - break; - } - } - closedir(d2); - } - } - } - - void erl_start(int, char**); - erl_start(ac, (char**)args); - mob_set_startup_error("BEAM exited unexpectedly — see logcat (tag: MobBeam) for details"); - LOGE("mob_start_beam: erl_start returned (unexpected)"); -} diff --git a/android/jni/mob_beam.h b/android/jni/mob_beam.h index 56781fff..13a82940 100644 --- a/android/jni/mob_beam.h +++ b/android/jni/mob_beam.h @@ -5,10 +5,12 @@ #define MOB_BEAM_H #include <jni.h> +#include <stddef.h> // size_t — ditto +#include <stdint.h> // uint8_t — for mob_deliver_vendor_usb_data // Call from JNI_OnLoad (main thread). // bridge_class: e.g. "com/myapp/MobBridge" -void mob_ui_cache_class(JNIEnv* env, const char* bridge_class); +void mob_ui_cache_class(JNIEnv *env, const char *bridge_class); // Send a tap event to the BEAM process registered for handle. // Called from the app's Java_..._MobBridge_nativeSendTap JNI stub. @@ -16,8 +18,8 @@ void mob_send_tap(int handle); // Send a {:change, tag, value} event. Called from the app's // Java_..._MobBridge_nativeSendChange* JNI stubs. -void mob_send_change_str(int handle, const char* utf8); -void mob_send_change_bool(int handle, int bool_val); // 0 = false, 1 = true +void mob_send_change_str(int handle, const char *utf8); +void mob_send_change_bool(int handle, int bool_val); // 0 = false, 1 = true void mob_send_change_float(int handle, double value); // Send {:focus, tag}, {:blur, tag}, {:submit, tag} events. @@ -32,7 +34,7 @@ void mob_send_select(int handle); // fields. phase is "began" | "updating" | "committed" | "cancelled". Apps // that observe this can implement commit-only behaviour for CJK input // (ignore on_change while composing, replace text on :committed). -void mob_send_compose(int handle, const char* text, const char* phase); +void mob_send_compose(int handle, const char *text, const char *phase); // ── Gesture senders (Batch 4) ──────────────────────────────────────────── // Called from beam_jni.c JNI stubs when Compose's gesture detector fires. @@ -45,29 +47,21 @@ void mob_send_swipe_up(int handle); void mob_send_swipe_down(int handle); // Direction-aware: emits {:swipe, tag, direction_atom} where direction is // "left" | "right" | "up" | "down". -void mob_send_swipe_with_direction(int handle, const char* direction); +void mob_send_swipe_with_direction(int handle, const char *direction); // ── Batch 5 Tier 1: high-frequency scroll/drag/pinch/rotate/pointer ───── // Throttling and delta-thresholding are applied native-side BEFORE these // fire — by the time they're called, the BEAM crossing is justified. // Defaults (when no explicit config): scroll 33ms/1px, drag 16ms/1px, // pinch 16ms/0.01, rotate 16ms/1°, pointer_move 33ms/4px. -void mob_set_throttle_config(int handle, - int throttle_ms, int debounce_ms, - double delta_threshold, +void mob_set_throttle_config(int handle, int throttle_ms, int debounce_ms, double delta_threshold, int leading, int trailing); // Phase is "began" | "dragging" | "decelerating" | "ended" -void mob_send_scroll(int handle, - double x, double y, - double dx, double dy, - double vx, double vy, - const char* phase); -void mob_send_drag(int handle, - double x, double y, - double dx, double dy, - const char* phase); -void mob_send_pinch(int handle, double scale, double velocity, const char* phase); -void mob_send_rotate(int handle, double degrees, double velocity, const char* phase); +void mob_send_scroll(int handle, double x, double y, double dx, double dy, double vx, double vy, + const char *phase); +void mob_send_drag(int handle, double x, double y, double dx, double dy, const char *phase); +void mob_send_pinch(int handle, double scale, double velocity, const char *phase); +void mob_send_rotate(int handle, double degrees, double velocity, const char *phase); void mob_send_pointer_move(int handle, double x, double y); // ── Batch 5 Tier 2: semantic single-fire scroll events ── @@ -82,55 +76,144 @@ void mob_send_scrolled_past(int handle); void mob_handle_back(void); // Call from nativeSetActivity. -void mob_init_bridge(JNIEnv* env, jobject activity); +void mob_init_bridge(JNIEnv *env, jobject activity); // Call from nativeStartBeam. // app_module: Erlang module name, e.g. "mob_demo" -void mob_start_beam(const char* app_module); +void mob_start_beam(const char *app_module); // Update the startup status shown on screen while BEAM is initialising. // mob_set_startup_error stalls the screen with an error message (does not crash). // Both are safe to call from any thread; no-op if MobBridge lacks the method. -void mob_set_startup_phase(const char* phase); -void mob_set_startup_error(const char* error); +void mob_set_startup_phase(const char *phase); +void mob_set_startup_error(const char *error); // Global JVM pointer — defined in mob_beam.c, extern'd for mob_nif.c. -extern JavaVM* g_jvm; +extern JavaVM *g_jvm; extern jobject g_activity; // ── Device capability delivery functions ───────────────────────────────── // Called from beam_jni.c JNI stubs when Kotlin delivers async results. // pid is an ErlNifPid passed as jlong through Kotlin. -void mob_deliver_atom2(jlong pid, const char* a1, const char* a2); -void mob_deliver_atom3(jlong pid, const char* a1, const char* a2, const char* a3); -void mob_deliver_location(jlong pid, double lat, double lon, double acc, double alt); -void mob_deliver_motion(jlong pid, double ax, double ay, double az, - double gx, double gy, double gz, long long ts); -void mob_deliver_file_result(jlong pid, const char* event, const char* sub, - const char* json_items); -void mob_deliver_push_token(jlong pid, const char* token); -void mob_deliver_notification(jlong pid, const char* json); -void mob_set_launch_notification(const char* json); +void mob_deliver_atom2(jlong pid, const char *a1, const char *a2); +void mob_deliver_atom3(jlong pid, const char *a1, const char *a2, const char *a3); +void mob_deliver_motion(jlong pid, double ax, double ay, double az, double gx, double gy, double gz, + long long ts); +void mob_deliver_motion_mag(jlong pid, double ax, double ay, double az, double gx, double gy, + double gz, double mx, double my, double mz, double heading, + long long ts); +void mob_deliver_file_result(jlong pid, const char *event, const char *sub, const char *json_items); +void mob_deliver_camera_frame(jlong pid, const unsigned char *bytes, size_t nbytes, int width, + int height, const char *format, jlong timestamp_ms, jlong dropped); +void mob_deliver_push_token(jlong pid, const char *token); +void mob_deliver_notification(jlong pid, const char *json); +void mob_set_launch_notification(const char *json); +// Store a document ("open with") item JSON ({path,name,mime,size}) handed to us +// by MainActivity from an ACTION_VIEW / ACTION_SEND intent. Consumed via +// Mob.Files.take_opened_document/0. +void mob_set_opened_document(const char *json); // Deliver WebView events from Java/Kotlin to the registered owner pid. // `mob_deliver_webview_message` for postMessage payloads from JS, // `mob_deliver_webview_blocked` for navigation attempts to disallowed URLs. -void mob_deliver_webview_message(jlong pid, const char* json); -void mob_deliver_webview_blocked(jlong pid, const char* url); +void mob_deliver_webview_message(jlong pid, const char *json); +void mob_deliver_webview_blocked(jlong pid, const char *url); + +// Deliver vendor_usb (Mob.VendorUsb / USB host) events. Each builds a +// 5-tuple {:peripheral, :vendor_usb, tag, session, payload} and posts +// it to pid. devices/permission/opened carry a JSON binary, decoded +// Elixir-side by Mob.VendorUsb.normalize_message/1. session=-1 → :nil +// atom; session>=0 → integer. +void mob_deliver_vendor_usb_devices(jlong pid, const char *json_array); +void mob_deliver_vendor_usb_permission(jlong pid, int granted, const char *device_json); +void mob_deliver_vendor_usb_opened(jlong pid, int session, const char *device_json); +void mob_deliver_vendor_usb_data(jlong pid, int session, const uint8_t *bytes, size_t nbytes); +void mob_deliver_vendor_usb_write_complete(jlong pid, int session, int bytes_written); +void mob_deliver_vendor_usb_event(jlong pid, int session, + const char *tag, // "closed" | "disconnected" | "error" + const char *reason); // atom-safe ASCII or NULL // Deliver {:alert, action_atom} to the registered :mob_screen process. // Called from beam_jni.c when a dialog button is tapped. -void mob_deliver_alert_action(const char* action); +void mob_deliver_alert_action(const char *action); // Deliver {:component_event, event, payload_json} to a native view component process. // Called from beam_jni.c when Kotlin fires a component event via the send callback. -void mob_send_component_event(int handle, const char* event, const char* payload_json); +void mob_send_component_event(int handle, const char *event, const char *payload_json); // Deliver {:mob_device, :color_scheme_changed, :light | :dark} to the // dispatcher pid registered via Mob.Device. Called from beam_jni.c's // nativeNotifyColorScheme when MainActivity sees a uiMode flip. // `scheme` must be "light" or "dark". -void mob_send_color_scheme_changed(const char* scheme); +void mob_send_color_scheme_changed(const char *scheme); + +// Deliver {:mob_device, :connectivity_changed, +// %{online, transport, expensive, validated, constrained}} +// to the dispatcher pid registered via Mob.Device. Called from beam_jni.c's +// nativeNotifyConnectivity when the ConnectivityManager.NetworkCallback fires. +// `online`/`expensive`/`validated` are 0/1; `transport` is +// "wifi" | "cellular" | "wired" | "other" | "none". +void mob_send_connectivity_changed(int online, const char *transport, int expensive, int validated); + +// mob_beam.h additions for Mob.Bt +// +// Append these to the existing mob_beam.h, after the +// mob_deliver_vendor_usb_* block. Order matches the +// `pub export fn mob_deliver_bt_*` definitions in mob_nif.zig. +// +// All BT deliveries take a jlong pid (ErlNifPid round-tripped through +// Kotlin) and post a 4-tuple `{:bt|:bt_hfp|:bt_spp|:bt_hid, tag, session_or_nil, payload}` +// to that pid. Payload shape varies by event; see mob_nif.zig for details. + +// ── Discovery (no-payload 2-tuples) ────────────────────────────────────── +void mob_deliver_bt_discovery_started(jlong pid); +void mob_deliver_bt_discovery_finished(jlong pid); +void mob_deliver_bt_discovery_cancelled(jlong pid); + +// ── Discovery / pairing events (3-tuples, no session) ──────────────────── +void mob_deliver_bt_discovered(jlong pid, const char *address, const char *name, int bonded); +void mob_deliver_bt_paired(jlong pid, const char *address, const char *name, int bonded); +void mob_deliver_bt_pair_failed(jlong pid, const char *address, const char *reason); +void mob_deliver_bt_unpaired(jlong pid, const char *address); +void mob_deliver_bt_error(jlong pid, const char *reason); + +// ── Legacy JSON paired-devices envelope (compat with older mob_new templates) ── +void mob_deliver_bt_paired_devices(jlong pid, const char *json); + +// ── Paired-list streaming (begin / entry / finish) ────────────────────── +// Kotlin invokes begin, then 0..N entry calls, then finish. The finish +// call emits a single `{:bt, :paired_list, list}` to the originating pid. +void mob_deliver_bt_paired_list_begin(jlong pid); +void mob_deliver_bt_paired_list_entry(jlong pid, const char *address, const char *name, int bonded); +void mob_deliver_bt_paired_list_finish(jlong pid); + +// ── HFP profile (8 deliveries) ────────────────────────────────────────── +void mob_deliver_bt_hfp_connecting(jlong pid, int session, const char *address); +void mob_deliver_bt_hfp_connected(jlong pid, int session, const char *address, const char *name); +void mob_deliver_bt_hfp_connect_failed(jlong pid, const char *address, const char *reason); +void mob_deliver_bt_hfp_disconnected(jlong pid, int session, const char *reason_atom); +void mob_deliver_bt_hfp_vendor_subscribed(jlong pid, int session); +void mob_deliver_bt_hfp_vendor_at(jlong pid, int session, const char *cmd, int cmd_type, + const char *args, const char *address); +void mob_deliver_bt_hfp_sco_started(jlong pid, int session, const char *address); +void mob_deliver_bt_hfp_sco_stopped(jlong pid, int session); +void mob_deliver_bt_hfp_sco_audio(jlong pid, int session, const char *pcm, size_t len); +void mob_deliver_bt_hfp_error(jlong pid, int session, const char *reason); + +// ── SPP profile (6 deliveries) ────────────────────────────────────────── +void mob_deliver_bt_spp_connected(jlong pid, int session, const char *address, const char *name); +void mob_deliver_bt_spp_connect_failed(jlong pid, const char *address, const char *reason); +void mob_deliver_bt_spp_disconnected(jlong pid, int session, const char *reason_atom); +void mob_deliver_bt_spp_data(jlong pid, int session, const char *bytes, size_t len); +void mob_deliver_bt_spp_written(jlong pid, int session, int size); +void mob_deliver_bt_spp_error(jlong pid, int session, const char *reason); + +// ── HID profile (5 deliveries) ────────────────────────────────────────── +void mob_deliver_bt_hid_connected(jlong pid, int session, const char *address); +void mob_deliver_bt_hid_connect_failed(jlong pid, const char *address, const char *reason); +void mob_deliver_bt_hid_disconnected(jlong pid, int session, const char *reason_atom); +void mob_deliver_bt_hid_input(jlong pid, int session, int type, int code, int value); +void mob_deliver_bt_hid_raw_report(jlong pid, int session, const char *bytes, size_t len); #endif // MOB_BEAM_H diff --git a/android/jni/mob_beam.zig b/android/jni/mob_beam.zig new file mode 100644 index 00000000..8c06a907 --- /dev/null +++ b/android/jni/mob_beam.zig @@ -0,0 +1,740 @@ +//! mob_beam.zig — Mob BEAM launcher and JNI bridge initialisation (Android). +//! +//! Phase 6b iter 2 of the build-system migration: Zig port of the original +//! mob_beam.c. Behaviour is intentionally byte-for-byte equivalent — every +//! load-bearing comment in the C version (cold-start race fix, SELinux exec +//! rules, Play Store split-APK fallback, exqlite/pythonx priv-dir symlinks) +//! is preserved verbatim because future maintainers will hit the same +//! constraints and need the same explanations in front of them. +//! +//! The FFI surface (JNI vtable, libc, Android log, dlfcn, pthreads) lives in +//! mob_zig.zig — see that file's header for why we hand-declare it (Zig +//! 0.17-dev's @cImport is gone and `zig translate-c` hangs on the NDK's +//! jni.h). +//! +//! Symbols defined elsewhere in the link: +//! * mob_nif.c provides `_mob_ui_cache_class_impl`, +//! `_mob_bridge_init_activity`, +//! `mob_set_startup_phase`, +//! `mob_set_startup_error`, +//! `g_jvm`, `g_activity`. +//! * libbeam.a provides `erl_start`. + +const std = @import("std"); +const jni = @import("mob_zig.zig"); +const build_options = @import("build_options"); + +// ── Comptime build flags ────────────────────────────────────────────────── +// Compile-time knobs threaded in by build.zig via `b.addOptions()`. +// +// * `no_beam` — Config A: baseline measurement. BEAM never launched, the +// activity stays a stock Android shell. Used for battery benchmarks. +// * `beam_flags_mode` — picks the default scheduler-tuning argv shape: +// - "untuned": no flags (stock Erlang defaults) +// - "sbwt_only": only -sbwt none / -sbwtdcpu / -sbwtdio (cuts the +// scheduler-busy-wait idle drain; lightest tuning) +// - "nerves_full": full Nerves-style tuning (-S 1:1 -SDcpu 1:1 ...) +// (default) +// +// The runtime override (beams_dir/mob_beam_flags) supersedes either default. +const NO_BEAM: bool = build_options.no_beam; +const BEAM_FLAGS_MODE: []const u8 = build_options.beam_flags_mode; + +// ── Logging ─────────────────────────────────────────────────────────────── + +const LOG_TAG: [*:0]const u8 = "MobBeam"; + +inline fn logi(comptime fmt: []const u8, args: anytype) void { + jni.logWrite(jni.ANDROID_LOG_INFO, LOG_TAG, fmt, args); +} + +inline fn loge(comptime fmt: []const u8, args: anytype) void { + jni.logWrite(jni.ANDROID_LOG_ERROR, LOG_TAG, fmt, args); +} + +inline fn lastErrno() [*:0]const u8 { + return jni.strerror(jni.__errno().*); +} + +// ── Externs from mob_nif.c ──────────────────────────────────────────────── +// Forward declarations for symbols that live next to us in the final .so. +// These are defined by mob_nif.c (kept C in iter 2 — port in a later iter). + +extern fn _mob_ui_cache_class_impl(env: *jni.JNIEnv, bridge_class: [*:0]const u8) callconv(.c) void; +extern fn _mob_bridge_init_activity(env: *jni.JNIEnv, activity: jni.JObject) callconv(.c) void; +extern fn mob_set_startup_phase(phase: [*:0]const u8) callconv(.c) void; +extern fn mob_set_startup_error(err: [*:0]const u8) callconv(.c) void; + +// Global JVM pointer + Activity global ref. Defined in mob_nif.c, populated +// from JNI_OnLoad / mob_init_bridge. Both may be null until those run. +extern var g_jvm: ?*jni.JavaVM; +extern var g_activity: jni.JObject; + +// ── Extern from libbeam.a ───────────────────────────────────────────────── +// BEAM entry point. erl_start blocks forever in the normal case; returning +// is an unexpected-exit condition we report and let the OS reap the process. +extern fn erl_start(argc: c_int, argv: [*]const ?[*:0]const u8) callconv(.c) void; + +// ── Constants ───────────────────────────────────────────────────────────── + +const ERTS_VSN: []const u8 = "erts-17.0"; + +// ── Module-level state ──────────────────────────────────────────────────── +// Populated in mob_init_bridge, read by mob_start_beam. Sized generously +// so paths under /data/data/<package>/files/... never truncate. + +var s_native_lib_dir: [512]u8 = @splat(0); +var s_files_dir: [512]u8 = @splat(0); + +// Runtime BEAM flag override loaded from beams_dir/mob_beam_flags. +// In-place tokenised (NULs replace whitespace), pointers indexed into the +// buffer. Same shape as the C version. +var s_flags_buf: [512]u8 = @splat(0); +var s_runtime_flags: [64]?[*:0]const u8 = @splat(null); +var s_runtime_flag_count: usize = 0; + +// ── Small helpers ───────────────────────────────────────────────────────── + +/// Format `fmt`/`args` into `buf`, NUL-terminating the result. Returns a +/// `[*:0]const u8` view of the buffer. Mirrors `snprintf(buf, sizeof(buf), ...)`. +fn formatZ(buf: []u8, comptime fmt: []const u8, args: anytype) [*:0]const u8 { + std.debug.assert(buf.len > 0); + const slice = std.fmt.bufPrint(buf, fmt, args) catch buf[0 .. buf.len - 1]; + const end = @min(slice.len, buf.len - 1); + buf[end] = 0; + return @ptrCast(buf.ptr); +} + +inline fn isWhitespace(c: u8) bool { + return c == ' ' or c == '\t' or c == '\n' or c == '\r'; +} + +// ── BEAM stdout/stderr → logcat ────────────────────────────────────────── +// Without this, anything the BEAM writes to stderr (including ** crash +// reports from Logger and the boot script's :application.start/2 errors) +// is silently dropped on Android. Wire stdout + stderr to a pipe and read +// them on a detached thread, emitting each line under the "BEAMout" tag. +// One-shot: called once from mob_init_bridge before any BEAM code runs. +// +// See beam_crash.md (Incident #1) for the case that motivated this. + +fn mobBeamLogReader(arg: ?*anyopaque) callconv(.c) ?*anyopaque { + const fd: c_int = @intCast(@intFromPtr(arg)); + var buf: [4096]u8 = undefined; + var line: [4096]u8 = undefined; + var line_pos: usize = 0; + while (true) { + const n = jni.read(fd, &buf, buf.len); + if (n <= 0) break; + const got: usize = @intCast(n); + var i: usize = 0; + while (i < got) : (i += 1) { + const c = buf[i]; + if (c == '\n' or line_pos >= line.len - 1) { + line[line_pos] = 0; + if (line_pos > 0) { + const cstr: [*:0]const u8 = @ptrCast(&line); + _ = jni.__android_log_write(jni.ANDROID_LOG_INFO, "BEAMout", cstr); + } + line_pos = 0; + } else if (c != '\r') { + line[line_pos] = c; + line_pos += 1; + } + } + } + return null; +} + +fn mobCaptureBeamStdio() void { + var pipe_fds: [2]c_int = undefined; + if (jni.pipe(&pipe_fds) != 0) { + loge("mob_capture_beam_stdio: pipe() failed: {s}", .{lastErrno()}); + return; + } + if (jni.dup2(pipe_fds[1], jni.STDOUT_FILENO) < 0) { + loge("mob_capture_beam_stdio: dup2 stdout failed: {s}", .{lastErrno()}); + } + if (jni.dup2(pipe_fds[1], jni.STDERR_FILENO) < 0) { + loge("mob_capture_beam_stdio: dup2 stderr failed: {s}", .{lastErrno()}); + } + _ = jni.close(pipe_fds[1]); + + var tid: jni.PthreadT = 0; + const arg: ?*anyopaque = @ptrFromInt(@as(usize, @intCast(pipe_fds[0]))); + if (jni.pthread_create(&tid, null, mobBeamLogReader, arg) != 0) { + loge("mob_capture_beam_stdio: pthread_create failed: {s}", .{lastErrno()}); + _ = jni.close(pipe_fds[0]); + return; + } + _ = jni.pthread_detach(tid); + + // Disable buffering so output reaches the pipe immediately, not on + // exit (which we never reach for a long-running BEAM). + _ = jni.setvbuf(jni.stdout, null, jni._IONBF, 0); + _ = jni.setvbuf(jni.stderr, null, jni._IONBF, 0); + logi("mob_capture_beam_stdio: piping stdout/stderr to logcat (tag: BEAMout)", .{}); +} + +// ── Public entry points ─────────────────────────────────────────────────── + +export fn mob_ui_cache_class(env: *jni.JNIEnv, bridge_class: [*:0]const u8) callconv(.c) void { + _mob_ui_cache_class_impl(env, bridge_class); +} + +export fn mob_init_bridge(env: *jni.JNIEnv, activity: jni.JObject) callconv(.c) void { + // Capture BEAM stdio first so any startup errors (NIF load failures, + // application:start/2 crashes) land in logcat instead of /dev/null. + mobCaptureBeamStdio(); + + const activity_global = jni.newGlobalRef(env, activity); + g_activity = activity_global; + _mob_bridge_init_activity(env, activity_global); + + // Get nativeLibraryDir so mob_start_beam can symlink ERTS executables there. + // Files in the native lib dir carry the apk_data_file SELinux label which + // allows execve() from untrusted_app, unlike files in app_data_file. + const ctx_cls = jni.findClass(env, "android/content/Context"); + const get_app_info = jni.getMethodID(env, ctx_cls, "getApplicationInfo", "()Landroid/content/pm/ApplicationInfo;"); + const app_info = jni.callObjectMethod(env, activity, get_app_info); + const app_info_cls = jni.findClass(env, "android/content/pm/ApplicationInfo"); + const fid = jni.getFieldID(env, app_info_cls, "nativeLibraryDir", "Ljava/lang/String;"); + const jdir = jni.getObjectField(env, app_info, fid); + if (jni.getStringUTFChars(env, jdir)) |dir| { + jni.copyZ(&s_native_lib_dir, dir); + jni.releaseStringUTFChars(env, jdir, dir); + } + logi("mob_init_bridge: native lib dir = {s}", .{jni.asCStr(&s_native_lib_dir)}); + + // Get filesDir for OTP root path (app-specific, avoids hardcoding package name). + const get_files_dir = jni.getMethodID(env, ctx_cls, "getFilesDir", "()Ljava/io/File;"); + const files_dir_obj = jni.callObjectMethod(env, activity, get_files_dir); + const file_cls = jni.findClass(env, "java/io/File"); + const get_path = jni.getMethodID(env, file_cls, "getPath", "()Ljava/lang/String;"); + const jfiles_path = jni.callObjectMethod(env, files_dir_obj, get_path); + if (jni.getStringUTFChars(env, jfiles_path)) |fp| { + jni.copyZ(&s_files_dir, fp); + jni.releaseStringUTFChars(env, jfiles_path, fp); + } + logi("mob_init_bridge: files dir = {s}", .{jni.asCStr(&s_files_dir)}); +} + +export fn mob_start_beam(app_module: [*:0]const u8) callconv(.c) void { + if (NO_BEAM) { + // Config A: baseline measurement — stock Android activity, BEAM never launched. + logi("mob_start_beam: NO_BEAM defined, skipping BEAM launch (battery baseline)", .{}); + return; + } + + // Re-dlopen ourselves with RTLD_GLOBAL so the BEAM's enif_* symbols + // (statically linked into this library) are visible when the BEAM + // later dlopens a NIF library (e.g. crypto.so). Without this, Android + // loads libpigeon.so with RTLD_LOCAL by default, hiding enif_* from + // dlopen'd children — crypto.so on_load fails with + // `cannot locate symbol enif_get_tuple`. + { + var self_path_buf: [600]u8 = undefined; + const self_path = formatZ(&self_path_buf, "{s}/lib{s}.so", .{ + jni.asCStr(&s_native_lib_dir), + app_module, + }); + if (jni.dlopen(self_path, jni.RTLD_NOW | jni.RTLD_GLOBAL) == null) { + const err: [*:0]const u8 = jni.dlerror() orelse "unknown"; + loge("mob_start_beam: dlopen self with RTLD_GLOBAL failed: {s}", .{err}); + } else { + logi("mob_start_beam: re-dlopened self RTLD_GLOBAL: {s}", .{self_path}); + } + } + + mob_set_startup_phase("Setting up BEAM environment…"); + + // Build all paths dynamically from s_files_dir (set in mob_init_bridge). + var otp_root_buf: [560]u8 = undefined; + const otp_root = formatZ(&otp_root_buf, "{s}/otp", .{jni.asCStr(&s_files_dir)}); + + var bindir_buf: [600]u8 = undefined; + const bindir = formatZ(&bindir_buf, "{s}/{s}/bin", .{ otp_root, ERTS_VSN }); + + var beams_dir_buf: [600]u8 = undefined; + const beams_dir = formatZ(&beams_dir_buf, "{s}/{s}", .{ otp_root, app_module }); + + var elixir_dir_buf: [600]u8 = undefined; + const elixir_dir = formatZ(&elixir_dir_buf, "{s}/lib/elixir/ebin", .{otp_root}); + + var logger_dir_buf: [600]u8 = undefined; + const logger_dir = formatZ(&logger_dir_buf, "{s}/lib/logger/ebin", .{otp_root}); + + var eex_dir_buf: [600]u8 = undefined; + const eex_dir = formatZ(&eex_dir_buf, "{s}/lib/eex/ebin", .{otp_root}); + + var crash_dump_buf: [560]u8 = undefined; + const crash_dump = formatZ(&crash_dump_buf, "{s}/erl_crash.dump", .{jni.asCStr(&s_files_dir)}); + + _ = jni.setenv("BINDIR", bindir, 1); + _ = jni.setenv("ROOTDIR", otp_root, 1); + _ = jni.setenv("PROGNAME", "erl", 1); + _ = jni.setenv("EMU", "beam", 1); + _ = jni.setenv("HOME", jni.asCStr(&s_files_dir), 1); + _ = jni.setenv("MOB_DATA_DIR", jni.asCStr(&s_files_dir), 1); + + // MOB_BEAMS_DIR — the directory where app BEAMs (and priv/) are deployed. + // + // Problem: Ecto.Migrator uses :code.priv_dir(app) to locate migration .exs + // files. :code.priv_dir/1 works by looking up the app's OTP lib structure + // ($OTP_ROOT/lib/APP-VERSION/ebin/). Mob apps are deployed to a flat -pa + // directory (e.g. files/otp/my_app/*.beam), not an OTP lib structure, so + // :code.priv_dir/1 returns {error, bad_name} and Ecto silently reports + // "Migrations already up" without running anything. + // + // Fix: deployer.ex pushes priv/ alongside the BEAMs into beams_dir/priv/. + // App code reads MOB_BEAMS_DIR at startup and passes the explicit path to + // Ecto.Migrator.run/4 instead of relying on :code.priv_dir/1. This env var + // is the only reliable way to communicate beams_dir to Elixir code since it + // is computed here from getFilesDir() at runtime (the path includes the + // Android user ID which is not predictable at compile time). + _ = jni.setenv("MOB_BEAMS_DIR", beams_dir, 1); + _ = jni.setenv("ERL_CRASH_DUMP", crash_dump, 1); + _ = jni.setenv("ERL_CRASH_DUMP_SECONDS", "30", 1); + + // MOB_NATIVE_LIB_DIR — the app's nativeLibraryDir (apk_data_file context, + // exec allowed). Apps that bundle extra binaries (escript, rebar3, etc.) + // as `lib<name>.so` in jniLibs/<abi>/ can find them here at runtime — + // their paths include the APK install hash and aren't predictable at + // compile time. Empty when launched from a split APK that didn't extract + // .so files; callers should fall back to BINDIR in that case. + if (s_native_lib_dir[0] != 0) { + _ = jni.setenv("MOB_NATIVE_LIB_DIR", jni.asCStr(&s_native_lib_dir), 1); + } + + // RUSTLER_BEAM_LIBRARY_PATH — tells rustler where the .so containing it + // (libpigeon.so in Mob's static-link model) lives, so its + // DlsymNifFiller can dlopen(path, RTLD_NOW | RTLD_NOLOAD) directly + // instead of dlopen(NULL). On Bionic, dlopen(NULL) returns the app + // process namespace which misses sibling .so's exported symbols even + // when System.loadLibrary'd with RTLD_GLOBAL — see filmor's comment on + // rusterlium/rustler#726. dladdr on a function we know is in this .so + // (mob_start_beam itself) gives us dli_fname = the absolute load path. + { + var info = std.mem.zeroes(jni.DlInfo); + const probe: *const anyopaque = @ptrCast(&mob_start_beam); + if (jni.dladdr(probe, &info) != 0) { + if (info.dli_fname) |fname| { + _ = jni.setenv("RUSTLER_BEAM_LIBRARY_PATH", fname, 1); + _ = jni.__android_log_print( + jni.ANDROID_LOG_INFO, + "MobBeam", + "RUSTLER_BEAM_LIBRARY_PATH=%s", + fname, + ); + } + } + } + + var eval_expr_buf: [280]u8 = undefined; + const eval_expr = formatZ(&eval_expr_buf, "{s}:start().", .{app_module}); + + // Compile-time default BEAM tuning flags. Selected by build_options.beam_flags_mode + // (untuned / sbwt_only / nerves_full). Runtime override below wins if present. + const default_flags: []const [*:0]const u8 = comptime selectDefaultFlags(); + + // Runtime override: read whitespace-separated flags from beams_dir/mob_beam_flags. + // Written by `mix mob.deploy --schedulers N` or `--beam-flags "..."`. + { + var flags_path_buf: [640]u8 = undefined; + const flags_path = formatZ(&flags_path_buf, "{s}/mob_beam_flags", .{beams_dir}); + if (jni.fopen(flags_path, "r")) |fp| { + const n_read = jni.fread(&s_flags_buf, 1, s_flags_buf.len - 1, fp); + _ = jni.fclose(fp); + s_flags_buf[n_read] = 0; + s_runtime_flag_count = 0; + var p: usize = 0; + while (p < n_read and s_runtime_flag_count < 63) { + while (p < n_read and isWhitespace(s_flags_buf[p])) : (p += 1) {} + if (p >= n_read or s_flags_buf[p] == 0) break; + s_runtime_flags[s_runtime_flag_count] = @ptrCast(&s_flags_buf[p]); + s_runtime_flag_count += 1; + while (p < n_read and !isWhitespace(s_flags_buf[p]) and s_flags_buf[p] != 0) : (p += 1) {} + if (p < n_read) { + s_flags_buf[p] = 0; + p += 1; + } + } + s_runtime_flags[s_runtime_flag_count] = null; + logi("mob_start_beam: loaded {d} runtime flags from {s}", .{ s_runtime_flag_count, flags_path }); + } + } + + var boot_path_buf: [580]u8 = undefined; + const boot_path = formatZ(&boot_path_buf, "{s}/releases/29/start_clean", .{otp_root}); + + var args: [128]?[*:0]const u8 = @splat(null); + var ac: usize = 0; + args[ac] = "beam"; + ac += 1; + if (s_runtime_flag_count > 0) { + var i: usize = 0; + while (i < s_runtime_flag_count) : (i += 1) { + args[ac] = s_runtime_flags[i]; + ac += 1; + } + } else { + for (default_flags) |f| { + args[ac] = f; + ac += 1; + } + } + args[ac] = "--"; + ac += 1; + args[ac] = "-root"; + ac += 1; + args[ac] = otp_root; + ac += 1; + args[ac] = "-bindir"; + ac += 1; + args[ac] = bindir; + ac += 1; + args[ac] = "-progname"; + ac += 1; + args[ac] = "erl"; + ac += 1; + args[ac] = "--"; + ac += 1; + args[ac] = "-noshell"; + ac += 1; + args[ac] = "-noinput"; + ac += 1; + args[ac] = "-boot"; + ac += 1; + args[ac] = boot_path; + ac += 1; + args[ac] = "-pa"; + ac += 1; + args[ac] = elixir_dir; + ac += 1; + args[ac] = "-pa"; + ac += 1; + args[ac] = logger_dir; + ac += 1; + args[ac] = "-pa"; + ac += 1; + args[ac] = eex_dir; + ac += 1; + args[ac] = "-pa"; + ac += 1; + args[ac] = beams_dir; + ac += 1; + args[ac] = "-eval"; + ac += 1; + args[ac] = eval_expr; + ac += 1; + args[ac] = null; + + // ── Cold-start race condition fix ──────────────────────────────────────── + // + // DO NOT REMOVE THIS BLOCK. + // + // Problem: on a cold start (first launch after install or after the process + // was killed), calling erl_start() too early causes a SIGABRT deep inside + // ERTS pthread initialisation. The crash looks like: + // + // FORTIFY: pthread_mutex_lock called on a destroyed mutex + // backtrace: + // #00 abort + // #01 pthread_mutex_lock (FORTIFY wrapper) + // #02 ... (ERTS internal thread pool setup) + // #03 erl_start + // + // Root cause: Android's hwui (hardware-accelerated UI renderer) creates its + // own native thread pool during the very first layout/draw pass. That + // initialisation uses pthread mutexes that it allocates and later destroys. + // ERTS also calls into pthreads during erl_start(). If erl_start() runs + // concurrently with hwui's first-draw setup, the two pthread paths race on + // the same internal libc state and the FORTIFY mutex check fires → SIGABRT. + // + // The race only reproduces on cold start because: + // • On warm start hwui's thread pool already exists → no race. + // • The window-focus event is the earliest point at which Android + // guarantees the first layout/draw pass has completed, so hwui's + // pthread state is stable. + // + // Fix: poll Activity.hasWindowFocus() every 50 ms before calling erl_start(). + // hasWindowFocus() returns true only after the window has been drawn and + // given input focus, which is *after* hwui finishes its thread-pool setup. + // We wait up to 3 seconds (covers slow emulators and heavily loaded devices) + // and fall through anyway so a stuck window never blocks BEAM forever. + // + // Why this lives here instead of in MainActivity.kt: + // Putting the delay in Kotlin would mean every app built on Mob needs to + // replicate and maintain the fix. Centralising it in mob_beam.zig means + // app code can stay a simple `Thread({ nativeStartBeam() }).start()`. + // + // JNI threading notes: + // • beam-main is created via `new Thread()` in Kotlin, so it is already + // attached to the JVM when this function runs. Calling + // AttachCurrentThread on an already-attached thread is a no-op, but + // calling DetachCurrentThread on a Java-created thread makes ART abort. + // • We therefore call GetEnv first. If the thread is already attached + // (needs_detach == 0) we skip both Attach and Detach. Only a purely + // native thread that was never attached would set needs_detach == 1. + if (g_jvm) |jvm| { + if (g_activity != null) { + mob_set_startup_phase("Waiting for window focus…"); + + const existing = jni.getEnv(jvm, jni.JNI_VERSION_1_6); + const needs_detach = existing == null; + const env2_maybe: ?*jni.JNIEnv = existing orelse jni.attachCurrentThread(jvm); + + if (env2_maybe) |env2| { + const act_cls = jni.getObjectClass(env2, g_activity); + const has_focus = jni.getMethodID(env2, act_cls, "hasWindowFocus", "()Z"); + var waited: i32 = 0; + const max_wait: i32 = 3000; // ms — fall through if focus never arrives + while (jni.callBooleanMethod(env2, g_activity, has_focus) == 0 and waited < max_wait) { + const ts = jni.Timespec{ .tv_sec = 0, .tv_nsec = 50_000_000 }; // 50 ms + _ = jni.nanosleep(&ts, null); + waited += 50; + } + // Only detach if we attached above — detaching a Java thread aborts ART. + if (needs_detach) jni.detachCurrentThread(jvm); + if (waited >= max_wait) { + logi("mob_start_beam: focus timeout ({d} ms) — starting BEAM anyway", .{waited}); + } else if (waited > 0) { + logi("mob_start_beam: waited {d} ms for window focus", .{waited}); + } + } else { + loge("mob_start_beam: AttachCurrentThread failed — skipping focus wait", .{}); + } + } + } + // ── end cold-start race condition fix ──────────────────────────────────── + + mob_set_startup_phase("Starting BEAM…"); + logi("mob_start_beam: starting BEAM with module={s}, argc={d}", .{ app_module, ac }); + + // Symlink ERTS executables from BINDIR to the native lib dir. + // + // When installed via `adb install`, nativeLibraryDir contains the .so files + // and the symlink approach works (apk_data_file SELinux label allows execve). + // + // When installed via Play Store (split APKs), Android does NOT extract .so + // files to nativeLibraryDir on modern devices — they stay inside the split APK + // zip. In that case MobBridge.extractBeamHelpersFromSplitApk() copies the + // binaries directly into erts/bin/ before this point. We detect that scenario + // by checking whether the nativeLibDir target exists: if it doesn't, skip the + // unlink+symlink so we don't clobber the already-extracted real file. + if (s_native_lib_dir[0] != 0) { + const exes = [_][*:0]const u8{ "erl_child_setup", "inet_gethost", "epmd" }; + const libs = [_][*:0]const u8{ "liberl_child_setup.so", "libinet_gethost.so", "libepmd.so" }; + var i: usize = 0; + while (i < exes.len) : (i += 1) { + var bin_path_buf: [512]u8 = undefined; + var lib_path_buf: [512]u8 = undefined; + const bin_path = formatZ(&bin_path_buf, "{s}/{s}/bin/{s}", .{ otp_root, ERTS_VSN, exes[i] }); + const lib_path = formatZ(&lib_path_buf, "{s}/{s}", .{ jni.asCStr(&s_native_lib_dir), libs[i] }); + var st: jni.Stat = undefined; + if (jni.stat(lib_path, &st) == 0) { + // nativeLibDir has the file (adb install) — use symlink + _ = jni.unlink(bin_path); + if (jni.symlink(lib_path, bin_path) == 0) { + logi("mob_start_beam: symlink {s} -> {s}", .{ exes[i], lib_path }); + } else { + loge("mob_start_beam: symlink {s} failed: {s}", .{ exes[i], lastErrno() }); + } + } else { + // nativeLibDir empty (Play Store split APK) — MobBridge should have + // extracted the binary directly to bin_path; leave it in place. + var st_bin: jni.Stat = undefined; + if (jni.stat(bin_path, &st_bin) == 0) { + logi("mob_start_beam: symlink {s} (extracted from split APK)", .{exes[i]}); + } else { + loge("mob_start_beam: symlink {s} missing from both nativeLibDir and bin/", .{exes[i]}); + } + } + } + } + + // Optional ERTS extras: symlink iff the app shipped them in jniLibs. + // Silently skip otherwise — these aren't required for BEAM boot, but apps + // that want them (e.g. Mix.install of a rebar3-built dep needs `escript` + // *and* a spawnable `erl` / `erlexec` for the escript runner to bootstrap + // a fresh VM) can drop `lib<name>.so` into android/app/src/main/jniLibs/<abi>/ + // to get a working BINDIR/<name>. `erl` and `erlexec` both target the + // same library because they're the same binary — erlexec doesn't switch + // on argv[0]. + if (s_native_lib_dir[0] != 0) { + const opt_exes = [_][*:0]const u8{ "escript", "erlexec", "erl", "beam.smp" }; + const opt_libs = [_][*:0]const u8{ "libescript.so", "liberlexec.so", "liberlexec.so", "libbeam_smp.so" }; + var j: usize = 0; + while (j < opt_exes.len) : (j += 1) { + var bin_path_buf: [512]u8 = undefined; + var lib_path_buf: [512]u8 = undefined; + const bin_path = formatZ(&bin_path_buf, "{s}/{s}/bin/{s}", .{ otp_root, ERTS_VSN, opt_exes[j] }); + const lib_path = formatZ(&lib_path_buf, "{s}/{s}", .{ jni.asCStr(&s_native_lib_dir), opt_libs[j] }); + var st: jni.Stat = undefined; + if (jni.stat(lib_path, &st) == 0) { + _ = jni.unlink(bin_path); + if (jni.symlink(lib_path, bin_path) == 0) { + logi("mob_start_beam: symlink {s} -> {s} (optional)", .{ opt_exes[j], lib_path }); + } else { + loge("mob_start_beam: symlink {s} failed: {s}", .{ opt_exes[j], lastErrno() }); + } + } + // No lib in nativeLibDir => app didn't ask for this extra. Skip + // silently — don't log; not an error. + } + } + + // Symlink sqlite3_nif.so into the exqlite OTP lib structure so that + // code:priv_dir(:exqlite) resolves correctly. + // + // The OTP code server registers lib_dirs by scanning $OTP_ROOT/lib/*/ebin + // at boot. For code:lib_dir(:exqlite) to work, exqlite must live at + // $OTP_ROOT/lib/exqlite-VERSION/ — a flat -pa dir is NOT sufficient. + // The deployer creates $OTP_ROOT/lib/exqlite-VERSION/{ebin,priv}; we + // create the sqlite3_nif.so symlink inside priv/ at runtime so the path + // (which contains the APK install hash) is always up-to-date. + if (s_native_lib_dir[0] != 0) { + var nif_target_buf: [560]u8 = undefined; + const nif_target = formatZ(&nif_target_buf, "{s}/libsqlite3_nif.so", .{jni.asCStr(&s_native_lib_dir)}); + + // Scan $OTP_ROOT/lib/ for exqlite-* and symlink the NIF in its priv/. + var lib_path_buf: [600]u8 = undefined; + const lib_path = formatZ(&lib_path_buf, "{s}/lib", .{otp_root}); + var found = false; + if (jni.opendir(lib_path)) |d| { + while (jni.readdir(d)) |entry| { + if (jni.strncmp(@ptrCast(&entry.d_name), "exqlite-", 8) == 0) { + var exqlite_priv_buf: [700]u8 = undefined; + const d_name_c: [*:0]const u8 = @ptrCast(&entry.d_name); + const exqlite_priv = formatZ(&exqlite_priv_buf, "{s}/{s}/priv", .{ lib_path, d_name_c }); + _ = jni.mkdir(exqlite_priv, 0o755); + var nif_link_buf: [760]u8 = undefined; + const nif_link = formatZ(&nif_link_buf, "{s}/sqlite3_nif.so", .{exqlite_priv}); + var st_nif: jni.Stat = undefined; + if (jni.stat(nif_target, &st_nif) == 0) { + // nativeLibDir has the NIF (adb install) — use symlink + _ = jni.unlink(nif_link); + if (jni.symlink(nif_target, nif_link) == 0) { + logi("mob_start_beam: symlink exqlite NIF -> {s}", .{nif_target}); + found = true; + } else { + loge("mob_start_beam: symlink exqlite NIF failed: {s}", .{lastErrno()}); + } + } else { + // nativeLibDir empty — MobBridge extracted NIF directly to nif_link + var st_nif_file: jni.Stat = undefined; + if (jni.stat(nif_link, &st_nif_file) == 0) { + logi("mob_start_beam: exqlite NIF extracted from split APK", .{}); + found = true; + } else { + loge("mob_start_beam: exqlite NIF missing from both nativeLibDir and priv/", .{}); + } + } + break; + } + } + _ = jni.closedir(d); + } + + if (!found) { + // Fallback: symlink into flat beams_dir/priv/ for backward compatibility + // while the deployer hasn't yet created the versioned lib structure. + var priv_dir_buf: [660]u8 = undefined; + const priv_dir = formatZ(&priv_dir_buf, "{s}/priv", .{beams_dir}); + _ = jni.mkdir(priv_dir, 0o755); + var nif_link_buf: [720]u8 = undefined; + const nif_link = formatZ(&nif_link_buf, "{s}/sqlite3_nif.so", .{priv_dir}); + var st_nif_fb: jni.Stat = undefined; + if (jni.stat(nif_target, &st_nif_fb) == 0) { + _ = jni.unlink(nif_link); + if (jni.symlink(nif_target, nif_link) == 0) { + logi("mob_start_beam: symlink sqlite3_nif.so (fallback) -> {s}", .{nif_target}); + } else { + loge("mob_start_beam: symlink sqlite3_nif (fallback) failed: {s}", .{lastErrno()}); + } + } else { + var st_fb_file: jni.Stat = undefined; + if (jni.stat(nif_link, &st_fb_file) == 0) { + logi("mob_start_beam: sqlite3_nif.so (fallback) extracted from split APK", .{}); + } else { + loge("mob_start_beam: sqlite3_nif.so (fallback) missing — NIF load will fail", .{}); + } + } + } + } + + // Symlink libpythonx.so into the pythonx OTP lib structure for the + // same reason as exqlite above. Pythonx's NIF on_load does + // path = :filename.join(:code.priv_dir(:pythonx), 'libpythonx') + // :erlang.load_nif(path, 0) + // For dlopen to resolve enif_* (defined in the main app native lib) + // the .so has to live in the app's namespace — i.e. nativeLibraryDir. + // mob_dev's NativeBuild already places libpythonx.so in jniLibs, so + // the APK installer extracts it to nativeLibraryDir at install time. + // We just symlink into the OTP lib priv/ to make :code.priv_dir + // return a path that dlopen can follow. + if (s_native_lib_dir[0] != 0) { + var pyx_target_buf: [560]u8 = undefined; + const pyx_target = formatZ(&pyx_target_buf, "{s}/libpythonx.so", .{jni.asCStr(&s_native_lib_dir)}); + + var st_pyx: jni.Stat = undefined; + if (jni.stat(pyx_target, &st_pyx) == 0) { + var lib_path_buf: [600]u8 = undefined; + const lib_path = formatZ(&lib_path_buf, "{s}/lib", .{otp_root}); + if (jni.opendir(lib_path)) |d2| { + while (jni.readdir(d2)) |entry| { + if (jni.strncmp(@ptrCast(&entry.d_name), "pythonx-", 8) == 0) { + var pyx_priv_buf: [700]u8 = undefined; + const d_name_c: [*:0]const u8 = @ptrCast(&entry.d_name); + const pyx_priv = formatZ(&pyx_priv_buf, "{s}/{s}/priv", .{ lib_path, d_name_c }); + _ = jni.mkdir(pyx_priv, 0o755); + var pyx_link_buf: [760]u8 = undefined; + const pyx_link = formatZ(&pyx_link_buf, "{s}/libpythonx.so", .{pyx_priv}); + _ = jni.unlink(pyx_link); + if (jni.symlink(pyx_target, pyx_link) == 0) { + logi("mob_start_beam: symlink pythonx NIF -> {s}", .{pyx_target}); + } else { + loge("mob_start_beam: symlink pythonx NIF failed: {s}", .{lastErrno()}); + } + break; + } + } + _ = jni.closedir(d2); + } + } + } + + // erl_start blocks forever in the normal case. If it returns at all the + // BEAM has exited unexpectedly — report it to the UI and let logcat carry + // the details. The caller's caller (Java thread) will reap the process. + erl_start(@intCast(ac), @ptrCast(&args)); + mob_set_startup_error("BEAM exited unexpectedly — see logcat (tag: MobBeam) for details"); + loge("mob_start_beam: erl_start returned (unexpected)", .{}); +} + +// ── Comptime helpers ────────────────────────────────────────────────────── + +fn selectDefaultFlags() []const [*:0]const u8 { + // String comparison at comptime — build_options.beam_flags_mode is a + // []const u8 baked into the binary at build time. + if (std.mem.eql(u8, BEAM_FLAGS_MODE, "untuned")) { + return &.{}; + } + if (std.mem.eql(u8, BEAM_FLAGS_MODE, "sbwt_only")) { + return &.{ + "-sbwt", "none", + "-sbwtdcpu", "none", + "-sbwtdio", "none", + }; + } + // Default: full Nerves-style tuning. + return &.{ + "-S", "1:1", + "-SDcpu", "1:1", + "-SDio", "1", + "-A", "1", + "-sbwt", "none", + "-sbwtdcpu", "none", + "-sbwtdio", "none", + }; +} diff --git a/android/jni/mob_erts.zig b/android/jni/mob_erts.zig new file mode 100644 index 00000000..5dae795c --- /dev/null +++ b/android/jni/mob_erts.zig @@ -0,0 +1,317 @@ +//! mob_erts.zig — Hand-declared FFI bindings for the BEAM's ERL_NIF surface. +//! +//! Companion to mob_zig.zig (which covers JNI / libc / Android log). This +//! module narrows in on the symbols that NIF authors call when writing +//! against `erl_nif.h`. We hand-declare what we use rather than @cImport'ing +//! erl_nif.h for the same reasons documented at the top of mob_zig.zig: +//! Zig 0.17-dev's @cImport is gone, translate-c is unreliable on deeply +//! nested headers, and the surface is small + stable so an auditable +//! hand declaration is easy to maintain. +//! +//! Phase 6b iter 3a introduces this file. It declares only what iter 3a's +//! NIFs (nif_platform, nif_log, nif_log2) need; later iters extend it as +//! their ported NIFs require more of the ERL_NIF surface. iter 3b adds the +//! list / tuple / map constructors, enif_get_int / enif_get_double, +//! enif_alloc_binary, and enif_inspect_iolist_as_binary for the test +//! harness NIFs. +//! +//! Authoritative reference: OTP 27+ `erl_nif.h` and `erl_nif_api_funcs.h`. + +const std = @import("std"); + +// ── Core types ───────────────────────────────────────────────────────────── + +/// ERL_NIF_TERM is `ErlNifUInt`, which is `unsigned long` on every platform +/// where BEAM is supported. c_ulong matches that and stays 64-bit on +/// aarch64-android (LP64), which is what we ship. +pub const ERL_NIF_TERM = c_ulong; + +/// Opaque from the user's perspective — the BEAM owns the layout. +pub const ErlNifEnv = opaque {}; + +/// ErlNifPid is a struct with a single ERL_NIF_TERM. Marked `extern` so +/// alignment matches the C definition. +pub const ErlNifPid = extern struct { + pid: ERL_NIF_TERM, +}; + +/// Opaque mutex handle. enif_mutex_create returns one; the others take a +/// pointer to it. +pub const ErlNifMutex = opaque {}; + +/// Char encoding for enif_get_atom / enif_get_string / enif_make_string. +pub const ErlNifCharEncoding = c_int; +pub const ERL_NIF_LATIN1: ErlNifCharEncoding = 1; +pub const ERL_NIF_UTF8: ErlNifCharEncoding = 2; + +/// Binary view. `data` points at heap-owned bytes; `size` is the length; +/// the trailing internal pointers (ref_bin, __spare__) are opaque to NIF +/// authors. Layout matches C exactly so `enif_inspect_binary(env, term, &bin)` +/// fills the same struct shape. +pub const ErlNifBinary = extern struct { + size: usize, + data: [*]u8, + ref_bin: ?*anyopaque = null, + __spare__: [2]?*anyopaque = .{ null, null }, +}; + +/// NIF table entry. `fptr` follows the standard NIF signature +/// `ERL_NIF_TERM (*)(ErlNifEnv*, int argc, const ERL_NIF_TERM argv[])`. +pub const ErlNifFunc = extern struct { + name: [*:0]const u8, + arity: c_uint, + fptr: ?*const fn (env: ?*ErlNifEnv, argc: c_int, argv: [*]const ERL_NIF_TERM) callconv(.c) ERL_NIF_TERM, + flags: c_uint, +}; + +/// NIF dirty-job flags. Match the `ErlNifDirtyTaskFlags` enum in erl_nif.h — +/// these are the `flags` field values for ErlNifFunc entries that should +/// dispatch on a dirty scheduler. Plain CPU-bound work on the BEAM thread +/// (JSON parse, tree walks) uses CPU_BOUND; long-blocking I/O uses IO_BOUND. +pub const ERL_NIF_DIRTY_JOB_CPU_BOUND: c_uint = 1; +pub const ERL_NIF_DIRTY_JOB_IO_BOUND: c_uint = 2; + +/// `ErlNifEntry` — the top-level NIF library descriptor. Returned by the +/// `<module>_nif_init` symbol that the `ERL_NIF_INIT` macro generates in C. +/// Iter 3d builds this struct manually in Zig (instead of via the C macro) +/// so the entire NIF surface — table, load callback, and entry returned to +/// the BEAM — lives in mob_nif.zig. +/// +/// Major/minor + min_erts must match the headers the BEAM was built with. +/// We hard-code 2/18 + "erts-14.0" to match the bundled OTP 29 headers; if +/// you bump the OTP runtime, also bump these. `options = 1` allows dirty +/// NIFs (matches what the ERL_NIF_INIT macro emits today). +pub const ERL_NIF_MAJOR_VERSION: c_int = 2; +pub const ERL_NIF_MINOR_VERSION: c_int = 18; +pub const ERL_NIF_MIN_ERTS_VERSION: [*:0]const u8 = "erts-14.0"; +pub const ERL_NIF_VM_VARIANT: [*:0]const u8 = "beam.vanilla"; + +/// ErlNifResourceTypeInit — declared opaque (we never construct one; only +/// its size is read from the entry to gate ABI compatibility). Size on +/// aarch64-linux: 5 pointers = 40 bytes (dtor/stop/down/dyncall + int). +pub const SIZEOF_ErlNifResourceTypeInit: usize = 40; + +pub const ErlNifLoadFn = ?*const fn (env: ?*ErlNifEnv, priv_data: *?*anyopaque, load_info: ERL_NIF_TERM) callconv(.c) c_int; +pub const ErlNifReloadFn = ?*const fn (env: ?*ErlNifEnv, priv_data: *?*anyopaque, load_info: ERL_NIF_TERM) callconv(.c) c_int; +pub const ErlNifUpgradeFn = ?*const fn (env: ?*ErlNifEnv, priv_data: *?*anyopaque, old_priv: *?*anyopaque, load_info: ERL_NIF_TERM) callconv(.c) c_int; +pub const ErlNifUnloadFn = ?*const fn (env: ?*ErlNifEnv, priv_data: ?*anyopaque) callconv(.c) void; + +pub const ErlNifEntry = extern struct { + major: c_int, + minor: c_int, + name: [*:0]const u8, + num_of_funcs: c_int, + funcs: [*]const ErlNifFunc, + load: ErlNifLoadFn, + reload: ErlNifReloadFn, + upgrade: ErlNifUpgradeFn, + unload: ErlNifUnloadFn, + vm_variant: [*:0]const u8, + options: c_uint, + sizeof_ErlNifResourceTypeInit: usize, + min_erts: [*:0]const u8, +}; + +// ── Term constructors ───────────────────────────────────────────────────── + +pub extern fn enif_make_atom(env: ?*ErlNifEnv, name: [*:0]const u8) ERL_NIF_TERM; +pub extern fn enif_make_int(env: ?*ErlNifEnv, i: c_int) ERL_NIF_TERM; +pub extern fn enif_make_double(env: ?*ErlNifEnv, d: f64) ERL_NIF_TERM; +pub extern fn enif_make_badarg(env: ?*ErlNifEnv) ERL_NIF_TERM; +pub extern fn enif_make_binary(env: ?*ErlNifEnv, bin: *ErlNifBinary) ERL_NIF_TERM; +pub extern fn enif_make_string(env: ?*ErlNifEnv, str: [*:0]const u8, enc: ErlNifCharEncoding) ERL_NIF_TERM; + +// List construction (iter 3b). +// +// `enif_make_list` in C is variadic with a count prefix; we expose the +// non-variadic `enif_make_list_from_array` and `enif_make_list_cell` +// (prepend) primitives. Fixed-arity helpers below are built on top. +pub extern fn enif_make_list_cell(env: ?*ErlNifEnv, car: ERL_NIF_TERM, cdr: ERL_NIF_TERM) ERL_NIF_TERM; +pub extern fn enif_make_list_from_array(env: ?*ErlNifEnv, arr: [*]const ERL_NIF_TERM, cnt: c_uint) ERL_NIF_TERM; + +// Tuple construction (iter 3b). `enif_make_tuple` is variadic; the +// non-variadic `enif_make_tuple_from_array` is the underlying primitive. +pub extern fn enif_make_tuple_from_array(env: ?*ErlNifEnv, arr: [*]const ERL_NIF_TERM, cnt: c_uint) ERL_NIF_TERM; + +// Map construction (iter 3b). Returns 1 on success, 0 on duplicate key. +// `keys` and `values` are parallel arrays of length `cnt`; `*map_out` is +// populated on success. +pub extern fn enif_make_map_from_arrays( + env: ?*ErlNifEnv, + keys: [*]const ERL_NIF_TERM, + values: [*]const ERL_NIF_TERM, + cnt: usize, + map_out: *ERL_NIF_TERM, +) c_int; + +// Binary allocation (iter 3b). Returns 1 on success, 0 on OOM. The caller +// owns `bin.data` until it's wrapped via `enif_make_binary`, after which +// BEAM owns it. +pub extern fn enif_alloc_binary(size: usize, bin: *ErlNifBinary) c_int; + +// 64-bit integer constructors (iter 3c). Used by the throttled gesture/ +// scroll/drag/pinch senders for monotonic timestamps and sequence numbers. +// +// Symbol-name twist: OTP's `erl_nif_api_funcs.h` does +// +// #if SIZEOF_LONG == 8 +// # define enif_make_int64 enif_make_long +// # define enif_make_uint64 enif_make_ulong +// #endif +// +// On aarch64-android (LP64 — `long` is 8 bytes) the real symbols in +// libbeam.a are `enif_make_long` / `enif_make_ulong`; `enif_make_int64` +// is just a preprocessor alias. Zig doesn't run the C preprocessor, so +// `extern fn enif_make_int64` would look for a literal symbol that +// doesn't exist on 64-bit and dlopen would fail at app launch with +// `cannot locate symbol "enif_make_int64"`. +// +// On armeabi-v7a (ILP32 — `long` is 4 bytes) the alias doesn't fire and +// `enif_make_int64` is a real symbol. We pick the right linker name at +// comptime via `@extern`. +const enif_make_int64_fn = @extern( + *const fn (?*ErlNifEnv, i64) callconv(.c) ERL_NIF_TERM, + .{ .name = if (@sizeOf(c_long) == 8) "enif_make_long" else "enif_make_int64" }, +); +const enif_make_uint64_fn = @extern( + *const fn (?*ErlNifEnv, u64) callconv(.c) ERL_NIF_TERM, + .{ .name = if (@sizeOf(c_long) == 8) "enif_make_ulong" else "enif_make_uint64" }, +); + +pub inline fn enif_make_int64(env: ?*ErlNifEnv, i: i64) ERL_NIF_TERM { + return enif_make_int64_fn(env, i); +} + +pub inline fn enif_make_uint64(env: ?*ErlNifEnv, i: u64) ERL_NIF_TERM { + return enif_make_uint64_fn(env, i); +} + +// Term-env hop (iter 3c). enif_send delivers a message to a pid; the +// `msg_env` must be a "process-independent" env allocated via +// enif_alloc_env / freed via enif_free_env after the send returns. +// Terms in `msg_env` must originate there or be copied in via +// enif_make_copy. +pub extern fn enif_alloc_env() ?*ErlNifEnv; +pub extern fn enif_free_env(env: ?*ErlNifEnv) void; +pub extern fn enif_make_copy(dst: ?*ErlNifEnv, src_term: ERL_NIF_TERM) ERL_NIF_TERM; +pub extern fn enif_send( + caller_env: ?*ErlNifEnv, + to_pid: *const ErlNifPid, + msg_env: ?*ErlNifEnv, + msg: ERL_NIF_TERM, +) c_int; +pub extern fn enif_self(caller_env: ?*ErlNifEnv, pid: *ErlNifPid) ?*ErlNifPid; + +// Pid resolution (iter 3c). +pub extern fn enif_get_local_pid(env: ?*ErlNifEnv, term: ERL_NIF_TERM, pid: *ErlNifPid) c_int; +pub extern fn enif_whereis_pid(env: ?*ErlNifEnv, name: ERL_NIF_TERM, pid: *ErlNifPid) c_int; + +// Tuple inspectors (iter 3c). +pub extern fn enif_get_tuple(env: ?*ErlNifEnv, tpl: ERL_NIF_TERM, arity: *c_int, array: *[*]const ERL_NIF_TERM) c_int; + +// Mutex (iter 3c). enif_mutex_create allocates; destroy + try-lock omitted +// — Mob only uses simple lock/unlock pairs and the mutexes live for the +// lifetime of the BEAM process (no destroy needed). +pub extern fn enif_mutex_create(name: [*:0]const u8) ?*ErlNifMutex; +pub extern fn enif_mutex_lock(mtx: ?*ErlNifMutex) void; +pub extern fn enif_mutex_unlock(mtx: ?*ErlNifMutex) void; + +// ── Term inspectors ─────────────────────────────────────────────────────── + +/// Returns 1 on success, 0 on failure. Fills `bin` with the binary's +/// {size, data} view (no copy). +pub extern fn enif_inspect_binary(env: ?*ErlNifEnv, term: ERL_NIF_TERM, bin: *ErlNifBinary) c_int; + +/// Returns 1 on success, 0 on failure. Like enif_inspect_binary, but +/// accepts an iolist (list of binaries/integers) and materialises a +/// contiguous binary view. Used when callers can pass either a plain +/// binary or an iolist (e.g. set_root/1). +pub extern fn enif_inspect_iolist_as_binary(env: ?*ErlNifEnv, term: ERL_NIF_TERM, bin: *ErlNifBinary) c_int; + +/// Returns 1 on success, 0 on failure. Reads an Erlang charlist into a +/// fixed-size C string buffer (NUL-terminated on success). +pub extern fn enif_get_string( + env: ?*ErlNifEnv, + list: ERL_NIF_TERM, + buf: [*]u8, + len: c_uint, + enc: ErlNifCharEncoding, +) c_int; + +/// Returns 1 on success, 0 on failure. Reads an atom name into a buffer +/// (NUL-terminated on success). +pub extern fn enif_get_atom( + env: ?*ErlNifEnv, + atom: ERL_NIF_TERM, + buf: [*]u8, + len: c_uint, + enc: ErlNifCharEncoding, +) c_int; + +/// Read an integer term. Returns 1 on success, 0 on failure. +pub extern fn enif_get_int(env: ?*ErlNifEnv, term: ERL_NIF_TERM, ip: *c_int) c_int; + +/// Read a double term. Returns 1 on success, 0 on failure. +pub extern fn enif_get_double(env: ?*ErlNifEnv, term: ERL_NIF_TERM, dp: *f64) c_int; + +/// Split a list term into its head (car) and tail (cdr). Returns 1 while there +/// is a cell to read, 0 at the empty-list tail — the standard iteration idiom. +pub extern fn enif_get_list_cell(env: ?*ErlNifEnv, list: ERL_NIF_TERM, head: *ERL_NIF_TERM, tail: *ERL_NIF_TERM) c_int; + +// ── Convenience wrappers ────────────────────────────────────────────────── +// Idiomatic Zig surface over the bare extern fns. Keeps NIF bodies tight. + +/// Make an atom from a comptime-known string literal. +pub inline fn atom(env: ?*ErlNifEnv, comptime name: [:0]const u8) ERL_NIF_TERM { + return enif_make_atom(env, name.ptr); +} + +/// The canonical `:ok` return. +pub inline fn ok(env: ?*ErlNifEnv) ERL_NIF_TERM { + return enif_make_atom(env, "ok"); +} + +/// The canonical `badarg` return — typed identically to `ok` so the call +/// sites read symmetrically. +pub inline fn badarg(env: ?*ErlNifEnv) ERL_NIF_TERM { + return enif_make_badarg(env); +} + +/// Build an N-tuple from a comptime-known list of terms. Mirrors the C +/// `enif_make_tupleN` inlines but works for any arity via the underlying +/// `enif_make_tuple_from_array` primitive. +pub inline fn makeTuple(env: ?*ErlNifEnv, elems: anytype) ERL_NIF_TERM { + const arr: [elems.len]ERL_NIF_TERM = elems; + return enif_make_tuple_from_array(env, &arr, elems.len); +} + +/// `{:error, Reason}` 2-tuple convenience. +pub inline fn errorTuple(env: ?*ErlNifEnv, reason: ERL_NIF_TERM) ERL_NIF_TERM { + return makeTuple(env, .{ enif_make_atom(env, "error"), reason }); +} + +/// Build a proper Erlang list from a slice of terms. +pub inline fn makeList(env: ?*ErlNifEnv, items: []const ERL_NIF_TERM) ERL_NIF_TERM { + return enif_make_list_from_array(env, items.ptr, @intCast(items.len)); +} + +/// Build a map from parallel key/value slices. Returns null on duplicate +/// key (matches the C convention of `enif_make_map_from_arrays` returning 0). +pub inline fn makeMap(env: ?*ErlNifEnv, keys: []const ERL_NIF_TERM, values: []const ERL_NIF_TERM) ?ERL_NIF_TERM { + std.debug.assert(keys.len == values.len); + var out: ERL_NIF_TERM = undefined; + if (enif_make_map_from_arrays(env, keys.ptr, values.ptr, keys.len, &out) == 0) return null; + return out; +} + +/// Read a numeric term as a double, accepting either a double or an integer +/// term. Returns null if neither path succeeds. Mirrors a common pattern +/// in the test harness NIFs where Erlang callers may pass `100` or `100.0` +/// interchangeably for coordinates. +pub inline fn getNumber(env: ?*ErlNifEnv, term: ERL_NIF_TERM) ?f64 { + var d: f64 = 0; + if (enif_get_double(env, term, &d) != 0) return d; + var i: c_int = 0; + if (enif_get_int(env, term, &i) != 0) return @floatFromInt(i); + return null; +} diff --git a/android/jni/mob_nif.c b/android/jni/mob_nif.c deleted file mode 100644 index c4d2c652..00000000 --- a/android/jni/mob_nif.c +++ /dev/null @@ -1,2287 +0,0 @@ -// mob_nif.c — Mob UI NIF for Android (Jetpack Compose backend). -// -// NIF functions: -// platform/0 — returns :android -// log/1, log/2 — Android logcat -// set_root/1 — pass JSON node tree to Compose -// register_tap/1 — register ErlNifPid, get integer handle back -// clear_taps/0 — clear tap registry before each render - -#include <jni.h> -#include <android/log.h> -#include <stdint.h> -#include <string.h> -#include <stdlib.h> -#include <time.h> -#include "erl_nif.h" -#include "mob_beam.h" - -#define LOG_TAG "MobNIF" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) -#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) - -// ── Cached JNI method IDs ──────────────────────────────────────────────────── - -static struct { - jclass cls; - jmethodID set_root; - jmethodID move_to_back; - jmethodID get_safe_area; - jmethodID get_color_scheme; - jmethodID haptic; - jmethodID clipboard_put; - jmethodID clipboard_get; - jmethodID share_text; - jmethodID open_url; - jmethodID request_permission; - jmethodID biometric_authenticate; - jmethodID location_get_once; - jmethodID location_start; - jmethodID location_stop; - jmethodID camera_capture_photo; - jmethodID camera_capture_video; - jmethodID camera_start_preview; - jmethodID camera_stop_preview; - jmethodID alert_show; - jmethodID action_sheet_show; - jmethodID toast_show; - jmethodID webview_eval_js; - jmethodID webview_post_message; - jmethodID webview_can_go_back; - jmethodID webview_go_back; - jmethodID photos_pick; - jmethodID files_pick; - jmethodID audio_start_recording; - jmethodID audio_stop_recording; - jmethodID audio_play; - jmethodID audio_stop_playback; - jmethodID audio_set_volume; - jmethodID motion_start; - jmethodID motion_stop; - jmethodID scanner_scan; - jmethodID notify_schedule; - jmethodID notify_cancel; - jmethodID notify_register_push; - jmethodID take_launch_notification; - jmethodID storage_dir; - jmethodID storage_save_to_media_store; - jmethodID storage_external_files_dir; - jmethodID background_keep_alive; - jmethodID background_stop; - // Cached before nif_load (used during BEAM startup before NIFs are loaded) - jmethodID set_startup_phase; - jmethodID set_startup_error; - // ── Test harness ────────────────────────────────────────────────────────── - jmethodID ui_tree; - jmethodID ui_view_tree; - jmethodID screen_info; - jmethodID tap_xy; - jmethodID tap_by_label; - jmethodID type_text; - jmethodID delete_backward; - jmethodID clear_text; - jmethodID long_press_xy; - jmethodID swipe_xy; -} Bridge; - -// ── Tap handle registry ─────────────────────────────────────────────────────── -// Cleared before every render. Max 256 tappable elements per frame. -// -// Each handle stores a pid and an optional tag term (copied into a persistent -// NIF env). When tapped, sends {:tap, tag} to pid. -// Backwards compat: register_tap(pid) stores tag = :ok. - -#define MAX_TAP_HANDLES 256 - -typedef struct { - ErlNifPid pid; - ErlNifEnv* tag_env; // persistent env owning tag; NULL when not in use - ERL_NIF_TERM tag; // the term sent as the second element of {:tap, tag} - - // ── Batch 5 throttle state — populated by mob_set_throttle_config ── - int throttle_ms; - int debounce_ms; - double delta_threshold; - int leading; - int trailing; - long long last_emit_ns; // CLOCK_MONOTONIC ns - double last_x; - double last_y; - unsigned long long seq; -} TapHandle; - -static TapHandle tap_handles[MAX_TAP_HANDLES]; -static int tap_handle_next = 0; -static ErlNifMutex* tap_mutex = NULL; -static char g_transition[16] = "none"; // set by set_transition/1, read+reset by set_root/1 - -// ── Component handle registry ───────────────────────────────────────────────── -// Persistent (not cleared between renders). Each slot maps an integer handle to -// a component process pid. register_component/1 allocates; deregister_component/1 frees. - -#define MAX_COMPONENT_HANDLES 64 - -typedef struct { - ErlNifPid pid; - int active; -} ComponentHandle; - -static ComponentHandle component_handles[MAX_COMPONENT_HANDLES]; -static ErlNifMutex* component_mutex = NULL; - -void mob_send_component_event(int handle, const char* event, const char* payload_json) { - if (handle < 0 || handle >= MAX_COMPONENT_HANDLES) return; - enif_mutex_lock(component_mutex); - if (!component_handles[handle].active) { - enif_mutex_unlock(component_mutex); - return; - } - ErlNifPid pid = component_handles[handle].pid; - enif_mutex_unlock(component_mutex); - - ErlNifEnv* env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "component_event"), - enif_make_string(env, event, ERL_NIF_LATIN1), - enif_make_string(env, payload_json, ERL_NIF_LATIN1)); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -// Called from the app's Java_..._MobBridge_nativeSendTap JNI stub -// (declared in mob_beam.h, defined here). -void mob_send_tap(int handle) { - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ErlNifEnv* tag_env = tap_handles[handle].tag_env; - ERL_NIF_TERM tag = tap_handles[handle].tag; - enif_mutex_unlock(tap_mutex); - - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(msg_env, - enif_make_atom(msg_env, "tap"), - enif_make_copy(msg_env, tag)); - enif_send(NULL, &pid, msg_env, msg); - enif_free_env(msg_env); - (void)tag_env; // owned by tap_handles; freed in clear_taps -} - -// ── Change senders ──────────────────────────────────────────────────────────── -// Called from beam_jni.c JNI stubs when an input widget fires an onChange event. -// Each builds {:change, tag, value} and sends it to the registered pid. - -static void send_change(int handle, ERL_NIF_TERM value_term) { - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - enif_mutex_unlock(tap_mutex); - - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "change"), - enif_make_copy(msg_env, tag), - enif_make_copy(msg_env, value_term)); - enif_send(NULL, &pid, msg_env, msg); - enif_free_env(msg_env); -} - -void mob_send_change_str(int handle, const char* utf8) { - ErlNifEnv* tmp = enif_alloc_env(); - ErlNifBinary bin; - size_t len = strlen(utf8); - enif_alloc_binary(len, &bin); - memcpy(bin.data, utf8, len); - ERL_NIF_TERM term = enif_make_binary(tmp, &bin); - send_change(handle, term); - enif_free_env(tmp); -} - -void mob_send_change_bool(int handle, int bool_val) { - ErlNifEnv* tmp = enif_alloc_env(); - ERL_NIF_TERM term = enif_make_atom(tmp, bool_val ? "true" : "false"); - send_change(handle, term); - enif_free_env(tmp); -} - -void mob_send_change_float(int handle, double value) { - ErlNifEnv* tmp = enif_alloc_env(); - ERL_NIF_TERM term = enif_make_double(tmp, value); - send_change(handle, term); - enif_free_env(tmp); -} - -// ── Focus / blur / submit senders ──────────────────────────────────────────── -// Called from beam_jni.c JNI stubs when a text field gains/loses focus or -// the return key is pressed. Sends a {:event, tag} 2-tuple to the registered pid. - -static void send_event(int handle, const char* atom) { - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - enif_mutex_unlock(tap_mutex); - - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(msg_env, - enif_make_atom(msg_env, atom), - enif_make_copy(msg_env, tag)); - enif_send(NULL, &pid, msg_env, msg); - enif_free_env(msg_env); -} - -void mob_send_focus(int handle) { send_event(handle, "focus"); } -void mob_send_blur(int handle) { send_event(handle, "blur"); } -void mob_send_submit(int handle) { send_event(handle, "submit"); } -void mob_send_select(int handle) { send_event(handle, "select"); } - -// IME composition. Sends {compose, tag, %{text, phase}} where phase is -// began/updating/committed/cancelled. Called from beam_jni.c when the -// Compose TextField's TextFieldValue.composition range changes, or from -// an InputConnection observer in legacy view-system text fields. -void mob_send_compose(int handle, const char* text, const char* phase) { - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - enif_mutex_unlock(tap_mutex); - - ErlNifEnv* env = enif_alloc_env(); - ERL_NIF_TERM keys[2] = { - enif_make_atom(env, "text"), - enif_make_atom(env, "phase"), - }; - ERL_NIF_TERM vals[2] = { - enif_make_string(env, text ? text : "", ERL_NIF_LATIN1), - enif_make_atom(env, phase), - }; - ERL_NIF_TERM payload; - enif_make_map_from_arrays(env, keys, vals, 2, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "compose"), - enif_make_copy(env, tag), - payload); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -// ── Gesture senders (Batch 4) ─────────────────────────────────────────────── -// Called from beam_jni.c when the Compose gesture detector fires. Each is -// per-widget opt-in — only registered handles emit. Direction-aware swipes -// use mob_send_swipe_with_direction. - -void mob_send_long_press(int handle) { send_event(handle, "long_press"); } -void mob_send_double_tap(int handle) { send_event(handle, "double_tap"); } -void mob_send_swipe_left(int handle) { send_event(handle, "swipe_left"); } -void mob_send_swipe_right(int handle) { send_event(handle, "swipe_right"); } -void mob_send_swipe_up(int handle) { send_event(handle, "swipe_up"); } -void mob_send_swipe_down(int handle) { send_event(handle, "swipe_down"); } - -void mob_send_swipe_with_direction(int handle, const char* direction) { - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - enif_mutex_unlock(tap_mutex); - - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "swipe"), - enif_make_copy(msg_env, tag), - enif_make_atom(msg_env, direction)); - enif_send(NULL, &pid, msg_env, msg); - enif_free_env(msg_env); -} - -// ── Batch 5 Tier 1: high-frequency events with throttling ───────────────── -// Mirrors the iOS implementation in mob_nif.m. Throttle state lives on each -// TapHandle (above). JNI stubs in beam_jni.c are pending — these C functions -// are the bridge target. - -static long long mob_now_ns_android(void) { - struct timespec ts; - clock_gettime(CLOCK_MONOTONIC, &ts); - return (long long)ts.tv_sec * 1000000000LL + (long long)ts.tv_nsec; -} - -void mob_set_throttle_config(int handle, - int throttle_ms, int debounce_ms, - double delta_threshold, - int leading, int trailing) { - enif_mutex_lock(tap_mutex); - if (handle >= 0 && handle < tap_handle_next && tap_handles[handle].tag_env) { - tap_handles[handle].throttle_ms = throttle_ms; - tap_handles[handle].debounce_ms = debounce_ms; - tap_handles[handle].delta_threshold = delta_threshold; - tap_handles[handle].leading = leading; - tap_handles[handle].trailing = trailing; - } - enif_mutex_unlock(tap_mutex); -} - -static int mob_throttle_check_a(int handle, double x, double y, - int default_throttle_ms, double default_delta) { - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return 0; - } - TapHandle* h = &tap_handles[handle]; - int throttle_ms = h->throttle_ms ? h->throttle_ms : default_throttle_ms; - double delta_threshold = h->delta_threshold > 0 ? h->delta_threshold : default_delta; - - long long now_ns = mob_now_ns_android(); - double dx = x - h->last_x; - double dy = y - h->last_y; - double dist = (dx < 0 ? -dx : dx) + (dy < 0 ? -dy : dy); - - if (h->last_emit_ns > 0 && throttle_ms > 0) { - long long elapsed_ms = (now_ns - h->last_emit_ns) / 1000000LL; - if (elapsed_ms < throttle_ms) { - enif_mutex_unlock(tap_mutex); - return 0; - } - } - if (h->last_emit_ns > 0 && dist < delta_threshold) { - enif_mutex_unlock(tap_mutex); - return 0; - } - - h->last_emit_ns = now_ns; - h->last_x = x; - h->last_y = y; - h->seq++; - enif_mutex_unlock(tap_mutex); - return 1; -} - -// Build payload map for scroll/drag/etc. Caller owns msg_env. -static ERL_NIF_TERM mob_build_scroll_map(ErlNifEnv* env, - double x, double y, - double dx, double dy, - double vx, double vy, - const char* phase, - long long ts_ms, - unsigned long long seq) { - ERL_NIF_TERM keys[9] = { - enif_make_atom(env, "x"), enif_make_atom(env, "y"), - enif_make_atom(env, "dx"), enif_make_atom(env, "dy"), - enif_make_atom(env, "velocity_x"), enif_make_atom(env, "velocity_y"), - enif_make_atom(env, "phase"), - enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), - }; - ERL_NIF_TERM vals[9] = { - enif_make_double(env, x), enif_make_double(env, y), - enif_make_double(env, dx), enif_make_double(env, dy), - enif_make_double(env, vx), enif_make_double(env, vy), - enif_make_atom(env, phase), - enif_make_int64(env, ts_ms), - enif_make_uint64(env, seq), - }; - ERL_NIF_TERM map; - enif_make_map_from_arrays(env, keys, vals, 9, &map); - return map; -} - -void mob_send_scroll(int handle, - double x, double y, - double dx, double dy, - double vx, double vy, - const char* phase) { - int phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!phase_boundary && !mob_throttle_check_a(handle, x, y, 33, 1.0)) return; - - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - unsigned long long seq = tap_handles[handle].seq; - enif_mutex_unlock(tap_mutex); - - long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv* env = enif_alloc_env(); - ERL_NIF_TERM payload = mob_build_scroll_map(env, x, y, dx, dy, vx, vy, phase, ts_ms, seq); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "scroll"), - enif_make_copy(env, tag), - payload); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -void mob_send_drag(int handle, - double x, double y, - double dx, double dy, - const char* phase) { - int phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!phase_boundary && !mob_throttle_check_a(handle, x, y, 16, 1.0)) return; - - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - unsigned long long seq = tap_handles[handle].seq; - enif_mutex_unlock(tap_mutex); - - long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv* env = enif_alloc_env(); - ERL_NIF_TERM keys[7] = { - enif_make_atom(env, "x"), enif_make_atom(env, "y"), - enif_make_atom(env, "dx"), enif_make_atom(env, "dy"), - enif_make_atom(env, "phase"), - enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), - }; - ERL_NIF_TERM vals[7] = { - enif_make_double(env, x), enif_make_double(env, y), - enif_make_double(env, dx), enif_make_double(env, dy), - enif_make_atom(env, phase), - enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), - }; - ERL_NIF_TERM payload; - enif_make_map_from_arrays(env, keys, vals, 7, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "drag"), - enif_make_copy(env, tag), - payload); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -void mob_send_pinch(int handle, double scale, double velocity, const char* phase) { - int phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!phase_boundary && !mob_throttle_check_a(handle, scale, 0, 16, 0.01)) return; - - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - unsigned long long seq = tap_handles[handle].seq; - enif_mutex_unlock(tap_mutex); - - long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv* env = enif_alloc_env(); - ERL_NIF_TERM keys[5] = { - enif_make_atom(env, "scale"), enif_make_atom(env, "velocity"), - enif_make_atom(env, "phase"), - enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), - }; - ERL_NIF_TERM vals[5] = { - enif_make_double(env, scale), enif_make_double(env, velocity), - enif_make_atom(env, phase), - enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), - }; - ERL_NIF_TERM payload; - enif_make_map_from_arrays(env, keys, vals, 5, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "pinch"), - enif_make_copy(env, tag), - payload); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -void mob_send_rotate(int handle, double degrees, double velocity, const char* phase) { - int phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!phase_boundary && !mob_throttle_check_a(handle, degrees, 0, 16, 1.0)) return; - - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - unsigned long long seq = tap_handles[handle].seq; - enif_mutex_unlock(tap_mutex); - - long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv* env = enif_alloc_env(); - ERL_NIF_TERM keys[5] = { - enif_make_atom(env, "degrees"), enif_make_atom(env, "velocity"), - enif_make_atom(env, "phase"), - enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), - }; - ERL_NIF_TERM vals[5] = { - enif_make_double(env, degrees), enif_make_double(env, velocity), - enif_make_atom(env, phase), - enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), - }; - ERL_NIF_TERM payload; - enif_make_map_from_arrays(env, keys, vals, 5, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "rotate"), - enif_make_copy(env, tag), - payload); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -void mob_send_pointer_move(int handle, double x, double y) { - if (!mob_throttle_check_a(handle, x, y, 33, 4.0)) return; - - enif_mutex_lock(tap_mutex); - if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { - enif_mutex_unlock(tap_mutex); - return; - } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; - unsigned long long seq = tap_handles[handle].seq; - enif_mutex_unlock(tap_mutex); - - long long ts_ms = mob_now_ns_android() / 1000000LL; - ErlNifEnv* env = enif_alloc_env(); - ERL_NIF_TERM keys[4] = { - enif_make_atom(env, "x"), enif_make_atom(env, "y"), - enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), - }; - ERL_NIF_TERM vals[4] = { - enif_make_double(env, x), enif_make_double(env, y), - enif_make_int64(env, ts_ms), enif_make_uint64(env, seq), - }; - ERL_NIF_TERM payload; - enif_make_map_from_arrays(env, keys, vals, 4, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "pointer_move"), - enif_make_copy(env, tag), - payload); - enif_send(NULL, &pid, env, msg); - enif_free_env(env); -} - -// ── Batch 5 Tier 2: semantic single-fire scroll events ── -void mob_send_scroll_began(int handle) { send_event(handle, "scroll_began"); } -void mob_send_scroll_ended(int handle) { send_event(handle, "scroll_ended"); } -void mob_send_scroll_settled(int handle) { send_event(handle, "scroll_settled"); } -void mob_send_top_reached(int handle) { send_event(handle, "top_reached"); } -void mob_send_scrolled_past(int handle) { send_event(handle, "scrolled_past"); } - -// ── Back gesture sender ─────────────────────────────────────────────────────── -// Called from beam_jni.c's nativeHandleBack JNI stub when the Android back -// gesture fires. Looks up the :mob_screen registered process and sends -// {:mob, :back} — Mob.Screen.handle_info/2 handles popping or exiting. - -void mob_handle_back(void) { - ErlNifEnv* env = enif_alloc_env(); - ErlNifPid pid; - if (enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { - ERL_NIF_TERM msg = enif_make_tuple2(env, - enif_make_atom(env, "mob"), - enif_make_atom(env, "back")); - enif_send(NULL, &pid, env, msg); - } - enif_free_env(env); -} - -// ── JNI helpers ────────────────────────────────────────────────────────────── - -static JNIEnv* get_jenv(int* attached) { - JNIEnv* env = NULL; - *attached = 0; - if ((*g_jvm)->GetEnv(g_jvm, (void**)&env, JNI_VERSION_1_6) == JNI_EDETACHED) { - (*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL); - *attached = 1; - } - return env; -} - -// ── Cache MobBridge class (called from mob_beam.c) ─────────────────────────── - -void _mob_ui_cache_class_impl(JNIEnv* jenv, const char* bridge_class) { - LOGI("mob_ui_cache_class: looking up %s", bridge_class); - jclass cls = (*jenv)->FindClass(jenv, bridge_class); - if (!cls) { LOGE("mob_ui_cache_class: %s not found", bridge_class); return; } - Bridge.cls = (*jenv)->NewGlobalRef(jenv, cls); - (*jenv)->DeleteLocalRef(jenv, cls); - // Cache startup status methods now — they're needed before nif_load runs. - // These are optional (older MobBridge versions may not have them); clear - // any pending exception rather than aborting. - Bridge.set_startup_phase = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "setStartupPhase", "(Ljava/lang/String;)V"); - if (!Bridge.set_startup_phase) (*jenv)->ExceptionClear(jenv); - Bridge.set_startup_error = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "setStartupError", "(Ljava/lang/String;)V"); - if (!Bridge.set_startup_error) (*jenv)->ExceptionClear(jenv); - LOGI("mob_ui_cache_class: %s cached OK", bridge_class); -} - -void mob_set_startup_phase(const char* phase) { - if (!g_jvm || !Bridge.cls || !Bridge.set_startup_phase) return; - int att; JNIEnv* env = get_jenv(&att); - jstring js = (*env)->NewStringUTF(env, phase); - (*env)->CallStaticVoidMethod(env, Bridge.cls, Bridge.set_startup_phase, js); - (*env)->DeleteLocalRef(env, js); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - LOGI("startup: %s", phase); -} - -void mob_set_startup_error(const char* error) { - if (!g_jvm || !Bridge.cls || !Bridge.set_startup_error) return; - int att; JNIEnv* env = get_jenv(&att); - jstring js = (*env)->NewStringUTF(env, error); - (*env)->CallStaticVoidMethod(env, Bridge.cls, Bridge.set_startup_error, js); - (*env)->DeleteLocalRef(env, js); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - LOGE("startup ERROR: %s", error); -} - -// ── Initialize bridge with Activity (called from mob_beam.c) ───────────────── - -void _mob_bridge_init_activity(JNIEnv* env, jobject activity) { - if (!Bridge.cls) { LOGE("_mob_bridge_init_activity: Bridge.cls not cached"); return; } - jmethodID init = (*env)->GetStaticMethodID(env, Bridge.cls, "init", - "(Landroid/app/Activity;)V"); - (*env)->CallStaticVoidMethod(env, Bridge.cls, init, activity); - LOGI("_mob_bridge_init_activity: MobBridge.init called"); -} - -// ── NIF: platform/0 ────────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_platform(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_atom(env, "android"); -} - -// ── NIF: color_scheme/0 ────────────────────────────────────────────────────── -// Returns :light or :dark based on the Activity's current Configuration.uiMode. -// Returns :light if MobBridge.getColorScheme() isn't compiled into the app -// (older projects). - -static ERL_NIF_TERM nif_color_scheme(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.get_color_scheme) return enif_make_atom(env, "light"); - int att; JNIEnv* jenv = get_jenv(&att); - jstring result = - (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.get_color_scheme); - ERL_NIF_TERM atom = enif_make_atom(env, "light"); - if (result) { - const char* str = (*jenv)->GetStringUTFChars(jenv, result, NULL); - if (str) { - if (strcmp(str, "dark") == 0) atom = enif_make_atom(env, "dark"); - (*jenv)->ReleaseStringUTFChars(jenv, result, str); - } - (*jenv)->DeleteLocalRef(jenv, result); - } - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return atom; -} - -// ── NIF: log/1 ─────────────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_log(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char buf[4096] = {0}; - ErlNifBinary bin; - if (enif_inspect_binary(env, argv[0], &bin)) { - size_t len = bin.size < sizeof(buf) - 1 ? bin.size : sizeof(buf) - 1; - memcpy(buf, bin.data, len); - buf[len] = 0; - } else if (!enif_get_string(env, argv[0], buf, sizeof(buf), ERL_NIF_LATIN1)) { - return enif_make_badarg(env); - } - __android_log_print(ANDROID_LOG_INFO, "Elixir", "%s", buf); - return enif_make_atom(env, "ok"); -} - -// ── NIF: log/2 ─────────────────────────────────────────────────────────────── - -static int atom_to_android_priority(ErlNifEnv* env, ERL_NIF_TERM level_atom) { - char level[16]; - if (!enif_get_atom(env, level_atom, level, sizeof(level), ERL_NIF_LATIN1)) - return ANDROID_LOG_INFO; - if (strcmp(level, "debug") == 0) return ANDROID_LOG_DEBUG; - if (strcmp(level, "warning") == 0) return ANDROID_LOG_WARN; - if (strcmp(level, "error") == 0) return ANDROID_LOG_ERROR; - return ANDROID_LOG_INFO; -} - -static ERL_NIF_TERM nif_log2(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char buf[4096] = {0}; - int priority = atom_to_android_priority(env, argv[0]); - ErlNifBinary bin; - if (enif_inspect_binary(env, argv[1], &bin)) { - size_t len = bin.size < sizeof(buf) - 1 ? bin.size : sizeof(buf) - 1; - memcpy(buf, bin.data, len); - buf[len] = 0; - } else if (!enif_get_string(env, argv[1], buf, sizeof(buf), ERL_NIF_LATIN1)) { - return enif_make_badarg(env); - } - __android_log_print(priority, "Elixir", "%s", buf); - return enif_make_atom(env, "ok"); -} - -// ── NIF: set_root/1 ────────────────────────────────────────────────────────── -// Accepts a JSON binary and passes it to MobBridge.setRootJson(String) on the -// Kotlin side. Compose state update is thread-safe — no main-thread hop needed. - -static ERL_NIF_TERM nif_set_root(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - - // Null-terminate for NewStringUTF - char* json = (char*)malloc(bin.size + 1); - if (!json) return enif_make_atom(env, "error"); - memcpy(json, bin.data, bin.size); - json[bin.size] = 0; - - // Snapshot the current transition (set by set_transition/1 before this call) - enif_mutex_lock(tap_mutex); - char transition[16]; - strncpy(transition, g_transition, sizeof(transition) - 1); - transition[sizeof(transition) - 1] = 0; - strncpy(g_transition, "none", sizeof(g_transition)); // reset to none - enif_mutex_unlock(tap_mutex); - - int att; JNIEnv* jenv = get_jenv(&att); - jstring jjson = (*jenv)->NewStringUTF(jenv, json); - jstring jtransition = (*jenv)->NewStringUTF(jenv, transition); - free(json); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.set_root, jjson, jtransition); - (*jenv)->DeleteLocalRef(jenv, jjson); - (*jenv)->DeleteLocalRef(jenv, jtransition); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: register_tap/1 ────────────────────────────────────────────────────── -// Accepts pid (tag = :ok) or {pid, tag} (any Erlang term used as the tag). - -static ERL_NIF_TERM nif_register_tap(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; - ERL_NIF_TERM tag_term; - - // Try plain pid first - if (enif_get_local_pid(env, argv[0], &pid)) { - // No explicit tag — use :ok - tag_term = enif_make_atom(env, "ok"); - } else { - // Try {pid, tag} 2-tuple - int arity; - const ERL_NIF_TERM* elems; - if (!enif_get_tuple(env, argv[0], &arity, &elems) || arity != 2) - return enif_make_badarg(env); - if (!enif_get_local_pid(env, elems[0], &pid)) - return enif_make_badarg(env); - tag_term = elems[1]; - } - - enif_mutex_lock(tap_mutex); - if (tap_handle_next >= MAX_TAP_HANDLES) { - enif_mutex_unlock(tap_mutex); - return enif_make_badarg(env); - } - int handle = tap_handle_next++; - tap_handles[handle].pid = pid; - tap_handles[handle].tag_env = enif_alloc_env(); - tap_handles[handle].tag = enif_make_copy(tap_handles[handle].tag_env, tag_term); - enif_mutex_unlock(tap_mutex); - - return enif_make_int(env, handle); -} - -// ── NIF: clear_taps/0 ──────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_clear_taps(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - enif_mutex_lock(tap_mutex); - for (int i = 0; i < tap_handle_next; i++) { - if (tap_handles[i].tag_env) { - enif_free_env(tap_handles[i].tag_env); - tap_handles[i].tag_env = NULL; - } - // Reset throttle state — slots get reused across renders. - tap_handles[i].throttle_ms = 0; - tap_handles[i].debounce_ms = 0; - tap_handles[i].delta_threshold = 0; - tap_handles[i].leading = 1; - tap_handles[i].trailing = 1; - tap_handles[i].last_emit_ns = 0; - tap_handles[i].last_x = 0; - tap_handles[i].last_y = 0; - tap_handles[i].seq = 0; - } - tap_handle_next = 0; - enif_mutex_unlock(tap_mutex); - return enif_make_atom(env, "ok"); -} - -// ── NIF: exit_app/0 ────────────────────────────────────────────────────────── -// Backgrounds the app via MobBridge.moveToBack() → activity.moveTaskToBack(true). -// Called by Mob.Screen when the back gesture fires at the root of the nav stack. - -static ERL_NIF_TERM nif_exit_app(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.move_to_back); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: set_transition/1 ──────────────────────────────────────────────────── -// Stores the transition type atom (push/pop/reset/none) to be passed to -// setRootJson on the next set_root call. Must be called before set_root. - -static ERL_NIF_TERM nif_set_transition(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - enif_mutex_lock(tap_mutex); - if (!enif_get_atom(env, argv[0], g_transition, sizeof(g_transition), ERL_NIF_LATIN1)) { - enif_mutex_unlock(tap_mutex); - return enif_make_badarg(env); - } - enif_mutex_unlock(tap_mutex); - return enif_make_atom(env, "ok"); -} - -// ── NIF: safe_area/0 ───────────────────────────────────────────────────────── -// Returns {Top, Right, Bottom, Left} in dp via MobBridge.getSafeArea(). - -static ERL_NIF_TERM nif_safe_area(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - jfloatArray arr = (jfloatArray)(*jenv)->CallStaticObjectMethod( - jenv, Bridge.cls, Bridge.get_safe_area); - float vals[4] = {0.0f, 0.0f, 0.0f, 0.0f}; - if (arr) { - (*jenv)->GetFloatArrayRegion(jenv, arr, 0, 4, vals); - (*jenv)->DeleteLocalRef(jenv, arr); - } - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_tuple4(env, - enif_make_double(env, (double)vals[0]), - enif_make_double(env, (double)vals[1]), - enif_make_double(env, (double)vals[2]), - enif_make_double(env, (double)vals[3]) - ); -} - -// ── NIF: haptic/1 ───────────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_haptic(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char type[32] = {0}; - enif_get_atom(env, argv[0], type, sizeof(type), ERL_NIF_LATIN1); - int att; JNIEnv* jenv = get_jenv(&att); - jstring jtype = (*jenv)->NewStringUTF(jenv, type); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.haptic, jtype); - (*jenv)->DeleteLocalRef(jenv, jtype); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: clipboard_put/1 ────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_clipboard_put(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char* text = (char*)malloc(bin.size + 1); - if (!text) return enif_make_atom(env, "error"); - memcpy(text, bin.data, bin.size); - text[bin.size] = 0; - int att; JNIEnv* jenv = get_jenv(&att); - jstring jtext = (*jenv)->NewStringUTF(jenv, text); - free(text); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.clipboard_put, jtext); - (*jenv)->DeleteLocalRef(jenv, jtext); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: clipboard_get/0 ────────────────────────────────────────────────────── -// Returns {:ok, Binary} or :empty. - -static ERL_NIF_TERM nif_clipboard_get(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - jstring result = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.clipboard_get); - - ERL_NIF_TERM ret; - if (result) { - const char* utf8 = (*jenv)->GetStringUTFChars(jenv, result, NULL); - ErlNifBinary bin; - size_t len = strlen(utf8); - enif_alloc_binary(len, &bin); - memcpy(bin.data, utf8, len); - (*jenv)->ReleaseStringUTFChars(jenv, result, utf8); - (*jenv)->DeleteLocalRef(jenv, result); - ret = enif_make_tuple2(env, enif_make_atom(env, "ok"), enif_make_binary(env, &bin)); - } else { - ret = enif_make_atom(env, "empty"); - } - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return ret; -} - -// ── NIF: open_url/1 ─────────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_open_url(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char* url = (char*)malloc(bin.size + 1); - if (!url) return enif_make_atom(env, "error"); - memcpy(url, bin.data, bin.size); - url[bin.size] = 0; - int att; JNIEnv* jenv = get_jenv(&att); - jstring jurl = (*jenv)->NewStringUTF(jenv, url); - free(url); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.open_url, jurl); - (*jenv)->DeleteLocalRef(jenv, jurl); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: share_text/1 ───────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_share_text(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char* text = (char*)malloc(bin.size + 1); - if (!text) return enif_make_atom(env, "error"); - memcpy(text, bin.data, bin.size); - text[bin.size] = 0; - int att; JNIEnv* jenv = get_jenv(&att); - jstring jtext = (*jenv)->NewStringUTF(jenv, text); - free(text); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.share_text, jtext); - (*jenv)->DeleteLocalRef(jenv, jtext); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ════════════════════════════════════════════════════════════════════════════ -// Device capability NIFs (Android JNI bridge) -// Each calls a static method on MobBridge with the PID encoded as a long so -// Kotlin can call mob_nif_deliver_event() with the result. -// ════════════════════════════════════════════════════════════════════════════ - -// Launch notification global (written by MobBridge.setLaunchNotification, read once) -static char* g_launch_notif_json = NULL; -static ErlNifMutex* g_launch_notif_mutex = NULL; - -// Called from MobBridge.setLaunchNotification(json) -void mob_set_launch_notification(const char* json) { - if (!g_launch_notif_mutex) return; - enif_mutex_lock(g_launch_notif_mutex); - free(g_launch_notif_json); - g_launch_notif_json = json ? strdup(json) : NULL; - enif_mutex_unlock(g_launch_notif_mutex); -} - -static ERL_NIF_TERM nif_take_launch_notification(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!g_launch_notif_mutex) return enif_make_atom(env, "none"); - enif_mutex_lock(g_launch_notif_mutex); - char* json = g_launch_notif_json; - g_launch_notif_json = NULL; - enif_mutex_unlock(g_launch_notif_mutex); - if (!json) return enif_make_atom(env, "none"); - ErlNifBinary bin; - enif_alloc_binary(strlen(json), &bin); - memcpy(bin.data, json, strlen(json)); - free(json); - return enif_make_binary(env, &bin); -} - -// Generic helper: call Kotlin static method(pid_long, string_arg) -static ERL_NIF_TERM call_bridge_pid_str(ErlNifEnv* env, jmethodID method, - ErlNifPid pid, const char* arg) { - int att; JNIEnv* jenv = get_jenv(&att); - jlong jpid; - memcpy(&jpid, &pid, sizeof(ErlNifPid) < sizeof(jlong) ? sizeof(ErlNifPid) : sizeof(jlong)); - jstring jarg = arg ? (*jenv)->NewStringUTF(jenv, arg) : NULL; - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, method, jpid, jarg); - if (jarg) (*jenv)->DeleteLocalRef(jenv, jarg); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM call_bridge_pid_str2(ErlNifEnv* env, jmethodID method, - ErlNifPid pid, const char* a1, const char* a2) { - int att; JNIEnv* jenv = get_jenv(&att); - jlong jpid; - memcpy(&jpid, &pid, sizeof(ErlNifPid) < sizeof(jlong) ? sizeof(ErlNifPid) : sizeof(jlong)); - jstring j1 = a1 ? (*jenv)->NewStringUTF(jenv, a1) : NULL; - jstring j2 = a2 ? (*jenv)->NewStringUTF(jenv, a2) : NULL; - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, method, jpid, j1, j2); - if (j1) (*jenv)->DeleteLocalRef(jenv, j1); - if (j2) (*jenv)->DeleteLocalRef(jenv, j2); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// mob_nif_deliver_event — called from Kotlin with a JSON string result. -// Decodes the JSON and sends the appropriate BEAM message to the pid stored in it. -// JSON format: {"pid": <long>, "event": [...erlang term json...]} -// We use a simpler approach: Kotlin encodes the event as a JSON array describing the term. -// Actually, simplest approach: Kotlin constructs a binary JSON string, and we route -// to pre-stored PIDs in a simple table. But since we pass the PID as a long to Kotlin, -// Kotlin passes it back to us and we reconstruct the ErlNifPid. -// -// mob_nif_deliver_json(pid_long, json_cstr) — send pre-formed JSON event to pid -// This is declared in mob_beam.h for Kotlin to call via JNI. -void mob_nif_deliver_json(jlong pid_long, const char* json_str) { - // We don't send JSON to the BEAM — we need to build proper Erlang terms. - // Instead, we use a set of typed delivery functions called from Kotlin. - // See mob_beam.h for the full set. -} - -// Typed event delivery functions called from Kotlin/JNI -// These are declared in mob_beam.h and implemented here. - -static ErlNifPid pid_from_long(jlong jpid) { - ErlNifPid pid; - memset(&pid, 0, sizeof(pid)); - memcpy(&pid, &jpid, sizeof(ErlNifPid) < sizeof(jlong) ? sizeof(ErlNifPid) : sizeof(jlong)); - return pid; -} - -void mob_deliver_atom2(jlong jpid, const char* a1, const char* a2) { - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,a1), enif_make_atom(e,a2)); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -void mob_deliver_atom3(jlong jpid, const char* a1, const char* a2, const char* a3) { - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e,a1), enif_make_atom(e,a2), enif_make_atom(e,a3)); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -void mob_deliver_location(jlong jpid, double lat, double lon, double acc, double alt) { - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM keys[4] = { - enif_make_atom(e,"lat"), enif_make_atom(e,"lon"), - enif_make_atom(e,"accuracy"), enif_make_atom(e,"altitude") - }; - ERL_NIF_TERM vals[4] = { - enif_make_double(e,lat), enif_make_double(e,lon), - enif_make_double(e,acc), enif_make_double(e,alt) - }; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 4, &map); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,"location"), map); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -void mob_deliver_motion(jlong jpid, double ax, double ay, double az, - double gx, double gy, double gz, long long ts) { - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM accel = enif_make_tuple3(e, - enif_make_double(e,ax), enif_make_double(e,ay), enif_make_double(e,az)); - ERL_NIF_TERM gyro = enif_make_tuple3(e, - enif_make_double(e,gx), enif_make_double(e,gy), enif_make_double(e,gz)); - ERL_NIF_TERM keys[3] = { - enif_make_atom(e,"accel"), enif_make_atom(e,"gyro"), enif_make_atom(e,"timestamp") - }; - ERL_NIF_TERM vals[3] = {accel, gyro, enif_make_int64(e,ts)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 3, &map); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,"motion"), map); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -// Deliver a {:webview, tag, binary} message. When jpid==0, looks up :mob_screen. -static void deliver_webview_binary(jlong jpid, const char* tag, const char* utf8) { - ErlNifEnv* e = enif_alloc_env(); - ErlNifPid pid; - if (jpid != 0) { - pid = pid_from_long(jpid); - } else if (!enif_whereis_pid(e, enif_make_atom(e, "mob_screen"), &pid)) { - enif_free_env(e); return; - } - size_t len = strlen(utf8); - ErlNifBinary bin; - enif_alloc_binary(len, &bin); - memcpy(bin.data, utf8, len); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e, "webview"), - enif_make_atom(e, tag), - enif_make_binary(e, &bin)); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -void mob_deliver_webview_message(jlong jpid, const char* json) { - deliver_webview_binary(jpid, "message", json); -} - -void mob_deliver_webview_blocked(jlong jpid, const char* url) { - deliver_webview_binary(jpid, "blocked", url); -} - -void mob_deliver_file_result(jlong jpid, const char* event, // "camera","photos","files","audio","scan" - const char* sub, // "photo","video","picked","recorded","result","cancelled" - const char* json_items) { // JSON array of item maps, or NULL for cancelled - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg; - if (!json_items || strcmp(json_items, "cancelled") == 0) { - msg = enif_make_tuple2(e, enif_make_atom(e,event), enif_make_atom(e,"cancelled")); - } else { - // Parse JSON array of maps and build Erlang list - // Simple approach: pass the raw JSON binary as a string; the BEAM can decode it if needed. - // Better: build proper terms here. - // For now, pass as binary; Elixir side can use :json.decode. - // But we want typed data, so let's build a simple list of maps. - // We'll use a JSON-like binary approach: send the raw JSON and let the BEAM decode it. - ErlNifBinary jb; - size_t jlen = strlen(json_items); - enif_alloc_binary(jlen, &jb); - memcpy(jb.data, json_items, jlen); - // Build: {event_atom, sub_atom, json_binary} - // The Elixir Mob.Screen will need to decode it. Actually, let's send the JSON - // and have Mob.Screen decode it — but screen doesn't do that for file results. - // Better: send as a tagged binary that Elixir wrappers decode. - // We'll send {:mob_file_result, event, sub, json_binary} and add a handler. - ErlNifBinary eb; size_t el = strlen(event); enif_alloc_binary(el,&eb); memcpy(eb.data,event,el); - ErlNifBinary sb; size_t sl = strlen(sub); enif_alloc_binary(sl,&sb); memcpy(sb.data,sub,sl); - msg = enif_make_tuple4(e, - enif_make_atom(e,"mob_file_result"), - enif_make_binary(e,&eb), - enif_make_binary(e,&sb), - enif_make_binary(e,&jb)); - } - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -void mob_deliver_push_token(jlong jpid, const char* token) { - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); - ErlNifBinary tb; size_t tl = strlen(token); enif_alloc_binary(tl,&tb); memcpy(tb.data,token,tl); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e,"push_token"), enif_make_atom(e,"android"), enif_make_binary(e,&tb)); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -void mob_deliver_notification(jlong jpid, const char* json) { - ErlNifPid pid = pid_from_long(jpid); - ErlNifEnv* e = enif_alloc_env(); - ErlNifBinary jb; size_t jl = strlen(json); enif_alloc_binary(jl,&jb); memcpy(jb.data,json,jl); - ERL_NIF_TERM msg = enif_make_tuple2(e, - enif_make_atom(e,"mob_launch_notification"), enif_make_binary(e,&jb)); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -// NIF implementations — thin wrappers that pass work to Kotlin - -static ERL_NIF_TERM nif_request_permission(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char cap[32]; enif_get_atom(env, argv[0], cap, sizeof(cap), ERL_NIF_LATIN1); - ErlNifPid pid; enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.request_permission, pid, cap); -} - -static ERL_NIF_TERM nif_biometric_authenticate(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char reason[256] = "Authenticate"; - if (bin.size < sizeof(reason)) { memcpy(reason, bin.data, bin.size); reason[bin.size] = 0; } - ErlNifPid pid; enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.biometric_authenticate, pid, reason); -} - -static ERL_NIF_TERM nif_location_get_once(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.location_get_once, pid, "balanced"); -} - -static ERL_NIF_TERM nif_location_start(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char acc[16] = "balanced"; enif_get_atom(env, argv[0], acc, sizeof(acc), ERL_NIF_LATIN1); - ErlNifPid pid; enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.location_start, pid, acc); -} - -static ERL_NIF_TERM nif_location_stop(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.location_stop); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_camera_capture_photo(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char qual[16] = "high"; enif_get_atom(env, argv[0], qual, sizeof(qual), ERL_NIF_LATIN1); - ErlNifPid pid; enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.camera_capture_photo, pid, qual); -} - -static ERL_NIF_TERM nif_camera_capture_video(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int max_dur = 60; enif_get_int(env, argv[0], &max_dur); - ErlNifPid pid; enif_self(env, &pid); - char dur_str[16]; snprintf(dur_str, sizeof(dur_str), "%d", max_dur); - return call_bridge_pid_str(env, Bridge.camera_capture_video, pid, dur_str); -} - -static ERL_NIF_TERM nif_camera_start_preview(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char* json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); json[bin.size] = 0; - ErlNifPid pid; enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.camera_start_preview, pid, json); - free(json); - return result; -} - -static ERL_NIF_TERM nif_camera_stop_preview(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.camera_stop_preview); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_photos_pick(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int max = 1; enif_get_int(env, argv[0], &max); - ErlNifPid pid; enif_self(env, &pid); - char max_str[16]; snprintf(max_str, sizeof(max_str), "%d", max); - return call_bridge_pid_str(env, Bridge.photos_pick, pid, max_str); -} - -static ERL_NIF_TERM nif_files_pick(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char* json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); json[bin.size] = 0; - ErlNifPid pid; enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.files_pick, pid, json); - free(json); - return result; -} - -static ERL_NIF_TERM nif_audio_start_recording(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char* json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); json[bin.size] = 0; - ErlNifPid pid; enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.audio_start_recording, pid, json); - free(json); - return result; -} - -static ERL_NIF_TERM nif_audio_stop_recording(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.audio_stop_recording); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_audio_play(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary path_bin, opts_bin; - if (!enif_inspect_binary(env, argv[0], &path_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &path_bin)) return enif_make_badarg(env); - if (!enif_inspect_binary(env, argv[1], &opts_bin) && - !enif_inspect_iolist_as_binary(env, argv[1], &opts_bin)) return enif_make_badarg(env); - char* path = malloc(path_bin.size + 1); - memcpy(path, path_bin.data, path_bin.size); path[path_bin.size] = 0; - char* opts = malloc(opts_bin.size + 1); - memcpy(opts, opts_bin.data, opts_bin.size); opts[opts_bin.size] = 0; - ErlNifPid pid; enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str2(env, Bridge.audio_play, pid, path, opts); - free(path); free(opts); - return result; -} - -static ERL_NIF_TERM nif_audio_stop_playback(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.audio_stop_playback); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_audio_set_volume(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - double vol = 1.0; - enif_get_double(env, argv[0], &vol); - char vol_str[32]; snprintf(vol_str, sizeof(vol_str), "%.6f", vol); - int att; JNIEnv* jenv = get_jenv(&att); - jstring jvol = (*jenv)->NewStringUTF(jenv, vol_str); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.audio_set_volume, jvol); - (*jenv)->DeleteLocalRef(jenv, jvol); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_motion_start(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int interval_ms = 100; enif_get_int(env, argv[1], &interval_ms); - char interval_str[16]; snprintf(interval_str, sizeof(interval_str), "%d", interval_ms); - ErlNifPid pid; enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.motion_start, pid, interval_str); -} - -static ERL_NIF_TERM nif_motion_stop(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.motion_stop); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_scanner_scan(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char* json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); json[bin.size] = 0; - ErlNifPid pid; enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.scanner_scan, pid, json); - free(json); - return result; -} - -static ERL_NIF_TERM nif_notify_schedule(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char* json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); json[bin.size] = 0; - ErlNifPid pid; enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str(env, Bridge.notify_schedule, pid, json); - free(json); - return result; -} - -static ERL_NIF_TERM nif_notify_cancel(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char nid[256] = ""; - if (bin.size < sizeof(nid)) { memcpy(nid, bin.data, bin.size); nid[bin.size] = 0; } - int att; JNIEnv* jenv = get_jenv(&att); - jstring js = (*jenv)->NewStringUTF(jenv, nid); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.notify_cancel, js); - (*jenv)->DeleteLocalRef(jenv, js); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_notify_register_push(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); - return call_bridge_pid_str(env, Bridge.notify_register_push, pid, NULL); -} - -// ── NIF table & load ───────────────────────────────────────────────────────── - -// ── Test harness NIFs ───────────────────────────────────────────────────────── -// -// Android implementation notes vs iOS: -// - View tree walk uses android.view.View hierarchy (Compose exposes Views) -// - Touch injection via DecorView.dispatchTouchEvent — no INJECT_EVENTS needed -// - Text input via InputConnection.commitText — works for Compose TextField -// - All blocking operations use CountDownLatch on the Kotlin side; -// from C we just call the JNI method which blocks until the latch fires -// - Coordinates in dp (density-independent pixels), matching iOS convention - -// Helper: jstring → ERL_NIF_TERM binary (UTF-8). Deletes local ref. -static ERL_NIF_TERM jstring_to_bin(ErlNifEnv* env, JNIEnv* jenv, jstring js) { - if (!js) return enif_make_atom(env, "nil"); - const char* utf = (*jenv)->GetStringUTFChars(jenv, js, NULL); - if (!utf) return enif_make_atom(env, "nil"); - size_t len = strlen(utf); - ErlNifBinary bin; - enif_alloc_binary(len, &bin); - memcpy(bin.data, utf, len); - (*jenv)->ReleaseStringUTFChars(jenv, js, utf); - (*jenv)->DeleteLocalRef(jenv, js); - return enif_make_binary(env, &bin); -} - -// Helper: make a binary term from a C string (does NOT delete jstring). -static ERL_NIF_TERM cstr_to_bin(ErlNifEnv* env, const char* s, size_t len) { - ErlNifBinary bin; - enif_alloc_binary(len, &bin); - memcpy(bin.data, s, len); - return enif_make_binary(env, &bin); -} - -// nif_ui_tree/0 — returns [{type_atom, label_binary, value_binary, {x,y,w,h}}, ...] -// -// Calls MobBridge.uiTree() which returns a newline-separated string: -// type|label|value|x|y|w|h\n... -// Parses that into a list of 4-tuples matching the iOS ui_tree format. -static ERL_NIF_TERM nif_ui_tree(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.ui_tree) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - - int att; JNIEnv* jenv = get_jenv(&att); - jstring jresult = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.ui_tree); - if (!jresult) { - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_list(env, 0); - } - - const char* raw = (*jenv)->GetStringUTFChars(jenv, jresult, NULL); - ERL_NIF_TERM list = enif_make_list(env, 0); - - // Parse lines in reverse (we'll reverse the list at the end) - // Format per line: type|label|value|x|y|w|h - const char* p = raw; - // Collect all lines into a temp array first (we build list in reverse for efficiency) - // Simple approach: walk forward, build list, reverse at end - ERL_NIF_TERM items[512]; - int count = 0; - - while (*p && count < 512) { - // Find end of line - const char* nl = strchr(p, '\n'); - if (!nl) break; - size_t line_len = nl - p; - char line[512]; - if (line_len >= sizeof(line)) { p = nl + 1; continue; } - memcpy(line, p, line_len); - line[line_len] = 0; - p = nl + 1; - - // Split on '|': type, label, value, x, y, w, h - char* fields[7]; - int nf = 0; - char* tok = line; - for (int i = 0; i < 7; i++) { - fields[i] = tok; - char* sep = (i < 6) ? strchr(tok, '|') : NULL; - if (sep) { *sep = 0; tok = sep + 1; nf++; } - else { nf = i + 1; break; } - } - if (nf < 7) continue; - - double x = atof(fields[3]); - double y = atof(fields[4]); - double w = atof(fields[5]); - double h = atof(fields[6]); - - ERL_NIF_TERM frame = enif_make_tuple4(env, - enif_make_double(env, x), enif_make_double(env, y), - enif_make_double(env, w), enif_make_double(env, h)); - - // label and value: non-empty → binary, empty → atom nil - size_t llen = strlen(fields[1]); - size_t vlen = strlen(fields[2]); - ERL_NIF_TERM label = llen > 0 ? cstr_to_bin(env, fields[1], llen) - : enif_make_atom(env, "nil"); - ERL_NIF_TERM value = vlen > 0 ? cstr_to_bin(env, fields[2], vlen) - : enif_make_atom(env, "nil"); - - items[count++] = enif_make_tuple4(env, - enif_make_atom(env, fields[0]), - label, value, frame); - } - - (*jenv)->ReleaseStringUTFChars(jenv, jresult, raw); - (*jenv)->DeleteLocalRef(jenv, jresult); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - - // Build list from items array (forward order) - list = enif_make_list(env, 0); - for (int i = count - 1; i >= 0; i--) - list = enif_make_list_cell(env, items[i], list); - return list; -} - -// nif_ui_view_tree/0 — returns nested-map UI tree from MobBridge.uiViewTree(). -// -// Bridge contract: Kotlin side returns a JSON string of the form: -// {"type":"root","label":null,"value":null,"frame":[0,0,W,H],"children":[ ... ]} -// Each child has the same shape. Empty registry returns an empty children list. -// -// The JSON is parsed by Mob.Test.tree/1 on the Erlang side (jason decode is fast -// and avoids hand-rolling a JSON tokenizer in C). Returns {:error, :not_loaded} -// if MobBridge.uiViewTree() isn't present (early adopter apps without registry). -static ERL_NIF_TERM nif_ui_view_tree(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.ui_view_tree) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - int att; JNIEnv* jenv = get_jenv(&att); - jstring jresult = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.ui_view_tree); - ERL_NIF_TERM result = jstring_to_bin(env, jenv, jresult); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return result; -} - -// nif_screen_info/0 — returns %{width, height, scale, safe_area: %{...}} -// -// Width/height are in dp (already px-divided by density on the Kotlin side). -// scale is the density factor (1.0/1.5/2.0/2.625/3.0/...) — same role as -// UIScreen.scale on iOS. -// -// Bridge contract: MobBridge.screenInfo() returns float[6] = [w, h, scale, -// safe_top, safe_bottom, safe_left]; safe_right is computed as 0 here for -// brevity but the Kotlin side should send it once added to the array. -// -// Falls back to safe_area-only info if screenInfo() isn't bound (older bridges). -static ERL_NIF_TERM nif_screen_info(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - float vals[7] = {0}; // w, h, scale, top, bottom, left, right - if (Bridge.screen_info) { - jfloatArray arr = (jfloatArray)(*jenv)->CallStaticObjectMethod( - jenv, Bridge.cls, Bridge.screen_info); - if (arr) { - jsize len = (*jenv)->GetArrayLength(jenv, arr); - if (len > 7) len = 7; - (*jenv)->GetFloatArrayRegion(jenv, arr, 0, len, vals); - (*jenv)->DeleteLocalRef(jenv, arr); - } - } - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - - ERL_NIF_TERM sa_keys[4] = { - enif_make_atom(env, "top"), - enif_make_atom(env, "bottom"), - enif_make_atom(env, "left"), - enif_make_atom(env, "right") - }; - ERL_NIF_TERM sa_vals[4] = { - enif_make_double(env, (double)vals[3]), - enif_make_double(env, (double)vals[4]), - enif_make_double(env, (double)vals[5]), - enif_make_double(env, (double)vals[6]) - }; - ERL_NIF_TERM safe_area; - enif_make_map_from_arrays(env, sa_keys, sa_vals, 4, &safe_area); - - ERL_NIF_TERM keys[4] = { - enif_make_atom(env, "width"), - enif_make_atom(env, "height"), - enif_make_atom(env, "scale"), - enif_make_atom(env, "safe_area") - }; - ERL_NIF_TERM vvals[4] = { - enif_make_double(env, (double)vals[0]), - enif_make_double(env, (double)vals[1]), - enif_make_double(env, (double)vals[2]), - safe_area - }; - ERL_NIF_TERM result; - enif_make_map_from_arrays(env, keys, vvals, 4, &result); - return result; -} - -// nif_ax_action/2 and nif_ax_action_at_xy/3 — Android stubs. -// -// Both are iOS-only today. Compose semantics walker (the proper Android -// implementation) is queued under WireTap (see future_developments.md). -// Return a clear error so callers get `{:error, :not_supported_on_android}` -// instead of an `:undef` crash. -static ERL_NIF_TERM nif_ax_action(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_supported_on_android")); -} -static ERL_NIF_TERM nif_ax_action_at_xy(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_supported_on_android")); -} - -// nif_ui_debug/0 — returns raw uiTree string as a binary (for debugging) -static ERL_NIF_TERM nif_ui_debug(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.ui_tree) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - int att; JNIEnv* jenv = get_jenv(&att); - jstring jresult = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.ui_tree); - ERL_NIF_TERM result = jstring_to_bin(env, jenv, jresult); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return result; -} - -// nif_tap/1 — tap by accessibility label binary -static ERL_NIF_TERM nif_tap(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.tap_by_label) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* label = (char*)malloc(bin.size + 1); - if (!label) return enif_make_atom(env, "error"); - memcpy(label, bin.data, bin.size); - label[bin.size] = 0; - - int att; JNIEnv* jenv = get_jenv(&att); - jstring jlabel = (*jenv)->NewStringUTF(jenv, label); - free(label); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.tap_by_label, jlabel); - (*jenv)->DeleteLocalRef(jenv, jlabel); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "no_element_with_label")); -} - -// nif_tap_xy/2 — tap at (x, y) dp coordinates -static ERL_NIF_TERM nif_tap_xy(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.tap_xy) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - double x, y; - if (!enif_get_double(env, argv[0], &x)) { int ix; if (!enif_get_int(env, argv[0], &ix)) return enif_make_badarg(env); x = ix; } - if (!enif_get_double(env, argv[1], &y)) { int iy; if (!enif_get_int(env, argv[1], &iy)) return enif_make_badarg(env); y = iy; } - - int att; JNIEnv* jenv = get_jenv(&att); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.tap_xy, (jfloat)x, (jfloat)y); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "dispatch_failed")); -} - -// nif_type_text/1 — type text into the focused view -static ERL_NIF_TERM nif_type_text(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.type_text) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* text = (char*)malloc(bin.size + 1); - if (!text) return enif_make_atom(env, "error"); - memcpy(text, bin.data, bin.size); - text[bin.size] = 0; - - int att; JNIEnv* jenv = get_jenv(&att); - jstring jtext = (*jenv)->NewStringUTF(jenv, text); - free(text); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.type_text, jtext); - (*jenv)->DeleteLocalRef(jenv, jtext); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); -} - -// nif_delete_backward/0 — delete one character backward -static ERL_NIF_TERM nif_delete_backward(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.delete_backward) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - int att; JNIEnv* jenv = get_jenv(&att); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.delete_backward); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); -} - -// nif_key_press/1 — not yet implemented on Android (no KeyCharacterMap lookup) -static ERL_NIF_TERM nif_key_press(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_implemented")); -} - -// nif_clear_text/0 — select-all + delete in the focused view -static ERL_NIF_TERM nif_clear_text(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.clear_text) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - int att; JNIEnv* jenv = get_jenv(&att); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.clear_text); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); -} - -// nif_long_press_xy/3 — long press at (x, y) for duration_ms milliseconds -static ERL_NIF_TERM nif_long_press_xy(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.long_press_xy) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - double x, y; int dur; - if (!enif_get_double(env, argv[0], &x)) { int ix; if (!enif_get_int(env, argv[0], &ix)) return enif_make_badarg(env); x = ix; } - if (!enif_get_double(env, argv[1], &y)) { int iy; if (!enif_get_int(env, argv[1], &iy)) return enif_make_badarg(env); y = iy; } - if (!enif_get_int(env, argv[2], &dur)) return enif_make_badarg(env); - - int att; JNIEnv* jenv = get_jenv(&att); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.long_press_xy, - (jfloat)x, (jfloat)y, (jlong)dur); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "dispatch_failed")); -} - -// nif_swipe_xy/4 — swipe from (x1,y1) to (x2,y2) in dp -static ERL_NIF_TERM nif_swipe_xy(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!Bridge.swipe_xy) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_loaded")); - double x1, y1, x2, y2; - if (!enif_get_double(env, argv[0], &x1)) { int i; if (!enif_get_int(env, argv[0], &i)) return enif_make_badarg(env); x1 = i; } - if (!enif_get_double(env, argv[1], &y1)) { int i; if (!enif_get_int(env, argv[1], &i)) return enif_make_badarg(env); y1 = i; } - if (!enif_get_double(env, argv[2], &x2)) { int i; if (!enif_get_int(env, argv[2], &i)) return enif_make_badarg(env); x2 = i; } - if (!enif_get_double(env, argv[3], &y2)) { int i; if (!enif_get_int(env, argv[3], &i)) return enif_make_badarg(env); y2 = i; } - - int att; JNIEnv* jenv = get_jenv(&att); - jboolean ok = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.swipe_xy, - (jfloat)x1, (jfloat)y1, (jfloat)x2, (jfloat)y2); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return ok ? enif_make_atom(env, "ok") - : enif_make_tuple2(env, enif_make_atom(env, "error"), - enif_make_atom(env, "dispatch_failed")); -} - -// ── Storage ─────────────────────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_storage_dir(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char loc[32]; enif_get_atom(env, argv[0], loc, sizeof(loc), ERL_NIF_LATIN1); - int att; JNIEnv* jenv = get_jenv(&att); - jstring jloc = (*jenv)->NewStringUTF(jenv, loc); - jstring result = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, Bridge.storage_dir, jloc); - (*jenv)->DeleteLocalRef(jenv, jloc); - ERL_NIF_TERM ret; - if (result) { - const char* utf8 = (*jenv)->GetStringUTFChars(jenv, result, NULL); - ErlNifBinary bin; size_t len = strlen(utf8); - enif_alloc_binary(len, &bin); memcpy(bin.data, utf8, len); - (*jenv)->ReleaseStringUTFChars(jenv, result, utf8); - (*jenv)->DeleteLocalRef(jenv, result); - ret = enif_make_binary(env, &bin); - } else { - ret = enif_make_atom(env, "nil"); - } - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return ret; -} - -static ERL_NIF_TERM nif_storage_save_to_media_store(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - char* path = malloc(bin.size + 1); - memcpy(path, bin.data, bin.size); path[bin.size] = 0; - char type[16] = "auto"; enif_get_atom(env, argv[1], type, sizeof(type), ERL_NIF_LATIN1); - ErlNifPid pid; enif_self(env, &pid); - ERL_NIF_TERM result = call_bridge_pid_str2(env, Bridge.storage_save_to_media_store, pid, path, type); - free(path); - return result; -} - -static ERL_NIF_TERM nif_storage_external_files_dir(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char type[32] = {0}; enif_get_atom(env, argv[0], type, sizeof(type), ERL_NIF_LATIN1); - int att; JNIEnv* jenv = get_jenv(&att); - jstring jtype = (*jenv)->NewStringUTF(jenv, type); - jstring result = (jstring)(*jenv)->CallStaticObjectMethod(jenv, Bridge.cls, - Bridge.storage_external_files_dir, jtype); - (*jenv)->DeleteLocalRef(jenv, jtype); - ERL_NIF_TERM ret; - if (result) { - const char* utf8 = (*jenv)->GetStringUTFChars(jenv, result, NULL); - ErlNifBinary bin; size_t len = strlen(utf8); - enif_alloc_binary(len, &bin); memcpy(bin.data, utf8, len); - (*jenv)->ReleaseStringUTFChars(jenv, result, utf8); - (*jenv)->DeleteLocalRef(jenv, result); - ret = enif_make_binary(env, &bin); - } else { - ret = enif_make_atom(env, "nil"); - } - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return ret; -} - -static ERL_NIF_TERM nif_storage_save_to_photo_library(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, enif_make_atom(env, "error"), enif_make_atom(env, "not_supported")); -} - -// ── WebView ──────────────────────────────────────────────────────────────────── - -// ── Alert delivery (called from beam_jni.c when a dialog button is tapped) ── - -void mob_deliver_alert_action(const char* action) { - ErlNifEnv* e = enif_alloc_env(); - ErlNifPid pid; - if (!enif_whereis_pid(e, enif_make_atom(e, "mob_screen"), &pid)) { - enif_free_env(e); return; - } - ERL_NIF_TERM msg = enif_make_tuple2(e, - enif_make_atom(e, "alert"), - enif_make_atom(e, action)); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); -} - -// ── NIF: alert_show/3 ───────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_alert_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary title_bin, msg_bin, btns_bin; - if (!enif_inspect_binary(env, argv[0], &title_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) - return enif_make_badarg(env); - if (!enif_inspect_binary(env, argv[1], &msg_bin) && - !enif_inspect_iolist_as_binary(env, argv[1], &msg_bin)) - return enif_make_badarg(env); - if (!enif_inspect_binary(env, argv[2], &btns_bin) && - !enif_inspect_iolist_as_binary(env, argv[2], &btns_bin)) - return enif_make_badarg(env); - - char* title = malloc(title_bin.size + 1); - memcpy(title, title_bin.data, title_bin.size); - title[title_bin.size] = '\0'; - - char* message = malloc(msg_bin.size + 1); - memcpy(message, msg_bin.data, msg_bin.size); - message[msg_bin.size] = '\0'; - - char* btns = malloc(btns_bin.size + 1); - memcpy(btns, btns_bin.data, btns_bin.size); - btns[btns_bin.size] = '\0'; - - int att; JNIEnv* jenv = get_jenv(&att); - jstring jtitle = (*jenv)->NewStringUTF(jenv, title); - jstring jmessage = (*jenv)->NewStringUTF(jenv, message); - jstring jbtns = (*jenv)->NewStringUTF(jenv, btns); - free(title); free(message); free(btns); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.alert_show, jtitle, jmessage, jbtns); - (*jenv)->DeleteLocalRef(jenv, jtitle); - (*jenv)->DeleteLocalRef(jenv, jmessage); - (*jenv)->DeleteLocalRef(jenv, jbtns); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: action_sheet_show/2 ────────────────────────────────────────────── - -static ERL_NIF_TERM nif_action_sheet_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary title_bin, btns_bin; - if (!enif_inspect_binary(env, argv[0], &title_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) - return enif_make_badarg(env); - if (!enif_inspect_binary(env, argv[1], &btns_bin) && - !enif_inspect_iolist_as_binary(env, argv[1], &btns_bin)) - return enif_make_badarg(env); - - char* title = malloc(title_bin.size + 1); - memcpy(title, title_bin.data, title_bin.size); - title[title_bin.size] = '\0'; - - char* btns = malloc(btns_bin.size + 1); - memcpy(btns, btns_bin.data, btns_bin.size); - btns[btns_bin.size] = '\0'; - - int att; JNIEnv* jenv = get_jenv(&att); - jstring jtitle = (*jenv)->NewStringUTF(jenv, title); - jstring jbtns = (*jenv)->NewStringUTF(jenv, btns); - free(title); free(btns); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.action_sheet_show, jtitle, jbtns); - (*jenv)->DeleteLocalRef(jenv, jtitle); - (*jenv)->DeleteLocalRef(jenv, jbtns); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── NIF: toast_show/2 ──────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_toast_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary msg_bin; - char dur[8] = "short"; - if (!enif_inspect_binary(env, argv[0], &msg_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &msg_bin)) - return enif_make_badarg(env); - enif_get_atom(env, argv[1], dur, sizeof(dur), ERL_NIF_LATIN1); - - char* msg = malloc(msg_bin.size + 1); - memcpy(msg, msg_bin.data, msg_bin.size); - msg[msg_bin.size] = '\0'; - - int att; JNIEnv* jenv = get_jenv(&att); - jstring jmsg = (*jenv)->NewStringUTF(jenv, msg); - jstring jdur = (*jenv)->NewStringUTF(jenv, dur); - free(msg); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.toast_show, jmsg, jdur); - (*jenv)->DeleteLocalRef(jenv, jmsg); - (*jenv)->DeleteLocalRef(jenv, jdur); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_webview_eval_js(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char* code = malloc(bin.size + 1); - memcpy(code, bin.data, bin.size); - code[bin.size] = '\0'; - int att; JNIEnv* jenv = get_jenv(&att); - jstring jcode = (*jenv)->NewStringUTF(jenv, code); - free(code); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.webview_eval_js, jcode); - (*jenv)->DeleteLocalRef(jenv, jcode); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_webview_post_message(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - char* json = malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); - json[bin.size] = '\0'; - int att; JNIEnv* jenv = get_jenv(&att); - jstring jjson = (*jenv)->NewStringUTF(jenv, json); - free(json); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.webview_post_message, jjson); - (*jenv)->DeleteLocalRef(jenv, jjson); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_webview_can_go_back(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - jboolean result = (*jenv)->CallStaticBooleanMethod(jenv, Bridge.cls, Bridge.webview_can_go_back); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, result ? "true" : "false"); -} - -static ERL_NIF_TERM nif_webview_go_back(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.webview_go_back); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── Native view component NIFs ──────────────────────────────────────────────── - -static ERL_NIF_TERM nif_register_component(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; - if (!enif_get_local_pid(env, argv[0], &pid)) - return enif_make_badarg(env); - - enif_mutex_lock(component_mutex); - for (int i = 0; i < MAX_COMPONENT_HANDLES; i++) { - if (!component_handles[i].active) { - component_handles[i].pid = pid; - component_handles[i].active = 1; - enif_mutex_unlock(component_mutex); - return enif_make_int(env, i); - } - } - enif_mutex_unlock(component_mutex); - return enif_make_badarg(env); -} - -static ERL_NIF_TERM nif_deregister_component(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int handle; - if (!enif_get_int(env, argv[0], &handle) || handle < 0 || handle >= MAX_COMPONENT_HANDLES) - return enif_make_badarg(env); - - enif_mutex_lock(component_mutex); - component_handles[handle].active = 0; - enif_mutex_unlock(component_mutex); - return enif_make_atom(env, "ok"); -} - -// ── NIF: background_keep_alive/0, background_stop/0 ───────────────────────── - -static ERL_NIF_TERM nif_background_keep_alive(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.background_keep_alive); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_background_stop(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int att; JNIEnv* jenv = get_jenv(&att); - (*jenv)->CallStaticVoidMethod(jenv, Bridge.cls, Bridge.background_stop); - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - return enif_make_atom(env, "ok"); -} - -// ── Mob.Device — lifecycle events + queries ───────────────────────────────── -// -// Android implementation is partial — only `:appearance` (color scheme -// changes from MainActivity.onConfigurationChanged) is wired today. The -// rest (lifecycle, battery, thermal) requires ProcessLifecycleObserver + -// ComponentCallbacks2 wiring. Until then the dispatcher pid is stored so -// what IS wired (color scheme) can deliver, and the query NIFs return -// reasonable defaults. - -static ErlNifPid g_device_dispatcher_pid; -static int g_device_dispatcher_set = 0; - -static void mob_device_send_atom_payload_android(const char *tag, const char *atom_name, - const char *payload_atom_str) { - if (!g_device_dispatcher_set) return; - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e, tag), - enif_make_atom(e, atom_name), - enif_make_atom(e, payload_atom_str)); - enif_send(NULL, &g_device_dispatcher_pid, e, msg); - enif_free_env(e); -} - -// Called from beam_jni.c's Java_..._MobBridge_nativeNotifyColorScheme -// stub when MainActivity.onConfigurationChanged sees a uiMode flip. -// `scheme` must be "light" or "dark". -void mob_send_color_scheme_changed(const char *scheme) { - if (!scheme) return; - mob_device_send_atom_payload_android("mob_device", "color_scheme_changed", scheme); -} - -static ERL_NIF_TERM nif_device_set_dispatcher(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; - if (!enif_get_local_pid(env, argv[0], &pid)) return enif_make_badarg(env); - g_device_dispatcher_pid = pid; - g_device_dispatcher_set = 1; - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_device_battery_state(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - // TODO(android): query BatteryManager. For now, unknown / -1. - return enif_make_tuple2(env, - enif_make_atom(env, "unknown"), - enif_make_int(env, -1)); -} - -static ERL_NIF_TERM nif_device_thermal_state(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - // TODO(android): query PowerManager.getCurrentThermalStatus() (API 29+). - return enif_make_atom(env, "nominal"); -} - -static ERL_NIF_TERM nif_device_low_power_mode(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - // TODO(android): query PowerManager.isPowerSaveMode(). - return enif_make_atom(env, "false"); -} - -static ERL_NIF_TERM nif_device_foreground(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - // TODO(android): track via ProcessLifecycleOwner. - return enif_make_atom(env, "true"); -} - -static ERL_NIF_TERM nif_device_os_version(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - // TODO(android): Build.VERSION.RELEASE via JNI. - return enif_make_string(env, "", ERL_NIF_LATIN1); -} - -static ERL_NIF_TERM nif_device_model(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - // TODO(android): Build.MODEL via JNI. - return enif_make_string(env, "Android", ERL_NIF_LATIN1); -} - -// ── NIF table & load ────────────────────────────────────────────────────────── - -// Scheduling notes — see docs/decisions/0001-dirty-nifs.md for the rationale. -// Short version: four NIFs do real CPU work on the BEAM thread (JSON parse, -// MobNode tree construction, accessibility-tree walk) and are marked -// ERL_NIF_DIRTY_JOB_CPU_BOUND so the regular scheduler isn't parked while -// they run. Everything else stays on a regular scheduler — most JNI calls -// hand off to the UI thread quickly and don't need dirty dispatch overhead. -static ErlNifFunc nif_funcs[] = { - // ── Test harness first (matches iOS nif_funcs[] ordering convention) ────── - {"ui_tree", 0, nif_ui_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"ui_view_tree", 0, nif_ui_view_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"ax_action", 2, nif_ax_action, 0}, - {"ax_action_at_xy", 3, nif_ax_action_at_xy, 0}, - {"ui_debug", 0, nif_ui_debug, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"screen_info", 0, nif_screen_info, 0}, - {"tap", 1, nif_tap, 0}, - {"tap_xy", 2, nif_tap_xy, 0}, - {"type_text", 1, nif_type_text, 0}, - {"delete_backward", 0, nif_delete_backward, 0}, - {"key_press", 1, nif_key_press, 0}, - {"clear_text", 0, nif_clear_text, 0}, - {"long_press_xy", 3, nif_long_press_xy, 0}, - {"swipe_xy", 4, nif_swipe_xy, 0}, - // ── Core mob functions ──────────────────────────────────────────────────── - {"platform", 0, nif_platform, 0}, - {"color_scheme", 0, nif_color_scheme, 0}, - {"log", 1, nif_log, 0}, - {"log", 2, nif_log2, 0}, - {"set_transition", 1, nif_set_transition, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"set_root", 1, nif_set_root, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"register_tap", 1, nif_register_tap, 0}, - {"clear_taps", 0, nif_clear_taps, 0}, - {"exit_app", 0, nif_exit_app, 0}, - {"safe_area", 0, nif_safe_area, 0}, - {"haptic", 1, nif_haptic, 0}, - {"clipboard_put", 1, nif_clipboard_put, 0}, - {"clipboard_get", 0, nif_clipboard_get, 0}, - {"share_text", 1, nif_share_text, 0}, - {"open_url", 1, nif_open_url, 0}, - {"request_permission", 1, nif_request_permission, 0}, - {"biometric_authenticate", 1, nif_biometric_authenticate, 0}, - {"location_get_once", 0, nif_location_get_once, 0}, - {"location_start", 1, nif_location_start, 0}, - {"location_stop", 0, nif_location_stop, 0}, - {"camera_capture_photo", 1, nif_camera_capture_photo, 0}, - {"camera_capture_video", 1, nif_camera_capture_video, 0}, - {"camera_start_preview", 1, nif_camera_start_preview, 0}, - {"camera_stop_preview", 0, nif_camera_stop_preview, 0}, - {"photos_pick", 2, nif_photos_pick, 0}, - {"files_pick", 1, nif_files_pick, 0}, - {"audio_start_recording", 1, nif_audio_start_recording, 0}, - {"audio_stop_recording", 0, nif_audio_stop_recording, 0}, - {"audio_play", 2, nif_audio_play, 0}, - {"audio_stop_playback", 0, nif_audio_stop_playback, 0}, - {"audio_set_volume", 1, nif_audio_set_volume, 0}, - {"motion_start", 2, nif_motion_start, 0}, - {"motion_stop", 0, nif_motion_stop, 0}, - {"scanner_scan", 1, nif_scanner_scan, 0}, - {"notify_schedule", 1, nif_notify_schedule, 0}, - {"notify_cancel", 1, nif_notify_cancel, 0}, - {"notify_register_push", 0, nif_notify_register_push, 0}, - {"take_launch_notification", 0, nif_take_launch_notification, 0}, - {"storage_dir", 1, nif_storage_dir, 0}, - {"storage_save_to_media_store", 2, nif_storage_save_to_media_store, 0}, - {"storage_external_files_dir", 1, nif_storage_external_files_dir, 0}, - {"storage_save_to_photo_library", 1, nif_storage_save_to_photo_library, 0}, - {"alert_show", 3, nif_alert_show, 0}, - {"action_sheet_show", 2, nif_action_sheet_show, 0}, - {"toast_show", 2, nif_toast_show, 0}, - {"webview_eval_js", 1, nif_webview_eval_js, 0}, - {"webview_post_message",1, nif_webview_post_message,0}, - {"webview_can_go_back", 0, nif_webview_can_go_back, 0}, - {"webview_go_back", 0, nif_webview_go_back, 0}, - {"register_component", 1, nif_register_component, 0}, - {"deregister_component", 1, nif_deregister_component, 0}, - {"background_keep_alive", 0, nif_background_keep_alive, 0}, - {"background_stop", 0, nif_background_stop, 0}, - // ── Mob.Device — lifecycle events + queries (Android stubs) ─────────────── - {"device_set_dispatcher", 1, nif_device_set_dispatcher, 0}, - {"device_battery_state", 0, nif_device_battery_state, 0}, - {"device_thermal_state", 0, nif_device_thermal_state, 0}, - {"device_low_power_mode", 0, nif_device_low_power_mode, 0}, - {"device_foreground", 0, nif_device_foreground, 0}, - {"device_os_version", 0, nif_device_os_version, 0}, - {"device_model", 0, nif_device_model, 0}, -}; - -static int nif_load(ErlNifEnv* env, void** priv, ERL_NIF_TERM info) { - LOGI("nif_load: entered, Bridge.cls=%p", (void*)Bridge.cls); - if (!Bridge.cls) { LOGE("Bridge.cls not cached — was mob_ui_cache_class called?"); return -1; } - - tap_mutex = enif_mutex_create("mob_tap_mutex"); - if (!tap_mutex) { LOGE("nif_load: failed to create tap mutex"); return -1; } - component_mutex = enif_mutex_create("mob_component_mutex"); - if (!component_mutex) { LOGE("nif_load: failed to create component mutex"); return -1; } - - int att; JNIEnv* jenv = get_jenv(&att); - Bridge.set_root = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, - "setRootJson", "(Ljava/lang/String;Ljava/lang/String;)V"); - if (!Bridge.set_root) { LOGE("nif_load: setRootJson(String,String) not found on MobBridge"); return -1; } - - Bridge.move_to_back = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "moveToBack", "()V"); - if (!Bridge.move_to_back) { LOGE("nif_load: moveToBack() not found on MobBridge"); return -1; } - - Bridge.get_safe_area = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "getSafeArea", "()[F"); - if (!Bridge.get_safe_area) { LOGE("nif_load: getSafeArea() not found on MobBridge"); return -1; } - - // getColorScheme() is optional — apps that haven't been regenerated since - // it was added still load fine; nif_color_scheme falls back to :light. - Bridge.get_color_scheme = - (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "getColorScheme", "()Ljava/lang/String;"); - if (!Bridge.get_color_scheme) { - LOGI("nif_load: MobBridge.getColorScheme() not found — color_scheme/0 returns :light"); - (*jenv)->ExceptionClear(jenv); - } - - Bridge.haptic = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "haptic", "(Ljava/lang/String;)V"); - if (!Bridge.haptic) { LOGE("nif_load: haptic(String) not found on MobBridge"); return -1; } - - Bridge.clipboard_put = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "clipboardPut", "(Ljava/lang/String;)V"); - if (!Bridge.clipboard_put) { LOGE("nif_load: clipboardPut(String) not found on MobBridge"); return -1; } - - Bridge.clipboard_get = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "clipboardGet", "()Ljava/lang/String;"); - if (!Bridge.clipboard_get) { LOGE("nif_load: clipboardGet() not found on MobBridge"); return -1; } - - Bridge.share_text = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "shareText", "(Ljava/lang/String;)V"); - if (!Bridge.share_text) { LOGE("nif_load: shareText(String) not found on MobBridge"); return -1; } - - Bridge.open_url = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, "openUrl", "(Ljava/lang/String;)V"); - if (!Bridge.open_url) { LOGE("nif_load: openUrl(String) not found on MobBridge"); return -1; } - - #define CACHE(name, sig) \ - Bridge.name = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, #name, sig); \ - if (!Bridge.name) { LOGE("nif_load: " #name " not found"); return -1; } - - CACHE(request_permission, "(JLjava/lang/String;)V") - CACHE(biometric_authenticate, "(JLjava/lang/String;)V") - CACHE(location_get_once, "(JLjava/lang/String;)V") - CACHE(location_start, "(JLjava/lang/String;)V") - CACHE(location_stop, "()V") - CACHE(camera_capture_photo, "(JLjava/lang/String;)V") - CACHE(camera_capture_video, "(JLjava/lang/String;)V") - CACHE(camera_start_preview, "(JLjava/lang/String;)V") - CACHE(camera_stop_preview, "()V") - CACHE(photos_pick, "(JLjava/lang/String;)V") - CACHE(files_pick, "(JLjava/lang/String;)V") - CACHE(audio_start_recording, "(JLjava/lang/String;)V") - CACHE(audio_stop_recording, "()V") - CACHE(audio_play, "(JLjava/lang/String;Ljava/lang/String;)V") - CACHE(audio_stop_playback, "()V") - CACHE(audio_set_volume, "(Ljava/lang/String;)V") - CACHE(storage_dir, "(Ljava/lang/String;)Ljava/lang/String;") - CACHE(storage_save_to_media_store, "(JLjava/lang/String;Ljava/lang/String;)V") - CACHE(storage_external_files_dir, "(Ljava/lang/String;)Ljava/lang/String;") - CACHE(alert_show, "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V") - CACHE(action_sheet_show, "(Ljava/lang/String;Ljava/lang/String;)V") - CACHE(toast_show, "(Ljava/lang/String;Ljava/lang/String;)V") - CACHE(webview_eval_js, "(Ljava/lang/String;)V") - CACHE(webview_post_message, "(Ljava/lang/String;)V") - CACHE(webview_can_go_back, "()Z") - CACHE(webview_go_back, "()V") - CACHE(motion_start, "(JLjava/lang/String;)V") - CACHE(motion_stop, "()V") - CACHE(scanner_scan, "(JLjava/lang/String;)V") - CACHE(notify_schedule, "(JLjava/lang/String;)V") - CACHE(notify_cancel, "(Ljava/lang/String;)V") - CACHE(notify_register_push, "(JLjava/lang/String;)V") - CACHE(background_keep_alive, "()V") - CACHE(background_stop, "()V") - #undef CACHE - - g_launch_notif_mutex = enif_mutex_create("mob_launch_notif_mutex"); - if (!g_launch_notif_mutex) { LOGE("nif_load: failed to create launch notif mutex"); return -1; } - - // ── Test harness method IDs (optional — clear exception if not present) ──── - #define CACHE_OPT(field, name, sig) \ - Bridge.field = (*jenv)->GetStaticMethodID(jenv, Bridge.cls, name, sig); \ - if (!Bridge.field) { (*jenv)->ExceptionClear(jenv); LOGI("nif_load: %s not found (optional)", name); } - - CACHE_OPT(ui_tree, "uiTree", "()Ljava/lang/String;") - CACHE_OPT(ui_view_tree, "uiViewTree", "()Ljava/lang/String;") - CACHE_OPT(screen_info, "screenInfo", "()[F") - CACHE_OPT(tap_xy, "tapXy", "(FF)Z") - CACHE_OPT(tap_by_label, "tapByLabel", "(Ljava/lang/String;)Z") - CACHE_OPT(type_text, "typeText", "(Ljava/lang/String;)Z") - CACHE_OPT(delete_backward,"deleteBackward","()Z") - CACHE_OPT(clear_text, "clearText", "()Z") - CACHE_OPT(long_press_xy, "longPressXy", "(FFJ)Z") - CACHE_OPT(swipe_xy, "swipeXy", "(FFFF)Z") - #undef CACHE_OPT - - if (att) (*g_jvm)->DetachCurrentThread(g_jvm); - - LOGI("Mob NIF loaded (Compose backend)"); - return 0; -} - -ERL_NIF_INIT(mob_nif, nif_funcs, nif_load, NULL, NULL, NULL) diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig new file mode 100644 index 00000000..152bb3be --- /dev/null +++ b/android/jni/mob_nif.zig @@ -0,0 +1,3992 @@ +//! mob_nif.zig — Mob Android NIF implementations (Zig). +//! +//! Phase 6b iter 3 of the build-system migration: incremental port of +//! mob_nif.c (~2570 lines, 79 NIFs) to Zig. The C file stays in the build +//! alongside this one — both contribute symbols to the final lib<app>.so. +//! mob_nif.c's static `ErlNifFunc nif_funcs[]` table references the Zig +//! exports here via `extern` declarations at the top of mob_nif.c. +//! +//! Sub-iter sequence: +//! * iter 3a: 3 standalone NIFs — platform/0, log/1, log/2. No JNI, no +//! shared state. Proved the cross-language linkage pattern. +//! * iter 3b: test harness NIFs (ui_tree, ui_view_tree, screen_info, +//! tap, tap_xy, type_text, delete_backward, key_press, clear_text, +//! long_press_xy, swipe_xy, ax_action stubs, ui_debug) + the cached +//! `Bridge` MobBridge method-ID struct + `get_jenv` (the thread- +//! attach helper). +//! * iter 3c (this iter): event senders (mob_send_* family — tap, +//! change, focus/blur/submit/select/compose, gestures, throttled +//! scroll/drag/pinch/rotate/pointer_move, scroll-began/ended/settled, +//! back), tap + component handle registries with their mutexes, +//! per-handle throttle state, and the 6 NIFs that touch these +//! statics (nif_set_root, nif_register_tap, nif_clear_taps, +//! nif_set_transition, nif_register_component, nif_deregister_component). +//! The C-side `nif_load` calls `mob_nif_init_state` (exported here) +//! to create the mutexes during BEAM init. +//! * iter 3d (this iter): the finale. Remaining feature NIFs (color +//! scheme, exit_app, safe_area, haptic, torch, clipboard, open_url, +//! share_text, launch notification, request_permission, +//! files_pick, +//! audio ×5, motion ×2, scanner, notifications ×3, storage ×4, +//! alert/action_sheet/toast, webview ×4, background ×2, +//! Mob.Device ×7), the bridge bootstrap helpers +//! (_mob_ui_cache_class_impl, _mob_bridge_init_activity, +//! mob_set_startup_phase, mob_set_startup_error), all the +//! deliver_* event dispatchers, and the NIF table itself with +//! nif_load + the ERL_NIF_INIT entry point. mob_nif.c deleted. +//! +//! All exports use the C ABI so the C-side NIF table can reference them. + +const std = @import("std"); +const jni = @import("mob_zig.zig"); +const erts = @import("mob_erts.zig"); + +// ── Logging tag for NIFs that log to Android logcat ────────────────────── + +const ELIXIR_TAG: [*:0]const u8 = "Elixir"; + +// ── NIF: platform/0 ────────────────────────────────────────────────────── +// Returns the atom :android. iOS has a parallel `nif_platform` in +// `ios/mob_nif.m` that returns :ios. + +export fn nif_platform( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return erts.atom(env, "android"); +} + +// ── NIF: log/1 ─────────────────────────────────────────────────────────── +// Accept either a binary or an Erlang charlist; emit under tag "Elixir" +// at ANDROID_LOG_INFO. Truncates at 4 KB (matches the C version's local +// buffer size). + +export fn nif_log( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var buf: [4096]u8 = @splat(0); + if (!fillBufferFromTerm(env, argv[0], &buf)) { + return erts.badarg(env); + } + const cstr: [*:0]const u8 = @ptrCast(&buf); + _ = jni.__android_log_print(jni.ANDROID_LOG_INFO, ELIXIR_TAG, "%s", cstr); + return erts.ok(env); +} + +// ── NIF: log/2 ─────────────────────────────────────────────────────────── +// argv[0] is a level atom (:debug | :info | :warning | :error); argv[1] is +// the message (binary or charlist). Unknown atom → INFO. + +export fn nif_log2( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var buf: [4096]u8 = @splat(0); + const priority = atomToAndroidPriority(env, argv[0]); + if (!fillBufferFromTerm(env, argv[1], &buf)) { + return erts.badarg(env); + } + const cstr: [*:0]const u8 = @ptrCast(&buf); + _ = jni.__android_log_print(priority, ELIXIR_TAG, "%s", cstr); + return erts.ok(env); +} + +// ── NIF: resolve_ipv4/1 ────────────────────────────────────────────────── +// +// In-process IPv4 DNS via Bionic's getaddrinfo. Exists because BEAM's +// default DNS path — forking `inet_gethost` (a port program) — returns +// `:nxdomain` on physical Android devices we've tested, even though the +// app's own HTTP stack resolves the same hostnames fine. Suspected cause: +// Bionic's netd-routed resolver doesn't carry across to execve'd children +// of the app process the way it does to in-process calls. The emulator +// happens not to hit this, which is why it wasn't caught earlier. +// +// This NIF runs getaddrinfo in-process (same address space, same uid as +// the app), so it follows whatever DNS path the JVM and the app's libraries +// use. Mirrors iOS's `nif_resolve_ipv4` in `ios/mob_nif.m` and uses the +// same atom/error vocabulary so `Mob.DNS.resolve/1` is platform-agnostic. +// +// Dirty-scheduled because getaddrinfo can block on the resolver for the +// full timeout (seconds). Keep it off the regular schedulers. +// +// Returns: +// {:ok, {a, b, c, d}} +// {:error, :badarg} — host arg wasn't a string/charlist +// {:error, :nxdomain} — no such hostname +// {:error, :timeout} — TRY_AGAIN +// {:error, :no_address} — got a result but no IPv4 in the chain +// {:error, {:gai, code}} — anything else; raw EAI_* int +export fn nif_resolve_ipv4( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var host: [256]u8 = undefined; + const got = erts.enif_get_string(env, argv[0], &host, host.len, erts.ERL_NIF_LATIN1); + if (got <= 0) return erts.errorTuple(env, erts.atom(env, "badarg")); + + const hints: jni.AddrInfo = .{ + .ai_flags = 0, + .ai_family = jni.AF_INET, + .ai_socktype = jni.SOCK_STREAM, + .ai_protocol = 0, + .ai_addrlen = 0, + .ai_canonname = null, + .ai_addr = null, + .ai_next = null, + }; + + var result: ?*jni.AddrInfo = null; + const err = jni.getaddrinfo(@ptrCast(&host), null, &hints, &result); + if (err != 0) { + // `erts.atom` requires a comptime-known name, so each EAI_* maps to a + // literal string in its own switch arm rather than a runtime-selected + // pointer. + return switch (err) { + jni.EAI_NONAME, jni.EAI_NODATA => erts.errorTuple(env, erts.atom(env, "nxdomain")), + jni.EAI_AGAIN => erts.errorTuple(env, erts.atom(env, "timeout")), + else => blk: { + // Surface raw EAI_* so callers can log/branch on it. + const gai = erts.makeTuple(env, .{ erts.atom(env, "gai"), erts.enif_make_int(env, err) }); + break :blk erts.errorTuple(env, gai); + }, + }; + } + + // Walk the chain for the first AF_INET sockaddr. getaddrinfo with + // ai_family=AF_INET shouldn't return anything else, but be defensive. + var ai: ?*jni.AddrInfo = result; + var found: ?u32 = null; + while (ai) |entry| : (ai = entry.ai_next) { + if (entry.ai_family != jni.AF_INET) continue; + const sin: *jni.SockAddrIn = @ptrCast(@alignCast(entry.ai_addr)); + // sin_addr is network byte order; bigToNative is the ntohl-equivalent + // on little-endian Android. + found = std.mem.bigToNative(u32, sin.sin_addr); + break; + } + jni.freeaddrinfo(result); + + if (found) |addr| { + const ip_tuple = erts.makeTuple(env, .{ + erts.enif_make_int(env, @intCast((addr >> 24) & 0xFF)), + erts.enif_make_int(env, @intCast((addr >> 16) & 0xFF)), + erts.enif_make_int(env, @intCast((addr >> 8) & 0xFF)), + erts.enif_make_int(env, @intCast(addr & 0xFF)), + }); + return erts.makeTuple(env, .{ erts.ok(env), ip_tuple }); + } + return erts.errorTuple(env, erts.atom(env, "no_address")); +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +/// Pull a binary or charlist into a NUL-terminated buffer. Returns false if +/// neither inspect_binary nor get_string succeeded. +fn fillBufferFromTerm(env: ?*erts.ErlNifEnv, term: erts.ERL_NIF_TERM, buf: *[4096]u8) bool { + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, term, &bin) != 0) { + const len = @min(bin.size, buf.len - 1); + @memcpy(buf[0..len], bin.data[0..len]); + buf[len] = 0; + return true; + } + return erts.enif_get_string(env, term, buf.ptr, @intCast(buf.len), erts.ERL_NIF_LATIN1) != 0; +} + +/// Map :debug / :info / :warning / :error to the Android log priority. +/// Unknown atom → INFO (matches the C default). +fn atomToAndroidPriority(env: ?*erts.ErlNifEnv, level_atom: erts.ERL_NIF_TERM) c_int { + var level: [16]u8 = @splat(0); + if (erts.enif_get_atom(env, level_atom, &level, level.len, erts.ERL_NIF_LATIN1) == 0) { + return jni.ANDROID_LOG_INFO; + } + const len = jni.zLen(&level); + const view = level[0..len]; + if (std.mem.eql(u8, view, "debug")) return jni.ANDROID_LOG_DEBUG; + if (std.mem.eql(u8, view, "warning")) return jni.ANDROID_LOG_WARN; + if (std.mem.eql(u8, view, "error")) return jni.ANDROID_LOG_ERROR; + return jni.ANDROID_LOG_INFO; +} + +// ── Cached MobBridge method IDs (Phase 6b iter 3b) ─────────────────────── +// Moved from mob_nif.c's `static struct { ... } Bridge;`. The C side now +// extern-declares a matching `struct BridgeMethods Bridge` so the senders +// and feature NIFs that haven't been ported yet can still read these +// fields. Field order matches the C struct exactly — drift here will +// silently mis-resolve method IDs at runtime. +// +// The set_startup_phase / set_startup_error pair is populated by mob_beam +// (during BEAM startup, before NIFs load); the rest are filled by +// nif_load on the BEAM-side load callback. + +pub const BridgeMethods = extern struct { + cls: jni.JClass = null, + set_root: jni.JMethodID = null, + set_theme: jni.JMethodID = null, + move_to_back: jni.JMethodID = null, + get_safe_area: jni.JMethodID = null, + get_color_scheme: jni.JMethodID = null, + haptic: jni.JMethodID = null, + torch: jni.JMethodID = null, + clipboard_put: jni.JMethodID = null, + clipboard_get: jni.JMethodID = null, + tts_speak: jni.JMethodID = null, + tts_stop: jni.JMethodID = null, + share_text: jni.JMethodID = null, + open_url: jni.JMethodID = null, + open_settings: jni.JMethodID = null, + request_permission: jni.JMethodID = null, + alert_show: jni.JMethodID = null, + action_sheet_show: jni.JMethodID = null, + toast_show: jni.JMethodID = null, + webview_eval_js: jni.JMethodID = null, + webview_post_message: jni.JMethodID = null, + webview_can_go_back: jni.JMethodID = null, + webview_go_back: jni.JMethodID = null, + files_pick: jni.JMethodID = null, + audio_start_recording: jni.JMethodID = null, + audio_stop_recording: jni.JMethodID = null, + audio_start_input_metering: jni.JMethodID = null, + audio_input_level: jni.JMethodID = null, + audio_stop_input_metering: jni.JMethodID = null, + audio_play: jni.JMethodID = null, + audio_play_at: jni.JMethodID = null, + audio_stop_playback: jni.JMethodID = null, + audio_set_volume: jni.JMethodID = null, + // Output probes — optional (cacheOptional); a drifted MobBridge.kt that + // predates them simply leaves these null and the NIFs return an error + // atom instead of crashing nif_load. + audio_output_status: jni.JMethodID = null, + audio_output_level: jni.JMethodID = null, + motion_start: jni.JMethodID = null, + motion_stop: jni.JMethodID = null, + take_launch_notification: jni.JMethodID = null, + storage_dir: jni.JMethodID = null, + storage_save_to_media_store: jni.JMethodID = null, + storage_external_files_dir: jni.JMethodID = null, + // Cached before nif_load (used during BEAM startup before NIFs are loaded) + set_startup_phase: jni.JMethodID = null, + set_startup_error: jni.JMethodID = null, + // ── Test harness ────────────────────────────────────────────────────── + ui_tree: jni.JMethodID = null, + ui_view_tree: jni.JMethodID = null, + screen_info: jni.JMethodID = null, + tap_xy: jni.JMethodID = null, + tap_by_label: jni.JMethodID = null, + type_text: jni.JMethodID = null, + delete_backward: jni.JMethodID = null, + clear_text: jni.JMethodID = null, + long_press_xy: jni.JMethodID = null, + swipe_xy: jni.JMethodID = null, + screenshot: jni.JMethodID = null, + scroll_info: jni.JMethodID = null, + scroll_to: jni.JMethodID = null, + // MobBridge.orientationLock(Int) — calls activity.setRequestedOrientation. + // Companion Kotlin method ships in the mob_new template (see PR notes). + orientation_lock: jni.JMethodID = null, + // MobBridge.keepAwake(Int) — toggles the window's FLAG_KEEP_SCREEN_ON. + // Companion Kotlin method ships in the mob_new template. + keep_awake: jni.JMethodID = null, + element_frames: jni.JMethodID = null, + // ── Mob.Peripheral.VendorUsb ───────────────────────────────────────── + // Each takes a pid as jlong (so Kotlin can echo it back when calling + // mob_deliver_vendor_usb_*) plus the operation's typed payload. + vendor_usb_list_devices: jni.JMethodID = null, + vendor_usb_request_permission: jni.JMethodID = null, + vendor_usb_open: jni.JMethodID = null, + vendor_usb_bulk_write: jni.JMethodID = null, + vendor_usb_start_reading: jni.JMethodID = null, + vendor_usb_stop_reading: jni.JMethodID = null, + vendor_usb_close: jni.JMethodID = null, + // ── Mob.Bt (Bluetooth Classic) — extracted to the mob_bluetooth plugin ── + // The bt method-id cache lives in the plugin NIF's own globals now. +}; + +/// Exported with C ABI so mob_nif.c (and beam_jni.c for the senders in +/// iter 3c) can extern-declare it and read/write the same memory. +pub export var Bridge: BridgeMethods = .{}; + +// ── Externs from mob_beam.zig (Phase 6b iter 2) ────────────────────────── +extern var g_jvm: ?*jni.JavaVM; +extern var g_activity: jni.JObject; + +// ── get_jenv: attach the current thread if needed ──────────────────────── +// Returns the env pointer; *attached is set to 1 iff this call had to +// attach (caller must DetachCurrentThread when done). Match the C +// signature byte-for-byte — `int *attached` in C → `*c_int` in Zig. +// Exported so the C-side senders + feature NIFs can call it. +// +// JNI_EDETACHED = -2 (from jni.h). When GetEnv returns it the calling +// thread is not yet attached; AttachCurrentThread takes care of that. +// Any other GetEnv return (JNI_OK = 0, JNI_EVERSION = -3) means "leave +// it alone" — attached stays 0 so we won't detach a thread we didn't +// attach (and detaching a Java-spawned thread aborts ART). +const JNI_EDETACHED: jni.JInt = -2; + +pub export fn get_jenv(attached: *c_int) ?*jni.JNIEnv { + attached.* = 0; + const jvm = g_jvm orelse return null; + var ptr: ?*anyopaque = null; + const rc = jvm.*.GetEnv.?(jvm, &ptr, jni.JNI_VERSION_1_6); + if (rc == JNI_EDETACHED) { + var env: ?*jni.JNIEnv = null; + if (jvm.*.AttachCurrentThread.?(jvm, &env, null) == jni.JNI_OK) { + attached.* = 1; + return env; + } + return null; + } + return @ptrCast(@alignCast(ptr)); +} + +/// Detach when get_jenv set *attached = 1. Convenience wrapper used by +/// every test harness NIF below — keeps the call-site idiom compact and +/// the comment-block "if attached → detach" rule local to one place. +inline fn detachIfAttached(attached: c_int) void { + if (attached != 0) { + if (g_jvm) |jvm| jni.detachCurrentThread(jvm); + } +} + +// ── Binary / string helpers ────────────────────────────────────────────── + +/// Make an `ErlNifBinary` from a C-style {ptr, len} pair and wrap it as a +/// term. BEAM owns the allocated bytes after make_binary returns. +fn cstrToBin(env: ?*erts.ErlNifEnv, src: [*]const u8, len: usize) erts.ERL_NIF_TERM { + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + @memcpy(bin.data[0..len], src[0..len]); + return erts.enif_make_binary(env, &bin); +} + +/// jstring → binary term. Returns `:nil` if the jstring is null or the +/// UTF-8 view can't be obtained. Always releases the local ref + UTF +/// chars; caller doesn't need to clean up. +fn jstringToBin(env: ?*erts.ErlNifEnv, jenv: *jni.JNIEnv, js: jni.JString) erts.ERL_NIF_TERM { + if (js == null) return erts.atom(env, "nil"); + const utf = jni.getStringUTFChars(jenv, js) orelse return erts.atom(env, "nil"); + const len = std.mem.span(utf).len; + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + @memcpy(bin.data[0..len], utf[0..len]); + jni.releaseStringUTFChars(jenv, js, utf); + jni.deleteLocalRef(jenv, js); + return erts.enif_make_binary(env, &bin); +} + +/// Return `{:error, atom}` after detaching if needed. Centralised so the +/// test harness NIFs don't repeat the boilerplate. +inline fn errorAtom(env: ?*erts.ErlNifEnv, comptime reason: [:0]const u8) erts.ERL_NIF_TERM { + return erts.errorTuple(env, erts.atom(env, reason)); +} + +/// `{:error, :not_loaded}` — the early-bail path for NIFs that need a +/// Bridge method that wasn't compiled into the app (e.g. older mob_dev +/// versions that pre-date a Kotlin-side helper). +inline fn notLoaded(env: ?*erts.ErlNifEnv) erts.ERL_NIF_TERM { + return errorAtom(env, "not_loaded"); +} + +// ── Test harness NIFs (Phase 6b iter 3b) ───────────────────────────────── +// Drive the running app from a Mac-side IEx via Erlang distribution. They +// look up cached method IDs on `Bridge`, hop into the JVM via get_jenv, +// dispatch via Compose's gesture/test bridge on the Kotlin side, then +// either return an `:ok` atom or a structured error tuple. dp coordinates, +// matching iOS convention. + +// nif_ui_tree/0 — returns [{type_atom, label_binary, value_binary, {x,y,w,h}}, ...] +// +// Calls MobBridge.uiTree() which returns a newline-separated string: +// type|label|value|x|y|w|h\n... +// Parses that into a list of 4-tuples matching the iOS ui_tree format. +export fn nif_ui_tree( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.ui_tree == null) return notLoaded(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jresult = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.ui_tree); + if (jresult == null) { + detachIfAttached(attached); + return erts.makeList(env, &.{}); + } + + const raw = jni.getStringUTFChars(jenv, jresult); + var items_buf: [512]erts.ERL_NIF_TERM = undefined; + var count: usize = 0; + + if (raw) |r| { + const raw_slice = std.mem.span(r); + var line_it = std.mem.splitScalar(u8, raw_slice, '\n'); + while (line_it.next()) |line| { + if (count >= items_buf.len) break; + if (line.len == 0 or line.len >= 512) continue; + + // Split on '|': type | label | value | x | y | w | h + var fields: [7][]const u8 = undefined; + var field_count: usize = 0; + var field_it = std.mem.splitScalar(u8, line, '|'); + while (field_it.next()) |f| { + if (field_count >= 7) { + field_count += 1; // overflow marker + break; + } + fields[field_count] = f; + field_count += 1; + } + if (field_count != 7) continue; + + const x = std.fmt.parseFloat(f64, fields[3]) catch 0.0; + const y = std.fmt.parseFloat(f64, fields[4]) catch 0.0; + const w = std.fmt.parseFloat(f64, fields[5]) catch 0.0; + const h = std.fmt.parseFloat(f64, fields[6]) catch 0.0; + + const frame = erts.makeTuple(env, .{ + erts.enif_make_double(env, x), + erts.enif_make_double(env, y), + erts.enif_make_double(env, w), + erts.enif_make_double(env, h), + }); + + // Empty label/value → atom :nil, non-empty → binary. + const label = if (fields[1].len == 0) + erts.atom(env, "nil") + else + cstrToBin(env, fields[1].ptr, fields[1].len); + const value = if (fields[2].len == 0) + erts.atom(env, "nil") + else + cstrToBin(env, fields[2].ptr, fields[2].len); + + // The type field is small and unbounded in length theoretically; + // copy it into a NUL-terminated buffer so enif_make_atom is safe. + var type_buf: [64]u8 = @splat(0); + const tlen = @min(fields[0].len, type_buf.len - 1); + @memcpy(type_buf[0..tlen], fields[0][0..tlen]); + const type_cstr: [*:0]const u8 = @ptrCast(&type_buf); + + items_buf[count] = erts.makeTuple(env, .{ + erts.enif_make_atom(env, type_cstr), + label, + value, + frame, + }); + count += 1; + } + jni.releaseStringUTFChars(jenv, jresult, r); + } + jni.deleteLocalRef(jenv, jresult); + detachIfAttached(attached); + + return erts.makeList(env, items_buf[0..count]); +} + +// nif_ui_view_tree/0 — returns nested-map UI tree from MobBridge.uiViewTree(). +// +// Bridge contract: Kotlin returns a JSON string of the form +// {"type":"root","label":null,"value":null,"frame":[0,0,W,H],"children":[...]} +// parsed by Mob.Test.tree/1 (jason decode is fast; no need for a C-side +// JSON tokenizer). Returns {:error, :not_loaded} when MobBridge.uiViewTree() +// isn't present (early-adopter apps without registry). +export fn nif_ui_view_tree( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.ui_view_tree == null) return notLoaded(env); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jresult = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.ui_view_tree); + const result = jstringToBin(env, jenv, jresult); + detachIfAttached(attached); + return result; +} + +// nif_screen_info/0 — returns %{width, height, scale, safe_area: %{...}} +// +// Width/height are in dp (already px-divided by density on the Kotlin +// side). scale is the density factor (1.0/1.5/2.0/2.625/3.0/...) — same +// role as UIScreen.scale on iOS. +// +// Bridge contract: MobBridge.screenInfo() returns float[6+] = [w, h, +// scale, safe_top, safe_bottom, safe_left, safe_right]. Falls back to +// safe_area-only info if screenInfo() isn't bound (older bridges). +export fn nif_screen_info( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + var vals: [7]f32 = @splat(0); + if (Bridge.screen_info != null) { + const arr = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.screen_info); + if (arr != null) { + const got = jni.getArrayLength(jenv, arr); + const take: jni.JInt = if (got > 7) 7 else got; + jni.getFloatArrayRegion(jenv, arr, 0, take, &vals); + jni.deleteLocalRef(jenv, arr); + } + } + detachIfAttached(attached); + + const sa_keys = [_]erts.ERL_NIF_TERM{ + erts.atom(env, "top"), + erts.atom(env, "bottom"), + erts.atom(env, "left"), + erts.atom(env, "right"), + }; + const sa_vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, @floatCast(vals[3])), + erts.enif_make_double(env, @floatCast(vals[4])), + erts.enif_make_double(env, @floatCast(vals[5])), + erts.enif_make_double(env, @floatCast(vals[6])), + }; + const safe_area = erts.makeMap(env, &sa_keys, &sa_vals) orelse erts.atom(env, "error"); + + const keys = [_]erts.ERL_NIF_TERM{ + erts.atom(env, "width"), + erts.atom(env, "height"), + erts.atom(env, "scale"), + erts.atom(env, "safe_area"), + }; + const vvals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, @floatCast(vals[0])), + erts.enif_make_double(env, @floatCast(vals[1])), + erts.enif_make_double(env, @floatCast(vals[2])), + safe_area, + }; + return erts.makeMap(env, &keys, &vvals) orelse erts.atom(env, "error"); +} + +// ── In-process screenshot + scroll control (agent driving over dist) ───────── +// +// Mirrors the iOS NIFs. These delegate to MobBridge (PixelCopy for capture, +// Compose scroll state for scroll) so a remotely-connected agent gets pixels + +// deterministic scroll with no adb/xcrun. Bridge methods are optional: apps +// generated before these existed return {:error, :not_loaded}. + +// Copy an id binary into a NUL-terminated buffer; returns the C string and an +// optional heap pointer the caller must free. Mirrors the nif_tap/type_text idiom. +const IdBuf = struct { cstr: [*:0]const u8, heap: ?*anyopaque }; + +fn idCString(bin: erts.ErlNifBinary, stack_buf: []u8) ?IdBuf { + const use_heap = bin.size + 1 > stack_buf.len; + const heap_buf: ?*anyopaque = if (use_heap) jni.malloc(bin.size + 1) else null; + if (use_heap and heap_buf == null) return null; + const buf_ptr: [*]u8 = if (use_heap) @ptrCast(heap_buf) else stack_buf.ptr; + @memcpy(buf_ptr[0..bin.size], bin.data[0..bin.size]); + buf_ptr[bin.size] = 0; + return .{ .cstr = @ptrCast(buf_ptr), .heap = heap_buf }; +} + +// nif_screenshot/3 — capture the activity window; returns PNG/JPEG bytes. +export fn nif_screenshot( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.screenshot == null) return notLoaded(env); + + var fmt: [8]u8 = @splat(0); + if (erts.enif_get_atom(env, argv[0], &fmt, fmt.len, erts.ERL_NIF_LATIN1) == 0) + return erts.badarg(env); + var quality: c_int = 90; + _ = erts.enif_get_int(env, argv[1], &quality); + const scale = erts.getNumber(env, argv[2]) orelse 1.0; + const fmt_cstr: [*:0]const u8 = @ptrCast(&fmt); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + const jfmt = jni.newStringUTF(jenv, fmt_cstr); + const jbytes = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.screenshot, jfmt, @as(jni.JInt, @intCast(quality)), @as(f64, scale)); + jni.deleteLocalRef(jenv, jfmt); + if (jbytes == null) return errorAtom(env, "no_window"); + + const len = jni.getArrayLength(jenv, jbytes); + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(@intCast(len), &bin); + if (len > 0) jni.getByteArrayRegion(jenv, jbytes, 0, len, @ptrCast(bin.data)); + jni.deleteLocalRef(jenv, jbytes); + return erts.enif_make_binary(env, &bin); +} + +// nif_scroll_info/1 — read a scroll view's offset/extent (JSON string by :id). +export fn nif_scroll_info( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.scroll_info == null) return notLoaded(env); + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, argv[0], &bin) == 0) return erts.badarg(env); + + var stack_buf: [256]u8 = undefined; + const id = idCString(bin, &stack_buf) orelse return erts.atom(env, "error"); + defer if (id.heap) |h| jni.free(h); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + const jid = jni.newStringUTF(jenv, id.cstr); + const jresult = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.scroll_info, jid); + jni.deleteLocalRef(jenv, jid); + if (jresult == null) return errorAtom(env, "scroll_view_not_found"); + return jstringToBin(env, jenv, jresult); // releases jresult +} + +// nif_scroll_to/3 — scroll a view (by :id) to absolute (x, y). +export fn nif_scroll_to( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.scroll_to == null) return notLoaded(env); + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, argv[0], &bin) == 0) return erts.badarg(env); + const x = erts.getNumber(env, argv[1]) orelse return erts.badarg(env); + const y = erts.getNumber(env, argv[2]) orelse return erts.badarg(env); + + var stack_buf: [256]u8 = undefined; + const id = idCString(bin, &stack_buf) orelse return erts.atom(env, "error"); + defer if (id.heap) |h| jni.free(h); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + const jid = jni.newStringUTF(jenv, id.cstr); + const ok = jenv.*.CallStaticBooleanMethod.?(jenv, Bridge.cls, Bridge.scroll_to, jid, @as(f64, x), @as(f64, y)); + jni.deleteLocalRef(jenv, jid); + return if (ok != 0) erts.ok(env) else errorAtom(env, "scroll_view_not_found"); +} + +// nif_element_frames/0 — JSON {id:[x,y,w,h],...} of tagged element frames (dp). +export fn nif_element_frames( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.element_frames == null) return notLoaded(env); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jresult = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.element_frames); + const result = jstringToBin(env, jenv, jresult); + detachIfAttached(attached); + return result; +} + +// nif_ax_action/2 + nif_ax_action_at_xy/3 — Android stubs. +// +// Both are iOS-only today. Compose semantics walker (the proper Android +// implementation) is queued under WireTap (see future_developments.md). +// Return a clear error so callers get `{:error, :not_supported_on_android}` +// instead of an `:undef` crash. + +export fn nif_ax_action( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return errorAtom(env, "not_supported_on_android"); +} + +export fn nif_ax_action_at_xy( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return errorAtom(env, "not_supported_on_android"); +} + +// nif_ui_debug/0 — returns raw uiTree string as a binary (for debugging). +export fn nif_ui_debug( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.ui_tree == null) return notLoaded(env); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jresult = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.ui_tree); + const result = jstringToBin(env, jenv, jresult); + detachIfAttached(attached); + return result; +} + +// nif_tap/1 — tap by accessibility label binary. +export fn nif_tap( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.tap_by_label == null) return notLoaded(env); + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, argv[0], &bin) == 0) return erts.badarg(env); + + // NewStringUTF takes a NUL-terminated C string; binary's data isn't + // NUL-terminated. Copy to a stack buffer for typical short labels; + // fall back to malloc on long ones. + var stack_buf: [512]u8 = undefined; + const use_heap = bin.size + 1 > stack_buf.len; + const heap_buf: ?*anyopaque = if (use_heap) jni.malloc(bin.size + 1) else null; + if (use_heap and heap_buf == null) return erts.atom(env, "error"); + const buf_ptr: [*]u8 = if (use_heap) @ptrCast(heap_buf) else &stack_buf; + defer if (use_heap) jni.free(heap_buf); + + @memcpy(buf_ptr[0..bin.size], bin.data[0..bin.size]); + buf_ptr[bin.size] = 0; + const label_cstr: [*:0]const u8 = @ptrCast(buf_ptr); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jlabel = jni.newStringUTF(jenv, label_cstr); + const ok = jenv.*.CallStaticBooleanMethod.?(jenv, Bridge.cls, Bridge.tap_by_label, jlabel); + jni.deleteLocalRef(jenv, jlabel); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "no_element_with_label"); +} + +// nif_tap_xy/2 — tap at (x, y) dp coordinates. +export fn nif_tap_xy( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.tap_xy == null) return notLoaded(env); + const x = erts.getNumber(env, argv[0]) orelse return erts.badarg(env); + const y = erts.getNumber(env, argv[1]) orelse return erts.badarg(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const ok = jenv.*.CallStaticBooleanMethod.?(jenv, Bridge.cls, Bridge.tap_xy, @as(f32, @floatCast(x)), @as(f32, @floatCast(y))); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "dispatch_failed"); +} + +// nif_type_text/1 — type text into the focused view. +export fn nif_type_text( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.type_text == null) return notLoaded(env); + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, argv[0], &bin) == 0) return erts.badarg(env); + + var stack_buf: [4096]u8 = undefined; + const use_heap = bin.size + 1 > stack_buf.len; + const heap_buf: ?*anyopaque = if (use_heap) jni.malloc(bin.size + 1) else null; + if (use_heap and heap_buf == null) return erts.atom(env, "error"); + const buf_ptr: [*]u8 = if (use_heap) @ptrCast(heap_buf) else &stack_buf; + defer if (use_heap) jni.free(heap_buf); + + @memcpy(buf_ptr[0..bin.size], bin.data[0..bin.size]); + buf_ptr[bin.size] = 0; + const text_cstr: [*:0]const u8 = @ptrCast(buf_ptr); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtext = jni.newStringUTF(jenv, text_cstr); + const ok = jenv.*.CallStaticBooleanMethod.?(jenv, Bridge.cls, Bridge.type_text, jtext); + jni.deleteLocalRef(jenv, jtext); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "no_first_responder"); +} + +// nif_delete_backward/0 — delete one character backward. +export fn nif_delete_backward( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.delete_backward == null) return notLoaded(env); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const ok = jenv.*.CallStaticBooleanMethod.?(jenv, Bridge.cls, Bridge.delete_backward); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "no_first_responder"); +} + +// nif_key_press/1 — not yet implemented on Android (no KeyCharacterMap lookup). +export fn nif_key_press( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return errorAtom(env, "not_implemented"); +} + +// nif_clear_text/0 — select-all + delete in the focused view. +export fn nif_clear_text( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.clear_text == null) return notLoaded(env); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const ok = jenv.*.CallStaticBooleanMethod.?(jenv, Bridge.cls, Bridge.clear_text); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "no_first_responder"); +} + +// nif_long_press_xy/3 — long press at (x, y) for duration_ms milliseconds. +export fn nif_long_press_xy( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.long_press_xy == null) return notLoaded(env); + const x = erts.getNumber(env, argv[0]) orelse return erts.badarg(env); + const y = erts.getNumber(env, argv[1]) orelse return erts.badarg(env); + var dur: c_int = 0; + if (erts.enif_get_int(env, argv[2], &dur) == 0) return erts.badarg(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const ok = jenv.*.CallStaticBooleanMethod.?( + jenv, + Bridge.cls, + Bridge.long_press_xy, + @as(f32, @floatCast(x)), + @as(f32, @floatCast(y)), + @as(i64, @intCast(dur)), + ); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "dispatch_failed"); +} + +// nif_swipe_xy/4 — swipe from (x1, y1) to (x2, y2) in dp. +export fn nif_swipe_xy( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.swipe_xy == null) return notLoaded(env); + const x1 = erts.getNumber(env, argv[0]) orelse return erts.badarg(env); + const y1 = erts.getNumber(env, argv[1]) orelse return erts.badarg(env); + const x2 = erts.getNumber(env, argv[2]) orelse return erts.badarg(env); + const y2 = erts.getNumber(env, argv[3]) orelse return erts.badarg(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const ok = jenv.*.CallStaticBooleanMethod.?( + jenv, + Bridge.cls, + Bridge.swipe_xy, + @as(f32, @floatCast(x1)), + @as(f32, @floatCast(y1)), + @as(f32, @floatCast(x2)), + @as(f32, @floatCast(y2)), + ); + detachIfAttached(attached); + return if (ok != 0) erts.ok(env) else errorAtom(env, "dispatch_failed"); +} + +// ── Handle registries (Phase 6b iter 3c) ───────────────────────────────── +// +// Two pools of per-widget routing slots. The tap registry is cleared every +// render frame (clear_taps); the component registry is persistent — slots +// stay live across renders and are explicitly freed by deregister_component. +// +// Both pools sit behind mutexes. The mutexes are created lazily by +// mob_nif_init_state (called from mob_nif.c's nif_load BEAM callback). + +const MAX_TAP_HANDLES: usize = 256; +const MAX_COMPONENT_HANDLES: usize = 64; + +/// Per-tap slot: the registered pid, an optional caller-supplied tag, and +/// the throttle state for high-frequency events. tag_env is non-null while +/// the slot is in use; clear_taps frees it and nulls it back out. +const TapHandle = extern struct { + pid: erts.ErlNifPid, + tag_env: ?*erts.ErlNifEnv, + tag: erts.ERL_NIF_TERM, + + // ── Batch 5 throttle state — populated by mob_set_throttle_config ── + throttle_ms: c_int, + debounce_ms: c_int, + delta_threshold: f64, + leading: c_int, + trailing: c_int, + last_emit_ns: i64, + last_x: f64, + last_y: f64, + seq: u64, +}; + +const ComponentHandle = extern struct { + pid: erts.ErlNifPid, + active: c_int, +}; + +// Double-buffered tap registry. Readers (mob_send_*) resolve a handle against +// the ACTIVE table; a render frame builds into the INACTIVE table (register_tap) +// and swaps it in atomically at set_root. This closes a race where a high- +// frequency event (drag/scroll) firing *during* a re-render saw a half-rebuilt +// table and got dropped — worse the later a widget registered (e.g. a canvas +// after a row of buttons). With the swap a concurrent send always sees a +// complete table (old or new), never a partial one. +var tap_tables: [2][MAX_TAP_HANDLES]TapHandle = std.mem.zeroes([2][MAX_TAP_HANDLES]TapHandle); +var tap_active: usize = 0; // index of the table readers resolve against +var tap_active_count: c_int = 0; // committed handle count in the active table +var tap_build_count: c_int = 0; // handles registered so far into the building table +var tap_mutex: ?*erts.ErlNifMutex = null; +/// Snapshotted by nif_set_root; written by nif_set_transition. Guarded by +/// tap_mutex (the C original reused that mutex rather than allocating a +/// second one — keep the lock geometry the same). +var g_transition: [16]u8 = blk: { + var buf: [16]u8 = @splat(0); + buf[0] = 'n'; + buf[1] = 'o'; + buf[2] = 'n'; + buf[3] = 'e'; + break :blk buf; +}; + +var component_handles: [MAX_COMPONENT_HANDLES]ComponentHandle = @splat(std.mem.zeroes(ComponentHandle)); +var component_mutex: ?*erts.ErlNifMutex = null; + +/// Initialise both mutexes. Called from mob_nif.c's nif_load BEAM callback +/// — must run once before any sender or NIF that locks them. Returns 0 +/// on success, -1 on failure (matches the C nif_load return convention). +pub export fn mob_nif_init_state() callconv(.c) c_int { + tap_mutex = erts.enif_mutex_create("mob_tap_mutex") orelse return -1; + component_mutex = erts.enif_mutex_create("mob_component_mutex") orelse return -1; + return 0; +} + +// ── Sender helpers ─────────────────────────────────────────────────────── +// All senders share the same shape: lock tap_mutex, validate the handle +// is in use (slot index in range AND tag_env non-null), copy the pid + tag +// out under the lock, then build and deliver the message to that pid in a +// freshly allocated env. The lock is dropped before enif_send so we don't +// hold it across a potentially-blocking send. + +/// Snapshot a TapHandle's routing under the tap_mutex. Returns null if +/// the handle is unused/out of range. The boolean flag pulls seq too — +/// only the throttled-event senders care about that. +const TapSnap = struct { + pid: erts.ErlNifPid, + tag: erts.ERL_NIF_TERM, + seq: u64, +}; + +fn snapTap(handle: c_int) ?TapSnap { + erts.enif_mutex_lock(tap_mutex); + defer erts.enif_mutex_unlock(tap_mutex); + if (handle < 0 or handle >= tap_active_count) return null; + const h = &tap_tables[tap_active][@intCast(handle)]; + if (h.tag_env == null) return null; + return TapSnap{ .pid = h.pid, .tag = h.tag, .seq = h.seq }; +} + +/// `{:event, tag}` — used by focus/blur/submit/select and the gesture +/// senders that don't carry a payload. +fn sendEvent(handle: c_int, comptime atom_name: [:0]const u8) void { + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, atom_name.ptr), + erts.enif_make_copy(env, snap.tag), + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +/// `{:change, tag, value}` — used by the three change senders below. The +/// value term must originate in the same env we're delivering through. +fn sendChange(handle: c_int, value_term: erts.ERL_NIF_TERM) void { + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "change"), + erts.enif_make_copy(env, snap.tag), + erts.enif_make_copy(env, value_term), + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Tap + change senders ──────────────────────────────────────────────── + +/// Called from beam_jni.c's `nativeSendTap` JNI stub. Sends `{:tap, tag}` +/// to the pid registered for `handle`. +pub export fn mob_send_tap(handle: c_int) callconv(.c) void { + sendEvent(handle, "tap"); +} + +pub export fn mob_send_change_str(handle: c_int, utf8: [*:0]const u8) callconv(.c) void { + const tmp = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(tmp); + var bin: erts.ErlNifBinary = undefined; + const len = std.mem.span(utf8).len; + _ = erts.enif_alloc_binary(len, &bin); + @memcpy(bin.data[0..len], utf8[0..len]); + const term = erts.enif_make_binary(tmp, &bin); + sendChange(handle, term); +} + +pub export fn mob_send_change_bool(handle: c_int, bool_val: c_int) callconv(.c) void { + const tmp = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(tmp); + const term = erts.enif_make_atom(tmp, if (bool_val != 0) "true" else "false"); + sendChange(handle, term); +} + +pub export fn mob_send_change_float(handle: c_int, value: f64) callconv(.c) void { + const tmp = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(tmp); + const term = erts.enif_make_double(tmp, value); + sendChange(handle, term); +} + +// ── Focus / blur / submit / select / compose ──────────────────────────── + +pub export fn mob_send_focus(handle: c_int) callconv(.c) void { + sendEvent(handle, "focus"); +} +pub export fn mob_send_blur(handle: c_int) callconv(.c) void { + sendEvent(handle, "blur"); +} +pub export fn mob_send_submit(handle: c_int) callconv(.c) void { + sendEvent(handle, "submit"); +} +pub export fn mob_send_select(handle: c_int) callconv(.c) void { + sendEvent(handle, "select"); +} + +/// `{:compose, tag, %{text, phase}}` — IME composition events. phase is +/// began | updating | committed | cancelled (the latter two are terminal). +pub export fn mob_send_compose(handle: c_int, text: ?[*:0]const u8, phase: [*:0]const u8) callconv(.c) void { + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const text_cstr: [*:0]const u8 = text orelse ""; + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "text"), + erts.enif_make_atom(env, "phase"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_string(env, text_cstr, erts.ERL_NIF_LATIN1), + erts.enif_make_atom(env, phase), + }; + const payload = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "compose"), + erts.enif_make_copy(env, snap.tag), + payload, + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Gesture senders (Batch 4) ─────────────────────────────────────────── +// Per-widget opt-in — only handles with a registered tag emit. Direction- +// aware swipes go through mob_send_swipe_with_direction; the legacy fixed +// directions stay around for any beam_jni.c stubs that haven't migrated. + +pub export fn mob_send_long_press(handle: c_int) callconv(.c) void { + sendEvent(handle, "long_press"); +} +pub export fn mob_send_double_tap(handle: c_int) callconv(.c) void { + sendEvent(handle, "double_tap"); +} +pub export fn mob_send_swipe_left(handle: c_int) callconv(.c) void { + sendEvent(handle, "swipe_left"); +} +pub export fn mob_send_swipe_right(handle: c_int) callconv(.c) void { + sendEvent(handle, "swipe_right"); +} +pub export fn mob_send_swipe_up(handle: c_int) callconv(.c) void { + sendEvent(handle, "swipe_up"); +} +pub export fn mob_send_swipe_down(handle: c_int) callconv(.c) void { + sendEvent(handle, "swipe_down"); +} + +pub export fn mob_send_swipe_with_direction(handle: c_int, direction: [*:0]const u8) callconv(.c) void { + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "swipe"), + erts.enif_make_copy(env, snap.tag), + erts.enif_make_atom(env, direction), + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Throttle infrastructure (Batch 5 Tier 1) ──────────────────────────── +// Per-handle throttle + delta-threshold gating, mirroring iOS. Phase +// boundaries (began/ended) bypass the throttle so the BEAM always sees +// the start + stop of a gesture even when intermediate samples are +// dropped. + +pub export fn mob_set_throttle_config( + handle: c_int, + throttle_ms: c_int, + debounce_ms: c_int, + delta_threshold: f64, + leading: c_int, + trailing: c_int, +) callconv(.c) void { + erts.enif_mutex_lock(tap_mutex); + defer erts.enif_mutex_unlock(tap_mutex); + if (handle < 0 or handle >= tap_active_count) return; + const h = &tap_tables[tap_active][@intCast(handle)]; + if (h.tag_env == null) return; + h.throttle_ms = throttle_ms; + h.debounce_ms = debounce_ms; + h.delta_threshold = delta_threshold; + h.leading = leading; + h.trailing = trailing; +} + +/// Returns true if this sample should emit (and updates last_emit_ns + +/// last_x/y + seq under the mutex). `default_throttle_ms` and +/// `default_delta` are the gesture-specific defaults applied when the +/// per-handle config left those fields at 0. +fn throttleCheck(handle: c_int, x: f64, y: f64, default_throttle_ms: i32, default_delta: f64) bool { + erts.enif_mutex_lock(tap_mutex); + defer erts.enif_mutex_unlock(tap_mutex); + if (handle < 0 or handle >= tap_active_count) return false; + const h = &tap_tables[tap_active][@intCast(handle)]; + if (h.tag_env == null) return false; + + const throttle_ms: i32 = if (h.throttle_ms != 0) h.throttle_ms else default_throttle_ms; + const delta_threshold: f64 = if (h.delta_threshold > 0) h.delta_threshold else default_delta; + + const now_ns = jni.nowNs(); + const dx = x - h.last_x; + const dy = y - h.last_y; + const dist = @abs(dx) + @abs(dy); + + if (h.last_emit_ns > 0 and throttle_ms > 0) { + const elapsed_ms = @divTrunc(now_ns - h.last_emit_ns, 1_000_000); + if (elapsed_ms < throttle_ms) return false; + } + if (h.last_emit_ns > 0 and dist < delta_threshold) return false; + + h.last_emit_ns = now_ns; + h.last_x = x; + h.last_y = y; + h.seq +%= 1; // wrap on overflow; matches C's `++` on unsigned long long + return true; +} + +inline fn isPhaseBoundary(phase: [*:0]const u8) bool { + const span = std.mem.span(phase); + return std.mem.eql(u8, span, "began") or std.mem.eql(u8, span, "ended"); +} + +/// Build the scroll/drag payload map. Caller owns `env`. +fn buildScrollMap( + env: ?*erts.ErlNifEnv, + x: f64, + y: f64, + dx: f64, + dy: f64, + vx: f64, + vy: f64, + phase: [*:0]const u8, + ts_ms: i64, + seq: u64, +) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "x"), + erts.enif_make_atom(env, "y"), + erts.enif_make_atom(env, "dx"), + erts.enif_make_atom(env, "dy"), + erts.enif_make_atom(env, "velocity_x"), + erts.enif_make_atom(env, "velocity_y"), + erts.enif_make_atom(env, "phase"), + erts.enif_make_atom(env, "ts"), + erts.enif_make_atom(env, "seq"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, x), + erts.enif_make_double(env, y), + erts.enif_make_double(env, dx), + erts.enif_make_double(env, dy), + erts.enif_make_double(env, vx), + erts.enif_make_double(env, vy), + erts.enif_make_atom(env, phase), + erts.enif_make_int64(env, ts_ms), + erts.enif_make_uint64(env, seq), + }; + return erts.makeMap(env, &keys, &vals) orelse erts.atom(env, "error"); +} + +pub export fn mob_send_scroll( + handle: c_int, + x: f64, + y: f64, + dx: f64, + dy: f64, + vx: f64, + vy: f64, + phase: [*:0]const u8, +) callconv(.c) void { + if (!isPhaseBoundary(phase) and !throttleCheck(handle, x, y, 33, 1.0)) return; + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const ts_ms = @divTrunc(jni.nowNs(), 1_000_000); + const payload = buildScrollMap(env, x, y, dx, dy, vx, vy, phase, ts_ms, snap.seq); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "scroll"), + erts.enif_make_copy(env, snap.tag), + payload, + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_send_drag( + handle: c_int, + x: f64, + y: f64, + dx: f64, + dy: f64, + phase: [*:0]const u8, +) callconv(.c) void { + if (!isPhaseBoundary(phase) and !throttleCheck(handle, x, y, 16, 1.0)) return; + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const ts_ms = @divTrunc(jni.nowNs(), 1_000_000); + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "x"), + erts.enif_make_atom(env, "y"), + erts.enif_make_atom(env, "dx"), + erts.enif_make_atom(env, "dy"), + erts.enif_make_atom(env, "phase"), + erts.enif_make_atom(env, "ts"), + erts.enif_make_atom(env, "seq"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, x), + erts.enif_make_double(env, y), + erts.enif_make_double(env, dx), + erts.enif_make_double(env, dy), + erts.enif_make_atom(env, phase), + erts.enif_make_int64(env, ts_ms), + erts.enif_make_uint64(env, snap.seq), + }; + const payload = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "drag"), + erts.enif_make_copy(env, snap.tag), + payload, + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_send_pinch(handle: c_int, scale: f64, velocity: f64, phase: [*:0]const u8) callconv(.c) void { + if (!isPhaseBoundary(phase) and !throttleCheck(handle, scale, 0, 16, 0.01)) return; + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const ts_ms = @divTrunc(jni.nowNs(), 1_000_000); + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "scale"), + erts.enif_make_atom(env, "velocity"), + erts.enif_make_atom(env, "phase"), + erts.enif_make_atom(env, "ts"), + erts.enif_make_atom(env, "seq"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, scale), + erts.enif_make_double(env, velocity), + erts.enif_make_atom(env, phase), + erts.enif_make_int64(env, ts_ms), + erts.enif_make_uint64(env, snap.seq), + }; + const payload = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "pinch"), + erts.enif_make_copy(env, snap.tag), + payload, + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_send_rotate(handle: c_int, degrees: f64, velocity: f64, phase: [*:0]const u8) callconv(.c) void { + if (!isPhaseBoundary(phase) and !throttleCheck(handle, degrees, 0, 16, 1.0)) return; + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const ts_ms = @divTrunc(jni.nowNs(), 1_000_000); + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "degrees"), + erts.enif_make_atom(env, "velocity"), + erts.enif_make_atom(env, "phase"), + erts.enif_make_atom(env, "ts"), + erts.enif_make_atom(env, "seq"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, degrees), + erts.enif_make_double(env, velocity), + erts.enif_make_atom(env, phase), + erts.enif_make_int64(env, ts_ms), + erts.enif_make_uint64(env, snap.seq), + }; + const payload = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "rotate"), + erts.enif_make_copy(env, snap.tag), + payload, + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_send_pointer_move(handle: c_int, x: f64, y: f64) callconv(.c) void { + if (!throttleCheck(handle, x, y, 33, 4.0)) return; + const snap = snapTap(handle) orelse return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const ts_ms = @divTrunc(jni.nowNs(), 1_000_000); + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "x"), + erts.enif_make_atom(env, "y"), + erts.enif_make_atom(env, "ts"), + erts.enif_make_atom(env, "seq"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_double(env, x), + erts.enif_make_double(env, y), + erts.enif_make_int64(env, ts_ms), + erts.enif_make_uint64(env, snap.seq), + }; + const payload = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "pointer_move"), + erts.enif_make_copy(env, snap.tag), + payload, + }); + var pid = snap.pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Tier 2: semantic single-fire scroll events ────────────────────────── + +pub export fn mob_send_scroll_began(handle: c_int) callconv(.c) void { + sendEvent(handle, "scroll_began"); +} +pub export fn mob_send_scroll_ended(handle: c_int) callconv(.c) void { + sendEvent(handle, "scroll_ended"); +} +pub export fn mob_send_scroll_settled(handle: c_int) callconv(.c) void { + sendEvent(handle, "scroll_settled"); +} +pub export fn mob_send_top_reached(handle: c_int) callconv(.c) void { + sendEvent(handle, "top_reached"); +} +pub export fn mob_send_scrolled_past(handle: c_int) callconv(.c) void { + sendEvent(handle, "scrolled_past"); +} + +// ── Component event sender ────────────────────────────────────────────── + +pub export fn mob_send_component_event( + handle: c_int, + event: [*:0]const u8, + payload_json: [*:0]const u8, +) callconv(.c) void { + if (handle < 0 or handle >= @as(c_int, @intCast(MAX_COMPONENT_HANDLES))) return; + erts.enif_mutex_lock(component_mutex); + const slot = &component_handles[@intCast(handle)]; + if (slot.active == 0) { + erts.enif_mutex_unlock(component_mutex); + return; + } + const pid_copy = slot.pid; + erts.enif_mutex_unlock(component_mutex); + + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "component_event"), + erts.enif_make_string(env, event, erts.ERL_NIF_LATIN1), + erts.enif_make_string(env, payload_json, erts.ERL_NIF_LATIN1), + }); + var pid = pid_copy; + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Back gesture ──────────────────────────────────────────────────────── + +/// Called from beam_jni.c's nativeHandleBack JNI stub when the Android +/// back gesture fires. Looks up the :mob_screen registered process and +/// sends {:mob, :back}. Mob.Screen handles popping the nav stack or +/// exiting the app at root. +pub export fn mob_handle_back() callconv(.c) void { + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + var pid: erts.ErlNifPid = undefined; + if (erts.enif_whereis_pid(env, erts.enif_make_atom(env, "mob_screen"), &pid) != 0) { + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "mob"), + erts.enif_make_atom(env, "back"), + }); + _ = erts.enif_send(null, &pid, env, msg); + } +} + +// ── NIFs that touch the tap registry / g_transition / Bridge.set_root ─── +// (Ported alongside the senders so all consumers of these statics are +// co-located in Zig.) + +// nif_set_root/1 — pass JSON node tree to Compose. Snapshots the current +// `g_transition` (set by nif_set_transition before this call) and resets +// it to "none" so the next render starts from a clean default. +export fn nif_set_root( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, argv[0], &bin) == 0 and + erts.enif_inspect_iolist_as_binary(env, argv[0], &bin) == 0) + { + return erts.badarg(env); + } + + // Null-terminate for NewStringUTF. + const json_ptr: ?*anyopaque = jni.malloc(bin.size + 1) orelse + return erts.atom(env, "error"); + defer jni.free(json_ptr); + const json_buf: [*]u8 = @ptrCast(json_ptr); + @memcpy(json_buf[0..bin.size], bin.data[0..bin.size]); + json_buf[bin.size] = 0; + const json_cstr: [*:0]const u8 = @ptrCast(json_buf); + + // Snapshot transition under the mutex; reset to "none" for next call. + var transition: [16]u8 = @splat(0); + erts.enif_mutex_lock(tap_mutex); + @memcpy(&transition, &g_transition); + @memset(&g_transition, 0); + g_transition[0] = 'n'; + g_transition[1] = 'o'; + g_transition[2] = 'n'; + g_transition[3] = 'e'; + // Commit the freshly-built tap table: register_tap wrote this frame's + // handlers into 1 - tap_active, so make that table active now. Events for + // the new tree (delivered to Compose just below) resolve against it, and + // any send racing this swap sees a complete table on either side. + tap_active = 1 - tap_active; + tap_active_count = tap_build_count; + erts.enif_mutex_unlock(tap_mutex); + const transition_cstr: [*:0]const u8 = @ptrCast(&transition); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jjson = jni.newStringUTF(jenv, json_cstr); + const jtransition = jni.newStringUTF(jenv, transition_cstr); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.set_root, jjson, jtransition); + jni.deleteLocalRef(jenv, jjson); + jni.deleteLocalRef(jenv, jtransition); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_register_tap/1 — accepts a pid (tag = :ok) or {pid, tag} (any term +// as the tag). Returns the integer handle. +export fn nif_register_tap( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var pid: erts.ErlNifPid = undefined; + var tag_term: erts.ERL_NIF_TERM = undefined; + + if (erts.enif_get_local_pid(env, argv[0], &pid) != 0) { + tag_term = erts.enif_make_atom(env, "ok"); + } else { + var arity: c_int = 0; + var elems: [*]const erts.ERL_NIF_TERM = undefined; + if (erts.enif_get_tuple(env, argv[0], &arity, &elems) == 0 or arity != 2) { + return erts.badarg(env); + } + if (erts.enif_get_local_pid(env, elems[0], &pid) == 0) return erts.badarg(env); + tag_term = elems[1]; + } + + erts.enif_mutex_lock(tap_mutex); + defer erts.enif_mutex_unlock(tap_mutex); + if (tap_build_count >= @as(c_int, @intCast(MAX_TAP_HANDLES))) return erts.badarg(env); + + const handle: c_int = tap_build_count; + tap_build_count += 1; + const slot = &tap_tables[1 - tap_active][@intCast(handle)]; + slot.pid = pid; + slot.tag_env = erts.enif_alloc_env() orelse return erts.atom(env, "error"); + slot.tag = erts.enif_make_copy(slot.tag_env, tag_term); + return erts.enif_make_int(env, handle); +} + +// nif_clear_taps/0 — cleared at the start of every render. Frees each +// slot's tag_env (which owns the persistent tag term) and zeroes the +// throttle state so reuse across renders doesn't leak stale config. +export fn nif_clear_taps( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + erts.enif_mutex_lock(tap_mutex); + defer erts.enif_mutex_unlock(tap_mutex); + // Prepare the INACTIVE (building) table for a fresh frame; leave the active + // table intact so concurrent mob_send_* keep resolving the last committed + // frame. The freshly built table is swapped in at set_root. + const build = &tap_tables[1 - tap_active]; + var i: usize = 0; + while (i < MAX_TAP_HANDLES) : (i += 1) { + const h = &build[i]; + if (h.tag_env != null) { + erts.enif_free_env(h.tag_env); + h.tag_env = null; + } + // Reset throttle state — slots get reused across renders. + h.throttle_ms = 0; + h.debounce_ms = 0; + h.delta_threshold = 0; + h.leading = 1; + h.trailing = 1; + h.last_emit_ns = 0; + h.last_x = 0; + h.last_y = 0; + h.seq = 0; + } + tap_build_count = 0; + return erts.ok(env); +} + +// nif_set_transition/1 — store the transition type atom (push/pop/reset/ +// none) to be picked up by the next set_root call. Must be called before +// set_root for the transition to take effect on that render. +export fn nif_set_transition( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + erts.enif_mutex_lock(tap_mutex); + defer erts.enif_mutex_unlock(tap_mutex); + if (erts.enif_get_atom(env, argv[0], &g_transition, g_transition.len, erts.ERL_NIF_LATIN1) == 0) { + return erts.badarg(env); + } + return erts.ok(env); +} + +// nif_register_component/1 — allocate a persistent component handle for +// a Native View pid. Linear scan through MAX_COMPONENT_HANDLES slots; +// fails when all are in use. +export fn nif_register_component( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var pid: erts.ErlNifPid = undefined; + if (erts.enif_get_local_pid(env, argv[0], &pid) == 0) return erts.badarg(env); + + erts.enif_mutex_lock(component_mutex); + defer erts.enif_mutex_unlock(component_mutex); + var i: usize = 0; + while (i < MAX_COMPONENT_HANDLES) : (i += 1) { + if (component_handles[i].active == 0) { + component_handles[i].pid = pid; + component_handles[i].active = 1; + return erts.enif_make_int(env, @intCast(i)); + } + } + return erts.badarg(env); +} + +// nif_deregister_component/1 — release a component handle. Slot becomes +// available for the next register call. +export fn nif_deregister_component( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var handle: c_int = 0; + if (erts.enif_get_int(env, argv[0], &handle) == 0 or + handle < 0 or + handle >= @as(c_int, @intCast(MAX_COMPONENT_HANDLES))) + { + return erts.badarg(env); + } + erts.enif_mutex_lock(component_mutex); + component_handles[@intCast(handle)].active = 0; + erts.enif_mutex_unlock(component_mutex); + return erts.ok(env); +} + +// ══════════════════════════════════════════════════════════════════════════ +// Phase 6b iter 3d — finale: bridge bootstrap, feature NIFs, deliver_* event +// dispatchers, NIF table, and the ERL_NIF_INIT entry point. After this iter +// mob_nif.c is gone — everything below was the last residency of native +// state and entry points in C. +// ══════════════════════════════════════════════════════════════════════════ + +const NIF_LOG_TAG: [*:0]const u8 = "MobNIF"; + +inline fn logi_nif(comptime fmt: []const u8, args: anytype) void { + jni.logWrite(jni.ANDROID_LOG_INFO, NIF_LOG_TAG, fmt, args); +} + +inline fn loge_nif(comptime fmt: []const u8, args: anytype) void { + jni.logWrite(jni.ANDROID_LOG_ERROR, NIF_LOG_TAG, fmt, args); +} + +// ── Bridge bootstrap helpers ───────────────────────────────────────────── +// Called from mob_beam.zig during BEAM startup, BEFORE nif_load runs. The +// startup_phase / startup_error paths must be safe to call when only +// `Bridge.cls` + `Bridge.set_startup_phase` / `Bridge.set_startup_error` +// are populated (which is what _mob_ui_cache_class_impl does first). + +/// `_mob_ui_cache_class_impl(jenv, bridge_class)` — invoked by +/// `mob_ui_cache_class` (in mob_beam.zig) from JNI_OnLoad. Caches the +/// MobBridge `jclass` as a global ref and pre-caches set_startup_phase / +/// set_startup_error so the BEAM launcher can drive the splash screen +/// before NIF load. +pub export fn _mob_ui_cache_class_impl(jenv_p: *jni.JNIEnv, bridge_class: [*:0]const u8) callconv(.c) void { + logi_nif("mob_ui_cache_class: looking up {s}", .{bridge_class}); + const cls = jni.findClass(jenv_p, bridge_class); + if (cls == null) { + loge_nif("mob_ui_cache_class: {s} not found", .{bridge_class}); + return; + } + Bridge.cls = jni.newGlobalRef(jenv_p, cls); + jni.deleteLocalRef(jenv_p, cls); + // Pre-cache startup status methods — needed before nif_load runs. + // These are optional (older MobBridge versions may not have them); + // clear any pending exception rather than aborting. + Bridge.set_startup_phase = jni.getStaticMethodID(jenv_p, Bridge.cls, "setStartupPhase", "(Ljava/lang/String;)V"); + if (Bridge.set_startup_phase == null) jni.exceptionClear(jenv_p); + Bridge.set_startup_error = jni.getStaticMethodID(jenv_p, Bridge.cls, "setStartupError", "(Ljava/lang/String;)V"); + if (Bridge.set_startup_error == null) jni.exceptionClear(jenv_p); + logi_nif("mob_ui_cache_class: {s} cached OK", .{bridge_class}); +} + +pub export fn mob_set_startup_phase(phase: [*:0]const u8) callconv(.c) void { + if (g_jvm == null or Bridge.cls == null or Bridge.set_startup_phase == null) return; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return; + const js = jni.newStringUTF(jenv, phase); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.set_startup_phase, js); + jni.deleteLocalRef(jenv, js); + detachIfAttached(attached); + logi_nif("startup: {s}", .{phase}); +} + +pub export fn mob_set_startup_error(err: [*:0]const u8) callconv(.c) void { + if (g_jvm == null or Bridge.cls == null or Bridge.set_startup_error == null) return; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return; + const js = jni.newStringUTF(jenv, err); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.set_startup_error, js); + jni.deleteLocalRef(jenv, js); + detachIfAttached(attached); + loge_nif("startup ERROR: {s}", .{err}); +} + +/// `_mob_bridge_init_activity` — invoked by `mob_init_bridge` (mob_beam.zig) +/// after the Activity global ref is set. Calls MobBridge.init(Activity) +/// which wires the Kotlin side to the running activity. +pub export fn _mob_bridge_init_activity(env: *jni.JNIEnv, activity: jni.JObject) callconv(.c) void { + if (Bridge.cls == null) { + loge_nif("_mob_bridge_init_activity: Bridge.cls not cached", .{}); + return; + } + const init = jni.getStaticMethodID(env, Bridge.cls, "init", "(Landroid/app/Activity;)V"); + env.*.CallStaticVoidMethod.?(env, Bridge.cls, init, activity); + logi_nif("_mob_bridge_init_activity: MobBridge.init called", .{}); +} + +// ── Helpers for the feature NIFs below ─────────────────────────────────── + +/// Accept either a plain binary or an iolist (deep-flatten to binary). +/// Returns null on failure — the caller turns that into `badarg`. +fn getBinOrIolist(env: ?*erts.ErlNifEnv, term: erts.ERL_NIF_TERM) ?erts.ErlNifBinary { + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, term, &bin) != 0) return bin; + if (erts.enif_inspect_iolist_as_binary(env, term, &bin) != 0) return bin; + return null; +} + +/// Heap-allocate a NUL-terminated copy of an `ErlNifBinary` for JNI's +/// NewStringUTF. Returns null on OOM. Caller frees via `freeCString`. +fn binToCString(bin: erts.ErlNifBinary) ?[*:0]u8 { + const buf_ptr = jni.malloc(bin.size + 1) orelse return null; + const dst: [*]u8 = @ptrCast(buf_ptr); + @memcpy(dst[0..bin.size], bin.data[0..bin.size]); + dst[bin.size] = 0; + return @ptrCast(buf_ptr); +} + +inline fn freeCString(p: ?[*:0]u8) void { + if (p) |ptr| jni.free(@as(?*anyopaque, @ptrCast(ptr))); +} + +/// Pack an ErlNifPid into a jlong for the JNI-side delivery handle. Kotlin +/// hands it back unchanged when it calls one of the mob_deliver_* hooks; +/// we round-trip via `pidFromLong`. +/// +/// Size mismatch handling: on aarch64 ERL_NIF_TERM is c_ulong = u64, +/// same width as jlong (i64), so a @bitCast is a true reinterpret. On +/// armeabi-v7a (32-bit ARM) ERL_NIF_TERM is u32 but jlong is still i64, +/// so we zero-extend on the way out and truncate on the way back. This +/// mirrors the C original's `memcpy(min(sizeof(ErlNifPid), sizeof(jlong)))` +/// dance — the high 32 bits of the jlong carry no information on 32-bit +/// ARM, they just round-trip whatever Kotlin saw. +inline fn pidToJlong(pid: erts.ErlNifPid) jni.JLong { + if (@sizeOf(erts.ERL_NIF_TERM) == @sizeOf(jni.JLong)) { + return @bitCast(pid.pid); + } + // 32-bit ARM: zero-extend the u32 pid into the low 32 bits of i64. + return @intCast(pid.pid); +} + +inline fn pidFromLong(jpid: jni.JLong) erts.ErlNifPid { + if (@sizeOf(erts.ERL_NIF_TERM) == @sizeOf(jni.JLong)) { + return .{ .pid = @bitCast(jpid) }; + } + // 32-bit ARM: take the low 32 bits of the jlong. The high bits are + // whatever Kotlin's been passing around — discard them. + const low: u32 = @truncate(@as(u64, @bitCast(jpid))); + return .{ .pid = low }; +} + +/// Call `MobBridge.<method>(pid_long, arg)` — the standard shape for +/// async device-capability NIFs (camera, audio_play, etc.). +/// Returns the `:ok` atom unconditionally; results land later via one of +/// the mob_deliver_* JNI hooks. `arg` may be null for void-of-pid methods. +fn callBridgePidStr(env: ?*erts.ErlNifEnv, method: jni.JMethodID, pid: erts.ErlNifPid, arg: ?[*:0]const u8) erts.ERL_NIF_TERM { + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jarg: jni.JString = if (arg) |a| jni.newStringUTF(jenv, a) else null; + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, method, pidToJlong(pid), jarg); + if (jarg != null) jni.deleteLocalRef(jenv, jarg); + detachIfAttached(attached); + return erts.ok(env); +} + +fn callBridgePidStr2(env: ?*erts.ErlNifEnv, method: jni.JMethodID, pid: erts.ErlNifPid, a1: ?[*:0]const u8, a2: ?[*:0]const u8) erts.ERL_NIF_TERM { + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const j1: jni.JString = if (a1) |a| jni.newStringUTF(jenv, a) else null; + const j2: jni.JString = if (a2) |a| jni.newStringUTF(jenv, a) else null; + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, method, pidToJlong(pid), j1, j2); + if (j1 != null) jni.deleteLocalRef(jenv, j1); + if (j2 != null) jni.deleteLocalRef(jenv, j2); + detachIfAttached(attached); + return erts.ok(env); +} + +fn callBridgePidStr3(env: ?*erts.ErlNifEnv, method: jni.JMethodID, pid: erts.ErlNifPid, a1: ?[*:0]const u8, a2: ?[*:0]const u8, a3: ?[*:0]const u8) erts.ERL_NIF_TERM { + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const j1: jni.JString = if (a1) |a| jni.newStringUTF(jenv, a) else null; + const j2: jni.JString = if (a2) |a| jni.newStringUTF(jenv, a) else null; + const j3: jni.JString = if (a3) |a| jni.newStringUTF(jenv, a) else null; + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, method, pidToJlong(pid), j1, j2, j3); + if (j1 != null) jni.deleteLocalRef(jenv, j1); + if (j2 != null) jni.deleteLocalRef(jenv, j2); + if (j3 != null) jni.deleteLocalRef(jenv, j3); + detachIfAttached(attached); + return erts.ok(env); +} + +/// Read a jstring into an `ErlNifBinary` via UTF-8. Returns the binary +/// term + 1 (success) or 0 (null jstring / GetStringUTFChars failed). +/// Deletes the local ref on success. +fn jstringToBinaryTerm(env: ?*erts.ErlNifEnv, jenv: *jni.JNIEnv, js: jni.JString) ?erts.ERL_NIF_TERM { + if (js == null) return null; + const utf = jni.getStringUTFChars(jenv, js) orelse return null; + const len = jni.strlen(utf); + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + @memcpy(bin.data[0..len], utf[0..len]); + jni.releaseStringUTFChars(jenv, js, utf); + jni.deleteLocalRef(jenv, js); + return erts.enif_make_binary(env, &bin); +} + +// ── Core feature NIFs ──────────────────────────────────────────────────── + +// nif_color_scheme/0 — :light | :dark. Returns :light if the optional +// MobBridge.getColorScheme() isn't compiled into the app. +export fn nif_color_scheme( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.get_color_scheme == null) return erts.atom(env, "light"); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "light"); + const result = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.get_color_scheme); + var out = erts.atom(env, "light"); + if (result != null) { + if (jni.getStringUTFChars(jenv, result)) |str| { + if (jni.strncmp(str, "dark", 4) == 0 and str[4] == 0) { + out = erts.atom(env, "dark"); + } + jni.releaseStringUTFChars(jenv, result, str); + } + jni.deleteLocalRef(jenv, result); + } + detachIfAttached(attached); + return out; +} + +// nif_exit_app/0 — Activity.moveTaskToBack(true). Called by Mob.Screen +// when the back gesture fires at the root of the nav stack. +export fn nif_exit_app( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.move_to_back); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_safe_area/0 — {Top, Right, Bottom, Left} in dp via +// MobBridge.getSafeArea(). The Kotlin side returns float[4] in +// {top, right, bottom, left} order. +export fn nif_safe_area( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + var vals: [4]f32 = @splat(0); + const arr = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.get_safe_area); + if (arr != null) { + jni.getFloatArrayRegion(jenv, arr, 0, 4, &vals); + jni.deleteLocalRef(jenv, arr); + } + detachIfAttached(attached); + return erts.makeTuple(env, .{ + erts.enif_make_double(env, @floatCast(vals[0])), + erts.enif_make_double(env, @floatCast(vals[1])), + erts.enif_make_double(env, @floatCast(vals[2])), + erts.enif_make_double(env, @floatCast(vals[3])), + }); +} + +// nif_haptic/1 — pass an atom (heavy/medium/light/...) to +// MobBridge.haptic(String). +export fn nif_haptic( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var type_buf: [32]u8 = @splat(0); + _ = erts.enif_get_atom(env, argv[0], &type_buf, type_buf.len, erts.ERL_NIF_LATIN1); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtype = jni.newStringUTF(jenv, jni.asCStr(&type_buf)); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.haptic, jtype); + jni.deleteLocalRef(jenv, jtype); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_torch/1 — pass the atom `on` or `off` to MobBridge.torch(String), which +// toggles the rear-camera torch. No-op on a device without a flash unit. +export fn nif_torch( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var state_buf: [8]u8 = @splat(0); + _ = erts.enif_get_atom(env, argv[0], &state_buf, state_buf.len, erts.ERL_NIF_LATIN1); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jstate = jni.newStringUTF(jenv, jni.asCStr(&state_buf)); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.torch, jstate); + jni.deleteLocalRef(jenv, jstate); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_clipboard_put/1 — ClipboardManager.setPrimaryClip via Kotlin. +export fn nif_clipboard_put( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const text = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(text); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtext = jni.newStringUTF(jenv, text); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.clipboard_put, jtext); + jni.deleteLocalRef(jenv, jtext); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_clipboard_get/0 — returns {:ok, Binary} or :empty. +export fn nif_clipboard_get( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const result = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.clipboard_get); + var out: erts.ERL_NIF_TERM = undefined; + if (jstringToBinaryTerm(env, jenv, result)) |bin_term| { + out = erts.makeTuple(env, .{ erts.atom(env, "ok"), bin_term }); + } else { + out = erts.atom(env, "empty"); + } + detachIfAttached(attached); + return out; +} + +// nif_tts_speak/2 — speaks Text via MobBridge.ttsSpeak(String, String). The +// OptsJson string carries {"rate","pitch","voice"} (all optional). The Bridge +// method is optional: apps generated before TTS existed won't have it, so we +// return :ok without doing anything rather than failing. +export fn nif_tts_speak( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.tts_speak == null) return erts.ok(env); + const text_bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const text = binToCString(text_bin) orelse return erts.atom(env, "error"); + defer freeCString(text); + const opts_bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + const opts = binToCString(opts_bin) orelse return erts.atom(env, "error"); + defer freeCString(opts); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtext = jni.newStringUTF(jenv, text); + const jopts = jni.newStringUTF(jenv, opts); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.tts_speak, jtext, jopts); + jni.deleteLocalRef(jenv, jtext); + jni.deleteLocalRef(jenv, jopts); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_tts_stop/0 — stops any in-progress speech via MobBridge.ttsStop(). +export fn nif_tts_stop( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.tts_stop == null) return erts.ok(env); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.tts_stop); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_set_theme/1 — push the resolved theme palette (as JSON) to Kotlin so +// Compose MaterialTheme follows runtime `Mob.Theme.set/1` calls. The +// `setTheme(String)` Bridge method is optional (older MobBridge templates +// predate it); if the cache lookup at load time didn't find it, this +// returns :ok without doing anything so apps don't crash on a theme push. +export fn nif_set_theme( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.set_theme == null) return erts.ok(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jjson = jni.newStringUTF(jenv, json); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.set_theme, jjson); + jni.deleteLocalRef(jenv, jjson); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_open_url/1 — Intent ACTION_VIEW with the URI. +export fn nif_open_url( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const url = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(url); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jurl = jni.newStringUTF(jenv, url); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.open_url, jurl); + jni.deleteLocalRef(jenv, jurl); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_open_settings/1 — open an OS settings screen. target is "app" | +// "notifications" | "exact_alarm" (the Kotlin side maps it to the Intent). +// Cached optionally, so an app whose scaffolded MobBridge.kt predates +// openSettings just no-ops instead of crashing. +export fn nif_open_settings( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const target = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(target); + if (Bridge.open_settings == null) return erts.ok(env); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtarget = jni.newStringUTF(jenv, target); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.open_settings, jtarget); + jni.deleteLocalRef(jenv, jtarget); + detachIfAttached(attached); + return erts.ok(env); +} + +// nif_audio_output_status/0 — {Volume, Muted, RouteCode, OtherAudio} as four +// doubles (decoded by Mob.Audio.output_status/0). MobBridge.audioOutputStatus() +// returns float[4] = [volume0..1, muted(0/1), routeCode, otherAudio(0/1)]. +// Optional bridge method: an older MobBridge.kt leaves it null → return all +// zeros (Mob.Audio decodes that as route :none, which reads as "unknown-ish" +// rather than crashing). +export fn nif_audio_output_status( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var vals: [4]f32 = @splat(0); + if (Bridge.audio_output_status != null) { + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const arr = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.audio_output_status); + if (arr != null) { + jni.getFloatArrayRegion(jenv, arr, 0, 4, &vals); + jni.deleteLocalRef(jenv, arr); + } + detachIfAttached(attached); + } + return erts.makeTuple(env, .{ + erts.enif_make_double(env, @floatCast(vals[0])), + erts.enif_make_double(env, @floatCast(vals[1])), + erts.enif_make_double(env, @floatCast(vals[2])), + erts.enif_make_double(env, @floatCast(vals[3])), + }); +} + +// nif_audio_output_level/1 — {RmsDb, PeakDb} as two doubles, or an error atom. +// Source is "mob" (Mob's own player session) | "mix". The global output mix is +// privileged on modern Android (a normal app gets ERROR_NO_INIT attaching a +// Visualizer to session 0), so "mix" is unsupported here — global device-audio +// capture lives in a separate MediaProjection-based plugin. MobBridge returns: +// float[2] = [rms_db, peak_db] → success +// float[1] = [code] → 1 unsupported_on_platform, 2 needs_record_audio, +// 3 not_playing (no active Mob.Audio player) +// null → generic error +export fn nif_audio_output_level( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.audio_output_level == null) return erts.atom(env, "unsupported_on_platform"); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const source = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(source); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jsource = jni.newStringUTF(jenv, source); + const arr = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.audio_output_level, jsource); + jni.deleteLocalRef(jenv, jsource); + if (arr == null) { + detachIfAttached(attached); + return erts.atom(env, "error"); + } + const len = jni.getArrayLength(jenv, arr); + if (len >= 2) { + var vals: [2]f32 = @splat(0); + jni.getFloatArrayRegion(jenv, arr, 0, 2, &vals); + jni.deleteLocalRef(jenv, arr); + detachIfAttached(attached); + return erts.makeTuple(env, .{ + erts.enif_make_double(env, @floatCast(vals[0])), + erts.enif_make_double(env, @floatCast(vals[1])), + }); + } + // Length-1 array carries an error code the Kotlin couldn't express otherwise. + var code: [1]f32 = @splat(0); + if (len == 1) jni.getFloatArrayRegion(jenv, arr, 0, 1, &code); + jni.deleteLocalRef(jenv, arr); + detachIfAttached(attached); + return switch (@as(i32, @intFromFloat(code[0]))) { + 1 => erts.atom(env, "unsupported_on_platform"), + 2 => erts.atom(env, "needs_record_audio"), + 3 => erts.atom(env, "not_playing"), + else => erts.atom(env, "error"), + }; +} + +// nif_share_text/1 — system share sheet (Intent ACTION_SEND text/plain). +export fn nif_share_text( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const text = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(text); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtext = jni.newStringUTF(jenv, text); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.share_text, jtext); + jni.deleteLocalRef(jenv, jtext); + detachIfAttached(attached); + return erts.ok(env); +} + +// ── Launch notification (written from Kotlin on cold start) ────────────── +// MobBridge.setLaunchNotification(json) → mob_set_launch_notification(json). +// Apps call Mob.Device.take_launch_notification/0 → nif_take_launch_notification +// to consume it. Guarded by g_launch_notif_mutex (lazily created in nif_load). + +var g_launch_notif_json: ?[*:0]u8 = null; +var g_launch_notif_mutex: ?*erts.ErlNifMutex = null; + +pub export fn mob_set_launch_notification(json: ?[*:0]const u8) callconv(.c) void { + const mutex = g_launch_notif_mutex orelse return; + erts.enif_mutex_lock(mutex); + defer erts.enif_mutex_unlock(mutex); + if (g_launch_notif_json) |old| jni.free(@as(?*anyopaque, @ptrCast(old))); + g_launch_notif_json = if (json) |j| jni.strdup(j) else null; +} + +export fn nif_take_launch_notification( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + const mutex = g_launch_notif_mutex orelse return erts.atom(env, "none"); + erts.enif_mutex_lock(mutex); + const taken = g_launch_notif_json; + g_launch_notif_json = null; + erts.enif_mutex_unlock(mutex); + const json = taken orelse return erts.atom(env, "none"); + const len = jni.strlen(json); + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + @memcpy(bin.data[0..len], json[0..len]); + jni.free(@as(?*anyopaque, @ptrCast(json))); + return erts.enif_make_binary(env, &bin); +} + +// ── Opened document ("open with"): a file handed to us by another app ──── +// MainActivity reads the ACTION_VIEW / ACTION_SEND intent, copies the content +// into the app's storage, builds an item JSON ({path,name,mime,size}), and calls +// MobBridge.setOpenedDocument(json) → mob_set_opened_document(json). Apps consume +// it via Mob.Files.take_opened_document/0 → nif_take_opened_document. Same +// store/take shape as the launch notification; cold-launch oriented, since the +// Activity stores it before the BEAM is up. +var g_opened_doc_json: ?[*:0]u8 = null; +var g_opened_doc_mutex: ?*erts.ErlNifMutex = null; + +pub export fn mob_set_opened_document(json: ?[*:0]const u8) callconv(.c) void { + // Store even before nif_load created the mutex: at a cold launch the Activity + // calls this before the BEAM thread starts, and nothing reads the global + // until take_opened_document, so there's no concurrent access in that window. + if (g_opened_doc_mutex) |mutex| erts.enif_mutex_lock(mutex); + if (g_opened_doc_json) |old| jni.free(@as(?*anyopaque, @ptrCast(old))); + g_opened_doc_json = if (json) |j| jni.strdup(j) else null; + if (g_opened_doc_mutex) |mutex| erts.enif_mutex_unlock(mutex); +} + +export fn nif_take_opened_document( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + const mutex = g_opened_doc_mutex orelse return erts.atom(env, "none"); + erts.enif_mutex_lock(mutex); + const taken = g_opened_doc_json; + g_opened_doc_json = null; + erts.enif_mutex_unlock(mutex); + const json = taken orelse return erts.atom(env, "none"); + const len = jni.strlen(json); + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + @memcpy(bin.data[0..len], json[0..len]); + jni.free(@as(?*anyopaque, @ptrCast(json))); + return erts.enif_make_binary(env, &bin); +} + +// ── Async result delivery (called from Kotlin via JNI) ─────────────────── +// Each `mob_deliver_*` is invoked by the Kotlin side (after an async +// operation like cameraCapturePhoto completes) with +// the pid encoded as a jlong + the typed result. We rebuild an ErlNifPid +// and ship the appropriate {:tag, payload} message. + +/// `mob_nif_deliver_json` exists for legacy callers in beam_jni.c — it's a +/// no-op. Typed dispatchers below cover the real surface. +pub export fn mob_nif_deliver_json(pid_long: jni.JLong, json_str: [*:0]const u8) callconv(.c) void { + _ = pid_long; + _ = json_str; +} + +pub export fn mob_deliver_atom2(jpid: jni.JLong, a1: [*:0]const u8, a2: [*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, a1), + erts.enif_make_atom(env, a2), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_atom3(jpid: jni.JLong, a1: [*:0]const u8, a2: [*:0]const u8, a3: [*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, a1), + erts.enif_make_atom(env, a2), + erts.enif_make_atom(env, a3), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_motion( + jpid: jni.JLong, + ax: f64, + ay: f64, + az: f64, + gx: f64, + gy: f64, + gz: f64, + ts: i64, +) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const accel = erts.makeTuple(env, .{ + erts.enif_make_double(env, ax), + erts.enif_make_double(env, ay), + erts.enif_make_double(env, az), + }); + const gyro = erts.makeTuple(env, .{ + erts.enif_make_double(env, gx), + erts.enif_make_double(env, gy), + erts.enif_make_double(env, gz), + }); + const keys = [_]erts.ERL_NIF_TERM{ + erts.atom(env, "accel"), + erts.atom(env, "gyro"), + erts.atom(env, "timestamp"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + accel, + gyro, + erts.enif_make_int64(env, ts), + }; + const map = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ erts.atom(env, "motion"), map }); + _ = erts.enif_send(null, &pid, env, msg); +} + +/// Like `mob_deliver_motion` but with the magnetometer field (µT) and a fused +/// heading. Sentinels for "no reading": `heading < 0` and a NaN `mag` component +/// are each delivered as the atom `nil` (RFC: magnetic north, degrees [0,360)). +/// This is the delivery used whenever `:magnetometer` was requested — even on a +/// device with no magnetometer, so the `mag`/`heading` keys are always present +/// (as `nil`) rather than absent. Emits the 5-key `{:motion, _}` map. +pub export fn mob_deliver_motion_mag( + jpid: jni.JLong, + ax: f64, + ay: f64, + az: f64, + gx: f64, + gy: f64, + gz: f64, + mx: f64, + my: f64, + mz: f64, + heading: f64, + ts: i64, +) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const accel = erts.makeTuple(env, .{ + erts.enif_make_double(env, ax), + erts.enif_make_double(env, ay), + erts.enif_make_double(env, az), + }); + const gyro = erts.makeTuple(env, .{ + erts.enif_make_double(env, gx), + erts.enif_make_double(env, gy), + erts.enif_make_double(env, gz), + }); + const mag = if (std.math.isNan(mx) or std.math.isNan(my) or std.math.isNan(mz)) + erts.atom(env, "nil") + else + erts.makeTuple(env, .{ + erts.enif_make_double(env, mx), + erts.enif_make_double(env, my), + erts.enif_make_double(env, mz), + }); + const heading_term = if (heading >= 0.0) + erts.enif_make_double(env, heading) + else + erts.atom(env, "nil"); + const keys = [_]erts.ERL_NIF_TERM{ + erts.atom(env, "accel"), + erts.atom(env, "gyro"), + erts.atom(env, "mag"), + erts.atom(env, "heading"), + erts.atom(env, "timestamp"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + accel, + gyro, + mag, + heading_term, + erts.enif_make_int64(env, ts), + }; + const map = erts.makeMap(env, &keys, &vals) orelse return; + const msg = erts.makeTuple(env, .{ erts.atom(env, "motion"), map }); + _ = erts.enif_send(null, &pid, env, msg); +} + +/// `{:webview, tag, binary}`. When `jpid == 0` the message routes to the +/// :mob_screen registered process; otherwise to the explicit pid. +fn deliverWebviewBinary(jpid: jni.JLong, comptime tag: [:0]const u8, utf8: [*:0]const u8) void { + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + var pid: erts.ErlNifPid = undefined; + if (jpid != 0) { + pid = pidFromLong(jpid); + } else if (erts.enif_whereis_pid(env, erts.atom(env, "mob_screen"), &pid) == 0) { + return; + } + const len = jni.strlen(utf8); + var bin: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &bin); + @memcpy(bin.data[0..len], utf8[0..len]); + const msg = erts.makeTuple(env, .{ + erts.atom(env, "webview"), + erts.atom(env, tag), + erts.enif_make_binary(env, &bin), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_webview_message(jpid: jni.JLong, json: [*:0]const u8) callconv(.c) void { + deliverWebviewBinary(jpid, "message", json); +} + +pub export fn mob_deliver_webview_blocked(jpid: jni.JLong, url: [*:0]const u8) callconv(.c) void { + deliverWebviewBinary(jpid, "blocked", url); +} + +/// `mob_deliver_file_result` — used by camera/photos/files/audio/scanner +/// capture results. Two shapes: +/// * `{event_atom, :cancelled}` when json_items is null OR "cancelled" +/// * `{:mob_file_result, event_bin, sub_bin, json_bin}` otherwise +pub export fn mob_deliver_file_result( + jpid: jni.JLong, + event: [*:0]const u8, + sub: [*:0]const u8, + json_items: ?[*:0]const u8, +) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const cancelled = blk: { + const j = json_items orelse break :blk true; + const span = std.mem.span(j); + break :blk std.mem.eql(u8, span, "cancelled"); + }; + + const msg = if (cancelled) erts.makeTuple(env, .{ + erts.enif_make_atom(env, event), + erts.atom(env, "cancelled"), + }) else build: { + const j = json_items.?; + const jl = jni.strlen(j); + const el = jni.strlen(event); + const sl = jni.strlen(sub); + var jb: erts.ErlNifBinary = undefined; + var eb: erts.ErlNifBinary = undefined; + var sb: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(jl, &jb); + _ = erts.enif_alloc_binary(el, &eb); + _ = erts.enif_alloc_binary(sl, &sb); + @memcpy(jb.data[0..jl], j[0..jl]); + @memcpy(eb.data[0..el], event[0..el]); + @memcpy(sb.data[0..sl], sub[0..sl]); + break :build erts.makeTuple(env, .{ + erts.atom(env, "mob_file_result"), + erts.enif_make_binary(env, &eb), + erts.enif_make_binary(env, &sb), + erts.enif_make_binary(env, &jb), + }); + }; + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_push_token(jpid: jni.JLong, token: [*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const len = jni.strlen(token); + var tb: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &tb); + @memcpy(tb.data[0..len], token[0..len]); + const msg = erts.makeTuple(env, .{ + erts.atom(env, "push_token"), + erts.atom(env, "android"), + erts.enif_make_binary(env, &tb), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_notification(jpid: jni.JLong, json: [*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const len = jni.strlen(json); + var jb: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &jb); + @memcpy(jb.data[0..len], json[0..len]); + const msg = erts.makeTuple(env, .{ + erts.atom(env, "mob_launch_notification"), + erts.enif_make_binary(env, &jb), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +/// `mob_deliver_alert_action` — called from beam_jni.c when a dialog +/// button is tapped. Routes to :mob_screen as {:alert, action_atom}. +pub export fn mob_deliver_alert_action(action: [*:0]const u8) callconv(.c) void { + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + var pid: erts.ErlNifPid = undefined; + if (erts.enif_whereis_pid(env, erts.atom(env, "mob_screen"), &pid) == 0) return; + const msg = erts.makeTuple(env, .{ + erts.atom(env, "alert"), + erts.enif_make_atom(env, action), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Mob.Peripheral.VendorUsb delivery functions ────────────────────────── +// +// Six typed delivery functions, called from beam_jni.c's +// Java_..._MobBridge_nativeDeliverVendorUsb* thunks when Kotlin-side USB +// events fire (enumeration result, permission grant/deny, device opened, +// inbound chunk, write completion, lifecycle events). They build a +// 5-tuple `{:peripheral, :vendor_usb, tag, session, payload}` and post +// it to `pid`. session==-1 → atom :nil; session>=0 → integer. +// +// devices_json / permission_*_json / opened_json carry a JSON binary +// payload that the Elixir side decodes via +// `Mob.VendorUsb.normalize_message/1` (mirrors the :mob_file_result +// JSON-binary precedent for camera/photos/files/audio/scan). + +/// Session integer or :nil atom, depending on whether the Kotlin side +/// knows a session yet. +inline fn vendorUsbSessionTerm(env: ?*erts.ErlNifEnv, session: c_int) erts.ERL_NIF_TERM { + return if (session < 0) erts.atom(env, "nil") else erts.enif_make_int(env, session); +} + +pub export fn mob_deliver_vendor_usb_devices(jpid: jni.JLong, json_array: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const len: usize = if (json_array) |p| jni.strlen(p) else 0; + var jb: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &jb); + if (len > 0) { + if (json_array) |p| @memcpy(jb.data[0..len], p[0..len]); + } + + const msg = erts.makeTuple(env, .{ + erts.atom(env, "peripheral"), + erts.atom(env, "vendor_usb"), + erts.atom(env, "devices_json"), + erts.atom(env, "nil"), + erts.enif_make_binary(env, &jb), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_vendor_usb_permission(jpid: jni.JLong, granted: c_int, device_json: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const len: usize = if (device_json) |p| jni.strlen(p) else 0; + var jb: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &jb); + if (len > 0) { + if (device_json) |p| @memcpy(jb.data[0..len], p[0..len]); + } + + const tag = if (granted != 0) + erts.atom(env, "permission_granted_json") + else + erts.atom(env, "permission_denied_json"); + + const msg = erts.makeTuple(env, .{ + erts.atom(env, "peripheral"), + erts.atom(env, "vendor_usb"), + tag, + erts.atom(env, "nil"), + erts.enif_make_binary(env, &jb), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_vendor_usb_opened(jpid: jni.JLong, session: c_int, device_json: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const len: usize = if (device_json) |p| jni.strlen(p) else 0; + var jb: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(len, &jb); + if (len > 0) { + if (device_json) |p| @memcpy(jb.data[0..len], p[0..len]); + } + + const msg = erts.makeTuple(env, .{ + erts.atom(env, "peripheral"), + erts.atom(env, "vendor_usb"), + erts.atom(env, "opened_json"), + vendorUsbSessionTerm(env, session), + erts.enif_make_binary(env, &jb), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_vendor_usb_data(jpid: jni.JLong, session: c_int, bytes: ?[*]const u8, nbytes: usize) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + var db: erts.ErlNifBinary = undefined; + _ = erts.enif_alloc_binary(nbytes, &db); + if (nbytes > 0) { + if (bytes) |p| @memcpy(db.data[0..nbytes], p[0..nbytes]); + } + + const msg = erts.makeTuple(env, .{ + erts.atom(env, "peripheral"), + erts.atom(env, "vendor_usb"), + erts.atom(env, "data"), + vendorUsbSessionTerm(env, session), + erts.enif_make_binary(env, &db), + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_vendor_usb_write_complete(jpid: jni.JLong, session: c_int, bytes_written: c_int) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const keys = [_]erts.ERL_NIF_TERM{erts.atom(env, "bytes")}; + const vals = [_]erts.ERL_NIF_TERM{erts.enif_make_int(env, bytes_written)}; + const map = erts.makeMap(env, &keys, &vals) orelse return; + + const msg = erts.makeTuple(env, .{ + erts.atom(env, "peripheral"), + erts.atom(env, "vendor_usb"), + erts.atom(env, "write_complete"), + vendorUsbSessionTerm(env, session), + map, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +pub export fn mob_deliver_vendor_usb_event(jpid: jni.JLong, session: c_int, tag: ?[*:0]const u8, reason: ?[*:0]const u8) callconv(.c) void { + var pid = pidFromLong(jpid); + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + + const payload = if (reason) |r| + erts.enif_make_atom(env, r) + else + erts.atom(env, "ok"); + const tag_term = if (tag) |t| + erts.enif_make_atom(env, t) + else + erts.atom(env, "error"); + + const msg = erts.makeTuple(env, .{ + erts.atom(env, "peripheral"), + erts.atom(env, "vendor_usb"), + tag_term, + vendorUsbSessionTerm(env, session), + payload, + }); + _ = erts.enif_send(null, &pid, env, msg); +} + +// ── Capability NIFs (thin shims to Kotlin) ─────────────────────────────── + +export fn nif_request_permission( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var cap_buf: [32]u8 = @splat(0); + _ = erts.enif_get_atom(env, argv[0], &cap_buf, cap_buf.len, erts.ERL_NIF_LATIN1); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.request_permission, pid, jni.asCStr(&cap_buf)); +} + +export fn nif_files_pick( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.files_pick, pid, json); +} + +export fn nif_audio_start_recording( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.audio_start_recording, pid, json); +} + +export fn nif_audio_stop_recording( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.audio_stop_recording); + detachIfAttached(attached); + return erts.ok(env); +} + +// Audio input metering (mic level probe) — MediaRecorder.getMaxAmplitude via the +// Kotlin bridge. The level NIF converts amplitude (0..32767, or -1 when not +// metering) to dBFS and returns {rms, peak} (rms == peak here) or :not_metering. +export fn nif_audio_start_input_metering( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.audio_start_input_metering == null) return erts.atom(env, "unsupported_on_platform"); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.audio_start_input_metering); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_audio_input_level( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.audio_input_level == null) return erts.atom(env, "unsupported_on_platform"); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const amp = jenv.*.CallStaticIntMethod.?(jenv, Bridge.cls, Bridge.audio_input_level); + detachIfAttached(attached); + if (amp < 0) return erts.atom(env, "not_metering"); + const db: f64 = if (amp == 0) + -160.0 + else + 20.0 * std.math.log10(@as(f64, @floatFromInt(amp)) / 32767.0); + return erts.makeTuple(env, .{ erts.enif_make_double(env, db), erts.enif_make_double(env, db) }); +} + +export fn nif_audio_stop_input_metering( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + if (Bridge.audio_stop_input_metering == null) return erts.atom(env, "unsupported_on_platform"); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.audio_stop_input_metering); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_audio_play( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const path_bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const opts_bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + const path = binToCString(path_bin) orelse return erts.atom(env, "error"); + defer freeCString(path); + const opts = binToCString(opts_bin) orelse return erts.atom(env, "error"); + defer freeCString(opts); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr2(env, Bridge.audio_play, pid, path, opts); +} + +export fn nif_audio_play_at( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const path_bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const opts_bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + // `at_wall_ms` arrives as a binary string. The wall-clock-ms-since-1970 + // value doesn't fit in `c_int`, and Mob's Android ERTS build doesn't + // export `enif_get_int64` dynamically. Marshaling as a string sidesteps + // both issues — matches the pattern audio_set_volume uses for floats. + const at_bin = getBinOrIolist(env, argv[2]) orelse return erts.badarg(env); + const path = binToCString(path_bin) orelse return erts.atom(env, "error"); + defer freeCString(path); + const opts = binToCString(opts_bin) orelse return erts.atom(env, "error"); + defer freeCString(opts); + const at_str = binToCString(at_bin) orelse return erts.atom(env, "error"); + defer freeCString(at_str); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr3(env, Bridge.audio_play_at, pid, path, opts, at_str); +} + +export fn nif_audio_stop_playback( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.audio_stop_playback); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_audio_set_volume( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var vol: f64 = 1.0; + _ = erts.enif_get_double(env, argv[0], &vol); + var vol_buf: [32]u8 = @splat(0); + _ = std.fmt.bufPrint(&vol_buf, "{d:.6}", .{vol}) catch {}; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jvol = jni.newStringUTF(jenv, jni.asCStr(&vol_buf)); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.audio_set_volume, jvol); + jni.deleteLocalRef(jenv, jvol); + detachIfAttached(attached); + return erts.ok(env); +} + +/// True if `list` (a list of sensor-name binaries) contains `name`. Used to +/// plumb the requested sensor set through to the Kotlin bridge, which otherwise +/// only sees the interval. +fn motionSensorRequested(env: ?*erts.ErlNifEnv, list: erts.ERL_NIF_TERM, name: []const u8) bool { + var cur = list; + var head: erts.ERL_NIF_TERM = undefined; + var tail: erts.ERL_NIF_TERM = undefined; + while (erts.enif_get_list_cell(env, cur, &head, &tail) != 0) { + var bin: erts.ErlNifBinary = undefined; + if (erts.enif_inspect_binary(env, head, &bin) != 0 and + std.mem.eql(u8, bin.data[0..bin.size], name)) return true; + cur = tail; + } + return false; +} + +export fn nif_motion_start( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var interval_ms: c_int = 100; + _ = erts.enif_get_int(env, argv[1], &interval_ms); + // Encode the sensor request into the spec string the Kotlin bridge parses: + // "<interval>" or "<interval>,magnetometer". Android's motion_start only had + // the interval before, so it registered the magnetometer whenever the + // hardware existed — regardless of what the app asked for. Passing the flag + // lets it honor the request (and keep the plain accel/gyro stream plain). + const want_mag = motionSensorRequested(env, argv[0], "magnetometer"); + var spec_buf: [32]u8 = @splat(0); + if (want_mag) + _ = std.fmt.bufPrint(&spec_buf, "{d},magnetometer", .{interval_ms}) catch {} + else + _ = std.fmt.bufPrint(&spec_buf, "{d}", .{interval_ms}) catch {}; + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.motion_start, pid, jni.asCStr(&spec_buf)); +} + +export fn nif_motion_stop( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.motion_stop); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_storage_dir( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var loc: [32]u8 = @splat(0); + _ = erts.enif_get_atom(env, argv[0], &loc, loc.len, erts.ERL_NIF_LATIN1); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jloc = jni.newStringUTF(jenv, jni.asCStr(&loc)); + const result = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.storage_dir, jloc); + jni.deleteLocalRef(jenv, jloc); + const out = jstringToBinaryTerm(env, jenv, result) orelse erts.atom(env, "nil"); + detachIfAttached(attached); + return out; +} + +export fn nif_storage_save_to_media_store( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const path = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(path); + var type_buf: [16]u8 = @splat(0); + jni.copyZ(&type_buf, "auto"); + _ = erts.enif_get_atom(env, argv[1], &type_buf, type_buf.len, erts.ERL_NIF_LATIN1); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr2(env, Bridge.storage_save_to_media_store, pid, path, jni.asCStr(&type_buf)); +} + +export fn nif_storage_external_files_dir( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var type_buf: [32]u8 = @splat(0); + _ = erts.enif_get_atom(env, argv[0], &type_buf, type_buf.len, erts.ERL_NIF_LATIN1); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtype = jni.newStringUTF(jenv, jni.asCStr(&type_buf)); + const result = jenv.*.CallStaticObjectMethod.?(jenv, Bridge.cls, Bridge.storage_external_files_dir, jtype); + jni.deleteLocalRef(jenv, jtype); + const out = jstringToBinaryTerm(env, jenv, result) orelse erts.atom(env, "nil"); + detachIfAttached(attached); + return out; +} + +/// iOS-only — Android has no equivalent. Returns `{:error, :not_supported}`. +export fn nif_storage_save_to_photo_library( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + return erts.errorTuple(env, erts.atom(env, "not_supported")); +} + +// ── Alert / action sheet / toast ───────────────────────────────────────── + +export fn nif_alert_show( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const title_bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const msg_bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + const btns_bin = getBinOrIolist(env, argv[2]) orelse return erts.badarg(env); + + const title = binToCString(title_bin) orelse return erts.atom(env, "error"); + defer freeCString(title); + const message = binToCString(msg_bin) orelse return erts.atom(env, "error"); + defer freeCString(message); + const btns = binToCString(btns_bin) orelse return erts.atom(env, "error"); + defer freeCString(btns); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtitle = jni.newStringUTF(jenv, title); + const jmessage = jni.newStringUTF(jenv, message); + const jbtns = jni.newStringUTF(jenv, btns); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.alert_show, jtitle, jmessage, jbtns); + jni.deleteLocalRef(jenv, jtitle); + jni.deleteLocalRef(jenv, jmessage); + jni.deleteLocalRef(jenv, jbtns); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_action_sheet_show( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const title_bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const btns_bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + const title = binToCString(title_bin) orelse return erts.atom(env, "error"); + defer freeCString(title); + const btns = binToCString(btns_bin) orelse return erts.atom(env, "error"); + defer freeCString(btns); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jtitle = jni.newStringUTF(jenv, title); + const jbtns = jni.newStringUTF(jenv, btns); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.action_sheet_show, jtitle, jbtns); + jni.deleteLocalRef(jenv, jtitle); + jni.deleteLocalRef(jenv, jbtns); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_toast_show( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const msg_bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + var dur: [8]u8 = @splat(0); + jni.copyZ(&dur, "short"); + _ = erts.enif_get_atom(env, argv[1], &dur, dur.len, erts.ERL_NIF_LATIN1); + const msg = binToCString(msg_bin) orelse return erts.atom(env, "error"); + defer freeCString(msg); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jmsg = jni.newStringUTF(jenv, msg); + const jdur = jni.newStringUTF(jenv, jni.asCStr(&dur)); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.toast_show, jmsg, jdur); + jni.deleteLocalRef(jenv, jmsg); + jni.deleteLocalRef(jenv, jdur); + detachIfAttached(attached); + return erts.ok(env); +} + +// ── WebView ────────────────────────────────────────────────────────────── + +export fn nif_webview_eval_js( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const code = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(code); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jcode = jni.newStringUTF(jenv, code); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.webview_eval_js, jcode); + jni.deleteLocalRef(jenv, jcode); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_webview_post_message( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + const jjson = jni.newStringUTF(jenv, json); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.webview_post_message, jjson); + jni.deleteLocalRef(jenv, jjson); + detachIfAttached(attached); + return erts.ok(env); +} + +export fn nif_webview_can_go_back( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "false"); + const result = jenv.*.CallStaticBooleanMethod.?(jenv, Bridge.cls, Bridge.webview_can_go_back); + detachIfAttached(attached); + return if (result != 0) erts.atom(env, "true") else erts.atom(env, "false"); +} + +export fn nif_webview_go_back( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.webview_go_back); + detachIfAttached(attached); + return erts.ok(env); +} + +// ── Mob.Device — lifecycle events + queries ────────────────────────────── +// Android implementation is partial — only `:appearance` (color scheme +// changes from MainActivity.onConfigurationChanged) is wired today. The +// rest (battery, thermal, lifecycle) is queued behind ProcessLifecycleOwner +// + ComponentCallbacks2 plumbing. Until then the dispatcher pid is stored +// so what IS wired (color scheme) can deliver, and the query NIFs return +// reasonable defaults. + +var g_device_dispatcher_pid: erts.ErlNifPid = .{ .pid = 0 }; +var g_device_dispatcher_set: bool = false; + +fn deviceSendAtomPayload(comptime tag: [:0]const u8, atom_name: [*:0]const u8, payload_atom_str: [*:0]const u8) void { + if (!g_device_dispatcher_set) return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const msg = erts.makeTuple(env, .{ + erts.atom(env, tag), + erts.enif_make_atom(env, atom_name), + erts.enif_make_atom(env, payload_atom_str), + }); + var pid = g_device_dispatcher_pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +/// Called from beam_jni.c's `Java_..._MobBridge_nativeNotifyColorScheme` +/// when MainActivity.onConfigurationChanged sees a uiMode flip. `scheme` +/// must be "light" or "dark". +pub export fn mob_send_color_scheme_changed(scheme: ?[*:0]const u8) callconv(.c) void { + const s = scheme orelse return; + deviceSendAtomPayload("mob_device", "color_scheme_changed", s); +} + +// Last-known interface orientation, updated by mob_send_orientation_changed. +// device_orientation/0 returns this (a partial getter, like the other Android +// device queries — accurate after the first onConfigurationChanged). +var g_last_orientation: [*:0]const u8 = "portrait"; + +/// Called from beam_jni.c's `Java_..._MobBridge_nativeNotifyOrientation` when +/// MainActivity.onConfigurationChanged sees an orientation flip. `orient` is +/// one of "portrait" | "landscape_left" | "landscape_right" | +/// "portrait_upside_down". (Companion hook ships in the mob_new template.) +pub export fn mob_send_orientation_changed(orient: ?[*:0]const u8) callconv(.c) void { + const o = orient orelse return; + g_last_orientation = o; + deviceSendAtomPayload("mob_device", "orientation_changed", o); +} + +// ── Network connectivity ───────────────────────────────────────────────────── +// +// Last-known connectivity, updated by mob_send_connectivity_changed (pushed +// from the template's ConnectivityManager.NetworkCallback via beam_jni.c). +// device_network_state/0 returns this; defaults to offline until the first +// callback fires (which happens on registration at app start). +// Transport is cached as an int code, NOT the incoming string pointer: the +// JNI string from beam_jni.c is released as soon as the trampoline returns, so +// storing that pointer would dangle and the query would read freed memory. The +// atom is always derived from a static literal (mirrors the iOS int-code path). +// 0 none, 1 wifi, 2 cellular, 3 wired, 4 other. +// Atomic to match the iOS half of this feature: written on the JNI callback +// thread, read on a BEAM scheduler thread in nif_device_network_state. Monotonic +// is sufficient — the fields are independent and the snapshot is +// eventually-consistent. +var g_net_online = std.atomic.Value(bool).init(false); +var g_net_transport = std.atomic.Value(c_int).init(0); +var g_net_expensive = std.atomic.Value(bool).init(false); +var g_net_validated = std.atomic.Value(bool).init(false); + +fn boolAtomName(b: bool) [*:0]const u8 { + return if (b) "true" else "false"; +} + +fn transportCode(name: [*:0]const u8) c_int { + const s = std.mem.sliceTo(name, 0); + if (std.mem.eql(u8, s, "wifi")) return 1; + if (std.mem.eql(u8, s, "cellular")) return 2; + if (std.mem.eql(u8, s, "wired")) return 3; + if (std.mem.eql(u8, s, "other")) return 4; + return 0; +} + +fn transportAtomName(code: c_int) [*:0]const u8 { + return switch (code) { + 1 => "wifi", + 2 => "cellular", + 3 => "wired", + 4 => "other", + else => "none", + }; +} + +// Builds %{online, transport, expensive, validated, constrained}. `constrained` +// is always :unavailable on Android — NetworkCapabilities has no per-network +// Low-Data-Mode equivalent (iOS nw_path_is_constrained). `validated` is +// Android's real-internet-reachability probe (NET_CAPABILITY_VALIDATED). +fn networkStateMap( + env: ?*erts.ErlNifEnv, + online: bool, + transport_code: c_int, + expensive: bool, + validated: bool, +) erts.ERL_NIF_TERM { + const keys = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, "online"), + erts.enif_make_atom(env, "transport"), + erts.enif_make_atom(env, "expensive"), + erts.enif_make_atom(env, "validated"), + erts.enif_make_atom(env, "constrained"), + }; + const vals = [_]erts.ERL_NIF_TERM{ + erts.enif_make_atom(env, boolAtomName(online)), + erts.enif_make_atom(env, transportAtomName(transport_code)), + erts.enif_make_atom(env, boolAtomName(expensive)), + erts.enif_make_atom(env, boolAtomName(validated)), + erts.enif_make_atom(env, "unavailable"), + }; + return erts.makeMap(env, &keys, &vals) orelse erts.enif_make_atom(env, "nil"); +} + +/// Called from beam_jni.c's `Java_..._MobBridge_nativeNotifyConnectivity` when +/// the template's ConnectivityManager.NetworkCallback fires. `online`/`expensive`/ +/// `validated` are 0/1; `transport` is "wifi" | "cellular" | "wired" | "other" | +/// "none". (Companion Kotlin hook ships in the mob_new template.) +pub export fn mob_send_connectivity_changed( + online: c_int, + transport: ?[*:0]const u8, + expensive: c_int, + validated: c_int, +) callconv(.c) void { + const new_online = online != 0; + const new_transport = transportCode(transport orelse "none"); + const new_expensive = expensive != 0; + const new_validated = validated != 0; + // Only emit when the exposed snapshot changed; onCapabilitiesChanged also + // fires on bandwidth/signal updates. Cache is refreshed either way so the + // synchronous query stays current. + const changed = new_online != g_net_online.load(.monotonic) or + new_transport != g_net_transport.load(.monotonic) or + new_expensive != g_net_expensive.load(.monotonic) or + new_validated != g_net_validated.load(.monotonic); + g_net_online.store(new_online, .monotonic); + g_net_transport.store(new_transport, .monotonic); + g_net_expensive.store(new_expensive, .monotonic); + g_net_validated.store(new_validated, .monotonic); + if (!changed) return; + if (!g_device_dispatcher_set) return; + const env = erts.enif_alloc_env() orelse return; + defer erts.enif_free_env(env); + const payload = networkStateMap(env, new_online, new_transport, new_expensive, new_validated); + const msg = erts.makeTuple(env, .{ + erts.enif_make_atom(env, "mob_device"), + erts.enif_make_atom(env, "connectivity_changed"), + payload, + }); + var pid = g_device_dispatcher_pid; + _ = erts.enif_send(null, &pid, env, msg); +} + +// Map a lock atom (+ :unspecified for unlock) to an Android +// ActivityInfo.SCREEN_ORIENTATION_* constant. +fn androidOrientationConst(name: []const u8) c_int { + if (std.mem.eql(u8, name, "portrait")) return 1; // PORTRAIT + if (std.mem.eql(u8, name, "portrait_upside_down")) return 9; // REVERSE_PORTRAIT + if (std.mem.eql(u8, name, "landscape")) return 6; // SENSOR_LANDSCAPE (either side) + if (std.mem.eql(u8, name, "landscape_left")) return 0; // LANDSCAPE + if (std.mem.eql(u8, name, "landscape_right")) return 8; // REVERSE_LANDSCAPE + return -1; // UNSPECIFIED -> unlock +} + +// nif_device_keep_awake/1 — pass the boolean atom `true`/`false` to +// MobBridge.keepAwake(Int) (1 = keep on). No-op if the app's bridge predates +// the method (cacheOptional + null guard). +export fn nif_device_keep_awake( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var buf: [8]u8 = @splat(0); + _ = erts.enif_get_atom(env, argv[0], &buf, buf.len, erts.ERL_NIF_LATIN1); + const on: jni.JInt = if (std.mem.eql(u8, std.mem.sliceTo(&buf, 0), "true")) 1 else 0; + + if (Bridge.keep_awake == null) return notLoaded(env); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.keep_awake, on); + return erts.ok(env); +} + +export fn nif_device_set_dispatcher( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var pid: erts.ErlNifPid = undefined; + if (erts.enif_get_local_pid(env, argv[0], &pid) == 0) return erts.badarg(env); + g_device_dispatcher_pid = pid; + g_device_dispatcher_set = true; + return erts.ok(env); +} + +export fn nif_device_battery_state( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // TODO(android): query BatteryManager. For now, unknown / -1. + return erts.makeTuple(env, .{ + erts.atom(env, "unknown"), + erts.enif_make_int(env, -1), + }); +} + +export fn nif_device_thermal_state( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // TODO(android): PowerManager.getCurrentThermalStatus() (API 29+). + return erts.atom(env, "nominal"); +} + +export fn nif_device_network_state( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // Partial getter (like the other Android device queries): returns the last + // snapshot pushed by mob_send_connectivity_changed; offline until then. + return networkStateMap( + env, + g_net_online.load(.monotonic), + g_net_transport.load(.monotonic), + g_net_expensive.load(.monotonic), + g_net_validated.load(.monotonic), + ); +} + +export fn nif_device_low_power_mode( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // TODO(android): PowerManager.isPowerSaveMode(). + return erts.atom(env, "false"); +} + +export fn nif_device_foreground( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // TODO(android): track via ProcessLifecycleOwner. + return erts.atom(env, "true"); +} + +export fn nif_device_os_version( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // TODO(android): Build.VERSION.RELEASE via JNI. + return erts.enif_make_string(env, "", erts.ERL_NIF_LATIN1); +} + +export fn nif_device_model( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // TODO(android): Build.MODEL via JNI. + return erts.enif_make_string(env, "Android", erts.ERL_NIF_LATIN1); +} + +export fn nif_device_orientation( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + _ = argv; + // Partial getter (like the other Android device queries): returns the last + // orientation reported via onConfigurationChanged; "portrait" until then. + return erts.enif_make_atom(env, g_last_orientation); +} + +export fn nif_device_lock_orientation( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + var buf: [32]u8 = undefined; + if (erts.enif_get_atom(env, argv[0], &buf, buf.len, erts.ERL_NIF_LATIN1) == 0) + return erts.badarg(env); + const code = androidOrientationConst(std.mem.sliceTo(&buf, 0)); + + if (Bridge.orientation_lock == null) return notLoaded(env); + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + jenv.*.CallStaticVoidMethod.?(jenv, Bridge.cls, Bridge.orientation_lock, @as(jni.JInt, code)); + return erts.ok(env); +} + +// ── Mob.Peripheral.VendorUsb NIFs ──────────────────────────────────────── +// +// Thin wrappers over MobBridge's @JvmStatic vendor_usb_* methods. Each +// runs on the caller's BEAM scheduler, dispatches to Kotlin via the +// cached jmethodID, and returns :ok. Results (devices listed, permission +// granted, read chunks, etc.) flow back asynchronously via the +// mob_deliver_vendor_usb_* exports above, which Kotlin invokes from its +// USB receiver / reader thread through the beam_jni.c thunks. +// +// bulk_write is marked DIRTY_IO in the NIF table because it does a +// blocking copy of up to 16 KiB into a Java byte[] + a synchronous +// Kotlin static call that ends up in UsbDeviceConnection.bulkTransfer. + +/// Sentinel-style guard: if `method` is null (MobBridge.kt doesn't have +/// the matching vendor_usb_* @JvmStatic), send a single +/// `{:peripheral, :vendor_usb, :error, nil, :unsupported}` to the caller +/// and short-circuit the NIF with :ok. Mirrors the iOS stub behaviour so +/// downstream code paths look the same whether the user is on iOS or an +/// Android app generated from an older mob_new template. +fn vendorUsbUnsupported(env: ?*erts.ErlNifEnv) erts.ERL_NIF_TERM { + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + const msg_env = erts.enif_alloc_env() orelse return erts.ok(env); + defer erts.enif_free_env(msg_env); + const msg = erts.makeTuple(msg_env, .{ + erts.atom(msg_env, "peripheral"), + erts.atom(msg_env, "vendor_usb"), + erts.atom(msg_env, "error"), + erts.atom(msg_env, "nil"), + erts.atom(msg_env, "unsupported"), + }); + _ = erts.enif_send(null, &pid, msg_env, msg); + return erts.ok(env); +} + +export fn nif_vendor_usb_list_devices( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_list_devices == null) return vendorUsbUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.vendor_usb_list_devices, pid, json); +} + +export fn nif_vendor_usb_request_permission( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_request_permission == null) return vendorUsbUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const ref = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(ref); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.vendor_usb_request_permission, pid, ref); +} + +export fn nif_vendor_usb_open( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_open == null) return vendorUsbUnsupported(env); + const bin = getBinOrIolist(env, argv[0]) orelse return erts.badarg(env); + const json = binToCString(bin) orelse return erts.atom(env, "error"); + defer freeCString(json); + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + return callBridgePidStr(env, Bridge.vendor_usb_open, pid, json); +} + +export fn nif_vendor_usb_bulk_write( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_bulk_write == null) return vendorUsbUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + const bin = getBinOrIolist(env, argv[1]) orelse return erts.badarg(env); + var timeout_ms: c_int = 1000; + if (erts.enif_get_int(env, argv[2], &timeout_ms) == 0) return erts.badarg(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + // Copy the bytes into a fresh Java `byte[]` so Kotlin can hand it to + // UsbDeviceConnection.bulkTransfer without re-resolving the BEAM + // binary. SetByteArrayRegion is a straight memcpy; the byte[] + // outlives this NIF call but Kotlin will let it GC once the bulk + // transfer returns. + const size: jni.JSize = @intCast(bin.size); + const jbytes = jni.newByteArray(jenv, size); + if (jbytes != null) { + // BEAM stores binary contents as unsigned bytes; JNI's byte[] is + // signed (jbyte = int8_t). A bit-for-bit copy is fine — the + // signed/unsigned distinction is irrelevant for bulk I/O bytes. + jni.setByteArrayRegion(jenv, jbytes, 0, size, @ptrCast(bin.data)); + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.vendor_usb_bulk_write, + pidToJlong(pid), + @as(jni.JInt, session), + jbytes, + @as(jni.JInt, timeout_ms), + ); + jni.deleteLocalRef(jenv, jbytes); + } + return erts.ok(env); +} + +export fn nif_vendor_usb_start_reading( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_start_reading == null) return vendorUsbUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + var chunk_bytes: c_int = 4096; + if (erts.enif_get_int(env, argv[1], &chunk_bytes) == 0) return erts.badarg(env); + + var pid: erts.ErlNifPid = undefined; + _ = erts.enif_self(env, &pid); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.vendor_usb_start_reading, + pidToJlong(pid), + @as(jni.JInt, session), + @as(jni.JInt, chunk_bytes), + ); + return erts.ok(env); +} + +export fn nif_vendor_usb_stop_reading( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_stop_reading == null) return vendorUsbUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.vendor_usb_stop_reading, + @as(jni.JInt, session), + ); + return erts.ok(env); +} + +export fn nif_vendor_usb_close( + env: ?*erts.ErlNifEnv, + argc: c_int, + argv: [*]const erts.ERL_NIF_TERM, +) callconv(.c) erts.ERL_NIF_TERM { + _ = argc; + if (Bridge.vendor_usb_close == null) return vendorUsbUnsupported(env); + var session: c_int = 0; + if (erts.enif_get_int(env, argv[0], &session) == 0) return erts.badarg(env); + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse return erts.atom(env, "error"); + defer detachIfAttached(attached); + + jenv.*.CallStaticVoidMethod.?( + jenv, + Bridge.cls, + Bridge.vendor_usb_close, + @as(jni.JInt, session), + ); + return erts.ok(env); +} + +// ── nif_load: cache all method IDs at BEAM startup ─────────────────────── + +/// Required-method helper. Returns false if the method isn't on the +/// Kotlin side — caller turns that into a `return -1` from nif_load. +inline fn cacheRequired(jenv: *jni.JNIEnv, name: [*:0]const u8, sig: [*:0]const u8, field: *jni.JMethodID) bool { + field.* = jni.getStaticMethodID(jenv, Bridge.cls, name, sig); + if (field.* == null) { + loge_nif("nif_load: {s} not found", .{name}); + return false; + } + return true; +} + +/// Optional-method helper. Clears any JNI exception and logs at INFO. +inline fn cacheOptional(jenv: *jni.JNIEnv, name: [*:0]const u8, sig: [*:0]const u8, field: *jni.JMethodID) void { + field.* = jni.getStaticMethodID(jenv, Bridge.cls, name, sig); + if (field.* == null) { + jni.exceptionClear(jenv); + logi_nif("nif_load: {s} not found (optional)", .{name}); + } +} + +fn nifLoad(env: ?*erts.ErlNifEnv, priv: *?*anyopaque, info: erts.ERL_NIF_TERM) callconv(.c) c_int { + _ = env; + _ = priv; + _ = info; + logi_nif("nif_load: entered, Bridge.cls={any}", .{Bridge.cls}); + if (Bridge.cls == null) { + loge_nif("Bridge.cls not cached — was mob_ui_cache_class called?", .{}); + return -1; + } + + // tap_mutex + component_mutex are created here (mob_nif_init_state is + // a Zig-side export, but for the all-Zig finale we just call the + // initialiser directly — no C boundary to cross). + if (mob_nif_init_state() != 0) { + loge_nif("nif_load: mob_nif_init_state failed (mutex create)", .{}); + return -1; + } + + var attached: c_int = 0; + const jenv = get_jenv(&attached) orelse { + loge_nif("nif_load: get_jenv returned null", .{}); + return -1; + }; + defer detachIfAttached(attached); + + if (!cacheRequired(jenv, "setRootJson", "(Ljava/lang/String;Ljava/lang/String;)V", &Bridge.set_root)) return -1; + if (!cacheRequired(jenv, "moveToBack", "()V", &Bridge.move_to_back)) return -1; + if (!cacheRequired(jenv, "getSafeArea", "()[F", &Bridge.get_safe_area)) return -1; + + // getColorScheme() is optional — apps that haven't been regenerated + // since it was added still load fine; nif_color_scheme falls back to + // :light. + cacheOptional(jenv, "getColorScheme", "()Ljava/lang/String;", &Bridge.get_color_scheme); + + // setTheme() is optional — older MobBridge templates predate the + // BEAM-driven theme push. When absent, nif_set_theme just returns :ok + // and the host's MaterialTheme stays at whatever the app code set. + cacheOptional(jenv, "setTheme", "(Ljava/lang/String;)V", &Bridge.set_theme); + + if (!cacheRequired(jenv, "haptic", "(Ljava/lang/String;)V", &Bridge.haptic)) return -1; + if (!cacheRequired(jenv, "torch", "(Ljava/lang/String;)V", &Bridge.torch)) return -1; + if (!cacheRequired(jenv, "clipboardPut", "(Ljava/lang/String;)V", &Bridge.clipboard_put)) return -1; + if (!cacheRequired(jenv, "clipboardGet", "()Ljava/lang/String;", &Bridge.clipboard_get)) return -1; + // Optional: apps generated before TTS existed lack these MobBridge methods. + cacheOptional(jenv, "ttsSpeak", "(Ljava/lang/String;Ljava/lang/String;)V", &Bridge.tts_speak); + cacheOptional(jenv, "ttsStop", "()V", &Bridge.tts_stop); + if (!cacheRequired(jenv, "shareText", "(Ljava/lang/String;)V", &Bridge.share_text)) return -1; + if (!cacheRequired(jenv, "openUrl", "(Ljava/lang/String;)V", &Bridge.open_url)) return -1; + cacheOptional(jenv, "openSettings", "(Ljava/lang/String;)V", &Bridge.open_settings); + + // Async device-capability methods. Most take (J, String) where J is + // the pid as a long. + if (!cacheRequired(jenv, "request_permission", "(JLjava/lang/String;)V", &Bridge.request_permission)) return -1; + if (!cacheRequired(jenv, "files_pick", "(JLjava/lang/String;)V", &Bridge.files_pick)) return -1; + if (!cacheRequired(jenv, "audio_start_recording", "(JLjava/lang/String;)V", &Bridge.audio_start_recording)) return -1; + if (!cacheRequired(jenv, "audio_stop_recording", "()V", &Bridge.audio_stop_recording)) return -1; + // Optional: input-level metering ships in a separate mob_new bridge + // template (mob_new#31). Cache-optional so core can merge independently + // of the template and a stale/drifted MobBridge degrades to + // :unsupported_on_platform at call time instead of crashing nif_load. + cacheOptional(jenv, "audio_start_input_metering", "()V", &Bridge.audio_start_input_metering); + cacheOptional(jenv, "audio_input_level", "()I", &Bridge.audio_input_level); + cacheOptional(jenv, "audio_stop_input_metering", "()V", &Bridge.audio_stop_input_metering); + if (!cacheRequired(jenv, "audio_play", "(JLjava/lang/String;Ljava/lang/String;)V", &Bridge.audio_play)) return -1; + if (!cacheRequired(jenv, "audio_play_at", "(JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", &Bridge.audio_play_at)) return -1; + if (!cacheRequired(jenv, "audio_stop_playback", "()V", &Bridge.audio_stop_playback)) return -1; + if (!cacheRequired(jenv, "audio_set_volume", "(Ljava/lang/String;)V", &Bridge.audio_set_volume)) return -1; + // Output probes are optional so a drifted MobBridge.kt that predates them + // no-ops (NIF returns an error atom) instead of failing nif_load. + cacheOptional(jenv, "audioOutputStatus", "()[F", &Bridge.audio_output_status); + cacheOptional(jenv, "audioOutputLevel", "(Ljava/lang/String;)[F", &Bridge.audio_output_level); + if (!cacheRequired(jenv, "storage_dir", "(Ljava/lang/String;)Ljava/lang/String;", &Bridge.storage_dir)) return -1; + if (!cacheRequired(jenv, "storage_save_to_media_store", "(JLjava/lang/String;Ljava/lang/String;)V", &Bridge.storage_save_to_media_store)) return -1; + if (!cacheRequired(jenv, "storage_external_files_dir", "(Ljava/lang/String;)Ljava/lang/String;", &Bridge.storage_external_files_dir)) return -1; + if (!cacheRequired(jenv, "alert_show", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", &Bridge.alert_show)) return -1; + if (!cacheRequired(jenv, "action_sheet_show", "(Ljava/lang/String;Ljava/lang/String;)V", &Bridge.action_sheet_show)) return -1; + if (!cacheRequired(jenv, "toast_show", "(Ljava/lang/String;Ljava/lang/String;)V", &Bridge.toast_show)) return -1; + if (!cacheRequired(jenv, "webview_eval_js", "(Ljava/lang/String;)V", &Bridge.webview_eval_js)) return -1; + if (!cacheRequired(jenv, "webview_post_message", "(Ljava/lang/String;)V", &Bridge.webview_post_message)) return -1; + if (!cacheRequired(jenv, "webview_can_go_back", "()Z", &Bridge.webview_can_go_back)) return -1; + if (!cacheRequired(jenv, "webview_go_back", "()V", &Bridge.webview_go_back)) return -1; + if (!cacheRequired(jenv, "motion_start", "(JLjava/lang/String;)V", &Bridge.motion_start)) return -1; + if (!cacheRequired(jenv, "motion_stop", "()V", &Bridge.motion_stop)) return -1; + + // Mob.Peripheral.VendorUsb. Optional rather than required so apps + // generated from an older `mob_new` template (without the matching + // MobBridge.kt vendor_usb block from mob_new#2) still load — every + // vendor_usb NIF below short-circuits with `:unsupported` when the + // matching methodID is null. The user-visible effect on a stale + // app is "call returns :ok but you get a single :error event with + // reason :unsupported", which mirrors the iOS stubs' behaviour. + cacheOptional(jenv, "vendor_usb_list_devices", "(JLjava/lang/String;)V", &Bridge.vendor_usb_list_devices); + cacheOptional(jenv, "vendor_usb_request_permission", "(JLjava/lang/String;)V", &Bridge.vendor_usb_request_permission); + cacheOptional(jenv, "vendor_usb_open", "(JLjava/lang/String;)V", &Bridge.vendor_usb_open); + cacheOptional(jenv, "vendor_usb_bulk_write", "(JI[BI)V", &Bridge.vendor_usb_bulk_write); + cacheOptional(jenv, "vendor_usb_start_reading", "(JII)V", &Bridge.vendor_usb_start_reading); + cacheOptional(jenv, "vendor_usb_stop_reading", "(I)V", &Bridge.vendor_usb_stop_reading); + cacheOptional(jenv, "vendor_usb_close", "(I)V", &Bridge.vendor_usb_close); + + // ── Mob.Bt (Bluetooth Classic) ─────────────────────────────────────── + // Extracted to the `mob_bluetooth` plugin (tier-1). The bt method-id cache, + // atom cache, paired-list accumulator, NIFs, and delivery exports now live + // in the plugin's own zig NIF + bridge class; core no longer knows about bt. + + g_launch_notif_mutex = erts.enif_mutex_create("mob_launch_notif_mutex"); + if (g_launch_notif_mutex == null) { + loge_nif("nif_load: failed to create launch notif mutex", .{}); + return -1; + } + + g_opened_doc_mutex = erts.enif_mutex_create("mob_opened_doc_mutex"); + if (g_opened_doc_mutex == null) { + loge_nif("nif_load: failed to create opened doc mutex", .{}); + return -1; + } + + // Test harness method IDs — optional. Apps without the harness build + // (release variants, downstream consumers that don't link it) won't + // have these and that's fine; the test NIFs return :not_loaded. + cacheOptional(jenv, "uiTree", "()Ljava/lang/String;", &Bridge.ui_tree); + cacheOptional(jenv, "uiViewTree", "()Ljava/lang/String;", &Bridge.ui_view_tree); + cacheOptional(jenv, "screenInfo", "()[F", &Bridge.screen_info); + cacheOptional(jenv, "screenshot", "(Ljava/lang/String;ID)[B", &Bridge.screenshot); + cacheOptional(jenv, "scrollInfo", "(Ljava/lang/String;)Ljava/lang/String;", &Bridge.scroll_info); + cacheOptional(jenv, "orientationLock", "(I)V", &Bridge.orientation_lock); + cacheOptional(jenv, "keepAwake", "(I)V", &Bridge.keep_awake); + cacheOptional(jenv, "scrollTo", "(Ljava/lang/String;DD)Z", &Bridge.scroll_to); + cacheOptional(jenv, "elementFrames", "()Ljava/lang/String;", &Bridge.element_frames); + cacheOptional(jenv, "tapXy", "(FF)Z", &Bridge.tap_xy); + cacheOptional(jenv, "tapByLabel", "(Ljava/lang/String;)Z", &Bridge.tap_by_label); + cacheOptional(jenv, "typeText", "(Ljava/lang/String;)Z", &Bridge.type_text); + cacheOptional(jenv, "deleteBackward", "()Z", &Bridge.delete_backward); + cacheOptional(jenv, "clearText", "()Z", &Bridge.clear_text); + cacheOptional(jenv, "longPressXy", "(FFJ)Z", &Bridge.long_press_xy); + cacheOptional(jenv, "swipeXy", "(FFFF)Z", &Bridge.swipe_xy); + + logi_nif("Mob NIF loaded (Compose backend)", .{}); + return 0; +} + +// ── NIF table + ERL_NIF_INIT entry point ───────────────────────────────── +// Replaces the static `ErlNifFunc nif_funcs[]` + `ERL_NIF_INIT` macro +// that used to live at the bottom of mob_nif.c. The entry point is the +// `<MODNAME>_nif_init` symbol the BEAM looks up from the driver_tab — +// driver_tab_android.zig already extern-declares `mob_nif_nif_init` for +// the static-NIF link path. + +const nif_funcs = [_]erts.ErlNifFunc{ + // Test harness first — matches the iOS nif_funcs[] ordering convention. + .{ .name = "ui_tree", .arity = 0, .fptr = nif_ui_tree, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, + .{ .name = "ui_view_tree", .arity = 0, .fptr = nif_ui_view_tree, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, + .{ .name = "ax_action", .arity = 2, .fptr = nif_ax_action, .flags = 0 }, + .{ .name = "ax_action_at_xy", .arity = 3, .fptr = nif_ax_action_at_xy, .flags = 0 }, + .{ .name = "ui_debug", .arity = 0, .fptr = nif_ui_debug, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, + .{ .name = "screen_info", .arity = 0, .fptr = nif_screen_info, .flags = 0 }, + .{ .name = "tap", .arity = 1, .fptr = nif_tap, .flags = 0 }, + .{ .name = "tap_xy", .arity = 2, .fptr = nif_tap_xy, .flags = 0 }, + .{ .name = "type_text", .arity = 1, .fptr = nif_type_text, .flags = 0 }, + .{ .name = "delete_backward", .arity = 0, .fptr = nif_delete_backward, .flags = 0 }, + .{ .name = "key_press", .arity = 1, .fptr = nif_key_press, .flags = 0 }, + .{ .name = "clear_text", .arity = 0, .fptr = nif_clear_text, .flags = 0 }, + .{ .name = "long_press_xy", .arity = 3, .fptr = nif_long_press_xy, .flags = 0 }, + .{ .name = "swipe_xy", .arity = 4, .fptr = nif_swipe_xy, .flags = 0 }, + .{ .name = "screenshot", .arity = 3, .fptr = nif_screenshot, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, + .{ .name = "scroll_info", .arity = 1, .fptr = nif_scroll_info, .flags = 0 }, + .{ .name = "scroll_to", .arity = 3, .fptr = nif_scroll_to, .flags = 0 }, + .{ .name = "element_frames", .arity = 0, .fptr = nif_element_frames, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, + // Core mob functions. + .{ .name = "platform", .arity = 0, .fptr = nif_platform, .flags = 0 }, + .{ .name = "color_scheme", .arity = 0, .fptr = nif_color_scheme, .flags = 0 }, + .{ .name = "log", .arity = 1, .fptr = nif_log, .flags = 0 }, + .{ .name = "log", .arity = 2, .fptr = nif_log2, .flags = 0 }, + .{ .name = "set_transition", .arity = 1, .fptr = nif_set_transition, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, + .{ .name = "set_root", .arity = 1, .fptr = nif_set_root, .flags = erts.ERL_NIF_DIRTY_JOB_CPU_BOUND }, + .{ .name = "set_theme", .arity = 1, .fptr = nif_set_theme, .flags = 0 }, + .{ .name = "register_tap", .arity = 1, .fptr = nif_register_tap, .flags = 0 }, + .{ .name = "clear_taps", .arity = 0, .fptr = nif_clear_taps, .flags = 0 }, + .{ .name = "exit_app", .arity = 0, .fptr = nif_exit_app, .flags = 0 }, + .{ .name = "safe_area", .arity = 0, .fptr = nif_safe_area, .flags = 0 }, + .{ .name = "haptic", .arity = 1, .fptr = nif_haptic, .flags = 0 }, + .{ .name = "torch", .arity = 1, .fptr = nif_torch, .flags = 0 }, + .{ .name = "clipboard_put", .arity = 1, .fptr = nif_clipboard_put, .flags = 0 }, + .{ .name = "clipboard_get", .arity = 0, .fptr = nif_clipboard_get, .flags = 0 }, + .{ .name = "tts_speak", .arity = 2, .fptr = nif_tts_speak, .flags = 0 }, + .{ .name = "tts_stop", .arity = 0, .fptr = nif_tts_stop, .flags = 0 }, + .{ .name = "share_text", .arity = 1, .fptr = nif_share_text, .flags = 0 }, + .{ .name = "open_url", .arity = 1, .fptr = nif_open_url, .flags = 0 }, + .{ .name = "open_settings", .arity = 1, .fptr = nif_open_settings, .flags = 0 }, + .{ .name = "request_permission", .arity = 1, .fptr = nif_request_permission, .flags = 0 }, + .{ .name = "files_pick", .arity = 1, .fptr = nif_files_pick, .flags = 0 }, + .{ .name = "audio_start_recording", .arity = 1, .fptr = nif_audio_start_recording, .flags = 0 }, + .{ .name = "audio_stop_recording", .arity = 0, .fptr = nif_audio_stop_recording, .flags = 0 }, + .{ .name = "audio_start_input_metering", .arity = 0, .fptr = nif_audio_start_input_metering, .flags = 0 }, + .{ .name = "audio_input_level", .arity = 0, .fptr = nif_audio_input_level, .flags = 0 }, + .{ .name = "audio_stop_input_metering", .arity = 0, .fptr = nif_audio_stop_input_metering, .flags = 0 }, + .{ .name = "audio_play", .arity = 2, .fptr = nif_audio_play, .flags = 0 }, + .{ .name = "audio_play_at", .arity = 3, .fptr = nif_audio_play_at, .flags = 0 }, + .{ .name = "audio_stop_playback", .arity = 0, .fptr = nif_audio_stop_playback, .flags = 0 }, + .{ .name = "audio_set_volume", .arity = 1, .fptr = nif_audio_set_volume, .flags = 0 }, + .{ .name = "audio_output_status", .arity = 0, .fptr = nif_audio_output_status, .flags = 0 }, + // Dirty IO: the Android side briefly settles a Visualizer measurement + // window, and iOS dispatch_syncs to the main queue — keep it off the + // regular schedulers. + .{ .name = "audio_output_level", .arity = 1, .fptr = nif_audio_output_level, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, + .{ .name = "motion_start", .arity = 2, .fptr = nif_motion_start, .flags = 0 }, + .{ .name = "motion_stop", .arity = 0, .fptr = nif_motion_stop, .flags = 0 }, + .{ .name = "take_launch_notification", .arity = 0, .fptr = nif_take_launch_notification, .flags = 0 }, + .{ .name = "take_opened_document", .arity = 0, .fptr = nif_take_opened_document, .flags = 0 }, + .{ .name = "storage_dir", .arity = 1, .fptr = nif_storage_dir, .flags = 0 }, + .{ .name = "storage_save_to_media_store", .arity = 2, .fptr = nif_storage_save_to_media_store, .flags = 0 }, + .{ .name = "storage_external_files_dir", .arity = 1, .fptr = nif_storage_external_files_dir, .flags = 0 }, + .{ .name = "storage_save_to_photo_library", .arity = 1, .fptr = nif_storage_save_to_photo_library, .flags = 0 }, + .{ .name = "alert_show", .arity = 3, .fptr = nif_alert_show, .flags = 0 }, + .{ .name = "action_sheet_show", .arity = 2, .fptr = nif_action_sheet_show, .flags = 0 }, + .{ .name = "toast_show", .arity = 2, .fptr = nif_toast_show, .flags = 0 }, + .{ .name = "webview_eval_js", .arity = 1, .fptr = nif_webview_eval_js, .flags = 0 }, + .{ .name = "webview_post_message", .arity = 1, .fptr = nif_webview_post_message, .flags = 0 }, + .{ .name = "webview_can_go_back", .arity = 0, .fptr = nif_webview_can_go_back, .flags = 0 }, + .{ .name = "webview_go_back", .arity = 0, .fptr = nif_webview_go_back, .flags = 0 }, + .{ .name = "register_component", .arity = 1, .fptr = nif_register_component, .flags = 0 }, + .{ .name = "deregister_component", .arity = 1, .fptr = nif_deregister_component, .flags = 0 }, + // Mob.Device — lifecycle events + queries (Android stubs except dispatcher set). + .{ .name = "device_set_dispatcher", .arity = 1, .fptr = nif_device_set_dispatcher, .flags = 0 }, + .{ .name = "device_battery_state", .arity = 0, .fptr = nif_device_battery_state, .flags = 0 }, + .{ .name = "device_thermal_state", .arity = 0, .fptr = nif_device_thermal_state, .flags = 0 }, + .{ .name = "device_network_state", .arity = 0, .fptr = nif_device_network_state, .flags = 0 }, + .{ .name = "device_low_power_mode", .arity = 0, .fptr = nif_device_low_power_mode, .flags = 0 }, + .{ .name = "device_foreground", .arity = 0, .fptr = nif_device_foreground, .flags = 0 }, + .{ .name = "device_os_version", .arity = 0, .fptr = nif_device_os_version, .flags = 0 }, + .{ .name = "device_model", .arity = 0, .fptr = nif_device_model, .flags = 0 }, + .{ .name = "device_orientation", .arity = 0, .fptr = nif_device_orientation, .flags = 0 }, + .{ .name = "device_lock_orientation", .arity = 1, .fptr = nif_device_lock_orientation, .flags = 0 }, + .{ .name = "device_keep_awake", .arity = 1, .fptr = nif_device_keep_awake, .flags = 0 }, + // ── Mob.Peripheral.VendorUsb (Android USB host) ────────────────────────── + .{ .name = "vendor_usb_list_devices", .arity = 1, .fptr = nif_vendor_usb_list_devices, .flags = 0 }, + .{ .name = "vendor_usb_request_permission", .arity = 1, .fptr = nif_vendor_usb_request_permission, .flags = 0 }, + .{ .name = "vendor_usb_open", .arity = 1, .fptr = nif_vendor_usb_open, .flags = 0 }, + .{ .name = "vendor_usb_bulk_write", .arity = 3, .fptr = nif_vendor_usb_bulk_write, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, + .{ .name = "vendor_usb_start_reading", .arity = 2, .fptr = nif_vendor_usb_start_reading, .flags = 0 }, + .{ .name = "vendor_usb_stop_reading", .arity = 1, .fptr = nif_vendor_usb_stop_reading, .flags = 0 }, + .{ .name = "vendor_usb_close", .arity = 1, .fptr = nif_vendor_usb_close, .flags = 0 }, + // ── Mob.Bt (Bluetooth Classic) — extracted to the mob_bluetooth plugin ── + // ── Mob.DNS (in-process IPv4 resolver via Bionic getaddrinfo) ──────── + .{ .name = "resolve_ipv4", .arity = 1, .fptr = nif_resolve_ipv4, .flags = erts.ERL_NIF_DIRTY_JOB_IO_BOUND }, +}; + +var mob_nif_entry: erts.ErlNifEntry = .{ + .major = erts.ERL_NIF_MAJOR_VERSION, + .minor = erts.ERL_NIF_MINOR_VERSION, + .name = "mob_nif", + .num_of_funcs = nif_funcs.len, + .funcs = &nif_funcs, + .load = nifLoad, + .reload = null, + .upgrade = null, + .unload = null, + .vm_variant = erts.ERL_NIF_VM_VARIANT, + .options = 1, // enable dirty-NIF support — matches what ERL_NIF_INIT emits. + .sizeof_ErlNifResourceTypeInit = erts.SIZEOF_ErlNifResourceTypeInit, + .min_erts = erts.ERL_NIF_MIN_ERTS_VERSION, +}; + +/// `mob_nif_nif_init` — the symbol the BEAM looks up via the static NIF +/// table to find this NIF's `ErlNifEntry`. driver_tab_android.zig already +/// extern-declares it. STATIC_ERLANG_NIF + ERL_NIF_INIT_NAME(mob_nif) in +/// the C header would have expanded to the same symbol. +pub export fn mob_nif_nif_init() callconv(.c) *erts.ErlNifEntry { + return &mob_nif_entry; +} diff --git a/android/jni/mob_zig.zig b/android/jni/mob_zig.zig new file mode 100644 index 00000000..bb6808d3 --- /dev/null +++ b/android/jni/mob_zig.zig @@ -0,0 +1,673 @@ +//! mob_zig.zig — Hand-declared JNI/Android/libc bindings for Mob's Zig code. +//! +//! Phase 6b of the build-system migration translates mob's Android C source +//! (mob_beam.c, mob_nif.c) to Zig. Zig 0.17-dev's `@cImport` builtin was +//! removed and `zig translate-c` hangs on the Android NDK's `jni.h` (deep +//! recursive include tree). Hand-declaring the FFI surface sidesteps both: +//! +//! * **Stable**: JNI ABI hasn't materially changed since Java 1.1 (1997). +//! Android log + libc surface used here is similarly stable. +//! * **Minimal**: declares only what Mob's Zig source actually uses. +//! ~250 lines beats a thousand-line auto-generated translation. +//! * **Auditable**: a reviewer can read the whole binding in one sitting. +//! * **Future-proof**: doesn't depend on Zig version's @cImport behavior. +//! +//! The hand-declared layouts mirror the C headers byte-for-byte (verified +//! against AOSP's `frameworks/native/include/jni.h` and Android NDK's +//! `android/log.h`, `dlfcn.h`, etc.). + +const std = @import("std"); + +// ── Android log ──────────────────────────────────────────────────────────── + +pub const ANDROID_LOG_VERBOSE: c_int = 2; +pub const ANDROID_LOG_DEBUG: c_int = 3; +pub const ANDROID_LOG_INFO: c_int = 4; +pub const ANDROID_LOG_WARN: c_int = 5; +pub const ANDROID_LOG_ERROR: c_int = 6; + +pub extern fn __android_log_write(prio: c_int, tag: [*:0]const u8, text: [*:0]const u8) c_int; +pub extern fn __android_log_print(prio: c_int, tag: [*:0]const u8, fmt: [*:0]const u8, ...) c_int; + +/// Format a message with std.fmt and write it via __android_log_write. +/// Truncates safely on oversize input (Android log already truncates at +/// ~4 KB anyway). +pub fn logWrite(prio: c_int, comptime tag: [*:0]const u8, comptime fmt: []const u8, args: anytype) void { + var buf: [4096]u8 = undefined; + const slice = std.fmt.bufPrint(&buf, fmt, args) catch buf[0..(buf.len - 1)]; + // bufPrint doesn't NUL-terminate; we need NUL for __android_log_write. + const end = @min(slice.len, buf.len - 1); + buf[end] = 0; + _ = __android_log_write(prio, tag, buf[0..end :0]); +} + +// ── POSIX / libc ─────────────────────────────────────────────────────────── + +pub const STDOUT_FILENO: c_int = 1; +pub const STDERR_FILENO: c_int = 2; + +pub extern fn pipe(fds: *[2]c_int) c_int; +pub extern fn dup2(oldfd: c_int, newfd: c_int) c_int; +pub extern fn close(fd: c_int) c_int; +pub extern fn read(fd: c_int, buf: [*]u8, count: usize) isize; +pub extern fn setvbuf(stream: *FILE, buf: ?[*]u8, mode: c_int, size: usize) c_int; +pub extern fn fopen(pathname: [*:0]const u8, mode: [*:0]const u8) ?*FILE; +pub extern fn fread(ptr: [*]u8, size: usize, nmemb: usize, stream: *FILE) usize; +pub extern fn fclose(stream: *FILE) c_int; +/// bionic's errno getter. The C `errno` macro expands to `(*__errno())`. +/// Symbol name matches the linker name in libc.so (`__errno`, not +/// `__errno_location` — that's the glibc spelling). +pub extern fn __errno() *c_int; +pub extern fn strerror(errnum: c_int) [*:0]const u8; +pub extern fn strncmp(s1: [*]const u8, s2: [*]const u8, n: usize) c_int; +pub extern fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; +pub extern fn mkdir(pathname: [*:0]const u8, mode: u32) c_int; +pub extern fn unlink(pathname: [*:0]const u8) c_int; +pub extern fn symlink(target: [*:0]const u8, linkpath: [*:0]const u8) c_int; +pub extern fn stat(pathname: [*:0]const u8, statbuf: *Stat) c_int; +pub extern fn opendir(name: [*:0]const u8) ?*DIR; +pub extern fn readdir(dirp: *DIR) ?*Dirent; +pub extern fn closedir(dirp: *DIR) c_int; +pub extern fn nanosleep(req: *const Timespec, rem: ?*Timespec) c_int; +pub extern fn snprintf(buf: [*]u8, size: usize, fmt: [*:0]const u8, ...) c_int; + +/// dladdr — POSIX/glibc/Bionic extension. Given an address, fills in +/// information about the shared object containing it. Used by the +/// BEAM launcher to discover libpigeon.so's absolute path so it can +/// be passed to rustler (and any other consumer) via env var. +pub const DlInfo = extern struct { + dli_fname: ?[*:0]const u8, + dli_fbase: ?*anyopaque, + dli_sname: ?[*:0]const u8, + dli_saddr: ?*anyopaque, +}; + +pub extern fn dladdr(addr: *const anyopaque, info: *DlInfo) c_int; + +/// POSIX clock identifiers. We only use CLOCK_MONOTONIC for throttle +/// timestamps in the gesture/scroll/drag/pinch sender path — it ticks +/// forward at a constant rate regardless of wall-clock NTP adjustments. +pub const CLOCK_MONOTONIC: c_int = 1; +pub extern fn clock_gettime(clk_id: c_int, tp: *Timespec) c_int; + +/// Monotonic nanoseconds since boot. Wrapper that hides the timespec +/// dance. Used by the throttle path in the senders. +pub fn nowNs() i64 { + var ts: Timespec = .{ .tv_sec = 0, .tv_nsec = 0 }; + _ = clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec * 1_000_000_000 + ts.tv_nsec; +} + +// libc allocator. We use `std.heap.c_allocator` in only one spot (test +// harness NIFs that copy a binary into a NUL-terminated buffer for +// NewStringUTF), and Zig 0.17 refuses to compile `std.heap.c_allocator` +// without `linkLibC()` on the module. Production builds link libc via +// the NDK clang link step anyway, so calling malloc/free directly is +// equivalent and skips the link-time guard. +pub extern fn malloc(size: usize) ?*anyopaque; +pub extern fn free(ptr: ?*anyopaque) void; +pub extern fn strlen(s: [*:0]const u8) usize; +pub extern fn strdup(s: [*:0]const u8) ?[*:0]u8; + +pub const _IONBF: c_int = 2; + +pub const FILE = opaque {}; + +/// bionic exposes `stdout` and `stderr` as `extern FILE*` symbols (NDK 23+, +/// API ≥ 21). We use them only to call `setvbuf(stdout, NULL, _IONBF, 0)` +/// after redirecting fd 1/2 to a pipe — the libc-side FILE objects retain +/// their own buffer until told otherwise. +pub extern var stdout: *FILE; +pub extern var stderr: *FILE; + +/// Opaque DIR for opendir/readdir/closedir. +pub const DIR = opaque {}; + +/// Android bionic dirent layout (sufficient for us — only need d_name). +/// AOSP source: bionic/libc/include/dirent.h. +pub const Dirent = extern struct { + d_ino: u64, + d_off: i64, + d_reclen: u16, + d_type: u8, + d_name: [256]u8, +}; + +pub const Stat = extern struct { + // Layout we don't fully care about — we only call stat() for existence + // check. Opaque-sized buffer is safer than getting field offsets wrong. + _opaque: [256]u8, +}; + +pub const Timespec = extern struct { + tv_sec: i64, + tv_nsec: i64, +}; + +pub extern fn pthread_create( + thread: *PthreadT, + attr: ?*const anyopaque, + start_routine: *const fn (?*anyopaque) callconv(.c) ?*anyopaque, + arg: ?*anyopaque, +) c_int; + +pub extern fn pthread_detach(thread: PthreadT) c_int; + +pub const PthreadT = usize; // Android: pthread_t is a long unsigned int + +// ── dlfcn ────────────────────────────────────────────────────────────────── + +pub const RTLD_NOW: c_int = 2; +pub const RTLD_GLOBAL: c_int = 0x00100; + +pub extern fn dlopen(filename: [*:0]const u8, flags: c_int) ?*anyopaque; +pub extern fn dlerror() ?[*:0]const u8; + +// ── netdb (in-process DNS) ───────────────────────────────────────────────── +// Bindings for Bionic's getaddrinfo so a NIF can resolve hostnames inside +// the BEAM's process. BEAM's default DNS path forks the `inet_gethost` port +// program and that path returns NXDOMAIN on physical Android devices for +// reasons we haven't fully pinned down (libnetd_client routing through netd +// behaves differently for execve'd children of the app — works on emulator, +// fails on phones we've tested). Calling getaddrinfo in-process from the +// app's UID and address space sidesteps the issue: it's the same code path +// the app's own HTTP stack uses when it succeeds. +// +// Layout mirrors Bionic's `bionic/libc/include/netdb.h` exactly. Note that +// Bionic's `struct addrinfo` orders `ai_canonname` *before* `ai_addr`, which +// is the historical BSD layout — glibc swaps them. Verified against AOSP +// `bionic/libc/include/netdb.h` (NDK r25+). + +pub const AF_INET: c_int = 2; +pub const SOCK_STREAM: c_int = 1; + +/// `getaddrinfo` EAI_* error codes. Bionic values, which happen to match +/// Darwin BSD for the ones we care about — but we declare them here for +/// clarity at the Zig call site. +pub const EAI_AGAIN: c_int = 2; +pub const EAI_NODATA: c_int = 7; +pub const EAI_NONAME: c_int = 8; + +pub const AddrInfo = extern struct { + ai_flags: c_int, + ai_family: c_int, + ai_socktype: c_int, + ai_protocol: c_int, + ai_addrlen: u32, + ai_canonname: ?[*:0]u8, + ai_addr: ?*SockAddr, + ai_next: ?*AddrInfo, +}; + +/// Generic sockaddr used by getaddrinfo's result chain. +pub const SockAddr = extern struct { + sa_family: u16, + _padding: [14]u8, +}; + +/// IPv4 sockaddr layout (sa_family=AF_INET). +pub const SockAddrIn = extern struct { + sin_family: u16, + sin_port: u16, + /// IPv4 address, network byte order. + sin_addr: u32, + sin_zero: [8]u8, +}; + +pub extern fn getaddrinfo( + node: [*:0]const u8, + service: ?[*:0]const u8, + hints: ?*const AddrInfo, + res: *?*AddrInfo, +) c_int; + +pub extern fn freeaddrinfo(res: ?*AddrInfo) void; + +// ── JNI ──────────────────────────────────────────────────────────────────── +// AOSP source: frameworks/native/include/jni.h. We only declare the vtable +// entries we actually call; future iters can add more as needed. + +pub const JNI_VERSION_1_6: c_int = 0x00010006; +pub const JNI_OK: c_int = 0; + +pub const JBoolean = u8; +pub const JByte = i8; +pub const JInt = i32; +pub const JLong = i64; +pub const JFloat = f32; +pub const JDouble = f64; +/// `jsize` is a typedef alias for `jint` in jni.h; keep them as distinct +/// names here so the byte-array helpers below read like the JNI signatures +/// they wrap. +pub const JSize = JInt; +pub const JByteArray = JObject; + +pub const JObject = ?*anyopaque; +pub const JClass = JObject; +pub const JString = JObject; +pub const JFieldID = ?*anyopaque; +pub const JMethodID = ?*anyopaque; + +/// JNIEnv is a pointer-to-pointer-to-JNINativeInterface. C usage: +/// `(*env)->FindClass(env, "..")` +/// Zig usage via our helpers: +/// `jni.findClass(env, "..")` +pub const JNIEnv = *const JNINativeInterface; + +/// Vtable inside JNIEnv. Order matters — must match jni.h exactly. +/// We declare only the slots we use, plus reserved padding for the rest. +/// Each `?*const fn(...) callconv(.c) ...` is a function pointer. +pub const JNINativeInterface = extern struct { + _reserved0: ?*anyopaque, + _reserved1: ?*anyopaque, + _reserved2: ?*anyopaque, + _reserved3: ?*anyopaque, + + // Index 4: GetVersion — unused but in the slot order. + GetVersion: ?*const fn (env: *JNIEnv) callconv(.c) JInt, + + // 5-8: DefineClass, FindClass, FromReflectedMethod, FromReflectedField + DefineClass: ?*anyopaque, + FindClass: ?*const fn (env: *JNIEnv, name: [*:0]const u8) callconv(.c) JClass, + FromReflectedMethod: ?*anyopaque, + FromReflectedField: ?*anyopaque, + + // 9-16: reflected/IsAssignableFrom + exceptions block + ToReflectedMethod: ?*anyopaque, + GetSuperclass: ?*anyopaque, + IsAssignableFrom: ?*anyopaque, + ToReflectedField: ?*anyopaque, + Throw: ?*anyopaque, + ThrowNew: ?*anyopaque, + ExceptionOccurred: ?*anyopaque, + ExceptionDescribe: ?*anyopaque, + + // 17-22: exception finish, refs + ExceptionClear: ?*const fn (env: *JNIEnv) callconv(.c) void, + FatalError: ?*anyopaque, + PushLocalFrame: ?*anyopaque, + PopLocalFrame: ?*anyopaque, + NewGlobalRef: ?*const fn (env: *JNIEnv, obj: JObject) callconv(.c) JObject, + DeleteGlobalRef: ?*const fn (env: *JNIEnv, gref: JObject) callconv(.c) void, + + // 23-26: local ref slots + DeleteLocalRef: ?*const fn (env: *JNIEnv, obj: JObject) callconv(.c) void, + IsSameObject: ?*anyopaque, + NewLocalRef: ?*anyopaque, + EnsureLocalCapacity: ?*anyopaque, + + // 27-29: object creation + AllocObject: ?*anyopaque, + NewObject: ?*anyopaque, + NewObjectV: ?*anyopaque, + + // 30-32: object type queries + NewObjectA: ?*anyopaque, + GetObjectClass: ?*const fn (env: *JNIEnv, obj: JObject) callconv(.c) JClass, + IsInstanceOf: ?*anyopaque, + + // 33: GetMethodID + GetMethodID: ?*const fn (env: *JNIEnv, cls: JClass, name: [*:0]const u8, sig: [*:0]const u8) callconv(.c) JMethodID, + + // 34-60: many CallXxxMethod variants — we only use CallObjectMethod + // and CallBooleanMethod by typed signature. Pad as opaque. + CallObjectMethod: ?*const fn (env: *JNIEnv, obj: JObject, mid: JMethodID, ...) callconv(.c) JObject, + CallObjectMethodV: ?*anyopaque, + CallObjectMethodA: ?*anyopaque, + CallBooleanMethod: ?*const fn (env: *JNIEnv, obj: JObject, mid: JMethodID, ...) callconv(.c) JBoolean, + CallBooleanMethodV: ?*anyopaque, + CallBooleanMethodA: ?*anyopaque, + CallByteMethod: ?*anyopaque, + CallByteMethodV: ?*anyopaque, + CallByteMethodA: ?*anyopaque, + CallCharMethod: ?*anyopaque, + CallCharMethodV: ?*anyopaque, + CallCharMethodA: ?*anyopaque, + CallShortMethod: ?*anyopaque, + CallShortMethodV: ?*anyopaque, + CallShortMethodA: ?*anyopaque, + CallIntMethod: ?*anyopaque, + CallIntMethodV: ?*anyopaque, + CallIntMethodA: ?*anyopaque, + CallLongMethod: ?*anyopaque, + CallLongMethodV: ?*anyopaque, + CallLongMethodA: ?*anyopaque, + CallFloatMethod: ?*anyopaque, + CallFloatMethodV: ?*anyopaque, + CallFloatMethodA: ?*anyopaque, + CallDoubleMethod: ?*anyopaque, + CallDoubleMethodV: ?*anyopaque, + CallDoubleMethodA: ?*anyopaque, + CallVoidMethod: ?*anyopaque, + CallVoidMethodV: ?*anyopaque, + CallVoidMethodA: ?*anyopaque, + + // 62-94: nonvirtual call variants + field accessors + CallNonvirtualObjectMethod: ?*anyopaque, + CallNonvirtualObjectMethodV: ?*anyopaque, + CallNonvirtualObjectMethodA: ?*anyopaque, + CallNonvirtualBooleanMethod: ?*anyopaque, + CallNonvirtualBooleanMethodV: ?*anyopaque, + CallNonvirtualBooleanMethodA: ?*anyopaque, + CallNonvirtualByteMethod: ?*anyopaque, + CallNonvirtualByteMethodV: ?*anyopaque, + CallNonvirtualByteMethodA: ?*anyopaque, + CallNonvirtualCharMethod: ?*anyopaque, + CallNonvirtualCharMethodV: ?*anyopaque, + CallNonvirtualCharMethodA: ?*anyopaque, + CallNonvirtualShortMethod: ?*anyopaque, + CallNonvirtualShortMethodV: ?*anyopaque, + CallNonvirtualShortMethodA: ?*anyopaque, + CallNonvirtualIntMethod: ?*anyopaque, + CallNonvirtualIntMethodV: ?*anyopaque, + CallNonvirtualIntMethodA: ?*anyopaque, + CallNonvirtualLongMethod: ?*anyopaque, + CallNonvirtualLongMethodV: ?*anyopaque, + CallNonvirtualLongMethodA: ?*anyopaque, + CallNonvirtualFloatMethod: ?*anyopaque, + CallNonvirtualFloatMethodV: ?*anyopaque, + CallNonvirtualFloatMethodA: ?*anyopaque, + CallNonvirtualDoubleMethod: ?*anyopaque, + CallNonvirtualDoubleMethodV: ?*anyopaque, + CallNonvirtualDoubleMethodA: ?*anyopaque, + CallNonvirtualVoidMethod: ?*anyopaque, + CallNonvirtualVoidMethodV: ?*anyopaque, + CallNonvirtualVoidMethodA: ?*anyopaque, + + // 95: GetFieldID — we use this + GetFieldID: ?*const fn (env: *JNIEnv, cls: JClass, name: [*:0]const u8, sig: [*:0]const u8) callconv(.c) JFieldID, + + // 96-104: GetXxxField — we use GetObjectField + GetObjectField: ?*const fn (env: *JNIEnv, obj: JObject, fid: JFieldID) callconv(.c) JObject, + GetBooleanField: ?*anyopaque, + GetByteField: ?*anyopaque, + GetCharField: ?*anyopaque, + GetShortField: ?*anyopaque, + GetIntField: ?*anyopaque, + GetLongField: ?*anyopaque, + GetFloatField: ?*anyopaque, + GetDoubleField: ?*anyopaque, + + // 105-113: SetXxxField + static method id/calls — unused + SetObjectField: ?*anyopaque, + SetBooleanField: ?*anyopaque, + SetByteField: ?*anyopaque, + SetCharField: ?*anyopaque, + SetShortField: ?*anyopaque, + SetIntField: ?*anyopaque, + SetLongField: ?*anyopaque, + SetFloatField: ?*anyopaque, + SetDoubleField: ?*anyopaque, + + // 114-152: GetStaticMethodID + CallStaticXxxMethod variants. Phase 6b + // iter 3b types the slots mob_nif.zig calls (GetStaticMethodID + the + // variadic ObjectMethod / BooleanMethod / VoidMethod); the rest stay + // opaque until a later iter needs them. + GetStaticMethodID: ?*const fn (env: *JNIEnv, cls: JClass, name: [*:0]const u8, sig: [*:0]const u8) callconv(.c) JMethodID, + CallStaticObjectMethod: ?*const fn (env: *JNIEnv, cls: JClass, mid: JMethodID, ...) callconv(.c) JObject, + CallStaticObjectMethodV: ?*anyopaque, + CallStaticObjectMethodA: ?*anyopaque, + CallStaticBooleanMethod: ?*const fn (env: *JNIEnv, cls: JClass, mid: JMethodID, ...) callconv(.c) JBoolean, + CallStaticBooleanMethodV: ?*anyopaque, + CallStaticBooleanMethodA: ?*anyopaque, + CallStaticByteMethod: ?*anyopaque, + CallStaticByteMethodV: ?*anyopaque, + CallStaticByteMethodA: ?*anyopaque, + CallStaticCharMethod: ?*anyopaque, + CallStaticCharMethodV: ?*anyopaque, + CallStaticCharMethodA: ?*anyopaque, + CallStaticShortMethod: ?*anyopaque, + CallStaticShortMethodV: ?*anyopaque, + CallStaticShortMethodA: ?*anyopaque, + CallStaticIntMethod: ?*const fn (env: *JNIEnv, cls: JClass, mid: JMethodID, ...) callconv(.c) JInt, + CallStaticIntMethodV: ?*anyopaque, + CallStaticIntMethodA: ?*anyopaque, + CallStaticLongMethod: ?*anyopaque, + CallStaticLongMethodV: ?*anyopaque, + CallStaticLongMethodA: ?*anyopaque, + CallStaticFloatMethod: ?*anyopaque, + CallStaticFloatMethodV: ?*anyopaque, + CallStaticFloatMethodA: ?*anyopaque, + CallStaticDoubleMethod: ?*anyopaque, + CallStaticDoubleMethodV: ?*anyopaque, + CallStaticDoubleMethodA: ?*anyopaque, + CallStaticVoidMethod: ?*const fn (env: *JNIEnv, cls: JClass, mid: JMethodID, ...) callconv(.c) void, + CallStaticVoidMethodV: ?*anyopaque, + CallStaticVoidMethodA: ?*anyopaque, + GetStaticFieldID: ?*anyopaque, + GetStaticObjectField: ?*anyopaque, + GetStaticBooleanField: ?*anyopaque, + GetStaticByteField: ?*anyopaque, + GetStaticCharField: ?*anyopaque, + GetStaticShortField: ?*anyopaque, + GetStaticIntField: ?*anyopaque, + GetStaticLongField: ?*anyopaque, + GetStaticFloatField: ?*anyopaque, + GetStaticDoubleField: ?*anyopaque, + + // 153-162: SetStaticXxxField — unused + SetStaticObjectField: ?*anyopaque, + SetStaticBooleanField: ?*anyopaque, + SetStaticByteField: ?*anyopaque, + SetStaticCharField: ?*anyopaque, + SetStaticShortField: ?*anyopaque, + SetStaticIntField: ?*anyopaque, + SetStaticLongField: ?*anyopaque, + SetStaticFloatField: ?*anyopaque, + SetStaticDoubleField: ?*anyopaque, + + // 163-168: NewString + GetStringChars — unused but pad for completeness + NewString: ?*anyopaque, + GetStringLength: ?*anyopaque, + GetStringChars: ?*anyopaque, + ReleaseStringChars: ?*anyopaque, + NewStringUTF: ?*const fn (env: *JNIEnv, utf: [*:0]const u8) callconv(.c) JString, + GetStringUTFLength: ?*anyopaque, + + // 169-170: GetStringUTFChars / ReleaseStringUTFChars — we use these + GetStringUTFChars: ?*const fn (env: *JNIEnv, str: JString, is_copy: ?*JBoolean) callconv(.c) ?[*:0]const u8, + ReleaseStringUTFChars: ?*const fn (env: *JNIEnv, str: JString, utf: [*:0]const u8) callconv(.c) void, + + // 171: GetArrayLength — typed (used by nif_screen_info). + GetArrayLength: ?*const fn (env: *JNIEnv, arr: JObject) callconv(.c) JInt, + + // 172-178: ObjectArray + primitive-array constructors. NewByteArray is + // typed because nif_vendor_usb_bulk_write needs it (Mob.VendorUsb's + // raw-USB write path hands an iolist→binary across the JNI boundary + // as a `byte[]`). The others stay opaque until something else needs + // them. + NewObjectArray: ?*anyopaque, + GetObjectArrayElement: ?*anyopaque, + SetObjectArrayElement: ?*anyopaque, + NewBooleanArray: ?*anyopaque, + NewByteArray: ?*const fn (env: *JNIEnv, len: JSize) callconv(.c) JByteArray, + NewCharArray: ?*anyopaque, + NewShortArray: ?*anyopaque, + + // 179-187: more New*Array + Get*ArrayElements. + NewIntArray: ?*anyopaque, + NewLongArray: ?*anyopaque, + NewFloatArray: ?*anyopaque, + NewDoubleArray: ?*anyopaque, + GetBooleanArrayElements: ?*anyopaque, + GetByteArrayElements: ?*anyopaque, + GetCharArrayElements: ?*anyopaque, + GetShortArrayElements: ?*anyopaque, + GetIntArrayElements: ?*anyopaque, + + // 188-203: remaining Get*ArrayElements + all Release*ArrayElements + + // Get*ArrayRegion entries up through GetFloatArrayRegion. We need + // GetFloatArrayRegion (slot 203) typed for nif_screen_info / + // nif_safe_area; everything between stays opaque. + GetLongArrayElements: ?*anyopaque, + GetFloatArrayElements: ?*anyopaque, + GetDoubleArrayElements: ?*anyopaque, + ReleaseBooleanArrayElements: ?*anyopaque, + ReleaseByteArrayElements: ?*anyopaque, + ReleaseCharArrayElements: ?*anyopaque, + ReleaseShortArrayElements: ?*anyopaque, + ReleaseIntArrayElements: ?*anyopaque, + ReleaseLongArrayElements: ?*anyopaque, + ReleaseFloatArrayElements: ?*anyopaque, + ReleaseDoubleArrayElements: ?*anyopaque, + GetBooleanArrayRegion: ?*anyopaque, + // Typed (used by nif_screenshot to read a Kotlin byte[] into a binary). + GetByteArrayRegion: ?*const fn (env: *JNIEnv, arr: JByteArray, start: JInt, len: JInt, buf: [*]JByte) callconv(.c) void, + GetCharArrayRegion: ?*anyopaque, + GetShortArrayRegion: ?*anyopaque, + GetIntArrayRegion: ?*anyopaque, + GetLongArrayRegion: ?*anyopaque, + GetFloatArrayRegion: ?*const fn (env: *JNIEnv, arr: JObject, start: JInt, len: JInt, buf: [*]f32) callconv(.c) void, + GetDoubleArrayRegion: ?*anyopaque, + + // 204-211: SetXxxArrayRegion. SetByteArrayRegion is typed because + // nif_vendor_usb_bulk_write copies BEAM-side bytes into a fresh + // `byte[]` via NewByteArray + SetByteArrayRegion before the static + // method call. + SetBooleanArrayRegion: ?*anyopaque, + SetByteArrayRegion: ?*const fn (env: *JNIEnv, arr: JByteArray, start: JSize, len: JSize, buf: [*]const JByte) callconv(.c) void, + + // The remaining ~25 slots (Set*ArrayRegion tail past byte, + // RegisterNatives, MonitorEnter/Exit, GetJavaVM, NewWeakGlobalRef, + // ExceptionCheck, DirectByteBuffer ops, GetObjectRefType) are not + // used by mob_nif.zig today. Add when a later iter needs them — the + // rule is "match jni.h up to the last USED slot". +}; + +/// JavaVM vtable — used for GetEnv / AttachCurrentThread / DetachCurrentThread. +pub const JavaVM = *const JNIInvokeInterface; + +pub const JNIInvokeInterface = extern struct { + _reserved0: ?*anyopaque, + _reserved1: ?*anyopaque, + _reserved2: ?*anyopaque, + DestroyJavaVM: ?*anyopaque, + AttachCurrentThread: ?*const fn (vm: *JavaVM, env: *?*JNIEnv, args: ?*anyopaque) callconv(.c) JInt, + DetachCurrentThread: ?*const fn (vm: *JavaVM) callconv(.c) JInt, + GetEnv: ?*const fn (vm: *JavaVM, env: *?*anyopaque, version: JInt) callconv(.c) JInt, + AttachCurrentThreadAsDaemon: ?*anyopaque, +}; + +// ── Wrapper helpers (hide vtable indirection) ────────────────────────────── +// Each one-liner unwraps the JNIEnv vtable pointer and the function-pointer +// optional. Cuts call-site noise: `jni.findClass(env, "X")` vs +// `env.*.FindClass.?(env, "X")`. + +pub inline fn findClass(env: *JNIEnv, name: [*:0]const u8) JClass { + return env.*.FindClass.?(env, name); +} + +pub inline fn getObjectClass(env: *JNIEnv, obj: JObject) JClass { + return env.*.GetObjectClass.?(env, obj); +} + +pub inline fn getMethodID(env: *JNIEnv, cls: JClass, name: [*:0]const u8, sig: [*:0]const u8) JMethodID { + return env.*.GetMethodID.?(env, cls, name, sig); +} + +pub inline fn getFieldID(env: *JNIEnv, cls: JClass, name: [*:0]const u8, sig: [*:0]const u8) JFieldID { + return env.*.GetFieldID.?(env, cls, name, sig); +} + +pub inline fn callObjectMethod(env: *JNIEnv, obj: JObject, mid: JMethodID) JObject { + return env.*.CallObjectMethod.?(env, obj, mid); +} + +pub inline fn callBooleanMethod(env: *JNIEnv, obj: JObject, mid: JMethodID) JBoolean { + return env.*.CallBooleanMethod.?(env, obj, mid); +} + +pub inline fn getObjectField(env: *JNIEnv, obj: JObject, fid: JFieldID) JObject { + return env.*.GetObjectField.?(env, obj, fid); +} + +pub inline fn getStringUTFChars(env: *JNIEnv, str: JString) ?[*:0]const u8 { + return env.*.GetStringUTFChars.?(env, str, null); +} + +pub inline fn releaseStringUTFChars(env: *JNIEnv, str: JString, utf: [*:0]const u8) void { + env.*.ReleaseStringUTFChars.?(env, str, utf); +} + +pub inline fn newGlobalRef(env: *JNIEnv, obj: JObject) JObject { + return env.*.NewGlobalRef.?(env, obj); +} + +// ── Static method helpers (added in iter 3b) ─────────────────────────────── + +pub inline fn getStaticMethodID(env: *JNIEnv, cls: JClass, name: [*:0]const u8, sig: [*:0]const u8) JMethodID { + return env.*.GetStaticMethodID.?(env, cls, name, sig); +} + +pub inline fn newStringUTF(env: *JNIEnv, utf: [*:0]const u8) JString { + return env.*.NewStringUTF.?(env, utf); +} + +pub inline fn deleteLocalRef(env: *JNIEnv, obj: JObject) void { + env.*.DeleteLocalRef.?(env, obj); +} + +pub inline fn exceptionClear(env: *JNIEnv) void { + env.*.ExceptionClear.?(env); +} + +pub inline fn getArrayLength(env: *JNIEnv, arr: JObject) JInt { + return env.*.GetArrayLength.?(env, arr); +} + +pub inline fn getFloatArrayRegion(env: *JNIEnv, arr: JObject, start: JInt, len: JInt, buf: [*]f32) void { + env.*.GetFloatArrayRegion.?(env, arr, start, len, buf); +} + +pub inline fn getByteArrayRegion(env: *JNIEnv, arr: JByteArray, start: JInt, len: JInt, buf: [*]JByte) void { + env.*.GetByteArrayRegion.?(env, arr, start, len, buf); +} + +pub inline fn newByteArray(env: *JNIEnv, len: JSize) JByteArray { + return env.*.NewByteArray.?(env, len); +} + +pub inline fn setByteArrayRegion(env: *JNIEnv, arr: JByteArray, start: JSize, len: JSize, buf: [*]const JByte) void { + env.*.SetByteArrayRegion.?(env, arr, start, len, buf); +} + +pub inline fn getEnv(vm: *JavaVM, version: JInt) ?*JNIEnv { + var env: ?*anyopaque = null; + if (vm.*.GetEnv.?(vm, &env, version) != JNI_OK) return null; + return @ptrCast(@alignCast(env)); +} + +pub inline fn attachCurrentThread(vm: *JavaVM) ?*JNIEnv { + var env: ?*JNIEnv = null; + if (vm.*.AttachCurrentThread.?(vm, &env, null) != JNI_OK) return null; + return env; +} + +pub inline fn detachCurrentThread(vm: *JavaVM) void { + _ = vm.*.DetachCurrentThread.?(vm); +} + +// ── Small string utilities ──────────────────────────────────────────────── + +/// Copy a NUL-terminated source string into a fixed-size buffer, truncating +/// (NUL-terminated) on overflow. Mirrors `snprintf(buf, sizeof(buf), "%s", src)`. +pub fn copyZ(buf: []u8, src: [*:0]const u8) void { + var i: usize = 0; + while (i < buf.len - 1 and src[i] != 0) : (i += 1) { + buf[i] = src[i]; + } + buf[i] = 0; +} + +/// Compute the NUL-terminated length of a buffer (i.e. C strlen of buf[..]). +pub fn zLen(buf: []const u8) usize { + var i: usize = 0; + while (i < buf.len and buf[i] != 0) : (i += 1) {} + return i; +} + +/// View a NUL-terminated buffer as a NUL-terminated [*:0]const u8. +/// The buffer must contain at least one NUL byte within its bounds. +pub fn asCStr(buf: []const u8) [*:0]const u8 { + return @ptrCast(buf.ptr); +} diff --git a/build_system_migration.md b/build_system_migration.md index 163d8c49..7ce7c536 100644 --- a/build_system_migration.md +++ b/build_system_migration.md @@ -5,6 +5,28 @@ > compile orchestration) → Xcode/Gradle (platform packaging). Touches all three > repos. Sequenced to ship value at every checkpoint. +**Status: Historical record (closed 2026-05-26).** The Mix → Igniter → Zig +migration shipped: apps build via `build.zig` / `build_device.zig`, `mix +mob.new` generates Zig build templates, and the OTP release scripts run as +tested Elixir (`MobDev.Release.*`). This document is retained for context, not +as a live tracker. + +Two caveats for future readers: + +- The per-phase status notes below were **last updated 2026-05-11** and were + **not** maintained as later phases landed — several still say "in progress" + but the work shipped. Treat the per-phase notes as historical, not current. +- One robustness follow-up the migration left open: the build orchestration + **hardcodes the mob Swift-source list** instead of globbing `ios/*.swift`. + When a new mob Swift file lands (as `MobGpuView.swift` did), any build that + doesn't list it fails with `cannot find '<Type>' in scope` (+ cascading + errors in `MobRootView.swift`). `mob_dev`'s release path now globs + (`mob_dev` PR #13); `mob_new`'s `ios/build.zig.eex` + `ios/build_device.zig.eex` + templates still list the three files by name and should be globbed to close + this class of break for good. + +_Original plan below, preserved as written._ + **Status:** Greenlit 2026-05-09. Begin after the in-flight `pythonx-support` work merges to master in `mob_dev` and `mob_new`. @@ -224,10 +246,20 @@ others. - iOS Objective-C stays as-is — ARC handles memory; ObjC's Cocoa idiom is right - Touches: mob primarily -**6c — OTP rebuild scripts → `build.zig`:** -- `scripts/release/openssl/build_crypto_static_*.sh` → Zig build -- `scripts/release/xcompile_*.sh` → Zig build -- `scripts/release/tarball_*.sh` → Zig build (or stay shell — these are simpler) +**6c — OTP release scripts → tested Elixir under `MobDev.Release.*`:** ✓ complete (2026-05-11) +- `scripts/release/openssl/build_crypto_static_*.sh` → `MobDev.Release.OpenSSL` + + `MobDev.Release.OpenSSL.CryptoNif` +- `scripts/release/xcompile_*.sh` → `MobDev.Release.OTP` +- `scripts/release/tarball_*.sh` → `MobDev.Release.Tarball` +- `scripts/release/publish.sh` → `MobDev.Release.Publish` +- All routed through `MobDev.Release.Shell` behaviour for mockable I/O; + every `gh` failure classified into typed error categories + (`:auth_required`, `:infra_unreachable`, `:precondition_failed`, + `:cmd_failed`) so the release pipeline can distinguish "GitHub + outage" from "expired auth" from "our bug" at the call site. The + Zig-build option was reconsidered: orchestration is the wrong job + for Zig's compile-graph model, but compile orchestration (zig cc) + stays in scope for the C-builder deferral in iter 13d. - Touches: mob_dev --- @@ -636,9 +668,779 @@ Apple framework module maps under -fmodules — Phase 1 finding). install. The Mix-driven path resolved an install-acceptance issue the shell flow had hit at the device-trust layer. + - iter 13a: slim_step pipeline restored in Elixir (was a TODO in + iter 12d's bundle/codesign migration). Mirrors the original shell + version: apple binaries strip, prefix libs strip, foreign apps + strip, dedup versions, src+headers strip, beam chunk strip via + `:beam_lib.strip_release/1`. Gated on `MOB_SLIM=1` to keep dev + iteration fast (the strip pass adds ~5-10s). + + - iter 13b: iOS sim build glue → Mix. The generated + `ios/build.sh.eex` template (288 lines) is gone. All iOS-sim + build glue (mix compile, BEAM copies, exqlite NIF cross-compile, + Pythonx framework + cross-compile, crypto shim for LV, + ssl beams from host OTP for LV, Phoenix asset build for LV, + Ecto migration copy, Elixir/EEx stdlib copy, OTP runtime sync, + enif_keepalive generation, zig binary build, .app bundle, simctl + install) now flows through MobDev.NativeBuild. LV detection gates + on `assets/` at project root (not on transitive phoenix_live_view + dep — vanilla mob pulls it in too). Smoke-tested LV (Phoenix 1.7) + and vanilla mob projects on both iOS sim and physical iPhone. + Companion mob_new commit removes `liveview_build_sh_content/2` + and the build.sh template. + + - iter 13c: eliminate `build_device.sh`. Same model as iter 13b but + for iOS device. `generate_build_device_sh/2` (~520 lines) is gone; + `build_ios_physical/2` is now a `with` chain over Mix helpers. + Reuses iter 13b helpers verbatim (compile, beam copy, exqlite OTP + lib, crypto shim, Elixir/EEx stdlib, migrations, Phoenix assets, + enif_keepalive). Adds device-specific helpers: + cross_compile_exqlite_nif_device (static .a, iphoneos arm64); + maybe_setup_pythonx_device (Python.framework rsync + + libpythonx.so for iphoneos arm64); maybe_install_ssl_shim + (LV-only full SSL stub); copy_otp_libs_for_phoenix (runtime_tools, + asn1, public_key from host OTP); install_app_in_otp_lib (so + Plug.Static's :code.lib_dir resolves); copy_mob_logos_to_otp_root; + patch_epmd_source (idempotent NO_DAEMON guard); + generate_erl_errno_compat_stub; zig_build_binary_ios_device. + Stale references swept: `has_ios_project?/0` in mob.doctor + + mob.install now checks `ios/build.zig`; doctor python3/rsync + rationale + battery_bench docstring updated; enable.ex's + detect_stale_pythonx_templates drops the obsolete build.sh entries. + Smoke-tested LV (Phoenix 1.7) and vanilla mob on Kevin's iPhone: + both deploy clean. + + - iter 13d: release scripts → zig cc — **researched, blocked, deferred**. + Goal was to swap `xcrun -sdk … cc` (and Android NDK clang) for + `zig cc -target …` in `scripts/release/xcomp/erl-xcomp-*.conf` so + the OTP tarball is built with the same toolchain the dev path uses. + + **What works:** + - zig cc 0.17.0-dev compiles + links iOS sim/device executables + with these specific flags (verified via standalone hello-world): + `zig cc -target aarch64-ios-simulator -nostdlibinc \` + ` -isysroot $SDK -isystem $SDK/usr/include -L$SDK/usr/lib` + The `-nostdlibinc` is required because zig's bundled stdlib + headers don't match iOS; `-isystem $SDK/usr/include` provides + Apple's iOS SDK headers explicitly (NOT picked up via + `-isysroot` alone in zig 0.17.0-dev). + - With these flags, OTP's autoconf passes `checking whether the + C compiler works... yes` for every subdir. + - Several OTP libraries (erl_interface, ei) build cleanly. + + **What blocks:** + - zig cc tries to provide its own libc++ when `-lc++` is in + LDFLAGS, which fails for iOS sim with cascade of "unknown type + mbstate_t / wint_t / size_t" errors against zig's bundled + libcxx headers (zig doesn't ship iOS-sim libc fragments). + Workaround: drop `-lc++` from LDFLAGS — works for OTP since + `--disable-jit --without-wx` removes the only C++ surfaces. + - After getting past configure + early lib builds, OTP's + emulator build fails: + `gmake[4]: *** No rule to make target 'stdbool.h', needed by` + `'obj/aarch64-apple-iossimulator/opt/emu/erl_main.o'. Stop.` + Root cause: OTP's emulator Makefile.in uses `-MM -MG` for its + custom dep-generation pass (`$(SED_DEPEND) $@.tmp > $@`). With + zig cc, `-MM` outputs only user headers + zig's stdbool.h + absolute path (`/Users/kevin/zig/.../include/stdbool.h`), + which OTP's SED_DEPEND step rewrites to a bare basename that + then has no make-rule to satisfy it. Apple's clang outputs + similar absolute paths but OTP's SED_DEPEND was tuned to its + output format. Patching OTP's emulator dep machinery to + tolerate zig's output format is OTP-internal engineering, not + Phase 1 cleanup work. + - Android: zig 0.17.0-dev rejects `aarch64-linux-android24` as + `UnknownApplicationBinaryInterface`. Workarounds (musl target + + NDK sysroot) don't survive OTP's autoconf feature tests. + + **Decision:** iter 13d is **deferred indefinitely**. The dev path + already uses zig cc for everything that matters (driver_tab, + enif_keepalive, the link via xcrun swiftc — see iter 1-12). The + release path runs once per OTP version bump (rare) and + successfully produces working tarballs with `xcrun cc` + NDK + clang. Pushing the swap through would require: + 1. patching OTP's emulator Makefile.in dep generation, OR + 2. building a `zig-cc-wrapper` shell that translates zig's dep + output into OTP-friendly format + Neither is justified by the marginal benefit (one-fewer + toolchain on the release machine, which is Kevin's Mac that + already has Xcode + NDK). Revisit when zig has first-class Apple + SDK + Android NDK support, or if a future OTP cleanup makes the + emulator dep machinery less custom. + + - iter 13e: timing validation. Measured no-change rebuild on iOS + sim (vanilla phase2q_smoke project): 2m12s end-to-end. Time is + dominated by: + - OTP runtime rsync to `~/.mob/runtime/ios-sim` (~195 MB) + - exqlite NIF cross-compile (always re-runs) + - .app bundle rebuild (full rsync of OTP into the bundle) + All three are operations the old shell pipeline did unconditionally + too — iter 13b/c preserves the original semantics, no regression. + Future caching wins (skip rsync when sources unchanged, mtime- + gated exqlite recompile, content-hashed bundle reuse) are out of + scope for Phase 2 cleanup; tracked as separate optimization work. + **Phase 2 is COMPLETE.** Every target — iOS sim vanilla, iOS sim LiveView, Android arm64, Android arm32, Android sqlite3_nif, iOS device — has its native compile + link in build.zig and its - bundle/install in Mix (or Gradle for Android). Shell scripts are - glue (asset copies, mix compile orchestration, exqlite NIF special - cases), not native build orchestration. + bundle/install in Mix (or Gradle for Android). Both + `ios/build.sh.eex` and runtime-generated `build_device.sh` are + gone. The iOS path no longer uses shell scripts at all; build + orchestration lives in Elixir (`mob_dev/lib/mob_dev/native_build.ex`) + and Zig (`build.zig` / `build_device.zig`). + +## Phase 3 — `mix mob.add_nif` (in progress) + +Greenfield Igniter usage. First commit to Igniter as a dep; lays the +groundwork for the Phase 4/5 rewrites by validating the AST-aware +generator pattern on a small, self-contained task. + + - iter 1: scaffold task. `mix mob.add_nif <name> [--type elixir-only|c]` + creates `lib/<app>/nifs/<name>.ex` (Elixir stub via + `Igniter.Project.Module.create_module`), appends + `%{module: :<name>, archs: [:all]}` to `mob.exs`'s `:static_nifs` + via `Igniter.Project.Config.modify_config_code/5`, and (with + `--type c`) drops a `c_src/<name>.c` skeleton with + `ERL_NIF_INIT(<name>, ...)` pre-wired. Validates name is + snake_case + length-bounded; `--type` is `elixir-only` or `c` + (zigler/rustler land in later iters that pull in those Hex deps). + Idempotent — re-running with the same name skips file writes and + keeps the existing list entry. Drive-by fix: `mix mob.regen_driver_tab` + was reading from `Application.get_env(:mob_dev, :static_nifs, [])` + but mob.exs is not auto-imported into Mix application env, so the + user's `:static_nifs` entries never reached driver_tab. Switched + regen to `MobDev.Config.load_mob_config()` matching every other + mob_dev task. Smoke-tested against `phase2q_smoke`: deploy added + one NIF, regen produced driver_tab containing the entry, second + add appended to the existing list cleanly. 20 new tests covering + validation/stub/append/idempotence/C-skeleton/notice paths. + `igniter ~> 0.8` added to mob_dev deps (the Phase 3 dep + commitment). 850/850 tests pass on mob_dev master. + + - iter 2: auto-regen via `Igniter.add_task/3`. The post-run notice + that asked the user to run `mix mob.regen_driver_tab` manually is + gone — `mob.add_nif` queues regen to fire after Igniter commits, + so the same shell invocation produces stub + mob.exs update + + optional native skeleton + regenerated driver_tab. Single command, + one diff, one confirm. + + - iter 3: `--type zigler`. Generates a Zigler-backed stub + (`use Zig, otp_app: :<app>` + inline `~Z` sigil with example + `pub fn add_one`). Adds `:zigler ~> 0.15` to mix.exs deps via + `Igniter.Project.Deps.add_dep/2`. Skips `c_src/<name>.c` (Zigler + manages its own native side via the sigil + zig-build pipeline). + Stub moduledoc warns about the static-link gap: Zigler's default + flow produces a dlopen'd `.so`, incompatible with Mob's iOS + App Store / Android RTLD_LOCAL constraints; on-device shipping + requires the user to wire the Zigler archive into ios/build.zig + + android/jni/ manually. Host-dev path works out-of-the-box. 6 + new tests. + + - iter 4: `--type rustler`. Generates a Rustler-backed stub + (`use Rustler, otp_app: :<app>, crate: "<name>"`) plus a full + Cargo crate skeleton at `native/<name>/`: `Cargo.toml` with + `crate-type = ["cdylib"]` (Rustler default; comment documents + the `staticlib` swap for Mob), `src/lib.rs` with example + `#[rustler::nif] fn add_one` + `rustler::init!` correctly + pointing at the generated Elixir module name, and `.gitignore` + excluding `/target`. Adds `:rustler ~> 0.32` to mix.exs deps. + Same static-link warning as zigler. 8 new tests; updated the + unknown-type test to use `haskell` (since `rustler` is now + valid). + + - iter 5: docs. `README.md` gains a top-level `mix mob.add_nif` + section with the four `--type` variants, the matrix of generated + files + Hex deps per type, and an explicit static-link gotcha + callout for zigler/rustler. The Mix tasks table now lists + `mob.add_nif` and `mob.regen_driver_tab` (the latter wasn't + documented at all before). `AGENTS.md` adds two gotchas: don't + edit `:static_nifs` in mob.exs by hand (use `mob.add_nif`), and + `mob.regen_driver_tab` reads from `Config.Reader` not + `Application.env` (template for future `:static_nifs` consumers). + + **Phase 3 is COMPLETE.** `mix mob.add_nif <name>` covers all four + backend types (`elixir-only`, `c`, `zigler`, `rustler`), composes + with regen automatically, and is documented in README + AGENTS.md. + 864/864 mob_dev tests pass on master. First Igniter-backed task + validated end-to-end; pattern is ready to apply to the heavier + Phase 4 (`mob.enable` rewrite) and Phase 5 (`mob.new --liveview` + generator rewrite). + +## Phase 4 — `mix mob.enable` → Igniter (in progress) + +The plan called for 3-4 weeks, one feature per iter (camera → ... +→ python). Iter 1 collapsed that into a single sweep because the +per-feature text-mutation logic is small enough to wrap uniformly. + + - iter 1: `Mix.Tasks.Mob.Enable` is now `use Igniter.Mix.Task`. + All seven features (camera, photo_library, location, + file_sharing, notifications, liveview, python) dispatch through + `MobDev.Enable.Igniter` per-feature handlers that return + `igniter -> igniter`. Wins from the conversion: + - Single diff preview + atomic apply across all features. + - Per-handler idempotency via Igniter's `update_file` rather + than the legacy "read content, conditional write, log to + Mix.shell" pattern (which scattered idempotency checks). + - Missing platform dirs → notices instead of silent file-not- + found, so the user sees what was/wasn't done. + The text-mutation logic itself (the Sourceror regex patches in + `MobDev.Enable`) is unchanged from the legacy path — kept inside + the Igniter wrappers. AST-aware deepening for the two features + that touch Elixir source (liveview + python) is iter 2. + 8 new tests; 38 legacy `MobDev.EnableTest` helper tests still pass. + 872/872 mob_dev tests pass on master. Smoke-tested on + phase2q_smoke: `mix mob.enable photo_library --yes` added the + plist key cleanly with the expected notice about Android. + + - iter 2: AST-aware pythonx dep injection. Replaces + `MobDev.Enable.inject_pythonx_dep/1` (regex on mix.exs source) + with `Igniter.Project.Deps.add_dep({:pythonx, "~> 0.4"})` — parses + the project's `defp deps do [...]` AST and appends in-place, + idempotent automatically. Liveview's `mob_screen.ex` and python's + `python_paths.ex` generation already went through + `Igniter.Project.Module.create_module` in iter 1, so this closes + the last text-on-Elixir manipulation in `mob.enable`. The + remaining text-level patches (JS, HEEX, plist, AndroidManifest) + are non-Elixir source and stay text-level — AST tooling for those + isn't a win. Drive-by fix: `mix mob.enable` was reading the + on-disk mix.exs for app name, which under `Igniter.test_project` + saw mob_dev's own app name; switched to + `Igniter.Project.Application.app_name/1` with on-disk fallback. + 3 new tests under the python describe; 875/875 mob_dev tests pass. + + - iter 3: docs. `README.md` gains a top-level + `mix mob.enable <feature>` section with a per-feature surface + table (iOS / Android / Elixir columns) and the diff-preview UX + notes. The Mix tasks index gains a `mob.enable` row. + `AGENTS.md` adds three gotchas: how to add a new feature + (dispatch + handler + valid_features list, plus when to reach for + AST-aware Igniter helpers vs text-level `update_file`); file + discovery in `Enable.Igniter` must use Igniter's view of the + filesystem (not raw `File.exists?`) so `test_project` virtualized + files are findable; app name reads should go through + `Igniter.Project.Application.app_name/1` rather than the on-disk + mix.exs. + + **Phase 4 is COMPLETE.** `mix mob.enable` is fully Igniter-driven; + all seven features route through `MobDev.Enable.Igniter` handlers + with diff preview, atomic apply, and idempotent semantics. AST-aware + Igniter helpers (`Project.Deps.add_dep`, `Project.Module.create_module`, + `Project.Config.modify_config_code`) cover every Elixir-source + mutation in the enable path; the regex-on-mix.exs sweep that the + plan flagged as the highest fragility is gone. 875/875 mob_dev + tests pass on master. + + Phase 5 (the `mob.new --liveview` generator rewrite — biggest + remaining fragility per the plan) is next. + +## Phase 5 — `mob.new --liveview` AST rewrite (COMPLETE) + +The plan called this "highest-value rewrite — biggest fragility removed." +On audit, the actual surface was smaller than expected: the file is +923 lines, but most of it is string templates that generate FRESH +Elixir files (mob_screen.ex, page_live.ex, repo.ex, …), not regex +patches against existing source. The one remaining regex-on-Elixir +was `inject_deps/3` — patching the user's mix.exs after `mix phx.new` +runs. Two iters closed it. + + - iter 1: AST-aware `inject_deps` via Sourceror. The old version + matched `defp deps do\\s*\\[` and inserted dep tuples at the head + of the list. Brittle when phx.new's mix.exs varied across Phoenix + versions or formatter configs — and we had debugged it twice + already (`:re.import/1` + Elixir version drift in the OTP 29 + rebuild). The new flow: + - `Sourceror.parse_string(content)` — full AST with comments. + - `Macro.prewalk` walks to `def(p) deps do [...] end`. + - Append the parsed dep tuples to the list. + - `Sourceror.to_string(ast)` — round-trip back to source. + Idempotency now scans the AST for `:mob` declarations regardless + of indentation or trailing-comma shape. Bails out safely (returns + content unchanged) when the deps function uses an unmatched shape + like `defp deps, do: [...]` shorthand. + + `sourceror ~> 1.0` added to mob_new deps. Chose direct Sourceror + over Igniter because Igniter's `Project.Deps.add_dep` is tied to + igniter state + the full Igniter.Mix.Task lifecycle, both overkill + for a one-shot patch from inside a regular Mix.Task generator. + Same underlying AST machinery, simpler boundary. + + 3 new tests covering the new AST cases (empty deps list, + shorthand form no-op, round-trip parse to validate output is + still legal Elixir) plus the existing 4 inject_deps tests + unchanged. 224/224 mob_new tests pass. + + - iter 2: docs. AGENTS.md gotchas gains two bullets — one on the + AST-vs-regex convention for future maintainers, one on + sourceror's archive-size cost. README unchanged (inject_deps is + internal, not user-facing API). + + **Phase 5 is COMPLETE.** The "regex-patched Elixir source in the + LV generator" stop criterion is hit. The remaining regex usage in + `live_view_patcher.ex` operates on JavaScript (`inject_mob_hook`) + and HEEX (`inject_mob_bridge_element`) source — both non-Elixir + and not in scope for AST tooling. The `insert_hooks_before_closing` + helper that wires MobHook into the LiveSocket call already uses + brace-depth line tracking instead of regex, by design. + + Build-system migration phases 0-5 are complete. Remaining work + (Phase 6 polish — comptime driver_tab.zig, Android C → Zig, + release-script zig cc swap deferred from Phase 1) is independently + valuable but not blocking. + +## Phase 6a — `driver_tab.zig` comptime-generated (in progress) + +The plan called for moving driver_tab from generated C to generated +Zig with comptime structure replacing `#ifdef` preprocessor gates. +Shipped in three iters across all three repos; end-to-end Zig path +validated on phase2q_smoke. + + - iter 1 (mob): hand-coded `driver_tab_{ios,android}.zig` as + reference impl. Validates Zig's `export` keyword produces the + C-ABI symbols libbeam.a expects (`erts_static_nif_tab`, + `driver_tab`, `erts_init_static_drivers`). Standalone-compile + against all three targets — aarch64-ios-simulator, + aarch64-ios, aarch64-linux-android — all produce the right + symbol layout. Comptime `if (sqlite_static) ... else ...` + replaces the C `#ifdef MOB_STATIC_SQLITE_NIF`. + + - iter 2 (mob_new): build.zig template gains `addZigObject` + helper paralleling `addCObject`. driver_tab call site + auto-detects file extension and routes to the right helper. + + - iter 3 (mob_dev + mob_new + mob): + * mob_dev: `MobDev.StaticNifs.generate/3` accepts + `format: :c | :zig` (defaults to :c). The Zig output mirrors + the hand-coded reference from iter 1, with comptime gates + for guarded NIFs. `mix mob.regen_driver_tab --format zig` + writes `priv/generated/driver_tab_{ios,android}.zig`. + `MobDev.NativeBuild`'s three driver_tab resolution sites + prefer .zig over .c in priv/generated, falling back to mob's + reference files in either extension. + * mob_new: build_device.zig template gets the same + addZigObject helper plus `b.addOptions(sqlite_static)` for + the device path. build.zig (sim) provides + sqlite_static=false so the same .zig file compiles + unconditionally for both targets. + * mob: `ios/driver_tab_ios.zig` switches `sqlite_static` from + iter-1's hardcoded false to + `@import("build_options").sqlite_static`. + + Smoke test on phase2q_smoke: regen --format zig + clean + `mix mob.deploy --native --device <sim>` succeeded. The + `.zig-cache/o/<hash>/driver_tab_ios.o` has the expected C-ABI + exports (`_erts_static_nif_tab`, `_driver_tab`, + `_erts_init_static_drivers`). Happy discovery: Zig's + `addCSourceFile` auto-detects .zig extension and routes to the + Zig compiler internally, so older project build.zig files + (without an `addZigObject` helper) keep working unchanged when + they receive a .zig driver_tab path. + + 7 new StaticNifs tests covering both formats including round- + trip `zig ast-check` of generated output. 882/882 mob_dev tests, + 224/224 mob_new tests pass. + + - iter 4: Zig is the default `mix mob.regen_driver_tab` format. + `parse_format(nil)` now calls `detect_default_format/0` which + reads `ios/build.zig` and picks: + * `:zig` if the file contains `addZigObject` (post-iter-2 + template); + * `:c` otherwise (legacy template that can't compile .zig + source via `addCObject`'s addCSourceFile pipeline on Zig + 0.17-dev); + * `:zig` when no build.zig exists yet (rare). + This makes the regen-default flip safe in-place: existing + projects continue producing C output that their old build.zig + can handle, while freshly-generated projects pick up Zig + automatically. `--format zig` / `--format c` override the + auto-detect explicitly. + + C NIF authors are fully unaffected: `mix mob.add_nif --type c` + still drops `c_src/<name>.c`; the Zig dispatch table calls into + user C code via standard C ABI (`extern fn <name>_nif_init() + callconv(.c)`); `--format c` is always available for projects + that want hand-editable dispatch tables. + + mob's redundant `ios/driver_tab_ios.c` + + `android/jni/driver_tab_android.c` reference files deleted — + nothing reads them after iter 3's resolve_driver_tab_* prefers + .zig. mob_new template comments + `b.option` help-strings + updated to mention `.{zig,c}` to reflect the new default with + legacy support. + + Smoke-tested both detection paths against `phase2q_smoke` + (legacy template) and a marker-injected variant (simulated new + template). Auto-detect picks the right format both ways; + deploys succeed end-to-end. 13/13 regen tests pass (added 2 + auto-detect-path tests); 884/884 mob_dev tests pass overall. + + **Phase 6a is COMPLETE.** Zig is the default driver_tab format + across the build system. Old projects keep working via the + auto-detect fallback to C. New projects ship Zig out of the box. + C NIF authoring path remains fully supported via `--type c` for + scaffolding and `--format c` for dispatch-table output. + + Optional further follow-ups (not required for Phase 6a closure): + wire `mix mob.regen_driver_tab` into `mix compile` so the regen + step disappears from user workflow entirely. + +## Phase 6b — Android C → Zig (in progress) + +The plan: migrate Android's `mob_nif.c` (~2570 lines) and +`mob_beam.c` (~540 lines) — about 3100 lines of C with non-trivial +JNI ergonomics — to Zig. Done incrementally so each iter ships +something useful even if the total project pauses. + + - iter 1 (toolchain plumbing): mob_new's Android build.zig template + gets the `addZigObject` helper and auto-detects file extension + in its source iteration loop. Same pattern the iOS templates use + (Phase 6a iter 2-3). The four sources Android handles — + driver_tab_android, mob_nif, mob_beam, beam_jni — can each be + `.zig` or `.c` now without touching the call site. + + `b.option` help-string + header comment updated to acknowledge + `.{zig,c}` extension. mob_dev's NativeBuild already resolves + `driver_tab_android.zig` over `.c` since Phase 6a iter 3, so the + full chain works end-to-end without further mob_dev work. + + Verified: rendered Android build.zig.eex through EEx + + `zig ast-check` passes; full Android native build through + `mix mob.deploy --native --device <emu>` succeeds against + phase2q_smoke with the new template. 224/224 mob_new tests pass. + + Iter 1 ships only the build plumbing. The real translation + (mob_beam.c → mob_beam.zig as iter 2; mob_nif.c → mob_nif.zig + across several iters as iter 3+) starts from a known-working + toolchain. + + - iter 2 (mob_beam.c → mob_beam.zig): full port of the Android + BEAM launcher (~540 lines). All load-bearing behaviour + preserved byte-for-byte: + • cold-start race fix (window-focus wait that prevents the + FORTIFY pthread_mutex SIGABRT against hwui's first-draw + setup — DO NOT REMOVE comment block kept verbatim) + • SELinux exec rules for ERTS bin symlinks + • Play Store split-APK fallback for exqlite/pythonx priv-dir + wiring + • BEAM stdio → logcat capture pipeline + + Foundation FFI bindings hand-declared in + `android/jni/mob_zig.zig` (~470 lines: JNI vtable, libc, + Android log, dlfcn, pthreads). Zig 0.17-dev's `@cImport` + builtin is gone and `zig translate-c` hangs at 99% CPU on + the Android NDK's `jni.h` (deep recursive include tree). + Hand-declaring sidesteps both. Surface is stable — JNI ABI + hasn't materially changed since Java 1.1 (1997). Reusable + for iter 3+ mob_nif.zig work; new vtable slots get added as + that surface needs them. + + Comptime gates replace `#ifdef`: + • `no_beam` — battery baseline config; default false + • `beam_flags_mode` — picks the default scheduler-tuning + argv shape ("untuned" / "sbwt_only" / "nerves_full"); + default "nerves_full". Runtime override file + (`beams_dir/mob_beam_flags`, written by + `mix mob.deploy --schedulers N`) still wins. + + Threaded via `b.addOptions()` from the per-app Android + `build.zig.eex` template (companion mob_new commit). The + `addZigObject` helper already accepted `?*Step.Options` from + iter 1, so wiring was four lines in the source-iteration + loop. + + Verified: standalone `zig build-obj -target + aarch64-linux-android.24` produces a clean object. Exported + symbols (`mob_init_bridge`, `mob_start_beam`, + `mob_ui_cache_class`) match the C surface; + undefined references match what mob_nif.c provides + (`g_jvm`, `g_activity`, `_mob_ui_cache_class_impl`, + `_mob_bridge_init_activity`, `mob_set_startup_phase`, + `mob_set_startup_error`) plus libbeam's `erl_start` plus + standard bionic / libdl / liblog. `mob_beam.c` deleted; + `mob_beam.h` retained (still included by per-app + `beam_jni.c`). Full Android smoke test (mix mob.deploy + --native against an emulator) deferred to iter 3 prep so + the BEAM launcher + the next mob_nif.zig slice ship + together — the build chain is verified at object-link + granularity here. + + - iter 3+ (mob_nif.c → mob_nif.zig): ~2570 lines, 79 NIF + functions. Will land across several iters, grouped by NIF + family (UI/render, gesture senders, device capabilities, + WebView, alerts, color-scheme, etc.). The mob_zig.zig FFI + binding module from iter 2 covers the JNI surface today; + additional CallStaticXxxMethod / array-op vtable slots get + added as each iter needs them. + + - iter 3a (foundation + 3 standalone NIFs): the inaugural slice. + Establishes the cross-language linkage pattern that the + remaining sub-iters will reuse: + + • NEW `android/jni/mob_erts.zig` — hand-declared ERL_NIF + FFI surface (ERL_NIF_TERM, ErlNifEnv, ErlNifPid, + ErlNifMutex, ErlNifBinary, ErlNifFunc, ErlNifCharEncoding, + plus the enif_make_* / enif_get_* / enif_inspect_* set + that iter 3a's NIFs need). Companion to mob_zig.zig — + same rationale (Zig 0.17 @cImport is gone, translate-c + unreliable on deeply nested OTP headers, surface small + + stable enough to hand-declare). + • NEW `android/jni/mob_nif.zig` — exports `nif_platform/0`, + `nif_log/1`, `nif_log/2`. Byte-for-byte equivalent to + the C versions removed from mob_nif.c. + • mob_nif.c shrinks ~35 lines (3 NIF defs + 1 helper); the + static `ErlNifFunc nif_funcs[]` table now resolves those + functions at link time via an `extern ERL_NIF_TERM ...` + block near the top. As iter 3b/3c/3d port more NIFs, the + extern block grows and the .c file shrinks. iter 3d + moves the table itself to Zig and removes mob_nif.c. + + Two .o files coexist in the link — `<abi>/mob_nif.o` (the + shrinking C side) and `<abi>/mob_nif_zig.o`. Both contribute + symbols to lib<app>.so. The mob_new build template adds the + .zig source as a separate spec entry; the loop already + handles per-source .zig vs .c detection from iter 1. + + Verified: standalone `zig build-obj -target + aarch64-linux-android.24` produces a clean mob_nif.o; symbol + check confirms `nif_platform`, `nif_log`, `nif_log2` + exported and only the expected ERL_NIF / Android-log + undefined references. mob_nif.c passes clang-format. + 224/224 mob_new tests + full mob test suite pass. Full + Android end-to-end smoke deploy deferred to bundle with the + next sub-iter so we test once over a meaningful slice. + + - iter 3b (test harness + cached Bridge + get_jenv): the big + coordination move. Ported in one shot: + + • cached `BridgeMethods` extern struct (52 method-ID fields) + — moved to mob_nif.zig as `pub export var Bridge`; the C + side keeps a matching `struct BridgeMethods` declaration + + `extern struct BridgeMethods Bridge` so the senders (iter + 3c) and feature NIFs (iter 3d) still in C can read it. + Field order is load-bearing — any future change has to + land in both files together. + • `get_jenv` (the thread-attach helper that ~25 C-side + callers use) — moved with C-ABI export so existing call + sites are unaffected. + • 13 test harness NIFs: `ui_tree`, `ui_view_tree`, + `screen_info`, `ui_debug`, `ax_action{,_at_xy}` (Android + stubs), `tap`, `tap_xy`, `type_text`, `delete_backward`, + `key_press` (Android stub), `clear_text`, `long_press_xy`, + `swipe_xy`. Coordinates in dp, matching iOS. + • `jstring_to_bin` / `cstr_to_bin` helpers (Zig-private — + only the test harness used them). + + FFI binding extensions: + + • mob_zig.zig: typed previously-opaque JNI vtable slots + (GetStaticMethodID, CallStaticObjectMethod / BooleanMethod + / VoidMethod as variadic, NewStringUTF, DeleteLocalRef, + ExceptionClear, GetArrayLength, GetFloatArrayRegion). + Added padding for the intervening Array* slots so the + layout up to GetFloatArrayRegion matches AOSP jni.h + slot-for-slot. Wrappers (getStaticMethodID, newStringUTF, + deleteLocalRef, exceptionClear, getArrayLength, + getFloatArrayRegion) + extern malloc/free for the + unbounded-binary path in nif_tap / nif_type_text. + • mob_erts.zig: enif_make_list_cell, enif_make_list_from_array, + enif_make_tuple_from_array, enif_make_map_from_arrays, + enif_alloc_binary, enif_inspect_iolist_as_binary, + enif_get_int, enif_get_double + convenience wrappers + (makeTuple, makeList, makeMap, errorTuple, getNumber). + + mob_nif.c net change: -409 lines (the static Bridge struct, + get_jenv, jstring_to_bin/cstr_to_bin, 13 NIF defs replaced + by the named-struct declaration, extern block expansion, and + a pointer comment). + + Verified: standalone `zig build-obj -target + aarch64-linux-android.24` produces a clean mob_nif.o with 19 + exported symbols (Bridge, get_jenv, 17 nif_* — 3 from iter 3a + + 13 test harness + 1 ax_action_at_xy stub) and 20 undefined + refs that all resolve at production link (enif_* / libc / liblog + / g_jvm from mob_beam.zig). mob_nif.c passes clang-format. + 702/702 mob tests + 224/224 mob_new tests + credo strict + clean. mob_new template needs no change this iter — mob_nif.zig + source spec was wired in iter 3a and new NIFs are internal + to that file. + - iter 3c (senders + handle registries): the concurrency-heavy + core. mob_nif.c is 1500 lines after this iter — 41% reduction + from the 2568 it started at iter 3a, and the native code is + now 56% Zig. Moved: + + • Handle registries: `TapHandle` extern struct (with per- + handle throttle state) + `tap_handles[256]` + `tap_mutex` + + `tap_handle_next`; `ComponentHandle` + `component_handles[64]` + + `component_mutex`. `g_transition` (per-render transition + snapshot consumed by set_root). + • `mob_nif_init_state` — exported initializer that nif_load + (still in C) calls during BEAM init. Replaces the inline + `enif_mutex_create` pair that used to live in nif_load. + • 25 sender functions: `mob_send_tap`, + `mob_send_component_event`, `mob_send_change_{str,bool,float}`, + `mob_send_{focus,blur,submit,select,compose}`, the gesture + senders (long_press, double_tap, swipe_{left,right,up,down, + with_direction}), the throttled Tier-1 senders + (scroll/drag/pinch/rotate/pointer_move with seq + ts_ms + + the began/ended phase-boundary bypass), the Tier-2 single- + fire (scroll_began/_ended/_settled, top_reached, + scrolled_past), and `mob_handle_back`. + • Throttle infrastructure: `throttleCheck` (replaces C + `mob_throttle_check_a` — same throttle_ms / delta_threshold + / seq-bump semantics), `buildScrollMap`, `isPhaseBoundary`. + `snapTap` / `sendEvent` / `sendChange` are internal helpers + that lock the mutex, snapshot pid + tag + seq, then drop + the lock before `enif_send` so we never block delivery + with the mutex held. + • 6 NIFs that touch the registries: `nif_set_root`, + `nif_register_tap`, `nif_clear_taps`, `nif_set_transition`, + `nif_register_component`, `nif_deregister_component`. + + FFI extensions: + + • mob_erts.zig: enif_send, enif_self, enif_make_copy, + enif_alloc_env, enif_free_env, enif_mutex_create / _lock / + _unlock, enif_get_local_pid, enif_whereis_pid, + enif_make_int64 / _uint64, enif_get_tuple. The full + process-hop + mutex surface. + • mob_zig.zig: clock_gettime + CLOCK_MONOTONIC + nowNs() + wrapper for the throttle path's monotonic timestamps. + + Verified: standalone `zig build-obj -target + aarch64-linux-android.24` produces a clean mob_nif.o with 55 + exported symbols (Bridge + get_jenv + mob_nif_init_state + 25 + senders + mob_handle_back + 23 nif_*). 702/702 mob tests + + credo strict clean + clang-format clean. mob_nif.c lost ~666 + lines net. + - iter 3d (finale — mob_nif.c deleted, all-Zig NIF surface): + the multi-iter port is done. mob_nif.c is gone after starting + iter 3a at 2570 lines. The final Android native code surface + is 4457 lines: 2932 in mob_nif.zig, 281 in mob_erts.zig, 569 + in mob_zig.zig, 675 in mob_beam.zig. The only `.c` file + remaining in the Android native build is the per-app + `beam_jni.c` stub (JNI entrypoints + `g_jvm`/`g_activity` + globals), kept as C so app authors don't need Zig to read + their own JNI bridge. + + Moved in this iter: + + • Bridge bootstrap (`_mob_ui_cache_class_impl`, + `mob_set_startup_phase`, `mob_set_startup_error`, + `_mob_bridge_init_activity`) — exported with C ABI so + mob_beam.zig and beam_jni.c keep calling them unchanged. + • All remaining feature NIFs: color_scheme, exit_app, + safe_area, haptic, clipboard ×2, open_url, share_text, + biometric_authenticate, request_permission, location ×3, + camera ×4, photos_pick, files_pick, audio ×5, motion ×2, + scanner, notify ×3, storage ×4, alert/action_sheet/toast, + webview ×4, background ×2, device ×7 (dispatcher_set + + 6 stubs). + • Async result dispatchers (called from Kotlin via JNI): + `mob_deliver_atom2/atom3/location/motion/webview_message/ + webview_blocked/file_result/push_token/notification/ + alert_action`, plus the legacy `mob_nif_deliver_json` + no-op. + • Launch notification global + writer + take NIF. + • Mob.Device dispatcher pid + `mob_send_color_scheme_changed`. + • The `ErlNifFunc nif_funcs[]` table (75 entries; dirty-job + flags preserved on the four CPU-bound NIFs). + • `nif_load` BEAM callback — caches all ~45 method IDs and + creates the launch-notification mutex. Replaces the C-side + `CACHE`/`CACHE_OPT` macros with `cacheRequired` / + `cacheOptional` Zig inlines. + • Hand-built `ErlNifEntry` struct + `mob_nif_nif_init` — + replaces the `ERL_NIF_INIT(mob_nif, …)` C macro with a + plain Zig struct literal + `export fn` returning a + pointer to it. driver_tab_android.zig already extern- + declared the symbol from iter 3a, so the static-NIF link + path keeps working unchanged. + + FFI extensions: + + • mob_erts.zig: `ErlNifEntry` extern struct + the four + callback function-pointer typedefs (Load/Reload/Upgrade/ + Unload) + ERL_NIF_{MAJOR,MINOR}_VERSION constants + + ERL_NIF_MIN_ERTS_VERSION + ERL_NIF_VM_VARIANT + + ERL_NIF_DIRTY_JOB_{CPU,IO}_BOUND flag constants + + SIZEOF_ErlNifResourceTypeInit (the ABI-compat gate). + • mob_zig.zig: `strlen` + `strdup` extern decls (used by + deliver_* helpers + the launch-notification strdup-and- + store path). + + Verified: standalone `zig build-obj -target + aarch64-linux-android.24` produces a clean mob_nif.o with + 124 exported symbols. Every reference beam_jni.c needs + (`mob_send_*`, `mob_deliver_*`, `mob_handle_back`, + `mob_set_launch_notification`, `mob_init_bridge`, + `mob_ui_cache_class`, `mob_start_beam`, + `mob_send_color_scheme_changed`) resolves at link time. The + `mob_nif_nif_init` symbol the driver_tab references is now + exported from Zig. 702/702 mob tests + 224/224 mob_new tests + + credo strict clean on both. mob_new template drops + mob_nif.c from its source list — the only remaining `.c` is + `beam_jni.c`. + + The full Android end-to-end smoke deploy (mix mob.deploy + --native against a connected emulator) is the next thing to + run — it bundles best as its own verification commit so the + test path is explicit about exercising the all-Zig finale. + Once that's green, Phase 6b is complete. + + - iter 3d verification (smoke deploy, 2026-05-11): + **Phase 6b complete.** End-to-end deploy of a freshly- + generated `mob_smoke_6b` project against emulator-5556 + (aarch64-android.24) succeeded: full Zig pipeline cross- + compiled for both arm64-v8a and armeabi-v7a, NDK clang linked + cleanly, APK installed, OTP runtime + 382 BEAMs pushed, BEAM + booted into `Mob NIF loaded (Compose backend)`. The cold-start + race fix from iter 2's mob_beam.zig fired correctly + (`waited 1750 ms for window focus`); SELinux symlink dance + for the ERTS bins + exqlite NIF succeeded; `nif_load` cached + all required + optional method IDs. + + Four latent bugs surfaced at the boundary and were fixed + before the green run: + + * `mob_new` build template — Zig module `.pic = true` + missing on `createModule`. `mob_beam.zig`'s `default_flags` + comptime array of pointers to string literals emitted + R_AARCH64_ABS64 relocations against local symbols, which + ld.lld refused in a shared library. The pure-compile + `zig build-obj` standalone check didn't catch this — the + relocations are only validated at link time. + * `mob_new` build template — `addLink` produced the cp step + that installs `lib<app>.so` into jniLibs/ but didn't + return it. `addExqliteLink` referenced the installed path + as a plain string arg (not a LazyPath), so the two link + steps raced and exqlite's clang errored out with `no + such file`. Fix: `addLink` returns the cp step; + `addExqliteLink.depends_on` carries the edge. + * `mob_erts.zig` — bare `extern fn enif_make_int64` / + `enif_make_uint64` failed dlopen with `cannot locate + symbol "enif_make_int64"`. OTP's `erl_nif_api_funcs.h` + does `#define enif_make_int64 enif_make_long` when + `SIZEOF_LONG == 8`; on aarch64-android (LP64) the real + libbeam.a symbol is `enif_make_long`. Zig doesn't run + the C preprocessor — fixed by switching to `@extern` + with comptime symbol-name selection (`enif_make_long` + on 64-bit, `enif_make_int64` on 32-bit where the alias + doesn't fire). + * `mob_nif.zig` — `pidToJlong` / `pidFromLong`'s `@bitCast` + failed to compile on armeabi-v7a: ERL_NIF_TERM is u32 + there but jlong is always i64. Fixed with a comptime + `@sizeOf` branch: bitcast on 64-bit, zero-extend/truncate + on 32-bit. Matches the C original's `memcpy(min(sizeof))` + dance. + + Lesson: **the pure-compile standalone check pattern caught + every compile error but no link error and no runtime error.** + For future iters touching the Zig native build, plan on the + end-to-end smoke deploy as a separate verification step — + object compile and test-suite pass are necessary but not + sufficient. The mob_new template's `mix test --only lint` + pipeline could grow a "zig build emit-relocatable" step that + actually links, which would have caught the PIC bug + pre-merge; queued as a follow-up. + + Bugs fixed in: mob `50f87bb` (mob_erts.zig + mob_nif.zig), + mob_new `481bcd5` (build.zig.eex template). Smoke-tested + project preserved at `/tmp/mob_smoke_6b/` for inspection. diff --git a/common_fixes.md b/common_fixes.md index d8785f0e..721e300c 100644 --- a/common_fixes.md +++ b/common_fixes.md @@ -125,19 +125,50 @@ Then after crash: `adb shell "run-as com.mob.demo cat /data/user/0/com.mob.demo/ ## iOS BEAM crashes with `eaddrinuse` when Android is also connected -**Symptom**: iOS simulator app exits immediately. `xcrun simctl launch --console` shows: +**Symptom**: iOS simulator app exits immediately after launch — sim returns to +the home screen, no app UI ever renders. No crash report. `Documents/beam_stdout.log` +inside the sim's app container (or `xcrun simctl launch --console`) shows: `Protocol 'inet_tcp': register/listen error: eaddrinuse` -**Root cause**: `mob_beam.m` defaults to dist port 9100 when `MOB_DIST_PORT` is not set. -When an Android device is connected, `adb forward tcp:9100 tcp:9100` is active and holds -port 9100 on localhost. The iOS BEAM tries to bind the same port for Erlang distribution -and fails. +**Root cause**: When an Android device or emulator is connected, `mob_dev`'s +Tunnel sets up `adb forward tcp:9100 tcp:9100` (and 9101+ for additional +devices) which binds those ports on `127.0.0.1`. iOS simulators share the +Mac's network stack, so any sim trying to bind the same port collides. -**Fix**: Default iOS dist port changed from 9100 → 9101 in `mob/ios/mob_beam.m`. -Per the port assignment scheme: Android = 9100, iOS sim = 9101. -Requires a native rebuild (`mix mob.deploy --native --ios`). +`mob_beam.m`'s default is 9101 (good), but `mob_dev`'s `MobDev.Discovery.IOS.launch_app/3` +and `MobDev.Tunnel.dist_port/1` actively set `SIMCTL_CHILD_MOB_DIST_PORT` per-device +starting at 9100, overriding the safe default. Single-device iOS-sim deploys +(`mix mob.deploy --device <udid>`) hit index 0 → port 9100 → collision. -**Fixed in**: `mob/ios/mob_beam.m` (2026-04-14). +**Workaround**: Pass an explicit port outside the adb forward range: + +```bash +mix mob.deploy --device <ios-sim-udid> --dist-port 9200 +``` + +The `--dist-port` flag landed in `mob_dev 0.5.10` (paired with `mob 0.6.10`'s +`MOB_NODE_SUFFIX` env var support). + +**Diagnostic first-pass** when an iOS sim launches and immediately dies: + +```bash +lsof -nP -iTCP:9100-9199 -sTCP:LISTEN | grep adb +``` + +Anything held by adb is poisoned for sim use until the Android device is +unplugged or the auto-allocator routes around it. + +**Real fix (open)**: `MobDev.Tunnel` should base iOS-sim dist ports above the +adb forward range (e.g. 9200+) so the auto-allocator is collision-free without +needing `--dist-port`. The 2026-04-14 fix in `mob_beam.m` (default port 9100 → +9101) was correct for the env-var-not-set path but mob_dev now actively sets +the env var, so the framework-side default no longer protects. + +**Surfaced in**: `guides/troubleshooting.md` ("iOS simulator: BEAM dies silently +when an Android device is also connected") and `~/.claude/.../memory/feedback_ios_sim_adb_port_collision.md`. + +**Fixed in**: `mob/ios/mob_beam.m` default (2026-04-14). Regressed via mob_dev +launcher setting the env var explicitly; rediscovered 2026-05-19. --- @@ -959,3 +990,164 @@ places, lock-step: test enforces equality). 3. `mob_dev/scripts/release/openssl/_lib.sh` — `NDK_VERSION` default. + +## Android `:inet.getaddr/2` returns `:nxdomain` on physical devices (works on emulator) + +**Symptom** — On a deployed mob app on a physical Android device, the +BEAM can't resolve hostnames: + +```elixir +:inet.getaddr(~c"repo.hex.pm", :inet) +#=> {:error, :nxdomain} +``` + +…but the SAME app can `:gen_tcp.connect/3` to a hardcoded IP fine, and +`adb shell ping` from the device works. The Android emulator does NOT +hit this — it works there — which is why this didn't show in early +testing. Verified on Moto G Power 5G 2024 (Android 14). + +**Root cause** — BEAM's default DNS path forks `inet_gethost` (a port +program) and reads what its `getaddrinfo` returns. On a physical +Android device, Bionic's `getaddrinfo` *in the execve'd child* of the +app process doesn't pick up the per-network DNS servers the way the +app's own in-process HTTPS stack does. We suspect this is related to +how `libnetd_client.so` routing into `netd` survives across execve, +but we haven't pinned it down — happy to take a PR with the actual +diagnosis. + +**Fix** — Resolve in-process via `Mob.DNS.resolve/1`, which calls +Bionic's `getaddrinfo` from a NIF and seeds `:inet_db` with the +result. Subsequent `:inet.getaddr/2` lookups hit the seeded `:file` +entry and succeed: + +```elixir +def on_start do + # Preresolve the hosts your app/notebook needs at startup. Idempotent; + # cheap; works on iOS, Android-physical, and Android-emulator alike. + Mob.DNS.preresolve(["repo.hex.pm", "hex.pm", "api.example.com"]) + + # …rest of startup. Any subsequent Req/Finch/Mint/Mix.install call for + # these hosts will find the seeded entry. +end +``` + +For a host not known until request-time, call `Mob.DNS.resolve/1` +just before the request. See the `Mob.DNS` moduledoc for the +cellular caveat and the `configure_pure_beam/1` fallback. + +**Background-app caveat** — Android's App Standby / battery +optimizer blocks *all* outbound network from a backgrounded mob +app (TCP-by-IP too, not just DNS). The DNS fix above only matters +once the app is foregrounded or running under a foreground +service. Symptom: any socket attempt returns `:closed` / `:timeout` +immediately. Bring the app foreground or attach a foreground +service before triggering long-lived network work. + +## `FunctionClauseError` in `pubkey_os_cacerts.conv_error_reason/1` (Android, missing CA bundle) + +**Symptom** — In a mob app on a real Android device, an HTTPS request via +Req / Mint / Finch / anything using OTP-26+ default `:ssl` opts crashes: + +``` +** (FunctionClauseError) no function clause matching in + :pubkey_os_cacerts.conv_error_reason/1 + (public_key 1.21) :pubkey_os_cacerts.conv_error_reason(:no_cacerts_found) +``` + +Hex itself doesn't hit this — it bakes its own CA bundle into +`Hex.HTTP.SSL` — so `mix install/2` may succeed where the *first* call +from a user dep fails. The crash also doesn't appear on iOS or on the +Android emulator (their `:ssl` defaults pick up the OS trust store). + +**Root cause** — `:public_key.cacerts_load/0` (called by `:ssl`'s +defaults at OTP 26+) probes `/etc/ssl/certs/ca-certificates.crt` and a +handful of distro-specific paths. None of those exist on Android — the +system trust store lives behind a Java API that BEAM's `:public_key` +doesn't reach. `cacerts_get/0` then raises with `no_cacerts_found`, and +in some OTP versions `pubkey_os_cacerts.conv_error_reason/1` doesn't +have a clause for that — the surface error becomes a +`FunctionClauseError` rather than the cleaner `no_cacerts_found`. + +**Fix** — Bundle a CA-bundle PEM in your app `priv/` (the conventional +source is the `castore` hex package — copy `cacerts.pem` into your priv +at build time) and call `Mob.Certs.load_cacerts!/1` once at startup +*before* anything tries TLS: + +```elixir +def on_start do + Mob.Certs.load_cacerts!(Application.app_dir(:my_app, "priv/cacerts.pem")) + # …rest of startup… +end +``` + +See the `Mob.Certs` moduledoc for the rationale, the cross-platform +notes (iOS and the Android emulator don't need this, but calling +unconditionally is safe), and the available functions. + +## Runtime `Mix.install` of a rebar3-built dep fails on Android (telemetry / jose / jiffy / …) + +**Symptom** — In a notebook setup cell on a real mob app (Livebook-style), +`Mix.install/2` of anything that transitively pulls a rebar3-built +Erlang dep — `telemetry` is the common one (Req → Mint → telemetry, +Phoenix → telemetry, etc.) — crashes: + +``` +** (Mix.Error) Could not compile dependency :telemetry, +"/data/user/0/<pkg>/files/livebook/mix_home/elixir/1-19-otp-29/rebar3 bare compile --paths …" +command failed. + (mix 1.19.5) lib/mix/tasks/deps.compile.ex:276: Mix.Tasks.Deps.Compile.do_rebar3/2 +``` + +Pure-Mix deps install fine. The rebar3 path fails because there is no +`rebar3` binary at the expected `$MIX_HOME` location, and even if you +copy one there, `app_data_file` SELinux context blocks `execve()` of +files under the app's writable storage. Same wall as `inet_gethost` +hits — and the fix is the same JNI-extracted-shared-lib trick. + +**Fix** — Bundle the chain ERTS needs to spawn a fresh BEAM, plus +rebar3's escript, as `lib<name>.so` files in your app's +`android/app/src/main/jniLibs/<abi>/`: + +| File in jniLibs | What it is | Source | +|---|---|---| +| `libescript.so` | OTP escript runner | `$OTP_ANDROID/erts-<vsn>/bin/escript` | +| `liberlexec.so` | BEAM launcher (also serves as `erl`) | `$OTP_ANDROID/erts-<vsn>/bin/erlexec` | +| `libbeam_smp.so` | The BEAM VM itself (~32 MB unstripped) | `$OTP_ANDROID/erts-<vsn>/bin/beam.smp` | +| `librebar3_data.so` | rebar3's escript archive | `~/.mix/elixir/<vsn>/rebar3` | +| `librebar3.so` | tiny `/system/bin/sh` wrapper (see below) | written at build time | + +`mob_beam.zig`'s optional-symlink list picks these up at boot: +`BINDIR/escript`, `BINDIR/erl`, `BINDIR/erlexec`, and `BINDIR/beam.smp` +all become `apk_data_file`-context-exec-able. `MOB_NATIVE_LIB_DIR` +exposes the nativeLibDir path so the app can reach the bundled files +at runtime. + +The rebar3 wrapper (`librebar3.so` content): + +```sh +#!/system/bin/sh +exec "${BINDIR}/escript" "${MOB_DATA_DIR}/rebar3" "$@" +``` + +…plus an app-side step at startup to symlink `librebar3_data.so` to a +filename of `rebar3` (escript derives the module name from the +file's basename, and rebar3's archive exports `rebar3:main/1`): + +```elixir +File.cp_r!(Path.join([System.get_env("MOB_NATIVE_LIB_DIR"), "librebar3_data.so"]), + Path.join([System.get_env("MOB_DATA_DIR"), "rebar3"])) +System.put_env("MIX_REBAR3", Path.join([System.get_env("MOB_NATIVE_LIB_DIR"), "librebar3.so"])) +``` + +`$OTP_ROOT/bin/<name>.boot` symlinks (`no_dot_erlang.boot`, `start.boot`) +are normally created by standard OTP's `bin/Install` script — mob's +deploy skips that. Materialize them lazily at app boot. + +Verified end-to-end on a Moto G Power 5G 2024 (Android 14): +`Mix.install([{:req, "~> 0.5"}])` resolves Req's full tree, compiles +`telemetry` via on-device rebar3, and a follow-up `Req.get!/2` returns +real JSON over real TLS. + +**Trade-off** — ~33 MB APK size for the bundled `beam.smp`. Pure-Mix +deps don't need any of this; if your app never runs rebar3 deps at +runtime, skip the whole bundling and avoid the cost. diff --git a/crypto_plan.md b/crypto_plan.md index 77a31f34..13b9fc38 100644 --- a/crypto_plan.md +++ b/crypto_plan.md @@ -411,4 +411,4 @@ sync. Bump in one commit. | How is OTP cross-compiled per target? | `~/code/mob_dev/build_release.md` | | Where does the `@otp_hash` get bumped? | `~/code/mob_dev/lib/mob_dev/otp_downloader.ex` | | What does the per-project `ios/build.sh` template look like? | `~/code/mob_new/priv/templates/mob.new/ios/build.sh.eex` (or wherever the template lives) | -| Where is the OTP source tree? | `~/code/otp` (currently at `OTP-29.0-rc2-256-g73ba6e0f92`, erts-16.3) | +| Where is the OTP source tree? | `~/code/otp` (track `maint-29` for OTP 29.0+, erts-17.0+) | diff --git a/decisions/2026-05-21-rustler-beam-library-path.md b/decisions/2026-05-21-rustler-beam-library-path.md new file mode 100644 index 00000000..66ed6be7 --- /dev/null +++ b/decisions/2026-05-21-rustler-beam-library-path.md @@ -0,0 +1,24 @@ +# Rustler-on-Android: host exports RUSTLER_BEAM_LIBRARY_PATH + +- Date: 2026-05-21 +- Status: accepted + +## Context +On Android Bionic, rustler's `nif_filler` used `dlopen(NULL)` to find `enif_*` +symbols, which fails for symbols statically linked into a sibling `.so` — so +rustler NIFs crashed at init in mob's static-link model. Our initial upstream +fix (GenericJam/rustler PR #726) used `dladdr` to self-resolve the `.so`, but +the rustler maintainer (filmor) preferred a smaller change exposing an env var +(rusterlium/rustler#733, `RUSTLER_BEAM_LIBRARY_PATH`) and did not want to +maintain the `dladdr` code path. + +## Decision +`mob_beam.zig` discovers the host `.so` path via `dladdr(&mob_start_beam)` and +exports `RUSTLER_BEAM_LIBRARY_PATH` before the BEAM starts; rustler reads it to +`dlopen` the right `.so`. We adopt filmor's env-var name to stay compatible +with the version that lands upstream. + +## Consequences +Rust NIFs resolve `enif_*` correctly on Bionic — verified end-to-end on a +physical arm64 device. Shipped in mob 0.6.18 (renamed from the earlier +`RUSTLER_NIF_LIB_PATH` used in 0.6.16/0.6.17). diff --git a/decisions/2026-05-26-data-dir-helper.md b/decisions/2026-05-26-data-dir-helper.md new file mode 100644 index 00000000..f15dd473 --- /dev/null +++ b/decisions/2026-05-26-data-dir-helper.md @@ -0,0 +1,29 @@ +# Mob.data_dir/0 is the one writable per-app dir; MOB_BEAMS_DIR is read-only + +- Date: 2026-05-26 +- Status: accepted + +## Context +Apps need a writable place for runtime files (SQLite DBs, caches, downloaded +assets). There was no public helper, so code derived paths ad hoc — and a +Code-To-Cloud app derived its stem cache from `MOB_BEAMS_DIR`. That works on +Android (its beams dir is under the writable `filesDir`) but on iOS +`MOB_BEAMS_DIR` lives inside the signed, read-only `.app` bundle, so +`File.mkdir_p!` failed with `:eperm` and downloads silently never started. The +trap is invisible until an app ships to iOS. `Mob.State` already had the +correct `MOB_DATA_DIR || HOME || cwd` logic inline but didn't expose it. + +## Decision +Add `Mob.data_dir/0` (and `data_dir/1` for a created subdir) as the public, +documented writable-dir helper, resolving `MOB_DATA_DIR` (iOS +NSDocumentDirectory, Android filesDir) with `$HOME` then cwd fallbacks, and +creating the dir. Refactor `Mob.State` to use it. The doc explicitly warns +against `MOB_BEAMS_DIR` for writes. + +## Consequences +- One blessed, documented path for runtime writes; the iOS read-only-bundle + trap is called out at the point of use. +- `Mob.State`'s host/dev fallback shifts from `cwd/priv/repo` to `cwd` only in + the (rare) case where both `MOB_DATA_DIR` and `$HOME` are unset — on device + and normal dev hosts the path is unchanged. +- Apps caching/downloading files should switch to `Mob.data_dir/1`. diff --git a/decisions/2026-05-26-dns-cellular-resolution.md b/decisions/2026-05-26-dns-cellular-resolution.md new file mode 100644 index 00000000..01becc8a --- /dev/null +++ b/decisions/2026-05-26-dns-cellular-resolution.md @@ -0,0 +1,60 @@ +# On iOS, resolve/preresolve is the robust DNS path; configure_pure_beam fails on cellular + +- Date: 2026-05-26 +- Status: accepted + +## Context + +iOS apps couldn't reach servers by hostname (Mint/Req `:nxdomain`). On iOS +`inet_gethost` can't `execve`, so `Mob.DNS` offers two workarounds: + +- `configure_pure_beam/1` — flip the lookup chain to `[:file, :dns]` and seed + nameservers (default: public 8.8.8.8 / 1.1.1.1) for in-BEAM raw DNS. +- `resolve/1` · `preresolve/1` — call Darwin `getaddrinfo` via a NIF (iOS's own + resolver) and seed `:inet_db`'s `:file` table. + +**Device-verified on a physical iPhone over cellular (Wi-Fi off):** `resolve/1` +(`getaddrinfo`) returns `{:ok, ip}`; pure-`:dns` to the public resolvers returns +`{:error, :nxdomain}` — **carriers block public DNS**. (Also confirmed forcing +`:native` is *fatal* on iOS: `getaddrs` tries to exec `inet_gethost` and crashes +the BEAM.) + +We tried to fix `configure_pure_beam` by seeding the device's *own* resolvers +instead of public ones, but no reliable way exists to read them on iOS: + +- `res_ninit` / `res_getservers` returns `[]` on-device (reads the static, empty + `/etc/resolv.conf`). Confirmed on cellular. +- `dns_configuration_copy` (`<dnsinfo.h>`) is private — the header ships in no + SDK on this machine, so the structs would have to be hand-declared (ABI risk). +- `SCDynamicStore` needs the SystemConfiguration framework linked (a build change + in the in-flux mob_dev build) and may be sandbox-restricted on iOS. + +iOS intentionally abstracts the resolver away — there's no clean public "list my +DNS servers" API. Seeding nameservers for pure-`:dns` fights the platform. + +## Decision + +Don't extract iOS nameservers. Treat **`resolve/1` · `preresolve/1` as the +robust path** (they use the OS resolver — cellular-safe — and already exist); +**`configure_pure_beam` is a WiFi-friendly fallback** for hosts you can't +enumerate. Land the fix as documentation, not code: + +- `Mob.DNS` moduledoc now leads with `preresolve`/`resolve` ("robust everywhere, + incl. cellular") and demotes `configure_pure_beam` to a fallback. +- `configure_pure_beam/1` gains a **Cellular caveat** (public resolvers blocked; + no reliable way to read the carrier's; prefer `preresolve`/`resolve`) and an + iOS-gating note (don't run it on Android; never force `:native` on iOS). +- No change to `Mob.DNS` code — the working mechanism (`getaddrinfo` NIF) already + exists. Code-To-Cloud already preresolves its hosts in `on_start`. + +## Consequences + +- iOS apps that hit known hosts should `preresolve` them at startup (works on + cellular without IP-pinning). For request-time-only hosts, call `resolve/1` + first. +- `configure_pure_beam` stays useful where public DNS is reachable (most WiFi) + and for dynamic hosts there; its cellular limitation is now documented. +- If a clean iOS API for the system resolvers appears, or `SCDynamicStore` is + confirmed readable in-sandbox, `configure_pure_beam` could seed them by + default — revisit then. (An earlier `system_nameservers/0` NIF via + `res_getservers` was prototyped and dropped: no-op on iOS.) diff --git a/decisions/2026-05-27-plugin-phase1-host.md b/decisions/2026-05-27-plugin-phase1-host.md new file mode 100644 index 00000000..d7b12b2c --- /dev/null +++ b/decisions/2026-05-27-plugin-phase1-host.md @@ -0,0 +1,32 @@ +# Phase 1 plugin prototypes live in a new `mob_plugin_demo` repo, not `mob_m3_test` + +- Date: 2026-05-27 +- Status: accepted + +## Context +`plugin_extraction_plan.md` kickoff item 6 left the Phase 1 working host open: +`mob_m3_test` (default) vs. a dedicated `mob_plugin_demo` repo. The plan's +default was `mob_m3_test`, with the note that a dedicated repo "decouples +plugin-system iteration from theme work but adds repo overhead." + +Inspecting `mob_m3_test` at decision time: it is **not a git repo**, it is +theme-focused, and its `mix.exs` still pins `:mob` to the `material-3` worktree +(`{:mob, path: ".../mob/.claude/worktrees/material-3", override: true}`) — the +same worktree Phase 0 flagged for retirement. Building plugin prototypes there +would couple the plugin epic to in-flight theme work and a soon-to-be-retired +worktree, with no version control on the host itself. + +## Decision +Phase 1 prototype plugins (the `plugins/` directory and its `path:` deps) live +in a **new, dedicated `mob_plugin_demo` git repo**, depending on `mob` and +`mob_dev`. `mob_m3_test` is left to theme work. + +## Consequences +- Plugin-system iteration is decoupled from theme work and from the retiring + `material-3` worktree; the host has its own git history. +- One additional repo to maintain — accepted as worth it for a clean, + versioned, single-purpose host. +- Phase 0's "create `plugins/` dir at the working host" precondition is + satisfied inside this new repo, not `mob_m3_test`. +- Supersedes the plan's stated default; `plugin_extraction_plan.md` references + to `mob_m3_test` as the host should be read as `mob_plugin_demo`. diff --git a/decisions/2026-05-27-pure-elixir-composite-tier.md b/decisions/2026-05-27-pure-elixir-composite-tier.md new file mode 100644 index 00000000..fdc0b301 --- /dev/null +++ b/decisions/2026-05-27-pure-elixir-composite-tier.md @@ -0,0 +1,55 @@ +# Pure-Elixir composite components: reserve the `expand:` field, defer the expansion pass + +- Date: 2026-05-27 +- Status: accepted + +## Context +Evaluating a third-party UI kit (Mishka Chelekom, shadcn-style Phoenix/Tailwind +generator) surfaced a gap: `:ui_components` (tier 2) assumes **native backing** — +every entry maps `tag`/`atom` to a SwiftUI `view_module` and an Android +`composable`. There is no slot for a **pure-Elixir composite**: a `<Tag/>` that +expands to a built-in widget tree (e.g. `<MishkaCombobox/>` → `Column` + +`TextField` + `List`) with no native code. This is the headline ask from any +UI-kit author who doesn't write Swift/Kotlin. + +The capability already exists at **tier 0**: `def combobox(opts), do: ~MOB"..."` +invoked via the sigil's `{combobox(...)}` child slot — pure Elixir, hot-pushable, +ships as a plain Hex package with no manifest. What's missing is (a) `<Tag/>` +syntax and (b) auto-injected event targets so authors don't thread `self()` +through every component. + +Closing (a)+(b) requires a **third expansion pass** in `Mob.Screen.do_render/3`, +run before `Mob.List.expand` / `Mob.Component.expand`, recursing to a fixpoint +with a depth guard. All four of those modules are in the plugin epic's "stays in +core, finalised" set, and Phase 1 of the epic is explicitly **"No core churn."** +This is a renderer feature that benefits all components, not a plugin-extraction +feature. + +## Decision +Reserve the third manifest form in the spec now, without implementing it: + +```elixir +ui_components: [ + %{tag: "MishkaCombobox", atom: :mishka_combobox, + expand: {Mishka.Combobox, :expand}} # pure-Elixir, no :ios/:android — RESERVED +] +``` + +- Document tier-0 function composites (`{combobox(...)}`) as the **v1 answer** for + pure-Elixir UI kits. Authors can ship today. +- The `expand:` field is **reserved/planned** in the spec — declared but not yet + honored by mob_dev — so adopting it later is not a breaking change. +- The third expansion pass (and the auto-inject-event-targets ergonomics) is + carved into a **separate core-runtime track**, not gated by and not gating the + plugin epic. Its API should be designed against a concrete consumer. + +## Consequences +- Phase 1's "no core churn" invariant holds; the epic doesn't grow a renderer + rewrite. +- UI-kit authors have a working path now (tier 0) and a documented future path + (`expand:`), so the spec doesn't have to break to accommodate them later. +- The ergonomic prize (tag syntax + auto event-target wiring) waits for a real + consumer to drive the design — deliberately, to avoid speculative core API. +- Follow-up: when the core-runtime track picks up the expansion pass, supersede + this decision with one that records the implemented pass + depth-guard + semantics. diff --git a/decisions/2026-05-27-tts-mob-speech.md b/decisions/2026-05-27-tts-mob-speech.md new file mode 100644 index 00000000..17a53095 --- /dev/null +++ b/decisions/2026-05-27-tts-mob-speech.md @@ -0,0 +1,49 @@ +# Text-to-speech ships as a core NIF (Mob.Speech), not a plugin + +- Date: 2026-05-27 +- Status: accepted + +## Context + +Apps (e.g. an offline docs reader) want to read text aloud. iOS and Android +both ship a system TTS engine (`AVSpeechSynthesizer` / `TextToSpeech`), but mob +had no wrapper. The plugin system that will eventually host optional native +capabilities is still phase-0 (design docs + stubs), so there's no in-lane way +to ship TTS as a plugin yet. + +## Decision + +Add `Mob.Speech` as a **core capability NIF**, mirroring the existing +`Mob.Camera` / `Mob.Clipboard` / `Mob.Audio` pattern: + +- `Mob.Speech.speak/3` + `stop_speaking/1` — socket-threading like `Mob.Haptic`; + options (`rate`/`pitch`/`voice`) whitelisted and `:json.encode`d. +- NIFs `tts_speak/2` + `tts_stop/0` (`src/mob_nif.erl`), implemented in + `ios/mob_nif.m` (AVSpeechSynthesizer, lazy persistent synth) and + `android/jni/mob_nif.zig` → `MobBridge.ttsSpeak` (generated Kotlin in mob_new). + +Notable sub-decisions: + +- **Android bridge methods are `cacheOptional`, not `cacheRequired`.** Apps + generated before TTS lack `ttsSpeak`/`ttsStop` in their `MobBridge.kt`; a + required cache lookup would fail `on_load` and purge the *entire* NIF library. + Optional caching + a `Bridge.tts_* == null` guard means those apps simply + no-op TTS until regenerated, rather than breaking. +- **`TextToSpeech` initializes asynchronously** (an `OnInitListener`), so the + Kotlin keeps one engine alive and queues the first utterance until `onInit`. +- **`rate`/`pitch` are platform-scaled, not normalized.** iOS `rate` is 0–1, + Android `setSpeechRate` centers on 1.0 (~0.5–2.0). Documented as + "platform-scaled"; a normalization layer can come later if needed. +- STT is **not** included — deferred (still a plugin candidate; see the surface + matrix). + +## Consequences + +- One blessed `Mob.Speech` API; TTS now shows ✅ in the capability matrix. +- New native capabilities follow this full path: Elixir module + `mob_nif.erl` + (export/`-nifs`/clause, covered by `nif_stub_test`) + `ios/mob_nif.m` (+ funcs + table) + `android/jni/mob_nif.zig` (struct field + impl + `cacheOptional` + + funcs table) + a `MobBridge.kt.eex` method in mob_new. +- Device-verified end-to-end on both platforms before merge (Android audible, + iOS screen + AVSpeechSynthesizer path). When the plugin system lands, TTS + could migrate out of core — revisit then. diff --git a/decisions/2026-05-27-ui-kit-distribution-model.md b/decisions/2026-05-27-ui-kit-distribution-model.md new file mode 100644 index 00000000..f12a9316 --- /dev/null +++ b/decisions/2026-05-27-ui-kit-distribution-model.md @@ -0,0 +1,41 @@ +# UI kits have two distribution lanes; the plugin epic owns only the dependency lane + +- Date: 2026-05-27 +- Status: accepted + +## Context +Evaluating a third-party UI kit (Mishka Chelekom) surfaced a model mismatch. +Mishka-class kits are **shadcn-style generators**: a dev-only tool +(`mix mishka.ui.gen.component`, built on Igniter) emits component **source the +user owns and edits** into their project. Components are free; the paid tier is +templates + support. The mob plugin system is **dependency-shaped** (Hex dep + +two-step activation in `mob.exs`). + +These are different products. Forcing a generator-style kit into the plugin +manifest would misrepresent the tool and the vendor's identity as an author. + +## Decision +Recognize two distribution lanes and keep them separate: + +- **Plugin (dependency lane)** — Hex dep + two-step activation. For native-backed, + capability-bearing, or centrally-maintained/versioned components. This is what + `MOB_PLUGINS.md` specs and is **in scope** for the plugin extraction epic. +- **Generator lane** — `mix mob.gen.component` (Igniter-based), emitting + owned-source presentational components into the user's project. This is a + **separate tool** tracked with the Igniter build-migration work, **NOT** part + of the plugin extraction epic. + +The two can coexist: a generator can scaffold owned source *from* a plugin +package. Decide per-vendor which lane fits. For Mishka specifically, the faithful +port is the **generator lane**. + +## Consequences +- The plugin epic stays scoped to the dependency lane; `mix mob.gen.component` is + not added to its checklist. +- Igniter is shared ground (Mishka is built on it; mob's build migration is + heading there), so the generator lane is not foreign territory when it's picked + up. +- A potential UI-kit vendor relationship is protected — the port matches the + tool's real shape rather than bending it to the manifest. +- Follow-up: spec the generator lane (`mix mob.gen.component`) under the Igniter + build-migration track when it begins; record that as its own decision. diff --git a/decisions/2026-05-29-bridge-nif-screenshot-scroll.md b/decisions/2026-05-29-bridge-nif-screenshot-scroll.md new file mode 100644 index 00000000..f9c8d5b2 --- /dev/null +++ b/decisions/2026-05-29-bridge-nif-screenshot-scroll.md @@ -0,0 +1,69 @@ +# In-process screenshot + scroll control via the bridge NIF + +- Date: 2026-05-29 +- Status: accepted + +## Context + +`Mob.Test` already drives Mob apps fully over Erlang distribution (state reads, +taps, navigation, synthetic touches) with no adb/xcrun. The one remaining hard +dependency on external device tooling was the *observe-visually* half of the +agent loop: `PLAN.md`'s Layer 5 (Visual) is "MCP, external" — screenshots came +only from `xcrun simctl io` / `adb screencap`. There was also no way over dist +to read a scroll view's offset/extent or command it to a position (only the +imprecise `swipe_xy` and iOS-AX-only `ax_action :scroll_*`). + +This blocks Sloppy Joe and WireTap, which must be programmable by a remote agent +that can only reach the device over dist. The agent needs eyes and deterministic +scroll through the bridge NIF itself. + +## Decision + +Add three test-harness NIFs, surfaced on `Mob.Test`: + +- `screenshot/3` (format, quality, scale) → PNG/JPEG bytes, returned over dist. +- `scroll_info/1` (id) → flat JSON `{offset,content,viewport,max,kind}`. +- `scroll_to/3` (id, x, y) → absolute offset (clamped by the Elixir wrapper). + +`Mob.Test` adds `screenshot/2`, `scroll_info/2`, `scroll_to/4`, and +`screenshot_tour/3` (page top→bottom, capture each). Target resolution +(`:top`/`:bottom`/`{:page,n}`/`{x,y}`) and the tour paging are pure, unit-tested +helpers; the NIF stays a dumb absolute setter. + +Scroll views are addressed by their `:id` prop: + +- **iOS**: the SwiftUI renderer applies `node.nativeViewId` as the scroll view's + `accessibilityIdentifier`; the NIF walks `UIScrollView`s and matches it. In + practice SwiftUI does **not** reliably propagate `.accessibilityIdentifier` onto + the backing `UIScrollView` (verified on-device 2026-05-29), so the NIF falls back + to the largest scroll view (the main content scroller) when an explicit id does + not match — correct for the common one-scroll-per-screen case. Pixel units. +- **Android**: the Compose renderer registers each `:scroll`/lazy-list state in an + id-keyed registry in `MobBridge` (with the measured viewport for `ScrollState`, + which doesn't expose it). `kind` is `"pixel"` for `verticalScroll`/`ScrollState` + and `"index"` for `LazyColumn`/`LazyListState` (y is an item index, viewport is + the visible-item count). The `kind` field makes the asymmetry explicit so paging + stays coherent in either unit. + +Capture is in-process: iOS `UIGraphicsImageRenderer` + `drawViewHierarchy`; +Android `PixelCopy` against the activity window (decor-view `draw` fallback +pre-API-26). Both are debug-only harness code (iOS `#if !MOB_RELEASE`). + +This is core test-harness work (same bucket as `ui_tree`/`tap_xy`), not a +plugin-shaped feature, so it lands under the current plugin-first hold. + +## Consequences + +- A remote agent gets pixels + deterministic scroll with zero adb/xcrun — the + capability `wiretap_screenshot` will build on. +- Capture is the app's own surface only; `FLAG_SECURE` (Android) and secure text + fields (iOS) render blank, and a backgrounded app has no window (returns + `{:error, :no_window}` / not_found). +- Cross-repo: the Android side spans `mob` (Zig NIF) and the `mob_new` + `MobBridge.kt.eex` template; existing apps pick it up on regeneration or a + manual `MobBridge.kt` patch. +- `:scroll` (ScrollState) is not persisted across BEAM re-renders the way lists + are; the registry holds the live state, which is current during a scroll→shot + tour. Persisting it by id is a possible follow-up. +- The Compose-semantics walker for arbitrary (non-Mob) apps remains deferred to + WireTap (see `future_developments.md`); this change covers Mob-rendered apps. diff --git a/decisions/2026-06-04-plugin-permission-registry.md b/decisions/2026-06-04-plugin-permission-registry.md new file mode 100644 index 00000000..81a3402c --- /dev/null +++ b/decisions/2026-06-04-plugin-permission-registry.md @@ -0,0 +1,93 @@ +# Extensible permission registry for plugins (Option A, runtime) + +- Date: 2026-06-04 +- Status: accepted + +## Context + +Wave 2 of the plugin-extraction epic moves the runtime-permission capabilities +(`:camera`, `:microphone`, `:photo_library`, `:location`, `:notifications`) out +of core and into plugins. The static *declarations* (iOS `Info.plist` keys, +Android `<uses-permission>`) already merge from plugin manifests at build time +(see the Wave-1 plist/manifest merge). What stayed core-bound is the **runtime +request**: `Mob.Permissions.request/2` had a hardcoded capability enum, and +`:mob_nif.request_permission(cap)` dispatched through a per-capability if/else +in the native layer (iOS `nif_request_permission`, Android +`MobBridge.request_permission`). + +Once Wave 2 extracts all five, that core enum/dispatch would be empty, so the +mechanism itself has to become extensible: a plugin must be able to add a new +runtime capability and its handler. + +Kevin chose **Option A — keep the unified `Mob.Permissions.request(socket, cap)` +API; make the native request_permission table-driven; a plugin ships its own +permission handler and registers its capability** (vs Option B, a separate +per-plugin API surface). He framed it as "the extensible registry is on device". + +## Decision + +A **runtime** registry on each platform, populated by the plugin at load / +bootstrap time. (This supersedes the earlier sketch of a build-time *codegen* +dispatcher on iOS — see Consequences for why.) + +**Elixir (`Mob.Permissions`)** — relax the hardcoded `when capability in [...]` +guard to accept any atom and delegate validity to the native layer. An unknown +capability returns `badarg` from the NIF (→ `ArgumentError`), so invalid caps +still error; valid plugin-registered caps now pass through. The core capability +list survives only as documentation. The real source of truth for "what's a +valid capability" is the native registry, i.e. genuinely on-device. + +**iOS** — core `mob_nif.m` holds a small fixed handler table and exports a +stable C symbol: + + void mob_register_permission_handler(const char *cap, + void (*fn)(ErlNifPid)); + +A plugin's C/ObjC NIF (already compiled + linked into the one static binary via +the `plugin_c_nifs` path) calls this from its `ERL_NIF_INIT` load callback to +register its capability. `nif_request_permission`'s `else` branch looks the +capability up in the table and calls `handler(pid)`; the handler drives the +native permission API and delivers `{:permission, cap, :granted|:denied}` to +`pid` via raw `enif_send` (the plugin has `erl_nif.h`). Unknown cap → `badarg`. + +**Android** — mob_dev generates a marker interface (mirroring `MobActivityAware`) + + interface MobPermissionProvider { fun permissionsFor(cap: String): Array<String>? } + +and extends the generated `MobPluginBootstrap` to collect every registered +bridge that implements it and expose `permissionsFor(cap)`. Core +`MobBridge.request_permission`'s `else` branch consults +`MobPluginBootstrap.permissionsFor(cap)`; the rest of the flow +(`checkSelfPermission` / `ActivityCompat.requestPermissions` / +`onRequestPermissionsResult` → `onPermissionResult`) stays generic in core. +Android's request flow was already almost entirely capability-agnostic — only +the cap→permission-string mapping was specific, and that's exactly what the +provider supplies. + +**Manifest** gains a `permissions:` field (tier-1, native, non-hot-pushable): + + permissions: [%{capability: :location, ios: %{handler: "mob_location_request_permission"}}] + +`:capability` is the atom `Mob.Permissions.request/2` accepts. `ios.handler` is +documentation of the cdecl symbol the plugin self-registers (not consumed by a +codegen step). Android needs no manifest entry — the provider is auto-discovered +by `bridge is MobPermissionProvider` at `registerAll`. + +## Consequences + +- **Much smaller surface than the codegen sketch.** No iOS permission codegen + module, no new `-Dplugin_perm_c` build.zig option (would have touched 4 files: + both iOS build templates + both demo copies), no `Merge` permission gatherers. + mob_dev changes reduce to Manifest validation + the Android Kotlin emitters. +- **iOS needs one exported core symbol** (`mob_register_permission_handler`). + It is referenced by plugin objects in the same static binary, so it links and + survives `-dead_strip` (reachable from the plugin's load callback). +- **Load-order**: the plugin's handler is registered when its NIF's `on_load` + fires (BEAM boot), well before any user-initiated permission request. The + table is written once at load, read later on a scheduler thread — same + single-write-then-read pattern as other core globals; no lock added. +- **Symmetry**: both platforms are now runtime registries populated at + load/bootstrap, which matches Kevin's "registry on device" mental model better + than the asymmetric (iOS-codegen / Android-runtime) sketch did. +- Proven with a trivial `mob_demo_perm` prototype on both platforms before the + real `mob_location` extraction, per the epic's trivial-first discipline. diff --git a/decisions/2026-06-05-mob-location-extraction.md b/decisions/2026-06-05-mob-location-extraction.md new file mode 100644 index 00000000..e0bb0e9f --- /dev/null +++ b/decisions/2026-06-05-mob-location-extraction.md @@ -0,0 +1,67 @@ +# Extract location from core into the mob_location plugin (Wave 2) + +- Date: 2026-06-05 +- Status: accepted + +## Context + +Wave 1 extracted Bluetooth into the `mob_bluetooth` plugin and proved the +plugin native pipeline (zig NIFs, Android bridge classes, per-host signing). +Wave 2 moves the runtime-permission-backed device capabilities (location, +camera, notify, photos, biometric) out of core. `mob_location` is the +pattern-setter: unlike bt (Android-only) it is cross-platform, so it exercises +the iOS plugin-NIF path (ObjC/`.m` via `lang: :objc`), the Android zig NIF +path, and the extensible permission registry (`mob_register_permission_handler` +on iOS, `MobPermissionProvider` on Android) all at once. + +The three blocking infra pieces (permission registry, per-platform NIF source +tagging, ObjC plugin NIF compile path) landed and were device-verified earlier; +this decision covers the relocation itself. + +## Decision + +Move location out of core into a standalone `mob_location` tier-1 plugin +(`/Users/kevin/code/mob_location`) and **hard-remove** it from core — no shim, +no deprecation alias, matching the Wave 1 bt precedent. + +- **Coexistence then strip.** The plugin registers Erlang module + `mob_location_nif`; core kept `:mob_nif.location_*`. Different module names = + no symbol/registration collision, so the plugin was built and device-verified + on both platforms *while core still owned location*. Only after that proof did + core get stripped (the breaking step). +- **What left core:** `lib/mob/location.ex`; the `location_*` NIFs + + `MobLocationDelegate`/`MobLocationPermissionDelegate` + `setup_location_manager` + + the hardcoded `"location"` branch of `nif_request_permission` in + `ios/mob_nif.m`; the `nif_location_*` exports + `mob_deliver_location` + + Bridge method-ids + `cacheRequired` + nif-table entries in + `android/jni/mob_nif.zig`; the `mob_deliver_location` decl in `mob_beam.h`; + the three `location_*` stubs in `src/mob_nif.erl`; `:location` from the + `Mob.Permissions` documented core-capability list and `@type capability`. +- **Permission flow after strip:** removing the hardcoded iOS `"location"` + branch lets `:location` fall through to the plugin handler the + `mob_location_nif.m` load callback registers via + `mob_register_permission_handler`. On Android the plugin's + `MobLocationBridge` implements `MobPermissionProvider`, discovered by the + generated `MobPluginBootstrap`. The unified `Mob.Permissions.request/2` API is + unchanged. +- **Templates:** the same location surface was stripped from the mob_new + generated-app templates (`MobBridge.kt.eex`, `beam_jni.c.eex`, + `AndroidManifest.xml.eex` LOCATION perms, `build.gradle.eex` + play-services-location). A generated app now gets location only by depending + on `mob_location`, whose manifest contributes the perms + gradle dep + + CoreLocation framework + plist key at build-time merge. + +## Consequences + +- **Breaking:** `Mob.Location` is gone from core. Any caller must add the + `mob_location` dep and call `MobLocation`. No in-repo caller used it; the one + external consumer is version-pinned (safe until they bump). CHANGELOG updated. +- Core's `nif_request_permission` no longer special-cases location; the + registry path is now the *only* location-permission path, exercising Phase A + infra in production rather than just the `mob_demo_perm` prototype. +- Device-verified on Moto G (ZY22DP6HFL) + iPhone SE (iOS 26.5) after the strip: + `mob_location_nif.location_{start,get_once}` round-trip real fixes through the + plugin alone, core location grep = 0. +- `mob_location` still needs its own signing key + GitHub repo (follow-up, + mirrors `mob_bluetooth`); until then it rides the demo's + `acknowledge_unsafe_plugins` hatch. diff --git a/decisions/2026-06-06-plugin-tiers-3-4.md b/decisions/2026-06-06-plugin-tiers-3-4.md new file mode 100644 index 00000000..47dc7fbb --- /dev/null +++ b/decisions/2026-06-06-plugin-tiers-3-4.md @@ -0,0 +1,69 @@ +# Plugin tiers 3 (multi-screen) and 4 (sub-app) + +- Date: 2026-06-06 +- Status: accepted + +## Context + +The plugin system shipped tiers 0-2 (pure-Elixir helper, NIF, native component). +Tiers 3 (multi-screen) and 4 (embedded sub-app) were specified in `MOB_PLUGINS.md` +and classified by the manifest engine, but nothing wired them. Unlike tiers 1-2 +(native symbols merged at link time), tiers 3-4 are **pure-Elixir and +runtime-wired**: a plugin's screens / lifecycle modules / settings / notification +handlers are ordinary Elixir compiled into the host release. The only missing +piece was on-device awareness of what each activated plugin declares — +`MobDev.Plugin.activated/0` is compile-time only. + +## Decision + +A **generated runtime manifest** is the linchpin. mob_dev gathers each activated +plugin's tier-3/4 sections (running spec-v2 `screens_generator`s under the +host-config audit) and emits `priv/generated/mob_plugins.exs`; the core +`Mob.Plugins` module reads it at boot and feeds the existing primitives: + +- **Screens** register into `Mob.Nav.Registry` by route at boot; the host still + chooses where to surface them (no silent route-grabbing). +- **Lifecycle**: `Mob.Plugins.Supervisor` runs each `on_start`, supervises the + declared children, and `Mob.Plugins.Lifecycle` dispatches `Mob.Device` app + events to `on_resume`/`on_background`. +- **Settings**: `Mob.State` (the persistent K/V store) namespaced per plugin, + schema-default on read, type-validated on write. (The spec said `Mob.Storage`; + the actual K/V store is `Mob.State`.) +- **Notifications**: `dispatch_notification/1` routes a payload to the first + matching handler (map prefix-match or `{M,F,arity}` predicate). +- **Migrations / images**: build-time file copies into the host bundle + (`native_build`), since their build-machine paths are meaningless on device. + +Two non-obvious calls the device runs forced: + +1. **Host app name at compile time.** `Mob.Plugins.boot` needs the host OTP app + to find the manifest, but `Application.get_application/1` returns nil on a mob + release (custom BEAM entry, not `Application.start`). The `use Mob.App` macro + captures `Mix.Project.config[:app]` at compile time instead. +2. **Plugin lifecycle starts before the host `on_start`.** A host `on_start` may + never return (iOS blocks in `Mob.Dist.ensure_started`); starting plugin + lifecycle after it would starve plugins. It runs before — framework services + a plugin needs are already up. Tradeoff: a plugin `on_start` can't depend on + the host's own `on_start` side-effects. + +Tier-3/4 manifest sections must be fully serializable (they feed the terms +file), so notification `match` is a map or `{M,F,arity}` predicate, never a +closure; notification `handler` is `{M,F,arity}` (invoked with the payload), +distinct from the `{M,F,args}` MFAs that generators/lifecycle use. + +## Consequences + +- New generated artifact `priv/generated/mob_plugins.exs`, regenerated when + `config :mob, :plugins` changes (`mix mob.regen_plugin_manifest`). +- Device-verified on iPhone + Moto G: tier-3 static screens, tier-3 spec-v2 + generated screens, tier-3 migrations (table created on device), `plugin://` + images (build-copied, resolved to the absolute bundle path, present on + device), notification central delivery (a `{:notification, _}` routed to a + plugin handler through `Mob.Screen`), and tier-4 on_start / supervised worker + / settings. +- Notification central delivery needed no native change: `Mob.Screen` + intercepts `{:notification, _}` at its GenServer and consults + `dispatch_notification/1` before the host screen's own `handle_info`. +- One remaining asset piece: **font bundling** (copy the font into the platform + bundle + iOS `UIAppFonts`). The `Assets.merge_ui_app_fonts/2` planner is built + and tested; the per-platform bundle-resource wiring is the follow-up. diff --git a/decisions/2026-06-10-load-plugin-nif-modules-at-boot.md b/decisions/2026-06-10-load-plugin-nif-modules-at-boot.md new file mode 100644 index 00000000..3aed84ca --- /dev/null +++ b/decisions/2026-06-10-load-plugin-nif-modules-at-boot.md @@ -0,0 +1,44 @@ +# Load plugin NIF modules at boot + +- Date: 2026-06-10 +- Status: accepted + +## Context + +On iOS a plugin's permission handler self-registers in its NIF's `load` +callback (`mob_register_permission_handler`), which only fires when the Erlang +NIF module is first loaded. Elixir loads modules lazily, so a screen that calls +`Mob.Permissions.request(socket, :camera)` in `mount/3` — before anything touches +the plugin's NIF module — runs before the handler is registered. Core's +`nif_request_permission` falls through to the registry, finds no `"camera"` +handler, and raises `:badarg`; the screen crashes on mount. + +Android has no such gap: plugin permission providers register eagerly at boot via +the generated `MobPluginBootstrap.registerAll()`. The asymmetry surfaced while +device-verifying the `mob_camera` extraction (camera worked on a Moto G, badarg'd +on the iOS simulator). A per-plugin `lifecycle.on_start` that force-loaded the NIF +module worked but is a workaround every iOS permission plugin would have to copy. + +## Decision + +The runtime manifest now carries `nifs` — the activated plugins' NIF module atoms +(emitted by `MobDev.Plugin.RuntimeManifest.build/1`, deduped and platform-agnostic +since the same module name backs both the iOS and Android NIF). `Mob.Plugins.boot/1` +calls `ensure_nif_modules_loaded/0`, which `Code.ensure_loaded/1`s each one at boot +— firing every plugin NIF's `load` callback eagerly, the iOS counterpart to +Android's bootstrap. `load_nif` failure is tolerated by each NIF module's +`on_load`, so a host build with no native linked is a no-op. + +## Consequences + +- Any iOS plugin that registers a permission (or any other `load`-callback side + effect) works without a per-plugin lifecycle workaround. The `mob_camera` + `lifecycle.on_start` + `__ensure_native_loaded__` shim were removed. +- All activated plugin NIF modules load at boot on both platforms (cheap; also + fail-fast if a NIF is mislinked). Loading on Android is redundant for permissions + but harmless. +- The runtime manifest gained a key; `@empty` in `Mob.Plugins` carries `nifs: []` + so older manifests without the key stay backward-compatible via `Map.merge`. +- Verified end-to-end on the iOS simulator: after removing the workaround and with + the camera privacy permission reset, the native permission dialog fires on the + camera screen (no badarg, no crash). diff --git a/decisions/2026-06-11-composite-expansion-pass.md b/decisions/2026-06-11-composite-expansion-pass.md new file mode 100644 index 00000000..f568cf6c --- /dev/null +++ b/decisions/2026-06-11-composite-expansion-pass.md @@ -0,0 +1,48 @@ +# Composite expansion pass (the ui_components `expand:` form, honored) + +- Date: 2026-06-11 +- Status: accepted + +## Context + +The 2026-05-27 pure-elixir-composite-tier ADR reserved `expand:` in +`ui_components` and deferred the renderer pass: Phase 1 forbade core +churn, and the feature deserved a concrete consumer. Both conditions +flipped — the plugin epic's phases are done, and a UI-kit author +(porting Mishka Chelekom, no Swift/Kotlin) asked for exactly this: +`<MishkaCombobox>` tags expanding to built-in widget trees, and relief +from threading `self()` through every event prop. + +## Decision + +`Mob.Composite`: a persistent_term registry (tag atom → `{Module, +:function}` expander) and an expansion pass that runs FIRST in +`Mob.Screen.do_render/3` — before `Mob.List.expand` and +`Mob.Component.expand`, so composites may emit `<List>` nodes and +`Mob.UI.native_view` components. Output is re-expanded to a fixpoint +with a depth guard (20); circular composites and crashing expanders log +and render an empty node rather than taking the screen down. The +expander contract is `expand(props, children, ctx)`. + +Event-target auto-injection: `on_*` props written as bare strings/atoms +arrive at expanders as `{screen_pid, tag}`. Composed tap tags +(`{pid, {tag, term}}`) carry per-row identity through the existing +event bridge. + +Registration: boot, from the runtime manifest (`composites:` — emitted +by mob_dev from `expand:` ui_components entries, validated native-XOR- +expand; expand-only plugins classify tier 2 but hot-push as pure +Elixir), or `Mob.Composite.register/2` for manifest-less Hex kits. + +## Consequences + +- UI kits ship tag-syntax components with zero native code; tier-0 + function composites remain the simpler form underneath. +- Composites are stateless; state stays in the screen (or a + `Mob.Component` for native-backed islands). Decoupling + `Mob.Component`'s stateful lifecycle from native backing is the + natural follow-on if kits need isolated component state. +- The `~MOB` whitelist warns once per call site for composite tags + (compile-time list inside mob) — follow-up: app-extendable tags. +- Worked example: `mob_plugin_demo/plugins/mob_demo_kit`, + Moto-G-verified (nested expansion, filtered combobox, selection). diff --git a/decisions/2026-06-16-files-pick-type-filter.md b/decisions/2026-06-16-files-pick-type-filter.md new file mode 100644 index 00000000..4c6eb4c5 --- /dev/null +++ b/decisions/2026-06-16-files-pick-type-filter.md @@ -0,0 +1,54 @@ +# Mob.Files.pick type filtering + +- Date: 2026-06-16 +- Status: accepted + +## Context + +`Mob.Files.pick/2` accepted a `:types` option but the native pickers ignored +it — iOS hardcoded `initForOpeningContentTypes:@[UTTypeData]` and Android +launched SAF with `arrayOf("*/*")`. So a picker always offered every file. This +surfaced as an App Store review rejection for Io (the Livebook-on-mob app): the +reviewer picked a non-`.livemd` file and the app errored trying to open it. + +The hard part is a platform asymmetry. iOS `UTType` can be built from a filename +extension and filters strictly even for an unregistered custom type. Android SAF +filters by MIME type only and has no extension filter, so a custom extension with +no registered MIME (`.livemd`) cannot be narrowed at the picker. + +## Decision + +Honor `:types` with a normalized envelope + result enforcement, all owned by +`Mob.Files`: + +- `:types` accepts extensions (`"livemd"` / `".livemd"`), MIME strings + (anything with a `/`), semantic atoms (`:images`, `:video`, `:audio`, `:pdf`, + `:text`), explicit `{:extension|:mime|:uti, value}` tuples, and `:any`. +- `normalize_types/1` (public, for testability + a documented wire contract) + produces a JSON list of `%{"kind","value"}` maps. `:any`/`"*/*"` collapses to + `[]` (no filter). The envelope is passed to `:mob_nif.files_pick/1` as a + binary (via `IO.iodata_to_binary/1`, since `:json.encode/1` returns an iolist + that `enif_inspect_binary` would reject). +- iOS `nif_files_pick` parses the envelope into `[UTType]` + (`typeWithFilenameExtension:` / `typeWithMIMEType:` / `typeWithIdentifier:` / + semantic constants), falling back to `UTTypeData` when empty or unresolved. +- `accept/2` + `matches?/2` enforce the filter on the *result* (by `name` + extension / `mime`), covering the Android gap. `{:uti, _}` specs are treated + as already-enforced by the iOS picker (not checkable from a result map). + +The model: **filter where the OS allows, enforce where it doesn't.** + +## Consequences + +- iOS strictly limits the picker (the App Store fix). Android picker stays wide + for custom extensions, but `accept/2` gives apps consistent semantics. +- Backward compatible: the default is `:any` → empty envelope → existing + "offer everything" behavior. +- **Follow-up (not in this change):** the Android Kotlin side still ignores the + forwarded `typesJson`. Narrowing the SAF picker by MIME (via `MimeTypeMap`) + lives in the `mob_new` `MobBridge.kt` / `MainActivity.kt` templates, not core + mob. Tracked separately; `accept/2` makes it a UX nicety, not a correctness + requirement. +- Native change → not exercised by Elixir tests. Needs an on-device verify + (`mix mob.deploy --native`, open the picker, confirm only `.livemd` shows on + iOS) before relying on it. diff --git a/decisions/2026-06-25-open-settings.md b/decisions/2026-06-25-open-settings.md new file mode 100644 index 00000000..81fb3ecf --- /dev/null +++ b/decisions/2026-06-25-open-settings.md @@ -0,0 +1,41 @@ +# Mob.Device.open_settings/1 — open OS settings (cacheOptional, not cacheRequired) + +- Date: 2026-06-25 +- Status: accepted + +## Context + +Screens could hand a URI to the OS (`Mob.Device.open_url/1`) but could not +deep-link into OS **settings screens**, which are Intent actions, not URIs. +Needed for (a) recovering from a *permanently* denied runtime permission (send +the user to the app's settings page) and (b) special-access permissions like +`SCHEDULE_EXACT_ALARM`, whose only grant path is a system settings screen. + +## Decision + +Add `Mob.Device.open_settings(target \\ :app)` where `target` is `:app`, +`:notifications`, or `:exact_alarm`, mirroring the `open_url` NIF path across all +layers (Elixir, `src/mob_nif.erl` export/nifs/stub, `android/jni/mob_nif.zig` +struct + nif + native nif-table + cache, `ios/mob_nif.m` nif + table). Invalid +targets return `{:error, :invalid}` (no NIF call), matching `lock_orientation/1`. + +The Android Kotlin bridge method (`MobBridge.openSettings(String)`) is cached +with **`cacheOptional`, not `cacheRequired`**, and `nif_open_settings` +null-guards `Bridge.open_settings`. Reason: the Kotlin `MobBridge` is app-owned +(scaffolded from the `mob_new` template) and drifts — a `cacheRequired` entry for +a method a stale `MobBridge.kt` lacks would fail `nif_load`, which makes the +**entire `mob_nif` module `undef` at boot** (the 0.7.6 regression class, fixed in +0.7.7). `cacheOptional` + the null guard degrade to a silent no-op instead. + +iOS exposes only the single app settings page (`UIApplicationOpenSettingsURLString`), +so `target` is validated but otherwise ignored there. + +## Consequences + +- The Kotlin `openSettings` method must be added to the `mob_new` template and + scaffolded into apps. Until an app refreshes its `MobBridge.kt`, + `open_settings/1` is a silent no-op on Android (by design, never a crash). +- `nif_stub_test` guards the erl export/nifs/stub agreement automatically; the + native nif-table entry is verified by booting on a device (host `mix test` + cannot catch a native-table mismatch). +- Consumers need mob at or above the release that carries this (target 0.7.8). diff --git a/decisions/2026-06-29-audio-output-probes.md b/decisions/2026-06-29-audio-output-probes.md new file mode 100644 index 00000000..b8739d62 --- /dev/null +++ b/decisions/2026-06-29-audio-output-probes.md @@ -0,0 +1,103 @@ +# Audio output probes — verify sound is actually working + +- Date: 2026-06-29 +- Status: accepted + +## Context + +Mob can verify *visual* output in-process via the `screenshot/3` NIF (reads the +composited framebuffer; see `Mob.Test`). There was no equivalent for *audio*: +nothing could answer "is sound actually coming out right now." This surfaced +bringing up Doom (the `mob_doom` plugin) in a mob app — Doom drives its own +`AudioTrack` at 11025 Hz from a polling thread, and there was no programmatic +way to tell working audio from silence. + +`adb shell dumpsys audio` / `dumpsys media.audio_flinger` already answer much of +this from outside the app on Android (active players + state, stream volume + +mute, mixer-track underrun counters). But they cannot distinguish a live signal +from pushed silence, and they do not exist on iOS. These probes are the +in-process, cross-platform layer on top of that. + +Audio has no single "final surface" the way the framebuffer is for video, so we +expose two probes at two vantage points rather than one screenshot-equivalent. + +## Decision + +Add two read-only NIF-backed functions to `Mob.Audio`: + +- `output_status/0` → `%{volume, muted, route, other_audio}`. Cheap, + synchronous, no permission. Catches the common "no sound" causes (muted, + volume 0, dead route). iOS: `AVAudioSession`. Android: `AudioManager`. +- `output_level/1` → `{rms_db, peak_db}` | `:silent` | `{:error, reason}`. Reads + actual signal energy — the part `output_status` and `adb` cannot answer. Takes + a `:source`: + - `:mob` (default) — meters `Mob.Audio`'s own player. iOS: `AVAudioPlayer` + metering (free, no permission). Android: `Visualizer` on the player's **own + audio session** (needs `RECORD_AUDIO`, runtime-granted). `{:error, + :not_playing}` when no `Mob.Audio` playback is active. + - `:mix` — *would* tap the global output mix to observe audio that bypasses + `Mob.Audio` (a game's own `AudioTrack`, another app). **Not available to a + normal app** on either platform → `{:error, :unsupported_on_platform}`. + Device-verified: a session-0 `Visualizer` on Android 11 fails with + `ERROR_NO_INIT` even with `RECORD_AUDIO` + `MODIFY_AUDIO_SETTINGS` (global + output capture is privileged); iOS forbids it by sandbox. Global + device-audio capture belongs in a separate MediaProjection-based plugin + intended as a **test-environment dependency**, not the core framework. + +Native wiring mirrors `screenshot/3` and `open_settings/1`: `-export` + `-nifs` + +stub in `src/mob_nif.erl`; native table entries in `android/jni/mob_nif.zig` and +`ios/mob_nif.m`; and **`cacheOptional` + null-guard** for the app-owned Android +bridge methods so a drifted `MobBridge.kt` no-ops (NIF returns an error atom) +instead of failing `nif_load` and crash-looping boot (the 0.7.6 lesson). The +Android bridge methods ship in the `mob_new` template; iOS level-2 is +self-contained in `mob_nif.m`. `output_level` is a dirty IO NIF (Android settles +a Visualizer window; iOS `dispatch_sync`s to the main queue). + +The NIFs return only doubles / bare atoms (no term-building in C/Zig); `Mob.Audio` +decodes route codes and the `:silent`/`:error` shapes in pure Elixir +(`decode_status/1`, `decode_level/1`, unit-tested on host). + +## Consequences + +- **Honest scope:** the in-app probes verify *your own* audio only. Metering a + foreign native player (a bundled game's `AudioTrack`, another app) is not + possible for a normal app on either platform — the global-mix tap is privileged + on Android and forbidden on iOS. That capability is deferred to a separate + capture plugin (below). For the immediate "is the bundled game's audio working" + question, `adb shell dumpsys media.audio_flinger` (active track + underruns) is + the answer, no in-app probe needed. +- Both probes observe only the device's own output, never "did a human hear it" + (that would need a mic loopback, deliberately out of scope). +- Verification idiom is `play → sleep a beat → output_level`; metering is + instantaneous and only valid while audio plays. The Android `Visualizer` + occasionally returns its `-96 dB` floor if a measurement window hasn't filled, + so sample a few times. + +## Device verification (moto g power 2021, Android 11) — 2026-06-29 + +Verified on hardware via `mix mob.connect` dist-RPC into a `doom_demo` build: + +- App **boots** with the new NIF table (stable pid) — the boot-critical check for + a native-table mismatch. +- `output_status/0` → `%{route: :speaker, volume: 0.2, muted: false, other_audio: + false}`. +- `output_level(:mob)` while a local tone looped → `{-34.8, -31.8}` (real signal); + idle / after stop → `{:error, :not_playing}`. +- `output_level(:mix)` → `{:error, :unsupported_on_platform}`. +- Without `RECORD_AUDIO` granted at runtime → `{:error, :needs_record_audio}`. +- Disproved the original design: a session-0 `Visualizer` returns `ERROR_NO_INIT` + even with `RECORD_AUDIO` + `MODIFY_AUDIO_SETTINGS`. This is why `:mix` is + unsupported in core. + +## Follow-up: separate device-audio capture plugin (test-env dependency) + +True global/foreign-app output capture on Android is achievable only via +`MediaProjection` + `AudioPlaybackCaptureConfiguration` (API 29+), which pops a +one-time system consent dialog and can capture other apps' output. That UX is +unacceptable in a shipped app but fine in a dev/test harness. Plan: a separate +`mob_*` capture plugin, added as a test-environment dep, exposing a +`capture_level/0`-style probe backed by `AudioPlaybackCapture`. This is where the +"meter Doom's own audio" capability lives. iOS has no equivalent (no +inter-app/system output capture), so that plugin is Android-only. Pairs with the +in-process screenshot work for agent-driven testing, and gives `mob_midi`'s +pending tone primitive something to assert against. diff --git a/decisions/2026-07-02-magnetometer-compass.md b/decisions/2026-07-02-magnetometer-compass.md new file mode 100644 index 00000000..c757c3f5 --- /dev/null +++ b/decisions/2026-07-02-magnetometer-compass.md @@ -0,0 +1,48 @@ +# Magnetometer / compass support in Mob.Motion + +- Date: 2026-07-02 +- Status: accepted; partially superseded by `2026-07-04-magnetometer-stable-key-contract.md` (the "Android registers whenever hardware present (v1)" activation trigger and the map-shape/`nil`-key contract — the magnetic-north scope, additive keys, and delivery-via-`mob_deliver_motion_mag` decisions still stand) +- Issue: MOB-6 + +## Context + +`Mob.Motion` exposed accelerometer + gyroscope but not the magnetometer, so a mob +app couldn't build a compass/heading. The sensor is present on most (not all) +phones. This adds it, cross-repo (`mob` Elixir + iOS + zig, `mob_new` Kotlin +template, per-app bridge regen). + +## Decision + +- **Report both `mag` (µT) and a fused `heading`** (degrees), not just the raw + field — a raw vector isn't a usable compass; heading needs sensor fusion, which + the platforms already do. +- **Magnetic north only.** True north needs location + geomagnetic declination — + out of scope; an app can layer it with `Mob.Location`. +- **iOS: opt-in via the sensor list.** When `:magnetometer` is requested and the + device supports the `XMagneticNorthZVertical` attitude reference frame, switch + device motion to that frame (fuses accel+gyro+mag → calibrated `magneticField` + + `heading` on one stream). Otherwise the plain accel/gyro stream is unchanged. +- **Android: register when the hardware is present** (v1), rather than threading + the sensor set through the JNI `motion_start` signature. Android already ignored + the sensor list (both accel+gyro always registered), so this matches existing + behavior; heading comes from `TYPE_ROTATION_VECTOR` → `getRotationMatrixFromVector` + → `getOrientation`. **Follow-up:** make it opt-in (encode the sensor set in the + existing `motion_start` string arg — no ABI change) to avoid running the + magnetometer for accel-only consumers. +- **Delivery via a new `mob_deliver_motion_mag`** (5-key `{:motion, _}` map) rather + than widening `mob_deliver_motion` — keeps the existing accel/gyro path + byte-identical (zero risk to current consumers like the tilt-follow eyes). +- **`heading < 0` ⇒ `nil`.** Both platforms use a negative sentinel for + "unavailable"; the native layer converts it to the atom `nil`. + +## Consequences + +- `mag`/`heading` are **additive** map keys — existing accel/gyro consumers are + unaffected (map patterns aren't exclusive). +- Android v1 runs the magnetometer + rotation-vector whenever the hardware exists, + a small battery cost for accel-only users until the opt-in follow-up lands. +- iOS is opt-in; Android is present-if-hardware. The `heading`/`mag` contract is + identical; only the activation trigger differs (documented in `Mob.Motion`). +- The new delivery function must bind through the generated JNI thunk seam; the + per-app `MobBridge.kt` needs regenerating from the `mob_new` template (the same + bridge-refresh step every native addition needs). diff --git a/decisions/2026-07-04-keep-awake.md b/decisions/2026-07-04-keep-awake.md new file mode 100644 index 00000000..ef090daa --- /dev/null +++ b/decisions/2026-07-04-keep-awake.md @@ -0,0 +1,47 @@ +# Keep-awake / idle-timer in core (`Mob.Device.keep_awake/1`) + +- Date: 2026-07-04 +- Status: accepted +- Issue: MOB-20 + +## Context + +`Mob.Device` could observe screen on/off events but not *prevent* the screen +from dimming/locking — so a video, reader, or navigation screen would sleep +mid-use. This was a Tier-3 gap from the 2026-07-04 capability audit, flagged as +high value-per-effort. + +## Decision + +- **Core `Mob.Device`, not a plugin.** Like `lock_orientation/1`, keeping the + screen awake is a permission-free, universal device-state toggle that belongs + in core. New function `keep_awake(on?) :: :ok`, matching the `Mob.Device` + convention (takes the value, returns `:ok`) rather than the socket-passthrough + style of `Mob.Haptic`/`Mob.Torch`. +- **Boolean toggle, no separate state read.** The app owns whether it wants the + screen kept awake; there's no getter (the flag is write-only OS state). The + keep-awake flag is app-scoped and the OS clears it on background, so the + docstring tells callers to re-assert on resume. +- **Wire contract:** `:mob_nif.device_keep_awake/1` takes the boolean atom + `true`/`false`. iOS reads it and sets `UIApplication.isIdleTimerDisabled` on + the main thread. Android maps it to an int (1/0) and calls + `MobBridge.keepAwake(Int)`, which toggles the window's `FLAG_KEEP_SCREEN_ON` + on the UI thread — the same seam and threading as `lock_orientation`. +- **Graceful drift.** The Android bridge method is cached with `cacheOptional` + and null-guarded (mirrors `orientationLock`), so an app whose generated + `MobBridge.kt` predates the method simply no-ops instead of failing to load. + +## Consequences + +- Cross-repo, one issue: `mob` (Elixir + NIF + iOS + zig) plus `mob_new` (the + generated `MobBridge.keepAwake` Kotlin method). Existing apps pick it up by + regenerating their bridge + depending on the mob release that carries the NIF. +- The `keep_awake/1` boolean guard is host-tested; the native effect is + **device-verified**: moto g power (2021) via `dumpsys` (the app window's + `fl=KEEP_SCREEN_ON` flag appears on `keep_awake(true)` and clears on `false`), + and iPhone SE (3rd gen) by observation (screen stays lit past the Auto-Lock + timeout while enabled, dims when released). +- No permission required on either platform, so no manifest/plist changes. +- Follow-ups if demand appears: a scoped/auto-release variant tied to screen + lifecycle, and an Android `WakeLock` (CPU-on, not just screen-on) for + background work — deliberately out of scope here (this is screen-on only). diff --git a/decisions/2026-07-04-magnetometer-stable-key-contract.md b/decisions/2026-07-04-magnetometer-stable-key-contract.md new file mode 100644 index 00000000..1830f438 --- /dev/null +++ b/decisions/2026-07-04-magnetometer-stable-key-contract.md @@ -0,0 +1,68 @@ +# Mob.Motion magnetometer: stable-key contract + Android opt-in + +- Date: 2026-07-04 +- Status: accepted +- Issue: MOB-6 +- Amends: `2026-07-02-magnetometer-compass.md` (supersedes its "Android registers + when hardware present" and "heading nil sentinel only" points) + +## Context + +A review of the first magnetometer cut found the public `Mob.Motion` docstring +made two promises the implementation didn't keep: + +1. "`heading` is `nil` on a device with no magnetometer." False — on both + platforms a magnetometer-less device fell back to the 3-key map, so the + `heading` key was **absent**, not `nil`. A compass app pattern-matching + `%{heading: h}` would hit a `KeyError` on exactly the phones this feature must + degrade gracefully on. +2. "`mag`/`heading` appear only when you request `:magnetometer`." True on iOS + (which parses the sensor list) but false on Android: `motion_start` only ever + received the interval, so it registered the magnetometer whenever the hardware + existed — regardless of the request. An accel/gyro-only consumer (e.g. the + tilt-follow eyes) on a magnetometer phone got surprise 5-key maps plus the + battery cost of two extra sensors. + +The earlier ADR documented the Android asymmetry as an accepted v1 shortcut with +"make it opt-in" as a follow-up. We're doing the follow-up now rather than +shipping an inaccurate public contract. + +## Decision + +Make the map shape a function of **what was requested**, uniformly across +platforms: + +- **Requested `:magnetometer` ⇒ `mag` + `heading` keys are always present**, each + `nil` when there's no reading (no magnetometer hardware, or heading not yet + fused). Stable keys — safe to pattern-match. +- **Did not request ⇒ neither key** (the plain 3-key accel/gyro stream, byte- + identical to before). + +Mechanics: + +- **Android sensor set is now plumbed through** without an ABI change: the + `motion_start/2` NIF still takes `(sensors, interval_ms)`, and `nif_motion_start` + (zig) encodes the request into the existing JNI string arg as + `"<interval>"` or `"<interval>,magnetometer"`. Kotlin parses it, registers the + magnetometer + rotation-vector **only when requested**, and picks the delivery + accordingly. +- **`nil` sentinels ride the existing 12-arg delivery.** `mob_deliver_motion_mag` + now maps a NaN `mag` component → `mag: nil` (in addition to the existing + `heading < 0` → `heading: nil`). So "requested but no hardware" still uses the + 5-key delivery, passing NaN/−1, and the app sees `mag: nil, heading: nil`. No + new FFI symbol. +- **iOS** already knew the request (`want_mag`); it now builds the 5-key map + whenever `want_mag`, filling `nil`/`nil` when the magnetic-north reference frame + isn't available, instead of dropping to the 3-key map. + +## Consequences + +- Public contract now matches the docstring on both platforms; the `KeyError` + trap is gone and accel/gyro-only apps are untouched (and pay nothing extra). +- The FFI arity and the accel/gyro-only C path are still byte-identical — the + change is additive (sentinel interpretation + a request flag in a string). +- Device-verified: happy path (real heading/mag) on moto g + iPhone SE; opt-in + (no request ⇒ 3-key, no mag sensors) on a physical device; `nil`/`nil` + requested-but-no-hardware path on the iOS simulator (which has no magnetometer). +- `NaN` is the "no mag reading" wire sentinel — callers never see it (the native + layer converts to `nil`); documented at the `mob_deliver_motion_mag` export. diff --git a/decisions/2026-07-04-torch.md b/decisions/2026-07-04-torch.md new file mode 100644 index 00000000..d989e20d --- /dev/null +++ b/decisions/2026-07-04-torch.md @@ -0,0 +1,49 @@ +# Torch / flashlight in core (`Mob.Torch`) + +- Date: 2026-07-04 +- Status: accepted +- Issue: MOB-15 + +## Context + +`Mob.Motion` gained the magnetometer (MOB-6); torch/flashlight was the next +Tier-2 hardware gap from the 2026-07-04 capability audit. The rear-camera torch +is high-utility and cheap to wrap. The open questions were **where it lives** +(core vs the `mob_camera` plugin) and **how rich the API is** (on/off vs +brightness levels). + +## Decision + +- **Core, not `mob_camera`.** Both platforms toggle the torch **without opening a + camera capture session and without the camera permission** — iOS via + `AVCaptureDevice.lockForConfiguration` + `torchMode`, Android via + `CameraManager.setTorchMode`. So torch has no dependency on the camera plugin + and belongs alongside the other lightweight, permission-free device outputs + like `Mob.Haptic`. A new `Mob.Torch` module mirrors that shape. +- **On/off only for v1.** iOS supports a brightness level + (`setTorchModeOnWithLevel:`) but Android `setTorchMode` is binary (per-torch + strength is API 33+ only, `turnOnTorchWithStrengthLevel`). A cross-platform + `level:` option would be honored on one side and clamped on the other, so it's + deferred to keep the v1 contract honest. v1 drives iOS at + `AVCaptureMaxAvailableTorchLevel`. +- **Fire-and-forget, no-op when absent.** `set/2` returns the socket unchanged + (like `Mob.Haptic.trigger/2`). A device with no flash unit (tablets, the iOS + simulator) is a **no-op, not an error** — both native sides guard on hardware + presence. The module does not read torch state back; the app owns the boolean. +- **Wire contract:** `:mob_nif.torch/1` takes the atom `on` | `off`. Same + command shape as `haptic/1`, so it reuses the established seam (iOS NIF array + entry; Android zig `CallStaticVoidMethod` on a cached `MobBridge.torch(String)` + method — the Kotlin bridge half is the paired `mob_new` change). + +## Consequences + +- Cross-repo, one issue: `mob` (Elixir + NIF + iOS + zig) plus `mob_new` (the + generated `MobBridge.torch` Kotlin method). Existing apps pick it up by + regenerating their bridge + depending on the mob release that carries the NIF. +- The pure `Mob.Torch.state_atom/1` mapping is host-unit-tested; the native + toggle is **device-verified** on real hardware (no torch on the simulator): + moto g power (2021) via `:mob_nif.torch(:on|:off)` over dist, and iPhone SE + (3rd gen) via an on-device `Mob.Torch.set/2` button — both physically lit the + rear flash on and off. +- Brightness-level control and a `hardware present?` query are the natural + follow-ups if demand appears. diff --git a/guides/architecture.md b/guides/architecture.md index 076b151d..9a9ca2c9 100644 --- a/guides/architecture.md +++ b/guides/architecture.md @@ -4,26 +4,18 @@ Mob takes an unusual position in the mobile framework landscape. To understand w ## The core idea -``` -┌─────────────────────────────────────────────────────────┐ -│ Your Elixir App │ -│ (GenServers, Phoenix, Ecto, whatever you normally use) │ -└────────────────────────────┬────────────────────────────┘ - │ OTP supervision tree - ┌─────────▼─────────┐ - │ Mob.Screen │ GenServer - │ (your UI module) │ - └─────────┬─────────┘ - │ render/1 → component tree - ┌─────────▼─────────┐ - │ Mob.Renderer │ serialise + token resolution - └─────────┬─────────┘ - │ JSON (set_root NIF call) - ┌──────────────┴──────────────┐ - ┌────────▼───────┐ ┌─────────▼───────┐ - │ Compose (JVM) │ │ SwiftUI (Swift) │ - │ Android │ │ iOS │ - └────────────────┘ └─────────────────┘ +```mermaid +flowchart TD + A["Your Elixir App<br/>(GenServers, Phoenix, Ecto, whatever you normally use)"] + B["Mob.Screen<br/>(your UI module) — GenServer"] + C["Mob.Renderer<br/>serialise + token resolution"] + D1["Compose (JVM)<br/>Android"] + D2["SwiftUI (Swift)<br/>iOS"] + + A -->|OTP supervision tree| B + B -->|"render/1 → component tree"| C + C -->|"set_root NIF (JSON)"| D1 + C -->|"set_root NIF (JSON)"| D2 ``` BEAM and OTP run **on the device** — embedded inside the APK and the iOS app bundle. There is no server. Your screen logic, navigation state, and business logic all execute locally in the same BEAM node that the user has installed. diff --git a/guides/background_execution.md b/guides/background_execution.md new file mode 100644 index 00000000..6cde01e8 --- /dev/null +++ b/guides/background_execution.md @@ -0,0 +1,86 @@ +# Background Execution + +Mob runs the BEAM on the device, but mobile operating systems still control +when a backgrounded app may execute. A GenServer cannot assume it will keep +running forever after the user leaves the app unless the app uses one of the +platform-approved background mechanisms. + +## The default model: wake, handle, suspend + +For most server-driven work, use push notifications: + +1. The app registers with APNs or FCM via `MobNotify.register_push/1`. + (`MobNotify` ships in the `mob_notify` plugin — add the dep + activate in + `mob.exs`; see the [Plugins guide](plugins.md). Delivery of + `{:notification, notif}` and `{:push_token, ...}` messages is core + behavior.) +2. The device token is sent to your server. +3. Your server sends a notification through `mob_push`. +4. The OS delivers or stores the notification. +5. When the notification is delivered to a foreground app, or tapped from the + background or killed state, Mob sends `{:notification, notif}` to the screen. + +This model is reliable because APNs and FCM are the OS-approved wakeup paths. +It is not the same as keeping an Erlang distribution connection or WebSocket +open indefinitely while the app is backgrounded. + +See [Push Notifications](push_notifications.md) for token registration, +payloads, and notification tap handling. + +## iOS + +iOS suspends normal apps shortly after they enter the background. When that +happens, BEAM schedulers stop running with the rest of the process. Timers, +GenServers, sockets, and distribution connections do not continue like they +would on a server. + +iOS can wake an app through sanctioned mechanisms such as visible notification +taps, silent pushes, background fetch, `BGTaskScheduler`, location, Bluetooth, +audio, and other entitlement-backed modes. These wakeups are constrained by the +OS and usually provide a short execution window rather than an always-on +process. + +The opt-in `mob_background` plugin (`{:mob_background, "~> 0.1"}` + `config :mob, +:plugins, [:mob_background]`) exposes `MobBackground.keep_alive/0` for apps that +legitimately use audio. It keeps iOS execution alive through the audio +background mode. Do not +use this just to hide a server listener in the background; Apple expects the +declared background mode to match a user-visible app capability. + +## Android + +Android permits true long-running background work through a foreground service. +Mob maps `MobBackground.keep_alive/0` to a foreground service on Android. +Foreground services must show a persistent notification; Android intentionally +makes always-running background work visible to the user. + +Without a foreground service, recent Android versions restrict background +execution heavily. Use FCM for server-initiated wakeups and WorkManager-style +patterns for deferred work. + +## Choosing a pattern + +| Goal | Recommended path | +|---|---| +| Show or route a server event to a screen | Push notification via APNs / FCM | +| Refresh local state after a user taps a notification | Handle `{:notification, notif}` and fetch from your server | +| Run continuously while visible | Normal Mob screen / supervision tree | +| Run continuously in Android background | `MobBackground.keep_alive/0` foreground service | +| Run continuously in iOS background | Only for legitimate background modes such as audio, location, or Bluetooth | +| Hold a hidden always-on iOS socket | Not a supported mobile OS model | + +## Practical design + +Design background flows as resumable work: + +- Persist enough state locally with `Mob.State` or SQLite to resume after + suspension or cold start. +- Treat network connections as disposable; reconnect after the app foregrounds + or receives a notification. +- Make notification handlers idempotent, because a user may tap an old + notification after the app has already synced. +- Use `MobBackground.keep_alive/0` only when your app has a real foreground + service or background audio/location/Bluetooth reason. + +For the platform API details, see the `mob_background` plugin and +[Device Capabilities](device_capabilities.md). diff --git a/guides/components.md b/guides/components.md index 438d6850..46afd4c4 100644 --- a/guides/components.md +++ b/guides/components.md @@ -39,6 +39,79 @@ Expression child slots use `{...}` and accept a single node map or a list: """ ``` +## Control flow + +The sigil borrows three authoring idioms from Phoenix HEEx, so screens read the way LiveView developers expect. + +### `@assigns` shorthand + +Inside a `{...}` expression, `@foo` rewrites to `assigns.foo` at compile time. It works in attribute values, `{expr}` children, and the `:if`/`:for` directives below. Nested access like `@user.name` works too. + +```elixir +def render(assigns) do + ~MOB""" + <Column padding={16}> + <Text text={@title} text_size={:xl} /> + <Text text={"by #{@author.name}"} /> + </Column> + """ +end +``` + +`@title` is exactly `assigns.title` — the two forms are interchangeable, so reach for whichever reads better. + +> **`@foo` only works where `assigns` is in scope** — that is, a screen's or component's `render(assigns)`. Reusable helper functions (the [function composites](#pure-elixir-composite-components) below) take **positional arguments**, and there is no `assigns` inside them, so interpolate the argument directly: +> +> ```elixir +> # Screen render — assigns is in scope: +> def render(assigns), do: ~MOB(<Text text={@title} />) +> +> # Helper — NO @; use the argument: +> def label(title), do: ~MOB(<Text text={title} />) # not @title +> ``` +> +> Reaching for `@foo` inside a helper is the most common mistake here. It raises a `CompileError` naming the fix (`{title}` instead of `@title`) rather than a cryptic "undefined variable assigns". If your `render` parameter is named something other than `assigns` (e.g. `socket`), `@foo` won't find it either — name it `assigns`. + +### `:if` — conditional rendering + +`:if={expr}` renders the element only when the expression is truthy. A falsy `:if` drops the element entirely (it does not render an empty placeholder): + +```elixir +~MOB""" +<Column> + <Badge text="New" :if={@unread > 0} /> + <Text text="All caught up" :if={@unread == 0} /> +</Column> +""" +``` + +### `:for` — comprehension + +`:for={x <- list}` repeats the element once per item and splices the results into the parent's children: + +```elixir +~MOB""" +<Column> + <Row :for={user <- @users}> + <Text text={user.name} /> + </Row> +</Column> +""" +``` + +This is the declarative equivalent of the `{Enum.map(...)}` child slot shown above — use whichever is clearer for the case at hand. + +### Combining `:for` and `:if` + +When both are present on the same element, `:if` acts as a comprehension filter (matching LiveView): an element is produced only for items where the condition holds. + +```elixir +# Renders a Text for 2 and 4 only +<Text text={to_string(n)} :for={n <- 1..4} :if={rem(n, 2) == 0} /> +``` + +`:if` and `:for` each require a `{expr}` value — `:if="true"` (a string) raises a `CompileError`. Only `:if` and `:for` are recognised; any other `:`-prefixed attribute is a compile-time error. + ## Map syntax The sigil compiles to plain maps. You can also write them directly — useful when building components programmatically: @@ -68,6 +141,7 @@ Props accept: - **Strings** — used as-is - **Booleans** — used as-is - **Color atoms** (`:primary`, `:blue_500`, etc.) — resolved via the active theme and the base palette to ARGB integers. See [Theming](theming.md). +- **Raw colors** — a 32-bit **`0xAARRGGBB` integer** (alpha first, e.g. `0xFF2196F3`), **not** a CSS `"#RRGGBB"` string and **not** alpha-last. Include the `0xFF` alpha byte or the color renders transparent. See [Theming → Raw colors](theming.md#raw-colors-are-0xaarrggbb-integers-not-css-hex-strings). - **Spacing tokens** (`:space_xs`, `:space_sm`, `:space_md`, `:space_lg`, `:space_xl`) — scaled by `theme.space_scale` and resolved to integers. - **Radius tokens** (`:radius_sm`, `:radius_md`, `:radius_lg`, `:radius_pill`) — resolved to integers from the active theme. - **Text size tokens** (`:xs`, `:sm`, `:base`, `:lg`, `:xl`, `:2xl`, `:3xl`, `:4xl`, `:5xl`, `:6xl`) — scaled by `theme.type_scale` and resolved to floats. @@ -350,7 +424,7 @@ Embeds a native web view. Communicates bidirectionally with JS via the `window.m ### `:camera_preview` -Displays a live camera feed inline. Requires an active preview session — call `Mob.Camera.start_preview/2` before rendering and `Mob.Camera.stop_preview/1` in `terminate/2`. No OS permission dialog is shown for preview alone. +Displays a live camera feed inline. The `<CameraPreview>` node itself ships in core, but the preview session is driven by `MobCamera` (the `mob_camera` plugin — add the dep + activate in `mob.exs`; see the [Plugins guide](plugins.md)). Call `MobCamera.start_preview/2` before rendering and `MobCamera.stop_preview/1` in `terminate/2`. No OS permission dialog is shown for preview alone. | Prop | Type | Description | |------|------|-------------| @@ -361,7 +435,7 @@ Displays a live camera feed inline. Requires an active preview session — call ```elixir def mount(_params, _session, socket) do - socket = Mob.Camera.start_preview(socket, facing: :back) + socket = MobCamera.start_preview(socket, facing: :back) {:ok, socket} end @@ -376,11 +450,237 @@ def render(assigns) do end def terminate(_reason, socket) do - Mob.Camera.stop_preview(socket) + MobCamera.stop_preview(socket) :ok end ``` +<a id="pure-elixir-composite-components"></a> + +## Defining your own components + +You can build reusable components out of the built-in widgets with no native +code, in two forms: **function composites** (a plain function you call) and +**tag composites** (a custom `<Tag>` you register). Both are stateless, pure +Elixir, and hot-pushable. Events raised from inside either kind route to the +**screen's** `handle_info/2`, exactly like a built-in widget does. + +> **Reached for `use Mob.Component`?** Easy mix-up: it's a *different* feature. +> `Mob.Component` is the behaviour for **native view components**, a stateful BEAM +> process paired with a platform-native view (declared via `Mob.UI.native_view/2`), +> whose `render/1` returns a **props map for a native factory** rather than a `~MOB` +> tree. That's an advanced, native-code path (see the [Plugins guide](plugins.md)). If +> you just want a reusable widget or custom `<Tag>` built out of the **built-in** +> components, with no native code, that's the `Mob.Composite` path below. The names are +> close; for pure-Elixir tags the one you want is **Composite**, and its module returns +> a `~MOB` tree from `expand/3`. + +### Function composites + +A function composite is a function that returns a render tree. You call it +through `{...}` interpolation inside the sigil. This is the lightest way to +factor out a chunk of UI you repeat. + +Here is a complete screen that defines a `stat_card/3` composite and uses it. +The tap target is built in `render/1` and passed in as an argument, so the +button inside the composite delivers to this screen's `handle_info/2`: + +```elixir +defmodule MyApp.DashboardScreen do + use Mob.Screen + + @impl true + def mount(_params, _session, socket) do + {:ok, Mob.Socket.assign(socket, :taps, 0)} + end + + # A function composite: returns a render tree, so it drops into the screen + # via {...}. `on_tap` is a pre-built {pid, tag} tuple passed in by the caller. + defp stat_card(label, value, on_tap) do + ~MOB""" + <Box background={:surface_raised} corner_radius={:radius_md} padding={:space_md}> + <Column gap={4}> + <Text text={label} text_size={:sm} text_color={:muted} /> + <Text text={to_string(value)} text_size={:2xl} text_color={:on_surface} /> + <Button text="Tap me" on_tap={on_tap} /> + </Column> + </Box> + """ + end + + @impl true + def render(assigns) do + bump = {self(), :bump} + + ~MOB""" + <Column padding={:space_lg} gap={12}> + <Text text="Dashboard" text_size={:xl} text_color={:on_surface} /> + {stat_card("Taps", @taps, bump)} + </Column> + """ + end + + @impl true + def handle_info({:tap, :bump}, socket) do + {:noreply, Mob.Socket.update(socket, :taps, &(&1 + 1))} + end +end +``` + +Two things to notice: + +- `@taps` inside `{stat_card(...)}` is `assigns.taps` (the `@` shorthand works + in any `{...}` expression, including a composite call). +- The composite is a plain function call in `render/1`, which runs in the screen + process, so events from the `<Button>` inside it reach this screen. Building + the `{self(), :bump}` tuple in `render/1` and passing it in keeps the + composite reusable and follows the pre-compute-the-tuple convention. + +### Tag composites + +A tag composite gives you custom tag syntax, like `<Card title="...">`. You +register an *expander* for the tag, then write the tag in any screen. + +The sigil turns a PascalCase tag into a snake_case atom (`<Card>` becomes +`:card`, `<LabeledButton>` becomes `:labeled_button`), and the expander is +looked up by that atom. An expander is a function `expand(props, children, ctx)` +that returns a render tree (`~MOB` output). + +**Step 1 — write the expanders.** `Card` wraps its children in a titled +surface; `LabeledButton` raises a tap event: + +```elixir +defmodule MyApp.UI.Card do + @moduledoc "`<Card title=\"...\">children</Card>` — a titled raised surface." + import Mob.Sigil + + @spec expand(map(), [map()], map()) :: map() + def expand(props, children, _ctx) do + title = Map.get(props, :title, "") + + ~MOB""" + <Column background={:surface_raised} corner_radius={:radius_md} padding={:space_md}> + <Text text={title} text_size={:lg} text_color={:on_surface} /> + <Spacer size={8} /> + {children} + </Column> + """ + end +end + +defmodule MyApp.UI.LabeledButton do + @moduledoc ~S(`<LabeledButton label="..." on_press="save" />` — a button with an auto-injected tap target.) + import Mob.Sigil + + @spec expand(map(), [map()], map()) :: map() + def expand(props, _children, _ctx) do + label = Map.get(props, :label, "") + # `on_press` arrives already shaped as {screen_pid, :save} (see "Event + # ergonomics" below), so we pass it straight to the button's on_tap. + on_press = Map.fetch!(props, :on_press) + + ~MOB""" + <Button text={label} on_tap={on_press} /> + """ + end +end +``` + +`~MOB` is auto-imported inside `use Mob.Screen`, but an expander is a plain +module, so it needs `import Mob.Sigil`. + +**Step 2 — register the tags.** Through a plugin manifest's `ui_components`: + +```elixir +ui_components: [ + %{tag: "Card", atom: :card, expand: {MyApp.UI.Card, :expand}}, + %{tag: "LabeledButton", atom: :labeled_button, expand: {MyApp.UI.LabeledButton, :expand}} +] +``` + +…or at runtime, for a plain Hex UI kit with no manifest (call from the host's +`on_start/0`) via `Mob.Composite.register/2`: + +```elixir +Mob.Composite.register(:card, {MyApp.UI.Card, :expand}) +Mob.Composite.register(:labeled_button, {MyApp.UI.LabeledButton, :expand}) +``` + +**Expect a compile-time warning on a registered tag; it's harmless.** +Registration happens at *runtime* (from `on_start/0` or a plugin manifest), so +the `~MOB` macro can't see it while compiling a screen. Every custom tag +therefore prints a warning the first time it's compiled: + +``` +~MOB: <Card> is not in the Mob tag whitelist — pass-through as :card +``` + +That is informational, not an error. The sigil compiles `<Card>` to the atom +`:card` and defers resolution to whatever expander is registered under that atom +at render time. As long as you registered one in Step 2, the tag renders; the +warning is just the compiler telling you it recognized a non-built-in tag and +passed it through. (A genuinely unregistered tag renders nothing, which is the +real "it doesn't work" symptom to look for.) + +**Step 3 — use them in a screen.** Note there is no `self()` anywhere in this +markup: + +```elixir +defmodule MyApp.ProfileScreen do + use Mob.Screen + + @impl true + def mount(_params, _session, socket) do + {:ok, Mob.Socket.assign(socket, :status, "not saved yet")} + end + + @impl true + def render(assigns) do + ~MOB""" + <Column padding={:space_lg} gap={12}> + <Card title="Profile"> + <Text text="Tap save to record it." text_color={:muted} /> + <Spacer size={8} /> + <LabeledButton label="Save" on_press="save" /> + </Card> + <Card title="Status"> + <Text text={@status} text_color={:primary} /> + </Card> + </Column> + """ + end + + @impl true + def handle_info({:tap, :save}, socket) do + {:noreply, Mob.Socket.assign(socket, :status, "saved")} + end +end +``` + +**The expander contract.** `expand(props, children, ctx)` returns a node map or +a list of nodes, which is re-expanded to a fixpoint so composites can build on +other composites. `ctx` carries the screen process as `ctx.screen`. + +**Event ergonomics (auto-injected targets).** Any `on_*` prop you write on a +composite tag as a bare string or atom (`on_press="save"`) arrives in the +expander's `props` already shaped as `{screen_pid, :save}`. That is why +`ProfileScreen` never writes `self()`, and why the screen receives +`{:tap, :save}` in `handle_info/2`. + +This auto-injection applies only to a composite tag's **own** props. A built-in +widget you place directly (a `<TextField>` or `<Button>` in a screen's own +markup, even one nested inside a composite's children) still needs an explicit +`{self(), tag}` tuple, because its props are not run through an expander. That +is why `DashboardScreen` above builds `bump = {self(), :bump}` for its plain +`<Button>`, while `ProfileScreen` can write `<LabeledButton on_press="save">` +unadorned: `LabeledButton` is a composite tag, so its `on_press` is shaped for +you. + +For the full design see `Mob.Composite` and the "Pure-Elixir composite +components" section of [`MOB_PLUGINS.md`](../MOB_PLUGINS.md). The `mob_demo_kit` +plugin in `mob_plugin_demo` (`<DemoCard>` / `<DemoCombobox>`) is a worked, +device-verified example. + ## Using `Mob.Style` for reusable styles Define shared styles as module attributes and attach them via the `:style` prop. Inline props override style values: @@ -429,7 +729,7 @@ end ### Sub-component event isolation (planned, not yet implemented) -A future `Mob.Component` wrapper will allow a subtree of the render tree to have its own `handle_info/2`, routing events to that component process instead of the screen. Until then, use the `tag` field to distinguish events from different parts of the same screen: +Per-subtree event isolation, where a render subtree owns its own `handle_info/2` so its events route to a dedicated process instead of the screen, is planned but not yet implemented. (Distinct from `Mob.Composite`, the tag-composite mechanism under "Defining your own components" above, which exists today for reusable widgets and custom tags; and from `Mob.Component`, the existing native-view behaviour.) Until then, use the `tag` field to distinguish events from different parts of the same screen: ```elixir top_save_tap = {self(), :top_save} diff --git a/guides/device_capabilities.md b/guides/device_capabilities.md index eea7769d..1b670c49 100644 --- a/guides/device_capabilities.md +++ b/guides/device_capabilities.md @@ -2,6 +2,18 @@ All device APIs in Mob follow a consistent pattern: call the function from a callback (returning the socket unchanged), then handle the result in `handle_info/2`. APIs never block the screen process. +Since 0.7.0, several capabilities ship as first-party **plugins** rather than in core: `MobCamera` (`mob_camera`), `MobPhotos` (`mob_photos`), `MobLocation` (`mob_location`), `MobBiometric` (`mob_biometric`), `MobScanner` (`mob_scanner`), and `MobNotify` (`mob_notify`). Activating one is the same two steps for each — add the dep, list it in `mob.exs`: + +```elixir +# mix.exs +{:mob_camera, "~> 0.1"} + +# mob.exs +config :mob, :plugins, [:mob_camera] +``` + +See the [Plugins guide](plugins.md). Everything else on this page (haptics, clipboard, share, files, audio, motion, storage, web view, alerts) is core. + ## Permissions Some capabilities require an OS permission before they can be used. Request permissions via `Mob.Permissions.request/2`. The result arrives asynchronously: @@ -25,6 +37,16 @@ end **No permission needed:** haptics, clipboard, share sheet, file picker. +> **`Mob.Permissions.request/2` is only half the picture.** Each +> permission-gated capability also needs an `Info.plist` usage +> description (iOS) and `AndroidManifest.xml` `uses-permission` entry +> (Android). The default `mix mob.new` template covers camera + +> microphone on iOS and most capabilities on Android, but leaves +> location, photo library, etc. for you to add explicitly. See +> [permissions](permissions.html) for the per-capability table, the +> iOS-specific gotchas, and a diagnostic checklist for "the dialog +> never appears". + ## Haptic feedback `Mob.Haptic.trigger/2` fires synchronously (no `handle_info` needed) and returns the socket: @@ -75,16 +97,16 @@ Options: `:text`, `:url`, `:title` ## Camera -Requires `:camera` permission (and `:microphone` for video). +`MobCamera` ships in the `mob_camera` plugin (which also registers the `:camera` permission) — add the dep + activate in `mob.exs`. Requires `:camera` permission (and `:microphone` for video). ```elixir # Capture a photo -socket = Mob.Camera.capture_photo(socket) -socket = Mob.Camera.capture_photo(socket, quality: :medium) +socket = MobCamera.capture_photo(socket) +socket = MobCamera.capture_photo(socket, quality: :medium) # Record a video -socket = Mob.Camera.capture_video(socket) -socket = Mob.Camera.capture_video(socket, max_duration: 30) +socket = MobCamera.capture_video(socket) +socket = MobCamera.capture_video(socket, max_duration: 30) # Results: def handle_info({:camera, :photo, %{path: path, width: w, height: h}}, socket) do @@ -104,11 +126,11 @@ end ## Photos -Browse and pick from the photo library. Requires `:photo_library` permission. +Browse and pick from the photo library. `MobPhotos` ships in the `mob_photos` plugin. Requires `:photo_library` permission. ```elixir -socket = Mob.Photos.pick(socket) -socket = Mob.Photos.pick(socket, max: 5) # pick up to 5 +socket = MobPhotos.pick(socket) +socket = MobPhotos.pick(socket, max: 5) # pick up to 5 def handle_info({:photos, :picked, photos}, socket) do # photos is a list of %{path: path, width: w, height: h} maps @@ -136,15 +158,14 @@ end ``` > **Platform note:** `types` uses iOS UTI strings on iOS (`"public.pdf"`) and MIME type strings on Android (`"application/pdf"`). To support both platforms with the same call, pass both forms — the platform ignores strings it doesn't recognise. See [Platform-specific props](components.md#platform-specific-props) for a cleaner pattern. -``` ## Camera preview -Display a live camera feed inline (no OS permission dialog for preview): +Display a live camera feed inline (no OS permission dialog for preview). The `<CameraPreview>` render-tree node lives in core, but the preview session API is `MobCamera` (`mob_camera` plugin): ```elixir def mount(_params, _session, socket) do - socket = Mob.Camera.start_preview(socket, facing: :back) + socket = MobCamera.start_preview(socket, facing: :back) {:ok, socket} end @@ -158,7 +179,7 @@ def render(assigns) do end def terminate(_reason, socket) do - Mob.Camera.stop_preview(socket) + MobCamera.stop_preview(socket) :ok end ``` @@ -208,16 +229,16 @@ iOS uses `AVAudioPlayer` / `AVPlayer`. Android uses `MediaPlayer`. ## Location -Requires `:location` permission. +`MobLocation` ships in the `mob_location` plugin. Requires `:location` permission. ```elixir # Single fix -socket = Mob.Location.get_once(socket) +socket = MobLocation.get_once(socket) # Continuous updates -socket = Mob.Location.start(socket) -socket = Mob.Location.start(socket, accuracy: :high) # :high | :balanced | :low -socket = Mob.Location.stop(socket) +socket = MobLocation.start(socket) +socket = MobLocation.start(socket, accuracy: :high) # :high | :balanced | :low +socket = MobLocation.stop(socket) def handle_info({:location, %{lat: lat, lon: lon, accuracy: acc, altitude: alt}}, socket) do {:noreply, Mob.Socket.assign(socket, :location, %{lat: lat, lon: lon})} @@ -244,8 +265,10 @@ end ## Biometric authentication +`MobBiometric` ships in the `mob_biometric` plugin. + ```elixir -socket = Mob.Biometric.authenticate(socket, reason: "Confirm your identity") +socket = MobBiometric.authenticate(socket, reason: "Confirm your identity") def handle_info({:biometric, :success}, socket) do {:noreply, Mob.Socket.assign(socket, :authenticated, true)} @@ -260,8 +283,10 @@ iOS uses Face ID / Touch ID. Android uses `BiometricPrompt`. ## QR / barcode scanner +`MobScanner` ships in the `mob_scanner` plugin. Activate `mob_camera` alongside it — `mob_camera` owns the `:camera` permission the scanner needs (`config :mob, :plugins, [:mob_camera, :mob_scanner]`). + ```elixir -socket = Mob.Scanner.scan(socket) +socket = MobScanner.scan(socket) def handle_info({:scan, :result, %{type: type, value: value}}, socket) do # type: :qr | :ean | :upc | etc. @@ -275,7 +300,7 @@ end ## Notifications -See also [Mob.Notify](Mob.Notify.html) for the full API. +`MobNotify` ships in the `mob_notify` plugin — see [its docs](https://hexdocs.pm/mob_notify) for the full API. Delivery (the `{:notification, ...}` and `{:push_token, ...}` messages below) is unchanged core behavior. Requires `:notifications` permission. @@ -283,7 +308,7 @@ Requires `:notifications` permission. ```elixir # Schedule -Mob.Notify.schedule(socket, +MobNotify.schedule(socket, id: "reminder_1", title: "Time to check in", body: "Open the app to see today's updates", @@ -292,7 +317,7 @@ Mob.Notify.schedule(socket, ) # Cancel -Mob.Notify.cancel(socket, "reminder_1") +MobNotify.cancel(socket, "reminder_1") # Receive in handle_info (all app states: foreground, background, relaunched): def handle_info({:notification, %{id: id, data: data, source: :local}}, socket) do @@ -308,7 +333,7 @@ Quick reference: ```elixir # After :notifications permission is granted: -{:noreply, Mob.Notify.register_push(socket)} +{:noreply, MobNotify.register_push(socket)} # Receive the device token — store it server-side with the platform: def handle_info({:push_token, platform, token}, socket) do diff --git a/guides/dns_on_ios.md b/guides/dns_on_ios.md new file mode 100644 index 00000000..7a189e02 --- /dev/null +++ b/guides/dns_on_ios.md @@ -0,0 +1,434 @@ +# DNS on iOS — Why Req / Finch / Mint Fail Without Configuring BEAM's DNS Path + +If you're running a mob app on iOS and you call out to an HTTPS endpoint +by hostname — `Req.get!("https://api.example.com/...")` — the request +fails. The same code works on macOS, Linux, the iOS simulator, Android, +and physical Android. **Only the iOS device sees the failure**, and the +error is usually some flavour of "nxdomain" or "lookup failed." + +This document explains why that happens and how to fix it. + +--- + +## TL;DR + +```elixir +# Once, in your app's on_start/0 (already in the mob.new template): +Mob.DNS.configure_pure_beam() + +# Now Req / Finch / Mint / HTTPoison / Tesla all work normally: +Req.get!("https://api.example.com/things") +``` + +`configure_pure_beam/1` flips BEAM's lookup chain from the broken +`:native` (port-program) path to `[:file, :dns]` — BEAM does raw DNS +queries from inside Erlang via `gen_udp` / `gen_tcp`, no fork, no +`execve`. Defaults to Google + Cloudflare as fallback nameservers; +override with `nameservers:` if you need to. + +For hosts that need iOS's own resolver — VPN-pushed DNS, `.local` / +mDNS, search-domain expansion, captive portals — use the per-host +`Mob.DNS.resolve/1` / `preresolve/1` path described below. Both +mechanisms compose; the per-host calls always win because `:file` is +first in the chain. + +--- + +## What mob already does for you + +Before any of this matters, `Mob.App.start/0` runs +`Mob.App.configure_ios_inet_db/0` on iOS (both simulator and device), +which switches the lookup chain to `[:file]` and seeds `localhost`. +That's the minimum needed so `Node.connect`, `:erpc.call`, and +`gen_tcp.connect/3` with a binary host don't crash the calling +process on the first lookup. Apps don't have to do anything for +distribution and local-loopback TCP to work. + +`Mob.DNS.configure_pure_beam/1` is the next step *on top of that* — +it upgrades the chain to `[:file, :dns]` and seeds fallback +nameservers so outbound HTTP to public hosts works. The framework +default doesn't enable `:dns` because apps that don't talk to the +public internet shouldn't pay for fallback-nameserver state they +won't use. + +--- + +## Why this exists + +BEAM resolves hostnames the same way it always has: it spawns an +external helper called `inet_gethost` — a small port program shipped +with OTP — and pipes hostname requests to it. The helper calls libc +`getaddrinfo` on your behalf and pipes the result back. The reason it's +out-of-process is historical (the BEAM didn't always trust libc to be +non-blocking, and `getaddrinfo` can block for seconds on slow +networks). + +On macOS, Linux, Windows, and Android this works fine. + +**On iOS it doesn't.** iOS sandboxes apps and forbids `execve` of any +binary the app didn't get a special pass for. There is no equivalent of +Android's "ship the helper as a `lib*.so` in `jniLibs/` and the SELinux +policy will let you `execve` it" escape hatch. When BEAM tries to spawn +`inet_gethost`, the kernel refuses. From the app's perspective, every +hostname lookup fails immediately. + +Everything that resolves hostnames through `:inet` is affected: + +- Req +- Finch +- Mint +- HTTPoison +- Tesla (via any of the above adapters) +- `:httpc` (the built-in OTP client) +- `gen_tcp:connect/3,4` when given a hostname + +Anything that resolves *outside* of `:inet` is fine — see "What this +does NOT affect" below. + +--- + +## Two ways to fix it + +`Mob.DNS` exposes two complementary mechanisms. They compose — call +`configure_pure_beam` once at startup as the default, then `resolve/1` +only for the specific hosts where Apple-resolver semantics matter. + +### 1. `configure_pure_beam/1` — pure-BEAM DNS as default + +```elixir +def on_start do + Mob.DNS.configure_pure_beam() + # …rest of startup… +end +``` + +What it does: + +1. Calls `:inet_db.set_lookup([:file, :dns])`. The `:dns` method + resolves via raw UDP/TCP queries performed inside BEAM by + `inet_res` (`gen_udp` / `gen_tcp`). No port program, no fork, no + `execve`. iOS doesn't block sockets, so this Just Works. +2. Seeds fallback nameservers — defaults to Google + Cloudflare + (`{8,8,8,8}` and `{1,1,1,1}`). Override via `nameservers:` opt. + +After this, `:inet.getaddr/2` (and therefore the entire HTTP-library +ecosystem) resolves any public hostname without per-host setup. + +### 2. `Mob.DNS.resolve/1` / `preresolve/1` — Apple-resolver-backed, per host + +```elixir +{:ok, _ip} = Mob.DNS.resolve("internal.corp.local") +``` + +What it does: + +1. Calls Darwin's `getaddrinfo` directly via a NIF (iOS allows + in-process libc calls — only `execve` of foreign binaries is + blocked). +2. Walks the result for the first IPv4 address. +3. Seeds it into `:inet_db`'s file table via `:inet_db.add_host/2`. +4. Ensures `:file` is first in the lookup chain so the seeded entry + wins over whatever comes after. + +Because the NIF goes through Apple's resolver, this path honours +**everything iOS knows about DNS** — VPN-pushed nameservers, search +domains, `.local` / mDNS, captive portals, encrypted-DNS configured +in iOS Settings. The pure-BEAM `:dns` path does none of that; it +just queries whatever nameservers you seeded. + +### How they compose + +`configure_pure_beam` puts `:file` first in the chain. So when you +later call `resolve/1` for `internal.corp.local`, the Apple-resolved +IP is added to the file table and **always wins** over the `:dns` +fallback. The two paths don't conflict. + +--- + +## What this does NOT affect + +If a NIF resolves hostnames itself — by calling libc `getaddrinfo` +directly inside its own C/Zig/Rust code — it doesn't go through BEAM's +`:inet` layer and so doesn't need (or benefit from) this fix. It +already works on iOS. + +Examples of NIFs that already do their own DNS: + +- **`crypto`** and **`ssl`** don't do DNS at all; they're handed an + already-connected socket. +- **`reticulum_nif`** (Pigeon's transport NIF) calls `getaddrinfo` + inside Reticulum's network stack. Pigeon transports work on iOS + without `Mob.DNS`. +- Most Rust NIFs using `tokio`/`hyper` (e.g. `Reqwest`-backed clients) + do their own DNS via libc. + +If you're not sure whether a particular library needs `Mob.DNS`, the +quick check is: does it eventually call `:inet.getaddr/2`, +`:gen_tcp.connect/3,4`, or `:ssl.connect/3,4` with a hostname (binary +or charlist)? If yes, it goes through BEAM's `:inet` layer and needs +`Mob.DNS`. If it shells out to a NIF that does its own networking, it +doesn't. + +--- + +## Android is unaffected — here's why + +The exact same `inet_gethost` mechanism *would* be blocked on Android +by default — SELinux policy refuses `execute_no_trans` on binaries in +the app's data directory. But Android has a documented escape hatch: +binaries packaged as `lib<name>.so` inside `jniLibs/<abi>/` get the +`apk_data_file` SELinux label, which *does* allow execution. + +`mob_beam.zig` (the Android BEAM launcher) ships the OTP helpers +(`inet_gethost`, `erl_child_setup`, `epmd`) as `lib*.so` files in +`jniLibs/arm64-v8a/`, then symlinks `BINDIR/<name>` → +`<nativeLibraryDir>/lib<name>.so` before calling `erl_start`. From +BEAM's perspective, the helpers live exactly where it expects them and +are executable. DNS works normally. + +iOS has no comparable mechanism. The `Mob.DNS` NIF is the workaround. + +--- + +## Trade-offs — pure-BEAM vs. Apple-resolver + +| Concern | `configure_pure_beam` (`:dns` method) | `resolve/1` / `preresolve/1` (libc NIF) | +|---|---|---| +| Who runs the DNS query? | BEAM's `inet_res` — raw UDP/TCP from Erlang | Apple's resolver, in-process via libc | +| Nameservers used | Whatever you seeded (defaults to Google + Cloudflare) | Whatever iOS knows about — DHCP, VPN, configured DoH/DoT | +| Captive portals (hotel / airport Wi-Fi) | Often broken — captive nets hijack DNS in ways the OS handles, raw UDP doesn't | Handled by iOS | +| Corporate / VPN DNS for internal hostnames | Doesn't work unless you also seed the corporate resolver | Works — iOS picks up DNS pushed by the VPN profile | +| Search-domain expansion (single-label `https://api/`) | Not applied | Applied by iOS resolver | +| `.local` / mDNS service discovery | Doesn't work | Works | +| IPv6 dual-stack (Happy Eyeballs) | Manual | Automatic | +| TTL respected; auto-refresh when an IP rotates | Yes (DNS TTLs honoured per lookup) | No — `inet_db` seed persists until you re-`resolve/1` | +| Cost per lookup | UDP round-trip every time `:dns` fires | Zero after the first call (cached in `inet_db`) | +| Per-host setup required? | No | Yes (`resolve/1` per hostname, or `preresolve/1` for a batch) | +| Code surface | One function call at startup | NIF + Elixir wrapper | + +**Default to `configure_pure_beam`.** It covers everything most apps +talk to (consumer Wi-Fi or cellular + public-internet endpoints) with +one line of setup. + +**Reach for `resolve/1` per-host** when you specifically need the +Apple-resolver behaviour from the table above. The two compose — the +manually-seeded entry always wins over the `:dns` fallback because +`:file` is first in the chain. + +--- + +## When to call `resolve` / `preresolve` + +**At app startup, for known-fixed backends.** List the backends that +need Apple-resolver semantics (VPN, mDNS, etc.) and resolve them +alongside the `configure_pure_beam` call: + +```elixir +def on_start do + Mob.DNS.configure_pure_beam() # public-internet hosts + + Mob.DNS.preresolve([ # OS-resolver-special hosts + "internal.corp.local", + "files.local" + ]) + + # …rest of startup… +end +``` + +The map returned from `preresolve/1` lets you log per-host failures +without aborting the whole startup: + +```elixir +for {host, result} <- Mob.DNS.preresolve(hosts) do + case result do + {:ok, ip} -> Logger.info("[dns] #{host} → #{:inet.ntoa(ip)}") + {:error, reason} -> Logger.warning("[dns] #{host} failed: #{inspect(reason)}") + end +end +``` + +**Lazily, right before the first request.** Useful if the set of +backends isn't known until login or some other runtime event: + +```elixir +def authenticated_request(host, path) do + Mob.DNS.resolve(host) # idempotent; fast if already resolved + Req.get!("https://#{host}#{path}") +end +``` + +`resolved?/1` lets you skip the call if you want to: + +```elixir +unless Mob.DNS.resolved?(host), do: Mob.DNS.resolve(host) +``` + +…but `resolve/1` is already cheap on the happy path (one libc call, +one map insertion), so the explicit guard is rarely worth it. + +--- + +## Limitations and caveats + +- **IPv4 only.** Most cloud endpoints serve A records and BEAM picks + the first one anyway. IPv6 (AAAA) is a follow-up — file an issue if + you need it. +- **One IP per host.** If the hostname has multiple A records, the + first one is used. There's no failover; if that IP becomes + unreachable mid-session, requests will fail until you call + `resolve/1` again. +- **No automatic refresh.** Seeded entries stay in `:inet_db` until + the BEAM exits. If your backend's IP changes (DNS round-robin, blue/ + green deploy), the cached entry will be stale until you re-resolve. + For most apps this is fine; if it isn't, set up a periodic + re-resolve task. +- **iOS only effectively.** On Android and host (Mac dev, Linux, the + iOS simulator) the NIF works but is unnecessary; BEAM's built-in + DNS path is fine. Calling `Mob.DNS.resolve/1` on those platforms is + harmless but redundant. +- **Doesn't help raw NIF networking.** See "What this does NOT + affect" above. + +--- + +## Errors `resolve/1` can return + +```elixir +{:ok, {a, b, c, d}} # success — IPv4 address +{:error, :badarg} # host arg invalid (not a charlist/binary) +{:error, :nxdomain} # no such hostname +{:error, :timeout} # resolver TRY_AGAIN +{:error, :no_address} # resolved but no IPv4 result +{:error, {:gai, code}} # raw getaddrinfo error code +{:error, :nif_not_loaded} # called off-device (host tests / IEx) +``` + +Treat `:nif_not_loaded` as "you're not on a device" — it's the signal +that returns from host BEAM where the NIF isn't compiled in. Useful in +tests; in production code on iOS you should never see it. + +--- + +## App Transport Security is a separate concern + +ATS (Apple's TLS-enforcement policy) is a different gate. If your +endpoint serves plain HTTP, or uses a self-signed cert, or uses an +older TLS version, ATS will block the connection even after DNS +succeeds. The errors look completely different (`NSURLErrorDomain +-1022` or similar), but it's worth knowing that "my request fails on +iOS" can mean DNS *or* ATS. If `Mob.DNS.resolve/1` returns `{:ok, _}` +and the request still fails with a TLS-looking error, suspect ATS +next. + +--- + +## Why the manual call instead of automatic interception + +In principle a startup hook could intercept every `:inet.getaddr` +call, resolve via NIF, and seed `:inet_db` transparently — and the +user would never have to touch `Mob.DNS` at all. We didn't go that +route because: + +1. **Predictability.** Explicit `resolve/1` calls show up in your + startup code and in profiles. Magic interception that fails + silently is harder to diagnose when a host you forgot to whitelist + breaks in production. +2. **Cost.** Resolving every hostname on every request adds a libc + round-trip even when the entry is already cached. Manual + `preresolve/1` at startup keeps the hot path zero-cost. +3. **Compatibility.** Some apps want to use a custom DNS server + (mDNS for service discovery, DNS-over-HTTPS for privacy). Manual + resolution leaves those paths open; automatic interception would + need to grow more configuration than the explicit call. + +If your app talks to a small fixed set of hosts (which most do), the +extra `preresolve/1` line at startup is the lowest-friction option. + +--- + +## Other gotchas (empirically discovered) + +Once `Mob.DNS` is in place, the next failures down the HTTPS stack +are easy to misread as "DNS still broken." They aren't — they're +separate issues that the iOS-device deployment surfaces because +the BEAM bootstrap is more minimal than a normal Mix project. If +your request still fails after seeding DNS, check these: + +### 1. Start the HTTP client's application + +Mob's iOS launcher (`mob_beam.m`) boots a minimal BEAM: +`compiler` → `elixir` → `logger` → `<your_app>.start/0`. That's +*all*. Hex dependencies like `:req` are **not auto-started** — the +normal OTP `applications:` list in your `.app` file isn't being +consulted by this boot path. + +If Req's `Finch` supervisor isn't running you'll see: + +``` +GenServer.call(Req.FinchSupervisor, ...) +** (EXIT) no process: the process is not alive ... +``` + +Fix: explicitly start the HTTP client and the cert store in your +`on_start/0`, after `Mob.DNS.preresolve/1`: + +```elixir +def on_start do + # ... your usual startup ... + + {:ok, _} = Application.ensure_all_started(:req) + {:ok, _} = Application.ensure_all_started(:castore) + + Mob.DNS.preresolve(["api.example.com"]) + + Mob.Screen.start_root(MyApp.HomeScreen) +end +``` + +Same pattern for `:finch`/`:mint`/`:httpoison`/`:tesla` if you're +using those directly. + +### 2. TLS trust store — `:castore` or `:public_key.cacerts_load!/0` + +Mint's HTTPS path verifies the server certificate by default and +needs a CA bundle. On iOS-device builds the default +`Application.app_dir(:castore, "priv/cacerts.pem")` path doesn't +always resolve correctly even with castore in deps. Two working +options: + +```elixir +# Option A — explicit CA file via transport opts +Req.get(url, connect_options: [transport_opts: [cacertfile: ...]]) + +# Option B — load the OS CA store (OTP 25+) +:public_key.cacerts_load!() +Req.get(url, connect_options: [ + transport_opts: [cacerts: :public_key.cacerts_get()] +]) +``` + +For dev / spike testing where you don't care about cert validation, +`verify: :verify_none` works but **never ship this**: + +```elixir +Req.get(url, connect_options: [transport_opts: [verify: :verify_none]]) +``` + +### 3. Stale `:inet_db` if the IP rotates + +`Mob.DNS.resolve/1` seeds `:inet_db` once per BEAM lifetime. If the +backend's IP changes mid-session (DNS round-robin, blue/green +deploy), subsequent requests will keep hitting the cached IP until +you call `resolve/1` again. For long-running apps that talk to +volatile endpoints, schedule a periodic re-resolve. + +### 4. Hot-push doesn't re-run `on_start/0` + +`mix mob.deploy` (without `--native`) hot-loads new BEAMs via +`:code.load_binary` — the running app's `on_start/0` is *not* +re-invoked, and the on-disk `.beam` files in the app's Documents +dir aren't updated. If you change `on_start/0` (e.g., to add the +`ensure_all_started` calls above), use `mix mob.deploy --native` +to actually reinstall the app with the new beams on disk so a +restart will pick them up. diff --git a/guides/events.md b/guides/events.md index 72cce7cc..f97a5077 100644 --- a/guides/events.md +++ b/guides/events.md @@ -499,5 +499,5 @@ receive" it's a footgun. ## Where to find more - [`event_model.md`](event_model.md) — design contract, address shape, ID rules -- [`event_audit.md`](event_audit.md) — current native emitters, migration plan -- [`PLAN.md`](../PLAN.md) — roadmap; what's done, what's coming +- [`event_audit.md`](https://github.com/GenericJam/mob/blob/master/guides/event_audit.md) — current native emitters, migration plan +- [`PLAN.md`](https://github.com/GenericJam/mob/blob/master/PLAN.md) — roadmap; what's done, what's coming diff --git a/guides/getting_started.md b/guides/getting_started.md index 5c24e4ea..ea8da311 100644 --- a/guides/getting_started.md +++ b/guides/getting_started.md @@ -632,7 +632,7 @@ defmodule MyApp.HomeScreen do ~MOB""" <Column padding={24} gap={16}> <Text text={"Count: #{assigns.count}"} text_size={:xl} /> - <Button text="Tap me" on_tap={tap(:increment)} /> + <Button text="Tap me" on_tap={{self(), :increment}} /> </Column> """ end @@ -646,8 +646,9 @@ end ``` `mount/3` initialises assigns. `render/1` returns the component tree via the `~MOB` -sigil. `handle_info/2` updates state in response to user events. After each update, -the framework calls `render/1` again and pushes the diff to the native layer. +sigil. An `on_tap={{self(), :increment}}` sends `{:tap, :increment}` to the screen +when the button is pressed, which `handle_info/2` matches to update state. After each +update, the framework calls `render/1` again and pushes the diff to the native layer. --- @@ -659,6 +660,7 @@ the framework calls `render/1` again and pushes the diff to the native layer. - [Theming](theming.md) — color tokens, named themes, runtime switching - [Data & Persistence](data.md) — `Mob.State` for preferences, Ecto + SQLite for structured data - [Device Capabilities](device_capabilities.md) — camera, location, haptics, notifications +- [Background Execution](background_execution.md) — push wakeups, foreground services, and OS background limits - [LiveView Mode](liveview.md) — full Phoenix LiveView app inside a native WebView (the two-bridge architecture, `mix mob.enable liveview`) - [Testing](testing.md) — unit tests and live device inspection - [Troubleshooting](troubleshooting.md) — if something isn't working, start here diff --git a/guides/liveview.md b/guides/liveview.md index ab1ecf45..4f0f758a 100644 --- a/guides/liveview.md +++ b/guides/liveview.md @@ -5,6 +5,13 @@ UI code required. Mob runs a local Phoenix endpoint on the device and wraps it i a native WebView. LiveView updates travel over the existing WebSocket at loopback speed (~1–5 ms). +> **Looking for LiveView-style authoring in native screens?** This guide is about +> running an actual Phoenix LiveView in a WebView. If you instead want the *feel* +> of LiveView while writing native `~MOB` screens, the sigil already mirrors +> several HEEx idioms — `@assigns` shorthand and the `:if` / `:for` control +> attributes — and `Mob.Socket` mirrors `assign/2,3`, `update/3`, and +> `assign_new/3`. See [Components → Control flow](components.md#control-flow). + ## Setup Run this from your Mob project root (the directory with `mix.exs`): diff --git a/guides/mobile_surface_matrix.md b/guides/mobile_surface_matrix.md new file mode 100644 index 00000000..338bb9c6 --- /dev/null +++ b/guides/mobile_surface_matrix.md @@ -0,0 +1,352 @@ +# Mobile surface matrix + +What Mob covers — what's solid, what's partial, what's missing. Use +this to set realistic expectations before starting an app, and to +spot gaps worth filling (either in mob core, in a plugin, or by +declaring out-of-scope). + +The reference surface is the union of **React Native core**, +**Expo SDK modules**, and platform-native capabilities both ecosystems +have converged on as "what mobile apps need." Many missing items are +**pluggable** — see [MOB_PLUGINS.md](../MOB_PLUGINS.md) for the +manifest spec. + +This doc is hand-maintained from inspection of `lib/mob/` and +`src/mob_nif.erl`. If you add a capability, update the matching row. + +## Legend + +| | | +|--|--| +| ✅ | Fully present — public Elixir API, both iOS + Android (unless noted) | +| 🟡 | Partial — works but limited (single platform, narrow API, or known caveats) | +| ❌ | Missing — could be a plugin or future core addition | +| ⛔ | Out of scope — requires separate deployment target (widgets, Watch app), or fundamentally incompatible with Mob's architecture | + +Per-platform columns: `✓` = supported, `—` = not supported, `n/a` = not applicable on that platform. + +A ✅ capability may live in **core** or in a **first-party plugin** +(0.7.0 extracted camera, photos, location, notifications, biometrics, +scanning, and Bluetooth into `mob_*` capability packages). The Notes +column names the supplying plugin; rows without one are core. Plugins +activate with the dep + `config :mob, :plugins, [...]` in `mob.exs` — +see the [Plugins guide](plugins.md). + +--- + +## UI components (render tree) + +Elements you can use inside `~MOB`. The set is intentionally small +and orthogonal — composition over a fat component library. + +| Component | Status | iOS | Android | Notes | +|--|--|--|--|--| +| `<Box>` | ✅ | ✓ | ✓ | Container with align, padding, background, corner radius, border | +| `<Column>`, `<Row>` | ✅ | ✓ | ✓ | Flex layouts | +| `<Text>` | ✅ | ✓ | ✓ | Font, color, size, weight, align, line height, letter spacing | +| `<Button>` | ✅ | ✓ | ✓ | Tap handler, text, background, fill width | +| `<Image>` | ✅ | ✓ | ✓ | Local + remote (Coil on Android, AsyncImage on iOS) | +| `<TextField>` | ✅ | ✓ | ✓ | Keyboard type, return key, placeholder, change events | +| `<Toggle>` | ✅ | ✓ | ✓ | Boolean switch | +| `<Slider>` | ✅ | ✓ | ✓ | Min/max/value, change events | +| `<Progress>` | ✅ | ✓ | ✓ | Linear + circular | +| `<Divider>` | ✅ | ✓ | ✓ | Horizontal line separator | +| `<Spacer>` | ✅ | ✓ | ✓ | Layout filler | +| `<Scroll>` | ✅ | ✓ | ✓ | Vertical or horizontal, scroll observation on iOS 18+ | +| `<List>` | ✅ | ✓ | ✓ | Vertical / horizontal stack with selection | +| `<LazyList>` | ✅ | ✓ | ✓ | Virtualised long-list with on_end_reached pagination | +| `<TabBar>` | ✅ | ✓ | ✓ | Bottom tab bar (Material 3 NavigationBar on Android, SwiftUI Tab on iOS) | +| `<WebView>` | ✅ | ✓ | ✓ | Inline web view, JS bridge, navigation control | +| `<CameraPreview>` | ✅ | ✓ | ✓ | Live preview with frame stream | +| `<Video>` | 🟡 | ✓ | 🟡 | Android: ExoPlayer integration pending | +| `<GpuView>` | ✅ | ✓ | ✓ | Metal (iOS) / GLES 3.0 (Android) fragment shader surface | +| Custom views via `<NativeView>` | ✅ | ✓ | ✓ | Register a plugin-defined render-tree node type | +| Date / Time / Color pickers | ❌ | — | — | Plugin candidate | +| `<SearchBar>` | ❌ | — | — | Native search bar (UISearchBar / SearchBar). Plugin candidate | +| `<DatePicker>` | ❌ | — | — | Plugin candidate | +| `<Modal>` (sheet presentation) | 🟡 | 🟡 | 🟡 | Programmatic alerts + action sheets exist; full sheet-style modal is plugin territory | +| Pull-to-refresh | ❌ | — | — | Missing; commonly requested. Plugin candidate | +| Bottom sheets | ❌ | — | — | Plugin candidate | +| Drawer navigation | ❌ | — | — | Plugin candidate; mob's nav model is stack-based today | + +## Touch, gesture, input + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| Tap / double-tap / long-press | ✅ | ✓ | ✓ | `on_tap`, `on_double_tap`, `on_long_press` props | +| Swipe (l/r/u/d) | ✅ | ✓ | ✓ | `on_swipe_left`, etc. | +| Pan / drag gesture | 🟡 | 🟡 | 🟡 | Tap-based; full pan-responder system (like react-native-gesture-handler) is missing | +| Pinch / zoom | ❌ | — | — | Plugin candidate; common for image/map views | +| Rotation gesture | ❌ | — | — | Plugin candidate | +| Hardware keyboard events | ❌ | — | — | `key_press/1` exists for the test harness but not as a user-facing API | +| Keyboard show/hide events | ❌ | — | — | Missing; commonly needed for keyboard-aware layouts | +| `<KeyboardAvoidingView>` equivalent | ❌ | — | — | Plugin / core candidate | +| Apple Pencil / stylus events | ❌ | — | n/a | Plugin territory | +| 3D Touch / Force Touch | ❌ | — | n/a | Deprecated by Apple; low priority | +| Drag and drop (cross-app) | ❌ | — | — | Plugin candidate | +| Haptic feedback | ✅ | ✓ | ✓ | `Mob.Haptic.trigger/2` | + +## Device + system info + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| Platform detection | ✅ | ✓ | ✓ | `Mob.Device.platform/0` returns `:ios` or `:android` | +| OS version | ✅ | ✓ | ✓ | `Mob.Device.os_version/0` | +| Device model | ✅ | ✓ | ✓ | `Mob.Device.model/0` | +| Foreground / background state | ✅ | ✓ | ✓ | `Mob.Device.foreground?/0` + `{:device, :foreground/:background, ...}` events | +| Battery level + state | ✅ | ✓ | ✓ | `Mob.Device.battery_level/0`, `battery_state/0` | +| Thermal state | ✅ | ✓ | ✓ | `Mob.Device.thermal_state/0` | +| Low-power mode | ✅ | ✓ | ✓ | `Mob.Device.low_power_mode?/0` | +| Color scheme (light/dark) | ✅ | ✓ | ✓ | `Mob.Theme.color_scheme/0` + `Mob.Theme.Adaptive` (auto-watch) | +| Safe area insets | ✅ | ✓ | ✓ | `Mob.Device.safe_area/0` | +| Screen dimensions / pixel ratio | ✅ | ✓ | ✓ | `Mob.Device.screen_info/0` | +| Locale / language | 🟡 | 🟡 | 🟡 | Derivable from system; no first-class API | +| Time zone | 🟡 | 🟡 | 🟡 | Use Erlang's `:calendar` directly | +| Network info (cell vs wifi, type) | ❌ | — | — | Plugin candidate (NetInfo equivalent) | +| Network reachability | ❌ | — | — | Plugin candidate | +| Screen brightness | ❌ | — | — | Plugin candidate | +| Screen orientation lock | ✅ | ✓ | ✓ | `Mob.Device.lock_orientation/1` + `unlock_orientation/0` (Android `setRequestedOrientation`; iOS supported-orientations + geometry request) | +| Idle timer / screen wake | ✅ | ✓ | ✓ | `Mob.Device.keep_awake/1` — prevent auto-dim/lock (iOS `isIdleTimerDisabled`; Android `FLAG_KEEP_SCREEN_ON`) | +| Exit app | ✅ | n/a | ✓ | Android only; iOS forbids programmatic exit | + +## Storage + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| Key-value store | ✅ | ✓ | ✓ | `Mob.Storage` with typed schemas, namespacing | +| Files API | ✅ | ✓ | ✓ | `Mob.Files`, plus `Mob.Storage.dir/1` for app-private paths | +| External files dir (Android) | ✅ | n/a | ✓ | `storage_external_files_dir/1` | +| Save to photo library | ✅ | ✓ | ✓ | `Mob.Storage.save_to_photo_library/1` + Android MediaStore equivalent | +| SQLite | ✅ | ✓ | ✓ | Via `:ecto_sqlite3` + bundled `libsqlite3_nif.so` | +| Keychain / Keystore | ❌ | — | — | Plugin candidate (standalone API beyond biometric) | +| Secure-storage / encrypted-storage | ❌ | — | — | Plugin candidate | + +## Camera + microphone + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| Capture photo | ✅ | ✓ | ✓ | `MobCamera.capture_photo/2` (`mob_camera` plugin) | +| Capture video | ✅ | ✓ | ✓ | `MobCamera.capture_video/2` (`mob_camera` plugin) | +| Live preview | ✅ | ✓ | ✓ | `<CameraPreview>` component (core; session API in `mob_camera`) | +| Per-frame stream | ✅ | ✓ | ✓ | `MobCamera.start_frame_stream/2` (`mob_camera` plugin) — pushes RGBA frames to `handle_info` | +| Photo library picker | ✅ | ✓ | ✓ | `MobPhotos.pick/2` (`mob_photos` plugin) | +| Audio recording | ✅ | ✓ | ✓ | `Mob.Audio.start_recording/2` | +| Audio playback | ✅ | ✓ | ✓ | `Mob.Audio.play/3`, stop, volume | +| Text-to-speech | ✅ | ✓ | ✓ | `Mob.Speech.speak/3` + `stop_speaking/1` (AVSpeechSynthesizer / TextToSpeech) | +| Speech recognition | ❌ | — | — | Plugin candidate (SFSpeechRecognizer / SpeechRecognizer) | +| Voice activity detection | ❌ | — | — | Plugin candidate | +| Audio effects (reverb, EQ) | ❌ | — | — | Plugin candidate | +| Camera zoom / focus / exposure | 🟡 | 🟡 | 🟡 | Basic capture works; fine-grained control missing | +| Torch / flashlight | ✅ | ✓ | ✓ | `Mob.Torch.on/1`, `off/1`, `set/2` — core, no camera session or permission. On/off only (brightness level is a follow-up) | + +## Connectivity + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| Bluetooth Classic | ✅ | n/a | ✓ | `MobBluetooth` plugin (extracted; Hfp / Spp sub-modules) — central/host role, Android only | +| Bluetooth Low Energy (BLE) | 🟡 | ✓ | ✓ | `MobBluetooth.Le` (mob_bluetooth 0.3.0) — GATT **peripheral** role only (advertise + notify + receive writes), iOS + Android; BLE central (scan/connect) is a future addition | +| NFC | ❌ | — | — | Plugin candidate (Core NFC / Android NFC) | +| WiFi info / scanning | ❌ | — | — | Plugin candidate; OS restrictions apply | +| USB host | ✅ | n/a | ✓ | `Mob.VendorUsb` — bulk read/write, custom devices | +| WebSocket client | 🟡 | n/a | n/a | Use Elixir libs directly (e.g. `:gun`) | +| HTTP client | 🟡 | n/a | n/a | Use Elixir libs (`:req`, `:finch`) | +| File upload progress | ❌ | — | — | Plugin candidate | +| Background download/upload | ❌ | — | — | Plugin candidate | +| mDNS / Bonjour | ❌ | — | — | Plugin candidate | +| Sockets (raw TCP/UDP) | 🟡 | n/a | n/a | Use Erlang's `:gen_tcp`/`:gen_udp` | +| Mob.Dist (BEAM clustering) | ✅ | ✓ | ✓ | Hot-push, device → desktop connection | + +## Sensors + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| Accelerometer | ✅ | ✓ | ✓ | `Mob.Motion.start(:accelerometer, ...)` | +| Gyroscope | ✅ | ✓ | ✓ | `Mob.Motion.start(:gyro, ...)` | +| Magnetometer | ✅ | ✓ | ✓ | `Mob.Motion.start(:magnetometer, ...)` — µT-calibrated `mag` + fused `heading` (0.7.14); keys present only when `:magnetometer` requested | +| Barometer | ❌ | — | — | Plugin candidate | +| Proximity | ❌ | — | — | Plugin candidate | +| Ambient light | ❌ | — | — | Plugin candidate | +| Pedometer / step counter | ❌ | — | — | Plugin candidate | +| Compass / heading | ✅ | ✓ | ✓ | Fused `heading` (degrees from magnetic north) via `Mob.Motion` `:magnetometer` — same request as above | + +## Location + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| One-shot location | ✅ | ✓ | ✓ | `MobLocation.get_once/1` (`mob_location` plugin) | +| Continuous updates | ✅ | ✓ | ✓ | `MobLocation.start/2`, stop (`mob_location` plugin) | +| Background location | 🟡 | 🟡 | 🟡 | Mob's foreground-service keep-alive lets updates continue while backgrounded; not a true background-location API | +| Geofencing | ❌ | — | — | Plugin candidate (`CLCircularRegion` / `Geofencing API`) | +| Significant-change updates | ❌ | — | — | Plugin candidate (iOS) | +| Mock-location detection | ❌ | — | — | Plugin candidate | +| Reverse geocoding | ❌ | — | — | Use third-party API for now (e.g. Mapbox) | + +## Notifications + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| Local notification scheduling | ✅ | ✓ | ✓ | `MobNotify.schedule/2`, cancel (`mob_notify` plugin) | +| Push notification registration | ✅ | ✓ | ✓ | `MobNotify.register_push/1` (`mob_notify` plugin) → token to `handle_info` | +| Push delivery via APNs / FCM | ✅ | ✓ | ✓ | Via `mob_push` Hex package | +| Notification tap handling | ✅ | ✓ | ✓ | Foreground + background + cold-start (`take_launch_notification/0`) | +| Notification actions (buttons) | ❌ | — | — | Plugin / core candidate | +| Critical / time-sensitive flags (iOS) | ❌ | — | n/a | Plugin candidate | +| Notification grouping / threading | ❌ | — | — | Plugin candidate | +| Badge management | 🟡 | 🟡 | 🟡 | Basic only | + +## Background tasks + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| Foreground service / keep-alive | ✅ | ✓ | ✓ | `MobBackground.keep_alive/0` (mob_background plugin) | +| Background fetch (silent periodic) | ❌ | — | — | Plugin candidate (iOS Background Tasks framework / Android WorkManager) | +| Silent push handling | 🟡 | 🟡 | 🟡 | Push arrives but no dedicated "wake-and-handle-then-suspend" lifecycle | +| Background URL session | ❌ | — | — | Plugin candidate | +| Scheduled jobs (periodic / one-shot) | ❌ | — | — | Plugin candidate (WorkManager equivalent) | + +## Auth + payment + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| Biometric auth (Face ID / fingerprint) | ✅ | ✓ | ✓ | `MobBiometric.authenticate/2` (`mob_biometric` plugin) | +| Apple Sign-In | ❌ | — | n/a | Plugin candidate (common requirement for App Store) | +| Google Sign-In | ❌ | — | — | Plugin candidate | +| Sign in with X / Facebook / etc. | ❌ | — | — | Plugin candidate | +| OAuth flow helpers | ❌ | — | — | Plugin candidate; can mostly be done from Elixir | +| In-app purchase (StoreKit / Play Billing) | ❌ | — | — | Plugin candidate; sensitive — needs receipt validation | +| Apple Pay | ❌ | — | n/a | Plugin candidate | +| Google Pay | ❌ | — | — | Plugin candidate | +| Passkeys / WebAuthn | ❌ | — | — | Plugin candidate | + +## ML / Vision + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| QR / barcode scanning | ✅ | ✓ | ✓ | `MobScanner.scan/2` (`mob_scanner` plugin; activate `mob_camera` too — it owns `:camera`) — full-screen scanner with format filtering | +| TFLite model inference | ✅ | ✓ | ✓ | Via `mix mob.enable tflite` (mob_dev 0.5.7+) — NNAPI/MTK on Android, Core ML delegate on iOS | +| Nx-based inference | 🟡 | 🟡 | 🟡 | Via `nx_eigen` exploration; not formalised | +| Apple Vision framework wrappers | ❌ | — | n/a | Plugin candidate (text recognition, face detection, image classification) | +| Apple Foundation Models (LLM) | ❌ | — | n/a | Plugin in flight — see mob PR #8 (DRAFT) | +| MLKit wrappers (Android) | 🟡 | n/a | 🟡 | Barcode scanning uses it under the hood; other models (text, face, pose) are plugin territory | +| OCR (text recognition) | ❌ | — | — | Plugin candidate | +| Face detection | ❌ | — | — | Plugin candidate | +| Pose detection | ❌ | — | — | Plugin candidate | +| Speech-to-text | ❌ | — | — | Plugin candidate | +| Translation | ❌ | — | — | Plugin candidate | +| Smart Reply | ❌ | — | — | Plugin candidate | + +## Maps + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| Native map view | ❌ | — | — | Plugin candidate (Apple Maps / Google Maps) | +| Annotations / markers | ❌ | — | — | Plugin candidate | +| Polylines / polygons | ❌ | — | — | Plugin candidate | +| User location display | ❌ | — | — | Plugin candidate | +| Map tile providers (Mapbox, etc.) | ❌ | — | — | Plugin candidate | + +## System integration + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| Clipboard | ✅ | ✓ | ✓ | `Mob.Clipboard.put/1`, `get/0` | +| Open URL (deep linking, browser) | ✅ | ✓ | ✓ | `Mob.Device.open_url/1` — picks browser, mail, tel, etc. | +| Share sheet (text) | ✅ | ✓ | ✓ | `Mob.Share.text/1` | +| Share sheet (image / file) | ❌ | — | — | Plugin candidate | +| Document picker | ✅ | ✓ | ✓ | `Mob.Files.pick/1` | +| Action sheet (iOS-style menu) | ✅ | ✓ | ✓ | `action_sheet_show/2` via Mob.Alert | +| Toast (Android-style) | ✅ | ✓ | ✓ | `toast_show/2` — implemented on both platforms | +| Alert dialog | ✅ | ✓ | ✓ | `Mob.Alert.alert/2` | +| Vibration patterns | ✅ | ✓ | ✓ | Via `Mob.Haptic` | +| App settings page (open) | ❌ | — | — | Plugin candidate | +| Calendar events | ❌ | — | — | Plugin candidate | +| Contacts | ❌ | — | — | Plugin candidate | +| Reminders (iOS) | ❌ | — | n/a | Plugin candidate | +| Permissions facade | ✅ | ✓ | ✓ | `Mob.Permissions.request/2` for camera, microphone, photos, location, notifications | + +## Accessibility + +| Capability | Status | iOS | Android | Notes | +|--|--|--|--|--| +| Accessibility labels / hints | 🟡 | 🟡 | 🟡 | Some component props expose this; not uniform across all components | +| Screen reader announcements (imperative) | ❌ | — | — | Plugin / core candidate | +| Focus management (programmatic) | ❌ | — | — | Plugin / core candidate | +| Reduce-motion preference | ❌ | — | — | Plugin candidate | +| Bold-text preference | ❌ | — | — | Plugin candidate | +| Dynamic type / font scaling | 🟡 | 🟡 | 🟡 | iOS automatic via system size; explicit override needed | +| RTL support | 🟡 | 🟡 | 🟡 | Layout-engine level; no explicit `I18nManager` equivalent | +| Accessibility test inspection | ✅ | ✓ | ✓ | `Mob.Test` reads the AX tree for assertion-based UI testing | + +## iOS-only platform features + +| Capability | Status | Notes | +|--|--|--| +| Live Activities / Dynamic Island | ⛔ | Requires Widget Extension target — separate from main app; not a Mob template today | +| Widgets (home + lock screen) | ⛔ | Same — Widget Extension target | +| App Clips | ⛔ | App Clip target; not in Mob templates | +| Watch app companion | ⛔ | WatchKit target; not in Mob templates | +| Share extensions | ⛔ | Share Extension target; not in Mob templates | +| Today extensions (deprecated by Apple) | ⛔ | — | +| Background App Refresh | ❌ | Plugin candidate | +| Apple Pencil events | ❌ | Plugin candidate | +| Multi-window (iPad) | 🟡 | App runs but no first-class multi-scene API | +| Split View / Slide Over | 🟡 | Same as multi-window | +| Picture in Picture (video) | ❌ | Plugin candidate | +| Universal Links / Custom URL Scheme | 🟡 | Open URL works; route registration is per-app, no unified API | +| Handoff / NSUserActivity | ❌ | Plugin candidate | +| Spotlight indexing | ❌ | Plugin candidate | +| App Shortcuts (Siri integration) | ❌ | Plugin candidate | + +## Android-only platform features + +| Capability | Status | Notes | +|--|--|--| +| Home screen widgets | ⛔ | AppWidgetProvider — separate component, not in Mob templates | +| Quick Settings tiles | ⛔ | TileService — separate component | +| App Shortcuts (long-press launcher) | ❌ | Plugin candidate | +| Picture in Picture | ❌ | Plugin candidate | +| Multi-window | ✅ | Works via resizable activity flag | +| Split-screen | ✅ | Works via resizable activity flag | +| Foldable / large-screen support | 🟡 | Layout adapts; no first-class foldable APIs | +| Auto Backup | ✅ | Honored by default per AndroidManifest | +| Doze mode handling | ❌ | Plugin candidate (alarm/wake-up scheduling) | +| Direct Share | ❌ | Plugin candidate | +| Notification channels (configurable) | 🟡 | Default channel works; per-app multi-channel API is partial | + +## Architecturally not present (and probably shouldn't be) + +| Item | Why | +|--|--| +| JavaScript / TypeScript runtime | Mob's host language is Elixir/Erlang/Gleam on BEAM; bridging JS would defeat the architecture | +| React reconciler | Mob has its own render tree; no React VDOM under it | +| CSS / Yoga flexbox engine | iOS uses SwiftUI layout; Android uses Compose layout; both are native flexbox-equivalents | +| `XMLHttpRequest` / `fetch` polyfill | Use Erlang/Elixir HTTP libraries directly (`:req`, `:finch`, `:gun`) | +| Babel / Metro / bundler | BEAM bytecode replaces JS bundling; `mix mob.push` ships `.beam` directly | + +--- + +## How to use this matrix + +- **Starting a new app**: scan the ❌ rows first to see what would need to be a plugin or worked around. +- **Reporting a gap**: if something here is wrong (mob has a capability I missed, or partial that's actually full), the doc is hand-maintained — please open a PR or flag it. +- **Filling a gap as a plugin**: see [MOB_PLUGINS.md](../MOB_PLUGINS.md) for the manifest spec. Most ❌ rows are plugin candidates (tier 1 or tier 2 depending on whether they ship UI). +- **Filling a gap in core**: when a capability is universal enough (every app needs it, both platforms support it cleanly) it's worth landing in core rather than as a plugin. The boundary is fuzzy; raising a discussion before doing the work is the right call. + +This matrix isn't a roadmap commitment — it's a snapshot of reality. +Some ❌ rows may stay ❌ for a long time because no one's asked. +Others will land via community plugins. The intent is honest +disclosure, not a promise of feature parity with React Native. + +--- + +## Related docs + +- [`MOB_PLUGINS.md`](../MOB_PLUGINS.md) — plugin manifest spec for + filling missing capabilities without merging into core +- [`RELEASE.md`](https://github.com/GenericJam/mob/blob/master/RELEASE.md) — release process if you're shipping a + new capability that lands in core +- [`guides/styling.md`](styling.md) — visual styling for the + components above (tokens, themes, dark mode) +- [`guides/support_matrix.md`](support_matrix.md) — minimum + OS / ABI / SDK supported (a different "support matrix" — + platform versions rather than capabilities) diff --git a/guides/native_extensions.md b/guides/native_extensions.md new file mode 100644 index 00000000..db2f6e69 --- /dev/null +++ b/guides/native_extensions.md @@ -0,0 +1,115 @@ +# Native Extensions + +Mob apps can be extended with native code in four ways, accessed +through two Mix tasks. This guide is a summary — the detailed +contract per backend (how Cargo / Zigler / Pythonx normally work, +what Mob changes for static linking, where the bundled Python +runtime comes from on each platform, which workarounds are +transient) lives in +[mob_dev's `guides/nifs.md`](https://hexdocs.pm/mob_dev/nifs.html). +Read that one before debugging a native build. + +## Two tasks, one decision + +| Question | Use | +|---|---| +| "I want to write a NIF I'll name myself." | `mix mob.add_nif <name>` | +| "I want to enable a pre-named Mob feature." | `mix mob.enable <feature>` | + +The split tracks a real distinction. `add_nif` creates *instances* +the user names (`audio_engine`, `image_codec`, `crypto_utils`) and +can have many of. `enable` toggles *singleton features* with fixed +implementations (`pythonx`, `mlx`, `camera`, `notifications`) — each +exists at most once per app. + +## `mix mob.add_nif <name>` + +Scaffolds a statically-linked NIF: Elixir stub, native skeleton +appropriate to the chosen backend, `:static_nifs` entry in `mob.exs`, +and regenerated dispatch table — one command, one diff. + +```bash +mix mob.add_nif audio_engine # Elixir-only stub; you wire native side +mix mob.add_nif audio_engine --type c # also drops c_src/audio_engine.c +mix mob.add_nif audio_engine --type rustler # native/audio_engine/ Cargo crate + :rustler dep +mix mob.add_nif audio_engine --type zigler # ~Z sigil in the stub + :zigler dep +mix mob.add_nif audio_engine --type rustler --demo # also generates a demo screen +``` + +Why static linking? iOS App Store rejects bundled `.dylib`; Android +`RTLD_LOCAL` hides the parent's `enif_*` symbols from a `dlopen`'d +child. Both platforms force the same answer: link the NIF init +function into the main app binary alongside `libbeam.a`. mob_dev +handles the cross-compile and link automatically — you write the +Rust/Zig/C, run `mix mob.deploy --native`, and the right `.a` ends +up in the right place per arch. + +**Bringing in an existing Rust project** (one crate or many — there's +no upper limit) takes four manual steps documented in +[mob_dev's NIF guide](https://hexdocs.pm/mob_dev/nifs.html#bringing-in-an-existing-rust-crate). +You don't need to be a Rust expert to follow it — the steps are +copy-paste. + +## `mix mob.enable <feature>` + +Toggles an optional feature with a fixed implementation. Patches +`mix.exs`, platform manifests (Info.plist / AndroidManifest.xml), +and any required source files in one Igniter run. + +| Feature | What it gives you | +|---|---| +| `liveview` | Phoenix LiveView mode — app renders a local web view | +| `camera` | Camera permission + capture API | +| `photo_library` | Photo picker + saving | +| `file_sharing` | iOS Files-app integration + Android FileProvider | +| `location` | Coarse + fine location permissions and API | +| `notifications` | Push notifications (entitlement + APNs / FCM glue) | +| `pythonx` | Embedded CPython interpreter on iOS + Android | +| `mlx` | Apple MLX tensor math + EMLX Nx backend (iOS) | + +```bash +mix mob.enable camera photo_library # multiple in one command +mix mob.enable pythonx # embeds CPython 3.13 on both platforms +mix mob.enable mlx # on-device tensor math (iOS, ~30 MB) +``` + +The `pythonx` and `mlx` features cost real bundle size (~70 MB and +~30 MB respectively). The rest are cheap (manifest entries + a few +hundred lines of generated Elixir/Swift/Kotlin). + +For exactly where Mob fetches the bundled CPython runtime from +(BeeWare's `Python-Apple-support` for iOS, Chaquopy for Android, why +two sources, what's identical between them) — see the Pythonx +section of [mob_dev's NIF guide](https://hexdocs.pm/mob_dev/nifs.html#python-via-pythonx). + +## What gets generated, where + +For any NIF added via `mob.add_nif <name>` (regardless of `--type`): + +``` +lib/<app>/nifs/<name>.ex # Elixir stub module +mob.exs # :static_nifs entry appended +priv/generated/driver_tab_ios.zig # dispatch table (regenerated) +priv/generated/driver_tab_android.zig # dispatch table (regenerated) +``` + +Plus, depending on `--type`: + +``` +c_src/<name>.c # --type c +native/<name>/Cargo.toml # --type rustler +native/<name>/src/lib.rs # --type rustler +native/<name>/.cargo/config.toml # --type rustler (macOS link flags) +``` + +For `mob.enable <feature>` the file list varies per feature — see +the individual feature docs via `mix help mob.enable`. + +## Where to dig deeper + +| Topic | Location | +|---|---| +| Per-backend mechanics, how each upstream library works, what Mob changes, transient workarounds | [`mob_dev/guides/nifs.md`](https://hexdocs.pm/mob_dev/nifs.html) | +| Embedded CPython app integration (wheels, first-launch extraction, host-dev fallback) | [`mob_dev/guides/python_embedding.md`](https://hexdocs.pm/mob_dev/python_embedding.html) | +| `MobDev.StaticNifs` schema (arch values, per-arch symbol naming) | `MobDev.StaticNifs` module doc | +| Full task references | `mix help mob.add_nif`, `mix help mob.enable` | diff --git a/guides/navigation.md b/guides/navigation.md index 31e4da76..2a555ead 100644 --- a/guides/navigation.md +++ b/guides/navigation.md @@ -191,3 +191,18 @@ stack(:home, root: MyApp.HomeScreen) # Later, anywhere: Mob.Socket.push_screen(socket, :home) # resolves to MyApp.HomeScreen ``` + +### Route-bound params + +`Mob.Nav.Registry.register/3` can bind a params map to a route, letting many +routes share one parameterized screen module (the data-driven pattern — e.g. +a plugin registering `:post_list` as `{MobAsh.ListScreen, %{resource: MyApp.Post}}`): + +```elixir +Mob.Nav.Registry.register(:post_list, MobAsh.ListScreen, %{resource: MyApp.Post}) +Mob.Nav.Registry.register(:user_list, MobAsh.ListScreen, %{resource: MyApp.User}) +``` + +When such a route is the navigation destination, the route-bound params are +merged *under* the caller's `push_screen` params (the caller's keys win on +conflict) and the merged map is what arrives in the destination's `mount/3`. diff --git a/guides/packages.md b/guides/packages.md new file mode 100644 index 00000000..88b1eca3 --- /dev/null +++ b/guides/packages.md @@ -0,0 +1,64 @@ +# First-Party Packages + +Mob is fully featured — but since 0.7.0 the capabilities live in focused +packages rather than one monolithic core. Core ships the kernel every app +needs (screens, navigation, rendering, state, storage, permissions, +distribution, the test harness, and the neutral light/dark/adaptive +themes); everything else is one dep + one config line away. + +Activating any capability plugin is the same two steps: + +```elixir +# mix.exs +{:mob_camera, "~> 0.1"} + +# mob.exs +config :mob, :plugins, [:mob_camera] +``` + +Style packages use the styles lane instead: + +```elixir +config :mob, :styles, [:mob_themes] +config :mob, :default_style, :mob_themes +``` + +## Capability plugins + +| Package | Gives you | Notes | +|---|---|---| +| [mob_camera](https://hexdocs.pm/mob_camera) | Photo/video capture, live preview session, ML-ready frame streaming | The `<CameraPreview>` view node is in core; pair it with `MobCamera.start_preview/2` | +| [mob_photos](https://hexdocs.pm/mob_photos) | The system photo/video picker | No runtime permission needed (out-of-process picker) | +| [mob_location](https://hexdocs.pm/mob_location) | GPS/network location — one-shot + continuous | | +| [mob_biometric](https://hexdocs.pm/mob_biometric) | Face ID / Touch ID / fingerprint auth | iOS fully working; Android currently reports `:not_available` (fix tracked) | +| [mob_notify](https://hexdocs.pm/mob_notify) | Local notification scheduling + push registration | Pairs with the server-side [mob_push](https://hexdocs.pm/mob_push); delivery into `handle_info` is core behavior | +| [mob_scanner](https://hexdocs.pm/mob_scanner) | QR / barcode scanning (full-screen scanner) | Also activate `mob_camera` (it owns the `:camera` permission) | +| [mob_bluetooth](https://hexdocs.pm/mob_bluetooth) | Bluetooth discovery + SPP/HFP/HID | | +| [mob_screencast](https://hexdocs.pm/mob_screencast) | The device's own screen as an on-device-encoded H264 stream | For remote viewing/WebRTC; `max_size` is Android-only today | + +## Style packages + +| Package | Gives you | +|---|---| +| [mob_themes](https://hexdocs.pm/mob_themes) | Five preset looks — Obsidian (default), ObsidianGlass, Citrus, Birch, Material3. Switch live with `Mob.Theme.set(MobThemes.Citrus)` | + +## Framework integrations + +| Package | Gives you | +|---|---| +| [mob_ash](https://hexdocs.pm/mob_ash) | Declare [Ash](https://hexdocs.pm/ash) resources, get generated list/detail/create screens per resource — Ash runs on-device | + +## Server-side companions + +| Package | Gives you | +|---|---| +| [mob_push](https://hexdocs.pm/mob_push) | APNs + FCM push sending from your Elixir server (no mob dependency — works for any app) | + +## Building your own + +- Pure-Elixir UI kits: function composites work with a plain Hex dep, and + tag-name composites via [`Mob.Composite`](Mob.Composite.html) — see the + [Components guide](components.md). +- Anything deeper: `mix mob.new_plugin --tier 0|1|2|3|4` scaffolds a plugin + with tests; the [Plugins guide](plugins.md) and the + [manifest reference](MOB_PLUGINS.md) cover the rest. diff --git a/guides/permissions.md b/guides/permissions.md new file mode 100644 index 00000000..79da1937 --- /dev/null +++ b/guides/permissions.md @@ -0,0 +1,235 @@ +# Permissions + +Single source of truth for the OS-level permissions Mob exposes, the +manifest / `Info.plist` entries each one requires, and the +platform-specific gotchas that aren't covered by the runtime API alone. + +If you're hitting "the dialog never appears" or "I called the NIF and +nothing happened", this is the first place to look. + +## TL;DR + +* Call `Mob.Permissions.request(socket, :capability)` from your screen. +* The result arrives as `handle_info({:permission, :capability, :granted | :denied}, socket)`. +* iOS additionally needs the matching `NS*UsageDescription` key in `ios/Info.plist`. Without it, the dialog is silently suppressed and you get nothing — no event, no error. +* Android additionally needs the matching `uses-permission` line in `AndroidManifest.xml`. The `mob.new` template ships most of these already; if you added a feature after generating the project, double-check. + +## The per-capability table + +| `Mob.Permissions` cap | iOS `Info.plist` key | Android `uses-permission` | Notes | +|-------------------------|-----------------------------------------------------------------|-------------------------------------------------------------------------------------------|-------| +| `:camera` | `NSCameraUsageDescription` | `android.permission.CAMERA` | Registered by the `mob_camera` plugin (see below). Required by `MobCamera`. `CameraPreview` *also* needs the plist key but does not call `Mob.Permissions.request/2` — request explicitly before mounting it. | +| `:microphone` | `NSMicrophoneUsageDescription` | `android.permission.RECORD_AUDIO` | Required by `Mob.Audio.start_recording/2` and by `MobCamera.capture_video/2`. | +| `:photo_library` | `NSPhotoLibraryUsageDescription` | API 33+: `READ_MEDIA_IMAGES` + `READ_MEDIA_VIDEO`. API ≤32: `READ_EXTERNAL_STORAGE`. | Required by `MobPhotos.pick/2` (`mob_photos` plugin). | +| `:location` | `NSLocationWhenInUseUsageDescription` | `ACCESS_FINE_LOCATION` (high accuracy) and/or `ACCESS_COARSE_LOCATION` (low accuracy). | See [iOS notes below](#ios-location-extras) — the dialog timing is unusual. | +| `:notifications` | (none — handled by `UNUserNotificationCenter`) | API 33+: `android.permission.POST_NOTIFICATIONS` | iOS shows the dialog the first time `request/2` runs. Android API ≤32 doesn't need a permission at all (notifications are user-controllable in Settings). | + +The permission registry is **extensible**: plugins can register the +capabilities they own. As of 0.7.0, `:camera` is registered by the +`mob_camera` plugin — activate it (`{:mob_camera, "~> 0.1"}` in deps + +`config :mob, :plugins, [:mob_camera]` in `mob.exs`) before requesting +`:camera`. The other rows above (`:microphone`, `:photo_library`, +`:location`, `:notifications`) are registered by core. The +`Mob.Permissions.request/2` API itself is unchanged regardless of who +registered the capability. + +Capabilities that need **no runtime permission** on either platform and +do not appear in the table: + +* `Mob.Haptic`, `Mob.Clipboard`, `Mob.Share`, `Mob.Files.pick/2`, + `Mob.Toast`, `Mob.Alert`, `Mob.WebView`, `Mob.Motion`, `MobBiometric` + (ships in the `mob_biometric` plugin; uses biometric prompt UI but does + not require a permission grant), + `Mob.Storage` (app-local paths only). + +Capabilities that need an `Info.plist` or manifest entry **without** going +through `Mob.Permissions.request/2`: + +| Operation | iOS `Info.plist` key | Android | +|-------------------------------------------------------------------|---------------------------------|---------| +| `Mob.Storage.Apple.save_to_photo_library/2` | `NSPhotoLibraryAddUsageDescription` | Same `READ_MEDIA_*` family as `:photo_library` on API 33+. | +| `Mob.Audio.play/2` (no permission) | none | none | +| `MobCamera.start_preview/2` (no permission for the *preview*; capture still needs `:camera`) | `NSCameraUsageDescription` | `CAMERA` | + +## What the `mob.new` template ships by default + +If you generate a fresh project with `mix mob.new`, the template emits: + +* **`ios/Info.plist`** — `NSCameraUsageDescription` and `NSMicrophoneUsageDescription`. Nothing else. +* **`android/app/src/main/AndroidManifest.xml`** — `CAMERA`, `RECORD_AUDIO`, `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VIDEO`, `READ_EXTERNAL_STORAGE` (API ≤32 only), `POST_NOTIFICATIONS`, `VIBRATE`, `FOREGROUND_SERVICE`, `INTERNET`, `RECEIVE_BOOT_COMPLETED`. + +So out-of-the-box your project covers camera + microphone on both +platforms, plus everything Android needs for the other capabilities. +**Anything iOS-side beyond camera + mic needs you to add the +`Info.plist` key yourself** before the first time you call that +capability. The most common ones to add: + +```xml +<key>NSLocationWhenInUseUsageDescription</key> +<string>MyApp shows your location to ...</string> + +<key>NSPhotoLibraryUsageDescription</key> +<string>MyApp lets you pick photos from your library.</string> + +<key>NSPhotoLibraryAddUsageDescription</key> +<string>MyApp saves captures to your photo library.</string> +``` + +If you ship without the key, iOS won't even log the missing-key error +in any obvious place — the dialog just silently doesn't appear, and +the underlying `request*Authorization` call no-ops. Symptom looks +identical to "the user denied permission" except no `denied` event +ever arrives. + +## iOS-specific notes + +### iOS location extras + +Apple's `CLLocationManager` couples permission and updates more +tightly than the other capabilities. Mob exposes both paths: + +1. `Mob.Permissions.request(socket, :location)` calls + `requestWhenInUseAuthorization` on a dedicated `CLLocationManager` + and reports the user's actual choice as `{:permission, :location, + :granted | :denied}` once the dialog is dismissed (or immediately + if the permission was previously decided). + +2. `MobLocation.get_once/1` and `MobLocation.start/2` (`mob_location` + plugin) *also* + trigger the dialog if `request/2` wasn't called yet. The dialog + is one-shot per app install — subsequent calls short-circuit + with the cached authorization. + +3. If the user denies, two events flow: + - `Mob.Permissions.request/2`'s caller hears `{:permission, + :location, :denied}`. + - `MobLocation.get_once/1`/`start/2`'s caller hears + `{:location, :error, :permission_denied}` (via the + `locationManagerDidChangeAuthorization:` callback). This means + a screen that skipped `request/2` and went straight to + `get_once` still has a way to break out of the "waiting for + fix…" state. + +4. The `Allow Once` button on iOS counts as `:granted` for the + current run of the app. The next launch will prompt again. + +5. Authorization can change mid-session — the user pops out to + Settings and revokes. The delegate fires + `{:location, :error, :permission_denied}` when that happens; + surface it in your screen if you care about long-running tracking + sessions. + +### Camera + microphone + +These go through `AVFoundation`'s `requestAccessForMediaType`, which +fires the dialog at `request/2` time. No additional gotchas — make +sure the plist key is present, the dialog appears, you get a typed +`{:permission, :camera | :microphone, ...}` event. + +### Photo library + +`PHPhotoLibrary.requestAuthorizationForAccessLevel:PHAccessLevelReadWrite` +is what `:photo_library` invokes. iOS treats +`PHAuthorizationStatusLimited` (the user picked "Selected Photos…") +as `:granted` from your screen's perspective — the rest of `MobPhotos` +deals with the limited-access set transparently. + +### Notifications + +Uses `UNUserNotificationCenter requestAuthorizationWithOptions:`. Asks +for alert, sound, and badge in one shot. The current implementation +returns `:granted` if the user granted any of the three. + +## Android-specific notes + +### Foreground vs background location + +Mob only requests *foreground* location (`ACCESS_FINE_LOCATION` / +`ACCESS_COARSE_LOCATION`). If your app needs to keep tracking while +backgrounded, you need to additionally declare +`ACCESS_BACKGROUND_LOCATION` in the manifest and request it through +a custom flow — `Mob.Permissions.request/2` doesn't surface that +capability today. + +### Notifications on Android ≤ 12 + +Pre-API-33, posting a notification does not require a runtime +permission grant — the user controls it via Settings. The +`{:permission, :notifications, :granted}` event will still fire from +`request/2` so your screen code stays portable. + +### Storage and photos + +API 33+ replaced the single `READ_EXTERNAL_STORAGE` permission with +per-media-type permissions (`READ_MEDIA_IMAGES`, `READ_MEDIA_VIDEO`). +The `mob.new` template declares all of them so the photo picker works +across API levels. Saving with `Mob.Storage.Apple.save_to_photo_library/2` +uses `MediaStore`, which doesn't require a permission on API 29+ at +all — the manifest declarations are only for the read path. + +## Re-requesting after denial + +Calling `Mob.Permissions.request/2` again *after* the user denied +does **not** re-show the dialog on either platform — that's an OS +restriction. The event still arrives (with `:denied`), so your screen +can re-render an explanation. To actually re-prompt the user, they +have to go through system Settings: + +* iOS: Settings → MyApp → \<capability\> +* Android: Settings → Apps → MyApp → Permissions → \<capability\> + +A common UX is: on `:denied`, show a "Permission needed — open +Settings" CTA. `Mob.OpenUrl.open/2` with the appropriate scheme +(`"app-settings:"` on iOS, `Intent.ACTION_APPLICATION_DETAILS_SETTINGS` +on Android — surfaced via `Mob.System.open_app_settings/1` if your +project has it; otherwise call the manifest-permitted scheme directly) +will jump straight to the right settings page. + +## Diagnosing a stuck request + +Symptom: you called `Mob.Permissions.request/2` (or a capability +function), no dialog appears, no `:permission`/`:error` event ever +arrives. + +Run through this in order: + +1. **iOS plist key present?** Open `ios/Info.plist` (or the rendered + bundle inside the `.app`) and confirm the `NS*UsageDescription` + for the capability is there. The single most common cause. +2. **Android manifest entry present?** Open + `android/app/src/main/AndroidManifest.xml`. If you added the + feature post-`mob.new`, the entry may be missing. +3. **Already denied at the OS level?** iOS: Settings → + MyApp → \<cap\>. Android: Settings → Apps → MyApp → + Permissions. A previously-denied permission won't re-prompt; + `request/2` still fires the `:denied` event, so check your + `handle_info({:permission, :cap, :denied}, _)` clause exists. +4. **The screen process actually still alive?** If your screen + crashed before `handle_info/2` ran, the message is lost. Check + `adb logcat` or the iOS device console for a crash earlier in the + pipeline. +5. **You're calling `request/2` from a non-screen process.** + `enif_send` targets the calling pid; if a Task or `spawn` ran the + request, its inbox is where the event went. Always request from + the screen GenServer. + +## Cross-platform pattern + +```elixir +def mount(_params, _session, socket) do + # Cheap and idempotent on both platforms. Safe to call even if + # you're not yet ready to use the capability — the response + # informs whether the action button below should be enabled. + socket = Mob.Permissions.request(socket, :location) + {:ok, Mob.Socket.assign(socket, permission: :pending)} +end + +def handle_info({:permission, :location, :granted}, socket) do + {:noreply, Mob.Socket.assign(socket, permission: :granted)} +end + +def handle_info({:permission, :location, :denied}, socket) do + # Render a "needs permission — open Settings" CTA. + {:noreply, Mob.Socket.assign(socket, permission: :denied)} +end +``` diff --git a/guides/plugins.md b/guides/plugins.md new file mode 100644 index 00000000..80fbc7d9 --- /dev/null +++ b/guides/plugins.md @@ -0,0 +1,195 @@ +# Writing a Plugin + +A mob plugin is a Hex package (or local path dep) that extends a host app — +from a few lines of pure-Elixir helpers to an embedded sub-app with its own +screens, database tables, background workers, and settings. This guide walks the +full authoring loop: **scaffold → implement → sign → activate → deploy**. For the +exhaustive manifest schema, see [`MOB_PLUGINS.md`](MOB_PLUGINS.md); for the trust +model, [`MOB_PLUGIN_SECURITY.md`](MOB_PLUGIN_SECURITY.md). + +## The five tiers + +A plugin declares only what it needs. The tier is just *how much* it ships: + +| Tier | Ships | Native rebuild? | +|--|--|--| +| 0 | Pure-Elixir helpers (no manifest) | No — plain Hex pkg, hot-pushable | +| 1 | A NIF + Elixir wrapper | Yes | +| 2 | A native UI component (`<MyView>`) | Yes | +| 3 | Whole `Mob.Screen`s + Ecto migrations + assets (fonts/images) | Yes | +| 4 | Lifecycle hooks + supervised workers + settings + notifications | Yes | + +Tiers are cumulative in spirit but independent in the manifest — a tier-4 plugin +can also ship NIFs and screens. The *tier* reported by `mix mob.plugins` is just +the highest section present. + +## 1. Scaffold + +`mix mob.new_plugin` generates a working skeleton for any tier into +`plugins/<name>/`: + +```bash +mix mob.new_plugin my_widget --tier 3 +``` + +What you get per tier: + +- **0** — `mix.exs` + `lib/my_widget.ex` (a `hello/0` to replace). +- **1** — adds an Erlang NIF stub (`src/my_widget_nif.erl`), the C source + (`priv/native/jni/my_widget_nif.c`), and a `:nifs` manifest. +- **2** — adds a `Mob.Component` module + its Kotlin Composable + Swift View, and + a `:ui_components` manifest. +- **3** — adds two `Mob.Screen` modules (list + detail), a `:screens` + + `:migrations` manifest, and a namespaced Ecto migration. +- **4** — adds a lifecycle module, a supervised `Worker`, a `Notifications` + handler, a settings editor screen, and a `:lifecycle` + `:settings` + + `:notifications` manifest. + +The generated manifest validates and the generated modules compile as-is — they +are stubs to fill in, not pseudocode. Every tier also ships a starter test +suite (`test/<name>_test.exs`): stdlib-only structural checks of the manifest +and stubs that run with plain `mix test` — grow it alongside your plugin's +pure logic. + +## 2. Implement + +Replace the stub bodies with your plugin's real logic. A few rules the manifest +comments also remind you of: + +- **NIF `:module`** is a C/Erlang token (not an Elixir module) — `ERL_NIF_INIT` + uses it as the registered name + the static-init symbol prefix. +- **Screen routes** (`screens.default_route`) and **migration `repo_namespace`** + must be unique across *every* activated plugin (see Conflicts, below). The + scaffold defaults the namespace to `"<name>_"`, which is unique by construction. +- **Migrations** are the plugin author's raw files; mob_dev namespaces the copied + filename at build so two plugins' migrations never collide. Rename the + scaffolded migration with a real timestamp before publishing. +- **Settings** are typed and per-plugin-namespaced; read/write them with + `Mob.Plugins.get_setting/2` and `Mob.Plugins.put_setting/3` (values are + validated against the declared `:type`). +- **Pure-Elixir composite components** (UI kits, no Swift/Kotlin): a + `ui_components` entry may declare `expand: {Module, :function}` instead of + native backing — your expander turns `<MyTag …/>` into a built-in widget + tree at render time, with `on_*` event props auto-wired to the screen + process. See the [Components guide](components.md) and `Mob.Composite`; + the worked example is `mob_demo_kit`. +- **`host_requirements`**: if your plugin needs something the build can't + automate — typically an `AndroidManifest.xml` fragment like a `<service>`, + `<activity>`, or `<provider>` — declare each step as a string in the + manifest's `host_requirements` list. Every `mix mob.deploy --native` of the + host prints them, so a missing manual step can't fail silently at first + feature use. (Examples: `mob_screencast`'s mediaProjection service, + `mob_scanner`'s scanner activity, `mob_notify`'s FCM wiring.) + +Validate as you go, from the plugin directory: + +```bash +mix mob.validate_plugin +``` + +## 3. Sign + +Plugins are cryptographically signed so a host can pin trust to a public key +(see [`MOB_PLUGIN_SECURITY.md`](MOB_PLUGIN_SECURITY.md)). One-time, generate a +keypair (the private key stays on your machine, under `~/.mob/keys/`): + +```bash +mix mob.plugin.keygen --plugin plugins/my_widget +``` + +Then sign (re-run after any manifest or source change): + +```bash +mix mob.plugin.sign --plugin plugins/my_widget +``` + +This writes `priv/mob_plugin.pub` + `priv/mob_plugin.sig` and prints a +fingerprint. The fingerprint is the public key — it does **not** change when you +re-sign new content, so a host's trust record stays valid across releases. + +## 4. Activate (in the host) + +Activation is two deliberate steps in the host app — a plugin in `deps` does +nothing until it's listed in `mob.exs`: + +```elixir +# mix.exs +defp deps, do: [{:my_widget, path: "plugins/my_widget"} | _] + +# mob.exs +config :mob, :plugins, [:my_widget] +config :mob, :trusted_plugins, %{my_widget: "ed25519:<fingerprint>"} +``` + +`mix mob.plugin.trust my_widget` records the fingerprint for you. An unsigned +prototype can instead be acknowledged explicitly via +`config :mob, :acknowledge_unsafe_plugins, [:my_widget]` (a banner prints). + +Verify the host sees it: + +```bash +mix mob.plugins # lists tier, hot-push status, vetting, activation; flags conflicts +``` + +## 5. Deploy + verify + +```bash +mix mob.deploy --native # tiers 1-4 need a native rebuild +mix mob.connect # drive the running app over dist +``` + +`--native` runs the build-time plugin wiring: NIF/component compilation, asset +bundling, migration copying, and a regeneration of the runtime plugin manifest +(`priv/generated/mob_plugins.exs`) so the device's tier-3/4 wiring always matches +what the plugins declare. + +## Multiple plugins and conflicts + +A host can activate any combination of plugins, so mob_dev checks at build time +that they compose: two plugins may not claim the same screen route, NIF module, +native view key, migration namespace, supervised worker name, plist key, or +notification match. A clash is a loud build error, not a silent last-write-wins. +See [`MOB_PLUGINS.md` → Cross-plugin conflict detection](MOB_PLUGINS.md) for the +full list and the completeness guarantee. Keep your routes/namespaces/worker +names specific to your plugin (the scaffold's `"<name>_"` defaults do this). + +## Style packages (a sibling lane) + +A package that ships a *look* rather than a capability uses the styles lane: +a four-field `priv/mob_style.exs` (`name`, `mob_version`, +`style_spec_version`, `theme:` — a module exporting `theme/0`) instead of a +plugin manifest, activated via `config :mob, :styles` + +`config :mob, :default_style`. Core applies the default style's theme at +boot. See [`MOB_STYLES.md`](MOB_STYLES.md) for the schema and current +implementation status; `mob_themes` is the worked example. A single package +may ship both manifests. + +## Worked examples + +The `mob_plugin_demo` project carries a device-verified plugin per tier — read +them as canonical patterns: + +| Plugin | Tier | Demonstrates | +|--|--|--| +| `mob_palette_demo` | 0 | Pure-Elixir, activated via `mob.exs` only | +| `mob_demo_haptic_extras` | 1 | C NIF + iOS framework (`CoreHaptics`) | +| `mob_demo_zig_extras` | 1 | Zig NIF + Android Kotlin bridge | +| `mob_demo_perm` | 1 | Extending the permission registry | +| `mob_demo_signature_pad` | 2 | Native SwiftUI / Compose component | +| `mob_demo_kv_browser` | 3 | Two screens + a migration + a bundled font + a `plugin://` image | +| `mob_demo_gen_screens` | 3+4 | Spec-v2 `screens_generator` *and* tier-4 lifecycle/settings/notifications in one plugin | +| `mob_demo_subapp` | 4 | Lifecycle hooks, supervised worker, settings, notification handler | +| `mob_demo_kit` | 2 (expand) | Pure-Elixir composite components (`<DemoCard>`, `<DemoCombobox>`) — no native code | + +Beyond the demo, the shipped first-party packages are full-size references: +`mob_camera` (the heaviest extraction — ObjC + Zig + Kotlin + permission +registry), `mob_scanner` (depends on `mob_camera`; an Activity +host-requirement), `mob_notify` (delivery-stays-in-core seam + +`host_requirements`), `mob_ash` (spec-v2 `screens_generator` against host +config), and `mob_themes` (a style package). See the +[First-Party Packages catalog](packages.md). + +`mob_demo_gen_screens` is the clearest example of a single plugin spanning +multiple tiers, and (with `mob_demo_kv_browser` + `mob_demo_subapp`) of multiple +plugins stacking the same tier — two namespaced migrations, two supervised +workers, two settings owners, and two notification handlers all active at once. diff --git a/guides/push_notifications.md b/guides/push_notifications.md index c1a6c2d4..e50b4a8f 100644 --- a/guides/push_notifications.md +++ b/guides/push_notifications.md @@ -12,6 +12,22 @@ Mob supports both **local notifications** (scheduled on-device) and **remote pus | Works when killed | Yes (OS delivers on schedule) | Yes (OS wakes on arrival) | | Requires permission | Yes (`:notifications`) | Yes (`:notifications`) | +The device-side API (`MobNotify`) ships in the `mob_notify` plugin — add the +dep and activate it in `mob.exs` (see the [Plugins guide](plugins.md)): + +```elixir +# mix.exs +{:mob_notify, "~> 0.1"} + +# mob.exs +config :mob, :plugins, [:mob_notify] +``` + +Delivery is unchanged core behavior: `{:notification, notif}` and +`{:push_token, platform, token}` arrive in your screen's `handle_info/2`. +The server side is the separate [`mob_push`](https://hexdocs.pm/mob_push) +package, also unchanged. + --- ## Local notifications @@ -39,7 +55,7 @@ end ```elixir # At a specific time -Mob.Notify.schedule(socket, +MobNotify.schedule(socket, id: "reminder_1", title: "Time to check in", body: "Open the app to see today's updates", @@ -48,7 +64,7 @@ Mob.Notify.schedule(socket, ) # After a delay -Mob.Notify.schedule(socket, +MobNotify.schedule(socket, id: "cooldown", title: "Cooldown complete", body: "Ready to go again", @@ -59,7 +75,7 @@ Mob.Notify.schedule(socket, ### Cancelling ```elixir -Mob.Notify.cancel(socket, "reminder_1") +MobNotify.cancel(socket, "reminder_1") ``` ### Receiving @@ -125,6 +141,13 @@ walkthrough (Apple Developer portal + Firebase console). ### App-side setup +Push registration needs a few host-app pieces the build can't fully automate: +the FCM `<service>` entry in `AndroidManifest.xml` plus a `google-services.json` +on Android, and the APNs token-forwarding hook in the `AppDelegate` on iOS. +The `mob_notify` plugin declares these as `host_requirements`, so every +`mix mob.deploy --native` prints exactly what's missing — follow the printed +snippets if registration silently yields no token. + #### 1. Request permission and register ```elixir @@ -140,7 +163,7 @@ defmodule MyApp.HomeScreen do @impl Mob.Screen def handle_info({:permission, :notifications, :granted}, socket) do # Register with APNs / FCM — token arrives asynchronously - {:noreply, Mob.Notify.register_push(socket)} + {:noreply, MobNotify.register_push(socket)} end def handle_info({:permission, :notifications, :denied}, socket) do @@ -164,7 +187,7 @@ devices). Store the platform alongside the token — you need it when calling `MobPush.send/3`. Tokens can change: the OS may issue a new token after an app reinstall or backup -restore. Re-registering on each launch with `Mob.Notify.register_push/1` keeps +restore. Re-registering on each launch with `MobNotify.register_push/1` keeps your stored token current. #### 3. Handle received notifications @@ -284,5 +307,5 @@ environment returns `{:error, {:apns_error, "BadDeviceToken"}}`. ## Further reading - [`mob_push` on HexDocs](https://hexdocs.pm/mob_push) — full server-side documentation: credential setup, all payload options, notification appearance, token lifecycle -- [`Mob.Notify`](Mob.Notify.html) — schedule/cancel local notifications, register for push +- [`MobNotify`](https://hexdocs.pm/mob_notify) — schedule/cancel local notifications, register for push (ships in the `mob_notify` plugin) - [`Mob.Permissions`](Mob.Permissions.html) — request OS permission diff --git a/guides/support_matrix.md b/guides/support_matrix.md index e9e39b5f..e1ccee57 100644 --- a/guides/support_matrix.md +++ b/guides/support_matrix.md @@ -14,7 +14,7 @@ producing an APK that crashes at install or runtime. | Platform | ABIs | Minimum OS | Source of constraint | |---|---|---|---| -| Android | `arm64-v8a`, `x86_64` (emulator) | API 28 / Android 9 | Mob ships pre-built BEAM/erts for these slices only. The OTP runtime tarballs hosted in `mob_dev`'s GitHub releases don't include an `armeabi-v7a` build, so 32-bit Android phones cannot run Mob — vanilla apps too, not just Pythonx-enabled ones. | +| Android | `arm64-v8a`, `x86_64` (emulator), `armeabi-v7a` | API 28 / Android 9 | Mob ships pre-built BEAM/erts tarballs for each supported slice. Android emulators on x86_64 hosts require the `otp-android-x86_64-*` release asset; `armeabi-v7a` works for vanilla Mob apps but remains unsupported for Pythonx-enabled apps. | | iOS | `arm64` (device + sim on Apple Silicon), `x86_64` (sim on Intel Macs) | iOS 13 | Mob's iOS template `IPHONEOS_DEPLOYMENT_TARGET` is set to 13.0 and the bundled OTP is arm64-only. Older iOS versions (and 32-bit hardware — i.e. iPhone 5/5c) cannot run Mob. | This floor is enforced at deploy time by `MobDev.SupportMatrix.check_device/2`, @@ -35,7 +35,7 @@ shows up in `MobDev.SupportMatrix.feature_requirements/1`. | iOS | `arm64` (device + sim) | iOS 13 | [BeeWare's `Python-Apple-support`](https://github.com/beeware/Python-Apple-support) framework targets iOS 13+. Older iPads / iPhones can't load it. | Bundle size adds ~70 MB on iOS, ~30 MB on Android. See -[`mob_dev/guides/python_embedding.md`](../../mob_dev/guides/python_embedding.md) +[`mob_dev/guides/python_embedding.md`](https://github.com/GenericJam/mob_dev/blob/master/guides/python_embedding.md) for the full pipeline. --- @@ -44,28 +44,20 @@ for the full pipeline. The instinct is "people with low-income or older hardware deserve to be considered, even if Google or Apple have abandoned them." That's -real, and the team agrees with it. - -But the cost of supporting `armeabi-v7a` Android (the most common -"old / cheap" floor) is not a polish job. It's: - -- Building OTP/erts for `armeabi-v7a` and hosting the tarballs in - `mob_dev`'s GitHub release infrastructure -- Cross-compiling every Mob NIF for `armeabi-v7a` -- Replacing the Chaquopy bundle with a self-built CPython 3.13 + - stdlib + C extensions (Chaquopy can't help — they dropped 32-bit - upstream) -- Maintaining two parallel build matrices indefinitely - -For a device class Google's own Play Store has tagged "deprecated for -new apps." Multi-week effort, structural maintenance burden, -diminishing returns each year as the target shrinks. - -The trade-off we made instead: declare the floor explicitly, validate -it at the earliest possible moment, and tell the user *which* of their -devices won't work and *why* — including which upstream vendor's -decision is the cause. They get the full picture before they invest -time, instead of a cryptic gradle error after a 5-minute build. +real, and the team agrees with it. For vanilla Mob apps, that means the +base runtime includes an `armeabi-v7a` slice alongside modern arm64 and +x86_64 emulator support. + +Feature-specific native dependencies can still tighten the floor. +Pythonx is the current example: Chaquopy no longer ships a 32-bit +Android distribution, so Pythonx-enabled Mob apps require `arm64-v8a` +or `x86_64` even though vanilla Mob can run on `armeabi-v7a`. + +The trade-off we make: declare the floor explicitly, validate it at the +earliest possible moment, and tell the user *which* of their devices +won't work and *why* — including which upstream vendor's decision is +the cause. They get the full picture before they invest time, instead +of a cryptic gradle error after a 5-minute build. If you have hardware that falls below the floor and want to discuss whether a path exists, open an issue. We'd rather hear the use case diff --git a/guides/theming.md b/guides/theming.md index d30d3f22..7d48c9b6 100644 --- a/guides/theming.md +++ b/guides/theming.md @@ -80,11 +80,19 @@ Pass token atoms as prop values for color, spacing, radius, and text size props. ## Named themes -Mob ships three built-in themes: +Core ships three themes: -- **`Mob.Theme.Obsidian`** — dark, neutral with blue accents (default dark theme) -- **`Mob.Theme.Citrus`** — warm background with lime-green primary -- **`Mob.Theme.Birch`** — warm neutral tones, brown accents +- **`Mob.Theme.Light`** / **`Mob.Theme.Dark`** — neutral light and dark baselines +- **`Mob.Theme.Adaptive`** — follows the system light/dark setting + +The preset themes moved to the [`mob_themes`](https://hex.pm/packages/mob_themes) +style package in 0.7.0 (see [Style packages](#style-packages) below for activation): + +- **`MobThemes.Obsidian`** — dark, neutral with blue accents +- **`MobThemes.ObsidianGlass`** — Obsidian variant with translucent surfaces +- **`MobThemes.Citrus`** — warm background with lime-green primary +- **`MobThemes.Birch`** — warm neutral tones, brown accents +- **`MobThemes.Material3`** — Material 3 baseline palette There are two ways to set a theme: @@ -92,7 +100,7 @@ There are two ways to set a theme: ```elixir defmodule MyApp do - use Mob.App, theme: Mob.Theme.Obsidian + use Mob.App, theme: Mob.Theme.Dark ... end ``` @@ -101,12 +109,12 @@ end ```elixir def mount(_params, _session, socket) do - Mob.Theme.set(Mob.Theme.Obsidian) + Mob.Theme.set(MobThemes.Obsidian) {:ok, Mob.Socket.assign(socket, :theme, :obsidian)} end def handle_info({:tap, :theme_citrus}, socket) do - Mob.Theme.set(Mob.Theme.Citrus) + Mob.Theme.set(MobThemes.Citrus) {:noreply, Mob.Socket.assign(socket, :theme, :citrus)} end ``` @@ -118,7 +126,7 @@ end Pass a `{module, overrides}` tuple to customise a named theme: ```elixir -use Mob.App, theme: {Mob.Theme.Obsidian, primary: :rose_500, radius_md: 14} +use Mob.App, theme: {Mob.Theme.Dark, primary: :rose_500, radius_md: 14} ``` ## Building a theme from scratch @@ -137,10 +145,10 @@ Call `Mob.Theme.set/1` at any point. The next render will use the new theme: ```elixir # Switch to a named theme -Mob.Theme.set(Mob.Theme.Citrus) +Mob.Theme.set(MobThemes.Citrus) -# Override individual tokens on the current theme -Mob.Theme.set({Mob.Theme.Obsidian, primary: :violet_500}) +# Override individual tokens on a named theme +Mob.Theme.set({Mob.Theme.Dark, primary: :violet_500}) # Override against the neutral base Mob.Theme.set(primary: :pink_500, type_scale: 1.2) @@ -151,6 +159,34 @@ Mob.Theme.set(%Mob.Theme{primary: :teal_500, space_scale: 1.1}) This is useful for accessibility features (larger type, high-contrast), user-selected themes, or A/B testing. +## Style packages + +Theme presets are distributed as **style packages** — a separate lane from +capability plugins. The currently-shipped tier is tokens-only: a style +package contributes theme modules (token sets), nothing native. Activation +in `mob.exs` uses `:styles` rather than `:plugins`: + +```elixir +# mix.exs +{:mob_themes, "~> 0.1"} + +# mob.exs +config :mob, :styles, [:mob_themes] +config :mob, :default_style, :mob_themes # boots into MobThemes.Obsidian +``` + +At boot, core applies the default style's theme (`:mob_themes` defaults to +`MobThemes.Obsidian`). The package's other themes are ordinary theme +modules — switch with `Mob.Theme.set(MobThemes.Citrus)` as usual. + +`:default_style` is a *default*, not a mandate: an explicit `Mob.Theme.set/1` +from app code (e.g. restoring a persisted user choice in `on_start/0` or +`mount/3`) outranks it. + +See [`MOB_STYLES.md`](MOB_STYLES.md) +for the style-package manifest schema and the design of the richer +(native-override) style tiers. + ## Publishing a custom theme A theme is any module that exports `theme/0 :: Mob.Theme.t()`: @@ -178,10 +214,41 @@ use Mob.App, theme: AcmeCorp.BrandTheme Token atoms that are not semantic theme tokens resolve through the built-in palette. The palette covers grays, blues, greens, reds, oranges, purples, teals, pinks, and more — all as `name_weight` atoms (e.g. `:blue_500`, `:gray_200`, `:emerald_400`). -You can also pass raw ARGB hex integers directly as prop values: +### Raw colors are `0xAARRGGBB` integers, not CSS hex strings + +Any color prop also accepts a raw color **as a 32-bit integer literal** in +`0xAARRGGBB` order — **alpha first**, then red, green, blue: ```elixir %{type: :text, props: %{text: "Hi", text_color: 0xFFFF5733}, children: []} +# ^^ alpha = FF (fully opaque) +# FF5733 red/green/blue +``` + +This is **not** web/CSS color syntax. Two differences trip people (and coding +assistants) up: + +- **It's an integer literal (`0xFFFF5733`), not a string.** A CSS-style + `"#FF5733"` string is **not** a valid color prop — pass the `0x…` integer. +- **Alpha comes first (`0xAARRGGBB`), not last.** CSS's 8-digit form is + `#RRGGBBAA` (alpha last); Mob is `0xAARRGGBB` (alpha first), matching the + Android/`Color`-int and iOS ARGB convention the native layer uses. Putting the + opacity byte in the wrong place gives a wrong color, not just wrong + transparency. + +**Always include the alpha byte.** `0xFF2196F3` is opaque blue; `0x002196F3` +is fully transparent (alpha `00`). A 6-digit `0x2196F3` is read as +`0x002196F3` — invisible — because the missing top byte defaults to `00`. +The built-in palette entries are all `0xFF…` for this reason, and +`:transparent` is `0x00000000`. + +The alpha byte is what makes translucency composable. For example, a frosted +overlay panel is just a box with a semi-transparent background stacked over +content — no special "glass" primitive required: + +```elixir +# ~40% black scrim / frosted panel over whatever is behind it +%{type: :box, props: %{background: 0x66000000}, children: [...]} ``` Use raw integers sparingly. Semantic tokens give you free dark-mode and theme switching. diff --git a/guides/troubleshooting.md b/guides/troubleshooting.md index f885b67b..3dd2a0af 100644 --- a/guides/troubleshooting.md +++ b/guides/troubleshooting.md @@ -185,6 +185,54 @@ your Mac. --- +## iOS simulator: BEAM dies silently when an Android device is also connected + +**Symptom:** Single-device iOS-sim deploy succeeds (`Apps restarted.`), but the +sim never reaches the app — stays on the home screen or shows the launcher +spinner for a few hundred ms before the app process exits. `mix mob.connect` +may briefly see the node and then lose it. `Documents/beam_stdout.log` inside +the sim's app container shows: + +``` +Protocol 'inet_tcp': register/listen error: eaddrinuse +``` + +**Cause:** `adb forward tcp:9100 tcp:9100` (set up automatically when an +Android device is attached) binds host `127.0.0.1:9100` so the corresponding +device port is tunneled. iOS simulators share the Mac's network stack, so +when the sim's BEAM tries to bind `127.0.0.1:9100` for `inet_dist_listen_min`, +it collides with adb. The OTP runtime catches the bind failure, prints the +error to its redirected stdout, and the boot script exits — taking the BEAM +(and the whole app process) with it. + +Mob's per-device dist-port allocator (`MobDev.Tunnel.dist_port/1`) returns +`9100 + index`. With a single iOS sim targeted (`mix mob.deploy --device +<udid>`), index is 0, port is 9100 — which adb already owns. Multi-device +deploys with iOS sims later in the list (e.g. index 5, port 9105) avoid the +collision by accident; single-iOS-sim deploys hit it head-on. + +**Fix:** Pass an explicit `--dist-port` outside the adb forward range: + +```bash +mix mob.deploy --device <ios-sim-udid> --dist-port 9200 +``` + +A clean way to be sure the port is free is `lsof -nP -iTCP:9100-9199 -sTCP:LISTEN`. +adb's forwards live on `127.0.0.1` for the duration of the connected devices. + +**Why this is recurring:** Android tooling has bitten the iOS sim path several +times. The shared Mac network stack means anything adb listens on (forward +tunnels, the adb server itself on 5037, the bridge daemon) competes with +simulators for the same `127.0.0.1` namespace. When investigating a "the iOS +sim won't start" problem and an Android device is connected, suspect a +host-port collision before assuming a sim or BEAM bug. + +A follow-up fix to `MobDev.Tunnel` to base iOS-sim dist ports above the adb +forward range (e.g. 9200+) is tracked separately. Until then, the manual +`--dist-port` flag is the workaround. + +--- + ## Distribution in production In development, `Mob.Dist.ensure_started/1` runs so `mix mob.connect` can @@ -264,6 +312,37 @@ rather than hot-push. --- +## Path-dependency mob: on-device `mob_nif:log` undef / stale beams + +**Symptom:** You depend on mob as a local **path dependency** +(`{:mob, path: "../mob", override: true}`) to test an unreleased framework +change on a device. The app crash-loops at boot — logcat shows the bootstrap's +first `mob_nif:log/1` (or `mob_nif:platform/0`) call returning **`undef`**, even +though the on-device `mob_nif.beam` is present and exports the function and the +native `.so` loaded without a `load_nif` error. + +**Cause:** The path-dep's beams in `_build` were stale or only partially +recompiled, so `mix mob.deploy` pushed an `mob` that disagreed with the boot +script — `mob_nif` wasn't loaded when boot first called it. A coherently-built +mob (the published Hex package, or a path-dep recompiled as its own step) boots +fine from the identical app, which is how you tell this apart from a real +framework regression. + +**Fix:** Recompile the path-dep explicitly *before* deploying, then deploy: + +```bash +mix deps.compile mob --force +mix mob.deploy --native --device <serial> +``` + +Also compile with the toolchain whose Elixir matches the on-device runtime +(`mob.exs`'s `elixir_lib`) — building candidate `.exs` with a different Elixir +(e.g. an `-rc` vs the final OTP build) emits `:elixir_quote` calls the device +stdlib lacks, a *separate* on-device `undef`. For the committed project, prefer +the Hex package and use the path-dep only as a transient verification vehicle. + +--- + ## Android: app crashes on first distribution startup **Symptom:** App starts successfully, then crashes 3–5 seconds later. Logcat @@ -328,3 +407,81 @@ lsof -i :9101 If something else is using it, configure a different dist port in `Mob.Dist.ensure_started/1` and update `mob.exs` accordingly. + +--- + +## iOS: `Req` / `Finch` / `Mint` request fails with nxdomain on device + +**Symptom:** HTTPS calls that work everywhere else (host, simulator, Android) +fail on a physical iOS device. Errors look like `nxdomain`, `:einval`, or a +generic "lookup failed." + +**Cause:** BEAM's `inet_gethost` helper is spawned via `execve`, which iOS's +app sandbox forbids. Every hostname lookup through `:inet` fails immediately. +Android works because its OTP helpers ship as `lib*.so` in `jniLibs/`, which +SELinux allows to exec; iOS has no equivalent escape hatch. + +`Mob.App.start/0` already switches the lookup chain to `[:file]` on iOS so +distribution and local-loopback TCP work without setup. That doesn't help +public-internet hostnames though — you still need to opt into one of the +DNS strategies below to talk to Req / Finch / Mint endpoints. + +**Fix:** Call `Mob.DNS.resolve/1` once per backend before your first request, +typically in your app's `on_start/0`: + +```elixir +Mob.DNS.preresolve([ + "api.example.com", + "auth.example.com" +]) +``` + +After that, Req / Finch / Mint / `:httpc` / `gen_tcp` all work normally. + +See the [DNS on iOS guide](dns_on_ios.md) for the full story, including why +manual resolution rather than automatic interception, what to do if the IP +changes mid-session, and which libraries (NIFs that do their own +`getaddrinfo`) don't need this fix. + +--- + +## `Mob.Canvas` draw ops appear shifted, cropped, or in the wrong place + +**Symptom:** Lines, rectangles, or other Canvas draw operations land at +the wrong screen coordinates. Bounding boxes drawn over a +`<CameraPreview>` are noticeably offset (typically down-and-right on +high-density Android devices, or off by some scale factor) and may +extend outside the visible canvas area. + +**Cause:** The host app's `MobBridge` Canvas renderer is interpreting +coordinates as raw pixels (or as dp with no viewport scaling) instead +of treating the Canvas's declared `width` / `height` props as a +logical viewport. The intended contract is documented in +`Mob.Canvas`'s `@moduledoc`: a draw op at `(width / 2, height / 2)` +lands in the dead centre of the rendered canvas regardless of actual +pixel size or device density. Older / scaffolded `MobBridge.kt`s +predate this contract and shipped a 1 coord = 1 pixel renderer. + +**Fix:** Apply the viewport-scaling recipe documented in +`Mob.Canvas`'s `@moduledoc` ("Implementing the renderer" section) to +your app's `MobBridge.kt` `MobCanvas` composable. Short version: +inside `Canvas { ... }`, compute + +```kotlin +val sx = if (width > 0f) size.width / width else 1f +val sy = if (height > 0f) size.height / height else 1f +``` + +and multiply every x-coord / width by `sx` and every y-coord / height +by `sy` inside `drawCanvasOp`. Scalar sizes (stroke widths, circle +radii, text sizes) use the average `(sx + sy) / 2` so they don't +squash when the viewport is non-square. + +The same fix applies to `MobBridge.swift` on iOS — Compose and SwiftUI +both deliver pixel-space draw scopes that need translating. + +**Why this isn't fixed once-and-for-all in Mob itself:** Mob ships +zero host-app Kotlin / Swift today; every app's `MobBridge` is its +own diverged copy. A future Mob improvement is to ship the renderer +as a generated module or an AAR / Swift package so this kind of +contract drift can't happen. Tracked in PLAN.md. diff --git a/guides/why_beam.md b/guides/why_beam.md index c427f122..cab73548 100644 --- a/guides/why_beam.md +++ b/guides/why_beam.md @@ -175,7 +175,7 @@ and reports drain and rate. The 30-minute duration is the default; longer runs g better rate estimates. Battery is read via `ideviceinfo` at start and end (USB connected briefly for reads only) for 1% precision. The screen-on row was measured at minimum brightness with the screen forced on; the screen-off row uses -`Mob.Background` audio keep-alive so the BEAM keeps running after the device +the `mob_background` plugin's audio keep-alive so the BEAM keeps running after the device locks. Android uses `mix mob.battery_bench_android` with `adb shell dumpsys battery` for per-second mAh readings. diff --git a/ios/MobDemo-Bridging-Header.h b/ios/MobDemo-Bridging-Header.h index ec6cebdc..08f00f58 100644 --- a/ios/MobDemo-Bridging-Header.h +++ b/ios/MobDemo-Bridging-Header.h @@ -9,15 +9,21 @@ void mob_handle_back(void); // Called from MobRootView.swift WebView delegate when JS sends a message or a URL is blocked. // Implemented in mob_nif.m; looks up :mob_screen and sends the appropriate tuple. -void mob_deliver_webview_message(const char* json_utf8); -void mob_deliver_webview_blocked(const char* url_utf8); +void mob_deliver_webview_message(const char *json_utf8); +void mob_deliver_webview_blocked(const char *url_utf8); // Called from MobNativeViewRegistry.send closure when a native view fires an event. // Implemented in mob_nif.m; looks up the component pid by handle and delivers // {:component_event, event, payload_json} to it. -void mob_send_component_event(int handle, const char* event, const char* payload_json); +void mob_send_component_event(int handle, const char *event, const char *payload_json); // Called from MobRootView.swift's .onChange(of: colorScheme) modifier when // the OS appearance toggles (light/dark). Dispatches to Mob.Device subscribers. // `scheme` is "light" or "dark". -void mob_notify_color_scheme(const char* scheme); +void mob_notify_color_scheme(const char *scheme); + +// Called from MobFrameTracker (SwiftUI) as a tagged element lays out, recording +// its on-screen frame (logical points) keyed by the element's :id. Read back via +// the element_frames NIF so an agent can locate/drive elements without a +// screenshot. Implemented in mob_nif.m. +void mob_register_frame(const char *id, double x, double y, double w, double h); diff --git a/ios/MobGpuView.swift b/ios/MobGpuView.swift new file mode 100644 index 00000000..0b8194f9 --- /dev/null +++ b/ios/MobGpuView.swift @@ -0,0 +1,321 @@ +// MobGpuView.swift — Metal-backed fragment-shader surface. +// +// Hosts an `MTKView` inside a SwiftUI `UIViewRepresentable`. Compiles the +// MSL fragment shader supplied by the BEAM into a render pipeline, binds +// per-frame uniforms into fragment buffer slot 0, and renders a +// full-screen quad at the display's refresh rate. +// +// Scope (v1): +// - Fragment-shader-only. Built-in vertex shader emits a full-screen +// NDC quad with a (0..1, 0..1) `uv` in `VertexOut.uv`. +// - Uniforms map keys become member names in the `Uniforms` struct. +// Supported types: float (NSNumber), float2/3/4 (NSArray of 2/3/4 +// numbers), uint (NSNumber promoted to integer). The uniform struct +// layout is a flat sequence of 16-byte-aligned slots — matches MSL's +// default alignment for vec types. +// - Shader compile errors surface as a translucent red overlay with the +// error message, on top of the (black) Metal view. +// +// Not yet: +// - Textures (camera frame, ML output) as samplers +// - Vertex shader override / custom mesh +// - GLSL → MSL transpilation (escape hatch via the BEAM-side +// %{ios: "..."} map form is the workaround; transpile is a future task) + +import Foundation +import Metal +import MetalKit +import SwiftUI + +// MARK: - SwiftUI wrapper + +struct MobGpuView: UIViewRepresentable { + let node: MobNode + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeUIView(context: Context) -> MobGpuMTKView { + let view = MobGpuMTKView(frame: .zero, device: MTLCreateSystemDefaultDevice()) + view.backgroundColor = .black + view.colorPixelFormat = .bgra8Unorm + view.framebufferOnly = false + view.preferredFramesPerSecond = 60 + view.isPaused = false + view.enableSetNeedsDisplay = false // continuous mode + view.delegate = view // self-delegate; renderer logic lives in MobGpuMTKView + return view + } + + func updateUIView(_ view: MobGpuMTKView, context: Context) { + if let shader = node.gpuShaderMSL { + view.setShader(shader) + } else { + view.setShader(nil) + } + view.setUniforms(node.gpuUniforms ?? []) + } + + final class Coordinator {} +} + +// MARK: - MTKView subclass + renderer + +/// A self-delegating MTKView that compiles MSL fragment shaders on demand +/// and renders a full-screen quad with caller-supplied uniforms. +final class MobGpuMTKView: MTKView, MTKViewDelegate { + // Compiled shader pipeline (nil until first valid shader arrives). + private var pipelineState: MTLRenderPipelineState? + private var commandQueue: MTLCommandQueue? + private var compileError: String? + private var currentShaderHash: Int = 0 + private var uniformBuffer: MTLBuffer? + private var uniformBytes = Data() + + // SwiftUI host for the error overlay. Rendered as a UILabel pinned to + // the top-left so the user sees compile errors inline. + private weak var errorLabel: UILabel? + + override init(frame frameRect: CGRect, device: MTLDevice?) { + super.init(frame: frameRect, device: device) + self.commandQueue = device?.makeCommandQueue() + } + + required init(coder: NSCoder) { + super.init(coder: coder) + self.commandQueue = self.device?.makeCommandQueue() + } + + // MARK: shader handoff from SwiftUI + + func setShader(_ source: String?) { + guard let source = source, !source.isEmpty else { + if pipelineState != nil { pipelineState = nil; showError(nil) } + return + } + let hash = source.hashValue + if hash == currentShaderHash, pipelineState != nil { return } + currentShaderHash = hash + compileShader(source) + } + + func setUniforms(_ uniforms: Any) { + // Uniforms arrive as a top-level NSArray (BEAM-side list) — packed + // in declaration order so the order survives JSON round-trip and + // map-iteration surprises. Each element is either: + // - NSNumber (float or int → 4-byte slot at natural alignment) + // - NSArray of 2 numbers (float2 → 8-byte slot at 8-byte align) + // - NSArray of 4 numbers (float4 → 16-byte slot at 16-byte align) + // + // The shader then declares its `Uniforms` struct with members in + // the SAME order: + // + // struct Uniforms { + // float2 center; // matches uniforms[0] + // float zoom; // matches uniforms[1] + // uint max_iter; // matches uniforms[2] + // }; + // + // (Map form was tempting but Elixir map iteration order is + // not stable beyond ~32 entries and differs across runtimes — + // discovered this empirically when the demo rendered black on + // device because :zoom came first on iOS BEAM.) + var data = Data() + if let list = uniforms as? [Any] { + for value in list { + appendUniformValue(value, to: &data) + } + } else if let dict = uniforms as? [AnyHashable: Any] { + // Fallback for backward compat — iteration order undefined. + // The shader-side struct MUST match whatever the runtime decides. + // Not recommended; use the list form above. + for (_, value) in dict { + appendUniformValue(value, to: &data) + } + } + uniformBytes = data + if data.count > 0 { + uniformBuffer = device?.makeBuffer(bytes: (data as NSData).bytes, length: data.count, options: []) + } else { + uniformBuffer = nil + } + } + + private func appendUniformValue(_ value: Any, to data: inout Data) { + if let n = value as? NSNumber { + let typeStr = String(cString: n.objCType) + if typeStr == "q" || typeStr == "l" || typeStr == "i" { + alignTo(4, in: &data) + var v: UInt32 = UInt32(truncatingIfNeeded: n.int64Value) + data.append(Data(bytes: &v, count: 4)) + } else { + alignTo(4, in: &data) + var v: Float = n.floatValue + data.append(Data(bytes: &v, count: 4)) + } + return + } + if let arr = value as? [Any] { + switch arr.count { + case 2: + alignTo(8, in: &data) + for i in 0..<2 { + if let n = arr[i] as? NSNumber { + var v: Float = n.floatValue + data.append(Data(bytes: &v, count: 4)) + } + } + case 4: + alignTo(16, in: &data) + for i in 0..<4 { + if let n = arr[i] as? NSNumber { + var v: Float = n.floatValue + data.append(Data(bytes: &v, count: 4)) + } + } + default: + // Unsupported arity (3 reserved for future float3, + // others unhandled). Skip silently — shader-side will + // read garbage, which is at least localizable in a debug. + break + } + } + } + + private func alignTo(_ alignment: Int, in data: inout Data) { + let mod = data.count % alignment + if mod != 0 { data.append(Data(count: alignment - mod)) } + } + + // MARK: compile + + private func compileShader(_ source: String) { + guard let device = device else { return } + + let full = """ + \(vertexSource) + \(source) + """ + + do { + let library = try device.makeLibrary(source: full, options: nil) + guard let vertexFn = library.makeFunction(name: "vertex_main") else { + showError("internal: vertex_main not found in built-in vertex source") + return + } + // Convention: fragment entry point is called `fragment_main`. If + // the supplied shader exports a function with a different name, + // make_function returns nil and we surface that to the user. + guard let fragmentFn = library.makeFunction(name: "fragment_main") else { + showError( + "fragment_main not found — your shader must define " + + "`fragment half4 fragment_main(VertexOut in [[stage_in]], " + + "constant Uniforms& u [[buffer(0)]])`" + ) + return + } + let desc = MTLRenderPipelineDescriptor() + desc.vertexFunction = vertexFn + desc.fragmentFunction = fragmentFn + desc.colorAttachments[0].pixelFormat = colorPixelFormat + pipelineState = try device.makeRenderPipelineState(descriptor: desc) + showError(nil) + } catch { + pipelineState = nil + showError(String(describing: error)) + } + } + + private var vertexSource: String { + // Full-screen quad in clip space + a passthrough uv in (0..1, 0..1). + // The fragment shader writes `Uniforms` member layout itself; we + // don't generate the struct here. + return """ + #include <metal_stdlib> + using namespace metal; + + struct VertexOut { + float4 position [[position]]; + float2 uv; + }; + + vertex VertexOut vertex_main(uint vid [[vertex_id]]) { + // Quad as a triangle strip: BL, BR, TL, TR + float2 pos[4] = { + float2(-1.0, -1.0), + float2( 1.0, -1.0), + float2(-1.0, 1.0), + float2( 1.0, 1.0) + }; + float2 uv[4] = { + float2(0.0, 1.0), + float2(1.0, 1.0), + float2(0.0, 0.0), + float2(1.0, 0.0) + }; + VertexOut out; + out.position = float4(pos[vid], 0.0, 1.0); + out.uv = uv[vid]; + return out; + } + """ + } + + // MARK: error overlay + + private func showError(_ message: String?) { + compileError = message + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if let message = message { + if self.errorLabel == nil { + let label = UILabel(frame: self.bounds) + label.numberOfLines = 0 + label.font = UIFont.monospacedSystemFont(ofSize: 11, weight: .regular) + label.textColor = .white + label.backgroundColor = UIColor.red.withAlphaComponent(0.7) + label.lineBreakMode = .byWordWrapping + label.textAlignment = .left + label.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: self.topAnchor), + label.leadingAnchor.constraint(equalTo: self.leadingAnchor), + label.trailingAnchor.constraint(equalTo: self.trailingAnchor) + ]) + self.errorLabel = label + } + self.errorLabel?.text = "shader error:\n\(message)" + self.errorLabel?.isHidden = false + } else { + self.errorLabel?.isHidden = true + } + } + } + + // MARK: MTKViewDelegate + + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} + + func draw(in view: MTKView) { + guard let pipeline = pipelineState, + let cmdBuf = commandQueue?.makeCommandBuffer(), + let renderPass = currentRenderPassDescriptor, + let drawable = currentDrawable, + let encoder = cmdBuf.makeRenderCommandEncoder(descriptor: renderPass) + else { + // Either no shader compiled yet or pipeline failed — let the + // overlay (if any) speak for itself; nothing to draw. + currentDrawable?.present() + return + } + + encoder.setRenderPipelineState(pipeline) + if let buf = uniformBuffer { + encoder.setFragmentBuffer(buf, offset: 0, index: 0) + } + encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) + encoder.endEncoding() + cmdBuf.present(drawable) + cmdBuf.commit() + } +} diff --git a/ios/MobNode.h b/ios/MobNode.h index 86724a78..a397e3e8 100644 --- a/ios/MobNode.h +++ b/ios/MobNode.h @@ -4,16 +4,19 @@ #pragma once -#import <UIKit/UIKit.h> -#import <Foundation/Foundation.h> #import <AVFoundation/AVFoundation.h> +#import <Foundation/Foundation.h> +#import <UIKit/UIKit.h> #import <WebKit/WebKit.h> -// Shared camera preview session — set by nif_camera_start/stop_preview, read by MobRootView. -extern AVCaptureSession* _Nullable g_preview_session; +// Shared camera preview session. Now OWNED by the mob_camera plugin (its NIF +// defines g_preview_session and drives start/stop_preview); core's MobRootView +// camera-preview view only reads it. Weak so core still links when mob_camera +// isn't activated — the symbol resolves to NULL and the preview shows black. +extern AVCaptureSession *_Nullable g_preview_session __attribute__((weak)); // Shared WebView — set by MobWebView when created, read by webview NIFs. -extern WKWebView* _Nullable g_webview; +extern WKWebView *_Nullable g_webview; typedef NS_ENUM(NSInteger, MobNodeType) { MobNodeTypeColumn, @@ -37,6 +40,7 @@ typedef NS_ENUM(NSInteger, MobNodeType) { MobNodeTypeNativeView, MobNodeTypeIcon, MobNodeTypeCanvas, + MobNodeTypeGpuView, }; NS_ASSUME_NONNULL_BEGIN @@ -44,40 +48,40 @@ NS_ASSUME_NONNULL_BEGIN @interface MobNode : NSObject // Layout -@property (nonatomic) MobNodeType nodeType; -@property (nonatomic, strong, nullable) UIColor* backgroundColor; -@property (nonatomic) CGFloat padding; // uniform; -1 if unset -@property (nonatomic) CGFloat paddingTop; // -1 = use uniform padding -@property (nonatomic) CGFloat paddingRight; // -1 = use uniform padding -@property (nonatomic) CGFloat paddingBottom; // -1 = use uniform padding -@property (nonatomic) CGFloat paddingLeft; // -1 = use uniform padding +@property(nonatomic) MobNodeType nodeType; +@property(nonatomic, strong, nullable) UIColor *backgroundColor; +@property(nonatomic) CGFloat padding; // uniform; -1 if unset +@property(nonatomic) CGFloat paddingTop; // -1 = use uniform padding +@property(nonatomic) CGFloat paddingRight; // -1 = use uniform padding +@property(nonatomic) CGFloat paddingBottom; // -1 = use uniform padding +@property(nonatomic) CGFloat paddingLeft; // -1 = use uniform padding // Text / Button -@property (nonatomic, copy, nullable) NSString* text; -@property (nonatomic) CGFloat textSize; -@property (nonatomic, strong, nullable) UIColor* textColor; +@property(nonatomic, copy, nullable) NSString *text; +@property(nonatomic) CGFloat textSize; +@property(nonatomic, strong, nullable) UIColor *textColor; // Tap -@property (nonatomic, copy, nullable) void (^onTap)(void); +@property(nonatomic, copy, nullable) void (^onTap)(void); // Value-bearing change callbacks (set by mob_nif.m; called by SwiftUI) -@property (nonatomic, copy, nullable) void (^onChangeStr)(NSString*); -@property (nonatomic, copy, nullable) void (^onChangeBool)(BOOL); -@property (nonatomic, copy, nullable) void (^onChangeFloat)(double); +@property(nonatomic, copy, nullable) void (^onChangeStr)(NSString *); +@property(nonatomic, copy, nullable) void (^onChangeBool)(BOOL); +@property(nonatomic, copy, nullable) void (^onChangeFloat)(double); // Selection (pickers, menus, segmented controls) -@property (nonatomic, copy, nullable) void (^onSelect)(void); +@property(nonatomic, copy, nullable) void (^onSelect)(void); // Gestures (Batch 4) — set by mob_nif.m via tap-handle registration. // SwiftUI side wires these through .onLongPressGesture, .gesture(TapGesture(count:2)), // .gesture(DragGesture(...)). Each is opt-in (nil = no gesture recognizer). -@property (nonatomic, copy, nullable) void (^onLongPress)(void); -@property (nonatomic, copy, nullable) void (^onDoubleTap)(void); -@property (nonatomic, copy, nullable) void (^onSwipe)(NSString* direction); -@property (nonatomic, copy, nullable) void (^onSwipeLeft)(void); -@property (nonatomic, copy, nullable) void (^onSwipeRight)(void); -@property (nonatomic, copy, nullable) void (^onSwipeUp)(void); -@property (nonatomic, copy, nullable) void (^onSwipeDown)(void); +@property(nonatomic, copy, nullable) void (^onLongPress)(void); +@property(nonatomic, copy, nullable) void (^onDoubleTap)(void); +@property(nonatomic, copy, nullable) void (^onSwipe)(NSString *direction); +@property(nonatomic, copy, nullable) void (^onSwipeLeft)(void); +@property(nonatomic, copy, nullable) void (^onSwipeRight)(void); +@property(nonatomic, copy, nullable) void (^onSwipeUp)(void); +@property(nonatomic, copy, nullable) void (^onSwipeDown)(void); // ── Batch 5 Tier 1: high-frequency scroll/drag/pinch/rotate/pointer ── // These callbacks are wired by mob_nif.m. Throttling and delta-thresholding @@ -86,150 +90,179 @@ NS_ASSUME_NONNULL_BEGIN // // Scroll: SwiftUI .onScrollGeometryChange (iOS 17+) or UIScrollView delegate. // (CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, CGFloat vx, CGFloat vy, NSString phase) -@property (nonatomic, copy, nullable) void (^onScroll)(CGFloat, CGFloat, CGFloat, CGFloat, CGFloat, CGFloat, NSString*); +@property(nonatomic, copy, nullable) void (^onScroll) + (CGFloat, CGFloat, CGFloat, CGFloat, CGFloat, CGFloat, NSString *); // Drag: pan gesture deltas. // (CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, NSString phase) -@property (nonatomic, copy, nullable) void (^onDrag)(CGFloat, CGFloat, CGFloat, CGFloat, NSString*); +@property(nonatomic, copy, nullable) void (^onDrag)(CGFloat, CGFloat, CGFloat, CGFloat, NSString *); // Pinch: scale + velocity. (CGFloat scale, CGFloat velocity, NSString phase) -@property (nonatomic, copy, nullable) void (^onPinch)(CGFloat, CGFloat, NSString*); +@property(nonatomic, copy, nullable) void (^onPinch)(CGFloat, CGFloat, NSString *); // Rotate: angle in degrees + velocity. (CGFloat degrees, CGFloat velocity, NSString phase) -@property (nonatomic, copy, nullable) void (^onRotate)(CGFloat, CGFloat, NSString*); +@property(nonatomic, copy, nullable) void (^onRotate)(CGFloat, CGFloat, NSString *); // Pointer move (iPad trackpad / Apple Pencil hover). // (CGFloat x, CGFloat y) -@property (nonatomic, copy, nullable) void (^onPointerMove)(CGFloat, CGFloat); +@property(nonatomic, copy, nullable) void (^onPointerMove)(CGFloat, CGFloat); // ── Batch 5 Tier 2: semantic scroll events (single-fire) ── -@property (nonatomic, copy, nullable) void (^onScrollBegan)(void); -@property (nonatomic, copy, nullable) void (^onScrollEnded)(void); -@property (nonatomic, copy, nullable) void (^onScrollSettled)(void); -@property (nonatomic, copy, nullable) void (^onTopReached)(void); -@property (nonatomic, copy, nullable) void (^onScrolledPast)(void); -@property (nonatomic) CGFloat scrolledPastThreshold; // y boundary +@property(nonatomic, copy, nullable) void (^onScrollBegan)(void); +@property(nonatomic, copy, nullable) void (^onScrollEnded)(void); +@property(nonatomic, copy, nullable) void (^onScrollSettled)(void); +@property(nonatomic, copy, nullable) void (^onTopReached)(void); +@property(nonatomic, copy, nullable) void (^onScrolledPast)(void); +@property(nonatomic) CGFloat scrolledPastThreshold; // y boundary // ── Batch 5 Tier 3: native-side scroll-driven UI ── // Each is a config dict (decoded from JSON). The SwiftUI view layer reads // these and wires them up using .scrollPosition / .onScrollGeometryChange // observers without going through the BEAM. nil = not configured. -@property (nonatomic, strong, nullable) NSDictionary* parallaxConfig; -@property (nonatomic, strong, nullable) NSDictionary* fadeOnScrollConfig; -@property (nonatomic, strong, nullable) NSDictionary* stickyWhenScrolledPastConfig; +@property(nonatomic, strong, nullable) NSDictionary *parallaxConfig; +@property(nonatomic, strong, nullable) NSDictionary *fadeOnScrollConfig; +@property(nonatomic, strong, nullable) NSDictionary *stickyWhenScrolledPastConfig; // text_field -@property (nonatomic, copy, nullable) NSString* placeholder; -@property (nonatomic, copy, nonnull) NSString* keyboardTypeStr; // "default","number","decimal","email","phone","url" -@property (nonatomic, copy, nonnull) NSString* returnKeyStr; // "done","next","go","search","send" -@property (nonatomic, copy, nullable) void (^onFocus)(void); -@property (nonatomic, copy, nullable) void (^onBlur)(void); -@property (nonatomic, copy, nullable) void (^onSubmit)(void); +@property(nonatomic, copy, nullable) NSString *placeholder; +@property(nonatomic, copy, nonnull) + NSString *keyboardTypeStr; // "default","number","decimal","email","phone","url" +@property(nonatomic, copy, nonnull) NSString *returnKeyStr; // "done","next","go","search","send" +@property(nonatomic, assign) BOOL isSecure; // mask input (SecureField on iOS) +@property(nonatomic, copy, nullable) void (^onFocus)(void); +@property(nonatomic, copy, nullable) void (^onBlur)(void); +@property(nonatomic, copy, nullable) void (^onSubmit)(void); // IME composition (CJK, Korean, Vietnamese, accent input). Called by // the iOS text-input layer when marked-text state changes. // text: the in-progress (or committed) text // phase: "began" | "updating" | "committed" | "cancelled" -@property (nonatomic, copy, nullable) void (^onCompose)(NSString* text, NSString* phase); +@property(nonatomic, copy, nullable) void (^onCompose)(NSString *text, NSString *phase); // toggle -@property (nonatomic) BOOL checked; +@property(nonatomic) BOOL checked; // slider -@property (nonatomic) CGFloat minValue; // default 0.0 -@property (nonatomic) CGFloat maxValue; // default 1.0 +@property(nonatomic) CGFloat minValue; // default 0.0 +@property(nonatomic) CGFloat maxValue; // default 1.0 // Divider -@property (nonatomic) CGFloat thickness; // default 1.0 +@property(nonatomic) CGFloat thickness; // default 1.0 // Scroll -@property (nonatomic, copy, nonnull) NSString* axis; // "vertical" | "horizontal" -@property (nonatomic) BOOL showIndicator; // default YES +@property(nonatomic, copy, nonnull) NSString *axis; // "vertical" | "horizontal" +@property(nonatomic) BOOL showIndicator; // default YES // Row vertical alignment — "top" | "center" (default) | "bottom" -@property (nonatomic, copy, nonnull) NSString* rowAlign; +@property(nonatomic, copy, nonnull) NSString *rowAlign; // Box content alignment — "top_leading" (default) | "center" | "top_center" | // "bottom_leading" | "bottom_center" | "bottom_trailing" | "top_trailing". // Affects how a box's children are placed within its frame; relevant when // the box has explicit width/height larger than the children. -@property (nonatomic, copy, nonnull) NSString* boxAlign; +@property(nonatomic, copy, nonnull) NSString *boxAlign; // Per-node offset applied as .offset(x:y:) on iOS / Modifier.offset on // Compose. Useful for absolute positioning within an aligned box. Default 0. -@property (nonatomic) CGFloat offsetX; -@property (nonatomic) CGFloat offsetY; +@property(nonatomic) CGFloat offsetX; +@property(nonatomic) CGFloat offsetY; // Spacer — fixedSize == 0 means fill available space -@property (nonatomic) CGFloat fixedSize; +@property(nonatomic) CGFloat fixedSize; // Progress — NaN means indeterminate -@property (nonatomic) CGFloat value; -@property (nonatomic, strong, nullable) UIColor* color; // track / indicator color +@property(nonatomic) CGFloat value; +@property(nonatomic, strong, nullable) UIColor *color; // track / indicator color // Layout behaviour -@property (nonatomic) BOOL fillWidth; // fill parent width (default NO; button default YES) -@property (nonatomic) BOOL fillHeight; // fill parent height (default NO) — used for full-screen overlays/dialogs -@property (nonatomic) CGFloat cornerRadius; // rounded corners in pt (default 0) +@property(nonatomic) BOOL fillWidth; // fill parent width (default NO; button default YES) +@property(nonatomic) + BOOL fillHeight; // fill parent height (default NO) — used for full-screen overlays/dialogs +@property(nonatomic) CGFloat cornerRadius; // rounded corners in pt (default 0) // Border (currently honored on box). Both must be set for a border to draw. -@property (nonatomic, strong, nullable) UIColor* borderColor; -@property (nonatomic) CGFloat borderWidth; // pt; default 0 = no border +@property(nonatomic, strong, nullable) UIColor *borderColor; +@property(nonatomic) CGFloat borderWidth; // pt; default 0 = no border + +// Liquid Glass opt-in (set by Mob.Renderer when the active theme has +// `glass: true` AND the node has a `background:`). MobBox replaces the +// solid fill with `.glassEffect()` on iOS 26+, falling back to +// `.ultraThinMaterial` on iOS 17–25. The original `backgroundColor` is +// preserved so the swap can be undone at runtime by toggling the theme. +@property(nonatomic) BOOL useGlass; // image -@property (nonatomic, copy, nullable) NSString* src; -@property (nonatomic, copy, nonnull) NSString* contentModeStr; // "fit" | "fill" | "stretch" -@property (nonatomic) CGFloat fixedWidth; // 0 = fill available -@property (nonatomic) CGFloat fixedHeight; // 0 = auto -@property (nonatomic, strong, nullable) UIColor* placeholderColor; +@property(nonatomic, copy, nullable) NSString *src; +@property(nonatomic, copy, nonnull) NSString *contentModeStr; // "fit" | "fill" | "stretch" +@property(nonatomic) CGFloat fixedWidth; // 0 = fill available +@property(nonatomic) CGFloat fixedHeight; // 0 = auto +@property(nonatomic, strong, nullable) UIColor *placeholderColor; // Typography -@property (nonatomic, copy, nullable) NSString* fontFamily; // nil = system font -@property (nonatomic, copy, nonnull) NSString* fontWeight; // "regular","medium","semibold","bold","light","thin" -@property (nonatomic, copy, nonnull) NSString* textAlign; // "left","center","right" -@property (nonatomic) BOOL italic; -@property (nonatomic) CGFloat lineHeight; // multiplier; 0 = default -@property (nonatomic) CGFloat letterSpacing; +@property(nonatomic, copy, nullable) NSString *fontFamily; // nil = system font +@property(nonatomic, copy, nonnull) + NSString *fontWeight; // "regular","medium","semibold","bold","light","thin" +@property(nonatomic, copy, nonnull) NSString *textAlign; // "left","center","right" +@property(nonatomic) BOOL italic; +@property(nonatomic) CGFloat lineHeight; // multiplier; 0 = default +@property(nonatomic) CGFloat letterSpacing; // Tab bar -@property (nonatomic, strong, nullable) NSArray* tabDefs; // array of NSDictionary, each with id/label/icon -@property (nonatomic, copy, nullable) NSString* activeTab; // selected tab id -@property (nonatomic, copy, nullable) void (^onTabSelect)(NSString*); // sends selected tab id as string +@property(nonatomic, strong, nullable) + NSArray *tabDefs; // array of NSDictionary, each with id/label/icon +@property(nonatomic, copy, nullable) NSString *activeTab; // selected tab id +@property(nonatomic, copy, nullable) void (^onTabSelect)(NSString *) + ; // sends selected tab id as string // Video player -@property (nonatomic) BOOL videoAutoplay; -@property (nonatomic) BOOL videoLoop; -@property (nonatomic) BOOL videoControls; +@property(nonatomic) BOOL videoAutoplay; +@property(nonatomic) BOOL videoLoop; +@property(nonatomic) BOOL videoControls; // Camera preview -@property (nonatomic, copy, nonnull) NSString* cameraFacing; // "back" | "front" +@property(nonatomic, copy, nonnull) NSString *cameraFacing; // "back" | "front" // WebView -@property (nonatomic, copy, nullable) NSString* webViewUrl; // URL to load -@property (nonatomic, copy, nullable) NSString* webViewAllow; // comma-separated allowed URL prefixes -@property (nonatomic) BOOL webViewShowUrl; -@property (nonatomic, copy, nullable) NSString* webViewTitle; // static title label; overrides show_url +@property(nonatomic, copy, nullable) NSString *webViewUrl; // URL to load +@property(nonatomic, copy, nullable) NSString *webViewAllow; // comma-separated allowed URL prefixes +@property(nonatomic) BOOL webViewShowUrl; +@property(nonatomic, copy, nullable) + NSString *webViewTitle; // static title label; overrides show_url // NativeView — rendered by MobNativeViewRegistry -@property (nonatomic, copy, nullable) NSString* nativeViewModule; // registry key (e.g. "MyApp_ChartComponent") -@property (nonatomic, copy, nullable) NSString* nativeViewId; // user-assigned id -@property (nonatomic) int nativeViewHandle; // NIF component handle for event callbacks -@property (nonatomic, strong, nullable) NSDictionary* nativeViewProps; // full props dict forwarded to the factory +@property(nonatomic, copy, nullable) + NSString *nativeViewModule; // registry key (e.g. "MyApp_ChartComponent") +@property(nonatomic, copy, nullable) NSString *nativeViewId; // user-assigned id +@property(nonatomic) int nativeViewHandle; // NIF component handle for event callbacks +@property(nonatomic, strong, nullable) + NSDictionary *nativeViewProps; // full props dict forwarded to the factory // Accessibility — set from the tap tag atom name; read by XCTest / ui_describe_all -@property (nonatomic, copy, nullable) NSString* accessibilityId; +@property(nonatomic, copy, nullable) NSString *accessibilityId; // Icon — logical name resolved to an SF Symbol on iOS / Material Symbol // on Android. textSize and textColor control glyph sizing + tint. -@property (nonatomic, copy, nullable) NSString* iconName; +@property(nonatomic, copy, nullable) NSString *iconName; // Canvas — declarative draw-op list from Mob.Canvas. Each entry is an // NSDictionary with an "op" key (e.g. "line", "circle") and op-specific // fields. Color values arrive pre-resolved (ARGB integers) from the // renderer's encode_canvas_op/2. -@property (nonatomic, strong, nullable) NSArray* canvasOps; -@property (nonatomic) CGFloat canvasWidth; // pt; required (>0) -@property (nonatomic) CGFloat canvasHeight; // pt; required (>0) +@property(nonatomic, strong, nullable) NSArray *canvasOps; +@property(nonatomic) CGFloat canvasWidth; // pt; required (>0) +@property(nonatomic) CGFloat canvasHeight; // pt; required (>0) + +// GpuView — Metal shader source + per-frame uniforms. The native side +// compiles `gpuShaderMSL` into an MTLRenderPipelineState (cached by +// the source hash) and binds `gpuUniforms` to fragment buffer slot 0 +// every frame. Shader compile errors surface as a translucent overlay +// on top of the view. See `Mob.UI.gpu_view/1` for the BEAM-side +// contract and the iOS-only / MSL-only scope. +@property(nonatomic, copy, nullable) NSString *gpuShaderMSL; +// May be an NSArray (preferred — ordered uniform list) or NSDictionary +// (legacy — iteration order undefined). See MobGpuView.swift for the +// expected packing semantics per element. +@property(nonatomic, strong, nullable) id gpuUniforms; // Children -@property (nonatomic, strong, nonnull) NSMutableArray<MobNode*>* children; +@property(nonatomic, strong, nonnull) NSMutableArray<MobNode *> *children; @end diff --git a/ios/MobNode.m b/ios/MobNode.m index 3061b949..4aa33b4a 100644 --- a/ios/MobNode.m +++ b/ios/MobNode.m @@ -7,40 +7,40 @@ @implementation MobNode - (instancetype)init { if ((self = [super init])) { - _textSize = 14.0; - _padding = 0.0; - _paddingTop = -1.0; - _paddingRight = -1.0; + _textSize = 14.0; + _padding = 0.0; + _paddingTop = -1.0; + _paddingRight = -1.0; _paddingBottom = -1.0; - _paddingLeft = -1.0; - _fontWeight = @"regular"; - _textAlign = @"left"; - _italic = NO; - _lineHeight = 0.0; + _paddingLeft = -1.0; + _fontWeight = @"regular"; + _textAlign = @"left"; + _italic = NO; + _lineHeight = 0.0; _letterSpacing = 0.0; - _thickness = 1.0; - _fixedSize = 0.0; - _value = NAN; // NaN = indeterminate (progress) or not-yet-set (slider) - _minValue = 0.0; - _maxValue = 1.0; - _checked = NO; - _axis = @"vertical"; - _showIndicator = YES; - _rowAlign = @"center"; - _boxAlign = @"top_leading"; - _offsetX = 0.0; - _offsetY = 0.0; + _thickness = 1.0; + _fixedSize = 0.0; + _value = NAN; // NaN = indeterminate (progress) or not-yet-set (slider) + _minValue = 0.0; + _maxValue = 1.0; + _checked = NO; + _axis = @"vertical"; + _showIndicator = YES; + _rowAlign = @"center"; + _boxAlign = @"top_leading"; + _offsetX = 0.0; + _offsetY = 0.0; _keyboardTypeStr = @"default"; - _returnKeyStr = @"done"; - _contentModeStr = @"fit"; - _fixedWidth = 0.0; - _fixedHeight = 0.0; - _fillWidth = NO; - _cornerRadius = 0.0; + _returnKeyStr = @"done"; + _contentModeStr = @"fit"; + _fixedWidth = 0.0; + _fixedHeight = 0.0; + _fillWidth = NO; + _cornerRadius = 0.0; _videoAutoplay = NO; - _videoLoop = NO; + _videoLoop = NO; _videoControls = YES; - _children = [NSMutableArray array]; + _children = [NSMutableArray array]; } return self; } diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index 53e63d83..347c6046 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -221,7 +221,12 @@ struct MobNodeView: View { VStack(alignment: .leading, spacing: 0) { ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) } } - .frame(maxWidth: .infinity, alignment: .leading) + // fill_height: true lets a column flex to fill its parent so children + // with Spacer() or fill_height of their own can pin to the bottom. + // Without maxHeight the VStack hugs its content vertically and a + // trailing footer sits directly below the last child instead of the + // parent's bottom edge. + .frame(maxWidth: .infinity, maxHeight: node.fillHeight ? .infinity : nil, alignment: .topLeading) .padding(node.paddingEdgeInsets) .background(node.backgroundColor.map { Color($0) } ?? Color.clear) .ifLet(node.onTap) { view, tap in @@ -232,9 +237,10 @@ struct MobNodeView: View { case .row: let alignment: VerticalAlignment = { switch node.rowAlign { - case "top": return .top - case "bottom": return .bottom - default: return .center + case "top": return .top + case "bottom": return .bottom + case "baseline": return .lastTextBaseline + default: return .center } }() HStack(alignment: alignment, spacing: 0) { @@ -297,15 +303,23 @@ struct MobNodeView: View { .mobGestures(node) case .button: + // Padding + background INSIDE the Button's label, not outside. + // SwiftUI's Button only registers taps on its content view's + // bounds — applying `.padding()` to the Button itself leaves + // the padded area visually present but not tappable, so users + // tap the visible edge of the button and nothing happens. + // contentShape(Rectangle()) ensures the full padded area is + // hit-testable even when the background is .clear. Button(action: { node.onTap?() }) { Text(node.text ?? "") .font(node.resolvedFont) .foregroundColor(node.textColor.map { Color($0) } ?? Color.clear) .lineLimit(1) .frame(maxWidth: node.fillWidth ? .infinity : nil) + .padding(node.paddingEdgeInsets) + .background(node.backgroundColor.map { Color($0) } ?? Color.clear) + .contentShape(Rectangle()) } - .padding(node.paddingEdgeInsets) - .background(node.backgroundColor.map { Color($0) } ?? Color.clear) .clipShape(RoundedRectangle(cornerRadius: node.cornerRadius)) .ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) @@ -313,30 +327,41 @@ struct MobNodeView: View { case .scroll: let isHorizontal = node.axis == "horizontal" - let axes: Axis.Set = isHorizontal ? .horizontal : .vertical - ScrollView(axes, showsIndicators: node.showIndicator) { - if isHorizontal { + if isHorizontal { + ScrollView(.horizontal, showsIndicators: node.showIndicator) { HStack(alignment: .top, spacing: 0) { ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) } } .frame(maxHeight: .infinity, alignment: .topLeading) - } else { - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) } + } + .scrollDismissesKeyboard(.interactively) + .padding(node.paddingEdgeInsets) + .background(node.backgroundColor.map { Color($0) } ?? Color.clear) + .ifLet(node.nativeViewId) { view, id in view.accessibilityIdentifier(id) } + .modifier(MobScrollObserverGate(node: node, isHorizontal: true)) + } else { + GeometryReader { viewport in + ScrollView(.vertical, showsIndicators: node.showIndicator) { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) } + } + // Bind vertical scroll content to the live scene + // width. Without this minimum, SwiftUI may retain + // the content's prior portrait-sized proposal after + // an iPad window rotates or resizes. + .frame( + minWidth: max(0, viewport.size.width), + maxWidth: .infinity, + alignment: .leading + ) } - .frame(maxWidth: .infinity, alignment: .leading) + .scrollDismissesKeyboard(.interactively) + .ifLet(node.nativeViewId) { view, id in view.accessibilityIdentifier(id) } + .modifier(MobScrollObserverGate(node: node, isHorizontal: false)) } + .padding(node.paddingEdgeInsets) + .background(node.backgroundColor.map { Color($0) } ?? Color.clear) } - .scrollDismissesKeyboard(.interactively) - .padding(node.paddingEdgeInsets) - .background(node.backgroundColor.map { Color($0) } ?? Color.clear) - // ── Batch 5 Tier 1: scroll position observation ── - // SwiftUI's onScrollGeometryChange is iOS 18+. On older iOS - // there's no clean SwiftUI API for raw offset; UIKit-backed - // alternative pending. Until then, scroll events are silently - // unavailable on iOS 17 (renderer still accepts on_scroll - // props — they just won't fire). - .modifier(MobScrollObserverGate(node: node, isHorizontal: isHorizontal)) case .textField: let placeholder = node.placeholder ?? "" @@ -392,6 +417,7 @@ struct MobNodeView: View { .frame(maxHeight: .infinity) .padding(node.paddingEdgeInsets) .background(node.backgroundColor.map { Color($0) } ?? Color.clear) + .ifLet(node.nativeViewId) { view, id in view.accessibilityIdentifier(id) } case .progress: let trackColor = node.color.map { Color($0) } ?? Color.accentColor @@ -417,20 +443,20 @@ struct MobNodeView: View { if let src = node.src { MobVideoPlayer(src: src, autoplay: node.videoAutoplay, loop: node.videoLoop, controls: node.videoControls) - .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } + .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } .ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) } .padding(node.paddingEdgeInsets) } case .cameraPreview: MobCameraPreviewView(facing: node.cameraFacing) - .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } + .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } .ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) } .padding(node.paddingEdgeInsets) case .webView: MobWebView(node: node) - .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } + .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } .ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) } .padding(node.paddingEdgeInsets) @@ -443,6 +469,12 @@ struct MobNodeView: View { MobCanvasView(node: node) .padding(node.paddingEdgeInsets) + case .gpuView: + MobGpuView(node: node) + .ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) } + .ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) } + .padding(node.paddingEdgeInsets) + @unknown default: EmptyView() } @@ -451,6 +483,36 @@ struct MobNodeView: View { // (0, 0) which is a no-op. Used by SquareTriangle's hexagonal // snowflake to position rings absolutely within a center-aligned box. .offset(x: CGFloat(node.offsetX), y: CGFloat(node.offsetY)) + // Record on-screen frame + set accessibilityIdentifier for any node + // carrying an :id, so the agent can read positions via the + // element_frames NIF without a screenshot. + .modifier(MobFrameTracker(node: node)) + } +} + +// MobFrameTracker — for any node with an :id, set it as the accessibility +// identifier and report the element's global frame (logical points) to the C +// registry as it lays out / moves. Untagged nodes pass through untouched, so +// there's no cost unless a dev opts an element in by giving it an :id. +private struct MobFrameTracker: ViewModifier { + let node: MobNode + + func body(content: Content) -> some View { + if let id = node.nativeViewId { + content + .accessibilityIdentifier(id) + .background( + GeometryReader { geo in + Color.clear.onChange(of: geo.frame(in: .global), initial: true) { _, frame in + mob_register_frame( + id, Double(frame.minX), Double(frame.minY), + Double(frame.width), Double(frame.height)) + } + } + ) + } else { + content + } } } @@ -493,7 +555,7 @@ private struct MobBox: View { } } .padding(node.paddingEdgeInsets) - .background(node.backgroundColor.map { Color($0) } ?? Color.clear) + .mobBoxBackground(node: node) .overlay( // Border opt-in via border_color + border_width on the BEAM side. // When width is 0 (default) the stroke draws nothing — no perf cost. @@ -513,6 +575,42 @@ private struct MobBox: View { } } +// Backgrounds for `MobBox`. When the active theme has `glass: true` the BEAM +// renderer sets `useGlass` on every box that has a `background:` so we swap +// the solid fill for a translucent material. Liquid Glass landed on iOS 26; +// on older systems we fall back to `.ultraThinMaterial` (visually similar, +// less expensive). Without `useGlass` the original solid behaviour is kept. +private extension View { + @ViewBuilder + func mobBoxBackground(node: MobNode) -> some View { + let radius = node.cornerRadius + let shape: AnyShape = + radius > 0 + ? AnyShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) + : AnyShape(Rectangle()) + + if node.useGlass { + // Liquid Glass on iOS 26+; otherwise the closest visual approximation + // that ships in older system SDKs. + // + // `Glass.clear` (vs `Glass.regular`) — the surface is noticeably + // more transparent; what's behind shows through. Card-style + // surfaces look "floating" rather than "frosted". Switch to + // `.regular` if a tinted, opaque-leaning glass is wanted. + if #available(iOS 26.0, *) { + self.glassEffect(.clear, in: shape) + } else { + self.background(.ultraThinMaterial, in: shape) + } + } else { + // `in: shape` so the solid fill is clipped to the corner radius — without + // it the fill is a plain rectangle and only the (separately-stroked) + // border looks rounded, leaving square fill corners on non-glass boxes. + self.background(node.backgroundColor.map { Color($0) } ?? Color.clear, in: shape) + } + } +} + private func boxAlignmentFromString(_ s: String) -> Alignment { switch s { case "center": return .center @@ -537,8 +635,12 @@ private func boxAlignmentFromString(_ s: String) -> Alignment { private struct MobCanvasView: View { let node: MobNode + // Tracks whether the active drag has emitted its "began" sample yet, so the + // first onChanged reports phase "began" and the rest "dragging". + @State private var dragging = false + var body: some View { - Canvas { ctx, size in + let canvas = Canvas { ctx, size in let ops = node.canvasOps as? [[String: Any]] ?? [] for op in ops { drawOp(op, in: &ctx, size: size) @@ -548,6 +650,48 @@ private struct MobCanvasView: View { width: node.canvasWidth > 0 ? CGFloat(node.canvasWidth) : nil, height: node.canvasHeight > 0 ? CGFloat(node.canvasHeight) : nil ) + + // Finger-drag input: when the node registered an on_drag handle, attach a + // continuous drag recognizer (the iOS analog of Android MobCanvas's + // detectDragGestures). The Canvas frame is sized to the declared logical + // units (points), and draw ops are drawn in that same space, so the + // gesture's local-space location is already in canvas coordinates — no + // pixel→logical rescale needed (unlike Android, where it is). + // + // minimumDistance: 0 is intentional: a finger-drawing canvas wants an + // immediate response and a stationary tap to register as a single point + // (a dot). This is a deliberate divergence from Android's + // detectDragGestures, which has a touch-slop threshold, so a bare tap + // fires a zero-length began/ended drag on iOS but nothing on Android. + if node.onDrag != nil { + canvas.gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + // Flip the @State flag only once, on the first sample, so + // a fast drag does not invalidate the view on every move. + let phase: String + if dragging { + phase = "dragging" + } else { + dragging = true + phase = "began" + } + node.onDrag?( + value.translation.width, value.translation.height, + value.location.x, value.location.y, phase + ) + } + .onEnded { value in + dragging = false + node.onDrag?( + value.translation.width, value.translation.height, + value.location.x, value.location.y, "ended" + ) + } + ) + } else { + canvas + } } private func drawOp(_ op: [String: Any], in ctx: inout GraphicsContext, size: CGSize) { @@ -573,16 +717,14 @@ private struct MobCanvasView: View { let r = cgNum(op["r"]) let rect = CGRect(x: cgNum(op["x"]) - r, y: cgNum(op["y"]) - r, width: r * 2, height: r * 2) let path = Path(ellipseIn: rect) - if isFill { ctx.fill(path, with: .color(color)) } - else { ctx.stroke(path, with: .color(color), style: strokeStyle) } + if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) } case "ellipse": let rx = cgNum(op["rx"]) let ry = cgNum(op["ry"]) let rect = CGRect(x: cgNum(op["x"]) - rx, y: cgNum(op["y"]) - ry, width: rx * 2, height: ry * 2) let path = Path(ellipseIn: rect) - if isFill { ctx.fill(path, with: .color(color)) } - else { ctx.stroke(path, with: .color(color), style: strokeStyle) } + if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) } case "arc": // Mob.Canvas arc convention: degrees, 0° to the right, sweeping clockwise. @@ -607,8 +749,7 @@ private struct MobCanvasView: View { let path: Path = radius > 0 ? Path(roundedRect: rect, cornerRadius: radius) : Path(rect) - if isFill { ctx.fill(path, with: .color(color)) } - else { ctx.stroke(path, with: .color(color), style: strokeStyle) } + if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) } case "path": guard let pts = op["points"] as? [[Double]], !pts.isEmpty else { return } @@ -620,8 +761,7 @@ private struct MobCanvasView: View { } if closed || isFill { p.closeSubpath() } } - if isFill { ctx.fill(path, with: .color(color)) } - else { ctx.stroke(path, with: .color(color), style: strokeStyle) } + if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) } case "text": let str = (op["text"] as? String) ?? "" @@ -833,11 +973,26 @@ private struct MobCameraPreviewView: UIViewRepresentable { view.cameraLayer.videoGravity = .resizeAspectFill // Connect immediately if the session is already running. view.cameraLayer.session = g_preview_session + rotatePreviewConnection(view: view) // Observe future session changes (start, stop, facing swap). context.coordinator.startObserving(view: view) return view } + // Pin the preview to portrait so what the user sees matches the + // upright frame we ship to the model. Without this, the sensor's + // landscape-native output renders sideways in a portrait UI. + private func rotatePreviewConnection(view: CameraPreviewUIView) { + guard let conn = view.cameraLayer.connection else { return } + if #available(iOS 17.0, *) { + if conn.isVideoRotationAngleSupported(90) { + conn.videoRotationAngle = 90 + } + } else if conn.isVideoOrientationSupported { + conn.videoOrientation = .portrait + } + } + func updateUIView(_ view: CameraPreviewUIView, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator() } @@ -853,7 +1008,17 @@ private struct MobCameraPreviewView: UIViewRepresentable { object: nil, queue: .main ) { [weak self] _ in - self?.hostView?.cameraLayer.session = g_preview_session + guard let view = self?.hostView else { return } + view.cameraLayer.session = g_preview_session + if let conn = view.cameraLayer.connection { + if #available(iOS 17.0, *) { + if conn.isVideoRotationAngleSupported(90) { + conn.videoRotationAngle = 90 + } + } else if conn.isVideoOrientationSupported { + conn.videoOrientation = .portrait + } + } } } @@ -993,8 +1158,17 @@ private struct MobTextField: View { } } + @ViewBuilder + private var field: some View { + if node.isSecure { + SecureField(placeholder, text: $text) + } else { + TextField(placeholder, text: $text) + } + } + var body: some View { - TextField(placeholder, text: $text) + field .focused($isFocused) .keyboardType(keyboardType) .submitLabel(submitLabel) @@ -1007,8 +1181,7 @@ private struct MobTextField: View { node.onChangeStr?(newValue) } .onChange(of: isFocused) { _, focused in - if focused { node.onFocus?() } - else { node.onBlur?() } + if focused { node.onFocus?() } else { node.onBlur?() } } // Sync from parent when the `value:` prop changes externally — // but only if the user isn't actively typing (which would yank @@ -1055,6 +1228,13 @@ private struct MobToggle: View { node.onChangeBool?(newValue) } .frame(maxWidth: .infinity, alignment: .leading) + // issues.md #8: SwiftUI's Toggle("Label", …) initializer does + // not propagate the label string into the underlying control's + // accessibilityLabel — the AX tree exposes the visual Text as + // a separate node and the Switch as a button with empty label. + // Setting it here lets `Mob.Test.toggle(node, "Notifications")` + // find the toggle via plain label match. + .accessibilityLabel(label) } } @@ -1069,12 +1249,31 @@ private struct MobSlider: View { } var body: some View { + // issues.md #7: SwiftUI's plain Slider doesn't emit AX adjustable + // actions unless `.accessibilityAdjustableAction` is attached. Without + // it, VoiceOver users (and `Mob.Test.adjust_slider/4` which calls the + // same AX API) see :ok back from increment/decrement but the value + // never changes. Default step is (max - min) / 10 — the same default + // VoiceOver picks for native UISlider when no explicit step is set. + let step = (node.maxValue - node.minValue) / 10.0 Slider(value: $value, in: node.minValue...node.maxValue) .onChange(of: value) { _, newValue in node.onChangeFloat?(newValue) } .tint(node.color.map { Color($0) } ?? Color.accentColor) .frame(maxWidth: .infinity) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: + value = Swift.min(value + step, node.maxValue) + node.onChangeFloat?(value) + case .decrement: + value = Swift.max(value - step, node.minValue) + node.onChangeFloat?(value) + @unknown default: + break + } + } } } @@ -1092,7 +1291,7 @@ private struct MobImage: View { var body: some View { Group { if let src = node.src { - if (src.hasPrefix("http://") || src.hasPrefix("https://")), + if src.hasPrefix("http://") || src.hasPrefix("https://"), let url = URL(string: src) { AsyncImage(url: url) { phase in switch phase { @@ -1114,7 +1313,7 @@ private struct MobImage: View { } } .frame( - width: node.fixedWidth > 0 ? node.fixedWidth : nil, + width: node.fixedWidth > 0 ? node.fixedWidth : nil, height: node.fixedHeight > 0 ? node.fixedHeight : nil ) .clipShape(RoundedRectangle(cornerRadius: node.cornerRadius)) @@ -1126,7 +1325,7 @@ private struct MobImage: View { public struct MobRootView: View { @ObservedObject var model = MobViewModel.shared @Environment(\.colorScheme) private var colorScheme - @State private var currentRoot: MobNode? = nil + @State private var currentRoot: MobNode? @State private var currentTransition: String = "none" // Local mirror of model.navVersion so the .id() change happens INSIDE // the withAnimation block (the model's @Published value changes via @@ -1220,13 +1419,13 @@ public struct MobRootView: View { switch t { case "push": return .asymmetric( - insertion: .move(edge: .trailing), - removal: .move(edge: .leading) + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) ) case "pop": return .asymmetric( - insertion: .move(edge: .leading), - removal: .move(edge: .trailing) + insertion: .move(edge: .leading), + removal: .move(edge: .trailing) ) case "reset": return .opacity @@ -1285,7 +1484,7 @@ struct MobScrollObserver: ViewModifier { @State private var lastTs: TimeInterval = 0 @State private var hasBegun: Bool = false @State private var pastThreshold: Bool = false - @State private var endTask: Task<Void, Never>? = nil + @State private var endTask: Task<Void, Never>? private static let endDebounceMs: Int = 150 diff --git a/ios/MobViewModel.swift b/ios/MobViewModel.swift index 09f26361..28168930 100644 --- a/ios/MobViewModel.swift +++ b/ios/MobViewModel.swift @@ -7,7 +7,7 @@ import Combine @objc public class MobViewModel: NSObject, ObservableObject { @objc public static let shared = MobViewModel() - @Published public var root: MobNode? = nil + @Published public var root: MobNode? /// Increments on every setRoot call; views use onChange(of: rootVersion) to /// trigger withAnimation rather than watching root directly (root identity /// may change even for same-screen re-renders). @@ -23,7 +23,7 @@ import Combine /// Current startup phase message shown while BEAM is initialising. @Published public var startupPhase: String = "Starting…" /// Non-nil when a fatal startup error has occurred; the error screen stalls here. - @Published public var startupError: String? = nil + @Published public var startupError: String? @objc public func setRoot(_ node: MobNode?, transition: String) { DispatchQueue.main.async { diff --git a/ios/driver_tab_ios.c b/ios/driver_tab_ios.c deleted file mode 100644 index fd100683..00000000 --- a/ios/driver_tab_ios.c +++ /dev/null @@ -1,81 +0,0 @@ -// driver_tab_ios.c — Reference snapshot of the static NIF table. -// -// As of mob 0.5.18 + mob_dev 0.4.x, the source of truth for an app's static -// NIF table lives in the app's mob.exs `:static_nifs` config and is generated -// to priv/generated/driver_tab_ios.c via `mix mob.regen_driver_tab`. This -// file remains as a fallback that build templates use when the generated file -// is absent (i.e. the project hasn't been migrated yet). -// -// Keep this file in sync with `MobDev.StaticNifs.default_nifs/0` so the -// fallback matches the generator's default output. -// -// Link BEFORE libbeam.a to override the built-in driver_tab. - -#include <stddef.h> - -typedef struct { void* de; int flags; } ErtsStaticDriver; -#define THE_NON_VALUE ((unsigned long)0) -typedef struct { - void* (*nif_init)(void); - int is_builtin; - unsigned long nif_mod; - void* entry; -} ErtsStaticNif; - -typedef struct { void* de; int flags; } ErlDrvEntryStub; -extern ErlDrvEntryStub inet_driver_entry; -extern ErlDrvEntryStub ram_file_driver_entry; - -ErtsStaticDriver driver_tab[] = { - {&inet_driver_entry, 0}, - {&ram_file_driver_entry, 0}, - {NULL, 0} -}; - -void erts_init_static_drivers(void) {} - -void *prim_tty_nif_init(void); -void *erl_tracer_nif_init(void); -void *prim_buffer_nif_init(void); -void *prim_file_nif_init(void); -void *zlib_nif_init(void); -void *zstd_nif_init(void); -void *prim_socket_nif_init(void); -void *prim_net_nif_init(void); -void *asn1rt_nif_nif_init(void); - -// crypto.c's ERL_NIF_INIT(crypto, ...) generates: crypto_nif_init. -// Built into the app binary via crypto.a + libcrypto.a (OpenSSL). -// Same pattern as Android — see driver_tab_android.c for the rationale -// (Android RTLD_LOCAL hides parent's enif_* symbols from dlopen'd -// children; iOS App Store likewise rejects dynamic NIFs in the bundle). -void *crypto_nif_init(void); - -// mob_nif.m's ERL_NIF_INIT(mob_nif,...) with -DSTATIC_ERLANG_NIF -// generates function name: mob_nif_nif_init -void *mob_nif_nif_init(void); - -// exqlite sqlite3_nif is linked statically on device (pass -DMOB_STATIC_SQLITE_NIF -// when compiling this file in device builds). On simulator it loads dynamically -// as a .so and must NOT appear in the static table. -#ifdef MOB_STATIC_SQLITE_NIF -void *sqlite3_nif_nif_init(void); -#endif - -ErtsStaticNif erts_static_nif_tab[] = { - {prim_tty_nif_init, 0, THE_NON_VALUE, NULL}, - {erl_tracer_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_buffer_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_file_nif_init, 0, THE_NON_VALUE, NULL}, - {zlib_nif_init, 0, THE_NON_VALUE, NULL}, - {zstd_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_socket_nif_init, 0, THE_NON_VALUE, NULL}, - {prim_net_nif_init, 0, THE_NON_VALUE, NULL}, - {asn1rt_nif_nif_init, 1, THE_NON_VALUE, NULL}, - {crypto_nif_init, 1, THE_NON_VALUE, NULL}, - {mob_nif_nif_init, 0, THE_NON_VALUE, NULL}, -#ifdef MOB_STATIC_SQLITE_NIF - {sqlite3_nif_nif_init, 0, THE_NON_VALUE, NULL}, -#endif - {NULL, 0, THE_NON_VALUE, NULL} -}; diff --git a/ios/driver_tab_ios.zig b/ios/driver_tab_ios.zig new file mode 100644 index 00000000..3e8880cf --- /dev/null +++ b/ios/driver_tab_ios.zig @@ -0,0 +1,150 @@ +//! driver_tab_ios.zig — Reference snapshot of the static NIF table (Zig rewrite). +//! +//! Phase 6a of the build-system migration: the per-app source-of-truth +//! for static NIFs still lives in `mob.exs`'s `:static_nifs` (regenerated +//! by `mix mob.regen_driver_tab`), but the *output* shape moves from C +//! to Zig. The hand-written file below matches the C version byte-for- +//! byte semantically, validates the C-ABI exports libbeam.a expects, and +//! gives later iters a comptime-friendly structure to build on. +//! +//! Link BEFORE libbeam.a so this overrides BEAM's built-in empty +//! `erts_static_nif_tab[]` and `driver_tab[]`. + +// ── ABI types ────────────────────────────────────────────────────────────── +// Layouts mirror the C structs in libbeam.a. They use Zig's `extern struct` +// so the field order + alignment matches the C ABI exactly. + +const ErtsStaticDriver = extern struct { + de: ?*anyopaque, + flags: c_int, +}; + +const ErtsStaticNif = extern struct { + nif_init: ?*const fn () callconv(.c) ?*anyopaque, + is_builtin: c_int, + nif_mod: c_ulong, + entry: ?*anyopaque, +}; + +const ErlDrvEntryStub = extern struct { + de: ?*anyopaque, + flags: c_int, +}; + +// NON-VALUE sentinel matches the C `#define THE_NON_VALUE` — used as +// `nif_mod` for entries the BEAM populates at load time. +const THE_NON_VALUE: c_ulong = 0; + +// ── External driver entry refs (from libbeam.a / OTP) ────────────────────── + +extern var inet_driver_entry: ErlDrvEntryStub; +extern var ram_file_driver_entry: ErlDrvEntryStub; + +// ── External NIF init refs ───────────────────────────────────────────────── +// Each ERL_NIF_INIT(name, ...) macro in NIF source files generates a +// `<name>_nif_init` C function. We declare them here as extern so the +// table below can reference them. + +extern fn prim_tty_nif_init() callconv(.c) ?*anyopaque; +extern fn erl_tracer_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_buffer_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_file_nif_init() callconv(.c) ?*anyopaque; +extern fn zlib_nif_init() callconv(.c) ?*anyopaque; +extern fn zstd_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_socket_nif_init() callconv(.c) ?*anyopaque; +extern fn prim_net_nif_init() callconv(.c) ?*anyopaque; +extern fn asn1rt_nif_nif_init() callconv(.c) ?*anyopaque; + +// crypto.c's ERL_NIF_INIT(crypto, ...) generates crypto_nif_init. +// Built into the app binary via crypto.a + libcrypto.a (OpenSSL). +// Same pattern as Android — see driver_tab_android.{c,zig} for rationale +// (Android RTLD_LOCAL hides parent's enif_* symbols from dlopen'd +// children; iOS App Store likewise rejects dynamic NIFs in the bundle). +extern fn crypto_nif_init() callconv(.c) ?*anyopaque; + +// mob_nif.m's ERL_NIF_INIT(mob_nif, ...) with -DSTATIC_ERLANG_NIF +// generates: mob_nif_nif_init. +extern fn mob_nif_nif_init() callconv(.c) ?*anyopaque; + +// exqlite's sqlite3_nif is linked statically on device only. The build +// system threads the flag in via `b.addOptions()` in build_device.zig +// (iter 3); see addZigObject. For simulator builds the option module +// either isn't provided OR has sqlite_static = false. +// +// emlx_nif is linked statically when the project opts into MLX via +// `mix mob.enable mlx`. Same threading mechanism — a separate flag +// keeps the two NIFs independent. +const build_options = @import("build_options"); +const sqlite_static = build_options.sqlite_static; +const emlx_static = build_options.emlx_static; +extern fn sqlite3_nif_nif_init() callconv(.c) ?*anyopaque; +extern fn emlx_nif_nif_init() callconv(.c) ?*anyopaque; + +// ── Static driver table ──────────────────────────────────────────────────── +// inet + ram_file are the only drivers in the iOS bundle. NULL-terminator +// at the end matches the C version exactly. + +export var driver_tab: [3]ErtsStaticDriver = .{ + .{ .de = &inet_driver_entry, .flags = 0 }, + .{ .de = &ram_file_driver_entry, .flags = 0 }, + .{ .de = null, .flags = 0 }, +}; + +// erts_init_static_drivers is a hook BEAM calls during init. We have +// no drivers to register dynamically — the table above is the whole +// story — so the function is empty. Matches the C version. +export fn erts_init_static_drivers() callconv(.c) void {} + +// ── Static NIF table ─────────────────────────────────────────────────────── +// Comptime-built so adding the conditional sqlite3_nif entry is a clean +// `if` rather than `#ifdef`. The output array length adjusts automatically. + +const base_nifs = [_]ErtsStaticNif{ + .{ .nif_init = prim_tty_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = erl_tracer_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_buffer_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_file_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = zlib_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = zstd_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_socket_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = prim_net_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = asn1rt_nif_nif_init, .is_builtin = 1, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = crypto_nif_init, .is_builtin = 1, .nif_mod = THE_NON_VALUE, .entry = null }, + .{ .nif_init = mob_nif_nif_init, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null }, +}; + +const sqlite3_nif_const = ErtsStaticNif{ + .nif_init = sqlite3_nif_nif_init, + .is_builtin = 0, + .nif_mod = THE_NON_VALUE, + .entry = null, +}; + +const emlx_nif_const = ErtsStaticNif{ + .nif_init = emlx_nif_nif_init, + .is_builtin = 0, + .nif_mod = THE_NON_VALUE, + .entry = null, +}; + +const sentinel = ErtsStaticNif{ + .nif_init = null, + .is_builtin = 0, + .nif_mod = THE_NON_VALUE, + .entry = null, +}; + +// 2^N branching: one branch per subset of enabled guarded NIFs. Order +// matters — most-specific subsets first so the comptime `if` doesn't +// mistakenly take a shadowed branch. +export var erts_static_nif_tab = blk: { + if (sqlite_static and emlx_static) { + break :blk base_nifs ++ [_]ErtsStaticNif{ sqlite3_nif_const, emlx_nif_const, sentinel }; + } else if (emlx_static) { + break :blk base_nifs ++ [_]ErtsStaticNif{ emlx_nif_const, sentinel }; + } else if (sqlite_static) { + break :blk base_nifs ++ [_]ErtsStaticNif{ sqlite3_nif_const, sentinel }; + } else { + break :blk base_nifs ++ [_]ErtsStaticNif{sentinel}; + } +}; diff --git a/ios/mob_beam.h b/ios/mob_beam.h index a32ac3ff..506c248b 100644 --- a/ios/mob_beam.h +++ b/ios/mob_beam.h @@ -10,23 +10,32 @@ void mob_init_ui(void); // Call mob_start_beam on a background thread — erl_start never returns. // app_module: Erlang module name, e.g. "mob_demo" -void mob_start_beam(const char* app_module); +void mob_start_beam(const char *app_module); // Update the startup status shown on screen while BEAM is initialising. // mob_set_startup_error stalls the screen with an error message (does not crash). // Both are safe to call from any thread. -void mob_set_startup_phase(const char* phase); -void mob_set_startup_error(const char* error); +void mob_set_startup_phase(const char *phase); +void mob_set_startup_error(const char *error); // Call from AppDelegate didRegisterForRemoteNotificationsWithDeviceToken // to forward the APNs device token to the BEAM as {:push_token, :ios, hex_string}. // Convert the raw NSData to a hex string before calling. -void mob_send_push_token(const char* hex_token); +void mob_send_push_token(const char *hex_token); // Store a notification JSON payload that launched the app from a killed state. // Call from application:didFinishLaunchingWithOptions: or scene:willConnectTo: // when a remote/local notification is the launch cause. The BEAM will deliver // it via handle_info({:notification, ...}) after the root screen is mounted. -void mob_set_launch_notification_json(const char* json); +void mob_set_launch_notification_json(const char *json); + +// Call from AppDelegate application:openURL:options: (or scene equivalent) when +// another app hands us a file to open — e.g. a `.livemd` emailed to the user and +// tapped, routed here because Info.plist declares the document type. Pass the +// NSURL's `path` (or absoluteString). Mob copies the file into tmp and delivers +// it to the BEAM: at the root screen's mount via Mob.Files.take_opened_document/0 +// (cold launch), and as {:files, :opened, item} to that screen if the app was +// already running (warm). +void mob_handle_opened_url(const char *url_cstr); #endif // MOB_BEAM_H diff --git a/ios/mob_beam.m b/ios/mob_beam.m index 6c55bb2e..6fdd7d06 100644 --- a/ios/mob_beam.m +++ b/ios/mob_beam.m @@ -3,17 +3,17 @@ // mob_set_startup_phase/error are implemented in mob_nif.m (which imports the // Swift-generated header) so this file stays free of app-specific includes. +#include "mob_beam.h" #import <Foundation/Foundation.h> -#include <stdlib.h> -#include <string.h> +#include <arpa/inet.h> #include <fcntl.h> -#include <unistd.h> -#include <pthread.h> #include <ifaddrs.h> #include <netinet/in.h> -#include <arpa/inet.h> +#include <pthread.h> +#include <stdlib.h> +#include <string.h> #include <sys/stat.h> -#include "mob_beam.h" +#include <unistd.h> // EPMD compiled into the binary (epmd.c / epmd_srv.c / epmd_cli.c compiled // with -Dmain=epmd_ios_main). Only present in device builds; the simulator @@ -26,9 +26,9 @@ // still works, but the app is networkless from a distribution POV. #if defined(MOB_BUNDLE_OTP) && !defined(MOB_RELEASE) extern int epmd_ios_main(int argc, char **argv); -static void* epmd_thread(void *arg) { +static void *epmd_thread(void *arg) { char *args[] = {"epmd", NULL}; - epmd_ios_main(1, args); // runs the EPMD event loop (does not return) + epmd_ios_main(1, args); // runs the EPMD event loop (does not return) return NULL; } #endif @@ -47,7 +47,7 @@ #define OTP_ROOT_LEGACY "/tmp/otp-ios-sim" #endif #ifndef ERTS_VSN -#define ERTS_VSN "erts-17.0" +#define ERTS_VSN "erts-17.0" #endif #ifndef OTP_RELEASE #define OTP_RELEASE "29" @@ -73,7 +73,8 @@ // route to os_log, so the failure is invisible. static const char *resolve_sim_otp_root(const char *app_module) { const char *env = getenv("MOB_SIM_RUNTIME_DIR"); - if (env && env[0]) return env; + if (env && env[0]) + return env; // iOS sim apps inherit HOME pointing to the per-app sandbox container // (…/CoreSimulator/Devices/<udid>/data/Containers/Data/Application/<uuid>), @@ -82,11 +83,11 @@ // ~/.mob/runtime/ios-sim. Fall back to HOME so this still works when the // binary runs outside simctl (e.g. raw test harness on the Mac). const char *home = getenv("SIMULATOR_HOST_HOME"); - if (!home || !home[0]) home = getenv("HOME"); + if (!home || !home[0]) + home = getenv("HOME"); if (home && app_module && app_module[0]) { static char new_default[1024]; - snprintf(new_default, sizeof(new_default), - "%s/.mob/runtime/ios-sim", home); + snprintf(new_default, sizeof(new_default), "%s/.mob/runtime/ios-sim", home); char check[1280]; snprintf(check, sizeof(check), "%s/%s", new_default, app_module); @@ -110,20 +111,25 @@ static void mob_write_diag(const char *docs_dir, const char *name, const char *i char path[1024]; snprintf(path, sizeof(path), "%s/%s", docs_dir, name); FILE *f = fopen(path, "w"); - if (f) { fprintf(f, "%s\n", info); fclose(f); } + if (f) { + fprintf(f, "%s\n", info); + fclose(f); + } } // Find the device's own USB link-local (169.254.x.x) IP by walking ifaddrs. // On simulator there is no such interface; returns NULL so callers fall back to 127.0.0.1. static const char *find_link_local_ip(char *buf, size_t len) { struct ifaddrs *ifa_list; - if (getifaddrs(&ifa_list) != 0) return NULL; + if (getifaddrs(&ifa_list) != 0) + return NULL; const char *found = NULL; for (struct ifaddrs *ifa = ifa_list; ifa && !found; ifa = ifa->ifa_next) { - if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) continue; + if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) + continue; struct sockaddr_in *sa = (struct sockaddr_in *)ifa->ifa_addr; uint32_t addr = ntohl(sa->sin_addr.s_addr); - if ((addr >> 16) == 0xA9FE) { // 169.254.0.0/16 + if ((addr >> 16) == 0xA9FE) { // 169.254.0.0/16 inet_ntop(AF_INET, &sa->sin_addr, buf, (socklen_t)len); found = buf; } @@ -136,18 +142,20 @@ static void mob_write_diag(const char *docs_dir, const char *name, const char *i // when no USB link-local interface is present. Returns NULL if none found. static const char *find_lan_ip(char *buf, size_t len) { struct ifaddrs *ifa_list; - if (getifaddrs(&ifa_list) != 0) return NULL; + if (getifaddrs(&ifa_list) != 0) + return NULL; const char *found = NULL; for (struct ifaddrs *ifa = ifa_list; ifa && !found; ifa = ifa->ifa_next) { - if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) continue; + if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) + continue; struct sockaddr_in *sa = (struct sockaddr_in *)ifa->ifa_addr; uint32_t addr = ntohl(sa->sin_addr.s_addr); - uint32_t top8 = addr >> 24; + uint32_t top8 = addr >> 24; uint32_t top16 = addr >> 16; - if (top8 == 10 || // 10.0.0.0/8 - (top16 >= 0xAC10 && top16 <= 0xAC1F) || // 172.16.0.0/12 - top16 == 0xC0A8 || // 192.168.0.0/16 - (top16 >= 0x6440 && top16 <= 0x647F)) { // 100.64.0.0/10 (Tailscale) + if (top8 == 10 || // 10.0.0.0/8 + (top16 >= 0xAC10 && top16 <= 0xAC1F) || // 172.16.0.0/12 + top16 == 0xC0A8 || // 192.168.0.0/16 + (top16 >= 0x6440 && top16 <= 0x647F)) { // 100.64.0.0/10 (Tailscale) inet_ntop(AF_INET, &sa->sin_addr, buf, (socklen_t)len); found = buf; } @@ -156,7 +164,7 @@ static void mob_write_diag(const char *docs_dir, const char *name, const char *i return found; } -void mob_start_beam(const char* app_module) { +void mob_start_beam(const char *app_module) { mob_set_startup_phase("Setting up BEAM environment…"); // Resolve Documents dir early for diagnostics. @@ -171,8 +179,8 @@ void mob_start_beam(const char* app_module) { // reads MOB_SIM_RUNTIME_DIR (set by mix mob.deploy via simctl) with a /tmp // fallback for legacy projects. #ifdef MOB_BUNDLE_OTP - NSString *bundle_otp = [[[NSBundle mainBundle] bundlePath] - stringByAppendingPathComponent:@"otp"]; + NSString *bundle_otp = + [[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:@"otp"]; const char *otp_root = [bundle_otp UTF8String]; const char *erts_vsn = ERTS_VSN; const char *otp_release = OTP_RELEASE; @@ -186,19 +194,19 @@ void mob_start_beam(const char* app_module) { // Compose dynamic paths that depend on otp_root. static char bindir[512], elixir_dir[512], logger_dir[512], boot_path[512]; - snprintf(bindir, sizeof(bindir), "%s/%s/bin", otp_root, erts_vsn); - snprintf(elixir_dir, sizeof(elixir_dir), "%s/lib/elixir/ebin", otp_root); - snprintf(logger_dir, sizeof(logger_dir), "%s/lib/logger/ebin", otp_root); - snprintf(boot_path, sizeof(boot_path), "%s/releases/%s/start_clean", otp_root, otp_release); + snprintf(bindir, sizeof(bindir), "%s/%s/bin", otp_root, erts_vsn); + snprintf(elixir_dir, sizeof(elixir_dir), "%s/lib/elixir/ebin", otp_root); + snprintf(logger_dir, sizeof(logger_dir), "%s/lib/logger/ebin", otp_root); + snprintf(boot_path, sizeof(boot_path), "%s/releases/%s/start_clean", otp_root, otp_release); mob_write_diag(docs_dir, "mob_diag_c_paths.txt", bindir); NSLog(@"[MobBeam] otp_root=%s erts=%s release=%s", otp_root, erts_vsn, otp_release); - setenv("BINDIR", bindir, 1); - setenv("ROOTDIR", otp_root, 1); + setenv("BINDIR", bindir, 1); + setenv("ROOTDIR", otp_root, 1); setenv("PROGNAME", "erl", 1); - setenv("EMU", "beam", 1); - setenv("HOME", "/tmp", 1); + setenv("EMU", "beam", 1); + setenv("HOME", "/tmp", 1); // Set MOB_DATA_DIR to the app's Documents directory — persistent storage // accessible to the app and backed up by iCloud. Used by the generated Repo // module to determine where to place the SQLite database file. @@ -238,22 +246,47 @@ void mob_start_beam(const char* app_module) { // would return the Mac's USB IP (wrong). Always use 127.0.0.1 on simulator. #ifdef MOB_BUNDLE_OTP // Physical device: WiFi/LAN → USB link-local → loopback fallback. + // Two physical devices on different LAN IPs already get distinct node + // names via the @host_ip part. MOB_NODE_SUFFIX is honored here too, + // for scripted scenarios where multiple builds of the same app run on + // distinct devices behind one IP (rare, but the override is harmless + // when unused). static char lan_ip_buf[64], link_local_buf[64]; const char *lan_ip = find_lan_ip(lan_ip_buf, sizeof(lan_ip_buf)); - const char *ll_ip = lan_ip ? NULL : find_link_local_ip(link_local_buf, sizeof(link_local_buf)); + const char *ll_ip = lan_ip ? NULL : find_link_local_ip(link_local_buf, sizeof(link_local_buf)); const char *host_ip = lan_ip ? lan_ip : (ll_ip ? ll_ip : "127.0.0.1"); static char eval_expr[280], node_name[128], beams_dir[512]; snprintf(eval_expr, sizeof(eval_expr), "%s:start().", app_module); - snprintf(node_name, sizeof(node_name), "%s_ios@%s", app_module, host_ip); + const char *phys_suffix = getenv("MOB_NODE_SUFFIX"); + if (phys_suffix && phys_suffix[0]) { + snprintf(node_name, sizeof(node_name), "%s_ios_%s@%s", app_module, phys_suffix, host_ip); + } else { + snprintf(node_name, sizeof(node_name), "%s_ios@%s", app_module, host_ip); + } #else - // Simulator: use 127.0.0.1 but include a short UDID suffix so concurrent + // Simulator: use 127.0.0.1 but include a short suffix so concurrent // simulators get unique node names and don't conflict in Mac's EPMD. - // SIMULATOR_UDID is set automatically by the iOS simulator runtime. + // + // Node-suffix resolution order (mirrors Android's Mob.Dist behaviour): + // 1. MOB_NODE_SUFFIX env var — explicit override, e.g. set by + // `mix mob.deploy --node-suffix foo` (forwarded to simctl via + // SIMCTL_CHILD_MOB_NODE_SUFFIX). Use this when the auto-derived + // UDID suffix isn't what you want (running two distinct apps in + // one sim, scripting a specific naming scheme, etc.). + // 2. SIMULATOR_UDID-derived hex (first 8 hex chars). Set + // automatically by the iOS simulator runtime; gives every sim a + // unique suffix out of the box so concurrent sims don't collide. + // 3. No suffix — bare `<app>_ios@127.0.0.1`. Only hit when neither + // env var is set. const char *host_ip = "127.0.0.1"; + const char *node_suffix_override = getenv("MOB_NODE_SUFFIX"); const char *sim_udid = getenv("SIMULATOR_UDID"); - static char sim_short[9]; + static char sim_short[64]; sim_short[0] = '\0'; - if (sim_udid) { + if (node_suffix_override && node_suffix_override[0]) { + strncpy(sim_short, node_suffix_override, sizeof(sim_short) - 1); + sim_short[sizeof(sim_short) - 1] = '\0'; + } else if (sim_udid) { int n = 0; for (int i = 0; sim_udid[i] && n < 8; i++) { unsigned char c = (unsigned char)sim_udid[i]; @@ -281,7 +314,7 @@ void mob_start_beam(const char* app_module) { // If that directory exists, prefer it over the in-bundle copy. static char docs_beams[512]; snprintf(docs_beams, sizeof(docs_beams), "%s/otp/%s", docs_dir, app_module); - if ([[NSFileManager defaultManager] fileExistsAtPath:@(docs_beams)]) { + if ([[NSFileManager defaultManager] fileExistsAtPath:@(docs_beams)]) { strlcpy(beams_dir, docs_beams, sizeof(beams_dir)); } mob_write_diag(docs_dir, "mob_diag_beams_dir.txt", beams_dir); @@ -302,15 +335,22 @@ void mob_start_beam(const char* app_module) { // Compile-time default BEAM tuning flags. // Overridden at runtime if beams_dir/mob_beam_flags exists // (written by `mix mob.deploy --schedulers N` or `--beam-flags "..."`). - static const char* s_default_flags[] = { - "-S", "1:1", "-SDcpu", "1:1", "-SDio", "1", "-A", "1", "-sbwt", "none", - NULL - }; + // + // `-MIscs 128` sets the literal super-carrier to 128 MB. On iOS the runtime + // can't reserve the OTP default 1 GB literal area (ERTS_LITERAL_VIRTUAL_AREA_SIZE) + // and falls back to ~10 MB. A large app — e.g. an embedded Livebook — plus a + // notebook's Mix.install fills that and the VM aborts with + // "literal_alloc: Cannot allocate N bytes (of type \"literal\")". 128 MB is a + // virtual (MAP_NORESERVE) reservation, so it costs ~nothing until used and iOS + // accepts it where 1 GB fails. iOS-only: Android keeps the normal large + // carrier (don't shrink it here). + static const char *s_default_flags[] = {"-S", "1:1", "-SDcpu", "1:1", "-SDio", "1", "-A", + "1", "-sbwt", "none", "-MIscs", "128", NULL}; // Runtime override: read whitespace-separated flags from beams_dir/mob_beam_flags. - static char s_flags_buf[512] = {0}; - static const char* s_runtime_flags[64] = {NULL}; - static int s_runtime_flag_count = 0; + static char s_flags_buf[512] = {0}; + static const char *s_runtime_flags[64] = {NULL}; + static int s_runtime_flag_count = 0; { char flags_path[640]; snprintf(flags_path, sizeof(flags_path), "%s/mob_beam_flags", beams_dir); @@ -322,59 +362,77 @@ void mob_start_beam(const char* app_module) { s_runtime_flag_count = 0; char *p = s_flags_buf; while (*p && s_runtime_flag_count < 63) { - while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++; - if (!*p) break; + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') + p++; + if (!*p) + break; s_runtime_flags[s_runtime_flag_count++] = p; - while (*p && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') p++; - if (*p) *p++ = '\0'; + while (*p && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') + p++; + if (*p) + *p++ = '\0'; } s_runtime_flags[s_runtime_flag_count] = NULL; NSLog(@"[MobBeam] loaded %d runtime flags from %s", s_runtime_flag_count, flags_path); } } - const char** selected_flags = (s_runtime_flag_count > 0) - ? s_runtime_flags - : s_default_flags; + const char **selected_flags = (s_runtime_flag_count > 0) ? s_runtime_flags : s_default_flags; - static const char* args[128]; + static const char *args[128]; int ac = 0; args[ac++] = "beam"; - for (int i = 0; selected_flags[i]; i++) args[ac++] = selected_flags[i]; - // Cap the BEAM's memory super carrier to 10MB on physical iOS devices. - // The default 1GB virtual reservation is rejected by iOS on real hardware - // (not on simulator where the Mac's VM handles it). Without this the BEAM - // crashes immediately during startup on any physical iOS device. -#ifdef MOB_BUNDLE_OTP - args[ac++] = "-MIscs"; args[ac++] = "10"; -#endif + for (int i = 0; selected_flags[i]; i++) + args[ac++] = selected_flags[i]; + // NOTE: the literal super-carrier size is set via -MIscs in selected_flags + // above (128 MB default; see s_default_flags). iOS rejects the OTP default + // 1 GB reservation but accepts 128 MB. (A hardcoded "-MIscs 10" used to be + // appended HERE and silently overrode the 128 above — allocator flags are + // last-wins — capping the literal area at 10 MB, which crashed Mix.install of + // any sizable dep once an embedded Livebook had filled it.) args[ac++] = "--"; - args[ac++] = "-root"; args[ac++] = otp_root; - args[ac++] = "-bindir"; args[ac++] = bindir; - args[ac++] = "-progname"; args[ac++] = "erl"; + args[ac++] = "-root"; + args[ac++] = otp_root; + args[ac++] = "-bindir"; + args[ac++] = bindir; + args[ac++] = "-progname"; + args[ac++] = "erl"; args[ac++] = "--"; #ifndef MOB_RELEASE // Distribution flags. Omitted for App Store builds — see MOB_RELEASE // notes at the top of this file. - args[ac++] = "-name"; args[ac++] = node_name; - args[ac++] = "-setcookie"; args[ac++] = "mob_secret"; - args[ac++] = "-kernel"; args[ac++] = "inet_dist_listen_min"; args[ac++] = dist_port_min; - args[ac++] = "-kernel"; args[ac++] = "inet_dist_listen_max"; args[ac++] = dist_port_max; + args[ac++] = "-name"; + args[ac++] = node_name; + args[ac++] = "-setcookie"; + args[ac++] = "mob_secret"; + args[ac++] = "-kernel"; + args[ac++] = "inet_dist_listen_min"; + args[ac++] = dist_port_min; + args[ac++] = "-kernel"; + args[ac++] = "inet_dist_listen_max"; + args[ac++] = dist_port_max; #else // Mark MOB_RELEASE in env so Mob.Dist.ensure_started/1 short-circuits // before trying Node.start (which would fail without -name anyway, but // the env var lets app code probe for release mode without parsing // erl args). setenv("MOB_RELEASE", "1", 1); - (void)dist_port_min; (void)dist_port_max; (void)node_name; + (void)dist_port_min; + (void)dist_port_max; + (void)node_name; #endif args[ac++] = "-noshell"; args[ac++] = "-noinput"; - args[ac++] = "-boot"; args[ac++] = boot_path; - args[ac++] = "-pa"; args[ac++] = elixir_dir; - args[ac++] = "-pa"; args[ac++] = logger_dir; - args[ac++] = "-pa"; args[ac++] = beams_dir; - args[ac++] = "-eval"; args[ac++] = eval_expr; + args[ac++] = "-boot"; + args[ac++] = boot_path; + args[ac++] = "-pa"; + args[ac++] = elixir_dir; + args[ac++] = "-pa"; + args[ac++] = logger_dir; + args[ac++] = "-pa"; + args[ac++] = beams_dir; + args[ac++] = "-eval"; + args[ac++] = eval_expr; args[ac] = NULL; NSLog(@"[MobBeam] mob_start_beam: starting BEAM module=%s argc=%d", app_module, ac); mob_set_startup_phase("Starting BEAM…"); @@ -398,11 +456,11 @@ void mob_start_beam(const char* app_module) { pthread_t epmd_t; pthread_create(&epmd_t, NULL, epmd_thread, NULL); pthread_detach(epmd_t); - usleep(300000); // 300ms — give EPMD time to bind port 4369 + usleep(300000); // 300ms — give EPMD time to bind port 4369 #endif - void erl_start(int, char**); - erl_start(ac, (char**)args); + void erl_start(int, char **); + erl_start(ac, (char **)args); mob_write_diag(docs_dir, "mob_diag_e_erl_exited.txt", "erl_start returned"); mob_set_startup_error("BEAM exited unexpectedly — check Documents/mob_erl_crash.dump"); NSLog(@"[MobBeam] mob_start_beam: erl_start returned (unexpected)"); diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 40a9f38d..23ab1d64 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -10,32 +10,35 @@ #import <Foundation/Foundation.h> #import <UIKit/UIKit.h> +#include <arpa/inet.h> +#include <dlfcn.h> #include <mach/mach_time.h> +#include <netdb.h> #include <objc/message.h> #include <objc/runtime.h> -#include <dlfcn.h> +#include <stdatomic.h> +#include <sys/socket.h> // dlopen/dlsym are marked unavailable in iOS SDK headers but exist at runtime // in the iOS Simulator (macOS). Declare prototypes directly to bypass the header // restriction. On a real device these will be NULL (weak symbols). #ifndef RTLD_DEFAULT #define RTLD_DEFAULT ((void *)-2L) -#define RTLD_LAZY 1 +#define RTLD_LAZY 1 #endif -extern void *dlopen(const char *path, int mode) __attribute__((weak)); +extern void *dlopen(const char *path, int mode) __attribute__((weak)); extern void *dlsym(void *handle, const char *symbol) __attribute__((weak)); extern char *dlerror(void) __attribute__((weak)); -#import <LocalAuthentication/LocalAuthentication.h> -#import <CoreLocation/CoreLocation.h> +#import "MobApp-Swift.h" +#import "MobNode.h" +#include "erl_nif.h" +#import <AVFoundation/AVFoundation.h> +#import <Accelerate/Accelerate.h> #import <CoreMotion/CoreMotion.h> +#import <Network/Network.h> #import <Photos/Photos.h> -#import <PhotosUI/PhotosUI.h> -#import <UserNotifications/UserNotifications.h> -#import <AVFoundation/AVFoundation.h> #import <UniformTypeIdentifiers/UniformTypeIdentifiers.h> +#import <UserNotifications/UserNotifications.h> #include <string.h> -#include "erl_nif.h" -#import "MobNode.h" -#import "MobApp-Swift.h" #define LOGI(...) NSLog(@"[MobNIF] " __VA_ARGS__) #define LOGE(...) NSLog(@"[MobNIF][ERROR] " __VA_ARGS__) @@ -44,12 +47,12 @@ // Implemented here rather than in mob_beam.m because this file is compiled with // -I $BUILD_DIR so it can import the Swift-generated MobApp-Swift.h header. -void mob_set_startup_phase(const char* phase) { +void mob_set_startup_phase(const char *phase) { NSLog(@"[MobBeam] startup: %s", phase); [MobViewModel.shared setStartupPhase:[NSString stringWithUTF8String:phase]]; } -void mob_set_startup_error(const char* error) { +void mob_set_startup_error(const char *error) { NSLog(@"[MobBeam] ERROR: %s", error); [MobViewModel.shared setStartupError:[NSString stringWithUTF8String:error]]; } @@ -60,46 +63,54 @@ void mob_set_startup_error(const char* error) { #define MAX_TAP_HANDLES 256 typedef struct { - ErlNifPid pid; - ErlNifEnv* tag_env; // persistent env owning tag; NULL when slot is free + ErlNifPid pid; + ErlNifEnv *tag_env; // persistent env owning tag; NULL when slot is free ERL_NIF_TERM tag; // ── Batch 5 throttle state — populated by mob_set_throttle_config ── - int throttle_ms; // 0 = no throttle (raw firing) - int debounce_ms; // 0 = no debounce - double delta_threshold; - int leading; // 1 = emit first event of burst - int trailing; // 1 = emit final event after debounce - uint64_t last_emit_ns; // mach_absolute_time of last successful emit - double last_x; // last emitted x (for delta check) - double last_y; // last emitted y - uint64_t seq; // monotonic counter per handle + int throttle_ms; // 0 = no throttle (raw firing) + int debounce_ms; // 0 = no debounce + double delta_threshold; + int leading; // 1 = emit first event of burst + int trailing; // 1 = emit final event after debounce + uint64_t last_emit_ns; // mach_absolute_time of last successful emit + double last_x; // last emitted x (for delta check) + double last_y; // last emitted y + uint64_t seq; // monotonic counter per handle } TapHandle; -static TapHandle tap_handles[MAX_TAP_HANDLES]; -static int tap_handle_next = 0; -static ErlNifMutex* tap_mutex = NULL; +// Double-buffered tap registry (see android/jni/mob_nif.zig for full rationale). +// `tap_handles`/`tap_handle_next` point at the ACTIVE table + its committed +// count — readers (mob_send_*) keep using them unchanged. A render builds into +// the INACTIVE table via register_tap (tap_build_count) and set_root swaps it in +// atomically under tap_mutex, so a concurrent high-frequency send (drag/scroll) +// never observes a half-rebuilt table. +static TapHandle tap_tables[2][MAX_TAP_HANDLES]; +static int tap_active = 0; +static TapHandle *tap_handles = tap_tables[0]; // active table (readers use this) +static int tap_handle_next = 0; // active committed count (readers' bound) +static int tap_build_count = 0; // cursor into the building table +static ErlNifMutex *tap_mutex = NULL; // Convert mach absolute time to nanoseconds (initialised once). static mach_timebase_info_data_t g_timebase = {0, 0}; static uint64_t mob_now_ns(void) { - if (g_timebase.denom == 0) mach_timebase_info(&g_timebase); + if (g_timebase.denom == 0) + mach_timebase_info(&g_timebase); return mach_absolute_time() * g_timebase.numer / g_timebase.denom; } // Set throttle config for a handle. Called from the prop deserialiser when // it sees a *_config sibling prop. Idempotent — safe to call multiple times. -static void mob_set_throttle_config(int handle, - int throttle_ms, int debounce_ms, - double delta_threshold, - int leading, int trailing) { +static void mob_set_throttle_config(int handle, int throttle_ms, int debounce_ms, + double delta_threshold, int leading, int trailing) { enif_mutex_lock(tap_mutex); if (handle >= 0 && handle < tap_handle_next && tap_handles[handle].tag_env) { - tap_handles[handle].throttle_ms = throttle_ms; - tap_handles[handle].debounce_ms = debounce_ms; + tap_handles[handle].throttle_ms = throttle_ms; + tap_handles[handle].debounce_ms = debounce_ms; tap_handles[handle].delta_threshold = delta_threshold; - tap_handles[handle].leading = leading; - tap_handles[handle].trailing = trailing; + tap_handles[handle].leading = leading; + tap_handles[handle].trailing = trailing; } enif_mutex_unlock(tap_mutex); } @@ -110,14 +121,14 @@ static void mob_set_throttle_config(int handle, // Defaults (when throttle/delta unset on a handle): use reasonable per-event // fallbacks so widgets that opt in without explicit config still get sane // gating. -static int mob_throttle_check(int handle, double x, double y, - int default_throttle_ms, double default_delta) { +static int mob_throttle_check(int handle, double x, double y, int default_throttle_ms, + double default_delta) { enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return 0; } - TapHandle* h = &tap_handles[handle]; + TapHandle *h = &tap_handles[handle]; int throttle_ms = h->throttle_ms ? h->throttle_ms : default_throttle_ms; double delta_threshold = h->delta_threshold > 0 ? h->delta_threshold : default_delta; @@ -151,18 +162,18 @@ static int mob_throttle_check(int handle, double x, double y, } // Read current seq + ts for a handle (for envelope construction). -static void mob_handle_meta(int handle, uint64_t* seq_out, uint64_t* ts_out) { +static void mob_handle_meta(int handle, uint64_t *seq_out, uint64_t *ts_out) { enif_mutex_lock(tap_mutex); if (handle >= 0 && handle < tap_handle_next && tap_handles[handle].tag_env) { *seq_out = tap_handles[handle].seq; - *ts_out = mob_now_ns() / 1000000ULL; // ms since boot + *ts_out = mob_now_ns() / 1000000ULL; // ms since boot } else { *seq_out = 0; - *ts_out = 0; + *ts_out = 0; } enif_mutex_unlock(tap_mutex); } -static char g_transition[16] = "none"; +static char g_transition[16] = "none"; // Called from node onTap blocks — routes tap to BEAM via enif_send. static void mob_send_tap(int handle) { @@ -171,14 +182,13 @@ static void mob_send_tap(int handle) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; - ERL_NIF_TERM tag = tap_handles[handle].tag; + ErlNifPid pid = tap_handles[handle].pid; + ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(msg_env, - enif_make_atom(msg_env, "tap"), - enif_make_copy(msg_env, tag)); + ErlNifEnv *msg_env = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple2(msg_env, enif_make_atom(msg_env, "tap"), enif_make_copy(msg_env, tag)); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } @@ -186,43 +196,50 @@ static void mob_send_tap(int handle) { // ── Focus / blur / submit senders ──────────────────────────────────────────── // Called from MobTextField SwiftUI view when focus state changes or return key tapped. -static void mob_send_event(int handle, const char* atom) { +static void mob_send_event(int handle, const char *atom) { enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(msg_env, - enif_make_atom(msg_env, atom), - enif_make_copy(msg_env, tag)); + ErlNifEnv *msg_env = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple2(msg_env, enif_make_atom(msg_env, atom), enif_make_copy(msg_env, tag)); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } -static void mob_send_focus(int handle) { mob_send_event(handle, "focus"); } -static void mob_send_blur(int handle) { mob_send_event(handle, "blur"); } -static void mob_send_submit(int handle) { mob_send_event(handle, "submit"); } -static void mob_send_select(int handle) { mob_send_event(handle, "select"); } +static void mob_send_focus(int handle) { + mob_send_event(handle, "focus"); +} +static void mob_send_blur(int handle) { + mob_send_event(handle, "blur"); +} +static void mob_send_submit(int handle) { + mob_send_event(handle, "submit"); +} +static void mob_send_select(int handle) { + mob_send_event(handle, "select"); +} // IME composition. Sends {compose, tag, %{text: ..., phase: ...}} where // phase is one of began/updating/committed/cancelled. Called from the // text-input layer when marked-text state changes. -static void mob_send_compose(int handle, const char* text, const char* phase) { +static void mob_send_compose(int handle, const char *text, const char *phase) { enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); + ErlNifEnv *msg_env = enif_alloc_env(); ERL_NIF_TERM keys[2] = { enif_make_atom(msg_env, "text"), enif_make_atom(msg_env, "phase"), @@ -233,10 +250,8 @@ static void mob_send_compose(int handle, const char* text, const char* phase) { }; ERL_NIF_TERM payload; enif_make_map_from_arrays(msg_env, keys, vals, 2, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "compose"), - enif_make_copy(msg_env, tag), - payload); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, enif_make_atom(msg_env, "compose"), + enif_make_copy(msg_env, tag), payload); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } @@ -245,29 +260,40 @@ static void mob_send_compose(int handle, const char* text, const char* phase) { // Each fires {atom, tag} just like tap. SwiftUI converts gesture recognizers // into onLongPress/onDoubleTap/onSwipe* callbacks on the MobNode. -static void mob_send_long_press(int handle) { mob_send_event(handle, "long_press"); } -static void mob_send_double_tap(int handle) { mob_send_event(handle, "double_tap"); } -static void mob_send_swipe_left(int handle) { mob_send_event(handle, "swipe_left"); } -static void mob_send_swipe_right(int handle) { mob_send_event(handle, "swipe_right"); } -static void mob_send_swipe_up(int handle) { mob_send_event(handle, "swipe_up"); } -static void mob_send_swipe_down(int handle) { mob_send_event(handle, "swipe_down"); } +static void mob_send_long_press(int handle) { + mob_send_event(handle, "long_press"); +} +static void mob_send_double_tap(int handle) { + mob_send_event(handle, "double_tap"); +} +static void mob_send_swipe_left(int handle) { + mob_send_event(handle, "swipe_left"); +} +static void mob_send_swipe_right(int handle) { + mob_send_event(handle, "swipe_right"); +} +static void mob_send_swipe_up(int handle) { + mob_send_event(handle, "swipe_up"); +} +static void mob_send_swipe_down(int handle) { + mob_send_event(handle, "swipe_down"); +} // Generic on_swipe with direction: emits {swipe, tag, direction} where direction is an atom. -static void mob_send_swipe_with_direction(int handle, const char* direction) { +static void mob_send_swipe_with_direction(int handle, const char *direction) { enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "swipe"), - enif_make_copy(msg_env, tag), - enif_make_atom(msg_env, direction)); + ErlNifEnv *msg_env = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple3(msg_env, enif_make_atom(msg_env, "swipe"), enif_make_copy(msg_env, tag), + enif_make_atom(msg_env, direction)); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } @@ -286,33 +312,20 @@ static void mob_send_swipe_with_direction(int handle, const char* direction) { // :pointer_move 33 ms / 4 px // Build a payload map: %{x, y, dx, dy, velocity_x, velocity_y, phase, ts, seq} -static ERL_NIF_TERM mob_build_scroll_payload(ErlNifEnv* env, - double x, double y, - double dx, double dy, - double vx, double vy, - const char* phase, - uint64_t ts, uint64_t seq) { +static ERL_NIF_TERM mob_build_scroll_payload(ErlNifEnv *env, double x, double y, double dx, + double dy, double vx, double vy, const char *phase, + uint64_t ts, uint64_t seq) { ERL_NIF_TERM keys[9] = { - enif_make_atom(env, "x"), - enif_make_atom(env, "y"), - enif_make_atom(env, "dx"), - enif_make_atom(env, "dy"), - enif_make_atom(env, "velocity_x"), - enif_make_atom(env, "velocity_y"), - enif_make_atom(env, "phase"), - enif_make_atom(env, "ts"), + enif_make_atom(env, "x"), enif_make_atom(env, "y"), + enif_make_atom(env, "dx"), enif_make_atom(env, "dy"), + enif_make_atom(env, "velocity_x"), enif_make_atom(env, "velocity_y"), + enif_make_atom(env, "phase"), enif_make_atom(env, "ts"), enif_make_atom(env, "seq"), }; ERL_NIF_TERM vals[9] = { - enif_make_double(env, x), - enif_make_double(env, y), - enif_make_double(env, dx), - enif_make_double(env, dy), - enif_make_double(env, vx), - enif_make_double(env, vy), - enif_make_atom(env, phase), - enif_make_uint64(env, ts), - enif_make_uint64(env, seq), + enif_make_double(env, x), enif_make_double(env, y), enif_make_double(env, dx), + enif_make_double(env, dy), enif_make_double(env, vx), enif_make_double(env, vy), + enif_make_atom(env, phase), enif_make_uint64(env, ts), enif_make_uint64(env, seq), }; ERL_NIF_TERM map; enif_make_map_from_arrays(env, keys, vals, 9, &map); @@ -321,193 +334,194 @@ static ERL_NIF_TERM mob_build_scroll_payload(ErlNifEnv* env, // Send a throttled high-frequency event. Phase is one of: // "began" | "dragging" | "decelerating" | "ended" -static void mob_send_scroll(int handle, - double x, double y, - double dx, double dy, - double vx, double vy, - const char* phase) { +static void mob_send_scroll(int handle, double x, double y, double dx, double dy, double vx, + double vy, const char *phase) { // Force-emit for began/ended phases regardless of throttle (semantic // boundaries are too important to drop). - int is_phase_boundary = (strcmp(phase, "began") == 0) || - (strcmp(phase, "ended") == 0); + int is_phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!is_phase_boundary && !mob_throttle_check(handle, x, y, 33, 1.0)) return; + if (!is_phase_boundary && !mob_throttle_check(handle, x, y, 33, 1.0)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; - uint64_t seq = tap_handles[handle].seq; + uint64_t seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); uint64_t ts = mob_now_ns() / 1000000ULL; - ErlNifEnv* msg_env = enif_alloc_env(); + ErlNifEnv *msg_env = enif_alloc_env(); ERL_NIF_TERM payload = mob_build_scroll_payload(msg_env, x, y, dx, dy, vx, vy, phase, ts, seq); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "scroll"), - enif_make_copy(msg_env, tag), - payload); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, enif_make_atom(msg_env, "scroll"), + enif_make_copy(msg_env, tag), payload); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } -static void mob_send_drag(int handle, - double x, double y, - double dx, double dy, - const char* phase) { - int is_phase_boundary = (strcmp(phase, "began") == 0) || - (strcmp(phase, "ended") == 0); - if (!is_phase_boundary && !mob_throttle_check(handle, x, y, 16, 1.0)) return; +static void mob_send_drag(int handle, double x, double y, double dx, double dy, const char *phase) { + int is_phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); + if (!is_phase_boundary && !mob_throttle_check(handle, x, y, 16, 1.0)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; - uint64_t seq = tap_handles[handle].seq; + uint64_t seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); uint64_t ts = mob_now_ns() / 1000000ULL; - ErlNifEnv* msg_env = enif_alloc_env(); + ErlNifEnv *msg_env = enif_alloc_env(); // Drag payload: %{x, y, dx, dy, phase, ts, seq} ERL_NIF_TERM keys[7] = { - enif_make_atom(msg_env, "x"), enif_make_atom(msg_env, "y"), - enif_make_atom(msg_env, "dx"), enif_make_atom(msg_env, "dy"), - enif_make_atom(msg_env, "phase"), - enif_make_atom(msg_env, "ts"), enif_make_atom(msg_env, "seq"), + enif_make_atom(msg_env, "x"), enif_make_atom(msg_env, "y"), + enif_make_atom(msg_env, "dx"), enif_make_atom(msg_env, "dy"), + enif_make_atom(msg_env, "phase"), enif_make_atom(msg_env, "ts"), + enif_make_atom(msg_env, "seq"), }; ERL_NIF_TERM vals[7] = { - enif_make_double(msg_env, x), enif_make_double(msg_env, y), - enif_make_double(msg_env, dx), enif_make_double(msg_env, dy), - enif_make_atom(msg_env, phase), - enif_make_uint64(msg_env, ts), enif_make_uint64(msg_env, seq), + enif_make_double(msg_env, x), enif_make_double(msg_env, y), + enif_make_double(msg_env, dx), enif_make_double(msg_env, dy), + enif_make_atom(msg_env, phase), enif_make_uint64(msg_env, ts), + enif_make_uint64(msg_env, seq), }; ERL_NIF_TERM payload; enif_make_map_from_arrays(msg_env, keys, vals, 7, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "drag"), - enif_make_copy(msg_env, tag), - payload); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, enif_make_atom(msg_env, "drag"), + enif_make_copy(msg_env, tag), payload); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } -static void mob_send_pinch(int handle, double scale, double velocity, const char* phase) { +static void mob_send_pinch(int handle, double scale, double velocity, const char *phase) { int is_phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!is_phase_boundary && !mob_throttle_check(handle, scale, 0, 16, 0.01)) return; + if (!is_phase_boundary && !mob_throttle_check(handle, scale, 0, 16, 0.01)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; - uint64_t seq = tap_handles[handle].seq; + uint64_t seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); uint64_t ts = mob_now_ns() / 1000000ULL; - ErlNifEnv* msg_env = enif_alloc_env(); + ErlNifEnv *msg_env = enif_alloc_env(); ERL_NIF_TERM keys[5] = { enif_make_atom(msg_env, "scale"), enif_make_atom(msg_env, "velocity"), - enif_make_atom(msg_env, "phase"), - enif_make_atom(msg_env, "ts"), enif_make_atom(msg_env, "seq"), + enif_make_atom(msg_env, "phase"), enif_make_atom(msg_env, "ts"), + enif_make_atom(msg_env, "seq"), }; ERL_NIF_TERM vals[5] = { enif_make_double(msg_env, scale), enif_make_double(msg_env, velocity), - enif_make_atom(msg_env, phase), - enif_make_uint64(msg_env, ts), enif_make_uint64(msg_env, seq), + enif_make_atom(msg_env, phase), enif_make_uint64(msg_env, ts), + enif_make_uint64(msg_env, seq), }; ERL_NIF_TERM payload; enif_make_map_from_arrays(msg_env, keys, vals, 5, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "pinch"), - enif_make_copy(msg_env, tag), - payload); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, enif_make_atom(msg_env, "pinch"), + enif_make_copy(msg_env, tag), payload); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } -static void mob_send_rotate(int handle, double degrees, double velocity, const char* phase) { +static void mob_send_rotate(int handle, double degrees, double velocity, const char *phase) { int is_phase_boundary = (strcmp(phase, "began") == 0) || (strcmp(phase, "ended") == 0); - if (!is_phase_boundary && !mob_throttle_check(handle, degrees, 0, 16, 1.0)) return; + if (!is_phase_boundary && !mob_throttle_check(handle, degrees, 0, 16, 1.0)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; - uint64_t seq = tap_handles[handle].seq; + uint64_t seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); uint64_t ts = mob_now_ns() / 1000000ULL; - ErlNifEnv* msg_env = enif_alloc_env(); + ErlNifEnv *msg_env = enif_alloc_env(); ERL_NIF_TERM keys[5] = { enif_make_atom(msg_env, "degrees"), enif_make_atom(msg_env, "velocity"), - enif_make_atom(msg_env, "phase"), - enif_make_atom(msg_env, "ts"), enif_make_atom(msg_env, "seq"), + enif_make_atom(msg_env, "phase"), enif_make_atom(msg_env, "ts"), + enif_make_atom(msg_env, "seq"), }; ERL_NIF_TERM vals[5] = { enif_make_double(msg_env, degrees), enif_make_double(msg_env, velocity), - enif_make_atom(msg_env, phase), - enif_make_uint64(msg_env, ts), enif_make_uint64(msg_env, seq), + enif_make_atom(msg_env, phase), enif_make_uint64(msg_env, ts), + enif_make_uint64(msg_env, seq), }; ERL_NIF_TERM payload; enif_make_map_from_arrays(msg_env, keys, vals, 5, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "rotate"), - enif_make_copy(msg_env, tag), - payload); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, enif_make_atom(msg_env, "rotate"), + enif_make_copy(msg_env, tag), payload); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } static void mob_send_pointer_move(int handle, double x, double y) { - if (!mob_throttle_check(handle, x, y, 33, 4.0)) return; + if (!mob_throttle_check(handle, x, y, 33, 4.0)) + return; enif_mutex_lock(tap_mutex); if (handle < 0 || handle >= tap_handle_next || !tap_handles[handle].tag_env) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; - uint64_t seq = tap_handles[handle].seq; + uint64_t seq = tap_handles[handle].seq; enif_mutex_unlock(tap_mutex); uint64_t ts = mob_now_ns() / 1000000ULL; - ErlNifEnv* msg_env = enif_alloc_env(); + ErlNifEnv *msg_env = enif_alloc_env(); ERL_NIF_TERM keys[4] = { - enif_make_atom(msg_env, "x"), enif_make_atom(msg_env, "y"), - enif_make_atom(msg_env, "ts"), enif_make_atom(msg_env, "seq"), + enif_make_atom(msg_env, "x"), + enif_make_atom(msg_env, "y"), + enif_make_atom(msg_env, "ts"), + enif_make_atom(msg_env, "seq"), }; ERL_NIF_TERM vals[4] = { - enif_make_double(msg_env, x), enif_make_double(msg_env, y), - enif_make_uint64(msg_env, ts), enif_make_uint64(msg_env, seq), + enif_make_double(msg_env, x), + enif_make_double(msg_env, y), + enif_make_uint64(msg_env, ts), + enif_make_uint64(msg_env, seq), }; ERL_NIF_TERM payload; enif_make_map_from_arrays(msg_env, keys, vals, 4, &payload); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "pointer_move"), - enif_make_copy(msg_env, tag), - payload); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, enif_make_atom(msg_env, "pointer_move"), + enif_make_copy(msg_env, tag), payload); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } // ── Batch 5 Tier 2 senders — semantic single-fire scroll events ───────────── -static void mob_send_scroll_began(int handle) { mob_send_event(handle, "scroll_began"); } -static void mob_send_scroll_ended(int handle) { mob_send_event(handle, "scroll_ended"); } -static void mob_send_scroll_settled(int handle) { mob_send_event(handle, "scroll_settled"); } -static void mob_send_top_reached(int handle) { mob_send_event(handle, "top_reached"); } -static void mob_send_scrolled_past(int handle) { mob_send_event(handle, "scrolled_past"); } +static void mob_send_scroll_began(int handle) { + mob_send_event(handle, "scroll_began"); +} +static void mob_send_scroll_ended(int handle) { + mob_send_event(handle, "scroll_ended"); +} +static void mob_send_scroll_settled(int handle) { + mob_send_event(handle, "scroll_settled"); +} +static void mob_send_top_reached(int handle) { + mob_send_event(handle, "top_reached"); +} +static void mob_send_scrolled_past(int handle) { + mob_send_event(handle, "scrolled_past"); +} // ── Back gesture sender ─────────────────────────────────────────────────────── // Called from MobHostingController when the left-edge-pan gesture fires. @@ -515,12 +529,11 @@ static void mob_send_pointer_move(int handle, double x, double y) { // Non-static so Swift can call it via the bridging header. void mob_handle_back(void) { - ErlNifEnv* env = enif_alloc_env(); + ErlNifEnv *env = enif_alloc_env(); ErlNifPid pid; if (enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { - ERL_NIF_TERM msg = enif_make_tuple2(env, - enif_make_atom(env, "mob"), - enif_make_atom(env, "back")); + ERL_NIF_TERM msg = + enif_make_tuple2(env, enif_make_atom(env, "mob"), enif_make_atom(env, "back")); enif_send(NULL, &pid, env, msg); } enif_free_env(env); @@ -535,21 +548,20 @@ static void mob_send_change(int handle, ERL_NIF_TERM value_term) { enif_mutex_unlock(tap_mutex); return; } - ErlNifPid pid = tap_handles[handle].pid; + ErlNifPid pid = tap_handles[handle].pid; ERL_NIF_TERM tag = tap_handles[handle].tag; enif_mutex_unlock(tap_mutex); - ErlNifEnv* msg_env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(msg_env, - enif_make_atom(msg_env, "change"), - enif_make_copy(msg_env, tag), - enif_make_copy(msg_env, value_term)); + ErlNifEnv *msg_env = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple3(msg_env, enif_make_atom(msg_env, "change"), enif_make_copy(msg_env, tag), + enif_make_copy(msg_env, value_term)); enif_send(NULL, &pid, msg_env, msg); enif_free_env(msg_env); } -static void mob_send_change_str(int handle, const char* utf8) { - ErlNifEnv* tmp = enif_alloc_env(); +static void mob_send_change_str(int handle, const char *utf8) { + ErlNifEnv *tmp = enif_alloc_env(); ErlNifBinary bin; size_t len = strlen(utf8); enif_alloc_binary(len, &bin); @@ -560,14 +572,14 @@ static void mob_send_change_str(int handle, const char* utf8) { } static void mob_send_change_bool(int handle, int bool_val) { - ErlNifEnv* tmp = enif_alloc_env(); + ErlNifEnv *tmp = enif_alloc_env(); ERL_NIF_TERM term = enif_make_atom(tmp, bool_val ? "true" : "false"); mob_send_change(handle, term); enif_free_env(tmp); } static void mob_send_change_float(int handle, double value) { - ErlNifEnv* tmp = enif_alloc_env(); + ErlNifEnv *tmp = enif_alloc_env(); ERL_NIF_TERM term = enif_make_double(tmp, value); mob_send_change(handle, term); enif_free_env(tmp); @@ -575,47 +587,71 @@ static void mob_send_change_float(int handle, double value) { // ── JSON → MobNode parser ───────────────────────────────────────────────────── -static UIColor* color_from_argb(long argb) { +static UIColor *color_from_argb(long argb) { CGFloat a = ((argb >> 24) & 0xFF) / 255.0; CGFloat r = ((argb >> 16) & 0xFF) / 255.0; - CGFloat g = ((argb >> 8) & 0xFF) / 255.0; - CGFloat b = ((argb >> 0) & 0xFF) / 255.0; + CGFloat g = ((argb >> 8) & 0xFF) / 255.0; + CGFloat b = ((argb >> 0) & 0xFF) / 255.0; return [UIColor colorWithRed:r green:g blue:b alpha:a]; } -static MobNode* mob_node_from_dict(NSDictionary* dict) { - if (![dict isKindOfClass:[NSDictionary class]]) return nil; - - MobNode* node = [[MobNode alloc] init]; - - NSString* type = dict[@"type"]; - if ([type isEqualToString:@"column"]) node.nodeType = MobNodeTypeColumn; - else if ([type isEqualToString:@"row"]) node.nodeType = MobNodeTypeRow; - else if ([type isEqualToString:@"text"] || - [type isEqualToString:@"label"]) node.nodeType = MobNodeTypeLabel; - else if ([type isEqualToString:@"button"]) node.nodeType = MobNodeTypeButton; - else if ([type isEqualToString:@"scroll"]) node.nodeType = MobNodeTypeScroll; - else if ([type isEqualToString:@"box"]) node.nodeType = MobNodeTypeBox; - else if ([type isEqualToString:@"divider"]) node.nodeType = MobNodeTypeDivider; - else if ([type isEqualToString:@"spacer"]) node.nodeType = MobNodeTypeSpacer; - else if ([type isEqualToString:@"progress"]) node.nodeType = MobNodeTypeProgress; - else if ([type isEqualToString:@"text_field"]) node.nodeType = MobNodeTypeTextField; - else if ([type isEqualToString:@"toggle"]) node.nodeType = MobNodeTypeToggle; - else if ([type isEqualToString:@"slider"]) node.nodeType = MobNodeTypeSlider; - else if ([type isEqualToString:@"image"]) node.nodeType = MobNodeTypeImage; - else if ([type isEqualToString:@"lazy_list"]) node.nodeType = MobNodeTypeLazyList; - else if ([type isEqualToString:@"tab_bar"]) node.nodeType = MobNodeTypeTabBar; - else if ([type isEqualToString:@"video"]) node.nodeType = MobNodeTypeVideo; - else if ([type isEqualToString:@"camera_preview"]) node.nodeType = MobNodeTypeCameraPreview; - else if ([type isEqualToString:@"web_view"]) node.nodeType = MobNodeTypeWebView; - else if ([type isEqualToString:@"native_view"]) node.nodeType = MobNodeTypeNativeView; - else if ([type isEqualToString:@"icon"]) node.nodeType = MobNodeTypeIcon; - else if ([type isEqualToString:@"canvas"]) node.nodeType = MobNodeTypeCanvas; - - NSDictionary* props = dict[@"props"]; +static MobNode *mob_node_from_dict(NSDictionary *dict) { + if (![dict isKindOfClass:[NSDictionary class]]) + return nil; + + MobNode *node = [[MobNode alloc] init]; + + NSString *type = dict[@"type"]; + if ([type isEqualToString:@"column"]) + node.nodeType = MobNodeTypeColumn; + else if ([type isEqualToString:@"row"]) + node.nodeType = MobNodeTypeRow; + else if ([type isEqualToString:@"text"] || [type isEqualToString:@"label"]) + node.nodeType = MobNodeTypeLabel; + else if ([type isEqualToString:@"button"]) + node.nodeType = MobNodeTypeButton; + else if ([type isEqualToString:@"scroll"]) + node.nodeType = MobNodeTypeScroll; + else if ([type isEqualToString:@"box"]) + node.nodeType = MobNodeTypeBox; + else if ([type isEqualToString:@"divider"]) + node.nodeType = MobNodeTypeDivider; + else if ([type isEqualToString:@"spacer"]) + node.nodeType = MobNodeTypeSpacer; + else if ([type isEqualToString:@"progress"]) + node.nodeType = MobNodeTypeProgress; + else if ([type isEqualToString:@"text_field"]) + node.nodeType = MobNodeTypeTextField; + else if ([type isEqualToString:@"toggle"]) + node.nodeType = MobNodeTypeToggle; + else if ([type isEqualToString:@"slider"]) + node.nodeType = MobNodeTypeSlider; + else if ([type isEqualToString:@"image"]) + node.nodeType = MobNodeTypeImage; + else if ([type isEqualToString:@"lazy_list"]) + node.nodeType = MobNodeTypeLazyList; + else if ([type isEqualToString:@"tab_bar"]) + node.nodeType = MobNodeTypeTabBar; + else if ([type isEqualToString:@"video"]) + node.nodeType = MobNodeTypeVideo; + else if ([type isEqualToString:@"camera_preview"]) + node.nodeType = MobNodeTypeCameraPreview; + else if ([type isEqualToString:@"web_view"]) + node.nodeType = MobNodeTypeWebView; + else if ([type isEqualToString:@"native_view"]) + node.nodeType = MobNodeTypeNativeView; + else if ([type isEqualToString:@"icon"]) + node.nodeType = MobNodeTypeIcon; + else if ([type isEqualToString:@"canvas"]) + node.nodeType = MobNodeTypeCanvas; + else if ([type isEqualToString:@"gpu_view"]) + node.nodeType = MobNodeTypeGpuView; + + NSDictionary *props = dict[@"props"]; if ([props isKindOfClass:[NSDictionary class]]) { id text = props[@"text"]; - if (text) node.text = [text isKindOfClass:[NSString class]] ? text : [text description]; + if (text) + node.text = [text isKindOfClass:[NSString class]] ? text : [text description]; // For text_field, `value:` is the controlled-input prop name (matches // the React/SwiftUI convention used in app code and demos). Map it @@ -623,74 +659,96 @@ static void mob_send_change_float(int handle, double value) { // `text:` and `value:` are passed, `value:` wins. if (node.nodeType == MobNodeTypeTextField) { id valueText = props[@"value"]; - if (valueText) node.text = [valueText isKindOfClass:[NSString class]] - ? valueText - : [valueText description]; + if (valueText) + node.text = [valueText isKindOfClass:[NSString class]] ? valueText + : [valueText description]; } id padding = props[@"padding"]; - if (padding) node.padding = [padding doubleValue]; + if (padding) + node.padding = [padding doubleValue]; id paddingTop = props[@"padding_top"]; - if (paddingTop) node.paddingTop = [paddingTop doubleValue]; + if (paddingTop) + node.paddingTop = [paddingTop doubleValue]; id paddingRight = props[@"padding_right"]; - if (paddingRight) node.paddingRight = [paddingRight doubleValue]; + if (paddingRight) + node.paddingRight = [paddingRight doubleValue]; id paddingBottom = props[@"padding_bottom"]; - if (paddingBottom) node.paddingBottom = [paddingBottom doubleValue]; + if (paddingBottom) + node.paddingBottom = [paddingBottom doubleValue]; id paddingLeft = props[@"padding_left"]; - if (paddingLeft) node.paddingLeft = [paddingLeft doubleValue]; + if (paddingLeft) + node.paddingLeft = [paddingLeft doubleValue]; id textSize = props[@"text_size"]; - if (textSize) node.textSize = [textSize doubleValue]; + if (textSize) + node.textSize = [textSize doubleValue]; id fontFamily = props[@"font"]; - if ([fontFamily isKindOfClass:[NSString class]]) node.fontFamily = fontFamily; + if ([fontFamily isKindOfClass:[NSString class]]) + node.fontFamily = fontFamily; id fontWeight = props[@"font_weight"]; - if (fontWeight) node.fontWeight = [fontWeight description]; + if (fontWeight) + node.fontWeight = [fontWeight description]; id textAlign = props[@"text_align"]; - if (textAlign) node.textAlign = [textAlign description]; + if (textAlign) + node.textAlign = [textAlign description]; id italic = props[@"italic"]; - if (italic) node.italic = [italic boolValue]; + if (italic) + node.italic = [italic boolValue]; id lineHeight = props[@"line_height"]; - if (lineHeight) node.lineHeight = [lineHeight doubleValue]; + if (lineHeight) + node.lineHeight = [lineHeight doubleValue]; id letterSpacing = props[@"letter_spacing"]; - if (letterSpacing) node.letterSpacing = [letterSpacing doubleValue]; + if (letterSpacing) + node.letterSpacing = [letterSpacing doubleValue]; id tabDefs = props[@"tabs"]; - if ([tabDefs isKindOfClass:[NSArray class]]) node.tabDefs = tabDefs; + if ([tabDefs isKindOfClass:[NSArray class]]) + node.tabDefs = tabDefs; id activeTab = props[@"active"]; - if (activeTab) node.activeTab = [activeTab description]; + if (activeTab) + node.activeTab = [activeTab description]; id onTabSelect = props[@"on_tab_select"]; if (onTabSelect && [onTabSelect isKindOfClass:[NSNumber class]]) { int handle = [onTabSelect intValue]; - node.onTabSelect = ^(NSString* tabId) { - mob_send_change_str(handle, [tabId UTF8String]); + node.onTabSelect = ^(NSString *tabId) { + mob_send_change_str(handle, [tabId UTF8String]); }; } id bg = props[@"background"]; - if (bg) node.backgroundColor = color_from_argb((long)[bg longLongValue]); + if (bg) + node.backgroundColor = color_from_argb((long)[bg longLongValue]); id borderColor = props[@"border_color"]; - if (borderColor) node.borderColor = color_from_argb((long)[borderColor longLongValue]); + if (borderColor) + node.borderColor = color_from_argb((long)[borderColor longLongValue]); id borderWidth = props[@"border_width"]; - if (borderWidth) node.borderWidth = [borderWidth doubleValue]; + if (borderWidth) + node.borderWidth = [borderWidth doubleValue]; id textColor = props[@"text_color"]; - if (textColor) node.textColor = color_from_argb((long)[textColor longLongValue]); + if (textColor) + node.textColor = color_from_argb((long)[textColor longLongValue]); id color = props[@"color"]; - if (color) node.color = color_from_argb((long)[color longLongValue]); + if (color) + node.color = color_from_argb((long)[color longLongValue]); id thickness = props[@"thickness"]; - if (thickness) node.thickness = [thickness doubleValue]; + if (thickness) + node.thickness = [thickness doubleValue]; id fixedSize = props[@"size"]; - if (fixedSize) node.fixedSize = [fixedSize doubleValue]; + if (fixedSize) + node.fixedSize = [fixedSize doubleValue]; id axis = props[@"axis"]; - if ([axis isKindOfClass:[NSString class]]) node.axis = axis; + if ([axis isKindOfClass:[NSString class]]) + node.axis = axis; // `align` plays two roles depending on node type — the Mob renderer // sets the same string and the iOS side picks the relevant @@ -702,143 +760,177 @@ static void mob_send_change_float(int handle, double value) { } id offsetX = props[@"offset_x"]; - if (offsetX) node.offsetX = [offsetX doubleValue]; + if (offsetX) + node.offsetX = [offsetX doubleValue]; id offsetY = props[@"offset_y"]; - if (offsetY) node.offsetY = [offsetY doubleValue]; + if (offsetY) + node.offsetY = [offsetY doubleValue]; id showIndicator = props[@"show_indicator"]; - if (showIndicator) node.showIndicator = [showIndicator boolValue]; + if (showIndicator) + node.showIndicator = [showIndicator boolValue]; id value = props[@"value"]; - if (value) node.value = [value doubleValue]; + if (value) + node.value = [value doubleValue]; id onTap = props[@"on_tap"]; if (onTap && [onTap isKindOfClass:[NSNumber class]]) { int handle = [onTap intValue]; - node.onTap = ^{ mob_send_tap(handle); }; + node.onTap = ^{ + mob_send_tap(handle); + }; } id placeholder = props[@"placeholder"]; - if (placeholder) node.placeholder = [placeholder isKindOfClass:[NSString class]] ? placeholder : [placeholder description]; + if (placeholder) + node.placeholder = [placeholder isKindOfClass:[NSString class]] + ? placeholder + : [placeholder description]; // Icon name — logical key (e.g. "settings"), resolved to an SF Symbol // by MobIconView at render time. iOS-only string parsing here. if (node.nodeType == MobNodeTypeIcon) { id iconName = props[@"name"]; - if (iconName) node.iconName = [iconName isKindOfClass:[NSString class]] - ? iconName - : [iconName description]; + if (iconName) + node.iconName = + [iconName isKindOfClass:[NSString class]] ? iconName : [iconName description]; } id keyboardType = props[@"keyboard"]; - if ([keyboardType isKindOfClass:[NSString class]]) node.keyboardTypeStr = keyboardType; + if ([keyboardType isKindOfClass:[NSString class]]) + node.keyboardTypeStr = keyboardType; id returnKey = props[@"return_key"]; - if ([returnKey isKindOfClass:[NSString class]]) node.returnKeyStr = returnKey; + if ([returnKey isKindOfClass:[NSString class]]) + node.returnKeyStr = returnKey; + + id secure = props[@"secure"]; + if ([secure isKindOfClass:[NSNumber class]]) + node.isSecure = [secure boolValue]; id onFocus = props[@"on_focus"]; if (onFocus && [onFocus isKindOfClass:[NSNumber class]]) { int handle = [onFocus intValue]; - node.onFocus = ^{ mob_send_focus(handle); }; + node.onFocus = ^{ + mob_send_focus(handle); + }; } id onBlur = props[@"on_blur"]; if (onBlur && [onBlur isKindOfClass:[NSNumber class]]) { int handle = [onBlur intValue]; - node.onBlur = ^{ mob_send_blur(handle); }; + node.onBlur = ^{ + mob_send_blur(handle); + }; } id onSubmit = props[@"on_submit"]; if (onSubmit && [onSubmit isKindOfClass:[NSNumber class]]) { int handle = [onSubmit intValue]; - node.onSubmit = ^{ mob_send_submit(handle); }; + node.onSubmit = ^{ + mob_send_submit(handle); + }; } id onCompose = props[@"on_compose"]; if (onCompose && [onCompose isKindOfClass:[NSNumber class]]) { int handle = [onCompose intValue]; - node.onCompose = ^(NSString* text, NSString* phase) { - mob_send_compose(handle, - text ? [text UTF8String] : "", - phase ? [phase UTF8String] : "updating"); + node.onCompose = ^(NSString *text, NSString *phase) { + mob_send_compose(handle, text ? [text UTF8String] : "", + phase ? [phase UTF8String] : "updating"); }; } id onSelect = props[@"on_select"]; if (onSelect && [onSelect isKindOfClass:[NSNumber class]]) { int handle = [onSelect intValue]; - node.onSelect = ^{ mob_send_select(handle); }; + node.onSelect = ^{ + mob_send_select(handle); + }; } // ── Gestures (Batch 4) ── id onLongPress = props[@"on_long_press"]; if (onLongPress && [onLongPress isKindOfClass:[NSNumber class]]) { int handle = [onLongPress intValue]; - node.onLongPress = ^{ mob_send_long_press(handle); }; + node.onLongPress = ^{ + mob_send_long_press(handle); + }; } id onDoubleTap = props[@"on_double_tap"]; if (onDoubleTap && [onDoubleTap isKindOfClass:[NSNumber class]]) { int handle = [onDoubleTap intValue]; - node.onDoubleTap = ^{ mob_send_double_tap(handle); }; + node.onDoubleTap = ^{ + mob_send_double_tap(handle); + }; } id onSwipe = props[@"on_swipe"]; if (onSwipe && [onSwipe isKindOfClass:[NSNumber class]]) { int handle = [onSwipe intValue]; - node.onSwipe = ^(NSString* direction) { - mob_send_swipe_with_direction(handle, [direction UTF8String]); + node.onSwipe = ^(NSString *direction) { + mob_send_swipe_with_direction(handle, [direction UTF8String]); }; } id onSwipeLeft = props[@"on_swipe_left"]; if (onSwipeLeft && [onSwipeLeft isKindOfClass:[NSNumber class]]) { int handle = [onSwipeLeft intValue]; - node.onSwipeLeft = ^{ mob_send_swipe_left(handle); }; + node.onSwipeLeft = ^{ + mob_send_swipe_left(handle); + }; } id onSwipeRight = props[@"on_swipe_right"]; if (onSwipeRight && [onSwipeRight isKindOfClass:[NSNumber class]]) { int handle = [onSwipeRight intValue]; - node.onSwipeRight = ^{ mob_send_swipe_right(handle); }; + node.onSwipeRight = ^{ + mob_send_swipe_right(handle); + }; } id onSwipeUp = props[@"on_swipe_up"]; if (onSwipeUp && [onSwipeUp isKindOfClass:[NSNumber class]]) { int handle = [onSwipeUp intValue]; - node.onSwipeUp = ^{ mob_send_swipe_up(handle); }; + node.onSwipeUp = ^{ + mob_send_swipe_up(handle); + }; } id onSwipeDown = props[@"on_swipe_down"]; if (onSwipeDown && [onSwipeDown isKindOfClass:[NSNumber class]]) { int handle = [onSwipeDown intValue]; - node.onSwipeDown = ^{ mob_send_swipe_down(handle); }; + node.onSwipeDown = ^{ + mob_send_swipe_down(handle); + }; } - // ── Batch 5 Tier 1: high-frequency events (with throttle config) ── - // Helper macro: read a *_config sibling prop and apply it to the - // handle's throttle state. - #define MOB_APPLY_THROTTLE(HANDLE, CONFIG_KEY) \ - do { \ - id _cfg = props[CONFIG_KEY]; \ - if ([_cfg isKindOfClass:[NSDictionary class]]) { \ - int t = [(_cfg[@"throttle_ms"] ?: @0) intValue]; \ - int d = [(_cfg[@"debounce_ms"] ?: @0) intValue]; \ - double dt = [(_cfg[@"delta_threshold"] ?: @0) doubleValue]; \ - int ld = [(_cfg[@"leading"] ?: @YES) boolValue] ? 1 : 0; \ - int tr = [(_cfg[@"trailing"] ?: @YES) boolValue] ? 1 : 0; \ - mob_set_throttle_config((HANDLE), t, d, dt, ld, tr); \ - } \ - } while (0) +// ── Batch 5 Tier 1: high-frequency events (with throttle config) ── +// Helper macro: read a *_config sibling prop and apply it to the +// handle's throttle state. +#define MOB_APPLY_THROTTLE(HANDLE, CONFIG_KEY) \ + do { \ + id _cfg = props[CONFIG_KEY]; \ + if ([_cfg isKindOfClass:[NSDictionary class]]) { \ + int t = [(_cfg[@"throttle_ms"] ?: @0) intValue]; \ + int d = [(_cfg[@"debounce_ms"] ?: @0) intValue]; \ + double dt = [(_cfg[@"delta_threshold"] ?: @0) doubleValue]; \ + int ld = [(_cfg[@"leading"] ?: @YES) boolValue] ? 1 : 0; \ + int tr = [(_cfg[@"trailing"] ?: @YES) boolValue] ? 1 : 0; \ + mob_set_throttle_config((HANDLE), t, d, dt, ld, tr); \ + } \ + } while (0) id onScroll = props[@"on_scroll"]; if ([onScroll isKindOfClass:[NSNumber class]]) { int handle = [onScroll intValue]; MOB_APPLY_THROTTLE(handle, @"scroll_config"); - node.onScroll = ^(CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, - CGFloat vx, CGFloat vy, NSString* phase) { - mob_send_scroll(handle, x, y, dx, dy, vx, vy, - phase ? [phase UTF8String] : "dragging"); + node.onScroll = ^(CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, CGFloat vx, CGFloat vy, + NSString *phase) { + mob_send_scroll(handle, x, y, dx, dy, vx, vy, + phase ? [phase UTF8String] : "dragging"); }; } @@ -846,9 +938,8 @@ static void mob_send_change_float(int handle, double value) { if ([onDrag isKindOfClass:[NSNumber class]]) { int handle = [onDrag intValue]; MOB_APPLY_THROTTLE(handle, @"drag_config"); - node.onDrag = ^(CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, NSString* phase) { - mob_send_drag(handle, x, y, dx, dy, - phase ? [phase UTF8String] : "dragging"); + node.onDrag = ^(CGFloat dx, CGFloat dy, CGFloat x, CGFloat y, NSString *phase) { + mob_send_drag(handle, x, y, dx, dy, phase ? [phase UTF8String] : "dragging"); }; } @@ -856,9 +947,8 @@ static void mob_send_change_float(int handle, double value) { if ([onPinch isKindOfClass:[NSNumber class]]) { int handle = [onPinch intValue]; MOB_APPLY_THROTTLE(handle, @"pinch_config"); - node.onPinch = ^(CGFloat scale, CGFloat velocity, NSString* phase) { - mob_send_pinch(handle, scale, velocity, - phase ? [phase UTF8String] : "dragging"); + node.onPinch = ^(CGFloat scale, CGFloat velocity, NSString *phase) { + mob_send_pinch(handle, scale, velocity, phase ? [phase UTF8String] : "dragging"); }; } @@ -866,9 +956,8 @@ static void mob_send_change_float(int handle, double value) { if ([onRotate isKindOfClass:[NSNumber class]]) { int handle = [onRotate intValue]; MOB_APPLY_THROTTLE(handle, @"rotate_config"); - node.onRotate = ^(CGFloat degrees, CGFloat velocity, NSString* phase) { - mob_send_rotate(handle, degrees, velocity, - phase ? [phase UTF8String] : "dragging"); + node.onRotate = ^(CGFloat degrees, CGFloat velocity, NSString *phase) { + mob_send_rotate(handle, degrees, velocity, phase ? [phase UTF8String] : "dragging"); }; } @@ -877,41 +966,51 @@ static void mob_send_change_float(int handle, double value) { int handle = [onPointerMove intValue]; MOB_APPLY_THROTTLE(handle, @"pointer_config"); node.onPointerMove = ^(CGFloat x, CGFloat y) { - mob_send_pointer_move(handle, x, y); + mob_send_pointer_move(handle, x, y); }; } - #undef MOB_APPLY_THROTTLE +#undef MOB_APPLY_THROTTLE // ── Batch 5 Tier 2: semantic single-fire scroll events ── id onScrollBegan = props[@"on_scroll_began"]; if ([onScrollBegan isKindOfClass:[NSNumber class]]) { int handle = [onScrollBegan intValue]; - node.onScrollBegan = ^{ mob_send_scroll_began(handle); }; + node.onScrollBegan = ^{ + mob_send_scroll_began(handle); + }; } id onScrollEnded = props[@"on_scroll_ended"]; if ([onScrollEnded isKindOfClass:[NSNumber class]]) { int handle = [onScrollEnded intValue]; - node.onScrollEnded = ^{ mob_send_scroll_ended(handle); }; + node.onScrollEnded = ^{ + mob_send_scroll_ended(handle); + }; } id onScrollSettled = props[@"on_scroll_settled"]; if ([onScrollSettled isKindOfClass:[NSNumber class]]) { int handle = [onScrollSettled intValue]; - node.onScrollSettled = ^{ mob_send_scroll_settled(handle); }; + node.onScrollSettled = ^{ + mob_send_scroll_settled(handle); + }; } id onTopReached = props[@"on_top_reached"]; if ([onTopReached isKindOfClass:[NSNumber class]]) { int handle = [onTopReached intValue]; - node.onTopReached = ^{ mob_send_top_reached(handle); }; + node.onTopReached = ^{ + mob_send_top_reached(handle); + }; } id onScrolledPast = props[@"on_scrolled_past"]; if ([onScrolledPast isKindOfClass:[NSNumber class]]) { int handle = [onScrolledPast intValue]; - node.onScrolledPast = ^{ mob_send_scrolled_past(handle); }; + node.onScrolledPast = ^{ + mob_send_scrolled_past(handle); + }; } id scrolledPastThreshold = props[@"scrolled_past_threshold"]; if (scrolledPastThreshold) { @@ -941,76 +1040,128 @@ static void mob_send_change_float(int handle, double value) { } id minVal = props[@"min"]; - if (minVal) node.minValue = [minVal doubleValue]; + if (minVal) + node.minValue = [minVal doubleValue]; id maxVal = props[@"max"]; - if (maxVal) node.maxValue = [maxVal doubleValue]; + if (maxVal) + node.maxValue = [maxVal doubleValue]; id src = props[@"src"]; - if ([src isKindOfClass:[NSString class]]) node.src = src; + if ([src isKindOfClass:[NSString class]]) + node.src = src; id contentMode = props[@"content_mode"]; - if ([contentMode isKindOfClass:[NSString class]]) node.contentModeStr = contentMode; + if ([contentMode isKindOfClass:[NSString class]]) + node.contentModeStr = contentMode; id fixedWidth = props[@"width"]; - if (fixedWidth) node.fixedWidth = [fixedWidth doubleValue]; + if (fixedWidth) + node.fixedWidth = [fixedWidth doubleValue]; id fixedHeight = props[@"height"]; - if (fixedHeight) node.fixedHeight = [fixedHeight doubleValue]; + if (fixedHeight) + node.fixedHeight = [fixedHeight doubleValue]; id cornerRadius = props[@"corner_radius"]; - if (cornerRadius) node.cornerRadius = [cornerRadius doubleValue]; + if (cornerRadius) + node.cornerRadius = [cornerRadius doubleValue]; + + // Liquid Glass opt-in — set by Mob.Renderer when the active theme + // has `glass: true`. MobBox swaps a solid background for + // `.glassEffect()` on iOS 26+, or `.ultraThinMaterial` on iOS 17–25. + id useGlass = props[@"glass"]; + if (useGlass) + node.useGlass = [useGlass boolValue]; id fillWidth = props[@"fill_width"]; - if (fillWidth) node.fillWidth = [fillWidth boolValue]; + if (fillWidth) + node.fillWidth = [fillWidth boolValue]; id fillHeight = props[@"fill_height"]; - if (fillHeight) node.fillHeight = [fillHeight boolValue]; + if (fillHeight) + node.fillHeight = [fillHeight boolValue]; id placeholderColor = props[@"placeholder_color"]; - if (placeholderColor) node.placeholderColor = color_from_argb((long)[placeholderColor longLongValue]); + if (placeholderColor) + node.placeholderColor = color_from_argb((long)[placeholderColor longLongValue]); id videoAutoplay = props[@"autoplay"]; - if (videoAutoplay) node.videoAutoplay = [videoAutoplay boolValue]; + if (videoAutoplay) + node.videoAutoplay = [videoAutoplay boolValue]; id videoLoop = props[@"loop"]; - if (videoLoop) node.videoLoop = [videoLoop boolValue]; + if (videoLoop) + node.videoLoop = [videoLoop boolValue]; id videoControls = props[@"controls"]; - if (videoControls) node.videoControls = [videoControls boolValue]; + if (videoControls) + node.videoControls = [videoControls boolValue]; id cameraFacing = props[@"facing"]; - if ([cameraFacing isKindOfClass:[NSString class]]) node.cameraFacing = cameraFacing; + if ([cameraFacing isKindOfClass:[NSString class]]) + node.cameraFacing = cameraFacing; // canvas props id canvasDraw = props[@"draw"]; - if ([canvasDraw isKindOfClass:[NSArray class]]) node.canvasOps = canvasDraw; + if ([canvasDraw isKindOfClass:[NSArray class]]) + node.canvasOps = canvasDraw; id canvasW = props[@"width"]; - if (canvasW && node.nodeType == MobNodeTypeCanvas) node.canvasWidth = [canvasW doubleValue]; + if (canvasW && node.nodeType == MobNodeTypeCanvas) + node.canvasWidth = [canvasW doubleValue]; id canvasH = props[@"height"]; - if (canvasH && node.nodeType == MobNodeTypeCanvas) node.canvasHeight = [canvasH doubleValue]; + if (canvasH && node.nodeType == MobNodeTypeCanvas) + node.canvasHeight = [canvasH doubleValue]; + + // gpu_view props: shader (string OR %{ios: "..."} map) + uniforms map. + // Map form is the "I already have hand-tuned MSL" escape hatch. + if (node.nodeType == MobNodeTypeGpuView) { + id shader = props[@"shader"]; + if ([shader isKindOfClass:[NSString class]]) { + node.gpuShaderMSL = shader; + } else if ([shader isKindOfClass:[NSDictionary class]]) { + id iosShader = ((NSDictionary *)shader)[@"ios"]; + if ([iosShader isKindOfClass:[NSString class]]) + node.gpuShaderMSL = iosShader; + } + + id uniforms = props[@"uniforms"]; + if ([uniforms isKindOfClass:[NSArray class]] || + [uniforms isKindOfClass:[NSDictionary class]]) + node.gpuUniforms = uniforms; + } // webview props id webViewUrl = props[@"url"]; - if ([webViewUrl isKindOfClass:[NSString class]]) node.webViewUrl = webViewUrl; + if ([webViewUrl isKindOfClass:[NSString class]]) + node.webViewUrl = webViewUrl; id webViewAllow = props[@"allow"]; - if ([webViewAllow isKindOfClass:[NSString class]]) node.webViewAllow = webViewAllow; + if ([webViewAllow isKindOfClass:[NSString class]]) + node.webViewAllow = webViewAllow; id webViewShowUrl = props[@"show_url"]; - if (webViewShowUrl) node.webViewShowUrl = [webViewShowUrl boolValue]; + if (webViewShowUrl) + node.webViewShowUrl = [webViewShowUrl boolValue]; id webViewTitle = props[@"title"]; - if ([webViewTitle isKindOfClass:[NSString class]]) node.webViewTitle = webViewTitle; + if ([webViewTitle isKindOfClass:[NSString class]]) + node.webViewTitle = webViewTitle; // native_view props id nativeViewModule = props[@"module"]; - if ([nativeViewModule isKindOfClass:[NSString class]]) node.nativeViewModule = nativeViewModule; + if ([nativeViewModule isKindOfClass:[NSString class]]) + node.nativeViewModule = nativeViewModule; id nativeViewId = props[@"id"]; - if ([nativeViewId isKindOfClass:[NSString class]]) node.nativeViewId = nativeViewId; + if ([nativeViewId isKindOfClass:[NSString class]]) + node.nativeViewId = nativeViewId; id nativeViewHandle = props[@"component_handle"]; - if (nativeViewHandle) node.nativeViewHandle = [nativeViewHandle intValue]; - if (node.nodeType == MobNodeTypeNativeView) node.nativeViewProps = props; + if (nativeViewHandle) + node.nativeViewHandle = [nativeViewHandle intValue]; + if (node.nodeType == MobNodeTypeNativeView) + node.nativeViewProps = props; id onEndReached = props[@"on_end_reached"]; if (onEndReached && [onEndReached isKindOfClass:[NSNumber class]]) { int handle = [onEndReached intValue]; - node.onTap = ^{ mob_send_tap(handle); }; + node.onTap = ^{ + mob_send_tap(handle); + }; } // For slider, value is the initial position (re-uses node.value property) @@ -1020,17 +1171,23 @@ static void mob_send_change_float(int handle, double value) { if (onChange && [onChange isKindOfClass:[NSNumber class]]) { int handle = [onChange intValue]; switch (node.nodeType) { - case MobNodeTypeTextField: - node.onChangeStr = ^(NSString* v) { mob_send_change_str(handle, [v UTF8String]); }; - break; - case MobNodeTypeToggle: - node.onChangeBool = ^(BOOL v) { mob_send_change_bool(handle, (int)v); }; - break; - case MobNodeTypeSlider: - node.onChangeFloat = ^(double v) { mob_send_change_float(handle, v); }; - break; - default: - break; + case MobNodeTypeTextField: + node.onChangeStr = ^(NSString *v) { + mob_send_change_str(handle, [v UTF8String]); + }; + break; + case MobNodeTypeToggle: + node.onChangeBool = ^(BOOL v) { + mob_send_change_bool(handle, (int)v); + }; + break; + case MobNodeTypeSlider: + node.onChangeFloat = ^(double v) { + mob_send_change_float(handle, v); + }; + break; + default: + break; } } @@ -1040,11 +1197,12 @@ static void mob_send_change_float(int handle, double value) { } } - NSArray* children = dict[@"children"]; + NSArray *children = dict[@"children"]; if ([children isKindOfClass:[NSArray class]]) { for (id child in children) { - MobNode* childNode = mob_node_from_dict(child); - if (childNode) [node.children addObject:childNode]; + MobNode *childNode = mob_node_from_dict(child); + if (childNode) + [node.children addObject:childNode]; } } @@ -1056,13 +1214,13 @@ static void mob_send_change_float(int handle, double value) { // handled by the OS. This is intentionally a no-op; backgrounding on iOS // happens naturally when the user swipes up. -static ERL_NIF_TERM nif_exit_app(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_exit_app(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { return enif_make_atom(env, "ok"); } // ── NIF: platform/0 ────────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_platform(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_platform(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { return enif_make_atom(env, "ios"); } @@ -1070,31 +1228,40 @@ static ERL_NIF_TERM nif_platform(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar // Returns :light or :dark based on UIUserInterfaceStyle. // Falls back to :light when called before any window is on screen. -static ERL_NIF_TERM nif_color_scheme(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_color_scheme(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { __block UIUserInterfaceStyle style = UIUserInterfaceStyleUnspecified; void (^read)(void) = ^{ - // Prefer the key window's trait collection (most accurate once the - // app is on screen). Fall back to UITraitCollection.current (set - // during a render pass) and finally UIScreen.mainScreen for the - // earliest startup edge case before any window exists. - UIWindow *win = nil; - for (UIWindowScene *scene in [UIApplication.sharedApplication.connectedScenes allObjects]) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *w in scene.windows) { - if (w.isKeyWindow) { win = w; break; } - } - if (win) break; - } - if (win) { - style = win.traitCollection.userInterfaceStyle; - } else { - UIUserInterfaceStyle current_s = UITraitCollection.currentTraitCollection.userInterfaceStyle; - style = (current_s != UIUserInterfaceStyleUnspecified) - ? current_s - : UIScreen.mainScreen.traitCollection.userInterfaceStyle; - } + // Prefer the key window's trait collection (most accurate once the + // app is on screen). Fall back to UITraitCollection.current (set + // during a render pass) and finally UIScreen.mainScreen for the + // earliest startup edge case before any window exists. + UIWindow *win = nil; + for (UIWindowScene *scene in [UIApplication.sharedApplication.connectedScenes allObjects]) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *w in scene.windows) { + if (w.isKeyWindow) { + win = w; + break; + } + } + if (win) + break; + } + if (win) { + style = win.traitCollection.userInterfaceStyle; + } else { + UIUserInterfaceStyle current_s = + UITraitCollection.currentTraitCollection.userInterfaceStyle; + style = (current_s != UIUserInterfaceStyleUnspecified) + ? current_s + : UIScreen.mainScreen.traitCollection.userInterfaceStyle; + } }; - if ([NSThread isMainThread]) read(); else dispatch_sync(dispatch_get_main_queue(), read); + if ([NSThread isMainThread]) + read(); + else + dispatch_sync(dispatch_get_main_queue(), read); return enif_make_atom(env, style == UIUserInterfaceStyleDark ? "dark" : "light"); } @@ -1103,152 +1270,17 @@ static ERL_NIF_TERM nif_color_scheme(ErlNifEnv* env, int argc, const ERL_NIF_TER // does not report battery info (unlikely on iPhone/iPad). // Enables battery monitoring if not already enabled. Must run on main thread. -// ── NIF: background_keep_alive/0, background_stop/0 ────────────────────────── -// Starts/stops a silent AVAudioEngine session so iOS keeps the app running -// when the screen locks. The session uses MixWithOthers so it does not -// interrupt or duck the user's music or the app's own Mob.Audio playback. -// -// Coexistence with Mob.Audio recording/playback: -// - Playback: MixWithOthers on both sides — they mix, silence is inaudible. -// - Recording: start_recording switches the session category to PlayAndRecord, -// which sends an interruption to this engine. The engine stops, but the -// recording itself keeps the app alive. When recording ends, the session -// sends InterruptionTypeEnded and this engine restarts automatically. -// -// Requires UIBackgroundModes: [audio] in the app's Info.plist. - -static AVAudioEngine *g_keep_alive_engine = nil; -static AVAudioPlayerNode *g_keep_alive_player = nil; -static BOOL g_keep_alive_active = NO; // user intent: should be running -static id g_keep_alive_interruption_observer = nil; // token from addObserverForName, needed for removeObserver - -static void keep_alive_start_engine(void) { - if (g_keep_alive_engine != nil) return; - - @try { - NSError *err = nil; - AVAudioSession *session = [AVAudioSession sharedInstance]; - if (![session setCategory:AVAudioSessionCategoryPlayback - withOptions:AVAudioSessionCategoryOptionMixWithOthers - error:&err]) { - NSLog(@"[mob] keep_alive setCategory failed: %@", err); - return; - } - if (![session setActive:YES error:&err]) { - NSLog(@"[mob] keep_alive setActive failed: %@", err); - return; - } - - g_keep_alive_engine = [[AVAudioEngine alloc] init]; - g_keep_alive_player = [[AVAudioPlayerNode alloc] init]; - [g_keep_alive_engine attachNode:g_keep_alive_player]; - - // Use the mixer's native format so connect: and the buffer agree — - // a format mismatch here throws NSInvalidArgumentException, which - // takes down the BEAM scheduler thread. - AVAudioFormat *fmt = - [g_keep_alive_engine.mainMixerNode outputFormatForBus:0]; - [g_keep_alive_engine connect:g_keep_alive_player - to:g_keep_alive_engine.mainMixerNode - format:fmt]; - - AVAudioFrameCount frames = (AVAudioFrameCount)fmt.sampleRate; - AVAudioPCMBuffer *buf = [[AVAudioPCMBuffer alloc] - initWithPCMFormat:fmt frameCapacity:frames]; - buf.frameLength = frames; - - // Engine must be running before scheduleBuffer/play. - if (![g_keep_alive_engine startAndReturnError:&err]) { - NSLog(@"[mob] keep_alive engine start failed: %@", err); - g_keep_alive_engine = nil; - g_keep_alive_player = nil; - return; - } - - [g_keep_alive_player scheduleBuffer:buf - atTime:nil - options:AVAudioPlayerNodeBufferLoops - completionHandler:nil]; - [g_keep_alive_player play]; - NSLog(@"[mob] keep_alive engine running (sampleRate=%.0f, channels=%u)", - fmt.sampleRate, (unsigned)fmt.channelCount); - } - @catch (NSException *ex) { - NSLog(@"[mob] keep_alive exception: %@ — %@", ex.name, ex.reason); - g_keep_alive_engine = nil; - g_keep_alive_player = nil; - } -} - -static void keep_alive_stop_engine(void) { - if (g_keep_alive_player) { [g_keep_alive_player stop]; g_keep_alive_player = nil; } - if (g_keep_alive_engine) { [g_keep_alive_engine stop]; g_keep_alive_engine = nil; } -} - -static ERL_NIF_TERM nif_background_keep_alive(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - // Async so the BEAM scheduler isn't blocked while AVFoundation initialises - // (which can throw an NSException and take down the scheduler thread). - dispatch_async(dispatch_get_main_queue(), ^{ - if (g_keep_alive_active) return; // idempotent - g_keep_alive_active = YES; - - // Restart engine after audio session interruptions (e.g. recording ends, - // phone call ends). InterruptionTypeEnded fires when the session is ours - // again; we reconfigure and resume the silence loop. - // Stash the returned token — block-based observers must be removed - // by their token, not by name (passing nil to removeObserver: is - // a no-op for the block API). - g_keep_alive_interruption_observer = [[NSNotificationCenter defaultCenter] - addObserverForName:AVAudioSessionInterruptionNotification - object:nil - queue:[NSOperationQueue mainQueue] - usingBlock:^(NSNotification *note) { - if (!g_keep_alive_active) return; - AVAudioSessionInterruptionType type = - [note.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue]; - if (type == AVAudioSessionInterruptionTypeBegan) { - keep_alive_stop_engine(); - } else { - // InterruptionTypeEnded — real audio finished, reclaim the session. - keep_alive_start_engine(); - } - }]; - - keep_alive_start_engine(); - }); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_background_stop(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - dispatch_async(dispatch_get_main_queue(), ^{ - g_keep_alive_active = NO; - if (g_keep_alive_interruption_observer) { - [[NSNotificationCenter defaultCenter] - removeObserver:g_keep_alive_interruption_observer]; - g_keep_alive_interruption_observer = nil; - } - keep_alive_stop_engine(); - [[AVAudioSession sharedInstance] - setActive:NO - withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation - error:nil]; - }); - return enif_make_atom(env, "ok"); -} - -// ── NIF: battery_level/0 ───────────────────────────────────────────────────── - -static ERL_NIF_TERM nif_battery_level(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_battery_level(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { __block int level = -1; dispatch_sync(dispatch_get_main_queue(), ^{ - UIDevice *dev = [UIDevice currentDevice]; - if (!dev.batteryMonitoringEnabled) { - dev.batteryMonitoringEnabled = YES; - } - float f = dev.batteryLevel; - if (f >= 0.0f) { - level = (int)roundf(f * 100.0f); - } + UIDevice *dev = [UIDevice currentDevice]; + if (!dev.batteryMonitoringEnabled) { + dev.batteryMonitoringEnabled = YES; + } + float f = dev.batteryLevel; + if (f >= 0.0f) { + level = (int)roundf(f * 100.0f); + } }); return enif_make_int(env, level); } @@ -1268,27 +1300,26 @@ static ERL_NIF_TERM nif_battery_level(ErlNifEnv* env, int argc, const ERL_NIF_TE // re-register observers (avoids duplicate notifications). static ErlNifPid g_device_dispatcher_pid; -static BOOL g_device_dispatcher_set = NO; +static BOOL g_device_dispatcher_set = NO; static dispatch_once_t g_device_observers_once = 0; static void mob_device_send_atom(const char *tag, const char *atom_name) { - if (!g_device_dispatcher_set) return; + if (!g_device_dispatcher_set) + return; ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(e, - enif_make_atom(e, tag), - enif_make_atom(e, atom_name)); + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, tag), enif_make_atom(e, atom_name)); enif_send(NULL, &g_device_dispatcher_pid, e, msg); enif_free_env(e); } -static void mob_device_send_atom_payload(const char *tag, const char *atom_name, ERL_NIF_TERM payload, ErlNifEnv *payload_env) { - if (!g_device_dispatcher_set) return; +static void mob_device_send_atom_payload(const char *tag, const char *atom_name, + ERL_NIF_TERM payload, ErlNifEnv *payload_env) { + if (!g_device_dispatcher_set) + return; ErlNifEnv *e = enif_alloc_env(); ERL_NIF_TERM payload_copy = enif_make_copy(e, payload); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e, tag), - enif_make_atom(e, atom_name), - payload_copy); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, tag), enif_make_atom(e, atom_name), payload_copy); enif_send(NULL, &g_device_dispatcher_pid, e, msg); enif_free_env(e); (void)payload_env; @@ -1296,142 +1327,362 @@ static void mob_device_send_atom_payload(const char *tag, const char *atom_name, static const char *thermal_state_atom(NSProcessInfoThermalState s) { switch (s) { - case NSProcessInfoThermalStateNominal: return "nominal"; - case NSProcessInfoThermalStateFair: return "fair"; - case NSProcessInfoThermalStateSerious: return "serious"; - case NSProcessInfoThermalStateCritical: return "critical"; - default: return "nominal"; + case NSProcessInfoThermalStateNominal: + return "nominal"; + case NSProcessInfoThermalStateFair: + return "fair"; + case NSProcessInfoThermalStateSerious: + return "serious"; + case NSProcessInfoThermalStateCritical: + return "critical"; + default: + return "nominal"; } } static const char *battery_state_atom(UIDeviceBatteryState s) { switch (s) { - case UIDeviceBatteryStateUnplugged: return "unplugged"; - case UIDeviceBatteryStateCharging: return "charging"; - case UIDeviceBatteryStateFull: return "full"; - default: return "unknown"; + case UIDeviceBatteryStateUnplugged: + return "unplugged"; + case UIDeviceBatteryStateCharging: + return "charging"; + case UIDeviceBatteryStateFull: + return "full"; + default: + return "unknown"; + } +} + +// ── Network connectivity (NWPathMonitor) ───────────────────────────────────── +// +// A single process-lifetime NWPathMonitor tracks the active path. Its update +// handler runs on a private serial queue: it caches the latest snapshot (for +// the synchronous device_network_state/0 query) and pushes a +// connectivity_changed event to Mob.Device subscribers. Transport codes: +// 0 none, 1 wifi, 2 cellular, 3 wired, 4 other. +static nw_path_monitor_t g_path_monitor = NULL; +static dispatch_once_t g_path_monitor_once = 0; +static _Atomic(bool) g_net_online = false; +static _Atomic(int) g_net_transport = 0; +static _Atomic(bool) g_net_expensive = false; +static _Atomic(bool) g_net_constrained = false; + +static const char *net_transport_atom(int t) { + switch (t) { + case 1: + return "wifi"; + case 2: + return "cellular"; + case 3: + return "wired"; + case 4: + return "other"; + default: + return "none"; + } +} + +static int net_classify_path(nw_path_t path) { + if (nw_path_get_status(path) != nw_path_status_satisfied) + return 0; + if (nw_path_uses_interface_type(path, nw_interface_type_wifi)) + return 1; + if (nw_path_uses_interface_type(path, nw_interface_type_cellular)) + return 2; + if (nw_path_uses_interface_type(path, nw_interface_type_wired)) + return 3; + return 4; +} + +// Builds %{online, transport, expensive, validated, constrained}. `validated` +// is always :unavailable on iOS — NWPath reports a usable path but has no +// internet-reachability probe (Android's NET_CAPABILITY_VALIDATED). `constrained` +// is iOS Low Data Mode. +static ERL_NIF_TERM mob_make_network_map(ErlNifEnv *e, bool online, int transport, bool expensive, + bool constrained) { + ERL_NIF_TERM keys[5] = {enif_make_atom(e, "online"), enif_make_atom(e, "transport"), + enif_make_atom(e, "expensive"), enif_make_atom(e, "validated"), + enif_make_atom(e, "constrained")}; + ERL_NIF_TERM vals[5] = {enif_make_atom(e, online ? "true" : "false"), + enif_make_atom(e, net_transport_atom(transport)), + enif_make_atom(e, expensive ? "true" : "false"), + enif_make_atom(e, "unavailable"), + enif_make_atom(e, constrained ? "true" : "false")}; + ERL_NIF_TERM map; + if (!enif_make_map_from_arrays(e, keys, vals, 5, &map)) + return enif_make_atom(e, "nil"); + return map; +} + +// Starts the shared path monitor exactly once. Safe to call from either the +// dispatcher handshake or a cold query. +static void ensure_path_monitor_once(void) { + dispatch_once(&g_path_monitor_once, ^{ + g_path_monitor = nw_path_monitor_create(); + dispatch_queue_t nq = dispatch_queue_create("com.mob.netmonitor", DISPATCH_QUEUE_SERIAL); + nw_path_monitor_set_queue(g_path_monitor, nq); + nw_path_monitor_set_update_handler(g_path_monitor, ^(nw_path_t path) { + bool online = nw_path_get_status(path) == nw_path_status_satisfied; + int transport = net_classify_path(path); + bool expensive = nw_path_is_expensive(path); + bool constrained = nw_path_is_constrained(path); + // NWPathMonitor fires on dns/other changes too; only emit an event when + // the snapshot we expose actually changed. The cache is still refreshed + // either way so the synchronous query stays current. + bool changed = online != atomic_load(&g_net_online) || + transport != atomic_load(&g_net_transport) || + expensive != atomic_load(&g_net_expensive) || + constrained != atomic_load(&g_net_constrained); + atomic_store(&g_net_online, online); + atomic_store(&g_net_transport, transport); + atomic_store(&g_net_expensive, expensive); + atomic_store(&g_net_constrained, constrained); + if (!changed) + return; + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM payload = mob_make_network_map(e, online, transport, expensive, constrained); + mob_device_send_atom_payload("mob_device", "connectivity_changed", payload, e); + enif_free_env(e); + }); + nw_path_monitor_start(g_path_monitor); + }); +} + +// ── Orientation ──────────────────────────────────────────────────────────── +// The locked mask the app shell's root view controller must report from +// -supportedInterfaceOrientations. UIInterfaceOrientationMaskAll means "no +// lock, follow the device". The shell reads this via the exported +// mob_locked_orientation_mask() (see PR notes — the VC override is the +// companion piece that makes the lock actually hold). +static UIInterfaceOrientationMask g_locked_orientation_mask = UIInterfaceOrientationMaskAll; + +UIInterfaceOrientationMask mob_locked_orientation_mask(void) { + return g_locked_orientation_mask; +} + +static const char *interface_orientation_atom(UIInterfaceOrientation o) { + switch (o) { + case UIInterfaceOrientationPortrait: + return "portrait"; + case UIInterfaceOrientationPortraitUpsideDown: + return "portrait_upside_down"; + case UIInterfaceOrientationLandscapeLeft: + return "landscape_left"; + case UIInterfaceOrientationLandscapeRight: + return "landscape_right"; + default: + return "unknown"; } } +// Read the foreground window scene's interface orientation (must run on the +// main thread). +static UIInterfaceOrientation mob_current_interface_orientation(void) { + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) + if ([scene isKindOfClass:[UIWindowScene class]] && + scene.activationState == UISceneActivationStateForegroundActive) + return ((UIWindowScene *)scene).interfaceOrientation; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) + if ([scene isKindOfClass:[UIWindowScene class]]) + return ((UIWindowScene *)scene).interfaceOrientation; + return UIInterfaceOrientationUnknown; +} + +// Map a lock atom (from Mob.Device.lock_orientation/1, plus :unspecified for +// unlock) to a UIKit mask. +static UIInterfaceOrientationMask orientation_mask_for_atom(const char *name) { + if (strcmp(name, "portrait") == 0) + return UIInterfaceOrientationMaskPortrait; + if (strcmp(name, "portrait_upside_down") == 0) + return UIInterfaceOrientationMaskPortraitUpsideDown; + if (strcmp(name, "landscape") == 0) + return UIInterfaceOrientationMaskLandscape; + if (strcmp(name, "landscape_left") == 0) + return UIInterfaceOrientationMaskLandscapeLeft; + if (strcmp(name, "landscape_right") == 0) + return UIInterfaceOrientationMaskLandscapeRight; + return UIInterfaceOrientationMaskAll; // :unspecified -> unlock +} + static void register_device_observers_once(void) { dispatch_once(&g_device_observers_once, ^{ - NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; - NSOperationQueue *q = [NSOperationQueue mainQueue]; - - // ── App lifecycle ── - [nc addObserverForName:UIApplicationWillResignActiveNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "will_resign_active"); - mob_device_send_atom("mob_device_ios", "will_resign_active"); - }]; - [nc addObserverForName:UIApplicationDidBecomeActiveNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "did_become_active"); - mob_device_send_atom("mob_device_ios", "did_become_active"); - }]; - [nc addObserverForName:UIApplicationDidEnterBackgroundNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "did_enter_background"); - mob_device_send_atom("mob_device_ios", "did_enter_background"); - }]; - [nc addObserverForName:UIApplicationWillEnterForegroundNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "will_enter_foreground"); - mob_device_send_atom("mob_device_ios", "will_enter_foreground"); - }]; - [nc addObserverForName:UIApplicationWillTerminateNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "will_terminate"); - mob_device_send_atom("mob_device_ios", "will_terminate"); - }]; - [nc addObserverForName:UIApplicationDidReceiveMemoryWarningNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "memory_warning"); - mob_device_send_atom("mob_device_ios", "memory_warning"); - }]; - - // ── Display / lock state (iOS proxies via data-protection) ── - [nc addObserverForName:UIApplicationProtectedDataWillBecomeUnavailable - object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "screen_off"); - mob_device_send_atom("mob_device_ios", "protected_data_will_become_unavailable"); - }]; - [nc addObserverForName:UIApplicationProtectedDataDidBecomeAvailable - object:nil queue:q - usingBlock:^(NSNotification *n) { - mob_device_send_atom("mob_device", "screen_on"); - mob_device_send_atom("mob_device_ios", "protected_data_did_become_available"); - }]; - - // ── Power / thermal ── - [nc addObserverForName:NSProcessInfoThermalStateDidChangeNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - const char *s = thermal_state_atom([[NSProcessInfo processInfo] thermalState]); - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM payload = enif_make_atom(e, s); - mob_device_send_atom_payload("mob_device", "thermal_state_changed", payload, e); - mob_device_send_atom_payload("mob_device_ios", "thermal_state_changed", payload, e); - enif_free_env(e); - }]; - [nc addObserverForName:NSProcessInfoPowerStateDidChangeNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - BOOL low = [[NSProcessInfo processInfo] isLowPowerModeEnabled]; - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM payload = enif_make_atom(e, low ? "true" : "false"); - mob_device_send_atom_payload("mob_device", "low_power_mode_changed", payload, e); - mob_device_send_atom_payload("mob_device_ios", "low_power_mode_changed", payload, e); - enif_free_env(e); - }]; - - // Ensure battery monitoring is on so the change notifications fire. - dispatch_async(dispatch_get_main_queue(), ^{ - UIDevice *dev = [UIDevice currentDevice]; - if (!dev.batteryMonitoringEnabled) dev.batteryMonitoringEnabled = YES; - }); - [nc addObserverForName:UIDeviceBatteryStateDidChangeNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - const char *s = battery_state_atom([[UIDevice currentDevice] batteryState]); - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM payload = enif_make_atom(e, s); - mob_device_send_atom_payload("mob_device", "battery_state_changed", payload, e); - mob_device_send_atom_payload("mob_device_ios", "battery_state_changed", payload, e); - enif_free_env(e); - }]; - [nc addObserverForName:UIDeviceBatteryLevelDidChangeNotification object:nil queue:q - usingBlock:^(NSNotification *n) { - float lvl = [[UIDevice currentDevice] batteryLevel]; - int pct = lvl >= 0.0f ? (int)roundf(lvl * 100.0f) : -1; - ErlNifEnv *e = enif_alloc_env(); - ERL_NIF_TERM payload = enif_make_int(e, pct); - mob_device_send_atom_payload("mob_device", "battery_level_changed", payload, e); - mob_device_send_atom_payload("mob_device_ios", "battery_level_changed", payload, e); - enif_free_env(e); - }]; - - // ── Audio session interruptions / route changes ── - [nc addObserverForName:AVAudioSessionInterruptionNotification object:nil queue:q - usingBlock:^(NSNotification *note) { - AVAudioSessionInterruptionType t = - [note.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue]; - const char *atom = (t == AVAudioSessionInterruptionTypeBegan) - ? "audio_interrupted" : "audio_resumed"; - mob_device_send_atom("mob_device", atom); - mob_device_send_atom("mob_device_ios", atom); - }]; - [nc addObserverForName:AVAudioSessionRouteChangeNotification object:nil queue:q - usingBlock:^(NSNotification *note) { - mob_device_send_atom("mob_device", "audio_route_changed"); - mob_device_send_atom("mob_device_ios", "audio_route_changed"); - }]; - - NSLog(@"[mob] Mob.Device observers registered"); + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + NSOperationQueue *q = [NSOperationQueue mainQueue]; + + // ── App lifecycle ── + [nc addObserverForName:UIApplicationWillResignActiveNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "will_resign_active"); + mob_device_send_atom("mob_device_ios", "will_resign_active"); + }]; + [nc addObserverForName:UIApplicationDidBecomeActiveNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "did_become_active"); + mob_device_send_atom("mob_device_ios", "did_become_active"); + }]; + [nc addObserverForName:UIApplicationDidEnterBackgroundNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "did_enter_background"); + mob_device_send_atom("mob_device_ios", "did_enter_background"); + }]; + [nc addObserverForName:UIApplicationWillEnterForegroundNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "will_enter_foreground"); + mob_device_send_atom("mob_device_ios", "will_enter_foreground"); + }]; + [nc addObserverForName:UIApplicationWillTerminateNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "will_terminate"); + mob_device_send_atom("mob_device_ios", "will_terminate"); + }]; + [nc addObserverForName:UIApplicationDidReceiveMemoryWarningNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "memory_warning"); + mob_device_send_atom("mob_device_ios", "memory_warning"); + }]; + + // ── Display / lock state (iOS proxies via data-protection) ── + [nc addObserverForName:UIApplicationProtectedDataWillBecomeUnavailable + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "screen_off"); + mob_device_send_atom("mob_device_ios", + "protected_data_will_become_unavailable"); + }]; + [nc addObserverForName:UIApplicationProtectedDataDidBecomeAvailable + object:nil + queue:q + usingBlock:^(NSNotification *n) { + mob_device_send_atom("mob_device", "screen_on"); + mob_device_send_atom("mob_device_ios", "protected_data_did_become_available"); + }]; + + // ── Power / thermal ── + [nc addObserverForName:NSProcessInfoThermalStateDidChangeNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + const char *s = thermal_state_atom([[NSProcessInfo processInfo] thermalState]); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM payload = enif_make_atom(e, s); + mob_device_send_atom_payload("mob_device", "thermal_state_changed", payload, e); + mob_device_send_atom_payload("mob_device_ios", "thermal_state_changed", payload, + e); + enif_free_env(e); + }]; + [nc addObserverForName:NSProcessInfoPowerStateDidChangeNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + BOOL low = [[NSProcessInfo processInfo] isLowPowerModeEnabled]; + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM payload = enif_make_atom(e, low ? "true" : "false"); + mob_device_send_atom_payload("mob_device", "low_power_mode_changed", payload, + e); + mob_device_send_atom_payload("mob_device_ios", "low_power_mode_changed", + payload, e); + enif_free_env(e); + }]; + + // Ensure battery monitoring is on so the change notifications fire. + dispatch_async(dispatch_get_main_queue(), ^{ + UIDevice *dev = [UIDevice currentDevice]; + if (!dev.batteryMonitoringEnabled) + dev.batteryMonitoringEnabled = YES; + }); + [nc addObserverForName:UIDeviceBatteryStateDidChangeNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + const char *s = battery_state_atom([[UIDevice currentDevice] batteryState]); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM payload = enif_make_atom(e, s); + mob_device_send_atom_payload("mob_device", "battery_state_changed", payload, e); + mob_device_send_atom_payload("mob_device_ios", "battery_state_changed", payload, + e); + enif_free_env(e); + }]; + [nc addObserverForName:UIDeviceBatteryLevelDidChangeNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + float lvl = [[UIDevice currentDevice] batteryLevel]; + int pct = lvl >= 0.0f ? (int)roundf(lvl * 100.0f) : -1; + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM payload = enif_make_int(e, pct); + mob_device_send_atom_payload("mob_device", "battery_level_changed", payload, e); + mob_device_send_atom_payload("mob_device_ios", "battery_level_changed", payload, + e); + enif_free_env(e); + }]; + + // ── Audio session interruptions / route changes ── + [nc addObserverForName:AVAudioSessionInterruptionNotification + object:nil + queue:q + usingBlock:^(NSNotification *note) { + AVAudioSessionInterruptionType t = + [note.userInfo[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue]; + const char *atom = (t == AVAudioSessionInterruptionTypeBegan) + ? "audio_interrupted" + : "audio_resumed"; + mob_device_send_atom("mob_device", atom); + mob_device_send_atom("mob_device_ios", atom); + }]; + [nc addObserverForName:AVAudioSessionRouteChangeNotification + object:nil + queue:q + usingBlock:^(NSNotification *note) { + mob_device_send_atom("mob_device", "audio_route_changed"); + mob_device_send_atom("mob_device_ios", "audio_route_changed"); + }]; + + // ── Orientation ── + dispatch_async(dispatch_get_main_queue(), ^{ + [[UIDevice currentDevice] beginGeneratingDeviceOrientationNotifications]; + }); + [nc addObserverForName:UIDeviceOrientationDidChangeNotification + object:nil + queue:q + usingBlock:^(NSNotification *n) { + // Report the *interface* orientation (skips face up/down), + // which is the one screens care about. + const char *s = interface_orientation_atom(mob_current_interface_orientation()); + if (strcmp(s, "unknown") == 0) + return; + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM payload = enif_make_atom(e, s); + mob_device_send_atom_payload("mob_device", "orientation_changed", payload, e); + enif_free_env(e); + }]; + + // ── Network connectivity ── + // NWPathMonitor delivers an initial snapshot shortly after start and on + // every subsequent change; the handler caches it and emits + // connectivity_changed. + ensure_path_monitor_once(); + + NSLog(@"[mob] Mob.Device observers registered"); }); } static ERL_NIF_TERM nif_device_set_dispatcher(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifPid pid; - if (!enif_get_local_pid(env, argv[0], &pid)) return enif_make_badarg(env); + if (!enif_get_local_pid(env, argv[0], &pid)) + return enif_make_badarg(env); g_device_dispatcher_pid = pid; g_device_dispatcher_set = YES; register_device_observers_once(); @@ -1446,10 +1697,11 @@ static ERL_NIF_TERM nif_device_set_dispatcher(ErlNifEnv *env, int argc, const ER // subscribers without polling. Use this rather than UITraitChange APIs because // SwiftUI handles iOS 13–17 compatibility for us. void mob_notify_color_scheme(const char *scheme) { - if (!g_device_dispatcher_set || !scheme) return; + if (!g_device_dispatcher_set || !scheme) + return; ErlNifEnv *e = enif_alloc_env(); ERL_NIF_TERM payload = enif_make_atom(e, scheme); - mob_device_send_atom_payload("mob_device", "color_scheme_changed", payload, e); + mob_device_send_atom_payload("mob_device", "color_scheme_changed", payload, e); mob_device_send_atom_payload("mob_device_ios", "color_scheme_changed", payload, e); enif_free_env(e); } @@ -1458,15 +1710,16 @@ static ERL_NIF_TERM nif_device_battery_state(ErlNifEnv *env, int argc, const ERL __block UIDeviceBatteryState s = UIDeviceBatteryStateUnknown; __block int pct = -1; dispatch_sync(dispatch_get_main_queue(), ^{ - UIDevice *dev = [UIDevice currentDevice]; - if (!dev.batteryMonitoringEnabled) dev.batteryMonitoringEnabled = YES; - s = dev.batteryState; - float f = dev.batteryLevel; - if (f >= 0.0f) pct = (int)roundf(f * 100.0f); + UIDevice *dev = [UIDevice currentDevice]; + if (!dev.batteryMonitoringEnabled) + dev.batteryMonitoringEnabled = YES; + s = dev.batteryState; + float f = dev.batteryLevel; + if (f >= 0.0f) + pct = (int)roundf(f * 100.0f); }); - return enif_make_tuple2(env, - enif_make_atom(env, battery_state_atom(s)), - enif_make_int(env, pct)); + return enif_make_tuple2(env, enif_make_atom(env, battery_state_atom(s)), + enif_make_int(env, pct)); } static ERL_NIF_TERM nif_device_thermal_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { @@ -1474,6 +1727,15 @@ static ERL_NIF_TERM nif_device_thermal_state(ErlNifEnv *env, int argc, const ERL return enif_make_atom(env, thermal_state_atom(s)); } +static ERL_NIF_TERM nif_device_network_state(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + // Start monitoring on the first cold query too, so the value populates even + // if the dispatcher handshake hasn't run. The first snapshot arrives async, + // so a query in the first few ms after boot may read the offline default. + ensure_path_monitor_once(); + return mob_make_network_map(env, atomic_load(&g_net_online), atomic_load(&g_net_transport), + atomic_load(&g_net_expensive), atomic_load(&g_net_constrained)); +} + static ERL_NIF_TERM nif_device_low_power_mode(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { BOOL low = [[NSProcessInfo processInfo] isLowPowerModeEnabled]; return enif_make_atom(env, low ? "true" : "false"); @@ -1482,7 +1744,7 @@ static ERL_NIF_TERM nif_device_low_power_mode(ErlNifEnv *env, int argc, const ER static ERL_NIF_TERM nif_device_foreground(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { __block UIApplicationState st = UIApplicationStateBackground; dispatch_sync(dispatch_get_main_queue(), ^{ - st = [UIApplication sharedApplication].applicationState; + st = [UIApplication sharedApplication].applicationState; }); return enif_make_atom(env, st == UIApplicationStateActive ? "true" : "false"); } @@ -1499,34 +1761,95 @@ static ERL_NIF_TERM nif_device_model(ErlNifEnv *env, int argc, const ERL_NIF_TER return enif_make_string(env, cstr ? cstr : "", ERL_NIF_LATIN1); } +static ERL_NIF_TERM nif_device_orientation(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + // interfaceOrientation must be read on the main thread. + __block UIInterfaceOrientation o = UIInterfaceOrientationUnknown; + if ([NSThread isMainThread]) + o = mob_current_interface_orientation(); + else + dispatch_sync(dispatch_get_main_queue(), ^{ + o = mob_current_interface_orientation(); + }); + return enif_make_atom(env, interface_orientation_atom(o)); +} + +static ERL_NIF_TERM nif_device_lock_orientation(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + (void)argc; + char name[32]; + if (enif_get_atom(env, argv[0], name, sizeof(name), ERL_NIF_LATIN1) == 0) + return enif_make_badarg(env); + + UIInterfaceOrientationMask mask = orientation_mask_for_atom(name); + g_locked_orientation_mask = mask; + + dispatch_async(dispatch_get_main_queue(), ^{ + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + UIWindowScene *ws = (UIWindowScene *)scene; + UIViewController *root = + ws.keyWindow.rootViewController ?: ws.windows.firstObject.rootViewController; + if (@available(iOS 16.0, *)) { + // The lock holds only if the root VC reports + // mob_locked_orientation_mask() from -supportedInterfaceOrientations + // (companion shell change). This requests the actual rotation. + [root setNeedsUpdateOfSupportedInterfaceOrientations]; + UIWindowSceneGeometryPreferencesIOS *prefs = + [[UIWindowSceneGeometryPreferencesIOS alloc] initWithInterfaceOrientations:mask]; + [ws requestGeometryUpdateWithPreferences:prefs + errorHandler:^(NSError *err) { + (void)err; + }]; + } + } + }); + return enif_make_atom(env, "ok"); +} + +// ── NIF: device_keep_awake/1 ────────────────────────────────────────────────── +// argv[0] is the boolean atom `true`/`false`. Disables the idle timer (auto-dim +// / auto-lock) while true. Must be set on the main thread. +static ERL_NIF_TERM nif_device_keep_awake(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + char name[8] = {0}; + enif_get_atom(env, argv[0], name, sizeof(name), ERL_NIF_LATIN1); + BOOL on = (strcmp(name, "true") == 0); + + dispatch_async(dispatch_get_main_queue(), ^{ + [UIApplication sharedApplication].idleTimerDisabled = on; + }); + return enif_make_atom(env, "ok"); +} + // ── NIF: safe_area/0 ───────────────────────────────────────────────────────── // Returns {Top, Right, Bottom, Left} in logical points (not pixels). // Must read UIWindow.safeAreaInsets on the main thread. -static ERL_NIF_TERM nif_safe_area(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_safe_area(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { __block UIEdgeInsets insets = UIEdgeInsetsZero; dispatch_sync(dispatch_get_main_queue(), ^{ - UIWindow* window = nil; - for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) { - if ([scene isKindOfClass:[UIWindowScene class]]) { - UIWindowScene* ws = (UIWindowScene*)scene; - window = ws.windows.firstObject; - break; - } - } - if (window) insets = window.safeAreaInsets; + UIWindow *window = nil; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if ([scene isKindOfClass:[UIWindowScene class]]) { + UIWindowScene *ws = (UIWindowScene *)scene; + window = ws.windows.firstObject; + break; + } + } + if (window) + insets = window.safeAreaInsets; }); - return enif_make_tuple4(env, - enif_make_double(env, insets.top), - enif_make_double(env, insets.right), - enif_make_double(env, insets.bottom), - enif_make_double(env, insets.left) - ); + return enif_make_tuple4( + env, enif_make_double(env, insets.top), enif_make_double(env, insets.right), + enif_make_double(env, insets.bottom), enif_make_double(env, insets.left)); } // ── NIF: log/1 ──────────────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_log(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_log(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char buf[4096] = {0}; ErlNifBinary bin; if (enif_inspect_binary(env, argv[0], &bin)) { @@ -1542,7 +1865,7 @@ static ERL_NIF_TERM nif_log(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) // ── NIF: log/2 ──────────────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_log2(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_log2(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char level[16] = {0}; char buf[4096] = {0}; enif_get_atom(env, argv[0], level, sizeof(level), ERL_NIF_LATIN1); @@ -1560,7 +1883,7 @@ static ERL_NIF_TERM nif_log2(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[] // ── NIF: set_transition/1 ───────────────────────────────────────────────────── -static ERL_NIF_TERM nif_set_transition(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_set_transition(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { enif_mutex_lock(tap_mutex); if (!enif_get_atom(env, argv[0], g_transition, sizeof(g_transition), ERL_NIF_LATIN1)) { enif_mutex_unlock(tap_mutex); @@ -1574,22 +1897,43 @@ static ERL_NIF_TERM nif_set_transition(ErlNifEnv* env, int argc, const ERL_NIF_T // Accepts a JSON binary, parses it to a MobNode tree, and pushes it to the // SwiftUI view model. Runs on the BEAM thread — MobViewModel dispatches to main. -static ERL_NIF_TERM nif_set_root(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +// nif_set_theme/1 — accept the resolved theme palette (as JSON) from +// Mob.Theme.set/1 and push it to the SwiftUI side. iOS doesn't use system +// chrome whose appearance depends on a global theme (we render every +// surface via mob's primitives with explicit color props), so the iOS +// implementation is a no-op that just confirms receipt. Kept here for +// symmetry with the Android implementation, which needs it to drive +// Material 3's NavigationBar / Button colour scheme. +static ERL_NIF_TERM nif_set_theme(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + return enif_make_atom(env, "ok"); +} + +static NSMutableDictionary *mob_frame_registry(void); // both defined with the +static void mob_clear_frames(void); // element frame registry below + +static ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSData* data = [NSData dataWithBytes:bin.data length:bin.size]; - NSError* err = nil; + // New render tree — drop stale element frames; MobFrameTracker repopulates + // on the next layout pass. + mob_clear_frames(); + + NSData *data = [NSData dataWithBytes:bin.data length:bin.size]; + NSError *err = nil; id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&err]; if (err || ![json isKindOfClass:[NSDictionary class]]) { LOGE(@"set_root: JSON parse error: %@", err); return enif_make_atom(env, "error"); } - MobNode* node = mob_node_from_dict((NSDictionary*)json); - if (!node) return enif_make_atom(env, "error"); + MobNode *node = mob_node_from_dict((NSDictionary *)json); + if (!node) + return enif_make_atom(env, "error"); // Snapshot and reset the transition enif_mutex_lock(tap_mutex); @@ -1597,9 +1941,15 @@ static ERL_NIF_TERM nif_set_root(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar strncpy(transition, g_transition, sizeof(transition) - 1); transition[sizeof(transition) - 1] = 0; strncpy(g_transition, "none", sizeof(g_transition)); + // Commit the freshly-built tap table: register_tap wrote this frame's + // handlers into 1 - tap_active; make that table active now so events for the + // new tree resolve against it (readers see a consistent pair under the lock). + tap_active = 1 - tap_active; + tap_handles = tap_tables[tap_active]; + tap_handle_next = tap_build_count; enif_mutex_unlock(tap_mutex); - NSString* transitionStr = [NSString stringWithUTF8String:transition]; + NSString *transitionStr = [NSString stringWithUTF8String:transition]; [[MobViewModel shared] setRoot:node transition:transitionStr]; return enif_make_atom(env, "ok"); @@ -1607,15 +1957,15 @@ static ERL_NIF_TERM nif_set_root(ErlNifEnv* env, int argc, const ERL_NIF_TERM ar // ── NIF: register_tap/1 ────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_register_tap(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; +static ERL_NIF_TERM nif_register_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; ERL_NIF_TERM tag_term; if (enif_get_local_pid(env, argv[0], &pid)) { tag_term = enif_make_atom(env, "ok"); } else { int arity; - const ERL_NIF_TERM* elems; + const ERL_NIF_TERM *elems; if (!enif_get_tuple(env, argv[0], &arity, &elems) || arity != 2) return enif_make_badarg(env); if (!enif_get_local_pid(env, elems[0], &pid)) @@ -1624,14 +1974,15 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv* env, int argc, const ERL_NIF_TER } enif_mutex_lock(tap_mutex); - if (tap_handle_next >= MAX_TAP_HANDLES) { + if (tap_build_count >= MAX_TAP_HANDLES) { enif_mutex_unlock(tap_mutex); return enif_make_badarg(env); } - int handle = tap_handle_next++; - tap_handles[handle].pid = pid; - tap_handles[handle].tag_env = enif_alloc_env(); - tap_handles[handle].tag = enif_make_copy(tap_handles[handle].tag_env, tag_term); + TapHandle *build = tap_tables[1 - tap_active]; + int handle = tap_build_count++; + build[handle].pid = pid; + build[handle].tag_env = enif_alloc_env(); + build[handle].tag = enif_make_copy(build[handle].tag_env, tag_term); enif_mutex_unlock(tap_mutex); return enif_make_int(env, handle); @@ -1639,25 +1990,29 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv* env, int argc, const ERL_NIF_TER // ── NIF: clear_taps/0 ───────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_clear_taps(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_clear_taps(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { enif_mutex_lock(tap_mutex); - for (int i = 0; i < tap_handle_next; i++) { - if (tap_handles[i].tag_env) { - enif_free_env(tap_handles[i].tag_env); - tap_handles[i].tag_env = NULL; + // Prepare the INACTIVE (building) table for a fresh frame; leave the active + // table intact so concurrent mob_send_* keep resolving the last committed + // frame. The freshly built table is swapped in at set_root. + TapHandle *build = tap_tables[1 - tap_active]; + for (int i = 0; i < MAX_TAP_HANDLES; i++) { + if (build[i].tag_env) { + enif_free_env(build[i].tag_env); + build[i].tag_env = NULL; } // Reset throttle state — slots get reused across renders. - tap_handles[i].throttle_ms = 0; - tap_handles[i].debounce_ms = 0; - tap_handles[i].delta_threshold = 0; - tap_handles[i].leading = 1; - tap_handles[i].trailing = 1; - tap_handles[i].last_emit_ns = 0; - tap_handles[i].last_x = 0; - tap_handles[i].last_y = 0; - tap_handles[i].seq = 0; + build[i].throttle_ms = 0; + build[i].debounce_ms = 0; + build[i].delta_threshold = 0; + build[i].leading = 1; + build[i].trailing = 1; + build[i].last_emit_ns = 0; + build[i].last_x = 0; + build[i].last_y = 0; + build[i].seq = 0; } - tap_handle_next = 0; + tap_build_count = 0; enif_mutex_unlock(tap_mutex); return enif_make_atom(env, "ok"); } @@ -1665,31 +2020,60 @@ static ERL_NIF_TERM nif_clear_taps(ErlNifEnv* env, int argc, const ERL_NIF_TERM // ── NIF: haptic/1 ───────────────────────────────────────────────────────────── // Triggers haptic feedback. Fire-and-forget; dispatched async to main thread. -static ERL_NIF_TERM nif_haptic(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_haptic(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char type[32] = {0}; enif_get_atom(env, argv[0], type, sizeof(type), ERL_NIF_LATIN1); - NSString* typeStr = [NSString stringWithUTF8String:type]; + NSString *typeStr = [NSString stringWithUTF8String:type]; dispatch_async(dispatch_get_main_queue(), ^{ - if ([typeStr isEqualToString:@"success"] || - [typeStr isEqualToString:@"error"] || - [typeStr isEqualToString:@"warning"]) { - UINotificationFeedbackGenerator* g = [[UINotificationFeedbackGenerator alloc] init]; - [g prepare]; - if ([typeStr isEqualToString:@"success"]) - [g notificationOccurred:UINotificationFeedbackTypeSuccess]; - else if ([typeStr isEqualToString:@"error"]) - [g notificationOccurred:UINotificationFeedbackTypeError]; - else - [g notificationOccurred:UINotificationFeedbackTypeWarning]; - } else { - UIImpactFeedbackStyle style = UIImpactFeedbackStyleMedium; - if ([typeStr isEqualToString:@"light"]) style = UIImpactFeedbackStyleLight; - if ([typeStr isEqualToString:@"heavy"]) style = UIImpactFeedbackStyleHeavy; - UIImpactFeedbackGenerator* g = [[UIImpactFeedbackGenerator alloc] initWithStyle:style]; - [g prepare]; - [g impactOccurred]; - } + if ([typeStr isEqualToString:@"success"] || [typeStr isEqualToString:@"error"] || + [typeStr isEqualToString:@"warning"]) { + UINotificationFeedbackGenerator *g = [[UINotificationFeedbackGenerator alloc] init]; + [g prepare]; + if ([typeStr isEqualToString:@"success"]) + [g notificationOccurred:UINotificationFeedbackTypeSuccess]; + else if ([typeStr isEqualToString:@"error"]) + [g notificationOccurred:UINotificationFeedbackTypeError]; + else + [g notificationOccurred:UINotificationFeedbackTypeWarning]; + } else { + UIImpactFeedbackStyle style = UIImpactFeedbackStyleMedium; + if ([typeStr isEqualToString:@"light"]) + style = UIImpactFeedbackStyleLight; + if ([typeStr isEqualToString:@"heavy"]) + style = UIImpactFeedbackStyleHeavy; + UIImpactFeedbackGenerator *g = [[UIImpactFeedbackGenerator alloc] initWithStyle:style]; + [g prepare]; + [g impactOccurred]; + } + }); + return enif_make_atom(env, "ok"); +} + +// ── NIF: torch/1 ────────────────────────────────────────────────────────────── +// Toggle the rear-camera torch. argv[0] is the atom `on` or `off`. No capture +// session and no camera permission needed. No-op (not an error) on a device +// without a torch — the simulator and most tablets have none. +static ERL_NIF_TERM nif_torch(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + char state[8] = {0}; + enif_get_atom(env, argv[0], state, sizeof(state), ERL_NIF_LATIN1); + BOOL on = (strcmp(state, "on") == 0); + + dispatch_async(dispatch_get_main_queue(), ^{ + AVCaptureDevice *device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; + if (!device || !device.hasTorch || !device.isTorchAvailable) + return; + NSError *err = nil; + if (![device lockForConfiguration:&err]) + return; + if (on) { + // setTorchModeOnWithLevel: validates the level and is preferred over + // the torchMode setter; max level = full brightness. + [device setTorchModeOnWithLevel:AVCaptureMaxAvailableTorchLevel error:NULL]; + } else { + device.torchMode = AVCaptureTorchModeOff; + } + [device unlockForConfiguration]; }); return enif_make_atom(env, "ok"); } @@ -1697,17 +2081,17 @@ static ERL_NIF_TERM nif_haptic(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv // ── NIF: clipboard_put/1 ────────────────────────────────────────────────────── // Writes a UTF-8 binary to the system clipboard. Fire-and-forget. -static ERL_NIF_TERM nif_clipboard_put(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_clipboard_put(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* text = [[NSString alloc] initWithBytes:bin.data + NSString *text = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; dispatch_async(dispatch_get_main_queue(), ^{ - [UIPasteboard generalPasteboard].string = text; + [UIPasteboard generalPasteboard].string = text; }); return enif_make_atom(env, "ok"); } @@ -1715,14 +2099,14 @@ static ERL_NIF_TERM nif_clipboard_put(ErlNifEnv* env, int argc, const ERL_NIF_TE // ── NIF: clipboard_get/0 ────────────────────────────────────────────────────── // Returns {:ok, Binary} or :empty. Synchronous (dispatch_sync to main thread). -static ERL_NIF_TERM nif_clipboard_get(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - __block NSString* text = nil; +static ERL_NIF_TERM nif_clipboard_get(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + __block NSString *text = nil; dispatch_sync(dispatch_get_main_queue(), ^{ - text = [UIPasteboard generalPasteboard].string; + text = [UIPasteboard generalPasteboard].string; }); if (text) { - const char* utf8 = [text UTF8String]; + const char *utf8 = [text UTF8String]; ErlNifBinary bin; size_t len = strlen(utf8); enif_alloc_binary(len, &bin); @@ -1733,60 +2117,203 @@ static ERL_NIF_TERM nif_clipboard_get(ErlNifEnv* env, int argc, const ERL_NIF_TE return enif_make_atom(env, "empty"); } +// ── NIF: tts_speak/2 ────────────────────────────────────────────────────────── +// Speaks UTF-8 text via AVSpeechSynthesizer. opts is a JSON object, all keys +// optional: {"rate": float, "pitch": float, "voice": "en-US"}. Fire-and-forget; +// a synthesizer is created lazily and kept alive so utterances can queue. + +static AVSpeechSynthesizer *g_tts_synth = nil; + +static ERL_NIF_TERM nif_tts_speak(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifBinary text_bin, opts_bin; + if (!enif_inspect_binary(env, argv[0], &text_bin) && + !enif_inspect_iolist_as_binary(env, argv[0], &text_bin)) + return enif_make_badarg(env); + if (!enif_inspect_binary(env, argv[1], &opts_bin) && + !enif_inspect_iolist_as_binary(env, argv[1], &opts_bin)) + return enif_make_badarg(env); + + NSString *text = [[NSString alloc] initWithBytes:text_bin.data + length:text_bin.size + encoding:NSUTF8StringEncoding]; + NSData *optsData = [NSData dataWithBytes:opts_bin.data length:opts_bin.size]; + + dispatch_async(dispatch_get_main_queue(), ^{ + if (!g_tts_synth) + g_tts_synth = [[AVSpeechSynthesizer alloc] init]; + + AVSpeechUtterance *utt = [AVSpeechUtterance speechUtteranceWithString:text]; + + NSDictionary *opts = [NSJSONSerialization JSONObjectWithData:optsData options:0 error:nil]; + if ([opts isKindOfClass:[NSDictionary class]]) { + NSNumber *rate = opts[@"rate"]; + if ([rate isKindOfClass:[NSNumber class]]) + utt.rate = [rate floatValue]; + NSNumber *pitch = opts[@"pitch"]; + if ([pitch isKindOfClass:[NSNumber class]]) + utt.pitchMultiplier = [pitch floatValue]; + NSString *voice = opts[@"voice"]; + if ([voice isKindOfClass:[NSString class]]) { + AVSpeechSynthesisVoice *v = [AVSpeechSynthesisVoice voiceWithLanguage:voice]; + if (v) + utt.voice = v; + } + } + + [g_tts_synth speakUtterance:utt]; + }); + return enif_make_atom(env, "ok"); +} + +// ── NIF: tts_stop/0 ─────────────────────────────────────────────────────────── +// Stops any in-progress speech immediately. Fire-and-forget. + +static ERL_NIF_TERM nif_tts_stop(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [g_tts_synth stopSpeakingAtBoundary:AVSpeechBoundaryImmediate]; + }); + return enif_make_atom(env, "ok"); +} + // ── NIF: open_url/1 ─────────────────────────────────────────────────────────── // Hands a URL to the OS to open in the user's default browser/app. // Fire-and-forget; returns :ok immediately. -static ERL_NIF_TERM nif_open_url(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_open_url(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* str = [[NSString alloc] initWithBytes:bin.data + NSString *str = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; - NSURL* url = [NSURL URLWithString:str]; - if (!url) return enif_make_badarg(env); + NSURL *url = [NSURL URLWithString:str]; + if (!url) + return enif_make_badarg(env); + + dispatch_async(dispatch_get_main_queue(), ^{ + [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; + }); + return enif_make_atom(env, "ok"); +} + +// ── NIF: open_settings/1 ────────────────────────────────────────────────────── +// Opens this app's settings page. The target arg (app|notifications|exact_alarm) +// is honored on Android; iOS exposes only the single app settings page, so the +// target is validated but otherwise ignored. Fire-and-forget; returns :ok. + +static ERL_NIF_TERM nif_open_settings(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifBinary bin; + if (!enif_inspect_binary(env, argv[0], &bin) && + !enif_inspect_iolist_as_binary(env, argv[0], &bin)) + return enif_make_badarg(env); dispatch_async(dispatch_get_main_queue(), ^{ - [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; + NSURL *url = [NSURL URLWithString:UIApplicationOpenSettingsURLString]; + if (url) + [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; }); return enif_make_atom(env, "ok"); } +// nif_audio_output_status/0 — {Volume, Muted, RouteCode, OtherAudio} as four +// doubles (decoded by Mob.Audio.output_status/0). iOS has no direct mute flag, +// so Muted is inferred from outputVolume == 0. RouteCode mirrors the Android +// encoding: 1=speaker, 2=headphones, 3=bluetooth, 4=receiver, 0=none. +static ERL_NIF_TERM nif_audio_output_status(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + AVAudioSession *session = [AVAudioSession sharedInstance]; + double volume = (double)session.outputVolume; + double other = session.isOtherAudioPlaying ? 1.0 : 0.0; + double route = 0.0; + for (AVAudioSessionPortDescription *out in session.currentRoute.outputs) { + NSString *t = out.portType; + if ([t isEqualToString:AVAudioSessionPortBuiltInSpeaker]) + route = 1.0; + else if ([t isEqualToString:AVAudioSessionPortHeadphones]) + route = 2.0; + else if ([t isEqualToString:AVAudioSessionPortBluetoothA2DP] || + [t isEqualToString:AVAudioSessionPortBluetoothLE] || + [t isEqualToString:AVAudioSessionPortBluetoothHFP]) + route = 3.0; + else if ([t isEqualToString:AVAudioSessionPortBuiltInReceiver]) + route = 4.0; + if (route != 0.0) + break; + } + double muted = (volume <= 0.0) ? 1.0 : 0.0; + return enif_make_tuple4(env, enif_make_double(env, volume), enif_make_double(env, muted), + enif_make_double(env, route), enif_make_double(env, other)); +} + +// nif_audio_output_level/1 — {RmsDb, PeakDb} as two doubles, or an error atom. +// iOS cannot tap the global output mix (sandbox), so "mix" is unsupported; +// "mob" meters Mob.Audio's own AVAudioPlayer (metering is enabled when the +// player is created). +// +// Forward-declare the play/1 player: it lives with the audio-playback globals +// defined further below, but output_level/1 (here) meters that same player. +static AVAudioPlayer *g_audio_player; +static ERL_NIF_TERM nif_audio_output_level(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifBinary bin; + if (!enif_inspect_binary(env, argv[0], &bin) && + !enif_inspect_iolist_as_binary(env, argv[0], &bin)) + return enif_make_badarg(env); + NSString *source = [[NSString alloc] initWithBytes:bin.data + length:bin.size + encoding:NSUTF8StringEncoding]; + if (![source isEqualToString:@"mob"]) + return enif_make_atom(env, "unsupported_on_platform"); + + __block double rms = -160.0; + __block double peak = -160.0; + __block BOOL playing = NO; + dispatch_sync(dispatch_get_main_queue(), ^{ + AVAudioPlayer *player = g_audio_player; + if (player && player.playing) { + playing = YES; + [player updateMeters]; + rms = (double)[player averagePowerForChannel:0]; + peak = (double)[player peakPowerForChannel:0]; + } + }); + if (!playing) + return enif_make_atom(env, "not_playing"); + return enif_make_tuple2(env, enif_make_double(env, rms), enif_make_double(env, peak)); +} + // ── NIF: share_text/1 ───────────────────────────────────────────────────────── // Opens the iOS share sheet with plain text. Fire-and-forget. -static ERL_NIF_TERM nif_share_text(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_share_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* text = [[NSString alloc] initWithBytes:bin.data + NSString *text = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; dispatch_async(dispatch_get_main_queue(), ^{ - UIActivityViewController* vc = - [[UIActivityViewController alloc] initWithActivityItems:@[text] - applicationActivities:nil]; - UIViewController* root = nil; - for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) { - if ([scene isKindOfClass:[UIWindowScene class]]) { - root = ((UIWindowScene*)scene).windows.firstObject.rootViewController; - break; - } - } - if (root) { - if (vc.popoverPresentationController) { - vc.popoverPresentationController.sourceView = root.view; - CGRect r = root.view.bounds; - vc.popoverPresentationController.sourceRect = - CGRectMake(CGRectGetMidX(r), CGRectGetMidY(r), 0, 0); - } - [root presentViewController:vc animated:YES completion:nil]; - } + UIActivityViewController *vc = + [[UIActivityViewController alloc] initWithActivityItems:@[ text ] + applicationActivities:nil]; + UIViewController *root = nil; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if ([scene isKindOfClass:[UIWindowScene class]]) { + root = ((UIWindowScene *)scene).windows.firstObject.rootViewController; + break; + } + } + if (root) { + if (vc.popoverPresentationController) { + vc.popoverPresentationController.sourceView = root.view; + CGRect r = root.view.bounds; + vc.popoverPresentationController.sourceRect = + CGRectMake(CGRectGetMidX(r), CGRectGetMidY(r), 0, 0); + } + [root presentViewController:vc animated:YES completion:nil]; + } }); return enif_make_atom(env, "ok"); } @@ -1798,29 +2325,30 @@ static ERL_NIF_TERM nif_share_text(ErlNifEnv* env, int argc, const ERL_NIF_TERM // ── Shared helpers ───────────────────────────────────────────────────────── // Build and send {atom1, atom2} to a pid from any thread. -static void mob_send2(const ErlNifPid* pid, const char* a1, const char* a2) { - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,a1), enif_make_atom(e,a2)); - enif_send(NULL, (ErlNifPid*)pid, e, msg); +static void mob_send2(const ErlNifPid *pid, const char *a1, const char *a2) { + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, a1), enif_make_atom(e, a2)); + enif_send(NULL, (ErlNifPid *)pid, e, msg); enif_free_env(e); } // Build and send {atom1, atom2, atom3} to a pid from any thread. -static void mob_send3(const ErlNifPid* pid, const char* a1, const char* a2, const char* a3) { - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e,a1), enif_make_atom(e,a2), enif_make_atom(e,a3)); - enif_send(NULL, (ErlNifPid*)pid, e, msg); +static void mob_send3(const ErlNifPid *pid, const char *a1, const char *a2, const char *a3) { + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, a1), enif_make_atom(e, a2), enif_make_atom(e, a3)); + enif_send(NULL, (ErlNifPid *)pid, e, msg); enif_free_env(e); } // Return the root view controller of the key window in the first active scene. -static UIViewController* mob_root_vc(void) { - for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) { +static UIViewController *mob_root_vc(void) { + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { if ([scene isKindOfClass:[UIWindowScene class]]) { - UIWindowScene* ws = (UIWindowScene*)scene; - UIWindow* w = ws.keyWindow ?: ws.windows.firstObject; - if (w.rootViewController) return w.rootViewController; + UIWindowScene *ws = (UIWindowScene *)scene; + UIWindow *w = ws.keyWindow ?: ws.windows.firstObject; + if (w.rootViewController) + return w.rootViewController; } } return nil; @@ -1829,45 +2357,50 @@ static void mob_send3(const ErlNifPid* pid, const char* a1, const char* a2, cons // ── Launch notification global ───────────────────────────────────────────── // Written by mob_set_launch_notification_json() (called from app delegate); // read and cleared by nif_take_launch_notification. -static char* g_launch_notification_json = NULL; -static ErlNifMutex* g_launch_notif_mutex = NULL; +static char *g_launch_notification_json = NULL; +static ErlNifMutex *g_launch_notif_mutex = NULL; @interface MobNotificationDelegate : NSObject <UNUserNotificationCenterDelegate> -@property (nonatomic) ErlNifPid screenPid; +@property(nonatomic) ErlNifPid screenPid; @end -static MobNotificationDelegate* g_notif_delegate; +static MobNotificationDelegate *g_notif_delegate; // Called from AppDelegate didRegisterForRemoteNotificationsWithDeviceToken. // Sends {:push_token, :ios, token_hex_string} to the registered screen process. -void mob_send_push_token(const char* hex_token) { - if (!g_notif_delegate) return; +void mob_send_push_token(const char *hex_token) { + if (!g_notif_delegate) + return; ErlNifPid p = g_notif_delegate.screenPid; - ErlNifEnv* e = enif_alloc_env(); + ErlNifEnv *e = enif_alloc_env(); size_t len = strlen(hex_token); - ErlNifBinary tb; enif_alloc_binary(len, &tb); memcpy(tb.data, hex_token, len); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e,"push_token"), - enif_make_atom(e,"ios"), - enif_make_binary(e,&tb)); + ErlNifBinary tb; + enif_alloc_binary(len, &tb); + memcpy(tb.data, hex_token, len); + ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "push_token"), + enif_make_atom(e, "ios"), enif_make_binary(e, &tb)); enif_send(NULL, &p, e, msg); enif_free_env(e); } -void mob_set_launch_notification_json(const char* json) { - if (!g_launch_notif_mutex) return; +void mob_set_launch_notification_json(const char *json) { + if (!g_launch_notif_mutex) + return; enif_mutex_lock(g_launch_notif_mutex); free(g_launch_notification_json); g_launch_notification_json = json ? strdup(json) : NULL; enif_mutex_unlock(g_launch_notif_mutex); } -static ERL_NIF_TERM nif_take_launch_notification(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - if (!g_launch_notif_mutex) return enif_make_atom(env, "none"); +static ERL_NIF_TERM nif_take_launch_notification(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + if (!g_launch_notif_mutex) + return enif_make_atom(env, "none"); enif_mutex_lock(g_launch_notif_mutex); - char* json = g_launch_notification_json; + char *json = g_launch_notification_json; g_launch_notification_json = NULL; enif_mutex_unlock(g_launch_notif_mutex); - if (!json) return enif_make_atom(env, "none"); + if (!json) + return enif_make_atom(env, "none"); ErlNifBinary bin; size_t len = strlen(json); enif_alloc_binary(len, &bin); @@ -1876,500 +2409,477 @@ static ERL_NIF_TERM nif_take_launch_notification(ErlNifEnv* env, int argc, const return enif_make_binary(env, &bin); } -// ── Permission request ──────────────────────────────────────────────────── +// ── Opened-document ("open with") ────────────────────────────────────────── +// +// When another app hands us a file to open — e.g. a `.livemd` emailed to the +// user and tapped, routed to us because Info.plist declares the document type — +// iOS calls `application:openURL:options:`, which forwards the URL here. +// +// Two delivery paths, because the file can arrive either before or after the +// root screen has mounted: +// * Cold launch: store the item JSON; `nif_take_opened_document` hands it to +// the screen at mount (same store-and-take shape as the launch notification). +// * Warm (app already running): if the screen registered a pid (it does so by +// calling take_opened_document/0 at mount), also `enif_send` it immediately +// as `{:files, :opened, %{path,name,mime,size}}` — parallel to files_pick's +// `{:files, :picked, …}`. +static char *g_opened_document_json = NULL; +static ErlNifMutex *g_opened_doc_mutex = NULL; +static ErlNifPid g_opened_doc_pid; +static BOOL g_opened_doc_pid_set = NO; + +// Build the `{:files, :opened, %{...}}` map term in `e` from an item NSDictionary. +static ERL_NIF_TERM mob_opened_doc_term(ErlNifEnv *e, NSString *path, NSString *name, + NSString *mime, long long size) { + const char *cpath = path.UTF8String, *cname = name.UTF8String, *cmime = mime.UTF8String; + ErlNifBinary pb, nb, mb; + enif_alloc_binary(strlen(cpath), &pb); + memcpy(pb.data, cpath, strlen(cpath)); + enif_alloc_binary(strlen(cname), &nb); + memcpy(nb.data, cname, strlen(cname)); + enif_alloc_binary(strlen(cmime), &mb); + memcpy(mb.data, cmime, strlen(cmime)); + ERL_NIF_TERM keys[4] = {enif_make_atom(e, "path"), enif_make_atom(e, "name"), + enif_make_atom(e, "mime"), enif_make_atom(e, "size")}; + ERL_NIF_TERM vals[4] = {enif_make_binary(e, &pb), enif_make_binary(e, &nb), + enif_make_binary(e, &mb), enif_make_int64(e, size)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 4, &map); + return map; +} -static ERL_NIF_TERM nif_request_permission(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char cap[32]; - if (!enif_get_atom(env, argv[0], cap, sizeof(cap), ERL_NIF_LATIN1)) - return enif_make_badarg(env); - ErlNifPid pid; - enif_self(env, &pid); +// Called from AppDelegate application:openURL:options:. Copies the (possibly +// security-scoped) file into the app's tmp dir so the BEAM can read it after the +// originating app's grant goes away, then stores it for take + warm-sends it. +void mob_handle_opened_url(const char *url_cstr) { + if (!url_cstr) + return; + NSURL *url = [NSURL fileURLWithPath:[NSString stringWithUTF8String:url_cstr]]; + if (!url.isFileURL) { + url = [NSURL URLWithString:[NSString stringWithUTF8String:url_cstr]]; + if (!url.isFileURL) + return; + } + BOOL scoped = [url startAccessingSecurityScopedResource]; + NSString *name = url.lastPathComponent.length ? url.lastPathComponent : @"document"; + NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:name]; + [[NSFileManager defaultManager] removeItemAtPath:tmp error:nil]; + NSError *err = nil; + [[NSFileManager defaultManager] copyItemAtURL:url toURL:[NSURL fileURLWithPath:tmp] error:&err]; + if (scoped) + [url stopAccessingSecurityScopedResource]; + if (err) { + NSLog(@"[Mob] open: copy failed for %@: %@", url, err); + return; + } + long long sz = + [[[NSFileManager defaultManager] attributesOfItemAtPath:tmp + error:nil][NSFileSize] longLongValue]; + NSString *mime = @"application/octet-stream"; + UTType *ut = [UTType typeWithFilenameExtension:url.pathExtension]; + if (ut.preferredMIMEType) + mime = ut.preferredMIMEType; + + NSDictionary *item = @{@"path" : tmp, @"name" : name, @"mime" : mime, @"size" : @(sz)}; + NSData *jd = [NSJSONSerialization dataWithJSONObject:item options:0 error:nil]; + NSString *json = [[NSString alloc] initWithData:jd encoding:NSUTF8StringEncoding]; + + if (json) { + // Store even if the mutex isn't up yet: at a cold launch openURL can + // fire before the BEAM has loaded the NIF (nif_load creates the mutex), + // and nothing reads the global until take_opened_document, so there's no + // concurrent access to guard against in that window. + if (g_opened_doc_mutex) + enif_mutex_lock(g_opened_doc_mutex); + free(g_opened_document_json); + g_opened_document_json = strdup(json.UTF8String); + if (g_opened_doc_mutex) + enif_mutex_unlock(g_opened_doc_mutex); + } - if (strcmp(cap, "camera") == 0 || strcmp(cap, "microphone") == 0) { - AVMediaType mtype = strcmp(cap, "camera") == 0 - ? AVMediaTypeVideo : AVMediaTypeAudio; - NSString* capStr = [NSString stringWithUTF8String:cap]; - [AVCaptureDevice requestAccessForMediaType:mtype completionHandler:^(BOOL granted) { - mob_send3(&pid, "permission", capStr.UTF8String, granted ? "granted" : "denied"); - }]; - } else if (strcmp(cap, "photo_library") == 0) { - [PHPhotoLibrary requestAuthorizationForAccessLevel:PHAccessLevelReadWrite - handler:^(PHAuthorizationStatus status) { - BOOL ok = (status == PHAuthorizationStatusAuthorized || - status == PHAuthorizationStatusLimited); - mob_send3(&pid, "permission", "photo_library", ok ? "granted" : "denied"); - }]; - } else if (strcmp(cap, "location") == 0) { - // Location permission is requested via CLLocationManager when get_once/start are called. - // Here we just signal granted for iOS (the actual dialog shows at location call time). - mob_send3(&pid, "permission", "location", "granted"); - } else if (strcmp(cap, "notifications") == 0) { - UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; - [center requestAuthorizationWithOptions: - UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge - completionHandler:^(BOOL granted, NSError* err) { - mob_send3(&pid, "permission", "notifications", granted ? "granted" : "denied"); - }]; - } else { - return enif_make_badarg(env); + if (g_opened_doc_pid_set) { + ErlNifPid p = g_opened_doc_pid; + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM map = mob_opened_doc_term(e, tmp, name, mime, sz); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, "files"), enif_make_atom(e, "opened"), map); + enif_send(NULL, &p, e, msg); + enif_free_env(e); } - return enif_make_atom(env, "ok"); } -// ── Biometric authentication ────────────────────────────────────────────── - -static ERL_NIF_TERM nif_biometric_authenticate(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +// take_opened_document/0 — returns the pending opened-document item JSON binary +// (or :none), AND registers the caller as the warm-delivery pid for any file +// opened later while the app is running. Call once from the root screen mount. +static ERL_NIF_TERM nif_take_opened_document(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + enif_self(env, &g_opened_doc_pid); + g_opened_doc_pid_set = YES; + if (!g_opened_doc_mutex) + return enif_make_atom(env, "none"); + enif_mutex_lock(g_opened_doc_mutex); + char *json = g_opened_document_json; + g_opened_document_json = NULL; + enif_mutex_unlock(g_opened_doc_mutex); + if (!json) + return enif_make_atom(env, "none"); ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - NSString* reason = [[NSString alloc] initWithBytes:bin.data length:bin.size - encoding:NSUTF8StringEncoding]; - ErlNifPid pid; enif_self(env, &pid); - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - LAContext* ctx = [[LAContext alloc] init]; - NSError* err = nil; - if ([ctx canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics error:&err]) { - [ctx evaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics - localizedReason:reason reply:^(BOOL ok, NSError* e) { - mob_send2(&pid, "biometric", ok ? "success" : "failure"); - }]; - } else { - mob_send2(&pid, "biometric", "not_available"); - } - }); - return enif_make_atom(env, "ok"); + size_t len = strlen(json); + enif_alloc_binary(len, &bin); + memcpy(bin.data, json, len); + free(json); + return enif_make_binary(env, &bin); } -// ── Location ────────────────────────────────────────────────────────────── - -@interface MobLocationDelegate : NSObject <CLLocationManagerDelegate> -@property (nonatomic) ErlNifPid pid; -@property (nonatomic) BOOL oneShot; -@end - -static MobLocationDelegate* g_location_delegate = nil; -static CLLocationManager* g_location_manager = nil; - -@implementation MobLocationDelegate -- (void)locationManager:(CLLocationManager*)mgr didUpdateLocations:(NSArray<CLLocation*>*)locs { - CLLocation* loc = locs.lastObject; - if (!loc) return; - ErlNifPid p = self.pid; - double lat = loc.coordinate.latitude; - double lon = loc.coordinate.longitude; - double acc = loc.horizontalAccuracy; - double alt = loc.altitude; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM keys[4] = { - enif_make_atom(e,"lat"), enif_make_atom(e,"lon"), - enif_make_atom(e,"accuracy"), enif_make_atom(e,"altitude") - }; - ERL_NIF_TERM vals[4] = { - enif_make_double(e,lat), enif_make_double(e,lon), - enif_make_double(e,acc), enif_make_double(e,alt) - }; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 4, &map); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,"location"), map); - enif_send(NULL, &p, e, msg); - enif_free_env(e); - }); - if (self.oneShot) [mgr stopUpdatingLocation]; -} -- (void)locationManager:(CLLocationManager*)mgr didFailWithError:(NSError*)err { - ErlNifPid p = self.pid; - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e,"location"), enif_make_atom(e,"error"), - enif_make_atom(e,"unavailable")); - enif_send(NULL, &p, e, msg); - enif_free_env(e); -} -@end +// ── Permission request ──────────────────────────────────────────────────── -static void setup_location_manager(ErlNifPid pid, BOOL oneShot, NSString* accuracy) { - dispatch_async(dispatch_get_main_queue(), ^{ - if (!g_location_manager) { - g_location_manager = [[CLLocationManager alloc] init]; +// ── Plugin permission registry ──────────────────────────────────────────── +// A plugin that owns a runtime permission capability (e.g. mob_location) ships +// its own C/ObjC permission handler and registers it from its NIF's load +// callback via mob_register_permission_handler. nif_request_permission falls +// through to this table for any capability core does not handle directly, so a +// capability can leave core without losing the unified +// Mob.Permissions.request/2 API. The handler drives the native permission API +// and delivers {:permission, cap, :granted|:denied} to `pid` itself (the plugin +// links erl_nif). Registration happens once at NIF load (BEAM boot); lookup +// happens later on a scheduler thread — single-write-then-read, no lock (same +// pattern as core's other boot-time globals). + +typedef void (*MobPermissionHandler)(ErlNifPid pid); + +#define MOB_MAX_PERMISSION_HANDLERS 16 +static struct { + char cap[32]; + MobPermissionHandler fn; +} g_permission_handlers[MOB_MAX_PERMISSION_HANDLERS]; +static int g_permission_handler_count = 0; + +// Exported (non-static) so a plugin object linked into the same static binary +// can call it. A plugin declares: +// extern void mob_register_permission_handler(const char *cap, +// void (*fn)(ErlNifPid)); +void mob_register_permission_handler(const char *cap, MobPermissionHandler fn) { + if (!cap || !fn) + return; + for (int i = 0; i < g_permission_handler_count; i++) { + if (strcmp(g_permission_handlers[i].cap, cap) == 0) { + g_permission_handlers[i].fn = fn; // last registration wins + return; } - g_location_delegate = [[MobLocationDelegate alloc] init]; - g_location_delegate.pid = pid; - g_location_delegate.oneShot = oneShot; - g_location_manager.delegate = g_location_delegate; - if ([accuracy isEqualToString:@"high"]) { - g_location_manager.desiredAccuracy = kCLLocationAccuracyBest; - } else if ([accuracy isEqualToString:@"low"]) { - g_location_manager.desiredAccuracy = kCLLocationAccuracyKilometer; - } else { - g_location_manager.desiredAccuracy = kCLLocationAccuracyHundredMeters; + } + if (g_permission_handler_count >= MOB_MAX_PERMISSION_HANDLERS) + return; + strncpy(g_permission_handlers[g_permission_handler_count].cap, cap, 31); + g_permission_handlers[g_permission_handler_count].cap[31] = '\0'; + g_permission_handlers[g_permission_handler_count].fn = fn; + g_permission_handler_count++; +} + +// Invokes a plugin-registered handler for `cap`. Returns YES if one ran. +static BOOL mob_dispatch_plugin_permission(const char *cap, ErlNifPid pid) { + for (int i = 0; i < g_permission_handler_count; i++) { + if (strcmp(g_permission_handlers[i].cap, cap) == 0) { + g_permission_handlers[i].fn(pid); + return YES; } - [g_location_manager requestWhenInUseAuthorization]; - [g_location_manager startUpdatingLocation]; - }); -} - -static ERL_NIF_TERM nif_location_get_once(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); - setup_location_manager(pid, YES, @"balanced"); - return enif_make_atom(env, "ok"); + } + return NO; } -static ERL_NIF_TERM nif_location_start(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - char acc[16] = "balanced"; - enif_get_atom(env, argv[0], acc, sizeof(acc), ERL_NIF_LATIN1); - ErlNifPid pid; enif_self(env, &pid); - setup_location_manager(pid, NO, [NSString stringWithUTF8String:acc]); - return enif_make_atom(env, "ok"); -} +static ERL_NIF_TERM nif_request_permission(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + char cap[32]; + if (!enif_get_atom(env, argv[0], cap, sizeof(cap), ERL_NIF_LATIN1)) + return enif_make_badarg(env); + ErlNifPid pid; + enif_self(env, &pid); -static ERL_NIF_TERM nif_location_stop(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - dispatch_async(dispatch_get_main_queue(), ^{ - [g_location_manager stopUpdatingLocation]; - }); + if (strcmp(cap, "microphone") == 0) { + // :microphone stays in core (audio recording needs it). :camera moved to + // the mob_camera plugin — it falls through to mob_dispatch_plugin_permission. + [AVCaptureDevice + requestAccessForMediaType:AVMediaTypeAudio + completionHandler:^(BOOL granted) { + mob_send3(&pid, "permission", "microphone", granted ? "granted" : "denied"); + }]; + } else if (strcmp(cap, "photo_library") == 0) { + [PHPhotoLibrary + requestAuthorizationForAccessLevel:PHAccessLevelReadWrite + handler:^(PHAuthorizationStatus status) { + BOOL ok = (status == PHAuthorizationStatusAuthorized || + status == PHAuthorizationStatusLimited); + mob_send3(&pid, "permission", "photo_library", + ok ? "granted" : "denied"); + }]; + } else if (strcmp(cap, "notifications") == 0) { + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + [center + requestAuthorizationWithOptions:UNAuthorizationOptionAlert | + UNAuthorizationOptionSound | UNAuthorizationOptionBadge + completionHandler:^(BOOL granted, NSError *err) { + mob_send3(&pid, "permission", "notifications", + granted ? "granted" : "denied"); + }]; + } else { + // Fall through to a plugin-registered capability (e.g. mob_location + // once :location leaves core). Unknown → badarg. + if (!mob_dispatch_plugin_permission(cap, pid)) + return enif_make_badarg(env); + } return enif_make_atom(env, "ok"); } -// ── Camera capture ──────────────────────────────────────────────────────── +// ── File picker ─────────────────────────────────────────────────────────── -@interface MobCameraDelegate : NSObject <UIImagePickerControllerDelegate, UINavigationControllerDelegate> -@property (nonatomic) ErlNifPid pid; -@property (nonatomic) BOOL isVideo; +@interface MobFilesDelegate : NSObject <UIDocumentPickerDelegate> +@property(nonatomic) ErlNifPid pid; @end -static MobCameraDelegate* g_camera_delegate = nil; +static MobFilesDelegate *g_files_delegate = nil; -@implementation MobCameraDelegate -- (void)imagePickerController:(UIImagePickerController*)picker - didFinishPickingMediaWithInfo:(NSDictionary<UIImagePickerControllerInfoKey,id>*)info { - [picker dismissViewControllerAnimated:YES completion:nil]; +@implementation MobFilesDelegate +- (void)documentPicker:(UIDocumentPickerViewController *)ctrl + didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls { + if (urls.count == 0) { + mob_send2(&_pid, "files", "cancelled"); + g_files_delegate = nil; + return; + } ErlNifPid p = self.pid; - BOOL isVid = self.isVideo; - g_camera_delegate = nil; - + g_files_delegate = nil; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg; - if (!isVid) { - UIImage* img = info[UIImagePickerControllerOriginalImage]; - NSString* tmp = [NSTemporaryDirectory() stringByAppendingPathComponent: - [NSString stringWithFormat:@"mob_photo_%@.jpg", [NSUUID UUID].UUIDString]]; - [UIImageJPEGRepresentation(img, 0.9) writeToFile:tmp atomically:YES]; - const char* path = tmp.UTF8String; - ErlNifBinary pbin; enif_alloc_binary(strlen(path), &pbin); - memcpy(pbin.data, path, strlen(path)); - ERL_NIF_TERM keys[3] = {enif_make_atom(e,"path"),enif_make_atom(e,"width"),enif_make_atom(e,"height")}; - ERL_NIF_TERM vals[3] = {enif_make_binary(e,&pbin), - enif_make_int(e,(int)img.size.width), enif_make_int(e,(int)img.size.height)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 3, &map); - msg = enif_make_tuple3(e, enif_make_atom(e,"camera"), enif_make_atom(e,"photo"), map); - } else { - NSURL* url = info[UIImagePickerControllerMediaURL]; - NSString* tmp = [NSTemporaryDirectory() stringByAppendingPathComponent: - [NSString stringWithFormat:@"mob_video_%@.mp4", [NSUUID UUID].UUIDString]]; - if (url) [[NSFileManager defaultManager] copyItemAtPath:url.path toPath:tmp error:nil]; - const char* path = tmp.UTF8String; - ErlNifBinary pbin; enif_alloc_binary(strlen(path), &pbin); - memcpy(pbin.data, path, strlen(path)); - ERL_NIF_TERM keys[2] = {enif_make_atom(e,"path"), enif_make_atom(e,"duration")}; - ERL_NIF_TERM vals[2] = {enif_make_binary(e,&pbin), enif_make_double(e,0.0)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 2, &map); - msg = enif_make_tuple3(e, enif_make_atom(e,"camera"), enif_make_atom(e,"video"), map); - } - enif_send(NULL, &p, e, msg); - enif_free_env(e); + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM list = enif_make_list(e, 0); + for (NSURL *url in urls.reverseObjectEnumerator) { + [url startAccessingSecurityScopedResource]; + NSString *name = url.lastPathComponent; + NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:name]; + [[NSFileManager defaultManager] copyItemAtURL:url + toURL:[NSURL fileURLWithPath:tmp] + error:nil]; + [url stopAccessingSecurityScopedResource]; + NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:tmp + error:nil]; + long long sz = [attrs[NSFileSize] longLongValue]; + const char *path = tmp.UTF8String; + const char *nm = name.UTF8String; + ErlNifBinary pb; + enif_alloc_binary(strlen(path), &pb); + memcpy(pb.data, path, strlen(path)); + ErlNifBinary nb; + enif_alloc_binary(strlen(nm), &nb); + memcpy(nb.data, nm, strlen(nm)); + ERL_NIF_TERM keys[3] = {enif_make_atom(e, "path"), enif_make_atom(e, "name"), + enif_make_atom(e, "size")}; + ERL_NIF_TERM vals[3] = {enif_make_binary(e, &pb), enif_make_binary(e, &nb), + enif_make_int64(e, sz)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 3, &map); + list = enif_make_list_cell(e, map, list); + } + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, "files"), enif_make_atom(e, "picked"), list); + enif_send(NULL, &p, e, msg); + enif_free_env(e); }); } -- (void)imagePickerControllerDidCancel:(UIImagePickerController*)picker { - [picker dismissViewControllerAnimated:YES completion:nil]; - mob_send2(&_pid, "camera", "cancelled"); - g_camera_delegate = nil; +- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)ctrl { + mob_send2(&_pid, "files", "cancelled"); + g_files_delegate = nil; } @end -static void present_image_picker(ErlNifPid pid, UIImagePickerControllerSourceType src, - UIImagePickerControllerCameraCaptureMode mode) { - dispatch_async(dispatch_get_main_queue(), ^{ - if (![UIImagePickerController isSourceTypeAvailable:src]) { - mob_send2(&pid, "camera", "not_available"); - return; - } - UIImagePickerController* picker = [[UIImagePickerController alloc] init]; - picker.sourceType = src; - picker.cameraCaptureMode = mode; - if (mode == UIImagePickerControllerCameraCaptureModeVideo) { - picker.mediaTypes = @[UTTypeMovie.identifier]; - } - g_camera_delegate = [[MobCameraDelegate alloc] init]; - g_camera_delegate.pid = pid; - g_camera_delegate.isVideo = (mode == UIImagePickerControllerCameraCaptureModeVideo); - picker.delegate = g_camera_delegate; - - [mob_root_vc() presentViewController:picker animated:YES completion:nil]; - }); +// Map a semantic group name (from Mob.Files' normalized envelope) to a UTType. +static UTType *mob_semantic_uttype(NSString *group) { + if ([group isEqualToString:@"images"]) + return UTTypeImage; + if ([group isEqualToString:@"video"]) + return UTTypeMovie; + if ([group isEqualToString:@"audio"]) + return UTTypeAudio; + if ([group isEqualToString:@"pdf"]) + return UTTypePDF; + if ([group isEqualToString:@"text"]) + return UTTypePlainText; + return nil; } -static ERL_NIF_TERM nif_camera_capture_photo(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); - present_image_picker(pid, UIImagePickerControllerSourceTypeCamera, - UIImagePickerControllerCameraCaptureModePhoto); - return enif_make_atom(env, "ok"); -} +// Turn Mob.Files' JSON type envelope into the content types the picker offers. +// The envelope is a list of {"kind","value"} maps; an empty list (the :any +// default) means no filter, which we represent as UTTypeData (every file). +static NSArray<UTType *> *mob_uttypes_from_json(NSString *json) { + NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; + id parsed = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil; + if (![parsed isKindOfClass:[NSArray class]]) + return @[ UTTypeData ]; + + NSMutableArray<UTType *> *types = [NSMutableArray array]; + for (id entry in (NSArray *)parsed) { + if (![entry isKindOfClass:[NSDictionary class]]) + continue; + NSString *kind = entry[@"kind"]; + NSString *value = entry[@"value"]; + if (![value isKindOfClass:[NSString class]]) + continue; + + UTType *t = nil; + if ([kind isEqualToString:@"extension"]) { + t = [UTType typeWithFilenameExtension:value]; + } else if ([kind isEqualToString:@"mime"]) { + t = [UTType typeWithMIMEType:value]; + } else if ([kind isEqualToString:@"uti"]) { + t = [UTType typeWithIdentifier:value]; + } else if ([kind isEqualToString:@"semantic"]) { + t = mob_semantic_uttype(value); + } + if (t) + [types addObject:t]; + } -static ERL_NIF_TERM nif_camera_capture_video(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); - int max_sec = 60; - enif_get_int(env, argv[0], &max_sec); - present_image_picker(pid, UIImagePickerControllerSourceTypeCamera, - UIImagePickerControllerCameraCaptureModeVideo); - return enif_make_atom(env, "ok"); + // Every spec failed to resolve (e.g. an unknown MIME) — fall back to "any" + // rather than presenting a picker that can offer nothing. + return types.count > 0 ? types : @[ UTTypeData ]; } -// ── Camera preview ──────────────────────────────────────────────────────── - -AVCaptureSession* g_preview_session = nil; +static ERL_NIF_TERM nif_files_pick(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; + enif_self(env, &pid); -static ERL_NIF_TERM nif_camera_start_preview(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - NSString* facing = @"back"; - if (enif_inspect_binary(env, argv[0], &bin) || enif_inspect_iolist_as_binary(env, argv[0], &bin)) { - NSString* json = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; - NSDictionary* opts = [NSJSONSerialization JSONObjectWithData:[json dataUsingEncoding:NSUTF8StringEncoding] - options:0 error:nil]; - if ([opts[@"facing"] isEqualToString:@"front"]) facing = @"front"; + NSArray<UTType *> *contentTypes = @[ UTTypeData ]; + ErlNifBinary jbin; + if (argc >= 1 && enif_inspect_iolist_as_binary(env, argv[0], &jbin)) { + NSString *json = [[NSString alloc] initWithBytes:jbin.data + length:jbin.size + encoding:NSUTF8StringEncoding]; + if (json) + contentTypes = mob_uttypes_from_json(json); } - // Session setup and startRunning must run on a background queue (Apple requirement). - // After the session is running, update the shared global and notify the preview view - // on the main queue so SwiftUI can safely read g_preview_session. - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - AVCaptureDevicePosition position = [facing isEqualToString:@"front"] - ? AVCaptureDevicePositionFront - : AVCaptureDevicePositionBack; - AVCaptureDevice* device = [AVCaptureDevice defaultDeviceWithDeviceType:AVCaptureDeviceTypeBuiltInWideAngleCamera - mediaType:AVMediaTypeVideo - position:position]; - if (!device) return; - AVCaptureDeviceInput* input = [AVCaptureDeviceInput deviceInputWithDevice:device error:nil]; - if (!input) return; - AVCaptureSession* session = [[AVCaptureSession alloc] init]; - session.sessionPreset = AVCaptureSessionPresetHigh; - if ([session canAddInput:input]) [session addInput:input]; - [session startRunning]; - dispatch_async(dispatch_get_main_queue(), ^{ - if (g_preview_session) [g_preview_session stopRunning]; - g_preview_session = session; - [[NSNotificationCenter defaultCenter] - postNotificationName:@"MobCameraSessionChanged" object:nil]; - }); - }); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_camera_stop_preview(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - AVCaptureSession* old = g_preview_session; - g_preview_session = nil; - [[NSNotificationCenter defaultCenter] - postNotificationName:@"MobCameraSessionChanged" object:nil]; - // Stop the session off the main queue so we don't block the UI. - if (old) dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - [old stopRunning]; - }); + UIDocumentPickerViewController *vc = + [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:contentTypes + asCopy:YES]; + vc.allowsMultipleSelection = YES; + g_files_delegate = [[MobFilesDelegate alloc] init]; + g_files_delegate.pid = pid; + vc.delegate = g_files_delegate; + [mob_root_vc() presentViewController:vc animated:YES completion:nil]; }); return enif_make_atom(env, "ok"); } -// ── Photo library picker ────────────────────────────────────────────────── - -@interface MobPhotosDelegate : NSObject <PHPickerViewControllerDelegate> -@property (nonatomic) ErlNifPid pid; -@property (nonatomic) int maxItems; -@end - -static MobPhotosDelegate* g_photos_delegate = nil; +// ── Audio recording ─────────────────────────────────────────────────────── -@implementation MobPhotosDelegate -- (void)picker:(PHPickerViewController*)picker didFinishPicking:(NSArray<PHPickerResult*>*)results { - [picker dismissViewControllerAnimated:YES completion:nil]; - if (results.count == 0) { - mob_send2(&_pid, "photos", "cancelled"); - g_photos_delegate = nil; - return; - } - ErlNifPid p = self.pid; - g_photos_delegate = nil; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - dispatch_group_t grp = dispatch_group_create(); - NSMutableArray* items = [NSMutableArray array]; - for (PHPickerResult* result in results) { - dispatch_group_enter(grp); - BOOL isVideo = [result.itemProvider hasItemConformingToTypeIdentifier:@"public.movie"]; - NSString* typeId = isVideo ? @"public.movie" : @"public.image"; - [result.itemProvider loadFileRepresentationForTypeIdentifier:typeId - completionHandler:^(NSURL* url, NSError* err) { - if (url) { - NSString* ext = isVideo ? @"mp4" : @"jpg"; - NSString* tmp = [NSTemporaryDirectory() stringByAppendingPathComponent: - [NSString stringWithFormat:@"mob_pick_%@.%@", [NSUUID UUID].UUIDString, ext]]; - [[NSFileManager defaultManager] copyItemAtURL:url toURL:[NSURL fileURLWithPath:tmp] error:nil]; - @synchronized(items) { - [items addObject:@{@"path": tmp, @"type": isVideo ? @"video" : @"image"}]; - } - } - dispatch_group_leave(grp); - }]; - } - dispatch_group_notify(grp, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM list = enif_make_list(e, 0); - for (NSDictionary* item in items.reverseObjectEnumerator) { - const char* path = [item[@"path"] UTF8String]; - const char* type = [item[@"type"] UTF8String]; - ErlNifBinary pbin; enif_alloc_binary(strlen(path), &pbin); - memcpy(pbin.data, path, strlen(path)); - ERL_NIF_TERM keys[2] = {enif_make_atom(e,"path"), enif_make_atom(e,"type")}; - ERL_NIF_TERM vals[2] = {enif_make_binary(e,&pbin), enif_make_atom(e,type)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 2, &map); - list = enif_make_list_cell(e, map, list); - } - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e,"photos"), enif_make_atom(e,"picked"), list); - enif_send(NULL, &p, e, msg); - enif_free_env(e); - }); - }); -} -@end +static AVAudioRecorder *g_audio_recorder = nil; +static ErlNifPid g_audio_pid; +static NSString *g_audio_path = nil; +static NSDate *g_audio_start = nil; -static ERL_NIF_TERM nif_photos_pick(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - int max = 1; enif_get_int(env, argv[0], &max); - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_audio_start_recording(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; + enif_self(env, &pid); + g_audio_pid = pid; dispatch_async(dispatch_get_main_queue(), ^{ - PHPickerConfiguration* cfg = [[PHPickerConfiguration alloc] init]; - cfg.selectionLimit = max; - PHPickerViewController* vc = [[PHPickerViewController alloc] initWithConfiguration:cfg]; - g_photos_delegate = [[MobPhotosDelegate alloc] init]; - g_photos_delegate.pid = pid; - g_photos_delegate.maxItems = max; - vc.delegate = g_photos_delegate; - [mob_root_vc() presentViewController:vc animated:YES completion:nil]; + [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryRecord error:nil]; + [[AVAudioSession sharedInstance] setActive:YES error:nil]; + NSString *tmp = [NSTemporaryDirectory() + stringByAppendingPathComponent:[NSString stringWithFormat:@"mob_audio_%@.m4a", + [NSUUID UUID].UUIDString]]; + g_audio_path = tmp; + g_audio_start = [NSDate date]; + NSURL *url = [NSURL fileURLWithPath:tmp]; + NSDictionary *settings = @{ + AVFormatIDKey : @(kAudioFormatMPEG4AAC), + AVSampleRateKey : @44100, + AVNumberOfChannelsKey : @1, + AVEncoderAudioQualityKey : @(AVAudioQualityMedium) + }; + g_audio_recorder = [[AVAudioRecorder alloc] initWithURL:url settings:settings error:nil]; + [g_audio_recorder record]; }); return enif_make_atom(env, "ok"); } -// ── File picker ─────────────────────────────────────────────────────────── - -@interface MobFilesDelegate : NSObject <UIDocumentPickerDelegate> -@property (nonatomic) ErlNifPid pid; -@end - -static MobFilesDelegate* g_files_delegate = nil; - -@implementation MobFilesDelegate -- (void)documentPicker:(UIDocumentPickerViewController*)ctrl - didPickDocumentsAtURLs:(NSArray<NSURL*>*)urls { - if (urls.count == 0) { mob_send2(&_pid, "files", "cancelled"); g_files_delegate = nil; return; } - ErlNifPid p = self.pid; - g_files_delegate = nil; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM list = enif_make_list(e, 0); - for (NSURL* url in urls.reverseObjectEnumerator) { - [url startAccessingSecurityScopedResource]; - NSString* name = url.lastPathComponent; - NSString* tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:name]; - [[NSFileManager defaultManager] copyItemAtURL:url toURL:[NSURL fileURLWithPath:tmp] error:nil]; - [url stopAccessingSecurityScopedResource]; - NSDictionary* attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:tmp error:nil]; - long long sz = [attrs[NSFileSize] longLongValue]; - const char* path = tmp.UTF8String; - const char* nm = name.UTF8String; - ErlNifBinary pb; enif_alloc_binary(strlen(path), &pb); memcpy(pb.data, path, strlen(path)); - ErlNifBinary nb; enif_alloc_binary(strlen(nm), &nb); memcpy(nb.data, nm, strlen(nm)); - ERL_NIF_TERM keys[3] = {enif_make_atom(e,"path"),enif_make_atom(e,"name"),enif_make_atom(e,"size")}; - ERL_NIF_TERM vals[3] = {enif_make_binary(e,&pb),enif_make_binary(e,&nb),enif_make_int64(e,sz)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 3, &map); - list = enif_make_list_cell(e, map, list); - } - ERL_NIF_TERM msg = enif_make_tuple3(e, - enif_make_atom(e,"files"), enif_make_atom(e,"picked"), list); +static ERL_NIF_TERM nif_audio_stop_recording(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (!g_audio_recorder) + return; + NSTimeInterval dur = -[g_audio_start timeIntervalSinceNow]; + [g_audio_recorder stop]; + [[AVAudioSession sharedInstance] setActive:NO error:nil]; + NSString *path = g_audio_path; + g_audio_recorder = nil; + ErlNifPid p = g_audio_pid; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + ErlNifEnv *e = enif_alloc_env(); + const char *cpath = path.UTF8String; + ErlNifBinary pb; + enif_alloc_binary(strlen(cpath), &pb); + memcpy(pb.data, cpath, strlen(cpath)); + ERL_NIF_TERM keys[2] = {enif_make_atom(e, "path"), enif_make_atom(e, "duration")}; + ERL_NIF_TERM vals[2] = {enif_make_binary(e, &pb), enif_make_double(e, dur)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 2, &map); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, "audio"), enif_make_atom(e, "recorded"), map); enif_send(NULL, &p, e, msg); enif_free_env(e); - }); -} -- (void)documentPickerWasCancelled:(UIDocumentPickerViewController*)ctrl { - mob_send2(&_pid, "files", "cancelled"); - g_files_delegate = nil; -} -@end - -static ERL_NIF_TERM nif_files_pick(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); - dispatch_async(dispatch_get_main_queue(), ^{ - UIDocumentPickerViewController* vc = - [[UIDocumentPickerViewController alloc] - initForOpeningContentTypes:@[UTTypeData] asCopy:YES]; - vc.allowsMultipleSelection = YES; - g_files_delegate = [[MobFilesDelegate alloc] init]; - g_files_delegate.pid = pid; - vc.delegate = g_files_delegate; - [mob_root_vc() presentViewController:vc animated:YES completion:nil]; + }); }); return enif_make_atom(env, "ok"); } -// ── Audio recording ─────────────────────────────────────────────────────── - -static AVAudioRecorder* g_audio_recorder = nil; -static ErlNifPid g_audio_pid; -static NSString* g_audio_path = nil; -static NSDate* g_audio_start = nil; +// ── Audio input metering (mic level probe, no recording kept) ────────────── +// A metering-only AVAudioRecorder: records to a throwaway temp file with +// metering enabled and reads averagePower/peakPower (dBFS). Shares the mic +// session with recording — callers must not run both at once. (A future +// AVAudioEngine input tap would avoid the temp file; see MOB-35.) +static AVAudioRecorder *g_meter_recorder = nil; +static NSString *g_meter_path = nil; -static ERL_NIF_TERM nif_audio_start_recording(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); - g_audio_pid = pid; +static ERL_NIF_TERM nif_audio_start_input_metering(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryRecord error:nil]; - [[AVAudioSession sharedInstance] setActive:YES error:nil]; - NSString* tmp = [NSTemporaryDirectory() stringByAppendingPathComponent: - [NSString stringWithFormat:@"mob_audio_%@.m4a", [NSUUID UUID].UUIDString]]; - g_audio_path = tmp; - g_audio_start = [NSDate date]; - NSURL* url = [NSURL fileURLWithPath:tmp]; - NSDictionary* settings = @{ - AVFormatIDKey: @(kAudioFormatMPEG4AAC), - AVSampleRateKey: @44100, - AVNumberOfChannelsKey: @1, - AVEncoderAudioQualityKey: @(AVAudioQualityMedium) - }; - g_audio_recorder = [[AVAudioRecorder alloc] initWithURL:url settings:settings error:nil]; - [g_audio_recorder record]; + [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryRecord error:nil]; + [[AVAudioSession sharedInstance] setActive:YES error:nil]; + NSString *tmp = [NSTemporaryDirectory() + stringByAppendingPathComponent:[NSString stringWithFormat:@"mob_meter_%@.m4a", + [NSUUID UUID].UUIDString]]; + g_meter_path = tmp; + NSURL *url = [NSURL fileURLWithPath:tmp]; + NSDictionary *settings = @{ + AVFormatIDKey : @(kAudioFormatMPEG4AAC), + AVSampleRateKey : @44100, + AVNumberOfChannelsKey : @1, + AVEncoderAudioQualityKey : @(AVAudioQualityMin) + }; + g_meter_recorder = [[AVAudioRecorder alloc] initWithURL:url settings:settings error:nil]; + g_meter_recorder.meteringEnabled = YES; + [g_meter_recorder record]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_audio_stop_recording(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_audio_input_level(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + AVAudioRecorder *rec = g_meter_recorder; + if (!rec || !rec.isRecording) + return enif_make_atom(env, "not_metering"); + [rec updateMeters]; + double avg = [rec averagePowerForChannel:0]; + double peak = [rec peakPowerForChannel:0]; + return enif_make_tuple2(env, enif_make_double(env, avg), enif_make_double(env, peak)); +} + +static ERL_NIF_TERM nif_audio_stop_input_metering(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - if (!g_audio_recorder) return; - NSTimeInterval dur = -[g_audio_start timeIntervalSinceNow]; - [g_audio_recorder stop]; - [[AVAudioSession sharedInstance] setActive:NO error:nil]; - NSString* path = g_audio_path; - g_audio_recorder = nil; - ErlNifPid p = g_audio_pid; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - const char* cpath = path.UTF8String; - ErlNifBinary pb; enif_alloc_binary(strlen(cpath), &pb); memcpy(pb.data, cpath, strlen(cpath)); - ERL_NIF_TERM keys[2] = {enif_make_atom(e,"path"), enif_make_atom(e,"duration")}; - ERL_NIF_TERM vals[2] = {enif_make_binary(e,&pb), enif_make_double(e,dur)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 2, &map); - ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e,"audio"), enif_make_atom(e,"recorded"), map); - enif_send(NULL, &p, e, msg); - enif_free_env(e); - }); + if (g_meter_recorder) { + [g_meter_recorder stop]; + g_meter_recorder = nil; + } + [[AVAudioSession sharedInstance] setActive:NO error:nil]; + if (g_meter_path) { + [[NSFileManager defaultManager] removeItemAtPath:g_meter_path error:nil]; + g_meter_path = nil; + } }); return enif_make_atom(env, "ok"); } @@ -2379,312 +2889,472 @@ static ERL_NIF_TERM nif_audio_stop_recording(ErlNifEnv* env, int argc, const ERL @interface MobAudioPlayerDelegate : NSObject <AVAudioPlayerDelegate> @end -static AVAudioPlayer* g_audio_player = nil; -static AVPlayer* g_av_player = nil; -static id g_av_observer = nil; -static ErlNifPid g_playback_pid; -static NSString* g_playback_path = nil; -static MobAudioPlayerDelegate* g_player_delegate = nil; +static AVAudioPlayer *g_audio_player = nil; +static AVPlayer *g_av_player = nil; +static id g_av_observer = nil; +static ErlNifPid g_playback_pid; +static NSString *g_playback_path = nil; +static MobAudioPlayerDelegate *g_player_delegate = nil; + +// Forward declarations: defined in the "Scheduled audio playback" +// section below but referenced by nif_audio_stop_playback / +// nif_audio_set_volume above it. +static NSMutableArray *g_scheduled_players; +static dispatch_queue_t g_scheduled_players_queue; @implementation MobAudioPlayerDelegate -- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer*)player successfully:(BOOL)flag { - NSString* path = g_playback_path; +- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag { + NSString *path = g_playback_path; ErlNifPid p = g_playback_pid; g_audio_player = nil; g_playback_path = nil; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - const char* cpath = path.UTF8String; - ErlNifBinary pb; enif_alloc_binary(strlen(cpath), &pb); memcpy(pb.data, cpath, strlen(cpath)); - ERL_NIF_TERM keys[1] = {enif_make_atom(e, "path")}; - ERL_NIF_TERM vals[1] = {enif_make_binary(e, &pb)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 1, &map); - ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "audio"), - enif_make_atom(e, "playback_finished"), map); - enif_send(NULL, &p, e, msg); - enif_free_env(e); + ErlNifEnv *e = enif_alloc_env(); + const char *cpath = path.UTF8String; + ErlNifBinary pb; + enif_alloc_binary(strlen(cpath), &pb); + memcpy(pb.data, cpath, strlen(cpath)); + ERL_NIF_TERM keys[1] = {enif_make_atom(e, "path")}; + ERL_NIF_TERM vals[1] = {enif_make_binary(e, &pb)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 1, &map); + ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "audio"), + enif_make_atom(e, "playback_finished"), map); + enif_send(NULL, &p, e, msg); + enif_free_env(e); }); } -- (void)audioPlayerDecodeErrorDidOccur:(AVAudioPlayer*)player error:(NSError*)error { +- (void)audioPlayerDecodeErrorDidOccur:(AVAudioPlayer *)player error:(NSError *)error { ErlNifPid p = g_playback_pid; - NSString* reason = error ? error.localizedDescription : @"decode_error"; + NSString *reason = error ? error.localizedDescription : @"decode_error"; g_audio_player = nil; g_playback_path = nil; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - const char* cr = reason.UTF8String; - ErlNifBinary rb; enif_alloc_binary(strlen(cr), &rb); memcpy(rb.data, cr, strlen(cr)); - ERL_NIF_TERM keys[1] = {enif_make_atom(e, "reason")}; - ERL_NIF_TERM vals[1] = {enif_make_binary(e, &rb)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 1, &map); - ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e, "audio"), - enif_make_atom(e, "playback_error"), map); - enif_send(NULL, &p, e, msg); - enif_free_env(e); + ErlNifEnv *e = enif_alloc_env(); + const char *cr = reason.UTF8String; + ErlNifBinary rb; + enif_alloc_binary(strlen(cr), &rb); + memcpy(rb.data, cr, strlen(cr)); + ERL_NIF_TERM keys[1] = {enif_make_atom(e, "reason")}; + ERL_NIF_TERM vals[1] = {enif_make_binary(e, &rb)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 1, &map); + ERL_NIF_TERM msg = + enif_make_tuple3(e, enif_make_atom(e, "audio"), enif_make_atom(e, "playback_error"), map); + enif_send(NULL, &p, e, msg); + enif_free_env(e); }); } @end -static ERL_NIF_TERM nif_audio_play(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_audio_play(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary path_bin, opts_bin; if (!enif_inspect_binary(env, argv[0], &path_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &path_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[0], &path_bin)) + return enif_make_badarg(env); if (!enif_inspect_binary(env, argv[1], &opts_bin) && - !enif_inspect_iolist_as_binary(env, argv[1], &opts_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[1], &opts_bin)) + return enif_make_badarg(env); - NSString* path = [[NSString alloc] initWithBytes:path_bin.data length:path_bin.size encoding:NSUTF8StringEncoding]; - NSString* opts = [[NSString alloc] initWithBytes:opts_bin.data length:opts_bin.size encoding:NSUTF8StringEncoding]; + NSString *path = [[NSString alloc] initWithBytes:path_bin.data + length:path_bin.size + encoding:NSUTF8StringEncoding]; + NSString *opts = [[NSString alloc] initWithBytes:opts_bin.data + length:opts_bin.size + encoding:NSUTF8StringEncoding]; - ErlNifPid pid; enif_self(env, &pid); - g_playback_pid = pid; + ErlNifPid pid; + enif_self(env, &pid); + g_playback_pid = pid; g_playback_path = path; dispatch_async(dispatch_get_main_queue(), ^{ - NSDictionary* o = [NSJSONSerialization - JSONObjectWithData:[opts dataUsingEncoding:NSUTF8StringEncoding] - options:0 error:nil]; - BOOL loop = [o[@"loop"] boolValue]; - double volume = o[@"volume"] ? [o[@"volume"] doubleValue] : 1.0; - - // Stop any in-flight players. - [g_audio_player stop]; - g_audio_player = nil; - if (g_av_observer) { [[NSNotificationCenter defaultCenter] removeObserver:g_av_observer]; g_av_observer = nil; } - [g_av_player pause]; - g_av_player = nil; - - [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:nil]; - [[AVAudioSession sharedInstance] setActive:YES error:nil]; - - BOOL isRemote = [path hasPrefix:@"http://"] || [path hasPrefix:@"https://"]; - if (isRemote) { - // Remote URL — use AVPlayer (AVAudioPlayer cannot stream HTTP). - NSURL* url = [NSURL URLWithString:path]; - AVPlayerItem* item = [AVPlayerItem playerItemWithURL:url]; - AVPlayer* player = [AVPlayer playerWithPlayerItem:item]; - player.volume = (float)volume; - g_av_player = player; - - ErlNifPid p = g_playback_pid; - NSString* pPath = path; - g_av_observer = [[NSNotificationCenter defaultCenter] - addObserverForName:AVPlayerItemDidPlayToEndTimeNotification - object:item queue:nil - usingBlock:^(NSNotification* n) { - if (loop) { - [g_av_player seekToTime:kCMTimeZero]; - [g_av_player play]; - } else { - g_av_player = nil; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - const char* cp = pPath.UTF8String; - ErlNifBinary pb; enif_alloc_binary(strlen(cp), &pb); memcpy(pb.data, cp, strlen(cp)); - ERL_NIF_TERM keys[1] = {enif_make_atom(e, "path")}; - ERL_NIF_TERM vals[1] = {enif_make_binary(e, &pb)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 1, &map); - enif_send(NULL, &p, e, enif_make_tuple3(e, - enif_make_atom(e, "audio"), enif_make_atom(e, "playback_finished"), map)); - enif_free_env(e); - }); - } - }]; - [player play]; - return; - } - - // Local file — use AVAudioPlayer. - NSURL* url = [NSURL fileURLWithPath:path]; - NSError* err = nil; - AVAudioPlayer* player = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&err]; - if (!player || err) { - NSString* reason = err ? err.localizedDescription : @"open_failed"; - ErlNifPid p = g_playback_pid; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - ErlNifEnv* e = enif_alloc_env(); - const char* cr = reason.UTF8String; - ErlNifBinary rb; enif_alloc_binary(strlen(cr), &rb); memcpy(rb.data, cr, strlen(cr)); - ERL_NIF_TERM keys[1] = {enif_make_atom(e, "reason")}; - ERL_NIF_TERM vals[1] = {enif_make_binary(e, &rb)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 1, &map); - enif_send(NULL, &p, e, enif_make_tuple3(e, - enif_make_atom(e, "audio"), enif_make_atom(e, "playback_error"), map)); - enif_free_env(e); - }); - return; - } - - if (!g_player_delegate) g_player_delegate = [[MobAudioPlayerDelegate alloc] init]; - player.delegate = g_player_delegate; - player.volume = (float)volume; - player.numberOfLoops = loop ? -1 : 0; - g_audio_player = player; - [player play]; + NSDictionary *o = + [NSJSONSerialization JSONObjectWithData:[opts dataUsingEncoding:NSUTF8StringEncoding] + options:0 + error:nil]; + BOOL loop = [o[@"loop"] boolValue]; + double volume = o[@"volume"] ? [o[@"volume"] doubleValue] : 1.0; + + // Stop any in-flight players. + [g_audio_player stop]; + g_audio_player = nil; + if (g_av_observer) { + [[NSNotificationCenter defaultCenter] removeObserver:g_av_observer]; + g_av_observer = nil; + } + [g_av_player pause]; + g_av_player = nil; + + [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:nil]; + [[AVAudioSession sharedInstance] setActive:YES error:nil]; + + BOOL isRemote = [path hasPrefix:@"http://"] || [path hasPrefix:@"https://"]; + if (isRemote) { + // Remote URL — use AVPlayer (AVAudioPlayer cannot stream HTTP). + NSURL *url = [NSURL URLWithString:path]; + AVPlayerItem *item = [AVPlayerItem playerItemWithURL:url]; + AVPlayer *player = [AVPlayer playerWithPlayerItem:item]; + player.volume = (float)volume; + g_av_player = player; + + ErlNifPid p = g_playback_pid; + NSString *pPath = path; + g_av_observer = [[NSNotificationCenter defaultCenter] + addObserverForName:AVPlayerItemDidPlayToEndTimeNotification + object:item + queue:nil + usingBlock:^(NSNotification *n) { + if (loop) { + [g_av_player seekToTime:kCMTimeZero]; + [g_av_player play]; + } else { + g_av_player = nil; + dispatch_async( + dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + ErlNifEnv *e = enif_alloc_env(); + const char *cp = pPath.UTF8String; + ErlNifBinary pb; + enif_alloc_binary(strlen(cp), &pb); + memcpy(pb.data, cp, strlen(cp)); + ERL_NIF_TERM keys[1] = {enif_make_atom(e, "path")}; + ERL_NIF_TERM vals[1] = {enif_make_binary(e, &pb)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 1, &map); + enif_send(NULL, &p, e, + enif_make_tuple3(e, enif_make_atom(e, "audio"), + enif_make_atom(e, "playback_finished"), + map)); + enif_free_env(e); + }); + } + }]; + [player play]; + return; + } + + // Local file — use AVAudioPlayer. + NSURL *url = [NSURL fileURLWithPath:path]; + NSError *err = nil; + AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&err]; + if (!player || err) { + NSString *reason = err ? err.localizedDescription : @"open_failed"; + ErlNifPid p = g_playback_pid; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + ErlNifEnv *e = enif_alloc_env(); + const char *cr = reason.UTF8String; + ErlNifBinary rb; + enif_alloc_binary(strlen(cr), &rb); + memcpy(rb.data, cr, strlen(cr)); + ERL_NIF_TERM keys[1] = {enif_make_atom(e, "reason")}; + ERL_NIF_TERM vals[1] = {enif_make_binary(e, &rb)}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 1, &map); + enif_send(NULL, &p, e, + enif_make_tuple3(e, enif_make_atom(e, "audio"), + enif_make_atom(e, "playback_error"), map)); + enif_free_env(e); + }); + return; + } + + if (!g_player_delegate) + g_player_delegate = [[MobAudioPlayerDelegate alloc] init]; + player.delegate = g_player_delegate; + player.volume = (float)volume; + player.numberOfLoops = loop ? -1 : 0; + // Enable metering so Mob.Audio.output_level(source: :mob) can read the + // signal level without a separate tap. Cheap; off by default otherwise. + player.meteringEnabled = YES; + g_audio_player = player; + [player play]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_audio_stop_playback(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_audio_stop_playback(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - [g_audio_player stop]; - g_audio_player = nil; - if (g_av_observer) { [[NSNotificationCenter defaultCenter] removeObserver:g_av_observer]; g_av_observer = nil; } - [g_av_player pause]; - g_av_player = nil; - g_playback_path = nil; - [[AVAudioSession sharedInstance] setActive:NO error:nil]; + [g_audio_player stop]; + g_audio_player = nil; + if (g_av_observer) { + [[NSNotificationCenter defaultCenter] removeObserver:g_av_observer]; + g_av_observer = nil; + } + [g_av_player pause]; + g_av_player = nil; + g_playback_path = nil; + // Stop and drop every scheduled play_at player too. + if (g_scheduled_players) { + dispatch_sync(g_scheduled_players_queue, ^{ + for (AVAudioPlayer *p in g_scheduled_players) + [p stop]; + [g_scheduled_players removeAllObjects]; + }); + } + [[AVAudioSession sharedInstance] setActive:NO error:nil]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_audio_set_volume(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_audio_set_volume(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { double vol = 1.0; enif_get_double(env, argv[0], &vol); dispatch_async(dispatch_get_main_queue(), ^{ - g_audio_player.volume = (float)vol; - g_av_player.volume = (float)vol; + g_audio_player.volume = (float)vol; + g_av_player.volume = (float)vol; + // Mirror onto every currently-scheduled play_at player. + if (g_scheduled_players) { + dispatch_sync(g_scheduled_players_queue, ^{ + for (AVAudioPlayer *p in g_scheduled_players) + p.volume = (float)vol; + }); + } }); return enif_make_atom(env, "ok"); } -// ── Motion sensors ──────────────────────────────────────────────────────── +// ── Scheduled audio playback (sample-accurate sync) ──────────────────────── +// +// AVAudioPlayer's `-playAtTime:` schedules playback against the audio +// hardware clock (`deviceCurrentTime`). The first `AVAudioEngine` + +// `scheduleBuffer:atTime:` cut of this code crashed the BEAM whenever a +// scheduled buffer hit playback time on a physical iPhone — likely a +// thread / audio-session interaction we never fully diagnosed. The +// `playAtTime:` path is simpler (no engine, no PCM buffers, no +// completionHandler reaching back into Erlang from an audio thread), +// well-documented since iOS 4, and gives the same sample-accurate +// scheduling guarantee. +// +// One `AVAudioPlayer` per scheduled note. The player is retained in a +// global mutable array so ARC doesn't release it before the audio +// hardware reads it; it's removed `duration + 1s` after its scheduled +// fire time. + +static NSMutableArray *g_scheduled_players = nil; +static dispatch_queue_t g_scheduled_players_queue = NULL; + +static void ensure_scheduled_players(void) { + static dispatch_once_t once; + dispatch_once(&once, ^{ + g_scheduled_players = [NSMutableArray array]; + g_scheduled_players_queue = + dispatch_queue_create("mob.audio.scheduled_players", DISPATCH_QUEUE_SERIAL); + }); +} -static CMMotionManager* g_motion_manager = nil; -static ErlNifPid g_motion_pid; +// audio_play_at(Path, OptsJson, AtWallMs) +// +// Schedules `Path` to begin playback at absolute local wall-clock time +// `AtWallMs` (in `System.system_time(:millisecond)` terms — caller is +// responsible for converting from server time via `Mob.ClockSync` or +// equivalent). Past targets play ASAP. +// +// Successive calls schedule independent players; they mix together. Use +// `audio_stop_playback` to interrupt anything currently in flight. +static ERL_NIF_TERM nif_audio_play_at(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifBinary path_bin, opts_bin, at_bin; -static ERL_NIF_TERM nif_motion_start(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); - g_motion_pid = pid; - int interval_ms = 100; - // argv[0] is a list of sensor name binaries; argv[1] is interval_ms int - enif_get_int(env, argv[1], &interval_ms); + if (!enif_inspect_binary(env, argv[0], &path_bin) && + !enif_inspect_iolist_as_binary(env, argv[0], &path_bin)) + return enif_make_badarg(env); + if (!enif_inspect_binary(env, argv[1], &opts_bin) && + !enif_inspect_iolist_as_binary(env, argv[1], &opts_bin)) + return enif_make_badarg(env); + // `at_wall_ms` arrives as a binary string. Marshaling as a string + // sidesteps cross-platform NIF symbol differences (Mob's Android + // ERTS build doesn't dynamically export `enif_get_int64`); we keep + // the iOS side on the same wire format for symmetry. + if (!enif_inspect_binary(env, argv[2], &at_bin) && + !enif_inspect_iolist_as_binary(env, argv[2], &at_bin)) + return enif_make_badarg(env); - dispatch_async(dispatch_get_main_queue(), ^{ - if (!g_motion_manager) g_motion_manager = [[CMMotionManager alloc] init]; - NSTimeInterval interval = interval_ms / 1000.0; - g_motion_manager.deviceMotionUpdateInterval = interval; - [g_motion_manager startDeviceMotionUpdatesToQueue:[NSOperationQueue new] - withHandler:^(CMDeviceMotion* motion, NSError* err) { - if (!motion) return; - ErlNifPid p = g_motion_pid; - double ax = motion.userAcceleration.x + motion.gravity.x; - double ay = motion.userAcceleration.y + motion.gravity.y; - double az = motion.userAcceleration.z + motion.gravity.z; - double gx = motion.rotationRate.x; - double gy = motion.rotationRate.y; - double gz = motion.rotationRate.z; - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM accel = enif_make_tuple3(e, - enif_make_double(e,ax), enif_make_double(e,ay), enif_make_double(e,az)); - ERL_NIF_TERM gyro = enif_make_tuple3(e, - enif_make_double(e,gx), enif_make_double(e,gy), enif_make_double(e,gz)); - long long ts = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); - ERL_NIF_TERM keys[3] = {enif_make_atom(e,"accel"),enif_make_atom(e,"gyro"),enif_make_atom(e,"timestamp")}; - ERL_NIF_TERM vals[3] = {accel, gyro, enif_make_int64(e,ts)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 3, &map); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,"motion"), map); - enif_send(NULL, &p, e, msg); - enif_free_env(e); - }]; - }); - return enif_make_atom(env, "ok"); -} + NSString *at_str = [[NSString alloc] initWithBytes:at_bin.data + length:at_bin.size + encoding:NSUTF8StringEncoding]; + int64_t at_wall_ms = (int64_t)at_str.longLongValue; + + NSString *path = [[NSString alloc] initWithBytes:path_bin.data + length:path_bin.size + encoding:NSUTF8StringEncoding]; + NSString *opts_str = [[NSString alloc] initWithBytes:opts_bin.data + length:opts_bin.size + encoding:NSUTF8StringEncoding]; -static ERL_NIF_TERM nif_motion_stop(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - [g_motion_manager stopDeviceMotionUpdates]; + ensure_scheduled_players(); + + [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:nil]; + [[AVAudioSession sharedInstance] setActive:YES error:nil]; + + NSDictionary *o = + [NSJSONSerialization JSONObjectWithData:[opts_str dataUsingEncoding:NSUTF8StringEncoding] + options:0 + error:nil]; + double volume = o[@"volume"] ? [o[@"volume"] doubleValue] : 1.0; + + NSURL *url = [NSURL fileURLWithPath:path]; + NSError *err = nil; + AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:&err]; + if (!player || err) { + NSLog(@"[mob audio] play_at open failed: %@", err); + return; + } + player.volume = (float)volume; + [player prepareToPlay]; + + // Convert wall-clock target → player's audio-clock domain. The + // player's `deviceCurrentTime` ticks at the audio hardware rate; + // adding (target_wall - now_wall) seconds gives the corresponding + // moment on that clock. Time skew between gettimeofday and the + // audio clock is irrelevant over the few seconds we schedule. + NSTimeInterval now_device = player.deviceCurrentTime; + struct timeval tv; + gettimeofday(&tv, NULL); + NSTimeInterval now_wall = (NSTimeInterval)tv.tv_sec + (NSTimeInterval)tv.tv_usec / 1e6; + NSTimeInterval target_wall = (NSTimeInterval)at_wall_ms / 1000.0; + NSTimeInterval delta = target_wall - now_wall; + + if (delta <= 0) { + [player play]; + } else { + NSTimeInterval target_device = now_device + delta; + [player playAtTime:target_device]; + } + + dispatch_async(g_scheduled_players_queue, ^{ + [g_scheduled_players addObject:player]; + }); + + // Release this player after it's done playing. `+ 1.0` provides + // generous slack so a slightly-late dispatch doesn't release a + // still-playing player. + NSTimeInterval clear_after = MAX(0.0, delta) + player.duration + 1.0; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(clear_after * NSEC_PER_SEC)), + g_scheduled_players_queue, ^{ + [g_scheduled_players removeObject:player]; + }); }); + return enif_make_atom(env, "ok"); } -// ── QR / barcode scanner ────────────────────────────────────────────────── +// ── Motion sensors ──────────────────────────────────────────────────────── -@interface MobScannerVC : UIViewController <AVCaptureMetadataOutputObjectsDelegate> -@property (nonatomic) ErlNifPid pid; -@property (nonatomic, strong) AVCaptureSession* session; -@property (nonatomic, strong) AVCaptureVideoPreviewLayer* preview; -@end +static CMMotionManager *g_motion_manager = nil; +static ErlNifPid g_motion_pid; -static MobScannerVC* g_scanner_vc = nil; - -@implementation MobScannerVC -- (void)viewDidLoad { - [super viewDidLoad]; - self.view.backgroundColor = [UIColor blackColor]; - NSError* err = nil; - AVCaptureDevice* dev = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; - AVCaptureDeviceInput* inp = [AVCaptureDeviceInput deviceInputWithDevice:dev error:&err]; - if (!inp) { mob_send2(&_pid, "scan", "not_available"); [self dismissViewControllerAnimated:YES completion:nil]; return; } - self.session = [[AVCaptureSession alloc] init]; - [self.session addInput:inp]; - AVCaptureMetadataOutput* out = [[AVCaptureMetadataOutput alloc] init]; - [self.session addOutput:out]; - [out setMetadataObjectsDelegate:self queue:dispatch_get_main_queue()]; - out.metadataObjectTypes = @[ - AVMetadataObjectTypeQRCode, AVMetadataObjectTypeEAN13Code, - AVMetadataObjectTypeEAN8Code, AVMetadataObjectTypeCode128Code, - AVMetadataObjectTypeCode39Code, AVMetadataObjectTypeAztecCode, - AVMetadataObjectTypePDF417Code, AVMetadataObjectTypeDataMatrixCode - ]; - self.preview = [AVCaptureVideoPreviewLayer layerWithSession:self.session]; - self.preview.videoGravity = AVLayerVideoGravityResizeAspectFill; - self.preview.frame = self.view.bounds; - [self.view.layer addSublayer:self.preview]; - // Cancel button - UIButton* btn = [UIButton buttonWithType:UIButtonTypeSystem]; - [btn setTitle:@"Cancel" forState:UIControlStateNormal]; - [btn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; - btn.frame = CGRectMake(16, 60, 80, 44); - [btn addTarget:self action:@selector(cancel) forControlEvents:UIControlEventTouchUpInside]; - [self.view addSubview:btn]; - [self.session startRunning]; -} -- (void)viewDidLayoutSubviews { - [super viewDidLayoutSubviews]; - self.preview.frame = self.view.bounds; -} -- (void)cancel { - [self.session stopRunning]; - mob_send2(&_pid, "scan", "cancelled"); - [self dismissViewControllerAnimated:YES completion:nil]; - g_scanner_vc = nil; -} -- (void)captureOutput:(AVCaptureOutput*)out - didOutputMetadataObjects:(NSArray<__kindof AVMetadataObject*>*)metas - fromConnection:(AVCaptureConnection*)conn { - AVMetadataMachineReadableCodeObject* code = metas.firstObject; - if (!code || !code.stringValue) return; - [self.session stopRunning]; - NSString* val = code.stringValue; - NSString* typ = @"qr"; - if ([code.type isEqualToString:AVMetadataObjectTypeEAN13Code]) typ = @"ean13"; - else if ([code.type isEqualToString:AVMetadataObjectTypeEAN8Code]) typ = @"ean8"; - else if ([code.type isEqualToString:AVMetadataObjectTypeCode128Code]) typ = @"code128"; - else if ([code.type isEqualToString:AVMetadataObjectTypeCode39Code]) typ = @"code39"; - ErlNifPid p = self.pid; - g_scanner_vc = nil; - [self dismissViewControllerAnimated:YES completion:^{ - ErlNifEnv* e = enif_alloc_env(); - const char* cval = val.UTF8String; - const char* ctyp = typ.UTF8String; - ErlNifBinary vb; enif_alloc_binary(strlen(cval), &vb); memcpy(vb.data, cval, strlen(cval)); - ERL_NIF_TERM keys[2] = {enif_make_atom(e,"type"), enif_make_atom(e,"value")}; - ERL_NIF_TERM vals[2] = {enif_make_atom(e,ctyp), enif_make_binary(e,&vb)}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 2, &map); - ERL_NIF_TERM msg = enif_make_tuple3(e, enif_make_atom(e,"scan"), enif_make_atom(e,"result"), map); +// True if `name` appears in the Erlang list of sensor-name binaries (argv[0]). +static bool motion_sensor_requested(ErlNifEnv *env, ERL_NIF_TERM list, const char *name) { + ERL_NIF_TERM head, tail = list; + ErlNifBinary bin; + size_t namelen = strlen(name); + while (enif_get_list_cell(env, tail, &head, &tail)) { + if (enif_inspect_binary(env, head, &bin) && bin.size == namelen && + memcmp(bin.data, name, namelen) == 0) { + return true; + } + } + return false; +} + +static ERL_NIF_TERM nif_motion_start(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifPid pid; + enif_self(env, &pid); + g_motion_pid = pid; + int interval_ms = 100; + // argv[0] is a list of sensor name binaries; argv[1] is interval_ms int + enif_get_int(env, argv[1], &interval_ms); + bool want_mag = motion_sensor_requested(env, argv[0], "magnetometer"); + + dispatch_async(dispatch_get_main_queue(), ^{ + if (!g_motion_manager) + g_motion_manager = [[CMMotionManager alloc] init]; + NSTimeInterval interval = interval_ms / 1000.0; + g_motion_manager.deviceMotionUpdateInterval = interval; + + // The magnetic-north reference frame fuses accel+gyro+magnetometer and yields + // a calibrated field + a heading on the same stream — but only if the device + // has a magnetometer. Fall back to the plain accel/gyro stream otherwise. + BOOL magOK = want_mag && ([CMMotionManager availableAttitudeReferenceFrames] & + CMAttitudeReferenceFrameXMagneticNorthZVertical); + + CMDeviceMotionHandler handler = ^(CMDeviceMotion *motion, NSError *err) { + if (!motion) + return; + ErlNifPid p = g_motion_pid; + // Normalize to Android's SensorManager convention so `accel` means the same + // thing on both platforms: + // * units — CoreMotion is in G (~1.0 at rest); Android TYPE_ACCELEROMETER is + // m/s² (~9.81). Scale by g (9.80665). + // * sign — Android reports specific force / proper acceleration, a_coord − + // g_field, so at rest it reads +g on the axis pointing UP (away from the + // ground). CoreMotion splits this into userAcceleration (a_coord) and + // gravity (the gravity field vector, pointing DOWN). So the Android- + // equivalent reading is userAcceleration − gravity, NOT + gravity (which is + // iOS's own "total acceleration" convention, sign-flipped from Android and + // what made a tilt-driven UI move backwards). Subtracting gravity matches + // Android for both the static tilt term and the dynamic linear term. + double ax = (motion.userAcceleration.x - motion.gravity.x) * 9.80665; + double ay = (motion.userAcceleration.y - motion.gravity.y) * 9.80665; + double az = (motion.userAcceleration.z - motion.gravity.z) * 9.80665; + double gx = motion.rotationRate.x; + double gy = motion.rotationRate.y; + double gz = motion.rotationRate.z; + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM accel = enif_make_tuple3(e, enif_make_double(e, ax), enif_make_double(e, ay), + enif_make_double(e, az)); + ERL_NIF_TERM gyro = enif_make_tuple3(e, enif_make_double(e, gx), enif_make_double(e, gy), + enif_make_double(e, gz)); + long long ts = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0); + ERL_NIF_TERM map; + if (want_mag) { + // When :magnetometer was requested, always emit the 5-key map so the + // mag/heading keys are a stable contract — nil when there's no reading + // (device has no magnetometer, i.e. !magOK, or heading not yet fused). + ERL_NIF_TERM mag, heading; + if (magOK) { + // CoreMotion reports the field in µT; heading is degrees [0,360), or -1. + CMMagneticField f = motion.magneticField.field; + double hd = motion.heading; + mag = enif_make_tuple3(e, enif_make_double(e, f.x), enif_make_double(e, f.y), + enif_make_double(e, f.z)); + heading = (hd >= 0.0) ? enif_make_double(e, hd) : enif_make_atom(e, "nil"); + } else { + mag = enif_make_atom(e, "nil"); + heading = enif_make_atom(e, "nil"); + } + ERL_NIF_TERM keys[5] = {enif_make_atom(e, "accel"), enif_make_atom(e, "gyro"), + enif_make_atom(e, "mag"), enif_make_atom(e, "heading"), + enif_make_atom(e, "timestamp")}; + ERL_NIF_TERM vals[5] = {accel, gyro, mag, heading, enif_make_int64(e, ts)}; + enif_make_map_from_arrays(e, keys, vals, 5, &map); + } else { + ERL_NIF_TERM keys[3] = {enif_make_atom(e, "accel"), enif_make_atom(e, "gyro"), + enif_make_atom(e, "timestamp")}; + ERL_NIF_TERM vals[3] = {accel, gyro, enif_make_int64(e, ts)}; + enif_make_map_from_arrays(e, keys, vals, 3, &map); + } + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, "motion"), map); enif_send(NULL, &p, e, msg); enif_free_env(e); - }]; + }; + + if (magOK) { + [g_motion_manager startDeviceMotionUpdatesUsingReferenceFrame: + CMAttitudeReferenceFrameXMagneticNorthZVertical + toQueue:[NSOperationQueue new] + withHandler:handler]; + } else { + [g_motion_manager startDeviceMotionUpdatesToQueue:[NSOperationQueue new] + withHandler:handler]; + } + }); + return enif_make_atom(env, "ok"); } -@end -static ERL_NIF_TERM nif_scanner_scan(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); +static ERL_NIF_TERM nif_motion_stop(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - g_scanner_vc = [[MobScannerVC alloc] init]; - g_scanner_vc.pid = pid; - g_scanner_vc.modalPresentationStyle = UIModalPresentationFullScreen; - [mob_root_vc() presentViewController:g_scanner_vc animated:YES completion:nil]; + [g_motion_manager stopDeviceMotionUpdates]; }); return enif_make_atom(env, "ok"); } @@ -2693,35 +3363,41 @@ static ERL_NIF_TERM nif_scanner_scan(ErlNifEnv* env, int argc, const ERL_NIF_TER @implementation MobNotificationDelegate // Foreground delivery -- (void)userNotificationCenter:(UNUserNotificationCenter*)center - willPresentNotification:(UNNotification*)notification - withCompletionHandler:(void(^)(UNNotificationPresentationOptions))handler { +- (void)userNotificationCenter:(UNUserNotificationCenter *)center + willPresentNotification:(UNNotification *)notification + withCompletionHandler:(void (^)(UNNotificationPresentationOptions))handler { handler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionSound); [self deliverNotification:notification.request.content - source:@"local" id:notification.request.identifier]; + source:@"local" + id:notification.request.identifier]; } // Tap on notification (foreground or background) -- (void)userNotificationCenter:(UNUserNotificationCenter*)center - didReceiveNotificationResponse:(UNNotificationResponse*)response - withCompletionHandler:(void(^)(void))handler { +- (void)userNotificationCenter:(UNUserNotificationCenter *)center + didReceiveNotificationResponse:(UNNotificationResponse *)response + withCompletionHandler:(void (^)(void))handler { [self deliverNotification:response.notification.request.content - source:@"local" id:response.notification.request.identifier]; + source:@"local" + id:response.notification.request.identifier]; handler(); } -- (void)deliverNotification:(UNNotificationContent*)content source:(NSString*)src id:(NSString*)nid { +- (void)deliverNotification:(UNNotificationContent *)content + source:(NSString *)src + id:(NSString *)nid { ErlNifPid p = self.screenPid; - ErlNifEnv* e = enif_alloc_env(); + ErlNifEnv *e = enif_alloc_env(); // Build data map from userInfo ERL_NIF_TERM data_map = enif_make_new_map(e); - NSDictionary* ui = content.userInfo; - for (NSString* key in ui) { + NSDictionary *ui = content.userInfo; + for (NSString *key in ui) { id val = ui[key]; - const char* ck = key.UTF8String; + const char *ck = key.UTF8String; ERL_NIF_TERM kterm = enif_make_atom(e, ck); ERL_NIF_TERM vterm; if ([val isKindOfClass:[NSString class]]) { - const char* cv = [val UTF8String]; - ErlNifBinary b; enif_alloc_binary(strlen(cv), &b); memcpy(b.data, cv, strlen(cv)); + const char *cv = [val UTF8String]; + ErlNifBinary b; + enif_alloc_binary(strlen(cv), &b); + memcpy(b.data, cv, strlen(cv)); vterm = enif_make_binary(e, &b); } else if ([val isKindOfClass:[NSNumber class]]) { vterm = enif_make_int64(e, [val longLongValue]); @@ -2730,95 +3406,38 @@ - (void)deliverNotification:(UNNotificationContent*)content source:(NSString*)sr } enif_make_map_put(e, data_map, kterm, vterm, &data_map); } - const char* cid = nid.UTF8String; - const char* csrc = src.UTF8String; - ErlNifBinary ib; enif_alloc_binary(strlen(cid), &ib); memcpy(ib.data, cid, strlen(cid)); - ERL_NIF_TERM keys[3] = {enif_make_atom(e,"id"),enif_make_atom(e,"source"),enif_make_atom(e,"data")}; - ERL_NIF_TERM vals[3] = {enif_make_binary(e,&ib),enif_make_atom(e,csrc),data_map}; - ERL_NIF_TERM map; enif_make_map_from_arrays(e, keys, vals, 3, &map); - ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e,"notification"), map); + const char *cid = nid.UTF8String; + const char *csrc = src.UTF8String; + ErlNifBinary ib; + enif_alloc_binary(strlen(cid), &ib); + memcpy(ib.data, cid, strlen(cid)); + ERL_NIF_TERM keys[3] = {enif_make_atom(e, "id"), enif_make_atom(e, "source"), + enif_make_atom(e, "data")}; + ERL_NIF_TERM vals[3] = {enif_make_binary(e, &ib), enif_make_atom(e, csrc), data_map}; + ERL_NIF_TERM map; + enif_make_map_from_arrays(e, keys, vals, 3, &map); + ERL_NIF_TERM msg = enif_make_tuple2(e, enif_make_atom(e, "notification"), map); enif_send(NULL, &p, e, msg); enif_free_env(e); } @end -static ERL_NIF_TERM nif_notify_schedule(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - ErlNifPid pid; enif_self(env, &pid); - - // Copy JSON to heap-allocated buffer for use in async block - char* json = (char*)malloc(bin.size + 1); - memcpy(json, bin.data, bin.size); - json[bin.size] = 0; - - dispatch_async(dispatch_get_main_queue(), ^{ - // Set delegate once - if (!g_notif_delegate) { - g_notif_delegate = [[MobNotificationDelegate alloc] init]; - g_notif_delegate.screenPid = pid; - [UNUserNotificationCenter currentNotificationCenter].delegate = g_notif_delegate; - } - g_notif_delegate.screenPid = pid; - - NSData* data = [NSData dataWithBytes:json length:strlen(json)]; - free(json); - NSDictionary* opts = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - if (!opts) return; - - UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init]; - content.title = opts[@"title"] ?: @""; - content.body = opts[@"body"] ?: @""; - NSDictionary* dataMap = opts[@"data"]; - if ([dataMap isKindOfClass:[NSDictionary class]]) content.userInfo = dataMap; - content.sound = [UNNotificationSound defaultSound]; - - NSTimeInterval delay = [opts[@"trigger_at"] doubleValue] - [[NSDate date] timeIntervalSince1970]; - if (delay < 1) delay = 1; - UNTimeIntervalNotificationTrigger* trigger = - [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:delay repeats:NO]; - NSString* nid = opts[@"id"] ?: [[NSUUID UUID] UUIDString]; - UNNotificationRequest* req = [UNNotificationRequest requestWithIdentifier:nid - content:content - trigger:trigger]; - [[UNUserNotificationCenter currentNotificationCenter] - addNotificationRequest:req withCompletionHandler:nil]; - }); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_notify_cancel(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &bin)) - return enif_make_badarg(env); - NSString* nid = [[NSString alloc] initWithBytes:bin.data length:bin.size - encoding:NSUTF8StringEncoding]; - dispatch_async(dispatch_get_main_queue(), ^{ - [[UNUserNotificationCenter currentNotificationCenter] - removePendingNotificationRequestsWithIdentifiers:@[nid]]; - }); - return enif_make_atom(env, "ok"); -} - -static ERL_NIF_TERM nif_notify_register_push(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - ErlNifPid pid; enif_self(env, &pid); +// Plugin seam (mob_notify): ensure the core-owned notification-center +// delegate exists and point deliveries (foreground present + tap) at pid. +// The scheduling/cancel/register NIFs moved to the mob_notify plugin; the +// DELEGATE, mob_send_push_token (host AppDelegate) and the launch- +// notification handoff stay here. Counterpart of the generated Android +// io.mob.plugin.MobNotifyHub. +void mob_notify_set_screen_pid(ErlNifPid pid) { dispatch_async(dispatch_get_main_queue(), ^{ - if (!g_notif_delegate) { - g_notif_delegate = [[MobNotificationDelegate alloc] init]; - [UNUserNotificationCenter currentNotificationCenter].delegate = g_notif_delegate; - } - g_notif_delegate.screenPid = pid; - [[UIApplication sharedApplication] registerForRemoteNotifications]; - // Token is delivered via AppDelegate didRegisterForRemoteNotificationsWithDeviceToken. - // Add a call to mob_send_push_token(token) there — see README for setup. + if (!g_notif_delegate) { + g_notif_delegate = [[MobNotificationDelegate alloc] init]; + [UNUserNotificationCenter currentNotificationCenter].delegate = g_notif_delegate; + } + g_notif_delegate.screenPid = pid; }); - return enif_make_atom(env, "ok"); } - // ════════════════════════════════════════════════════════════════════════════ // TEST HARNESS — compiled out of release builds (MOB_RELEASE). // ════════════════════════════════════════════════════════════════════════════ @@ -2840,9 +3459,11 @@ static ERL_NIF_TERM nif_notify_register_push(ErlNifEnv* env, int argc, const ERL // ── Test harness helpers (a11y walk, nsstring_to_term, AX framework) ─────────── static ERL_NIF_TERM nsstring_to_term(ErlNifEnv *env, NSString *s) { - if (!s) return enif_make_atom(env, "nil"); + if (!s) + return enif_make_atom(env, "nil"); const char *utf8 = [s UTF8String]; - if (!utf8) return enif_make_atom(env, "nil"); + if (!utf8) + return enif_make_atom(env, "nil"); size_t len = strlen(utf8); ErlNifBinary bin; enif_alloc_binary(len, &bin); @@ -2851,39 +3472,45 @@ static ERL_NIF_TERM nsstring_to_term(ErlNifEnv *env, NSString *s) { } static void walk_a11y(ErlNifEnv *env, id obj, ERL_NIF_TERM *list, int depth) { - if (!obj || depth > 30) return; + if (!obj || depth > 30) + return; // Collect leaf accessibility elements (visible, interactive, or labelled nodes) BOOL isElem = [obj respondsToSelector:@selector(isAccessibilityElement)] && [(id)obj isAccessibilityElement]; if (isElem) { - NSString *label = [obj respondsToSelector:@selector(accessibilityLabel)] - ? [(id)obj accessibilityLabel] : nil; - NSString *value = [obj respondsToSelector:@selector(accessibilityValue)] - ? [(id)obj accessibilityValue] : nil; + NSString *label = [obj respondsToSelector:@selector(accessibilityLabel)] + ? [(id)obj accessibilityLabel] + : nil; + NSString *value = [obj respondsToSelector:@selector(accessibilityValue)] + ? [(id)obj accessibilityValue] + : nil; UIAccessibilityTraits traits = [obj respondsToSelector:@selector(accessibilityTraits)] - ? [(id)obj accessibilityTraits] : 0; - CGRect frame = [obj respondsToSelector:@selector(accessibilityFrame)] - ? [(id)obj accessibilityFrame] : CGRectZero; + ? [(id)obj accessibilityTraits] + : 0; + CGRect frame = [obj respondsToSelector:@selector(accessibilityFrame)] + ? [(id)obj accessibilityFrame] + : CGRectZero; const char *type_str = "element"; - if (traits & UIAccessibilityTraitButton) type_str = "button"; - else if (traits & UIAccessibilityTraitStaticText) type_str = "text"; - else if (traits & UIAccessibilityTraitImage) type_str = "image"; - else if (traits & UIAccessibilityTraitHeader) type_str = "header"; - else if (traits & UIAccessibilityTraitSearchField) type_str = "text_field"; - - ERL_NIF_TERM frame_tup = enif_make_tuple4(env, - enif_make_double(env, frame.origin.x), - enif_make_double(env, frame.origin.y), - enif_make_double(env, frame.size.width), - enif_make_double(env, frame.size.height)); - - ERL_NIF_TERM elem = enif_make_tuple4(env, - enif_make_atom(env, type_str), - nsstring_to_term(env, label), - nsstring_to_term(env, value), - frame_tup); + if (traits & UIAccessibilityTraitButton) + type_str = "button"; + else if (traits & UIAccessibilityTraitStaticText) + type_str = "text"; + else if (traits & UIAccessibilityTraitImage) + type_str = "image"; + else if (traits & UIAccessibilityTraitHeader) + type_str = "header"; + else if (traits & UIAccessibilityTraitSearchField) + type_str = "text_field"; + + ERL_NIF_TERM frame_tup = enif_make_tuple4( + env, enif_make_double(env, frame.origin.x), enif_make_double(env, frame.origin.y), + enif_make_double(env, frame.size.width), enif_make_double(env, frame.size.height)); + + ERL_NIF_TERM elem = + enif_make_tuple4(env, enif_make_atom(env, type_str), nsstring_to_term(env, label), + nsstring_to_term(env, value), frame_tup); *list = enif_make_list_cell(env, elem, *list); } @@ -2896,7 +3523,8 @@ static void walk_a11y(ErlNifEnv *env, id obj, ERL_NIF_TERM *list, int depth) { NSArray *elems = [(id)obj accessibilityElements]; if (elems.count > 0) { for (id child in elems) { - if (child && child != obj) walk_a11y(env, child, list, depth + 1); + if (child && child != obj) + walk_a11y(env, child, list, depth + 1); } walked = YES; } @@ -2907,7 +3535,8 @@ static void walk_a11y(ErlNifEnv *env, id obj, ERL_NIF_TERM *list, int depth) { if (count != NSNotFound && count > 0) { for (NSInteger i = 0; i < count; i++) { id child = [(id)obj accessibilityElementAtIndex:i]; - if (child && child != obj) walk_a11y(env, child, list, depth + 1); + if (child && child != obj) + walk_a11y(env, child, list, depth + 1); } walked = YES; } @@ -2922,64 +3551,82 @@ static void walk_a11y(ErlNifEnv *env, id obj, ERL_NIF_TERM *list, int depth) { // ── NIF: ui_debug/0 — diagnostic: dumps window/view/a11y structure to NSLog ── static void debug_walk(id obj, int depth) { - if (!obj || depth > 8) return; - NSString *indent = [@"" stringByPaddingToLength:depth*2 withString:@" " startingAtIndex:0]; + if (!obj || depth > 8) + return; + NSString *indent = [@"" stringByPaddingToLength:depth * 2 withString:@" " startingAtIndex:0]; NSString *cls = NSStringFromClass([obj class]); - NSString *label = [obj respondsToSelector:@selector(accessibilityLabel)] ? [obj accessibilityLabel] : @"-"; - NSString *value = [obj respondsToSelector:@selector(accessibilityValue)] ? [obj accessibilityValue] : @"-"; - BOOL isElem = [obj respondsToSelector:@selector(isAccessibilityElement)] && [obj isAccessibilityElement]; - NSInteger a11yCount = [obj respondsToSelector:@selector(accessibilityElementCount)] ? [obj accessibilityElementCount] : -99; - NSArray *a11yArr = [obj respondsToSelector:@selector(accessibilityElements)] ? [obj accessibilityElements] : nil; - NSInteger subCount = [obj isKindOfClass:[UIView class]] ? [(UIView*)obj subviews].count : -1; - NSLog(@"[ui_debug]%@%@ isElem=%d a11yCount=%ld a11yArr=%ld subs=%ld label=%@ value=%@", - indent, cls, isElem, (long)a11yCount, (long)a11yArr.count, (long)subCount, label, value); + NSString *label = + [obj respondsToSelector:@selector(accessibilityLabel)] ? [obj accessibilityLabel] : @"-"; + NSString *value = + [obj respondsToSelector:@selector(accessibilityValue)] ? [obj accessibilityValue] : @"-"; + BOOL isElem = + [obj respondsToSelector:@selector(isAccessibilityElement)] && [obj isAccessibilityElement]; + NSInteger a11yCount = [obj respondsToSelector:@selector(accessibilityElementCount)] + ? [obj accessibilityElementCount] + : -99; + NSArray *a11yArr = [obj respondsToSelector:@selector(accessibilityElements)] + ? [obj accessibilityElements] + : nil; + NSInteger subCount = [obj isKindOfClass:[UIView class]] ? [(UIView *)obj subviews].count : -1; + NSLog(@"[ui_debug]%@%@ isElem=%d a11yCount=%ld a11yArr=%ld subs=%ld label=%@ value=%@", indent, + cls, isElem, (long)a11yCount, (long)a11yArr.count, (long)subCount, label, value); if ([obj respondsToSelector:@selector(accessibilityElementCount)]) { NSInteger cnt = [obj accessibilityElementCount]; if (cnt != NSNotFound && cnt > 0) { - for (NSInteger i = 0; i < cnt; i++) debug_walk([obj accessibilityElementAtIndex:i], depth+1); + for (NSInteger i = 0; i < cnt; i++) + debug_walk([obj accessibilityElementAtIndex:i], depth + 1); } } - for (id child in [obj respondsToSelector:@selector(accessibilityElements)] ? [obj accessibilityElements] : @[]) - debug_walk(child, depth+1); + for (id child in [obj respondsToSelector:@selector(accessibilityElements)] + ? [obj accessibilityElements] + : @[]) + debug_walk(child, depth + 1); if ([obj isKindOfClass:[UIView class]]) - for (UIView *sub in [(UIView*)obj subviews]) debug_walk(sub, depth+1); + for (UIView *sub in [(UIView *)obj subviews]) + debug_walk(sub, depth + 1); } // Walk macOS AXUIElement tree (works because the iOS Simulator IS a macOS process). // We load ApplicationServices from the Mac host path (not the simulator runtime root). typedef void *AXUIElementRef_t; -typedef int AXError_t; +typedef int AXError_t; typedef void *(*AXUIElementCreateApplicationFn)(pid_t pid); -typedef AXError_t (*AXUIElementCopyAttributeValueFn)(AXUIElementRef_t elem, void *attr, void **value); +typedef AXError_t (*AXUIElementCopyAttributeValueFn)(AXUIElementRef_t elem, void *attr, + void **value); typedef AXError_t (*AXUIElementCopyAttributeNamesFn)(AXUIElementRef_t elem, void **names); typedef Boolean (*AXIsProcessTrustedFn)(void); static void *g_AppSvc = NULL; -static AXUIElementCreateApplicationFn g_AXCreateApp = NULL; -static AXUIElementCopyAttributeValueFn g_AXCopyAttr = NULL; -static AXIsProcessTrustedFn g_AXIsTrusted = NULL; +static AXUIElementCreateApplicationFn g_AXCreateApp = NULL; +static AXUIElementCopyAttributeValueFn g_AXCopyAttr = NULL; +static AXIsProcessTrustedFn g_AXIsTrusted = NULL; static NSString *g_ax_load_error = nil; static void load_ax(void) { - if (g_AppSvc) return; + if (g_AppSvc) + return; // The iOS Simulator is a macOS process. Check if AX symbols are already available // in the process image (RTLD_DEFAULT searches all loaded libraries). if (dlsym) { void *fn = dlsym(RTLD_DEFAULT, "AXUIElementCreateApplication"); if (fn) { - g_AppSvc = RTLD_DEFAULT; // sentinel: symbols are available + g_AppSvc = RTLD_DEFAULT; // sentinel: symbols are available } else { const char *err = dlerror ? dlerror() : "no dlerror"; - g_ax_load_error = [NSString stringWithFormat:@"RTLD_DEFAULT AXUIElementCreateApplication: %s", err]; + g_ax_load_error = + [NSString stringWithFormat:@"RTLD_DEFAULT AXUIElementCreateApplication: %s", err]; } } - if (!g_AppSvc) return; - g_AXCreateApp = (AXUIElementCreateApplicationFn) dlsym(g_AppSvc, "AXUIElementCreateApplication"); - g_AXCopyAttr = (AXUIElementCopyAttributeValueFn)dlsym(g_AppSvc, "AXUIElementCopyAttributeValue"); - g_AXIsTrusted = (AXIsProcessTrustedFn) dlsym(g_AppSvc, "AXIsProcessTrusted"); + if (!g_AppSvc) + return; + g_AXCreateApp = (AXUIElementCreateApplicationFn)dlsym(g_AppSvc, "AXUIElementCreateApplication"); + g_AXCopyAttr = + (AXUIElementCopyAttributeValueFn)dlsym(g_AppSvc, "AXUIElementCopyAttributeValue"); + g_AXIsTrusted = (AXIsProcessTrustedFn)dlsym(g_AppSvc, "AXIsProcessTrusted"); } static void ax_walk(void *elem, ErlNifEnv *env, ERL_NIF_TERM *list, int depth) { - if (!elem || depth > 20) return; + if (!elem || depth > 20) + return; // role void *role = NULL; g_AXCopyAttr(elem, (void *)CFSTR("AXRole"), &role); @@ -2996,7 +3643,7 @@ static void ax_walk(void *elem, ErlNifEnv *env, ERL_NIF_TERM *list, int depth) { // Only emit if we have a role (leaf or intermediate) if (role) { // CF types loaded via dlopen — bridge via CFStringRef intermediate (no ARC transfer) - NSString *roleStr = (__bridge NSString *)((CFStringRef)role); + NSString *roleStr = (__bridge NSString *)((CFStringRef)role); NSString *labelStr = label ? (__bridge NSString *)((CFStringRef)label) : @""; NSString *valueStr = value ? (__bridge NSString *)((CFStringRef)value) : @""; CGRect frame = CGRectZero; @@ -3004,23 +3651,23 @@ static void ax_walk(void *elem, ErlNifEnv *env, ERL_NIF_TERM *list, int depth) { // AXFrame value is an AXValue (AXValueType kAXValueCGRectType == 3) typedef Boolean (*AXValueGetValueFn)(CFTypeRef axval, int type, void *out); AXValueGetValueFn axGetVal = (AXValueGetValueFn)dlsym(g_AppSvc, "AXValueGetValue"); - if (axGetVal) axGetVal((CFTypeRef)frameVal, 3, &frame); + if (axGetVal) + axGetVal((CFTypeRef)frameVal, 3, &frame); CFRelease((CFTypeRef)frameVal); } - ERL_NIF_TERM frame_tup = enif_make_tuple4(env, - enif_make_double(env, frame.origin.x), - enif_make_double(env, frame.origin.y), - enif_make_double(env, frame.size.width), - enif_make_double(env, frame.size.height)); - ERL_NIF_TERM elem_tup = enif_make_tuple4(env, - nsstring_to_term(env, roleStr), - nsstring_to_term(env, labelStr), - nsstring_to_term(env, valueStr), - frame_tup); + ERL_NIF_TERM frame_tup = enif_make_tuple4( + env, enif_make_double(env, frame.origin.x), enif_make_double(env, frame.origin.y), + enif_make_double(env, frame.size.width), enif_make_double(env, frame.size.height)); + ERL_NIF_TERM elem_tup = + enif_make_tuple4(env, nsstring_to_term(env, roleStr), nsstring_to_term(env, labelStr), + nsstring_to_term(env, valueStr), frame_tup); *list = enif_make_list_cell(env, elem_tup, *list); - if (role) CFRelease((CFTypeRef)role); - if (label) CFRelease((CFTypeRef)label); - if (value) CFRelease((CFTypeRef)value); + if (role) + CFRelease((CFTypeRef)role); + if (label) + CFRelease((CFTypeRef)label); + if (value) + CFRelease((CFTypeRef)value); } // recurse into children void *children = NULL; @@ -3035,8 +3682,6 @@ static void ax_walk(void *elem, ErlNifEnv *env, ERL_NIF_TERM *list, int depth) { } } - - // ── view-tree walker (no AX activation needed) ─────────────────────────────── // // Walks UIView.subviews directly instead of going through the accessibility @@ -3053,16 +3698,26 @@ static void ax_walk(void *elem, ErlNifEnv *env, ERL_NIF_TERM *list, int depth) { // things AX wouldn't surface. static const char *classify_view_type(UIView *view) { - if ([view isKindOfClass:[UIButton class]]) return "button"; - if ([view isKindOfClass:[UISwitch class]]) return "switch"; - if ([view isKindOfClass:[UISlider class]]) return "slider"; - if ([view isKindOfClass:[UITextField class]]) return "text_field"; - if ([view isKindOfClass:[UITextView class]]) return "text_field"; - if ([view isKindOfClass:[UILabel class]]) return "text"; - if ([view isKindOfClass:[UIImageView class]]) return "image"; - if ([view isKindOfClass:[UIScrollView class]]) return "scroll"; - if ([view isKindOfClass:[UIPickerView class]]) return "picker"; - if ([view isKindOfClass:[UIWindow class]]) return "window"; + if ([view isKindOfClass:[UIButton class]]) + return "button"; + if ([view isKindOfClass:[UISwitch class]]) + return "switch"; + if ([view isKindOfClass:[UISlider class]]) + return "slider"; + if ([view isKindOfClass:[UITextField class]]) + return "text_field"; + if ([view isKindOfClass:[UITextView class]]) + return "text_field"; + if ([view isKindOfClass:[UILabel class]]) + return "text"; + if ([view isKindOfClass:[UIImageView class]]) + return "image"; + if ([view isKindOfClass:[UIScrollView class]]) + return "scroll"; + if ([view isKindOfClass:[UIPickerView class]]) + return "picker"; + if ([view isKindOfClass:[UIWindow class]]) + return "window"; return "view"; } @@ -3072,26 +3727,29 @@ static void ax_walk(void *elem, ErlNifEnv *env, ERL_NIF_TERM *list, int depth) { NSString *t = [btn titleForState:UIControlStateNormal]; return t.length ? t : btn.titleLabel.text; } - if ([view isKindOfClass:[UILabel class]]) return ((UILabel *)view).text; - if ([view isKindOfClass:[UITextField class]]) return ((UITextField *)view).text; - if ([view isKindOfClass:[UITextView class]]) return ((UITextView *)view).text; - if (view.accessibilityLabel.length) return view.accessibilityLabel; + if ([view isKindOfClass:[UILabel class]]) + return ((UILabel *)view).text; + if ([view isKindOfClass:[UITextField class]]) + return ((UITextField *)view).text; + if ([view isKindOfClass:[UITextView class]]) + return ((UITextView *)view).text; + if (view.accessibilityLabel.length) + return view.accessibilityLabel; return nil; } static ERL_NIF_TERM build_view_node(ErlNifEnv *env, UIView *view, int depth) { - if (!view || depth > 50) return enif_make_atom(env, "nil"); + if (!view || depth > 50) + return enif_make_atom(env, "nil"); CGRect win_frame = [view convertRect:view.bounds toView:nil]; - NSString *text = extract_view_text(view); - NSString *value = view.accessibilityValue; + NSString *text = extract_view_text(view); + NSString *value = view.accessibilityValue; const char *type_str = classify_view_type(view); - ERL_NIF_TERM frame = enif_make_tuple4(env, - enif_make_double(env, win_frame.origin.x), - enif_make_double(env, win_frame.origin.y), - enif_make_double(env, win_frame.size.width), - enif_make_double(env, win_frame.size.height)); + ERL_NIF_TERM frame = enif_make_tuple4( + env, enif_make_double(env, win_frame.origin.x), enif_make_double(env, win_frame.origin.y), + enif_make_double(env, win_frame.size.width), enif_make_double(env, win_frame.size.height)); NSArray *subs = view.subviews; ERL_NIF_TERM children = enif_make_list(env, 0); @@ -3100,20 +3758,11 @@ static ERL_NIF_TERM build_view_node(ErlNifEnv *env, UIView *view, int depth) { children = enif_make_list_cell(env, child, children); } - ERL_NIF_TERM keys[5] = { - enif_make_atom(env, "type"), - enif_make_atom(env, "label"), - enif_make_atom(env, "value"), - enif_make_atom(env, "frame"), - enif_make_atom(env, "children") - }; - ERL_NIF_TERM vals[5] = { - enif_make_atom(env, type_str), - nsstring_to_term(env, text), - nsstring_to_term(env, value), - frame, - children - }; + ERL_NIF_TERM keys[5] = {enif_make_atom(env, "type"), enif_make_atom(env, "label"), + enif_make_atom(env, "value"), enif_make_atom(env, "frame"), + enif_make_atom(env, "children")}; + ERL_NIF_TERM vals[5] = {enif_make_atom(env, type_str), nsstring_to_term(env, text), + nsstring_to_term(env, value), frame, children}; ERL_NIF_TERM result; enif_make_map_from_arrays(env, keys, vals, 5, &result); return result; @@ -3123,40 +3772,33 @@ static ERL_NIF_TERM nif_ui_view_tree(ErlNifEnv *env, int argc, const ERL_NIF_TER __block ERL_NIF_TERM windows_list = enif_make_list(env, 0); __block CGSize screen_size = CGSizeZero; dispatch_sync(dispatch_get_main_queue(), ^{ - screen_size = [UIScreen mainScreen].bounds.size; - NSMutableArray<UIWindow *> *wins = [NSMutableArray array]; - for (UIScene *s in [UIApplication sharedApplication].connectedScenes) { - if (![s isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *w in [(UIWindowScene *)s windows]) { - if (!w.isHidden) [wins addObject:w]; - } - } - for (NSInteger i = (NSInteger)wins.count - 1; i >= 0; i--) { - ERL_NIF_TERM wnode = build_view_node(env, wins[i], 0); - windows_list = enif_make_list_cell(env, wnode, windows_list); - } + screen_size = [UIScreen mainScreen].bounds.size; + NSMutableArray<UIWindow *> *wins = [NSMutableArray array]; + for (UIScene *s in [UIApplication sharedApplication].connectedScenes) { + if (![s isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *w in [(UIWindowScene *)s windows]) { + if (!w.isHidden) + [wins addObject:w]; + } + } + for (NSInteger i = (NSInteger)wins.count - 1; i >= 0; i--) { + ERL_NIF_TERM wnode = build_view_node(env, wins[i], 0); + windows_list = enif_make_list_cell(env, wnode, windows_list); + } }); // Synthetic root wrapping all top-level windows. Frame is the screen size // so consumers always have a valid bounding box for the whole UI. - ERL_NIF_TERM root_keys[5] = { - enif_make_atom(env, "type"), - enif_make_atom(env, "label"), - enif_make_atom(env, "value"), - enif_make_atom(env, "frame"), - enif_make_atom(env, "children") - }; + ERL_NIF_TERM root_keys[5] = {enif_make_atom(env, "type"), enif_make_atom(env, "label"), + enif_make_atom(env, "value"), enif_make_atom(env, "frame"), + enif_make_atom(env, "children")}; ERL_NIF_TERM root_vals[5] = { - enif_make_atom(env, "root"), - enif_make_atom(env, "nil"), - enif_make_atom(env, "nil"), - enif_make_tuple4(env, - enif_make_double(env, 0.0), - enif_make_double(env, 0.0), - enif_make_double(env, screen_size.width), - enif_make_double(env, screen_size.height)), - windows_list - }; + enif_make_atom(env, "root"), enif_make_atom(env, "nil"), enif_make_atom(env, "nil"), + enif_make_tuple4(env, enif_make_double(env, 0.0), enif_make_double(env, 0.0), + enif_make_double(env, screen_size.width), + enif_make_double(env, screen_size.height)), + windows_list}; ERL_NIF_TERM root; enif_make_map_from_arrays(env, root_keys, root_vals, 5, &root); return root; @@ -3172,46 +3814,36 @@ static ERL_NIF_TERM nif_screen_info(ErlNifEnv *env, int argc, const ERL_NIF_TERM __block CGFloat scale = 1.0; __block UIEdgeInsets insets = UIEdgeInsetsZero; dispatch_sync(dispatch_get_main_queue(), ^{ - UIScreen *screen = [UIScreen mainScreen]; - bounds = screen.bounds; - scale = screen.scale; - // Pull safe-area from the first visible window we find. - for (UIScene *s in [UIApplication sharedApplication].connectedScenes) { - if (![s isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *w in [(UIWindowScene *)s windows]) { - if (!w.isHidden) { insets = w.safeAreaInsets; goto done; } - } - } - done:; + UIScreen *screen = [UIScreen mainScreen]; + bounds = screen.bounds; + scale = screen.scale; + // Pull safe-area from the first visible window we find. + for (UIScene *s in [UIApplication sharedApplication].connectedScenes) { + if (![s isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *w in [(UIWindowScene *)s windows]) { + if (!w.isHidden) { + insets = w.safeAreaInsets; + goto done; + } + } + } + done:; }); - ERL_NIF_TERM sa_keys[4] = { - enif_make_atom(env, "top"), - enif_make_atom(env, "bottom"), - enif_make_atom(env, "left"), - enif_make_atom(env, "right") - }; + ERL_NIF_TERM sa_keys[4] = {enif_make_atom(env, "top"), enif_make_atom(env, "bottom"), + enif_make_atom(env, "left"), enif_make_atom(env, "right")}; ERL_NIF_TERM sa_vals[4] = { - enif_make_double(env, insets.top), - enif_make_double(env, insets.bottom), - enif_make_double(env, insets.left), - enif_make_double(env, insets.right) - }; + enif_make_double(env, insets.top), enif_make_double(env, insets.bottom), + enif_make_double(env, insets.left), enif_make_double(env, insets.right)}; ERL_NIF_TERM safe_area; enif_make_map_from_arrays(env, sa_keys, sa_vals, 4, &safe_area); - ERL_NIF_TERM keys[4] = { - enif_make_atom(env, "width"), - enif_make_atom(env, "height"), - enif_make_atom(env, "scale"), - enif_make_atom(env, "safe_area") - }; - ERL_NIF_TERM vals[4] = { - enif_make_double(env, bounds.size.width), - enif_make_double(env, bounds.size.height), - enif_make_double(env, scale), - safe_area - }; + ERL_NIF_TERM keys[4] = {enif_make_atom(env, "width"), enif_make_atom(env, "height"), + enif_make_atom(env, "scale"), enif_make_atom(env, "safe_area")}; + ERL_NIF_TERM vals[4] = {enif_make_double(env, bounds.size.width), + enif_make_double(env, bounds.size.height), enif_make_double(env, scale), + safe_area}; ERL_NIF_TERM result; enif_make_map_from_arrays(env, keys, vals, 4, &result); return result; @@ -3225,10 +3857,11 @@ static ERL_NIF_TERM nif_ui_debug(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar // Probe via macOS AXUIElement — runs on NIF thread, no main-queue needed. Boolean trusted = g_AXIsTrusted ? g_AXIsTrusted() : NO; ERL_NIF_TERM trusted_t = enif_make_atom(env, trusted ? "trusted" : "not_trusted"); - ERL_NIF_TERM appsvc_t = enif_make_atom(env, g_AppSvc ? "loaded" : "not_loaded"); - result = enif_make_list_cell(env, enif_make_tuple2(env, - enif_make_atom(env, "ax_status"), - enif_make_tuple2(env, appsvc_t, trusted_t)), result); + ERL_NIF_TERM appsvc_t = enif_make_atom(env, g_AppSvc ? "loaded" : "not_loaded"); + result = enif_make_list_cell(env, + enif_make_tuple2(env, enif_make_atom(env, "ax_status"), + enif_make_tuple2(env, appsvc_t, trusted_t)), + result); if (g_ax_load_error) { result = enif_make_list_cell(env, nsstring_to_term(env, g_ax_load_error), result); } @@ -3246,25 +3879,27 @@ static ERL_NIF_TERM nif_ui_debug(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar return reversed; } - // ensure_a11y_enabled: no-op in the NIF itself. // Accessibility must be activated from the Mac side before calling ui_tree(): -// xcrun simctl spawn <udid> defaults write com.apple.Accessibility VoiceOverTouchEnabled -bool YES -// xcrun simctl spawn <udid> notifyutil -p com.apple.accessibility.voiceover.status.changed +// xcrun simctl spawn <udid> defaults write com.apple.Accessibility VoiceOverTouchEnabled -bool +// YES xcrun simctl spawn <udid> notifyutil -p com.apple.accessibility.voiceover.status.changed // pegleg_dev's `mix mob.connect` will do this automatically. -static void ensure_a11y_enabled(void) { } +static void ensure_a11y_enabled(void) { +} static ERL_NIF_TERM nif_ui_tree(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { __block ERL_NIF_TERM list = enif_make_list(env, 0); dispatch_sync(dispatch_get_main_queue(), ^{ - ensure_a11y_enabled(); - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *window in [(UIWindowScene *)scene windows]) { - if (window.isHidden) continue; - walk_a11y(env, window, &list, 0); - } - } + ensure_a11y_enabled(); + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *window in [(UIWindowScene *)scene windows]) { + if (window.isHidden) + continue; + walk_a11y(env, window, &list, 0); + } + } }); ERL_NIF_TERM reversed; enif_make_reverse_list(env, list, &reversed); @@ -3288,7 +3923,8 @@ static ERL_NIF_TERM nif_ui_tree(ErlNifEnv *env, int argc, const ERL_NIF_TERM arg // Returns the deepest accessibility element whose frame contains 'pt'. // Walks children depth-first (deepest/most-specific match wins). static id find_a11y_at_point(id obj, CGPoint pt, int depth) { - if (!obj || depth > 30) return nil; + if (!obj || depth > 30) + return nil; // Recurse into children first (deepest match wins) if ([obj respondsToSelector:@selector(accessibilityElements)]) { @@ -3297,7 +3933,8 @@ static id find_a11y_at_point(id obj, CGPoint pt, int depth) { for (id child in elems) { if (child && child != obj) { id found = find_a11y_at_point(child, pt, depth + 1); - if (found) return found; + if (found) + return found; } } goto check_self; @@ -3310,7 +3947,8 @@ static id find_a11y_at_point(id obj, CGPoint pt, int depth) { id child = [(id)obj accessibilityElementAtIndex:i]; if (child && child != obj) { id found = find_a11y_at_point(child, pt, depth + 1); - if (found) return found; + if (found) + return found; } } goto check_self; @@ -3319,7 +3957,8 @@ static id find_a11y_at_point(id obj, CGPoint pt, int depth) { if ([obj isKindOfClass:[UIView class]]) { for (UIView *sub in [(UIView *)obj subviews]) { id found = find_a11y_at_point(sub, pt, depth + 1); - if (found) return found; + if (found) + return found; } } @@ -3328,19 +3967,23 @@ static id find_a11y_at_point(id obj, CGPoint pt, int depth) { [(id)obj isAccessibilityElement] && [obj respondsToSelector:@selector(accessibilityFrame)]) { CGRect frame = [(id)obj accessibilityFrame]; - if (CGRectContainsPoint(frame, pt)) return obj; + if (CGRectContainsPoint(frame, pt)) + return obj; } return nil; } static id find_a11y_by_label(id obj, NSString *target, int depth) { - if (!obj || depth > 30) return nil; + if (!obj || depth > 30) + return nil; if ([obj respondsToSelector:@selector(isAccessibilityElement)] && [(id)obj isAccessibilityElement]) { NSString *lbl = [obj respondsToSelector:@selector(accessibilityLabel)] - ? [(id)obj accessibilityLabel] : nil; - if ([lbl isEqualToString:target]) return obj; + ? [(id)obj accessibilityLabel] + : nil; + if ([lbl isEqualToString:target]) + return obj; } // Walk children via the same single-path logic as walk_a11y() to avoid duplicates. @@ -3350,7 +3993,8 @@ static id find_a11y_by_label(id obj, NSString *target, int depth) { for (id child in elems) { if (child && child != obj) { id found = find_a11y_by_label(child, target, depth + 1); - if (found) return found; + if (found) + return found; } } return nil; @@ -3363,7 +4007,8 @@ static id find_a11y_by_label(id obj, NSString *target, int depth) { id child = [(id)obj accessibilityElementAtIndex:i]; if (child && child != obj) { id found = find_a11y_by_label(child, target, depth + 1); - if (found) return found; + if (found) + return found; } } return nil; @@ -3372,7 +4017,8 @@ static id find_a11y_by_label(id obj, NSString *target, int depth) { if ([obj isKindOfClass:[UIView class]]) { for (UIView *sub in [(UIView *)obj subviews]) { id found = find_a11y_by_label(sub, target, depth + 1); - if (found) return found; + if (found) + return found; } } return nil; @@ -3381,35 +4027,37 @@ static id find_a11y_by_label(id obj, NSString *target, int depth) { static ERL_NIF_TERM nif_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { // Accept Elixir binary strings (the normal case) ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin)) return enif_make_badarg(env); + if (!enif_inspect_binary(env, argv[0], &bin)) + return enif_make_badarg(env); NSString *label = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; - if (!label) return enif_make_badarg(env); + if (!label) + return enif_make_badarg(env); __block BOOL activated = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *window in [(UIWindowScene *)scene windows]) { - if (window.isHidden) continue; - id elem = find_a11y_by_label(window, label, 0); - if (elem) { - [elem accessibilityActivate]; - activated = YES; - return; - } - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *window in [(UIWindowScene *)scene windows]) { + if (window.isHidden) + continue; + id elem = find_a11y_by_label(window, label, 0); + if (elem) { + [elem accessibilityActivate]; + activated = YES; + return; + } + } + } }); - if (activated) return enif_make_atom(env, "ok"); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_found")); + if (activated) + return enif_make_atom(env, "ok"); + return enif_make_tuple2(env, enif_make_atom(env, "error"), enif_make_atom(env, "not_found")); } - // ── ax_action/2 — invoke an accessibility action on an element ──────────────── // // Finds the first AX element whose label OR value contains `match`, then sends @@ -3432,14 +4080,17 @@ static ERL_NIF_TERM nif_tap(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) // IMPORTANT: this requires accessibility to be activated (VoiceOver on, or // similar AX-client toggle). Same constraint as ui_tree/0. static id find_a11y_by_label_or_value(id obj, NSString *target, int depth) { - if (!obj || depth > 30) return nil; + if (!obj || depth > 30) + return nil; if ([obj respondsToSelector:@selector(isAccessibilityElement)] && [(id)obj isAccessibilityElement]) { NSString *lbl = [obj respondsToSelector:@selector(accessibilityLabel)] - ? [(id)obj accessibilityLabel] : nil; + ? [(id)obj accessibilityLabel] + : nil; NSString *val = [obj respondsToSelector:@selector(accessibilityValue)] - ? [(id)obj accessibilityValue] : nil; + ? [(id)obj accessibilityValue] + : nil; if ((lbl && [lbl rangeOfString:target].location != NSNotFound) || (val && [val rangeOfString:target].location != NSNotFound)) { return obj; @@ -3452,7 +4103,8 @@ static id find_a11y_by_label_or_value(id obj, NSString *target, int depth) { for (id child in elems) { if (child && child != obj) { id found = find_a11y_by_label_or_value(child, target, depth + 1); - if (found) return found; + if (found) + return found; } } return nil; @@ -3465,7 +4117,8 @@ static id find_a11y_by_label_or_value(id obj, NSString *target, int depth) { id child = [(id)obj accessibilityElementAtIndex:i]; if (child && child != obj) { id found = find_a11y_by_label_or_value(child, target, depth + 1); - if (found) return found; + if (found) + return found; } } return nil; @@ -3474,7 +4127,8 @@ static id find_a11y_by_label_or_value(id obj, NSString *target, int depth) { if ([obj isKindOfClass:[UIView class]]) { for (UIView *sub in [(UIView *)obj subviews]) { id found = find_a11y_by_label_or_value(sub, target, depth + 1); - if (found) return found; + if (found) + return found; } } return nil; @@ -3482,10 +4136,13 @@ static id find_a11y_by_label_or_value(id obj, NSString *target, int depth) { static ERL_NIF_TERM nif_ax_action(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; - if (!enif_inspect_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString *match = [[NSString alloc] initWithBytes:bin.data length:bin.size + if (!enif_inspect_binary(env, argv[0], &bin)) + return enif_make_badarg(env); + NSString *match = [[NSString alloc] initWithBytes:bin.data + length:bin.size encoding:NSUTF8StringEncoding]; - if (!match) return enif_make_badarg(env); + if (!match) + return enif_make_badarg(env); char action_buf[32] = {0}; if (!enif_get_atom(env, argv[1], action_buf, sizeof(action_buf), ERL_NIF_LATIN1)) @@ -3494,53 +4151,64 @@ static ERL_NIF_TERM nif_ax_action(ErlNifEnv *env, int argc, const ERL_NIF_TERM a __block id elem = nil; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - elem = find_a11y_by_label_or_value(win, match, 0); - if (elem) return; - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + elem = find_a11y_by_label_or_value(win, match, 0); + if (elem) + return; + } + } }); - if (!elem) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "not_found")); + if (!elem) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_found")); __block BOOL ok = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - if ([action isEqualToString:@"increment"]) { - if ([elem respondsToSelector:@selector(accessibilityIncrement)]) { - [elem accessibilityIncrement]; ok = YES; - } - } else if ([action isEqualToString:@"decrement"]) { - if ([elem respondsToSelector:@selector(accessibilityDecrement)]) { - [elem accessibilityDecrement]; ok = YES; - } - } else if ([action isEqualToString:@"activate"]) { - if ([elem respondsToSelector:@selector(accessibilityActivate)]) { - ok = [elem accessibilityActivate]; - } - } else if ([action isEqualToString:@"escape"]) { - if ([elem respondsToSelector:@selector(accessibilityPerformEscape)]) { - ok = [elem accessibilityPerformEscape]; - } - } else if ([action hasPrefix:@"scroll_"]) { - NSString *dir_str = [action substringFromIndex:7]; - UIAccessibilityScrollDirection dir = 0; - if ([dir_str isEqualToString:@"up"]) dir = UIAccessibilityScrollDirectionUp; - else if ([dir_str isEqualToString:@"down"]) dir = UIAccessibilityScrollDirectionDown; - else if ([dir_str isEqualToString:@"left"]) dir = UIAccessibilityScrollDirectionLeft; - else if ([dir_str isEqualToString:@"right"]) dir = UIAccessibilityScrollDirectionRight; - if (dir && [elem respondsToSelector:@selector(accessibilityScroll:)]) { - ok = [elem accessibilityScroll:dir]; - } - } + if ([action isEqualToString:@"increment"]) { + if ([elem respondsToSelector:@selector(accessibilityIncrement)]) { + [elem accessibilityIncrement]; + ok = YES; + } + } else if ([action isEqualToString:@"decrement"]) { + if ([elem respondsToSelector:@selector(accessibilityDecrement)]) { + [elem accessibilityDecrement]; + ok = YES; + } + } else if ([action isEqualToString:@"activate"]) { + if ([elem respondsToSelector:@selector(accessibilityActivate)]) { + ok = [elem accessibilityActivate]; + } + } else if ([action isEqualToString:@"escape"]) { + if ([elem respondsToSelector:@selector(accessibilityPerformEscape)]) { + ok = [elem accessibilityPerformEscape]; + } + } else if ([action hasPrefix:@"scroll_"]) { + NSString *dir_str = [action substringFromIndex:7]; + UIAccessibilityScrollDirection dir = 0; + if ([dir_str isEqualToString:@"up"]) + dir = UIAccessibilityScrollDirectionUp; + else if ([dir_str isEqualToString:@"down"]) + dir = UIAccessibilityScrollDirectionDown; + else if ([dir_str isEqualToString:@"left"]) + dir = UIAccessibilityScrollDirectionLeft; + else if ([dir_str isEqualToString:@"right"]) + dir = UIAccessibilityScrollDirectionRight; + if (dir && [elem respondsToSelector:@selector(accessibilityScroll:)]) { + ok = [elem accessibilityScroll:dir]; + } + } }); - if (ok) return enif_make_atom(env, "ok"); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "action_failed")); + if (ok) + return enif_make_atom(env, "ok"); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "action_failed")); } // ── ax_action_at_xy/3 — invoke an AX action on whatever element is at (x, y) ── @@ -3563,56 +4231,66 @@ static ERL_NIF_TERM nif_ax_action_at_xy(ErlNifEnv *env, int argc, const ERL_NIF_ CGPoint pt = CGPointMake((CGFloat)x, (CGFloat)y); __block id elem = nil; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - elem = find_a11y_at_point(win, pt, 0); - if (elem) return; - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + elem = find_a11y_at_point(win, pt, 0); + if (elem) + return; + } + } }); - if (!elem) return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "no_element_at_point")); + if (!elem) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_element_at_point")); __block BOOL ok = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - if ([action isEqualToString:@"increment"]) { - if ([elem respondsToSelector:@selector(accessibilityIncrement)]) { - [elem accessibilityIncrement]; ok = YES; - } - } else if ([action isEqualToString:@"decrement"]) { - if ([elem respondsToSelector:@selector(accessibilityDecrement)]) { - [elem accessibilityDecrement]; ok = YES; - } - } else if ([action isEqualToString:@"activate"]) { - if ([elem respondsToSelector:@selector(accessibilityActivate)]) { - ok = [elem accessibilityActivate]; - } - } else if ([action isEqualToString:@"escape"]) { - if ([elem respondsToSelector:@selector(accessibilityPerformEscape)]) { - ok = [elem accessibilityPerformEscape]; - } - } else if ([action hasPrefix:@"scroll_"]) { - NSString *dir_str = [action substringFromIndex:7]; - UIAccessibilityScrollDirection dir = 0; - if ([dir_str isEqualToString:@"up"]) dir = UIAccessibilityScrollDirectionUp; - else if ([dir_str isEqualToString:@"down"]) dir = UIAccessibilityScrollDirectionDown; - else if ([dir_str isEqualToString:@"left"]) dir = UIAccessibilityScrollDirectionLeft; - else if ([dir_str isEqualToString:@"right"]) dir = UIAccessibilityScrollDirectionRight; - if (dir && [elem respondsToSelector:@selector(accessibilityScroll:)]) { - ok = [elem accessibilityScroll:dir]; - } - } + if ([action isEqualToString:@"increment"]) { + if ([elem respondsToSelector:@selector(accessibilityIncrement)]) { + [elem accessibilityIncrement]; + ok = YES; + } + } else if ([action isEqualToString:@"decrement"]) { + if ([elem respondsToSelector:@selector(accessibilityDecrement)]) { + [elem accessibilityDecrement]; + ok = YES; + } + } else if ([action isEqualToString:@"activate"]) { + if ([elem respondsToSelector:@selector(accessibilityActivate)]) { + ok = [elem accessibilityActivate]; + } + } else if ([action isEqualToString:@"escape"]) { + if ([elem respondsToSelector:@selector(accessibilityPerformEscape)]) { + ok = [elem accessibilityPerformEscape]; + } + } else if ([action hasPrefix:@"scroll_"]) { + NSString *dir_str = [action substringFromIndex:7]; + UIAccessibilityScrollDirection dir = 0; + if ([dir_str isEqualToString:@"up"]) + dir = UIAccessibilityScrollDirectionUp; + else if ([dir_str isEqualToString:@"down"]) + dir = UIAccessibilityScrollDirectionDown; + else if ([dir_str isEqualToString:@"left"]) + dir = UIAccessibilityScrollDirectionLeft; + else if ([dir_str isEqualToString:@"right"]) + dir = UIAccessibilityScrollDirectionRight; + if (dir && [elem respondsToSelector:@selector(accessibilityScroll:)]) { + ok = [elem accessibilityScroll:dir]; + } + } }); - if (ok) return enif_make_atom(env, "ok"); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), enif_make_atom(env, "action_failed")); + if (ok) + return enif_make_atom(env, "ok"); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "action_failed")); } - // ─── tap_xy/2 — Phase 3: real UITouch injection at screen coordinates ───────── // // Synthesises genuine UITouch/UIEvent objects and delivers them through UIKit's @@ -3684,13 +4362,13 @@ - (void)_clearTouches; - (void)_addTouch:(UITouch *)touch forDelayedDelivery:(BOOL)delayed; // iOS 26+ — create bare event backed by IOHIDEvent - (instancetype)_init; -- (void)_setHIDEvent:(CFTypeRef)hidEvent; // back UIEvent with IOHIDEventRef +- (void)_setHIDEvent:(CFTypeRef)hidEvent; // back UIEvent with IOHIDEventRef @end @interface UITouch (MobPhase3) // Private on all iOS versions - (void)_setLocationInWindow:(CGPoint)pt resetPrevious:(BOOL)reset; -- (void)_setHidEvent:(CFTypeRef)hidEvent; // per-touch HID backing (lowercase 'id') +- (void)_setHidEvent:(CFTypeRef)hidEvent; // per-touch HID backing (lowercase 'id') // Private on iOS < 26, GONE on iOS 26 (replaced by public setters below) - (void)_setWindow:(UIWindow *)window; - (void)_setView:(UIView *)view; @@ -3707,16 +4385,15 @@ - (void)setTapCount:(NSUInteger)n; // Preserved UITouch from Began phase — reused for Ended/Cancelled so that // the touch object's pointer identity remains stable across phases. -static UITouch * __strong sSavedTouch = nil; +static UITouch *__strong sSavedTouch = nil; // IOHIDEventCreateDigitizerFingerEvent — resolved once via dlsym. typedef CFTypeRef IOHIDEventRef_t; -typedef IOHIDEventRef_t (*IOHIDCreateFingerFn)( - CFAllocatorRef, uint64_t, uint32_t, uint32_t, uint32_t, - double, double, double, double, double, bool, bool, uint32_t -); +typedef IOHIDEventRef_t (*IOHIDCreateFingerFn)(CFAllocatorRef, uint64_t, uint32_t, uint32_t, + uint32_t, double, double, double, double, double, + bool, bool, uint32_t); static IOHIDCreateFingerFn sIOHIDCreateFinger; -static dispatch_once_t sIOHIDOnce; +static dispatch_once_t sIOHIDOnce; // ── Core touch-phase helper ──────────────────────────────────────────────────── // Delivers one touch phase to UIKit. @@ -3730,54 +4407,52 @@ typedef IOHIDEventRef_t (*IOHIDCreateFingerFn)( // then [UIWindow sendEvent:]. // // Returns NO if the required APIs are missing on this iOS version. -static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, - CGPoint pt, UITouchPhase phase) { +static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, CGPoint pt, + UITouchPhase phase) { // ── iOS 26+ path: pure IOHIDEvent → _handleHIDEvent: ───────────────────────── // Let UIKit create UITouch and dispatch through its full pipeline. // Both Began and Ended go through _handleHIDEvent: — no manual UITouch injection, // no [window sendEvent:]. UIKit routes based on the window's contextId. { dispatch_once(&sIOHIDOnce, ^{ - sIOHIDCreateFinger = - dlsym(RTLD_DEFAULT, "IOHIDEventCreateDigitizerFingerEvent"); + sIOHIDCreateFinger = dlsym(RTLD_DEFAULT, "IOHIDEventCreateDigitizerFingerEvent"); }); SEL handleSel = NSSelectorFromString(@"_handleHIDEvent:"); UIApplication *app = [UIApplication sharedApplication]; if (!sIOHIDCreateFinger || ![app respondsToSelector:handleSel]) { - LOGE(@"tap_xy: IOHIDCreateFinger=%p handleHIDEvent=%d", - (void *)sIOHIDCreateFinger, (int)[app respondsToSelector:handleSel]); + LOGE(@"tap_xy: IOHIDCreateFinger=%p handleHIDEvent=%d", (void *)sIOHIDCreateFinger, + (int)[app respondsToSelector:handleSel]); return NO; } CGSize screen = [UIScreen mainScreen].bounds.size; - double normX = pt.x / screen.width; - double normY = pt.y / screen.height; - uint64_t ts = mach_absolute_time(); + double normX = pt.x / screen.width; + double normY = pt.y / screen.height; + uint64_t ts = mach_absolute_time(); // fingerDown=YES for Began/Moved, NO for Ended/Cancelled BOOL fingerDown = (phase == UITouchPhaseBegan || phase == UITouchPhaseMoved); - IOHIDEventRef_t hidEvent = sIOHIDCreateFinger( - kCFAllocatorDefault, ts, - 0u, // fingerIndex - 1u, // identity - 1u | 2u | 4u, // eventMask: Range | Touch | Position - normX, normY, 0.0, - fingerDown ? 1.0 : 0.0, // tipPressure: 1.0 down, 0.0 up - 0.0, - (bool)fingerDown, // range: finger in digitizer range? - (bool)fingerDown, // touch: finger touching? - 0u - ); + IOHIDEventRef_t hidEvent = + sIOHIDCreateFinger(kCFAllocatorDefault, ts, + 0u, // fingerIndex + 1u, // identity + 1u | 2u | 4u, // eventMask: Range | Touch | Position + normX, normY, 0.0, + fingerDown ? 1.0 : 0.0, // tipPressure: 1.0 down, 0.0 up + 0.0, + (bool)fingerDown, // range: finger in digitizer range? + (bool)fingerDown, // touch: finger touching? + 0u); if (!hidEvent) { LOGE(@"tap_xy: IOHIDEventCreateDigitizerFingerEvent returned nil"); return NO; } - LOGI(@"tap_xy: _handleHIDEvent: phase=%d normX=%.3f normY=%.3f fingerDown=%d", - (int)phase, normX, normY, (int)fingerDown); + LOGI(@"tap_xy: _handleHIDEvent: phase=%d normX=%.3f normY=%.3f fingerDown=%d", (int)phase, + normX, normY, (int)fingerDown); typedef void (*HandleFn)(id, SEL, CFTypeRef); ((HandleFn)objc_msgSend)(app, handleSel, hidEvent); @@ -3785,8 +4460,8 @@ static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, // Check what UIKit created — did it produce a UITouch? if ([app respondsToSelector:@selector(_touchesEvent)]) { UIEvent *ev = [app _touchesEvent]; - LOGI(@"tap_xy: post-handleHID: _touchesEvent=%p allTouches=%lu", - (__bridge void *)ev, (unsigned long)ev.allTouches.count); + LOGI(@"tap_xy: post-handleHID: _touchesEvent=%p allTouches=%lu", (__bridge void *)ev, + (unsigned long)ev.allTouches.count); if (ev && ev.allTouches.count > 0) { // UIKit created a UITouch — dispatch via the correct window LOGI(@"tap_xy: dispatching via [window sendEvent:] with UIKit-created touch"); @@ -3796,7 +4471,7 @@ static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, CFRelease(hidEvent); return YES; - } // end iOS 26+ pure-HID block + } // end iOS 26+ pure-HID block // ── iOS <26 path: manual UITouch + UIEvent ──────────────────────────────── // UITouch private setters + _touchesEvent + _addTouch:forDelayedDelivery:. @@ -3809,7 +4484,10 @@ static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, // window setter if ([touch respondsToSelector:@selector(_setWindow:)]) [touch _setWindow:window]; - else { LOGE(@"tap_xy (<26): no _setWindow: on UITouch"); return NO; } + else { + LOGE(@"tap_xy (<26): no _setWindow: on UITouch"); + return NO; + } // view setter (best-effort; nil is tolerated by some iOS versions) if ([touch respondsToSelector:@selector(_setView:)]) @@ -3818,7 +4496,10 @@ static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, // phase setter if ([touch respondsToSelector:@selector(_setPhase:)]) [touch _setPhase:phase]; - else { LOGE(@"tap_xy (<26): no _setPhase: on UITouch"); return NO; } + else { + LOGE(@"tap_xy (<26): no _setPhase: on UITouch"); + return NO; + } // timestamp if ([touch respondsToSelector:@selector(_setTimestamp:)]) @@ -3831,18 +4512,25 @@ static BOOL mob_send_touch_phase(UIWindow *window, UIView *hitView, // location if ([touch respondsToSelector:@selector(_setLocationInWindow:resetPrevious:)]) [touch _setLocationInWindow:pt resetPrevious:(phase == UITouchPhaseBegan)]; - else { LOGE(@"tap_xy (<26): no _setLocationInWindow:resetPrevious: on UITouch"); return NO; } + else { + LOGE(@"tap_xy (<26): no _setLocationInWindow:resetPrevious: on UITouch"); + return NO; + } // build UIEvent if (![app respondsToSelector:@selector(_touchesEvent)]) { - LOGE(@"tap_xy (<26): no _touchesEvent on UIApplication"); return NO; + LOGE(@"tap_xy (<26): no _touchesEvent on UIApplication"); + return NO; } UIEvent *event = [app _touchesEvent]; if ([event respondsToSelector:@selector(_clearTouches)]) [event _clearTouches]; if ([event respondsToSelector:@selector(_addTouch:forDelayedDelivery:)]) [event _addTouch:touch forDelayedDelivery:NO]; - else { LOGE(@"tap_xy (<26): no _addTouch:forDelayedDelivery:"); return NO; } + else { + LOGE(@"tap_xy (<26): no _addTouch:forDelayedDelivery:"); + return NO; + } [window sendEvent:event]; return YES; @@ -3870,29 +4558,35 @@ static ERL_NIF_TERM nif_tap_xy_probe(ErlNifEnv *env) { UITouch *touch = [[UITouch alloc] init]; UIEvent *fakeEvent = [UIEvent new]; - struct { const char *name; BOOL found; } checks[] = { - {"UIApp._touchesEvent", [app respondsToSelector:@selector(_touchesEvent)]}, + struct { + const char *name; + BOOL found; + } checks[] = { + {"UIApp._touchesEvent", [app respondsToSelector:@selector(_touchesEvent)]}, // UITouch — old private names (iOS <26) - {"UITouch._setWindow:", [touch respondsToSelector:@selector(_setWindow:)]}, - {"UITouch._setView:", [touch respondsToSelector:@selector(_setView:)]}, - {"UITouch._setPhase:", [touch respondsToSelector:@selector(_setPhase:)]}, - {"UITouch._setTimestamp:", [touch respondsToSelector:@selector(_setTimestamp:)]}, - {"UITouch._setTapCount:", [touch respondsToSelector:@selector(_setTapCount:)]}, - {"UITouch._setLocationInWindow:resetPrevious:", [touch respondsToSelector:@selector(_setLocationInWindow:resetPrevious:)]}, + {"UITouch._setWindow:", [touch respondsToSelector:@selector(_setWindow:)]}, + {"UITouch._setView:", [touch respondsToSelector:@selector(_setView:)]}, + {"UITouch._setPhase:", [touch respondsToSelector:@selector(_setPhase:)]}, + {"UITouch._setTimestamp:", [touch respondsToSelector:@selector(_setTimestamp:)]}, + {"UITouch._setTapCount:", [touch respondsToSelector:@selector(_setTapCount:)]}, + {"UITouch._setLocationInWindow:resetPrevious:", + [touch respondsToSelector:@selector(_setLocationInWindow:resetPrevious:)]}, // UITouch — iOS 26+ names (no underscore) - {"UITouch.setWindow:", [touch respondsToSelector:@selector(setWindow:)]}, - {"UITouch.setView:", [touch respondsToSelector:@selector(setView:)]}, - {"UITouch.setPhase:", [touch respondsToSelector:@selector(setPhase:)]}, - {"UITouch.setTimestamp:", [touch respondsToSelector:@selector(setTimestamp:)]}, - {"UITouch.setTapCount:", [touch respondsToSelector:@selector(setTapCount:)]}, + {"UITouch.setWindow:", [touch respondsToSelector:@selector(setWindow:)]}, + {"UITouch.setView:", [touch respondsToSelector:@selector(setView:)]}, + {"UITouch.setPhase:", [touch respondsToSelector:@selector(setPhase:)]}, + {"UITouch.setTimestamp:", [touch respondsToSelector:@selector(setTimestamp:)]}, + {"UITouch.setTapCount:", [touch respondsToSelector:@selector(setTapCount:)]}, // UIEvent — old private names (iOS <26) - {"UIEvent._clearTouches", [fakeEvent respondsToSelector:@selector(_clearTouches)]}, - {"UIEvent._addTouch:forDelayedDelivery:", [fakeEvent respondsToSelector:@selector(_addTouch:forDelayedDelivery:)]}, + {"UIEvent._clearTouches", [fakeEvent respondsToSelector:@selector(_clearTouches)]}, + {"UIEvent._addTouch:forDelayedDelivery:", + [fakeEvent respondsToSelector:@selector(_addTouch:forDelayedDelivery:)]}, // UIEvent — iOS 26+ - {"UIEvent._initWithEvent:touches:", [UIEvent instancesRespondToSelector:@selector(_initWithEvent:touches:)]}, + {"UIEvent._initWithEvent:touches:", + [UIEvent instancesRespondToSelector:@selector(_initWithEvent:touches:)]}, // UITouch HID backing - {"UITouch._setHidEvent:", [touch respondsToSelector:@selector(_setHidEvent:)]}, - {"UITouch._hidEvent", [touch respondsToSelector:@selector(_hidEvent)]}, + {"UITouch._setHidEvent:", [touch respondsToSelector:@selector(_setHidEvent:)]}, + {"UITouch._hidEvent", [touch respondsToSelector:@selector(_hidEvent)]}, }; ERL_NIF_TERM list = enif_make_list(env, 0); @@ -3912,29 +4606,28 @@ static ERL_NIF_TERM nif_tap_xy_probe(ErlNifEnv *env) { // enc looks like "@24@0:8@16@16" — arg0 is return (id), arg2 is self, // arg3 is SEL, arg4 is first real arg. We want arg4's type. ERL_NIF_TERM enc_term = enif_make_string(env, enc ? enc : "(null)", ERL_NIF_LATIN1); - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, "UIEvent._initWithEvent:touches:.encoding"), - enc_term), + list = enif_make_list_cell( + env, + enif_make_tuple2( + env, enif_make_atom(env, "UIEvent._initWithEvent:touches:.encoding"), enc_term), list); } } // Test _initWithEvent: with empty NSSet to isolate whether UITouch or base causes nil return. { - UIEvent *baseInit = [UIEvent instancesRespondToSelector:@selector(_init)] - ? [[UIEvent alloc] _init] : nil; + UIEvent *baseInit = + [UIEvent instancesRespondToSelector:@selector(_init)] ? [[UIEvent alloc] _init] : nil; SEL initWithEvSel = NSSelectorFromString(@"_initWithEvent:touches:"); - typedef UIEvent* (*InitWithEvFn)(id, SEL, void*, NSSet*); - UIEvent *testEmpty = [UIEvent instancesRespondToSelector:initWithEvSel] - ? ((InitWithEvFn)objc_msgSend)([[UIEvent alloc] init], initWithEvSel, (__bridge void *)baseInit, [NSSet set]) - : nil; + typedef UIEvent *(*InitWithEvFn)(id, SEL, void *, NSSet *); + UIEvent *testEmpty = + [UIEvent instancesRespondToSelector:initWithEvSel] + ? ((InitWithEvFn)objc_msgSend)([[UIEvent alloc] init], initWithEvSel, + (__bridge void *)baseInit, [NSSet set]) + : nil; ERL_NIF_TERM val = enif_make_atom(env, testEmpty ? "non_nil" : "nil"); - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, "_initWithEvent:emptySet"), - val), - list); + list = enif_make_list_cell( + env, enif_make_tuple2(env, enif_make_atom(env, "_initWithEvent:emptySet"), val), list); } // Type encoding of UIEvent._setHIDEvent: to learn what it takes. @@ -3942,10 +4635,10 @@ static ERL_NIF_TERM nif_tap_xy_probe(ErlNifEnv *env) { Method m = class_getInstanceMethod([UIEvent class], @selector(_setHIDEvent:)); if (m) { const char *enc = method_getTypeEncoding(m); - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, "UIEvent._setHIDEvent:.encoding"), - enif_make_string(env, enc ? enc : "(null)", ERL_NIF_LATIN1)), + list = enif_make_list_cell( + env, + enif_make_tuple2(env, enif_make_atom(env, "UIEvent._setHIDEvent:.encoding"), + enif_make_string(env, enc ? enc : "(null)", ERL_NIF_LATIN1)), list); } } @@ -3953,15 +4646,17 @@ static ERL_NIF_TERM nif_tap_xy_probe(ErlNifEnv *env) { // Check if IOHIDEventCreate* functions are available (for direct HID injection). { BOOL hasCreateFinger = dlsym(RTLD_DEFAULT, "IOHIDEventCreateDigitizerFingerEvent") != NULL; - BOOL hasCreateFingerQ = dlsym(RTLD_DEFAULT, "IOHIDEventCreateDigitizerFingerEventWithQuality") != NULL; - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, "dlsym.IOHIDEventCreateDigitizerFingerEvent"), - enif_make_atom(env, hasCreateFinger ? "true" : "false")), + BOOL hasCreateFingerQ = + dlsym(RTLD_DEFAULT, "IOHIDEventCreateDigitizerFingerEventWithQuality") != NULL; + list = enif_make_list_cell( + env, + enif_make_tuple2(env, enif_make_atom(env, "dlsym.IOHIDEventCreateDigitizerFingerEvent"), + enif_make_atom(env, hasCreateFinger ? "true" : "false")), list); - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, "dlsym.IOHIDEventCreateDigitizerFingerEventWithQuality"), + list = enif_make_list_cell( + env, + enif_make_tuple2( + env, enif_make_atom(env, "dlsym.IOHIDEventCreateDigitizerFingerEventWithQuality"), enif_make_atom(env, hasCreateFingerQ ? "true" : "false")), list); } @@ -3970,27 +4665,26 @@ static ERL_NIF_TERM nif_tap_xy_probe(ErlNifEnv *env) { { UIApplication *a = [UIApplication sharedApplication]; BOOL hasHandle = [a respondsToSelector:NSSelectorFromString(@"_handleHIDEvent:")]; - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, "UIApp._handleHIDEvent:"), - enif_make_atom(env, hasHandle ? "true" : "false")), - list); + list = + enif_make_list_cell(env, + enif_make_tuple2(env, enif_make_atom(env, "UIApp._handleHIDEvent:"), + enif_make_atom(env, hasHandle ? "true" : "false")), + list); } // Check for GSSendSystemEvent / GSSynthesizeSystemEvent via dlsym. { const char *gsFuncs[] = { - "GSSendSystemEvent", "GSSynthesizeSystemEvent", - "GSSendEvent", "GSEventDispatch", - "GSSendSystemEventFast", + "GSSendSystemEvent", "GSSynthesizeSystemEvent", "GSSendEvent", + "GSEventDispatch", "GSSendSystemEventFast", }; for (int i = 0; i < 5; i++) { BOOL found = dlsym(RTLD_DEFAULT, gsFuncs[i]) != NULL; - list = enif_make_list_cell(env, - enif_make_tuple2(env, - enif_make_atom(env, gsFuncs[i]), - enif_make_atom(env, found ? "true" : "false")), - list); + list = + enif_make_list_cell(env, + enif_make_tuple2(env, enif_make_atom(env, gsFuncs[i]), + enif_make_atom(env, found ? "true" : "false")), + list); } } @@ -4000,7 +4694,8 @@ static ERL_NIF_TERM nif_tap_xy_probe(ErlNifEnv *env) { static ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { // Diagnostics mode — pass :probe or :enumerate_touch or :enumerate_event if (enif_is_atom(env, argv[0])) { - char atom[64]; enif_get_atom(env, argv[0], atom, sizeof(atom), ERL_NIF_LATIN1); + char atom[64]; + enif_get_atom(env, argv[0], atom, sizeof(atom), ERL_NIF_LATIN1); if (strcmp(atom, "enumerate_touch") == 0) return nif_tap_xy_enumerate(env, [UITouch class], NULL); if (strcmp(atom, "enumerate_event") == 0) @@ -4022,7 +4717,7 @@ static ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv ERL_NIF_TERM list = enif_make_list(env, 0); for (unsigned int i = 0; i < count; i++) { const char *name = ivar_getName(ivars[i]); - ptrdiff_t off = ivar_getOffset(ivars[i]); + ptrdiff_t off = ivar_getOffset(ivars[i]); const char *type = ivar_getTypeEncoding(ivars[i]); char buf[256]; snprintf(buf, sizeof(buf), "%s@%td(%s)", name ? name : "?", off, type ? type : "?"); @@ -4042,42 +4737,45 @@ static ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv if (strcmp(atom, "window_info") == 0) { __block ERL_NIF_TERM result = enif_make_atom(env, "no_window"); dispatch_sync(dispatch_get_main_queue(), ^{ - UIWindow *win = nil; - for (UIScene *sc in [UIApplication sharedApplication].connectedScenes) { - if ([sc isKindOfClass:[UIWindowScene class]]) { - for (UIWindow *w in [(UIWindowScene *)sc windows]) { - if (!w.isHidden) { win = w; break; } - } - if (win) break; - } - } - if (!win) return; - - // Try various contextId getters - uint32_t ctxId = 0; - SEL ctxSels[] = { - @selector(_contextId), - @selector(_windowContextID), - @selector(contextId), - @selector(_displayID), - }; - NSString *ctxSelName = @"none"; - for (int i = 0; i < 4; i++) { - if ([win respondsToSelector:ctxSels[i]]) { - typedef uint32_t (*GetU32Fn)(id, SEL); - ctxId = ((GetU32Fn)objc_msgSend)(win, ctxSels[i]); - ctxSelName = NSStringFromSelector(ctxSels[i]); - break; - } - } - - char buf[256]; - snprintf(buf, sizeof(buf), "win=%p class=%s ctxSel=%s ctxId=0x%08x", - (__bridge void *)win, - class_getName(object_getClass(win)), - [ctxSelName UTF8String], - ctxId); - result = enif_make_string(env, buf, ERL_NIF_LATIN1); + UIWindow *win = nil; + for (UIScene *sc in [UIApplication sharedApplication].connectedScenes) { + if ([sc isKindOfClass:[UIWindowScene class]]) { + for (UIWindow *w in [(UIWindowScene *)sc windows]) { + if (!w.isHidden) { + win = w; + break; + } + } + if (win) + break; + } + } + if (!win) + return; + + // Try various contextId getters + uint32_t ctxId = 0; + SEL ctxSels[] = { + @selector(_contextId), + @selector(_windowContextID), + @selector(contextId), + @selector(_displayID), + }; + NSString *ctxSelName = @"none"; + for (int i = 0; i < 4; i++) { + if ([win respondsToSelector:ctxSels[i]]) { + typedef uint32_t (*GetU32Fn)(id, SEL); + ctxId = ((GetU32Fn)objc_msgSend)(win, ctxSels[i]); + ctxSelName = NSStringFromSelector(ctxSels[i]); + break; + } + } + + char buf[256]; + snprintf(buf, sizeof(buf), "win=%p class=%s ctxSel=%s ctxId=0x%08x", + (__bridge void *)win, class_getName(object_getClass(win)), + [ctxSelName UTF8String], ctxId); + result = enif_make_string(env, buf, ERL_NIF_LATIN1); }); return result; } @@ -4085,10 +4783,16 @@ static ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv } double x, y; if (!enif_get_double(env, argv[0], &x)) { - int ix; if (!enif_get_int(env, argv[0], &ix)) return enif_make_badarg(env); x = ix; + int ix; + if (!enif_get_int(env, argv[0], &ix)) + return enif_make_badarg(env); + x = ix; } if (!enif_get_double(env, argv[1], &y)) { - int iy; if (!enif_get_int(env, argv[1], &iy)) return enif_make_badarg(env); y = iy; + int iy; + if (!enif_get_int(env, argv[1], &iy)) + return enif_make_badarg(env); + y = iy; } CGPoint pt = CGPointMake(x, y); @@ -4102,89 +4806,95 @@ static ERL_NIF_TERM nif_tap_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv // a simulator-specific event injection mechanism would be needed. __block BOOL activated = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - id elem = find_a11y_at_point(win, pt, 0); - if (elem) { - LOGI(@"tap_xy(sim): accessibilityActivate on %@ frame=%@", - NSStringFromClass(object_getClass(elem)), - NSStringFromCGRect([elem accessibilityFrame])); - [elem accessibilityActivate]; - // For text fields: accessibilityActivate on UITextFieldLabel - // (the hint label inside UITextField) doesn't focus the - // field. Walk the responder chain up from the hit view to - // find the first UITextField/UITextView and focus it. - UIView *hv = [win hitTest:pt withEvent:nil]; - UIResponder *r = hv; - while (r) { - if ([r isKindOfClass:[UITextField class]] || - [r isKindOfClass:[UITextView class]]) { - [(UIView *)r becomeFirstResponder]; - break; - } - r = r.nextResponder; - } - activated = YES; - return; - } - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + id elem = find_a11y_at_point(win, pt, 0); + if (elem) { + LOGI(@"tap_xy(sim): accessibilityActivate on %@ frame=%@", + NSStringFromClass(object_getClass(elem)), + NSStringFromCGRect([elem accessibilityFrame])); + [elem accessibilityActivate]; + // For text fields: accessibilityActivate on UITextFieldLabel + // (the hint label inside UITextField) doesn't focus the + // field. Walk the responder chain up from the hit view to + // find the first UITextField/UITextView and focus it. + UIView *hv = [win hitTest:pt withEvent:nil]; + UIResponder *r = hv; + while (r) { + if ([r isKindOfClass:[UITextField class]] || + [r isKindOfClass:[UITextView class]]) { + [(UIView *)r becomeFirstResponder]; + break; + } + r = r.nextResponder; + } + activated = YES; + return; + } + } + } }); - if (activated) return enif_make_atom(env, "ok"); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_element_at_point")); + if (activated) + return enif_make_atom(env, "ok"); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_element_at_point")); #else // ── Real device: UITouch injection via IOHIDEvent ───────────────────────────── __block UIWindow *targetWindow = nil; - __block UIView *hitView = nil; + __block UIView *hitView = nil; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - UIView *hit = [win hitTest:pt withEvent:nil]; - if (hit) { targetWindow = win; hitView = hit; return; } - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + UIView *hit = [win hitTest:pt withEvent:nil]; + if (hit) { + targetWindow = win; + hitView = hit; + return; + } + } + } }); if (!hitView) { - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_view_at_point")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_view_at_point")); } __block BOOL ok = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - ok = mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseBegan); + ok = mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseBegan); }); if (!ok) { - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - nif_tap_xy_probe(env)); + return enif_make_tuple2(env, enif_make_atom(env, "error"), nif_tap_xy_probe(env)); } [NSThread sleepForTimeInterval:0.10]; dispatch_sync(dispatch_get_main_queue(), ^{ - mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseEnded); + mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseEnded); }); return enif_make_atom(env, "ok"); #endif } - static id find_first_responder_in(UIView *view) { - if (view.isFirstResponder) return view; + if (view.isFirstResponder) + return view; for (UIView *sub in view.subviews) { id fr = find_first_responder_in(sub); - if (fr) return fr; + if (fr) + return fr; } return nil; } @@ -4200,31 +4910,33 @@ static ERL_NIF_TERM nif_delete_backward(ErlNifEnv *env, int argc, const ERL_NIF_ __block BOOL done = NO; __block BOOL found = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - id fr = find_first_responder_in(win); - if (!fr) continue; - found = YES; - if ([fr respondsToSelector:@selector(deleteBackward)]) { - [fr deleteBackward]; - done = YES; - } - return; - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + id fr = find_first_responder_in(win); + if (!fr) + continue; + found = YES; + if ([fr respondsToSelector:@selector(deleteBackward)]) { + [fr deleteBackward]; + done = YES; + } + return; + } + } }); - if (done) return enif_make_atom(env, "ok"); - if (found) return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_text_input")); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); + if (done) + return enif_make_atom(env, "ok"); + if (found) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_text_input")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_first_responder")); } - // ─── key_press/1 — send a special key to the focused text input ─────────────── // // Accepts an atom: @@ -4247,53 +4959,55 @@ static ERL_NIF_TERM nif_key_press(ErlNifEnv *env, int argc, const ERL_NIF_TERM a __block BOOL unknown = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - id fr = find_first_responder_in(win); - if (!fr) continue; - found = YES; - - if ([key isEqualToString:@"return"]) { - if ([fr respondsToSelector:@selector(insertText:)]) { - [fr insertText:@"\n"]; - done = YES; - } - } else if ([key isEqualToString:@"tab"]) { - if ([fr respondsToSelector:@selector(insertText:)]) { - [fr insertText:@"\t"]; - done = YES; - } - } else if ([key isEqualToString:@"space"]) { - if ([fr respondsToSelector:@selector(insertText:)]) { - [fr insertText:@" "]; - done = YES; - } - } else if ([key isEqualToString:@"escape"]) { - [fr resignFirstResponder]; - done = YES; - } else { - unknown = YES; - } - return; - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + id fr = find_first_responder_in(win); + if (!fr) + continue; + found = YES; + + if ([key isEqualToString:@"return"]) { + if ([fr respondsToSelector:@selector(insertText:)]) { + [fr insertText:@"\n"]; + done = YES; + } + } else if ([key isEqualToString:@"tab"]) { + if ([fr respondsToSelector:@selector(insertText:)]) { + [fr insertText:@"\t"]; + done = YES; + } + } else if ([key isEqualToString:@"space"]) { + if ([fr respondsToSelector:@selector(insertText:)]) { + [fr insertText:@" "]; + done = YES; + } + } else if ([key isEqualToString:@"escape"]) { + [fr resignFirstResponder]; + done = YES; + } else { + unknown = YES; + } + return; + } + } }); - if (unknown) return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "unknown_key")); - if (done) return enif_make_atom(env, "ok"); - if (found) return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_text_input")); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); + if (unknown) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "unknown_key")); + if (done) + return enif_make_atom(env, "ok"); + if (found) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_text_input")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_first_responder")); } - // ─── clear_text/0 — erase all text in the focused input ────────────────────── // // Calls selectAll: then deleteBackward: on the first responder. Works on @@ -4305,38 +5019,40 @@ static ERL_NIF_TERM nif_clear_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM __block BOOL done = NO; __block BOOL found = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - id fr = find_first_responder_in(win); - if (!fr) continue; - found = YES; - BOOL canClear = [fr respondsToSelector:@selector(selectAll:)] && - [fr respondsToSelector:@selector(deleteBackward)]; - if (canClear) { - [fr selectAll:nil]; - // selectAll: is async in UITextView — yield once to let selection settle - // before deleting. - dispatch_async(dispatch_get_main_queue(), ^{ - [fr deleteBackward]; - }); - done = YES; - } - return; - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + id fr = find_first_responder_in(win); + if (!fr) + continue; + found = YES; + BOOL canClear = [fr respondsToSelector:@selector(selectAll:)] && + [fr respondsToSelector:@selector(deleteBackward)]; + if (canClear) { + [fr selectAll:nil]; + // selectAll: is async in UITextView — yield once to let selection settle + // before deleting. + dispatch_async(dispatch_get_main_queue(), ^{ + [fr deleteBackward]; + }); + done = YES; + } + return; + } + } }); - if (done) return enif_make_atom(env, "ok"); - if (found) return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_text_input")); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); + if (done) + return enif_make_atom(env, "ok"); + if (found) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_text_input")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_first_responder")); } - // ─── long_press_xy/3 — hold touch at (x, y) for duration_ms milliseconds ───── // // Simulator: finds UILongPressGestureRecognizer on the hit view or its ancestors @@ -4351,8 +5067,7 @@ static ERL_NIF_TERM nif_clear_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM static ERL_NIF_TERM nif_long_press_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { double x, y; int duration_ms; - if (!enif_get_double(env, argv[0], &x) || - !enif_get_double(env, argv[1], &y) || + if (!enif_get_double(env, argv[0], &x) || !enif_get_double(env, argv[1], &y) || !enif_get_int(env, argv[2], &duration_ms)) return enif_make_badarg(env); @@ -4361,90 +5076,103 @@ static ERL_NIF_TERM nif_long_press_xy(ErlNifEnv *env, int argc, const ERL_NIF_TE #if TARGET_OS_SIMULATOR __block BOOL fired = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - UIView *hitView = nil; - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - UIView *h = [win hitTest:pt withEvent:nil]; - if (h) { hitView = h; break; } - } - if (hitView) break; - } - if (!hitView) return; - - // Walk up the responder chain looking for any UILongPressGestureRecognizer - SEL setStateSel = NSSelectorFromString(@"_setState:"); - UIView *v = hitView; - while (v && !fired) { - for (UIGestureRecognizer *gr in v.gestureRecognizers) { - if (![gr isKindOfClass:[UILongPressGestureRecognizer class]]) continue; - if (![gr respondsToSelector:setStateSel]) continue; - typedef void (*SetStateFn)(id, SEL, NSInteger); - SetStateFn setState = (SetStateFn)objc_msgSend; - LOGI(@"long_press_xy(sim): firing LPGR on %@", NSStringFromClass([v class])); - setState(gr, setStateSel, UIGestureRecognizerStateBegan); - setState(gr, setStateSel, UIGestureRecognizerStateEnded); - fired = YES; - break; - } - v = v.superview; - } - - // SwiftUI onLongPressGesture may also surface as an accessibility custom action. - // Try accessibilityActivate as a fallback — limited but better than nothing. - if (!fired) { - id elem = find_a11y_at_point(hitView, pt, 0); - if (elem && [elem respondsToSelector:@selector(accessibilityActivate)]) { - LOGI(@"long_press_xy(sim): fallback to accessibilityActivate on %@", - NSStringFromClass(object_getClass(elem))); - [elem accessibilityActivate]; - fired = YES; - } - } + UIView *hitView = nil; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + UIView *h = [win hitTest:pt withEvent:nil]; + if (h) { + hitView = h; + break; + } + } + if (hitView) + break; + } + if (!hitView) + return; + + // Walk up the responder chain looking for any UILongPressGestureRecognizer + SEL setStateSel = NSSelectorFromString(@"_setState:"); + UIView *v = hitView; + while (v && !fired) { + for (UIGestureRecognizer *gr in v.gestureRecognizers) { + if (![gr isKindOfClass:[UILongPressGestureRecognizer class]]) + continue; + if (![gr respondsToSelector:setStateSel]) + continue; + typedef void (*SetStateFn)(id, SEL, NSInteger); + SetStateFn setState = (SetStateFn)objc_msgSend; + LOGI(@"long_press_xy(sim): firing LPGR on %@", NSStringFromClass([v class])); + setState(gr, setStateSel, UIGestureRecognizerStateBegan); + setState(gr, setStateSel, UIGestureRecognizerStateEnded); + fired = YES; + break; + } + v = v.superview; + } + + // SwiftUI onLongPressGesture may also surface as an accessibility custom action. + // Try accessibilityActivate as a fallback — limited but better than nothing. + if (!fired) { + id elem = find_a11y_at_point(hitView, pt, 0); + if (elem && [elem respondsToSelector:@selector(accessibilityActivate)]) { + LOGI(@"long_press_xy(sim): fallback to accessibilityActivate on %@", + NSStringFromClass(object_getClass(elem))); + [elem accessibilityActivate]; + fired = YES; + } + } }); - if (fired) return enif_make_atom(env, "ok"); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_long_press_recognizer")); + if (fired) + return enif_make_atom(env, "ok"); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_long_press_recognizer")); #else // Real device: Began → hold → Ended __block UIWindow *targetWindow = nil; - __block UIView *hitView = nil; + __block UIView *hitView = nil; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - UIView *h = [win hitTest:pt withEvent:nil]; - if (h) { targetWindow = win; hitView = h; return; } - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + UIView *h = [win hitTest:pt withEvent:nil]; + if (h) { + targetWindow = win; + hitView = h; + return; + } + } + } }); if (!hitView) - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_view_at_point")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_view_at_point")); dispatch_sync(dispatch_get_main_queue(), ^{ - mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseBegan); + mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseBegan); }); [NSThread sleepForTimeInterval:(double)duration_ms / 1000.0]; dispatch_sync(dispatch_get_main_queue(), ^{ - mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseEnded); + mob_send_touch_phase(targetWindow, hitView, pt, UITouchPhaseEnded); }); return enif_make_atom(env, "ok"); #endif } - // ─── type_text/1 — type into whatever UITextField/UITextView has focus ──────── // // Finds the current first responder in the view hierarchy and calls insertText: @@ -4460,43 +5188,44 @@ static ERL_NIF_TERM nif_type_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM a NSString *text = [[NSString alloc] initWithBytes:bin.data length:bin.size - encoding:NSUTF8StringEncoding]; + encoding:NSUTF8StringEncoding]; if (!text) - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "invalid_utf8")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "invalid_utf8")); __block BOOL typed = NO; __block BOOL found = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - id fr = find_first_responder_in(win); - if (!fr) continue; - found = YES; - if ([fr respondsToSelector:@selector(insertText:)]) { - LOGI(@"type_text: inserting %lu chars into %@", - (unsigned long)text.length, NSStringFromClass([fr class])); - [fr insertText:text]; - typed = YES; - } - return; - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + id fr = find_first_responder_in(win); + if (!fr) + continue; + found = YES; + if ([fr respondsToSelector:@selector(insertText:)]) { + LOGI(@"type_text: inserting %lu chars into %@", (unsigned long)text.length, + NSStringFromClass([fr class])); + [fr insertText:text]; + typed = YES; + } + return; + } + } }); - if (typed) return enif_make_atom(env, "ok"); - if (found) return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "not_text_input")); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_first_responder")); + if (typed) + return enif_make_atom(env, "ok"); + if (found) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_text_input")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_first_responder")); } - // ─── swipe_xy/4 — scroll gesture from (x1,y1) to (x2,y2) ──────────────────── // // Simulator: walks the hit-test chain up from the touch point to find a @@ -4509,9 +5238,11 @@ static ERL_NIF_TERM nif_type_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM a static UIScrollView *find_scroll_view_at(CGPoint pt) { for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; + if (win.isHidden) + continue; UIView *hit = [win hitTest:pt withEvent:nil]; UIView *v = hit; while (v) { @@ -4526,10 +5257,8 @@ static ERL_NIF_TERM nif_type_text(ErlNifEnv *env, int argc, const ERL_NIF_TERM a static ERL_NIF_TERM nif_swipe_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { double x1, y1, x2, y2; - if (!enif_get_double(env, argv[0], &x1) || - !enif_get_double(env, argv[1], &y1) || - !enif_get_double(env, argv[2], &x2) || - !enif_get_double(env, argv[3], &y2)) + if (!enif_get_double(env, argv[0], &x1) || !enif_get_double(env, argv[1], &y1) || + !enif_get_double(env, argv[2], &x2) || !enif_get_double(env, argv[3], &y2)) return enif_make_badarg(env); CGFloat dx = (CGFloat)(x2 - x1); @@ -4540,70 +5269,74 @@ static ERL_NIF_TERM nif_swipe_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar #if TARGET_OS_SIMULATOR __block BOOL scrolled = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - UIScrollView *sv = find_scroll_view_at(mid); - if (!sv) { - // Also try start point - sv = find_scroll_view_at(CGPointMake((CGFloat)x1, (CGFloat)y1)); - } - if (!sv) return; - - CGPoint cur = sv.contentOffset; - // Swiping up (dy < 0) means content moves down (contentOffset.y increases) - CGFloat newX = cur.x - dx; - CGFloat newY = cur.y - dy; - // Clamp to valid range - CGFloat maxX = MAX(0.0f, sv.contentSize.width - sv.bounds.size.width); - CGFloat maxY = MAX(0.0f, sv.contentSize.height - sv.bounds.size.height); - newX = MAX(0.0f, MIN(newX, maxX)); - newY = MAX(0.0f, MIN(newY, maxY)); - LOGI(@"swipe_xy(sim): sv=%@ offset (%.1f,%.1f) → (%.1f,%.1f)", - NSStringFromClass([sv class]), cur.x, cur.y, newX, newY); - [sv setContentOffset:CGPointMake(newX, newY) animated:YES]; - scrolled = YES; + UIScrollView *sv = find_scroll_view_at(mid); + if (!sv) { + // Also try start point + sv = find_scroll_view_at(CGPointMake((CGFloat)x1, (CGFloat)y1)); + } + if (!sv) + return; + + CGPoint cur = sv.contentOffset; + // Swiping up (dy < 0) means content moves down (contentOffset.y increases) + CGFloat newX = cur.x - dx; + CGFloat newY = cur.y - dy; + // Clamp to valid range + CGFloat maxX = MAX(0.0f, sv.contentSize.width - sv.bounds.size.width); + CGFloat maxY = MAX(0.0f, sv.contentSize.height - sv.bounds.size.height); + newX = MAX(0.0f, MIN(newX, maxX)); + newY = MAX(0.0f, MIN(newY, maxY)); + LOGI(@"swipe_xy(sim): sv=%@ offset (%.1f,%.1f) → (%.1f,%.1f)", NSStringFromClass([sv class]), + cur.x, cur.y, newX, newY); + [sv setContentOffset:CGPointMake(newX, newY) animated:YES]; + scrolled = YES; }); - if (scrolled) return enif_make_atom(env, "ok"); - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_scroll_view")); + if (scrolled) + return enif_make_atom(env, "ok"); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_scroll_view")); #else // Real device: emit Began → 10 Moved steps → Ended via HID events __block UIWindow *targetWindow = nil; - __block UIView *hitView = nil; + __block UIView *hitView = nil; CGPoint startPt = CGPointMake((CGFloat)x1, (CGFloat)y1); - CGPoint endPt = CGPointMake((CGFloat)x2, (CGFloat)y2); + CGPoint endPt = CGPointMake((CGFloat)x2, (CGFloat)y2); dispatch_sync(dispatch_get_main_queue(), ^{ - for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *win in [(UIWindowScene *)scene windows]) { - if (win.isHidden) continue; - UIView *hit = [win hitTest:startPt withEvent:nil]; - if (hit) { targetWindow = win; hitView = hit; return; } - } - } + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + UIView *hit = [win hitTest:startPt withEvent:nil]; + if (hit) { + targetWindow = win; + hitView = hit; + return; + } + } + } }); if (!hitView) - return enif_make_tuple2(env, - enif_make_atom(env, "error"), - enif_make_atom(env, "no_view_at_point")); + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_view_at_point")); // Began dispatch_sync(dispatch_get_main_queue(), ^{ - mob_send_touch_phase(targetWindow, hitView, startPt, UITouchPhaseBegan); + mob_send_touch_phase(targetWindow, hitView, startPt, UITouchPhaseBegan); }); // 10 evenly-spaced Moved steps int steps = 10; for (int i = 1; i <= steps; i++) { [NSThread sleepForTimeInterval:0.016]; // ~60fps - CGPoint movePt = CGPointMake( - (CGFloat)(x1 + dx * i / steps), - (CGFloat)(y1 + dy * i / steps) - ); + CGPoint movePt = + CGPointMake((CGFloat)(x1 + dx * i / steps), (CGFloat)(y1 + dy * i / steps)); dispatch_sync(dispatch_get_main_queue(), ^{ - mob_send_touch_phase(targetWindow, hitView, movePt, UITouchPhaseMoved); + mob_send_touch_phase(targetWindow, hitView, movePt, UITouchPhaseMoved); }); } @@ -4611,101 +5344,347 @@ static ERL_NIF_TERM nif_swipe_xy(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar // Ended dispatch_sync(dispatch_get_main_queue(), ^{ - mob_send_touch_phase(targetWindow, hitView, endPt, UITouchPhaseEnded); + mob_send_touch_phase(targetWindow, hitView, endPt, UITouchPhaseEnded); }); return enif_make_atom(env, "ok"); #endif } -#endif // !MOB_RELEASE — end of test harness block (started near line 2780) +// ── In-process screenshot + scroll control (agent driving over dist) ───────── +// +// screenshot/3, scroll_info/1, scroll_to/3 give a remotely-connected agent +// pixels and deterministic scroll without adb/xcrun, using only public UIKit +// APIs (UIGraphicsImageRenderer, UIScrollView.contentOffset). scroll_* stay in +// the debug-only harness; screenshot/3 is carved out just below so a host can +// opt it into release with -DMOB_ENABLE_SCREENSHOT — an agent needs to SEE the +// screen to error-correct even in a shipped build, while still being unable to +// DRIVE it (the synthetic-input NIFs above stay stripped in release). + +// Recursively collect every UIScrollView under `view` into `acc`. +static void mob_collect_scroll_views(UIView *view, NSMutableArray<UIScrollView *> *acc) { + if ([view isKindOfClass:[UIScrollView class]]) + [acc addObject:(UIScrollView *)view]; + for (UIView *sub in view.subviews) + mob_collect_scroll_views(sub, acc); +} + +// Find the scroll view addressed by `identifier` (the node's :id, which the +// SwiftUI renderer applies as accessibilityIdentifier). If `identifier` is +// empty, fall back to the largest scroll view — the main content scroller. +// Returns nil if none match. Main-thread only. +static UIScrollView *mob_find_scroll_view(NSString *identifier) { + NSMutableArray<UIScrollView *> *all = [NSMutableArray array]; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (!win.isHidden) + mob_collect_scroll_views(win, all); + } + } + if (all.count == 0) + return nil; + + if (identifier.length > 0) { + for (UIScrollView *sv in all) { + if ([sv.accessibilityIdentifier isEqualToString:identifier]) + return sv; + } + // SwiftUI does not reliably propagate `.accessibilityIdentifier` onto the + // backing UIScrollView, so an explicit id may not match even when set on + // the Mob node. Fall through to the largest scroll view (the main content + // scroller) rather than failing — correct for the common one-scroll screen. + } + + UIScrollView *best = nil; + CGFloat bestArea = -1.0; + for (UIScrollView *sv in all) { + CGFloat area = sv.bounds.size.width * sv.bounds.size.height; + if (area > bestArea) { + bestArea = area; + best = sv; + } + } + return best; +} +#endif // !MOB_RELEASE — end of the debug-only synthetic-input + scroll harness + +// Screen capture — public UIKit only (UIGraphicsImageRenderer + drawViewHierarchy), +// no private selectors, so it is App-Store-safe. Carved out of the harness so a host +// can opt it into release builds via -DMOB_ENABLE_SCREENSHOT (mob_dev config +// `ios_release_screenshot: true`); OFF by default. It captures the app's own key window +// with no OS prompt or indicator, so shipping a remotely-triggerable capture must be a +// conscious build choice, never a silent default. In release this lets an agent SEE the +// screen to error-correct while remaining unable to DRIVE it (tap/type stay stripped). +#if !MOB_RELEASE || defined(MOB_ENABLE_SCREENSHOT) +static ERL_NIF_TERM nif_screenshot(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + char fmt[8] = {0}; + int quality = 90; + double scale = 1.0; + if (!enif_get_atom(env, argv[0], fmt, sizeof(fmt), ERL_NIF_LATIN1) || + !enif_get_int(env, argv[1], &quality) || !enif_get_double(env, argv[2], &scale)) + return enif_make_badarg(env); + + BOOL jpeg = (strcmp(fmt, "jpeg") == 0); + if (scale <= 0.0) + scale = 1.0; + + __block NSData *imageData = nil; + dispatch_sync(dispatch_get_main_queue(), ^{ + UIWindow *window = nil; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) + continue; + for (UIWindow *win in [(UIWindowScene *)scene windows]) { + if (win.isHidden) + continue; + if (win.isKeyWindow) { + window = win; + break; + } + if (!window) + window = win; // first visible window as fallback + } + if (window.isKeyWindow) + break; + } + if (!window) + return; + + // `scale` is a multiplier of the native screen scale: 1.0 = crisp native + // resolution, 0.5 = half (smaller payload over dist). + UIGraphicsImageRendererFormat *rf = [UIGraphicsImageRendererFormat preferredFormat]; + rf.scale = [UIScreen mainScreen].scale * (CGFloat)scale; + rf.opaque = YES; + UIGraphicsImageRenderer *renderer = + [[UIGraphicsImageRenderer alloc] initWithSize:window.bounds.size format:rf]; + UIImage *img = [renderer imageWithActions:^(UIGraphicsImageRendererContext *_Nonnull ctx) { + (void)ctx; + [window drawViewHierarchyInRect:window.bounds afterScreenUpdates:YES]; + }]; + imageData = jpeg ? UIImageJPEGRepresentation(img, (CGFloat)quality / 100.0) + : UIImagePNGRepresentation(img); + }); + + if (!imageData) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_window")); + + ErlNifBinary bin; + enif_alloc_binary(imageData.length, &bin); + memcpy(bin.data, imageData.bytes, imageData.length); + return enif_make_binary(env, &bin); +} +#endif // !MOB_RELEASE || MOB_ENABLE_SCREENSHOT + +#if !MOB_RELEASE // resume the debug-only harness (scroll + element frames) +static ERL_NIF_TERM nif_scroll_info(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifBinary idb; + if (!enif_inspect_binary(env, argv[0], &idb)) + return enif_make_badarg(env); + NSString *identifier = [[NSString alloc] initWithBytes:idb.data + length:idb.size + encoding:NSUTF8StringEncoding] + ?: @""; + + __block NSData *jsonData = nil; + dispatch_sync(dispatch_get_main_queue(), ^{ + UIScrollView *sv = mob_find_scroll_view(identifier); + if (!sv) + return; + + // Normalize so offset 0 == content top, regardless of inset. + UIEdgeInsets in = sv.adjustedContentInset; + CGFloat vw = sv.bounds.size.width - in.left - in.right; + CGFloat vh = sv.bounds.size.height - in.top - in.bottom; + CGFloat cw = sv.contentSize.width; + CGFloat ch = sv.contentSize.height; + NSDictionary *d = @{ + @"offset_x" : @(sv.contentOffset.x + in.left), + @"offset_y" : @(sv.contentOffset.y + in.top), + @"content_w" : @(cw), + @"content_h" : @(ch), + @"viewport_w" : @(vw), + @"viewport_h" : @(vh), + @"max_x" : @(MAX(0.0, cw - vw)), + @"max_y" : @(MAX(0.0, ch - vh)), + @"kind" : @"pixel" + }; + jsonData = [NSJSONSerialization dataWithJSONObject:d options:0 error:nil]; + }); + + if (!jsonData) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "scroll_view_not_found")); + + ErlNifBinary bin; + enif_alloc_binary(jsonData.length, &bin); + memcpy(bin.data, jsonData.bytes, jsonData.length); + return enif_make_binary(env, &bin); +} + +static ERL_NIF_TERM nif_scroll_to(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + ErlNifBinary idb; + double x, y; + if (!enif_inspect_binary(env, argv[0], &idb) || !enif_get_double(env, argv[1], &x) || + !enif_get_double(env, argv[2], &y)) + return enif_make_badarg(env); + NSString *identifier = [[NSString alloc] initWithBytes:idb.data + length:idb.size + encoding:NSUTF8StringEncoding] + ?: @""; + + __block BOOL ok = NO; + dispatch_sync(dispatch_get_main_queue(), ^{ + UIScrollView *sv = mob_find_scroll_view(identifier); + if (!sv) + return; + // Caller works in normalized coords (0 == top); convert to offset space. + UIEdgeInsets in = sv.adjustedContentInset; + [sv setContentOffset:CGPointMake((CGFloat)x - in.left, (CGFloat)y - in.top) animated:NO]; + ok = YES; + }); + + return ok ? enif_make_atom(env, "ok") + : enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "scroll_view_not_found")); +} + +// nif_element_frames/0 — JSON {"id":[x,y,w,h],...} of tagged element frames +// (logical points). Recorded by MobFrameTracker; see mob_register_frame. +static ERL_NIF_TERM nif_element_frames(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + NSMutableDictionary *reg = mob_frame_registry(); + NSData *jsonData = nil; + @synchronized(reg) { + jsonData = [NSJSONSerialization dataWithJSONObject:reg options:0 error:nil]; + } + if (!jsonData) + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "encode_failed")); + + ErlNifBinary bin; + enif_alloc_binary(jsonData.length, &bin); + memcpy(bin.data, jsonData.bytes, jsonData.length); + return enif_make_binary(env, &bin); +} +#endif // !MOB_RELEASE — end of test harness block (started near line 2780) // ── Storage ─────────────────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_storage_dir(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_storage_dir(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { char loc[32]; enif_get_atom(env, argv[0], loc, sizeof(loc), ERL_NIF_LATIN1); - NSString* path = nil; - NSFileManager* fm = [NSFileManager defaultManager]; + NSString *path = nil; + NSFileManager *fm = [NSFileManager defaultManager]; if (strcmp(loc, "temp") == 0) { path = NSTemporaryDirectory(); } else if (strcmp(loc, "documents") == 0) { - path = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject]; + path = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) + firstObject]; } else if (strcmp(loc, "cache") == 0) { - path = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject]; + path = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) + firstObject]; } else if (strcmp(loc, "app_support") == 0) { - path = [NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES) firstObject]; + path = [NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, + YES) firstObject]; [fm createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:nil]; } else if (strcmp(loc, "icloud") == 0) { - NSURL* url = [fm URLForUbiquityContainerIdentifier:nil]; + NSURL *url = [fm URLForUbiquityContainerIdentifier:nil]; if (url) { path = [url URLByAppendingPathComponent:@"Documents"].path; - [fm createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:nil]; + [fm createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:nil + error:nil]; } } - if (!path) return enif_make_atom(env, "nil"); - const char* cpath = path.UTF8String; - ErlNifBinary bin; enif_alloc_binary(strlen(cpath), &bin); + if (!path) + return enif_make_atom(env, "nil"); + const char *cpath = path.UTF8String; + ErlNifBinary bin; + enif_alloc_binary(strlen(cpath), &bin); memcpy(bin.data, cpath, strlen(cpath)); return enif_make_binary(env, &bin); } -static ERL_NIF_TERM nif_storage_save_to_photo_library(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_storage_save_to_photo_library(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* path = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; - ErlNifPid pid; enif_self(env, &pid); - - [PHPhotoLibrary requestAuthorizationForAccessLevel:PHAccessLevelAddOnly - handler:^(PHAuthorizationStatus status) { - if (status != PHAuthorizationStatusAuthorized && status != PHAuthorizationStatusLimited) { - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple4(e, - enif_make_atom(e, "storage"), enif_make_atom(e, "error"), - enif_make_atom(e, "save_to_library"), enif_make_atom(e, "permission_denied")); - enif_send(NULL, &pid, e, msg); - enif_free_env(e); - return; - } - [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{ - NSURL* url = [NSURL fileURLWithPath:path]; - NSString* ext = path.pathExtension.lowercaseString; - BOOL isVideo = [@[@"mp4", @"mov", @"m4v"] containsObject:ext]; - if (isVideo) [PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:url]; - else [PHAssetChangeRequest creationRequestForAssetFromImageAtFileURL:url]; - } completionHandler:^(BOOL success, NSError* err) { - ErlNifEnv* e = enif_alloc_env(); - ERL_NIF_TERM msg; - if (success) { - const char* cpath = path.UTF8String; - ErlNifBinary pb; enif_alloc_binary(strlen(cpath), &pb); - memcpy(pb.data, cpath, strlen(cpath)); - msg = enif_make_tuple3(e, - enif_make_atom(e, "storage"), - enif_make_atom(e, "saved_to_library"), - enif_make_binary(e, &pb)); - } else { - msg = enif_make_tuple4(e, - enif_make_atom(e, "storage"), enif_make_atom(e, "error"), - enif_make_atom(e, "save_to_library"), enif_make_atom(e, "save_failed")); - } - enif_send(NULL, &pid, e, msg); - enif_free_env(e); - }]; - }]; + NSString *path = [[NSString alloc] initWithBytes:bin.data + length:bin.size + encoding:NSUTF8StringEncoding]; + ErlNifPid pid; + enif_self(env, &pid); + + [PHPhotoLibrary + requestAuthorizationForAccessLevel:PHAccessLevelAddOnly + handler:^(PHAuthorizationStatus status) { + if (status != PHAuthorizationStatusAuthorized && + status != PHAuthorizationStatusLimited) { + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple4( + e, enif_make_atom(e, "storage"), + enif_make_atom(e, "error"), + enif_make_atom(e, "save_to_library"), + enif_make_atom(e, "permission_denied")); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); + return; + } + [[PHPhotoLibrary sharedPhotoLibrary] + performChanges:^{ + NSURL *url = [NSURL fileURLWithPath:path]; + NSString *ext = path.pathExtension.lowercaseString; + BOOL isVideo = + [@[ @"mp4", @"mov", @"m4v" ] containsObject:ext]; + if (isVideo) + [PHAssetChangeRequest + creationRequestForAssetFromVideoAtFileURL:url]; + else + [PHAssetChangeRequest + creationRequestForAssetFromImageAtFileURL:url]; + } + completionHandler:^(BOOL success, NSError *err) { + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg; + if (success) { + const char *cpath = path.UTF8String; + ErlNifBinary pb; + enif_alloc_binary(strlen(cpath), &pb); + memcpy(pb.data, cpath, strlen(cpath)); + msg = enif_make_tuple3( + e, enif_make_atom(e, "storage"), + enif_make_atom(e, "saved_to_library"), + enif_make_binary(e, &pb)); + } else { + msg = enif_make_tuple4( + e, enif_make_atom(e, "storage"), + enif_make_atom(e, "error"), + enif_make_atom(e, "save_to_library"), + enif_make_atom(e, "save_failed")); + } + enif_send(NULL, &pid, e, msg); + enif_free_env(e); + }]; + }]; return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_storage_save_to_media_store(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { - return enif_make_tuple2(env, enif_make_atom(env, "error"), enif_make_atom(env, "not_supported")); +static ERL_NIF_TERM nif_storage_save_to_media_store(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "not_supported")); } -static ERL_NIF_TERM nif_storage_external_files_dir(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_storage_external_files_dir(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { return enif_make_atom(env, "nil"); } @@ -4713,56 +5692,63 @@ static ERL_NIF_TERM nif_storage_external_files_dir(ErlNifEnv* env, int argc, con // g_webview is set by MobWebView (MobRootView.swift) when the component is created. // mob_deliver_webview_message / _blocked are called from Swift (via bridging header). -static void deliver_webview_binary(const char* tag, const char* utf8) { - ErlNifEnv* env = enif_alloc_env(); +static void deliver_webview_binary(const char *tag, const char *utf8) { + ErlNifEnv *env = enif_alloc_env(); ErlNifPid pid; if (!enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { - enif_free_env(env); return; + enif_free_env(env); + return; } size_t len = strlen(utf8); ErlNifBinary bin; enif_alloc_binary(len, &bin); memcpy(bin.data, utf8, len); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "webview"), - enif_make_atom(env, tag), - enif_make_binary(env, &bin)); + ERL_NIF_TERM msg = enif_make_tuple3(env, enif_make_atom(env, "webview"), + enif_make_atom(env, tag), enif_make_binary(env, &bin)); enif_send(NULL, &pid, env, msg); enif_free_env(env); } -void mob_deliver_webview_message(const char* json_utf8) { +void mob_deliver_webview_message(const char *json_utf8) { deliver_webview_binary("message", json_utf8); } -void mob_deliver_webview_blocked(const char* url_utf8) { +void mob_deliver_webview_blocked(const char *url_utf8) { deliver_webview_binary("blocked", url_utf8); } -WKWebView* g_webview = nil; +WKWebView *g_webview = nil; +// Camera preview session — OWNED by the mob_camera plugin (its NIF supplies the +// strong definition and drives start/stop_preview). Defined weak here so core +// still links when mob_camera isn't activated: the symbol resolves to nil and the +// preview shows black. The weak *declaration* in MobNode.h is not enough on its +// own — swiftc compiles MobRootView's reference into a *strong* undefined symbol +// (the weak attribute doesn't cross the C→Swift interop boundary), so a definition +// must exist in core. The plugin's non-weak definition overrides this one when linked. +AVCaptureSession *g_preview_session __attribute__((weak)) = nil; // ── Alert delivery (called from UIAlertAction blocks) ──────────────────────── -static void mob_deliver_alert_action(const char* action) { - ErlNifEnv* env = enif_alloc_env(); +static void mob_deliver_alert_action(const char *action) { + ErlNifEnv *env = enif_alloc_env(); ErlNifPid pid; if (enif_whereis_pid(env, enif_make_atom(env, "mob_screen"), &pid)) { - ERL_NIF_TERM msg = enif_make_tuple2(env, - enif_make_atom(env, "alert"), - enif_make_atom(env, action)); + ERL_NIF_TERM msg = + enif_make_tuple2(env, enif_make_atom(env, "alert"), enif_make_atom(env, action)); enif_send(NULL, &pid, env, msg); } enif_free_env(env); } // Returns the root UIViewController for presenting dialogs. -static UIViewController* root_vc(void) { - for (UIWindowScene* scene in [UIApplication sharedApplication].connectedScenes) { +static UIViewController *root_vc(void) { + for (UIWindowScene *scene in [UIApplication sharedApplication].connectedScenes) { if (scene.activationState == UISceneActivationStateForegroundActive) { - UIWindow* win = scene.windows.firstObject; - UIViewController* vc = win.rootViewController; - while (vc.presentedViewController) vc = vc.presentedViewController; + UIWindow *win = scene.windows.firstObject; + UIViewController *vc = win.rootViewController; + while (vc.presentedViewController) + vc = vc.presentedViewController; return vc; } } @@ -4771,184 +5757,229 @@ static void mob_deliver_alert_action(const char* action) { // ── NIF: alert_show/3 ──────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_alert_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_alert_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary title_bin, msg_bin, btns_bin; if (!enif_inspect_binary(env, argv[0], &title_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) + return enif_make_badarg(env); if (!enif_inspect_binary(env, argv[1], &msg_bin) && - !enif_inspect_iolist_as_binary(env, argv[1], &msg_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[1], &msg_bin)) + return enif_make_badarg(env); if (!enif_inspect_binary(env, argv[2], &btns_bin) && - !enif_inspect_iolist_as_binary(env, argv[2], &btns_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[2], &btns_bin)) + return enif_make_badarg(env); - NSString* title = [[NSString alloc] initWithBytes:title_bin.data length:title_bin.size encoding:NSUTF8StringEncoding]; - NSString* message = msg_bin.size > 0 ? [[NSString alloc] initWithBytes:msg_bin.data length:msg_bin.size encoding:NSUTF8StringEncoding] : nil; - NSData* btns_d = [NSData dataWithBytes:btns_bin.data length:btns_bin.size]; + NSString *title = [[NSString alloc] initWithBytes:title_bin.data + length:title_bin.size + encoding:NSUTF8StringEncoding]; + NSString *message = msg_bin.size > 0 ? [[NSString alloc] initWithBytes:msg_bin.data + length:msg_bin.size + encoding:NSUTF8StringEncoding] + : nil; + NSData *btns_d = [NSData dataWithBytes:btns_bin.data length:btns_bin.size]; dispatch_async(dispatch_get_main_queue(), ^{ - NSArray* buttons = [NSJSONSerialization JSONObjectWithData:btns_d options:0 error:nil]; - if (![buttons isKindOfClass:[NSArray class]]) return; - - UIAlertController* ac = [UIAlertController alertControllerWithTitle:title - message:message - preferredStyle:UIAlertControllerStyleAlert]; - for (NSDictionary* btn in buttons) { - NSString* label = btn[@"label"] ?: @""; - NSString* action = btn[@"action"] ?: @"dismiss"; - NSString* style = btn[@"style"] ?: @"default"; - UIAlertActionStyle as = UIAlertActionStyleDefault; - if ([style isEqualToString:@"cancel"]) as = UIAlertActionStyleCancel; - if ([style isEqualToString:@"destructive"]) as = UIAlertActionStyleDestructive; - const char* act_c = [action UTF8String]; - [ac addAction:[UIAlertAction actionWithTitle:label style:as handler:^(UIAlertAction* _) { - mob_deliver_alert_action(act_c); - }]]; - } - UIViewController* vc = root_vc(); - if (vc) [vc presentViewController:ac animated:YES completion:nil]; + NSArray *buttons = [NSJSONSerialization JSONObjectWithData:btns_d options:0 error:nil]; + if (![buttons isKindOfClass:[NSArray class]]) + return; + + UIAlertController *ac = + [UIAlertController alertControllerWithTitle:title + message:message + preferredStyle:UIAlertControllerStyleAlert]; + for (NSDictionary *btn in buttons) { + NSString *label = btn[@"label"] ?: @""; + NSString *action = btn[@"action"] ?: @"dismiss"; + NSString *style = btn[@"style"] ?: @"default"; + UIAlertActionStyle as = UIAlertActionStyleDefault; + if ([style isEqualToString:@"cancel"]) + as = UIAlertActionStyleCancel; + if ([style isEqualToString:@"destructive"]) + as = UIAlertActionStyleDestructive; + const char *act_c = [action UTF8String]; + [ac addAction:[UIAlertAction actionWithTitle:label + style:as + handler:^(UIAlertAction *_) { + mob_deliver_alert_action(act_c); + }]]; + } + UIViewController *vc = root_vc(); + if (vc) + [vc presentViewController:ac animated:YES completion:nil]; }); return enif_make_atom(env, "ok"); } // ── NIF: action_sheet_show/2 ───────────────────────────────────────────────── -static ERL_NIF_TERM nif_action_sheet_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_action_sheet_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary title_bin, btns_bin; if (!enif_inspect_binary(env, argv[0], &title_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[0], &title_bin)) + return enif_make_badarg(env); if (!enif_inspect_binary(env, argv[1], &btns_bin) && - !enif_inspect_iolist_as_binary(env, argv[1], &btns_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[1], &btns_bin)) + return enif_make_badarg(env); - NSString* title = title_bin.size > 0 ? [[NSString alloc] initWithBytes:title_bin.data length:title_bin.size encoding:NSUTF8StringEncoding] : nil; - NSData* btns_d = [NSData dataWithBytes:btns_bin.data length:btns_bin.size]; + NSString *title = title_bin.size > 0 ? [[NSString alloc] initWithBytes:title_bin.data + length:title_bin.size + encoding:NSUTF8StringEncoding] + : nil; + NSData *btns_d = [NSData dataWithBytes:btns_bin.data length:btns_bin.size]; dispatch_async(dispatch_get_main_queue(), ^{ - NSArray* buttons = [NSJSONSerialization JSONObjectWithData:btns_d options:0 error:nil]; - if (![buttons isKindOfClass:[NSArray class]]) return; - - UIAlertController* ac = [UIAlertController alertControllerWithTitle:title - message:nil - preferredStyle:UIAlertControllerStyleActionSheet]; - for (NSDictionary* btn in buttons) { - NSString* label = btn[@"label"] ?: @""; - NSString* action = btn[@"action"] ?: @"dismiss"; - NSString* style = btn[@"style"] ?: @"default"; - UIAlertActionStyle as = UIAlertActionStyleDefault; - if ([style isEqualToString:@"cancel"]) as = UIAlertActionStyleCancel; - if ([style isEqualToString:@"destructive"]) as = UIAlertActionStyleDestructive; - const char* act_c = [action UTF8String]; - [ac addAction:[UIAlertAction actionWithTitle:label style:as handler:^(UIAlertAction* _) { - mob_deliver_alert_action(act_c); - }]]; - } - UIViewController* vc = root_vc(); - if (!vc) return; - // iPad requires a source view for action sheets - if (ac.popoverPresentationController) { - ac.popoverPresentationController.sourceView = vc.view; - ac.popoverPresentationController.sourceRect = - CGRectMake(vc.view.bounds.size.width / 2, vc.view.bounds.size.height, 0, 0); - } - [vc presentViewController:ac animated:YES completion:nil]; + NSArray *buttons = [NSJSONSerialization JSONObjectWithData:btns_d options:0 error:nil]; + if (![buttons isKindOfClass:[NSArray class]]) + return; + + UIAlertController *ac = + [UIAlertController alertControllerWithTitle:title + message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + for (NSDictionary *btn in buttons) { + NSString *label = btn[@"label"] ?: @""; + NSString *action = btn[@"action"] ?: @"dismiss"; + NSString *style = btn[@"style"] ?: @"default"; + UIAlertActionStyle as = UIAlertActionStyleDefault; + if ([style isEqualToString:@"cancel"]) + as = UIAlertActionStyleCancel; + if ([style isEqualToString:@"destructive"]) + as = UIAlertActionStyleDestructive; + const char *act_c = [action UTF8String]; + [ac addAction:[UIAlertAction actionWithTitle:label + style:as + handler:^(UIAlertAction *_) { + mob_deliver_alert_action(act_c); + }]]; + } + UIViewController *vc = root_vc(); + if (!vc) + return; + // iPad requires a source view for action sheets + if (ac.popoverPresentationController) { + ac.popoverPresentationController.sourceView = vc.view; + ac.popoverPresentationController.sourceRect = + CGRectMake(vc.view.bounds.size.width / 2, vc.view.bounds.size.height, 0, 0); + } + [vc presentViewController:ac animated:YES completion:nil]; }); return enif_make_atom(env, "ok"); } // ── NIF: toast_show/2 ──────────────────────────────────────────────────────── -static ERL_NIF_TERM nif_toast_show(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_toast_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary msg_bin; char dur[8] = "short"; if (!enif_inspect_binary(env, argv[0], &msg_bin) && - !enif_inspect_iolist_as_binary(env, argv[0], &msg_bin)) return enif_make_badarg(env); + !enif_inspect_iolist_as_binary(env, argv[0], &msg_bin)) + return enif_make_badarg(env); enif_get_atom(env, argv[1], dur, sizeof(dur), ERL_NIF_LATIN1); - NSString* message = [[NSString alloc] initWithBytes:msg_bin.data length:msg_bin.size encoding:NSUTF8StringEncoding]; + NSString *message = [[NSString alloc] initWithBytes:msg_bin.data + length:msg_bin.size + encoding:NSUTF8StringEncoding]; double seconds = strcmp(dur, "long") == 0 ? 3.5 : 2.0; dispatch_async(dispatch_get_main_queue(), ^{ - // Find the key window - UIWindow* window = nil; - for (UIWindowScene* scene in [UIApplication sharedApplication].connectedScenes) { - if (scene.activationState == UISceneActivationStateForegroundActive) { - window = scene.windows.firstObject; break; - } - } - if (!window) return; - - UILabel* label = [[UILabel alloc] init]; - label.text = message; - label.textColor = [UIColor whiteColor]; - label.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.75]; - label.textAlignment = NSTextAlignmentCenter; - label.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium]; - label.layer.cornerRadius = 12; - label.layer.masksToBounds = YES; - label.numberOfLines = 0; - - CGFloat maxW = window.bounds.size.width - 48; - CGSize fit = [label sizeThatFits:CGSizeMake(maxW - 32, 200)]; - CGFloat w = MIN(fit.width + 32, maxW); - CGFloat h = fit.height + 16; - CGFloat x = (window.bounds.size.width - w) / 2; - CGFloat y = window.bounds.size.height - h - 80; // above home indicator - label.frame = CGRectMake(x, y, w, h); - label.alpha = 0; - - [window addSubview:label]; - [UIView animateWithDuration:0.25 animations:^{ label.alpha = 1.0; } completion:^(BOOL _) { + // Find the key window + UIWindow *window = nil; + for (UIWindowScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (scene.activationState == UISceneActivationStateForegroundActive) { + window = scene.windows.firstObject; + break; + } + } + if (!window) + return; + + UILabel *label = [[UILabel alloc] init]; + label.text = message; + label.textColor = [UIColor whiteColor]; + label.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.75]; + label.textAlignment = NSTextAlignmentCenter; + label.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium]; + label.layer.cornerRadius = 12; + label.layer.masksToBounds = YES; + label.numberOfLines = 0; + + CGFloat maxW = window.bounds.size.width - 48; + CGSize fit = [label sizeThatFits:CGSizeMake(maxW - 32, 200)]; + CGFloat w = MIN(fit.width + 32, maxW); + CGFloat h = fit.height + 16; + CGFloat x = (window.bounds.size.width - w) / 2; + CGFloat y = window.bounds.size.height - h - 80; // above home indicator + label.frame = CGRectMake(x, y, w, h); + label.alpha = 0; + + [window addSubview:label]; + [UIView animateWithDuration:0.25 + animations:^{ + label.alpha = 1.0; + } + completion:^(BOOL _) { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(seconds * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [UIView animateWithDuration:0.25 animations:^{ label.alpha = 0; } - completion:^(BOOL _) { [label removeFromSuperview]; }]; - }); - }]; + [UIView animateWithDuration:0.25 + animations:^{ + label.alpha = 0; + } + completion:^(BOOL _) { + [label removeFromSuperview]; + }]; + }); + }]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_webview_eval_js(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_webview_eval_js(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* code = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; + NSString *code = [[NSString alloc] initWithBytes:bin.data + length:bin.size + encoding:NSUTF8StringEncoding]; dispatch_async(dispatch_get_main_queue(), ^{ - [g_webview evaluateJavaScript:code completionHandler:nil]; + [g_webview evaluateJavaScript:code completionHandler:nil]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_webview_post_message(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_webview_post_message(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifBinary bin; if (!enif_inspect_binary(env, argv[0], &bin) && !enif_inspect_iolist_as_binary(env, argv[0], &bin)) return enif_make_badarg(env); - NSString* json = [[NSString alloc] initWithBytes:bin.data length:bin.size encoding:NSUTF8StringEncoding]; + NSString *json = [[NSString alloc] initWithBytes:bin.data + length:bin.size + encoding:NSUTF8StringEncoding]; // Escape for single-quoted JS string: backslash then apostrophe - NSString* escaped = [json stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"]; + NSString *escaped = [json stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"]; escaped = [escaped stringByReplacingOccurrencesOfString:@"'" withString:@"\\'"]; - NSString* js = [NSString stringWithFormat:@"window.mob&&window.mob._dispatch('%@')", escaped]; + NSString *js = [NSString stringWithFormat:@"window.mob&&window.mob._dispatch('%@')", escaped]; dispatch_async(dispatch_get_main_queue(), ^{ - [g_webview evaluateJavaScript:js completionHandler:nil]; + [g_webview evaluateJavaScript:js completionHandler:nil]; }); return enif_make_atom(env, "ok"); } -static ERL_NIF_TERM nif_webview_can_go_back(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_webview_can_go_back(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { // dispatch_sync blocks this BEAM scheduler thread until the main queue drains. // Intentional — the caller (Mob.Screen back handler) needs the boolean before deciding // whether to pop the nav stack. Same pattern as clipboard_get and safe_area. // The main thread is expected to be idle during a back gesture. __block BOOL result = NO; dispatch_sync(dispatch_get_main_queue(), ^{ - result = g_webview ? [g_webview canGoBack] : NO; + result = g_webview ? [g_webview canGoBack] : NO; }); return enif_make_atom(env, result ? "true" : "false"); } -static ERL_NIF_TERM nif_webview_go_back(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_webview_go_back(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { dispatch_async(dispatch_get_main_queue(), ^{ - [g_webview goBack]; + [g_webview goBack]; }); return enif_make_atom(env, "ok"); } @@ -4964,13 +5995,13 @@ static ERL_NIF_TERM nif_webview_go_back(ErlNifEnv* env, int argc, const ERL_NIF_ typedef struct { ErlNifPid pid; - int active; + int active; } ComponentHandle; static ComponentHandle component_handles[MAX_COMPONENT_HANDLES]; -static ErlNifMutex* component_mutex = NULL; +static ErlNifMutex *component_mutex = NULL; -static ERL_NIF_TERM nif_register_component(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_register_component(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { ErlNifPid pid; if (!enif_get_local_pid(env, argv[0], &pid)) return enif_make_badarg(env); @@ -4978,7 +6009,7 @@ static ERL_NIF_TERM nif_register_component(ErlNifEnv* env, int argc, const ERL_N enif_mutex_lock(component_mutex); for (int i = 0; i < MAX_COMPONENT_HANDLES; i++) { if (!component_handles[i].active) { - component_handles[i].pid = pid; + component_handles[i].pid = pid; component_handles[i].active = 1; enif_mutex_unlock(component_mutex); return enif_make_int(env, i); @@ -4988,7 +6019,7 @@ static ERL_NIF_TERM nif_register_component(ErlNifEnv* env, int argc, const ERL_N return enif_make_badarg(env); } -static ERL_NIF_TERM nif_deregister_component(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) { +static ERL_NIF_TERM nif_deregister_component(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { int handle; if (!enif_get_int(env, argv[0], &handle) || handle < 0 || handle >= MAX_COMPONENT_HANDLES) return enif_make_badarg(env); @@ -4999,8 +6030,99 @@ static ERL_NIF_TERM nif_deregister_component(ErlNifEnv* env, int argc, const ERL return enif_make_atom(env, "ok"); } -void mob_send_component_event(int handle, const char* event, const char* payload_json) { - if (handle < 0 || handle >= MAX_COMPONENT_HANDLES) return; +// ── NIF: resolve_ipv4/1 ────────────────────────────────────────────────────── +// +// In-process IPv4 DNS resolution via Darwin's libc getaddrinfo. Exists +// because BEAM's normal DNS path (`inet_gethost`, a port-program subprocess) +// is unrunnable on iOS — the sandbox forbids execve of bundled helper +// binaries. getaddrinfo is a libc function that runs in the app process +// with no exec / no sandbox interaction, so DNS via this NIF works where +// BEAM's built-in path doesn't. +// +// Callers should not invoke this NIF directly in app code. Use +// `Mob.DNS.resolve/1` (Elixir wrapper) which also seeds `:inet_db` so +// subsequent `:inet.getaddr/2` lookups by Req / Finch / Mint find the +// host. See `guides/dns_on_ios.md`. +// +// Dirty-scheduled because getaddrinfo can block on network for the full +// resolver timeout (sometimes seconds). Keeping it off regular schedulers +// avoids head-of-line blocking on every other BEAM activity. +// +// Returns: +// {:ok, {a, b, c, d}} +// {:error, :badarg} — host arg isn't a string/charlist +// {:error, :nxdomain} — no such hostname +// {:error, :timeout} — getaddrinfo TRY_AGAIN +// {:error, :no_address} — got a result but no IPv4 in the chain +// {:error, {:gai, code}} — anything else; `code` is the raw EAI_* int + +static ERL_NIF_TERM nif_resolve_ipv4(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + char host[256]; + int got = enif_get_string(env, argv[0], host, sizeof(host), ERL_NIF_LATIN1); + + if (got <= 0) { + // got == 0 means the term wasn't a string; got < 0 means truncation. + return enif_make_tuple2(env, enif_make_atom(env, "error"), enif_make_atom(env, "badarg")); + } + + struct addrinfo hints = {0}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + + struct addrinfo *result = NULL; + int err = getaddrinfo(host, NULL, &hints, &result); + + if (err != 0) { + const char *atom = NULL; + switch (err) { + case EAI_NONAME: + case EAI_NODATA: + atom = "nxdomain"; + break; + case EAI_AGAIN: + atom = "timeout"; + break; + default: + break; + } + if (atom) { + return enif_make_tuple2(env, enif_make_atom(env, "error"), enif_make_atom(env, atom)); + } + // Anything else: surface the raw EAI_* code so the caller can + // distinguish or log it. + return enif_make_tuple2( + env, enif_make_atom(env, "error"), + enif_make_tuple2(env, enif_make_atom(env, "gai"), enif_make_int(env, err))); + } + + // Walk the result chain for the first AF_INET. getaddrinfo with + // ai_family=AF_INET should only return AF_INET entries but be + // defensive in case the resolver returns IPv6-mapped records. + ERL_NIF_TERM out_term = 0; + for (struct addrinfo *ai = result; ai != NULL; ai = ai->ai_next) { + if (ai->ai_family != AF_INET) + continue; + struct sockaddr_in *sin = (struct sockaddr_in *)ai->ai_addr; + uint32_t addr = ntohl(sin->sin_addr.s_addr); + out_term = enif_make_tuple2(env, enif_make_atom(env, "ok"), + enif_make_tuple4(env, enif_make_int(env, (addr >> 24) & 0xFF), + enif_make_int(env, (addr >> 16) & 0xFF), + enif_make_int(env, (addr >> 8) & 0xFF), + enif_make_int(env, addr & 0xFF))); + break; + } + freeaddrinfo(result); + + if (out_term == 0) { + return enif_make_tuple2(env, enif_make_atom(env, "error"), + enif_make_atom(env, "no_address")); + } + return out_term; +} + +void mob_send_component_event(int handle, const char *event, const char *payload_json) { + if (handle < 0 || handle >= MAX_COMPONENT_HANDLES) + return; enif_mutex_lock(component_mutex); if (!component_handles[handle].active) { @@ -5010,15 +6132,127 @@ void mob_send_component_event(int handle, const char* event, const char* payload ErlNifPid pid = component_handles[handle].pid; enif_mutex_unlock(component_mutex); - ErlNifEnv* env = enif_alloc_env(); - ERL_NIF_TERM msg = enif_make_tuple3(env, - enif_make_atom(env, "component_event"), - enif_make_string(env, event, ERL_NIF_LATIN1), - enif_make_string(env, payload_json, ERL_NIF_LATIN1)); + ErlNifEnv *env = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple3(env, enif_make_atom(env, "component_event"), + enif_make_string(env, event, ERL_NIF_LATIN1), + enif_make_string(env, payload_json, ERL_NIF_LATIN1)); enif_send(NULL, &pid, env, msg); enif_free_env(env); } +// ── Element frame registry (positions without a screenshot) ────────────────── +// +// mob_register_frame is called from MobFrameTracker (SwiftUI) on the main thread +// as a tagged element lays out; the element_frames NIF reads it from a NIF +// thread. Both use only public APIs, so this is compiled unconditionally (the +// reading NIF is still debug-gated). @synchronized guards the shared dictionary. +static NSMutableDictionary<NSString *, NSArray<NSNumber *> *> *g_element_frames = nil; +static dispatch_once_t g_element_frames_once; + +static NSMutableDictionary *mob_frame_registry(void) { + dispatch_once(&g_element_frames_once, ^{ + g_element_frames = [NSMutableDictionary dictionary]; + }); + return g_element_frames; +} + +void mob_register_frame(const char *id, double x, double y, double w, double h) { + if (!id) + return; + NSString *key = [NSString stringWithUTF8String:id]; + if (!key) + return; + NSMutableDictionary *reg = mob_frame_registry(); + @synchronized(reg) { + reg[key] = @[ @(x), @(y), @(w), @(h) ]; + } +} + +// Drop stale frames when the render tree changes (called from nif_set_root). +static void mob_clear_frames(void) { + NSMutableDictionary *reg = mob_frame_registry(); + @synchronized(reg) { + [reg removeAllObjects]; + } +} + +// ── Mob.Peripheral.VendorUsb (iOS stubs) ────────────────────────────────────── +// +// iOS exposes no public USB-host API equivalent to Android's UsbManager. +// All seven NIFs below send {:peripheral, :vendor_usb, :error, nil, :unsupported} +// back to the caller and return :ok. Cross-platform screens see the error +// event and degrade gracefully via Mob.Peripheral.capabilities/0. + +static void send_vendor_usb_unsupported(ErlNifPid pid) { + ErlNifEnv *e = enif_alloc_env(); + ERL_NIF_TERM msg = enif_make_tuple5(e, enif_make_atom(e, "peripheral"), + enif_make_atom(e, "vendor_usb"), enif_make_atom(e, "error"), + enif_make_atom(e, "nil"), enif_make_atom(e, "unsupported")); + enif_send(NULL, &pid, e, msg); + enif_free_env(e); +} + +static ERL_NIF_TERM nif_vendor_usb_list_devices(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_request_permission(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_open(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_bulk_write(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_start_reading(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + ErlNifPid pid; + enif_self(env, &pid); + send_vendor_usb_unsupported(pid); + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_stop_reading(ErlNifEnv *env, int argc, + const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + return enif_make_atom(env, "ok"); +} + +static ERL_NIF_TERM nif_vendor_usb_close(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + return enif_make_atom(env, "ok"); +} + // Scheduling notes for nif_funcs[] below — see docs/decisions/0001-dirty-nifs.md // for the full rationale. Short version: most NIFs here either dispatch_async // to the main queue and return in microseconds, or dispatch_sync but read a @@ -5046,94 +6280,125 @@ void mob_send_component_event(int handle, const char* event, const char* payload // :nif_error when these aren't loaded, which is the right thing for // shipped apps (the harness uses private UIKit APIs and Apple's // App Store validator rejects binaries that reference them). - {"ui_tree", 0, nif_ui_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"ui_view_tree", 0, nif_ui_view_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"ui_debug", 0, nif_ui_debug, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"screen_info", 0, nif_screen_info, 0}, - {"tap", 1, nif_tap, 0}, - {"ax_action", 2, nif_ax_action, 0}, - {"ax_action_at_xy", 3, nif_ax_action_at_xy, 0}, - {"tap_xy", 2, nif_tap_xy, 0}, - {"type_text", 1, nif_type_text, 0}, - {"delete_backward", 0, nif_delete_backward, 0}, - {"key_press", 1, nif_key_press, 0}, - {"clear_text", 0, nif_clear_text, 0}, - {"long_press_xy", 3, nif_long_press_xy, 0}, - {"swipe_xy", 4, nif_swipe_xy, 0}, + {"ui_tree", 0, nif_ui_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"ui_view_tree", 0, nif_ui_view_tree, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"ui_debug", 0, nif_ui_debug, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"screen_info", 0, nif_screen_info, 0}, + {"tap", 1, nif_tap, 0}, + {"ax_action", 2, nif_ax_action, 0}, + {"ax_action_at_xy", 3, nif_ax_action_at_xy, 0}, + {"tap_xy", 2, nif_tap_xy, 0}, + {"type_text", 1, nif_type_text, 0}, + {"delete_backward", 0, nif_delete_backward, 0}, + {"key_press", 1, nif_key_press, 0}, + {"clear_text", 0, nif_clear_text, 0}, + {"long_press_xy", 3, nif_long_press_xy, 0}, + {"swipe_xy", 4, nif_swipe_xy, 0}, +#endif +#if !MOB_RELEASE || defined(MOB_ENABLE_SCREENSHOT) + {"screenshot", 3, nif_screenshot, ERL_NIF_DIRTY_JOB_CPU_BOUND}, +#endif +#if !MOB_RELEASE + {"scroll_info", 1, nif_scroll_info, 0}, + {"scroll_to", 3, nif_scroll_to, 0}, + {"element_frames", 0, nif_element_frames, ERL_NIF_DIRTY_JOB_CPU_BOUND}, #endif // ── Core mob functions ─────────────────────────────────────────────────── - {"background_keep_alive", 0, nif_background_keep_alive, 0}, - {"background_stop", 0, nif_background_stop, 0}, - {"battery_level", 0, nif_battery_level, 0}, + {"battery_level", 0, nif_battery_level, 0}, // ── Mob.Device — lifecycle events + queries ────────────────────────────── {"device_set_dispatcher", 1, nif_device_set_dispatcher, 0}, - {"device_battery_state", 0, nif_device_battery_state, 0}, - {"device_thermal_state", 0, nif_device_thermal_state, 0}, + {"device_battery_state", 0, nif_device_battery_state, 0}, + {"device_thermal_state", 0, nif_device_thermal_state, 0}, + {"device_network_state", 0, nif_device_network_state, 0}, {"device_low_power_mode", 0, nif_device_low_power_mode, 0}, - {"device_foreground", 0, nif_device_foreground, 0}, - {"device_os_version", 0, nif_device_os_version, 0}, - {"device_model", 0, nif_device_model, 0}, - {"platform", 0, nif_platform, 0}, - {"color_scheme", 0, nif_color_scheme, 0}, - {"log", 1, nif_log, 0}, - {"log", 2, nif_log2, 0}, + {"device_foreground", 0, nif_device_foreground, 0}, + {"device_os_version", 0, nif_device_os_version, 0}, + {"device_model", 0, nif_device_model, 0}, + {"device_orientation", 0, nif_device_orientation, 0}, + {"device_lock_orientation", 1, nif_device_lock_orientation, 0}, + {"device_keep_awake", 1, nif_device_keep_awake, 0}, + {"platform", 0, nif_platform, 0}, + {"color_scheme", 0, nif_color_scheme, 0}, + {"log", 1, nif_log, 0}, + {"log", 2, nif_log2, 0}, {"set_transition", 1, nif_set_transition, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"set_root", 1, nif_set_root, ERL_NIF_DIRTY_JOB_CPU_BOUND}, - {"register_tap", 1, nif_register_tap, 0}, - {"clear_taps", 0, nif_clear_taps, 0}, - {"exit_app", 0, nif_exit_app, 0}, - {"safe_area", 0, nif_safe_area, 0}, - {"haptic", 1, nif_haptic, 0}, - {"clipboard_put", 1, nif_clipboard_put, 0}, - {"clipboard_get", 0, nif_clipboard_get, 0}, - {"share_text", 1, nif_share_text, 0}, - {"open_url", 1, nif_open_url, 0}, - {"request_permission", 1, nif_request_permission, 0}, - {"biometric_authenticate", 1, nif_biometric_authenticate, 0}, - {"location_get_once", 0, nif_location_get_once, 0}, - {"location_start", 1, nif_location_start, 0}, - {"location_stop", 0, nif_location_stop, 0}, - {"camera_capture_photo", 1, nif_camera_capture_photo, 0}, - {"camera_capture_video", 1, nif_camera_capture_video, 0}, - {"camera_start_preview", 1, nif_camera_start_preview, 0}, - {"camera_stop_preview", 0, nif_camera_stop_preview, 0}, - {"photos_pick", 2, nif_photos_pick, 0}, - {"files_pick", 1, nif_files_pick, 0}, - {"audio_start_recording", 1, nif_audio_start_recording, 0}, - {"audio_stop_recording", 0, nif_audio_stop_recording, 0}, - {"audio_play", 2, nif_audio_play, 0}, - {"audio_stop_playback", 0, nif_audio_stop_playback, 0}, - {"audio_set_volume", 1, nif_audio_set_volume, 0}, - {"motion_start", 2, nif_motion_start, 0}, - {"motion_stop", 0, nif_motion_stop, 0}, - {"scanner_scan", 1, nif_scanner_scan, 0}, - {"notify_schedule", 1, nif_notify_schedule, 0}, - {"notify_cancel", 1, nif_notify_cancel, 0}, - {"notify_register_push", 0, nif_notify_register_push, 0}, - {"take_launch_notification", 0, nif_take_launch_notification, 0}, - {"storage_dir", 1, nif_storage_dir, 0}, - {"storage_save_to_photo_library", 1, nif_storage_save_to_photo_library, 0}, - {"storage_save_to_media_store", 2, nif_storage_save_to_media_store, 0}, - {"storage_external_files_dir", 1, nif_storage_external_files_dir, 0}, - {"alert_show", 3, nif_alert_show, 0}, - {"action_sheet_show", 2, nif_action_sheet_show, 0}, - {"toast_show", 2, nif_toast_show, 0}, - {"webview_eval_js", 1, nif_webview_eval_js, 0}, - {"webview_post_message",1, nif_webview_post_message,0}, + {"set_root", 1, nif_set_root, ERL_NIF_DIRTY_JOB_CPU_BOUND}, + {"set_theme", 1, nif_set_theme, 0}, + {"register_tap", 1, nif_register_tap, 0}, + {"clear_taps", 0, nif_clear_taps, 0}, + {"exit_app", 0, nif_exit_app, 0}, + {"safe_area", 0, nif_safe_area, 0}, + {"haptic", 1, nif_haptic, 0}, + {"torch", 1, nif_torch, 0}, + {"clipboard_put", 1, nif_clipboard_put, 0}, + {"clipboard_get", 0, nif_clipboard_get, 0}, + {"share_text", 1, nif_share_text, 0}, + {"open_url", 1, nif_open_url, 0}, + {"open_settings", 1, nif_open_settings, 0}, + {"request_permission", 1, nif_request_permission, 0}, + {"files_pick", 1, nif_files_pick, 0}, + {"audio_start_recording", 1, nif_audio_start_recording, 0}, + {"audio_stop_recording", 0, nif_audio_stop_recording, 0}, + {"audio_start_input_metering", 0, nif_audio_start_input_metering, 0}, + {"audio_input_level", 0, nif_audio_input_level, 0}, + {"audio_stop_input_metering", 0, nif_audio_stop_input_metering, 0}, + {"audio_play", 2, nif_audio_play, 0}, + {"audio_play_at", 3, nif_audio_play_at, 0}, + {"audio_stop_playback", 0, nif_audio_stop_playback, 0}, + {"audio_set_volume", 1, nif_audio_set_volume, 0}, + {"audio_output_status", 0, nif_audio_output_status, 0}, + {"audio_output_level", 1, nif_audio_output_level, ERL_NIF_DIRTY_JOB_IO_BOUND}, + {"tts_speak", 2, nif_tts_speak, 0}, + {"tts_stop", 0, nif_tts_stop, 0}, + {"motion_start", 2, nif_motion_start, 0}, + {"motion_stop", 0, nif_motion_stop, 0}, + {"take_launch_notification", 0, nif_take_launch_notification, 0}, + {"take_opened_document", 0, nif_take_opened_document, 0}, + {"storage_dir", 1, nif_storage_dir, 0}, + {"storage_save_to_photo_library", 1, nif_storage_save_to_photo_library, 0}, + {"storage_save_to_media_store", 2, nif_storage_save_to_media_store, 0}, + {"storage_external_files_dir", 1, nif_storage_external_files_dir, 0}, + {"alert_show", 3, nif_alert_show, 0}, + {"action_sheet_show", 2, nif_action_sheet_show, 0}, + {"toast_show", 2, nif_toast_show, 0}, + {"webview_eval_js", 1, nif_webview_eval_js, 0}, + {"webview_post_message", 1, nif_webview_post_message, 0}, {"webview_can_go_back", 0, nif_webview_can_go_back, 0}, - {"webview_go_back", 0, nif_webview_go_back, 0}, - {"register_component", 1, nif_register_component, 0}, + {"webview_go_back", 0, nif_webview_go_back, 0}, + {"register_component", 1, nif_register_component, 0}, {"deregister_component", 1, nif_deregister_component, 0}, + // ── Mob.Peripheral.VendorUsb (iOS stubs — emit :unsupported) ────────────── + {"vendor_usb_list_devices", 1, nif_vendor_usb_list_devices, 0}, + {"vendor_usb_request_permission", 1, nif_vendor_usb_request_permission, 0}, + {"vendor_usb_open", 1, nif_vendor_usb_open, 0}, + {"vendor_usb_bulk_write", 3, nif_vendor_usb_bulk_write, 0}, + {"vendor_usb_start_reading", 2, nif_vendor_usb_start_reading, 0}, + {"vendor_usb_stop_reading", 1, nif_vendor_usb_stop_reading, 0}, + {"vendor_usb_close", 1, nif_vendor_usb_close, 0}, + // getaddrinfo can block on the resolver for seconds — dirty-IO so it + // doesn't head-of-line-block the regular schedulers. See the impl + // above for the iOS rationale. + {"resolve_ipv4", 1, nif_resolve_ipv4, ERL_NIF_DIRTY_JOB_IO_BOUND}, }; -static int nif_load(ErlNifEnv* env, void** priv, ERL_NIF_TERM info) { +static int nif_load(ErlNifEnv *env, void **priv, ERL_NIF_TERM info) { LOGI(@"nif_load: initialising mob_nif (iOS/SwiftUI JSON backend)"); tap_mutex = enif_mutex_create("mob_tap_mutex"); - if (!tap_mutex) { LOGE(@"nif_load: failed to create tap mutex"); return -1; } + if (!tap_mutex) { + LOGE(@"nif_load: failed to create tap mutex"); + return -1; + } component_mutex = enif_mutex_create("mob_component_mutex"); - if (!component_mutex) { LOGE(@"nif_load: failed to create component mutex"); return -1; } + if (!component_mutex) { + LOGE(@"nif_load: failed to create component mutex"); + return -1; + } g_launch_notif_mutex = enif_mutex_create("mob_launch_notif_mutex"); - if (!g_launch_notif_mutex) { LOGE(@"nif_load: failed to create launch notif mutex"); return -1; } + g_opened_doc_mutex = enif_mutex_create("mob_opened_doc_mutex"); + if (!g_launch_notif_mutex) { + LOGE(@"nif_load: failed to create launch notif mutex"); + return -1; + } LOGI(@"nif_load: mob_nif ready"); return 0; } diff --git a/issues.md b/issues.md index d7418882..ad5a6a7f 100644 --- a/issues.md +++ b/issues.md @@ -4,7 +4,13 @@ Tracked items not yet addressed. Each section captures the symptom, why it happens, and what a fix would look like — so the next session can pick one up without re-deriving context. -## 1. Disable `phoenix_live_reload` on iOS device builds +## 1. Disable `phoenix_live_reload` on iOS device builds — **FIXED 2026-05-10** + +> **Resolution.** `mob_new`'s `mob_live_app_content/4` (LiveViewPatcher) +> now sets `code_reloader: false`, `watchers: []`, `live_reload: false` +> in the on-device endpoint config. Newly-generated LV projects pick this +> up automatically; existing projects need a one-line edit in their +> `mob_app.ex`. **Symptom** — `beam_stdout.log` on launch: ``` @@ -37,7 +43,12 @@ when running on-device (it's a dev-only dep anyway). --- -## 2. Silence `:esbuild` / `:tailwind` startup warnings on-device +## 2. Silence `:esbuild` / `:tailwind` startup warnings on-device — **FIXED 2026-05-10** + +> **Resolution.** Option (a) — `Application.put_env(:esbuild, :version, "0.25.0")` +> + `:tailwind, :version, "3.4.6"` set in `mob_app.ex` before +> `ensure_all_started`. Versions match Phoenix 1.7's defaults; bump +> alongside `mix phx.new` upgrades. **Symptom** — same log: ``` @@ -125,7 +136,23 @@ later (intermittent disconnects under load). --- -## 4. LiveView port 4200 collides across multiple installed Mob LV apps +## 4. LiveView port 4200 collides across multiple installed Mob LV apps — **FIXED 2026-05-10** + +> **Resolution.** Recommendation #1 from below: hash the app name into +> `4200..4999` for the on-device default. Implementation lives in +> `mob_new`'s `mob_live_app_content/4`: +> +> ```elixir +> defp default_liveview_port do +> 4200 + :erlang.phash2(:<app_name>, 800) +> end +> ``` +> +> Generated `mob.exs` ships `# config :mob, liveview_port: 4200` +> commented out — uncomment to pin a specific value (e.g. for a test +> harness that hardcodes a port). `Mob.LiveView.local_url/1` reads +> the env automatically, so the WebView URL stays in sync with the +> resolved port without further changes. **Symptom** — second LV app fails to start with: ``` @@ -207,7 +234,12 @@ regenerate). --- -## 5. `mix mob.deploy --native --ios` silently prefers iPhone over sim +## 5. `mix mob.deploy --native --ios` silently prefers iPhone over sim — **FIXED 2026-05-10** + +> **Resolution.** Option #1 (the recommended one): when +> `auto_detect_physical_ios/0` picks an iPhone and a sim is also booted, +> it now prints the alternative `--device <short-id>` invocation so the +> user can target the sim explicitly. Default behavior unchanged. **Symptom** — `mix mob.deploy --native --ios` builds and installs on the physical iPhone, never the booted simulator. No log line indicates the @@ -361,7 +393,13 @@ phones will be Android 15+). --- -## 7. iOS Slider doesn't honor `accessibilityIncrement`/`Decrement` +## 7. iOS Slider doesn't honor `accessibilityIncrement`/`Decrement` — **FIXED 2026-05-10** + +> **Resolution.** `MobSlider` in `ios/MobRootView.swift` now has +> `.accessibilityAdjustableAction { direction in … }` with default step +> `(max - min) / 10` (matches VoiceOver's default for native UISlider). +> Increments/decrements call `node.onChangeFloat?` so `:change` events +> still flow. `Mob.Test.adjust_slider/4` works end-to-end after this. **Symptom** — `Mob.Test.adjust_slider/4` (and direct `mob_nif:ax_action_at_xy(x, y, :increment)`) returns `:ok` but the slider's value never changes. Verified 2026-04-30 against `mob_test`'s ControlsScreen on a real iPhone with VoiceOver active. @@ -394,7 +432,15 @@ This unblocks `Mob.Test.adjust_slider/4` end-to-end. Same component on Android ( --- -## 8. iOS Toggle's `label:` prop doesn't reach the AX tree +## 8. iOS Toggle's `label:` prop doesn't reach the AX tree — **FIXED 2026-05-10** + +> **Resolution.** `MobToggle` in `ios/MobRootView.swift` now appends +> `.accessibilityLabel(label)` after the `Toggle("Label", isOn:)` view. +> SwiftUI's Toggle initializer doesn't propagate the label string into +> the underlying control's accessibilityLabel, so this is the explicit +> bridge. After the fix, the toggle appears in `ui_tree` as +> `:button label="Notifications" value="1"` and +> `Mob.Test.toggle(node, "Notifications")` finds it via plain match. **Symptom** — `Mob.Test.toggle/2` returns `{:error, :label_not_found}` because the visible label text doesn't appear in `mob_nif:ui_tree/0`. The toggle itself comes through as: @@ -756,7 +802,24 @@ problem. --- -## 14. iOS sim's distribution node name doesn't match `mix mob.connect`'s expectation +## 14. iOS sim's distribution node name doesn't match `mix mob.connect`'s expectation — **WORKED AROUND 2026-05-10** + +> **Resolution.** Option (1) from the fix list — defensive fallback in +> `mob_dev/lib/mob_dev/connector.ex`. `wait_for_nodes/2` now builds a +> per-device candidate list and tries each in parallel via +> `try_connect_each/2`. For iOS sims the list is +> `[<app>_ios_<short>@127.0.0.1, <app>_ios@127.0.0.1]`; first responder +> wins and the connected `Device.node` is updated to whichever name +> actually registered. The output surfaces the alternate name when the +> fallback is used so the user can copy it for direct RPC. +> +> **Root cause still TBD.** The fallback works around the symptom but +> doesn't explain why `mob_beam.m`'s `getenv("SIMULATOR_UDID")` sometimes +> returns NULL in launch contexts where it should be set. Worth +> investigating: confirm via `simctl spawn <udid> printenv | grep SIM` +> on a freshly-deployed sim, then trace through the launcher chain +> (`xcrun simctl install` then user-tap vs `simctl launch`). The fix +> there belongs in mob_beam.m (or the launch path that drops the var). **Symptom** — After `mix mob.deploy --native --ios --device <sim-udid>`, running `mix mob.connect --no-iex` shows the sim node as a timeout while @@ -810,3 +873,722 @@ sim verification. Use `Mob.Test` against any connected physical device **Where this matters** — every dev iteration on a sim. This is the agentic-coding loop's foundation; if `Mob.Test` doesn't work against the sim, agents lose the fast path and burn cycles on screenshots. + +--- + +## 15. `mix mob.add_nif --type zigler` — Zig toolchain mismatch — **FIXED 2026-05-13 via GenericJam/zigler fork (interim until upstream catches up)** + +**Resolution (partial, 2026-05-12, mob_dev commit forthcoming):** The +scaffold now queues `mix zig.get` after adding the `:zigler ~> 0.15` +dep, so Zigler installs and uses its pinned Zig 0.15.2 from the +user-cache directory instead of falling through to +`System.find_executable("zig")` (which on this machine picks up the +mob-pinned 0.17-dev). A test pins the contract: every +`--type zigler` scaffold run must queue `zig.get`. The moduledoc on +the generated stub now spells out the toolchain pin so users +understand why `mix zig.get` ran. + +**macOS 26 host-dev (FIXED 2026-05-13 via fork).** Forked Zigler +to `github.com/GenericJam/zigler` branch `zig-016-port` with a +minimal port of `priv/beam/` to Zig 0.16's stdlib. Zig 0.16.0 +stable (released 2026-04-13) works on macOS 26. The mob_dev +scaffold now pulls Zigler from this fork by default. Once +upstream Zigler ships a 0.16 release (community issue #578), +the dep pin flips back to hex. + +**Port details:** 5 files changed in priv/beam/, ~50 net lines. +The Zig 0.15→0.16 stdlib breaking changes that hit Zigler: + + - `std.fs.File.stdout()` moved to `std.Io.File.stdout()`, and + `File.writer/1` now takes an `Io` instance as its first arg + (sema.zig + sema_doc.zig) + - `@Type(.{ .@"struct" = ... })` split into per-variant + builtins: `@Struct`, `@Tuple`, `@Enum`, `@Union`, `@Pointer`, + `@Int`, `@Fn`, `@Vector`. Parallel-array signatures replace + the old array-of-records (get.zig + payload.zig) + - `std.debug.SelfInfo.open(allocator)` removed — replaced by + zero-value `init` const + per-method `Io` parameter. Stubbed + out for now (stacktrace.zig); NIF crashes lose per-frame + source-location info until proper port lands. + +**Empirically verified on macOS 26.4:** + + iex> TestMigration.Nifs.GreetZig.greet() + "Hello from Zig!" + +**iPhone deploy — foundation landed in fork; one piece pending in +Zigler itself (2026-05-13).** + +Two Zigler-fork build options added (GenericJam/zigler 2f17e63): + + -Dnif_linkage=static + produces a `.a` instead of the default dylib/so/dll. + linkage flows into `b.addLibrary(.linkage = ...)`; the + `linker_allow_shlib_undefined` flag is skipped for static + (it's a dynamic-library concept). + + -Dnif_init_alias=<name>_nif_init + adds an additional `@export` of nif_init under that name. + Static-NIF table lookup matches `<modname>_nif_init`; the + default `nif_init` symbol is always kept so dlopen also + works. Both names point to the same function. + +Verified on a host-target sanity build: `nm libElixir.<Mod>.a` +shows both `_nif_init` AND `_greet_zig_nif_init` exported. + +mob_dev plumbing landed in 2c405c5: `classify_project_nif/2` +detects `:zig` from a `use Zig` stub, `cross_compile_zig_nif` +invokes `zig build -Dtarget=… -Dnif_linkage=static +-Dnif_init_alias=…` against Zigler's staging dir, output `.a` +flows into the iOS link via the existing `project_rust_libs` arg. + +**iPhone empirical verification on 2026-05-13:** + + iex> Mob.Test.tap(node, :run); Mob.Test.assigns(node) + %{result: "Hello from Zig!", ...} + + [info] [greet_zig-nif] call 1 returned: "Hello from Zig!" + (visible in Mac-side IEx via mix mob.connect) + +Two additional fork patches needed beyond the linkage + alias +options for iPhone: + + -Dapple_sdkroot=<absolute SDK path> + Resolves Apple-target cImport headers (sys/types.h etc.). + mob_dev calls `xcrun --show-sdk-path -sdk iphoneos` and + passes the result. Empty/unset → host build (no SDK + injection needed). + + module.zig: pub const panic = ... if alias set, no_panic, else + simple_panic + Default panic pulls in std.debug.SelfInfo for stack traces, + which on Mach-O references dyld functions + (`_dyld_get_image_header_containing_address`). Not linkable + in static archives going into an embedded BEAM. Swap to + no_panic (trap-only, no SelfInfo) when alias is set + (our static-build marker). Host still gets simple_panic for + readable error messages. + +**Status summary** + + Host (macOS 26) ✓ via fork + iOS device (real) ✓ via fork (verified 2026-05-13) + iOS sim untested (mob_dev plumbing reuses iOS + device path; SDK swap is the only diff) + Android untested (different SDK story — needs + NDK sysroot threading, parallel work + to Apple SDK) + +The fork (`github.com/GenericJam/zigler` branch `zig-016-port`) +is the interim until Isaac's upstream 0.16 release ships with +the iOS / cross-compile fixes integrated. The mechanism we +landed should drop in cleanly: + + - 5-file priv/beam/ port to Zig 0.16 stdlib (the actual 0.16 work) + - `-Dnif_linkage=static` build option + - `-Dnif_init_alias=<name>_nif_init` build option (writes + additional `@export`) + - `-Dapple_sdkroot=<path>` build option (addSystemIncludePath + on erl_nif module) + - `pub const panic` selection based on alias presence + + + +**Symptom** — After scaffolding with `mix mob.add_nif foo --type zigler`, +`mix compile` fails inside the zigler dep's sema phase: + +``` +/_build/dev/lib/zigler/priv/beam/get.zig:718:12: error: invalid builtin function: '@Type' + return @Type(.{ .@"struct" = constructed_struct }); +/_build/dev/lib/zigler/priv/beam/payload.zig:36:12: error: invalid builtin function: '@Type' + return @Type(result_type_info); +/_build/dev/lib/zigler/priv/beam/sema.zig:282:27: error: root source file struct 'fs' has no member named 'File' + const stdout = std.fs.File.stdout(); +``` + +**Why** — `:zigler ~> 0.15` resolves to `zigler 0.15.2`, which targets a +Zig stdlib snapshot from before the `@Type` builtin signature change +and before `std.fs.File.stdout()` was removed. The installed Zig +(currently `0.17.0-dev.269+ebff43698`, the version mob_dev builds with) +is past both changes. + +The Elixir-side scaffolding itself is correct — it emits a clean +`use Zig, otp_app: :app` module with an example `pub fn` in a `~Z` +sigil. The bug is only the version pin. + +**Fix options** + +1. **Bump the zigler dep pin.** Check what version of zigler (if any) + tracks Zig 0.17-dev. If a newer zigler release is compatible, bump + the version in `MobDev.AddNif.maybe_add_zigler_dep/2`. + +2. **Pin Zig instead.** Mob already pins a specific Zig version via + `~/zig/zig-aarch64-macos-0.17.0-dev.269+ebff43698/`. If zigler 0.15 + needs an earlier Zig, document the supported range, or vendor a + second Zig install for the zigler path. + +3. **Skip zigler-via-Hex entirely.** Zigler's "compile a .so" model + doesn't fit Mob's static-link constraint anyway (the moduledoc + already warns about this). The static-link path requires manual + wire-up regardless of zigler. Consider removing `--type zigler` + from `mob.add_nif` and pointing users at writing the Zig directly + through the existing `ios/build.zig` + `android/jni/*.zig` + pipelines that the framework already uses. + +**Where this matters** — anyone trying `mix mob.add_nif --type zigler` +hits this on first compile. The error is multi-line stdlib-internal +output that doesn't suggest "your version pin is wrong" — easy to +read as "Zigler is broken" and give up. + +**Empirically verified 2026-05-12** in `~/code/test_migration` against +`zigler 0.15.2` + `zig 0.17.0-dev.269`. The Elixir scaffold ran cleanly +(stub + mob.exs entry + driver_tab regen all succeeded); the failure is +purely the dep's Zig source not matching the installed Zig. + +--- + +## 16. `mix mob.add_nif --type rustler` Rust crate fails to link on macOS host (no `-undefined dynamic_lookup`) — **FIXED 2026-05-12** + +**Resolution (2026-05-12, mob_dev commit forthcoming):** The +scaffold now emits `native/<name>/.cargo/config.toml` with the +required `rustflags` for both Apple targets: + +```toml +[target.aarch64-apple-darwin] +rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"] + +[target.x86_64-apple-darwin] +rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"] +``` + +Empirically verified: scaffolding `mix mob.add_nif foo_rustler +--type rustler` and running `mix compile` on macOS arm64 now +succeeds (links to `priv/native/foo_rustler.so`). A test pins +the contract — every `--type rustler` scaffold run creates a +`.cargo/config.toml` with both targets and the dynamic_lookup +flags. + +Linux is unaffected — `rustflags` scope is Apple-only. + + + +**Symptom** — After scaffolding with `mix mob.add_nif foo --type rustler`, +`mix compile` invokes Cargo which fails the link step: + +``` +Undefined symbols for architecture arm64: + "_enif_raise_exception", referenced from: + rustler::codegen_runtime::NifReturned::apply in librustler-*.rlib + "_enif_schedule_nif", referenced from: + rustler::codegen_runtime::NifReturned::apply in librustler-*.rlib +ld: symbol(s) not found for architecture arm64 +error: could not compile `foo_rustler` (lib) due to 1 previous error +``` + +**Why** — Rustler's default `crate-type = ["cdylib"]` builds a `.dylib` +that gets `dlopen`'d at NIF load. The `enif_*` symbols come from the +*host* BEAM process at load time, not from a library the .dylib links +against. Apple's `ld` errors out on the undefined symbols unless told +explicitly to defer them. + +On Linux this isn't an issue (`ld.bfd`/`ld.lld` defer by default). On +macOS, Rustler-on-host needs `.cargo/config.toml`: + +```toml +[target.aarch64-apple-darwin] +rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"] + +[target.x86_64-apple-darwin] +rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"] +``` + +Our scaffolding doesn't emit this file. The user hits the cryptic +link error and has to know to search "Rustler macOS undefined symbols" +to find the answer. + +**Fix** — `MobDev.AddNif.add_rustler_files/3` (the writer that creates +`native/<name>/Cargo.toml` + `src/lib.rs` + `.gitignore`) should also +emit `native/<name>/.cargo/config.toml` with the dynamic_lookup +rustflags pinned for both Apple targets. + +Note the static-link path that Mob actually ships with is different — +the moduledoc warns this scaffold's default `cdylib` won't work on +iOS/Android anyway; the user has to switch to `staticlib` and wire +the resulting `.a` into `ios/build.zig` + `android/jni/`. But the +host-dev flow (sim, `mix run`) should at least compile cleanly so +the user can iterate before doing the static-link work. + +**Empirically verified 2026-05-12** in `~/code/test_migration` against +`rustler 0.37.3`. Scaffold succeeded; first `mix compile` failed at +the cdylib link step on macOS arm64. + +--- + +## 17. NIF surface discoverability — `--python` vs `--type {c, zigler, rustler}` + +**Symptom** — Three NIF-related Mix surfaces, three different shapes: + +- `mix mob.add_nif <name> --type {c, zigler, rustler, elixir-only}` + — scaffold a *new* NIF (you write the native side). +- `mix mob.enable pythonx` — wire a *pre-built* hex NIF dep (CPython) + into an existing project, including OTP-bundle changes. +- `mix mob.new --python` — sugar for "generate project then enable + pythonx". + +A user thinking "I want to add a NIF" finds `mob.add_nif`, sees C/ +Zigler/Rustler under `--type`, and reasonably wonders why pythonx +isn't there. + +**Why the split exists** — they're conceptually different: + +- `add_nif` produces *stubs to fill in* (your own C/Rust/Zig). +- `enable pythonx` *wires a third-party prebuilt NIF dep* — there's no + user-written native code, but there IS OTP-runtime work (bundling + Python.framework on iOS, packaging the Android Python lib dir). + +Future third-party NIF deps that need similar bundling work (a +TensorFlow Lite wrapper, an OpenCV wrapper, a RocksDB NIF) would +naturally also live under `mob.enable`, not `mob.add_nif`. Conflating +the two surfaces will eventually break. + +**Fix options** + +1. **Add a discoverability alias.** `mob.add_nif --type pythonx` + becomes a thin shim that prints `"pythonx is a third-party dep, + delegating to mob.enable pythonx"` and chains to it. Cheap; keeps + the conceptual split clean; surfaces the right command via the + wrong one. + +2. **Document the split in both task moduledocs.** `mob.add_nif`'s + `@moduledoc` mentions "for third-party NIF deps, see `mob.enable`"; + `mob.enable`'s mentions the inverse. Cheapest; relies on users + reading `--help`. + +3. **Keep both routes.** `mob.add_nif --type pythonx` does the same + thing as `mob.enable pythonx`. Most consistent surface, but + conflates the two concepts conceptually (a user might then expect + `mob.add_nif --type tflite` to also Just Work). + +**Recommendation** — (1) for now. The split is conceptually right, +but discoverability is poor. + +**Where this matters** — when a user types `mix mob.add_nif --help` +and tries to figure out how to add Python. + +--- + +## 18. NIF source auto-wiring missing for `mob.add_nif --type {c, rustler}` builds — **FIXED 2026-05-13** + +**Resolution (2026-05-13, mob_dev + mob_new).** Auto-wiring landed +in both build templates and the Mix-task build pipeline. `mix mob.deploy +--native --ios-device` now: + +1. Reads `:static_nifs` from `mob.exs`, filtering to user-declared entries + (the baked-in NIFs in `MobDev.StaticNifs.default_nifs/0` are excluded + — those live in `libbeam.a`). +2. For each entry, runs `MobDev.NativeBuild.classify_project_nif/2`: + - `c_src/<name>.c` exists → C path + - `native/<name>/Cargo.toml` exists → Rust path + - neither → `:elixir_only` (no native wiring, just the stub raises) +3. For Rust NIFs: invokes `cargo rustc --release --target aarch64-apple-ios + --crate-type staticlib --manifest-path native/<name>/Cargo.toml` + (or the `-sim` target for iOS sim builds). +4. Passes the resolved lists to `zig build` as `-D` args: + - `-Dproject_root=<absolute project root>` + - `-Dproject_c_nifs=<comma-separated names>` + - `-Dproject_rust_libs=<comma-separated absolute .a paths>` +5. `build_device.zig` (and `build.zig` for sim) iterates the names and + emits `addCObject` for each C NIF with + `-DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME=<name>`, and adds + each Rust `.a` to the linker's lib list. + +**Empirically verified 2026-05-13 on physical iPhone:** scaffolded +`greet_c --type c --demo` and `greet_rust --type rustler --demo` +in test_migration with **zero hand-editing of `build_device.zig`**. +`mix mob.deploy --native --ios-device` succeeded; `Mob.Test.tap` on +both demo screens returned the expected strings (`~c"Hello from C!"` +and `"Hello from Rust!"`). + +Old workaround code in this issue's earlier history (the hand-edit +sample) is now superseded — the scaffold's pre-deploy step does it +all. The companion changes from this same session (Cargo.toml emits +`["staticlib", "cdylib"]` and `rustler = "0.37"`) make the cross-compile +step Just Work without user intervention. + +**Still hand-work for new project setup**: one-time `rustup target add +aarch64-apple-ios` (and `aarch64-apple-ios-sim` for sim). `mix mob.doctor` +could prompt for this — filed as a follow-up. + +**Android auto-wiring** (`android/jni/CMakeLists.txt` reading +`:static_nifs`) is still to do — the iOS work establishes the pattern. + + + +**Symptom** — After `mix mob.add_nif foo --type c`, the next native +build (`mix mob.deploy --native`) leaves `c_src/foo.c` unlinked. +On Elixir-side, `:erlang.load_nif/2` fails with: + +``` +The on_load function for module Elixir.<App>.Nifs.Foo returned: + {:error, {:load_failed, + "Failed to load NIF library: 'dlopen(foo.so, 0x0006): tried: ...'"}} +``` + +(BEAM fell through from the static-NIF table to dlopen because +nothing registered `<name>_nif_init` at link time.) + +The C scaffold's moduledoc currently tells the user to do this +manually — but the right scaffolding action is to auto-wire it. + +**Why this matters now** — `--demo` made this gap visible because +the demo flow expects the C NIF to actually work. Verified manually: +hand-adding an `addCObject` block + `-DSTATIC_ERLANG_NIF +-DSTATIC_ERLANG_NIF_LIBNAME=<name>` flags to `ios/build_device.zig` +gets the demo working end-to-end (Hello from C! on iPhone). + +**Fix shape** + +1. **iOS** — the `build_device.zig` template (in `mob_new`) and + `build.zig` (sim) should iterate `:static_nifs` from `mob.exs` + and emit an `addCObject` block for each entry that has a + corresponding `c_src/<name>.c` file. The `c_flags` need + `-DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME=<name>` baked + in. + +2. **Android** — equivalent in `android/jni/CMakeLists.txt`: glob + `${PROJECT_ROOT}/c_src/*.c` (or read `mob.exs :static_nifs`) + and add to `target_sources` with the same -D flags. + +3. **`mob.regen_driver_tab`** could grow a side-effect that lists + which `c_src/*.c` files exist and warns if the project's + `build.zig` / CMakeLists isn't picking them up. Belt-and-braces. + +**Workaround until then** — hand-edit `ios/build_device.zig` to add: + +```zig +installAndCollect(b, objects_step, &objs, addCObject(b, .{ + .name = "<your_nif>", + .source = "<project>/c_src/<your_nif>.c", + .target = target, + .optimize = optimize, + .c_flags = c_flags_base ++ &[_][]const u8{ + "-DSTATIC_ERLANG_NIF", + "-DSTATIC_ERLANG_NIF_LIBNAME=<your_nif>", + }, + .mob_dir = mob_dir, + .otp_root = otp_root, + .erts_vsn = erts_vsn, + .sdkroot = sdkroot, +}), "<your_nif>.o"); +``` + +The two -D flags are mandatory: without `STATIC_ERLANG_NIF_LIBNAME`, +`ERL_NIF_INIT(Elixir.App.Nifs.Foo, ...)` mangles to an invalid C +symbol name (dots in identifiers don't compile). + +**Empirically verified 2026-05-12** via the demo screen flow in +`~/code/test_migration`. The full diagnosis lives in +`mob_dev/lib/mix/tasks/mob.add_nif.ex`'s `c_skeleton/3` docstring. + +### Rustler is in the same boat (verified 2026-05-12) + +Same gap, harder shape: + +1. **Cargo `crate-type`** — scaffolded as `cdylib` (for host-dev + ergonomics). iOS device needs `staticlib`. Add both: + `crate-type = ["staticlib", "cdylib"]`. The Mob scaffold should + emit this dual form by default — host-dev still gets the + `.dylib`, iOS device gets the `.a`. +2. **Cross-compile target** — `rustup target add aarch64-apple-ios` + is a one-time setup the scaffold doesn't run. `mix mob.doctor` + could check for this and prompt. +3. **Invoke cross-compile** — Rustler's mix integration only knows + about the host target. iOS device needs: + ```bash + cd native/<name> && cargo rustc --release \ + --target aarch64-apple-ios --crate-type staticlib + ``` + This isn't wired into `mix mob.deploy --native`. +4. **Link the `.a` into iOS build** — hand-add `run.addArg(...)` + for `native/<name>/target/aarch64-apple-ios/release/lib<name>.a` + inside `addLink()` in `ios/build_device.zig`. Same pattern as + the `sqlite_static_lib` hook already there. +5. **Rustler crate version pin** — scaffold currently pins + `rustler = "0.32"` in the generated `Cargo.toml`. Rustler 0.32 + hardcodes `nif_init` (no per-crate symbol). Rustler 0.37+ derives + `<crate>_nif_init` from `CARGO_CRATE_NAME` automatically, which + is exactly what mob's static-NIF table expects. **Bump the + Cargo.toml template to `rustler = "0.37"` (or latest).** +6. **rustler::init! deprecation** — the macro warns "deprecated: + only one argument expected" with the 0.37 form. The scaffold's + `rustler::init!("Elixir.<Mod>", [greet]);` should drop the + functions list and use `#[rustler::nif]` exclusively (auto- + discovery via inventory). + +**Empirically verified 2026-05-12 on physical iPhone**: +- Scaffolded `mix mob.add_nif greet_rust --type rustler --demo --yes` +- Hand-bumped `Cargo.toml` to `rustler = "0.37"` and added + `staticlib` to crate-type. +- `cargo rustc --release --target aarch64-apple-ios --crate-type staticlib` +- Hand-added the `.a` to addLink's lib list in `build_device.zig`. +- `mix mob.deploy --native --ios-device` → succeeds. +- `Mob.Test.tap(node, :run)` → + `result: "Hello from Rust!"` and + `[info] [greet_rust-nif] call 1 returned: "Hello from Rust!"` + +So the path works; what's missing is automation. Steps 1-2 are +scaffold-side (mob_dev). Steps 3-4 are build-template-side +(mob_new templates). Step 5 is a one-line bump. Step 6 is a +template polish. + +### Zigler — blocked upstream + +`mob.add_nif --type zigler --demo` fails at host compile on macOS 26 +before iOS even enters the picture (issue #15). Until Zigler supports +Zig 0.16+, no automated iOS-device path is possible on this Mac. +Linux and older macOS users can verify zigler --demo end-to-end +following the same pattern as C/Rust above. + +--- + +## 19. Android NIF auto-wiring — port the iOS work for `--type {c, rustler, zigler}` deploys + +**Status:** Open, ready for a fresh session. iOS pattern landed in +issues #18 + #15 (both FIXED 2026-05-13); Android is the parallel +work that mirrors it for the `aarch64-linux-android` target. + +### Goal + +End-to-end verification of all three demo scaffolds on a physical +Android device (or emulator): + + $ mix mob.add_nif greet_c --type c --demo --yes + $ mix mob.add_nif greet_rust --type rustler --demo --yes + $ mix mob.add_nif greet_zig --type zigler --demo --yes + $ mix mob.deploy --native --android + $ # via Mob.Test from a Mac-side IEx: + $ Mob.Test.tap(node, :run); Mob.Test.assigns(node).result + ~c"Hello from C!" # or "Hello from Rust!" / "Hello from Zig!" + +Plus the matching `[info] [<name>-nif] call 1 returned: ...` +Logger line visible in `adb logcat` (or via dist to a Mac-side +IEx — see CLAUDE.md for `mix mob.connect` setup). + +`moto e` and the `sdk_gphone64_arm64` emulators are typically +connected in this workspace. Prefer the physical `moto e` because +emulators sometimes mask SELinux/dlopen quirks (see issue #10). + +### Reference: where the iOS pattern lives + +The iOS work is a clean template to mirror. Read these first: + +- **mob_dev cross-compile helpers** — + `lib/mob_dev/native_build.ex`: + - `project_nif_user_entries/0` (filters out baked-in NIFs) + - `classify_project_nif/2` — returns + `{:c, _} | {:rust, _} | {:zig, _} | :elixir_only` + - `cross_compile_rust_nifs/2` + `rust_target_for(:android)` → + `"aarch64-linux-android"` (already wired, just not invoked) + - `cross_compile_zig_nifs/2` + `zig_build_target_for(:android)` + → `"aarch64-linux-android"` (same — wired but unused) + - `project_nif_zig_args/1` — gathers everything and emits + `-Dproject_root=`, `-Dproject_c_nifs=`, `-Dproject_rust_libs=` + +- **iOS build template consumer** — + `mob_new/priv/templates/mob.new/ios/build_device.zig.eex` + + the matching `ios/build.zig.eex` for sim: + - Reads the `-D` options + - Iterates `project_c_nifs` and emits `addCObject` per name + with `-DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME=<name>` + - Appends each `project_rust_libs` `.a` to the linker line + +- **Apple-SDK plumbing for Zigler cImport** — + GenericJam/zigler fork `zig-016-port`, commit `e2a4c19`. Adds + `-Dapple_sdkroot=...` build option used by the build template + to `addSystemIncludePath` on the `erl_nif` module. + +### Android-specific surface (what needs to change) + +Android uses a different build stack: **Gradle → CMake → NDK +clang**, plus its own `build.zig` for the BEAM library +(mob's Phase 2 work moved most of the native build into +`zig build`). Each layer is parallel-but-different from iOS. + +**1. `cross_compile_*_nifs` already handles `:android` — just +hook them in.** mob_dev currently only calls +`project_nif_zig_args` from `zig_build_binary_ios_device` and +`zig_build_binary_ios_sim`. Add a parallel call from +`run_zig_android_objects` (line ~184 of native_build.ex) so the +flags propagate to the Android `zig build` invocation. + +**2. Rust prerequisites the scaffold doesn't yet check.** + - `rustup target add aarch64-linux-android` + - `cargo-ndk` *or* manual NDK linker env vars + (`CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER` etc.) so cargo + can find the NDK's `aarch64-linux-android<API>-clang` + - `mix mob.doctor` should warn if missing — same shape as the + iPhone-target check filed in #18. + +**3. Zigler fork needs an `android_sdkroot` companion to +`apple_sdkroot` — IF NEEDED. Verify first.** Zig 0.16's +`aarch64-linux-android` target may already bundle Bionic libc +headers (unlike Apple, where `cImport` truly needs the SDK +headers). Run the cross-compile FIRST and see what (if anything) +fails on cImport. If headers are bundled, this step is a no-op. + + If needed: same insertion site + (`zigler/lib/zig/templates/build_mod.zig.eex`), same mechanism + as `apple_sdkroot`. mob_dev's `ndk_sysroot/0` (line ~216 of + native_build.ex) already resolves the NDK path — pass it as + `-Dandroid_sdkroot=<path>` to Zigler's build. Probably ~15 + lines in the fork; lands alongside the existing + `apple_sdkroot` commit (e2a4c19). + +**4. `android/app/src/main/jni/build.zig.eex` consumes the +project NIF flags.** This is the Android counterpart to +`ios/build_device.zig.eex`. The template (in mob_new) iterates +`project_c_nifs` to emit objects into `zig-out/<abi>/` and adds +each `project_rust_libs` `.a` to the final link. Same shape as +the iOS template; different file. + +**5. `CMakeLists.txt.eex` fallback paths.** The Android +CMakeLists template has three paths +(`mob_new/priv/templates/mob.new/android/app/src/main/jni/CMakeLists.txt.eex`, +lines ~28-60): + + 1. zig-built `lib<app>.so` in `jniLibs/<abi>/` — Gradle picks + it up directly (the happy path under mob) + 2. zig-built `.o` files in `zig-out/<abi>/` — CMake links them + 3. `.c` sources fallback — CMake compiles via NDK clang + + Paths 1 and 2 are covered by step 4. Path 3 is for non-Mix + invocations (Android Studio "Sync Project", standalone + `./gradlew assembleDebug`) — emit `target_sources` for each + `c_src/<name>.c` with the right `-DSTATIC_ERLANG_NIF` flags. + +**6. Symbol naming + static-NIF table.** Same as iOS: +`ERL_NIF_INIT(Elixir.<DotPath>, ...)` with +`-DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME=<name>` for C; +rustler 0.37+ auto-derives `<crate>_nif_init` for Rust; Zigler +fork's `-Dnif_init_alias=<name>_nif_init` for Zig. No new +mechanism — these all already work; just need to be **invoked** +from the Android build. + +**7. `driver_tab_android.zig` regeneration.** +`mix mob.regen_driver_tab` already handles this. The generated +table declares `<name>_nif_init` for every entry in `mob.exs +:static_nifs` — verified via `priv/generated/driver_tab_android.zig` +after `mix mob.add_nif greet_c --type c --demo --yes`. No change. + +### Likely gotchas + +- **SELinux on Android 17+** (issue #10) — physical device may + refuse `dlopen`/`execve` of certain `lib*.so` files. NIFs + going through the static-table path should be unaffected + (they're in the main `.so`), but worth a sanity check on the + `moto e` if anything weird shows up. + +- **JNI symbol stripping** — Android `--gc-sections` strips + unreferenced symbols aggressively. The existing + `enif_keepalive` table covers BEAM's `enif_*` API; verify it + also covers any symbols the project NIFs introduce. + +- **Multi-ABI** — mob currently builds both `arm64-v8a` AND + `armeabi-v7a` (see `zig_build_android_objects` loop). Project + NIFs need to cross-compile for both. The Rust 32-bit target is + `armv7-linux-androideabi`; Zig's `arm-linux-androideabi`. + Plumbing both ABIs may be the longest pole — **start with + arm64 only for the demo**, file 32-bit as a follow-up. + +- **rustup targets may not be installed.** Run + `rustup target add aarch64-linux-android` early and surface a + clear error if it fails. Sequester it from the user's iOS-only + Rust setup if they have one. + +- **`cargo-ndk` vs raw env vars.** `cargo-ndk` simplifies path + resolution but adds a tool dep. Raw env vars (e.g. + `CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER`) avoid the dep but + are more brittle. Pick one approach and document the choice in + the scaffold's moduledoc + `mob.doctor`. + +### Scope guardrails + +**In scope:** +- iOS-pattern parity for `arm64-v8a` (most users) +- All three `--demo` flows end-to-end on a connected Android +- mob_dev plumbing + mob_new template changes +- Zigler fork's `android_sdkroot` option *if needed* (verify first) + +**Out of scope (file as follow-ups):** +- `armeabi-v7a` (32-bit) cross-compile — arm64 first +- `mix mob.doctor` prerequisite checks for rustup targets / NDK +- Pythonx on Android — Python bundling is its own thing + (`nif_future.md` issue #2) + +### Done definition + +- All three `--demo` scaffolds (`c`, `rustler`, `zigler`) deploy + to a real Android device via `mix mob.deploy --native --android` + with **zero hand-editing** of CMakeLists.txt, build.zig, or + Cargo.toml. +- `Mob.Test.tap(node, :run)` on each demo screen returns the + expected greeting on Android the same way it does on iPhone + (verified for iOS in issues #15 + #18). +- Logger output `[info] [<name>-nif] call 1 returned: ...` + visible in `adb logcat` (or via dist to Mac-side IEx). +- Tests + credo clean in mob_dev. + +### Verification recipe + + $ # Prerequisites + $ rustup target add aarch64-linux-android + $ # (verify NDK is configured — android/local.properties has sdk.dir + $ # and either ndk.dir or ANDROID_NDK_HOME) + $ mix mob.doctor # should report no missing prerequisites + + $ cd ~/code/test_migration # known-good scratch project + $ rm -rf lib/test_migration/nifs native c_src # clean slate + $ # reset mob.exs to drop old :static_nifs entries + + $ # Scaffold all three demos + $ for type in c rustler zigler; do + mix mob.add_nif greet_$type --type $type --demo --yes + done + + $ # Build + deploy + $ mix mob.deploy --native --android + + $ # Drive each demo via dist + $ # Node name is `test_migration_android_<suffix>@127.0.0.1`; + $ # `mix mob.devices` lists exact names per attached device. + $ # See CLAUDE.md → "Connecting an IEx session to a running mob app" + $ # for the dist setup. mob/lib/mob/test.ex is the harness. + +### Suggested commit shape + +One logical PR per layer so each is independently verifiable: + +1. **mob_dev**: invoke `project_nif_zig_args` from the Android + build path. Confirms cross-compiles run (they may not link + yet because templates don't consume the args). +2. **mob_new**: `android/app/src/main/jni/build.zig.eex` + consumes `-Dproject_c_nifs` / `-Dproject_rust_libs`. Confirms + C demo works. +3. **mob_new**: `CMakeLists.txt.eex` mirrors for the fallback + paths (Studio sync + standalone gradle). Confirms emulator + + Studio builds. +4. *(if verified-needed)* **GenericJam/zigler fork**: + `-Dandroid_sdkroot=...`. Confirms Zig demo works. +5. **issues.md**: flip #19 to FIXED with empirical results. + +### Related issues to read first + +- **#18** — iOS auto-wiring, the pattern to mirror +- **#15** — Zigler fork (the four fork patches the agent will + extend with `android_sdkroot` if needed) +- **#10** — Android 17 SELinux constraints (open; may or may not + affect this work) +- **CLAUDE.md** in mob — Android deploy / multi-device / dist-port + story; required reading +- **mob_dev/AGENTS.md** — TDD discipline, "tests cover everything" + including build helpers diff --git a/lib/mix/tasks/erlfmt.ex b/lib/mix/tasks/erlfmt.ex new file mode 100644 index 00000000..ea2fd159 --- /dev/null +++ b/lib/mix/tasks/erlfmt.ex @@ -0,0 +1,111 @@ +defmodule Mix.Tasks.Erlfmt do + @moduledoc """ + Format `.erl` files (or check formatting with `--check`). + + Wraps `erlfmt`'s library API. Exists because the upstream `erlfmt` Hex package + ships an escript build but no `mix` task; the project's pre-commit checklist + (`CLAUDE.md`) references `mix erlfmt --check src/` so this task makes that + instruction actually work. + + ## Usage + + mix erlfmt --check src/ # exit 0 if clean, exit 1 if any file would change + mix erlfmt --write src/ # rewrite files in place + + Either `--check` or `--write` is required. Paths can be files or directories; + directories are walked for `*.erl` files. + """ + + use Mix.Task + + @shortdoc "Format Erlang sources via erlfmt" + + @impl Mix.Task + def run(args) do + {opts, paths} = + OptionParser.parse!(args, + strict: [check: :boolean, write: :boolean], + aliases: [c: :check, w: :write] + ) + + if !opts[:check] and !opts[:write] do + Mix.raise("mix erlfmt requires --check or --write") + end + + if paths == [], do: Mix.raise("mix erlfmt requires at least one path") + + # erlfmt is `only: :dev, runtime: false` in mob's mix.exs, so when + # mob is compiled as a dep of a downstream project (without erlfmt + # in *their* deps), the static reference would warn. Resolve at + # runtime via apply so the compiler doesn't complain — and surface + # a clean error if the dep really isn't on the path. + unless Code.ensure_loaded?(:erlfmt) do + Mix.raise( + "mix erlfmt requires the `:erlfmt` dep — add `{:erlfmt, \"~> 1.8\", " <> + "only: :dev, runtime: false}` to your project's mix.exs and rerun `mix deps.get`." + ) + end + + Application.ensure_all_started(:erlfmt) + + files = Enum.flat_map(paths, &collect_erl_files/1) + + {ok_count, changed} = + Enum.reduce(files, {0, []}, fn file, {ok, changed} -> + case apply(:erlfmt, :format_file, [String.to_charlist(file), [:return]]) do + {:ok, formatted, _warnings} -> + original = File.read!(file) + # erlfmt returns iodata that may include codepoints > 255 (e.g. + # em-dashes inside strings/comments). `:unicode.characters_to_binary` + # handles those; `IO.iodata_to_binary` would crash with ArgumentError. + new = :unicode.characters_to_binary(formatted) + + cond do + new == original -> + {ok + 1, changed} + + opts[:write] -> + File.write!(file, new) + Mix.shell().info("formatted #{file}") + {ok + 1, changed} + + true -> + {ok, [file | changed]} + end + + {:skip, _} -> + {ok + 1, changed} + + {:error, reason} -> + Mix.shell().error("#{file}: #{inspect(reason)}") + {ok, [file | changed]} + end + end) + + cond do + changed == [] -> + Mix.shell().info("erlfmt: #{ok_count} file(s) checked, all formatted") + :ok + + opts[:check] -> + Mix.shell().error( + "erlfmt: #{length(changed)} file(s) need formatting:\n " <> + Enum.join(changed, "\n ") <> + "\n\nRun `mix erlfmt --write <path>` to fix." + ) + + exit({:shutdown, 1}) + + true -> + :ok + end + end + + defp collect_erl_files(path) do + cond do + File.dir?(path) -> Path.wildcard("#{path}/**/*.erl") + File.regular?(path) and String.ends_with?(path, ".erl") -> [path] + true -> [] + end + end +end diff --git a/lib/mob.ex b/lib/mob.ex index 315de265..b2c19dfb 100644 --- a/lib/mob.ex +++ b/lib/mob.ex @@ -42,4 +42,44 @@ defmodule Mob do defdelegate assign(socket, key, value), to: Mob.Socket defdelegate assign(socket, kw), to: Mob.Socket + + @doc """ + A writable, app-private directory for runtime data — DB files, caches, + downloaded assets, anything you write at runtime. + + On device this is `MOB_DATA_DIR`, set by the BEAM launcher to the platform's + persistent app-private location (iOS `NSDocumentDirectory`, Android + `getFilesDir()`). Off device (host/dev/tests) it falls back to `$HOME`, then + the current working directory. The directory is created if missing. + + Use this — **not** `MOB_BEAMS_DIR`. `MOB_BEAMS_DIR` points inside the signed, + read-only `.app` bundle on iOS, so writing there fails with `:eperm`; it + happens to be writable on Android, which is how that trap stays hidden until + an app ships to iOS. + + path = Path.join(Mob.data_dir(), "my.db") + + See `data_dir/1` for a created subdirectory. + """ + @spec data_dir() :: String.t() + def data_dir do + dir = + System.get_env("MOB_DATA_DIR") || + System.get_env("HOME") || + File.cwd!() + + File.mkdir_p!(dir) + dir + end + + @doc """ + Like `data_dir/0` but returns (and creates) the `sub` directory beneath it, + e.g. `Mob.data_dir("audio_cache")`. + """ + @spec data_dir(String.t()) :: String.t() + def data_dir(sub) when is_binary(sub) do + dir = Path.join(data_dir(), sub) + File.mkdir_p!(dir) + dir + end end diff --git a/lib/mob/app.ex b/lib/mob/app.ex index f3ec68ab..ccba0c8e 100644 --- a/lib/mob/app.ex +++ b/lib/mob/app.ex @@ -50,10 +50,25 @@ defmodule Mob.App do defmacro __using__(opts) do theme_opts = Keyword.get(opts, :theme, []) + # The host OTP app name, captured at compile time. A mob app boots via a + # custom BEAM entry (not `Application.start`), so `Application.get_application/1` + # returns nil at runtime — but the host module compiles inside its own Mix + # project, so `Mix.Project.config[:app]` is the reliable source. Used to find + # the host's `priv/generated/mob_plugins.exs` (the tier-3/4 runtime manifest). + otp_app = + Keyword.get(opts, :otp_app) || + try do + Mix.Project.config()[:app] + rescue + _ -> nil + end + quote do @behaviour Mob.App import Mob.App + @mob_otp_app unquote(otp_app) + @doc """ Framework entry point — called from the BEAM entry module (e.g. `mob_demo.erl`) after OTP applications have started. @@ -66,6 +81,17 @@ defmodule Mob.App do Do not override — implement `on_start/0` instead. """ def start do + # iOS-only: BEAM's default :native hostname lookup spawns the + # `inet_gethost` port program via execve, which the iOS app + # sandbox refuses. Any subsequent code path that resolves a + # hostname — Node.connect, :erpc.call, gen_tcp.connect with a + # binary host, Logger forwarding to a named node — crashes the + # calling process with badarg before this is fixed. Switch to + # file-only lookup and seed `localhost` so distribution and + # local TCP work out of the box. Apps that need real outbound + # DNS layer Mob.DNS.configure_pure_beam/1 on top in on_start/0. + Mob.App.configure_ios_inet_db() + Mob.NativeLogger.install() # Compile theme from options passed to `use Mob.App, theme: [...]` @@ -78,6 +104,10 @@ defmodule Mob.App do {:error, {:already_started, _}} -> :ok end + # Load the activated plugins' tier-3/4 runtime manifest and register + # their screens into the nav registry (no-op when none are active). + Mob.Plugins.boot(@mob_otp_app) + case Mob.State.start_link() do {:ok, _} -> :ok {:error, {:already_started, _}} -> :ok @@ -114,6 +144,15 @@ defmodule Mob.App do {:error, {:already_started, _}} -> :ok end + # Start the tier-4 plugins' lifecycle (on_start MFAs, supervised + # children, fore/background dispatcher) BEFORE the host's own on_start. + # The framework services a plugin's on_start depends on (State, Device, + # ComponentRegistry, …) are already up; running here means a host + # on_start that never returns (e.g. one that blocks on a run loop — + # observed on iOS via Mob.Dist.ensure_started) can't starve plugin + # startup. No-op when no plugin declares a :lifecycle. + Mob.Plugins.start_lifecycle() + __MODULE__.on_start() end @@ -123,6 +162,58 @@ defmodule Mob.App do end end + @doc """ + Apply the iOS-only `:inet_db` workaround so distribution, RPC, and + TCP-by-hostname don't crash on the first lookup. + + iOS sandboxes any app that isn't Apple's own and refuses `execve` of + binaries the app didn't get a special pass for. BEAM's default + `:native` hostname-resolution path spawns the `inet_gethost` port + program — exactly the kind of `execve` iOS rejects — so the very + first `:inet.getaddr/2` call (transitively reached by `Node.connect`, + `:erpc.call`, `gen_tcp.connect/3` with a binary host, etc.) crashes + the calling process with `:badarg`. The simulator hits the same + failure for a related but distinct reason: `inet_gethost` doesn't + live at the path BEAM expects under the mob iOS sim OTP layout. + Either way, the fix is the same. + + Switching the lookup chain to `[:file]` keeps everything in BEAM's + in-process name table — no port program, no fork, no `execve`. We + also seed `localhost` so apps using `@localhost` node names (or any + `gen_tcp` call that resolves `"localhost"`) work without further + setup. + + Called automatically by the macro-generated `start/0` before + anything else, so app `on_start/0` code never has to think about it. + Apps that need outbound DNS (Req / Finch / Mint to arbitrary hosts) + can layer `Mob.DNS.configure_pure_beam/1` on top — it upgrades the + chain to `[:file, :dns]` and seeds fallback nameservers, while the + file-table entries we add here keep winning. + + Other platforms (`:android`, `:host`) are unaffected — BEAM's native + resolver works there. Safe to call on host BEAM where the NIF isn't + loaded; rescues the `UndefinedFunctionError` / `ErlangError` and + returns `:ok`. + """ + @spec configure_ios_inet_db() :: :ok + def configure_ios_inet_db do + case safe_platform() do + :ios -> + :inet_db.set_lookup([:file]) + :inet_db.add_host({127, 0, 0, 1}, [~c"localhost"]) + :ok + + _ -> + :ok + end + end + + defp safe_platform do + :mob_nif.platform() + rescue + _ in [UndefinedFunctionError, ErlangError] -> :host + end + # ── Navigation helpers ───────────────────────────────────────────────────── @doc """ diff --git a/lib/mob/audio.ex b/lib/mob/audio.ex index 2b180ce3..02df5297 100644 --- a/lib/mob/audio.ex +++ b/lib/mob/audio.ex @@ -3,6 +3,12 @@ defmodule Mob.Audio do Microphone recording and audio playback. Recording requires `:microphone` permission (`Mob.Permissions.request/2`). + iOS additionally needs `NSMicrophoneUsageDescription` in + `Info.plist`; Android needs `RECORD_AUDIO` in + `AndroidManifest.xml`. The default `mix mob.new` templates ship + both. See the [permissions guide](permissions.html) for the + cross-platform table. + Playback requires no permission. ## Recording @@ -22,10 +28,54 @@ defmodule Mob.Audio do # → handle_info({:audio, :playback_error, %{reason: reason}}, socket) iOS: `AVAudioPlayer` / `AVPlayer`. Android: `MediaPlayer`. + + ## Output probes — is sound actually working? + + Two read-only probes answer "is audio coming out right now," the audio + analog of `Mob.Test`'s in-process `screenshot/2` for video. Use them in + tests and agent-driven verification. + + Mob.Audio.output_status() + # => %{volume: 0.8, muted: false, route: :speaker, other_audio: false} + + Mob.Audio.play(socket, "blip.wav") + Mob.Audio.output_level(source: :mob) + # => {-18.4, -6.1} # {rms_db, peak_db}, or :silent + + `output_status/0` is a cheap, permission-free read of the system audio + config (volume, mute, route). It catches the common "no sound" causes: + muted, volume 0, routed to a disconnected sink. `output_level/1` reads + actual signal energy so you can tell live audio from pushed silence — the + part `output_status` (and `adb dumpsys audio`) cannot answer. + + `output_level/1` takes a `:source`: + + - `:mob` (default) — meters `Mob.Audio`'s own player. iOS reads the + `AVAudioPlayer` meter (free, no permission); Android attaches a + `Visualizer` to the player's own audio session (needs `RECORD_AUDIO`, + granted at runtime — without it you get `{:error, :needs_record_audio}`). + Returns `{:error, :not_playing}` when no `Mob.Audio` playback is active. + - `:mix` — *would* tap the global output mix to observe audio that bypasses + `Mob.Audio` (a game's own `AudioTrack`, another app). This is **not + available to a normal app**: iOS forbids it (sandbox) and modern Android + treats a session-0 `Visualizer` as privileged (`ERROR_NO_INIT` even with + `RECORD_AUDIO`). So `:mix` returns `{:error, :unsupported_on_platform}` + on both platforms. Global device-audio capture lives in a separate, + MediaProjection-based capture plugin intended as a test-environment + dependency, not here. + + So in-app these probes verify *your own* audio. To check audio from a + foreign native player (e.g. a bundled game) without that plugin, read + `adb shell dumpsys media.audio_flinger` (active track + underrun counts). + + Metering is instantaneous and only meaningful while audio is playing, so + the idiom is `play → sleep a beat → output_level`. """ @type format :: :aac | :wav @type quality :: :low | :medium | :high + @type route :: :speaker | :headphones | :bluetooth | :receiver | :none | :unknown + @type level_source :: :mix | :mob @doc """ Start recording audio from the microphone. @@ -59,6 +109,49 @@ defmodule Mob.Audio do socket end + @doc """ + Start metering the microphone input level — without recording to a file. + + The agent-facing "ears" primitive: poll `input_level/0` to detect whether the + device is producing sound (e.g. an agent loop "keep going until you hear + sound"). The mic picks up the device's own speaker, so it registers audio from + any source. Shares the mic session with recording — don't run both at once. + + Requires `:microphone` permission (same as recording). + """ + @spec start_input_metering(Mob.Socket.t()) :: Mob.Socket.t() + def start_input_metering(socket) do + :mob_nif.audio_start_input_metering() + socket + end + + @doc """ + Read the current microphone input level as `{rms_db, peak_db}` (dBFS), `:silent` + when there is no measurable signal, or `{:error, reason}`. + + `start_input_metering/1` must be active first, else `{:error, :not_metering}`. + Same `{rms, peak} | :silent` shape as `MobAudioCapture.output_level/0`, so the + `:mic` source (here) and the `:output` source (mob_audio_capture) read uniformly. + """ + @spec input_level() :: {float(), float()} | :silent | {:error, atom()} + def input_level do + decode_level(:mob_nif.audio_input_level()) + end + + @doc "Stop microphone input metering." + @spec stop_input_metering(Mob.Socket.t()) :: Mob.Socket.t() + def stop_input_metering(socket) do + :mob_nif.audio_stop_input_metering() + socket + end + + @doc false + @spec decode_level(term()) :: {float(), float()} | :silent | {:error, atom()} + def decode_level({_rms, peak}) when peak <= -120.0, do: :silent + def decode_level({rms, peak}), do: {rms, peak} + def decode_level(reason) when is_atom(reason), do: {:error, reason} + def decode_level(_), do: {:error, :unknown} + @doc """ Play an audio file. Stops any currently playing audio first. @@ -98,4 +191,127 @@ defmodule Mob.Audio do :mob_nif.audio_set_volume(volume / 1.0) socket end + + @doc """ + Schedule `path` to begin playing at absolute local wall-clock time + `at_wall_ms` (in `System.system_time(:millisecond)` terms — caller is + responsible for translating from a server-supplied target time via their + own clock-sync component). + + The audio hardware clock — not BEAM's timer wheel — fires playback at + the requested instant. Multiple `play_at/3` calls accumulate on the + player's timeline (call `stop_playback/1` to flush). If `at_wall_ms` is + already in the past, the buffer plays as soon as the audio engine can. + + Options: + - `volume: float 0.0–1.0` (default `1.0`) + + Result arrives as `{:audio, :playback_finished, %{path: path}}` when + the scheduled buffer drains, or `{:audio, :playback_error, + %{reason: reason}}` if the file fails to open. + + iOS: `AVAudioEngine` + `AVAudioPlayerNode.scheduleBuffer(_:at:options:)`, + with the `at:` `AVAudioTime` constructed from `mach_absolute_time` so the + buffer starts at the requested host time. + + Android: TODO — falls back to immediate playback on Android until the + AAudio port lands. + """ + @spec play_at(Mob.Socket.t(), String.t(), integer(), keyword()) :: Mob.Socket.t() + def play_at(socket, path, at_wall_ms, opts \\ []) when is_integer(at_wall_ms) do + # at_wall_ms is shipped as a binary string. ms-since-epoch values exceed + # the 32-bit range that mob's Android ERTS build can read via + # `enif_get_int`, and the `enif_get_int64` symbol isn't dynamically + # exported on that build. Strings cross both NIF boundaries cleanly. + :mob_nif.audio_play_at( + path, + :json.encode(play_at_opts(opts)), + Integer.to_string(at_wall_ms) + ) + + socket + end + + @doc false + @spec play_at_opts(keyword()) :: %{String.t() => term()} + def play_at_opts(opts) do + %{"volume" => Keyword.get(opts, :volume, 1.0) * 1.0} + end + + @doc """ + Read the current system audio output configuration. + + Returns `%{volume: float, muted: boolean, route: route(), other_audio: + boolean}`. Cheap, synchronous, no permission. The first thing to check + when verifying sound: a `volume` of `0.0`, `muted: true`, or a `route` of + `:none` explains silence regardless of what a player is doing. + + `volume` is normalized 0.0–1.0 (the media stream volume on Android, + `AVAudioSession.outputVolume` on iOS). `route` is the active output sink. + `other_audio` is true when another app is already playing (iOS + `isOtherAudioPlaying` / Android `isMusicActive`). + """ + @spec output_status() :: %{ + volume: float(), + muted: boolean(), + route: route(), + other_audio: boolean() + } + def output_status do + decode_status(:mob_nif.audio_output_status()) + end + + @doc false + @spec decode_status(term()) :: %{ + volume: float(), + muted: boolean(), + route: route(), + other_audio: boolean() + } + def decode_status({volume, muted, route_code, other_audio}) do + %{ + volume: volume, + muted: muted >= 0.5, + route: decode_route(route_code), + other_audio: other_audio >= 0.5 + } + end + + def decode_status(_), do: %{volume: 0.0, muted: false, route: :unknown, other_audio: false} + + @doc """ + Read the current output signal level as `{rms_db, peak_db}` (dBFS, e.g. + `{-18.0, -6.0}`), or `:silent` when there is no measurable signal. + + This is the probe that distinguishes live audio from pushed silence — the + one thing `output_status/0` and `adb dumpsys audio` cannot tell you. + Metering is instantaneous and only valid while audio plays, so call it as + `play → sleep a beat → output_level`. + + Options: + - `source: :mob` (default) — meters `Mob.Audio`'s own player. Android needs + `RECORD_AUDIO` (runtime-granted); iOS uses `AVAudioPlayer` metering. + - `source: :mix` — the global output mix. Unsupported for a normal app on + both platforms (see module docs); use the separate capture plugin. + + Returns `{:error, reason}` when unavailable: `:not_playing` (no active + `Mob.Audio` player), `:needs_record_audio` (Android, permission not granted + at runtime), or `:unsupported_on_platform` (`:mix`). + """ + @spec output_level(keyword()) :: {float(), float()} | :silent | {:error, atom()} + def output_level(opts \\ []) do + source = Keyword.get(opts, :source, :mob) + decode_level(:mob_nif.audio_output_level(Atom.to_string(source))) + end + + # Native side returns a numeric route code (kept numeric to avoid building + # atoms in C/Zig); decode here. + @spec decode_route(number()) :: route() + defp decode_route(1), do: :speaker + defp decode_route(2), do: :headphones + defp decode_route(3), do: :bluetooth + defp decode_route(4), do: :receiver + defp decode_route(0), do: :none + defp decode_route(code) when is_float(code), do: decode_route(round(code)) + defp decode_route(_), do: :unknown end diff --git a/lib/mob/background.ex b/lib/mob/background.ex deleted file mode 100644 index 7763edb2..00000000 --- a/lib/mob/background.ex +++ /dev/null @@ -1,134 +0,0 @@ -defmodule Mob.Background do - @moduledoc """ - Background execution keep-alive via a silent audio session. - - iOS suspends apps when the screen locks unless they hold an active background - execution mode. `keep_alive/0` starts a silent `AVAudioEngine` looping a - zero-filled buffer with `AVAudioSessionCategoryOptionMixWithOthers` — the OS - sees an active audio session and keeps the process running, the user hears - nothing, and any music already playing is undisturbed. - - ## Requirements - - The app's `Info.plist` must declare the `audio` background mode: - - <key>UIBackgroundModes</key> - <array> - <string>audio</string> - </array> - - This is included in all projects generated by `mix mob.new`. For Xcode - projects, add it under *Signing & Capabilities → Background Modes → - Audio, AirPlay, and Picture in Picture*. - - ## Usage - - # Keep the app alive when the screen locks (e.g. in mount/2): - Mob.Background.keep_alive() - - # Allow suspension again when background execution is no longer needed: - Mob.Background.stop() - - `keep_alive/0` is idempotent — safe to call multiple times. - - ## Coexistence with Mob.Audio - - **Playback** (`Mob.Audio.play/3`): both sides use `MixWithOthers`, so they - mix transparently. The silent buffer is inaudible alongside real audio. - - **Recording** (`Mob.Audio.start_recording/2`): recording switches the global - `AVAudioSession` category to `PlayAndRecord`, which sends an interruption to - the keep-alive engine. The engine stops — but the recording itself holds an - active audio session, so the app stays alive for the duration of the - recording. When `stop_recording/1` is called and the session is released, iOS - fires `AVAudioSessionInterruptionTypeEnded` and the keep-alive engine restarts - automatically. No Elixir code is needed to handle this transition. - - The same automatic restart applies to phone calls and any other event that - temporarily takes the audio session away from the app. - - ## Known limitation — observer cleanup - - Internally, the NIF registers an `NSNotificationCenter` observer for - `AVAudioSessionInterruptionNotification` to handle the recording restart - described above. When `stop/0` is called, it removes observers using - `removeObserver:nil` scoped to that notification name, which removes *all* - observers for that notification in the process — not just the one registered - by this module. - - In practice this is harmless because mob owns the audio session for all - `Mob.Audio` functions, and none of them rely on surviving this cleanup. - However, if you integrate a third-party audio library that registers its own - `AVAudioSessionInterruptionNotification` observer, calling `stop/0` will - silently remove that observer too. In that case, avoid calling `stop/0` while - the third-party library is active, or file an issue so the NIF can be updated - to store and remove only its own observer token. - - ## Apple's stance - - Apple permits the `audio` background mode for apps that legitimately use - audio. Mob apps that use `Mob.Audio` recording or playback qualify. Apple - will reject apps that declare this mode without any audio feature — do not - add `UIBackgroundModes: [audio]` to an app that has no audio functionality. - - ## Android — Foreground Service - - On Android the OS equivalent of iOS background execution is a *foreground - service*. `keep_alive/0` starts `BeamForegroundService`, which calls - `startForeground/2` with a low-priority persistent notification. The OS - will not kill a foreground service under memory pressure and will not - pause the process when the screen locks. - - ### Visible notification (required by Android) - - Android requires every foreground service to post a visible notification. - The notification appears in the status bar and notification tray with the - app name and the text "Running in background". It has `IMPORTANCE_LOW` so - it produces no sound or vibration. There is no API to hide it — this is - an OS-level constraint designed to inform users when apps are running in - the background. - - ### Manifest requirements - - `mix mob.new` adds the necessary declarations automatically: - - <!-- AndroidManifest.xml --> - <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> - <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" - android:minSdkVersion="34" /> - - <service android:name=".BeamForegroundService" - android:exported="false" - android:foregroundServiceType="dataSync" /> - - For apps created before this feature was added, copy the above snippet - manually into your `AndroidManifest.xml`. - - ### Stop behaviour - - `stop/0` sends `ACTION_STOP` to the service, which calls `stopForeground` - and `stopSelf`. The OS removes the notification immediately. If the BEAM - node goes silent (no incoming distribution traffic) the OS may still - eventually kill the process — `keep_alive/0` prevents aggressive - *background process killing* but not an eventual idle OOM kill after many - hours of complete inactivity. - """ - - @doc """ - Starts a silent audio session to prevent iOS from suspending the app - when the screen locks. Idempotent — safe to call more than once. - """ - @spec keep_alive() :: :ok - def keep_alive do - :mob_nif.background_keep_alive() - end - - @doc """ - Stops the keep-alive audio session and allows iOS to suspend the app - normally when it goes to background. - """ - @spec stop() :: :ok - def stop do - :mob_nif.background_stop() - end -end diff --git a/lib/mob/biometric.ex b/lib/mob/biometric.ex deleted file mode 100644 index cc006ea4..00000000 --- a/lib/mob/biometric.ex +++ /dev/null @@ -1,28 +0,0 @@ -defmodule Mob.Biometric do - @moduledoc """ - Biometric authentication (Face ID / Touch ID / fingerprint). - - No permission dialog is shown — uses the device's existing biometric enrollment. - - Mob.Biometric.authenticate(socket, reason: "Confirm payment") - - Result arrives as: - - handle_info({:biometric, :success}, socket) - handle_info({:biometric, :failure}, socket) - handle_info({:biometric, :not_available}, socket) - - `:not_available` is returned if the device has no biometric hardware or the - user has not enrolled any biometrics. - - iOS: `LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, ...)` - Android: `BiometricPrompt` - """ - - @spec authenticate(Mob.Socket.t(), keyword()) :: Mob.Socket.t() - def authenticate(socket, opts \\ []) do - reason = Keyword.get(opts, :reason, "Authenticate") - :mob_nif.biometric_authenticate(reason) - socket - end -end diff --git a/lib/mob/camera.ex b/lib/mob/camera.ex deleted file mode 100644 index 3d3ad4d2..00000000 --- a/lib/mob/camera.ex +++ /dev/null @@ -1,64 +0,0 @@ -defmodule Mob.Camera do - @moduledoc """ - Native camera capture for photos and videos. - - Requires `:camera` permission (and `:microphone` for video). - - Opens the native OS camera UI. Results arrive as: - - handle_info({:camera, :photo, %{path: path, width: w, height: h}}, socket) - handle_info({:camera, :video, %{path: path, duration: seconds}}, socket) - handle_info({:camera, :cancelled}, socket) - - The `path` is a local temp file. Copy it elsewhere before the next capture. - - iOS: `UIImagePickerController`. Android: `TakePicture` / `CaptureVideo` activity contracts. - """ - - @doc """ - Open the camera to capture a photo. - - Options: - - `quality: :high | :medium | :low` (default `:high`) — JPEG compression level - """ - @spec capture_photo(Mob.Socket.t(), keyword()) :: Mob.Socket.t() - def capture_photo(socket, opts \\ []) do - quality = Keyword.get(opts, :quality, :high) - :mob_nif.camera_capture_photo(quality) - socket - end - - @doc """ - Open the camera to record a video. - - Options: - - `max_duration: integer` — maximum clip length in seconds (default `60`) - """ - @spec capture_video(Mob.Socket.t(), keyword()) :: Mob.Socket.t() - def capture_video(socket, opts \\ []) do - max_duration = Keyword.get(opts, :max_duration, 60) - :mob_nif.camera_capture_video(max_duration) - socket - end - - @doc """ - Start a live camera preview session. Pair with a `:camera_preview` component - in your render tree to display the feed. - - Options: - - `facing: :back | :front` (default `:back`) - """ - @spec start_preview(Mob.Socket.t(), keyword()) :: Mob.Socket.t() - def start_preview(socket, opts \\ []) do - facing = Keyword.get(opts, :facing, :back) |> Atom.to_string() - :mob_nif.camera_start_preview(:json.encode(%{"facing" => facing})) - socket - end - - @doc "Stop the active camera preview session." - @spec stop_preview(Mob.Socket.t()) :: Mob.Socket.t() - def stop_preview(socket) do - :mob_nif.camera_stop_preview() - socket - end -end diff --git a/lib/mob/canvas.ex b/lib/mob/canvas.ex index 7dcb38f8..efeec0fc 100644 --- a/lib/mob/canvas.ex +++ b/lib/mob/canvas.ex @@ -8,9 +8,63 @@ defmodule Mob.Canvas do raw strings ("#ff0000") — they are resolved by `Mob.Renderer` against the active theme before serialisation to the native side. - All coordinates are canvas-local in points/dp, top-left origin - (matches SwiftUI `Canvas` and Jetpack Compose `Canvas` natively, no - translation cost). + ## Coordinate system (important — read this once) + + All coordinates are **canvas-local logical units**, top-left origin. + The unit is whatever the host app's `<Canvas>` component declared + via the `width` and `height` props on the canvas — a draw op at + `(width / 2, height / 2)` lands in the dead centre of the rendered + canvas regardless of the canvas's actual on-screen pixel size. + + This deliberately differs from raw Compose `DrawScope.size` (which + is in pixels) and from raw SwiftUI `Canvas` (which is in points). + The renderer multiplies every coordinate by + `(actual_pixels / declared_logical_units)` per axis so callers + don't have to thread density and parent-constraint information + through every draw call. + + Practical consequence: a YOLO model that outputs bbox coords in + `0..640` can be drawn directly on a `<Canvas width=640 height=640>` + and the boxes will line up with the underlying preview image + regardless of the actual on-screen size or device density. + + See "Implementing the renderer" below for the contract the host + app's Kotlin / Swift `MobBridge` must honor. + + ## Implementing the renderer (host app's `MobBridge`) + + Mob ships no host-app code; each app's `MobBridge.kt` / + `MobBridge.swift` contains the Canvas renderer. The viewport-scaling + contract above is non-obvious and easy to get wrong — the original + per-app implementations interpreted coordinates as raw pixels, which + made bounding-box overlays drift on every device where 1 dp ≠ 1 px + (i.e., every modern Android device). Reference recipe for Compose: + + @Composable + private fun MobCanvas(node: MobNode, modifier: Modifier) { + val width = floatProp(node.props, "width") ?: 0f + val height = floatProp(node.props, "height") ?: 0f + val ops = ... // List<Map<String, Any?>> + + val sized = if (width > 0f && height > 0f) + modifier.size(width.dp, height.dp) else modifier + + Canvas(modifier = sized) { + // size.width / size.height are in PIXELS. + val sx = if (width > 0f) size.width / width else 1f + val sy = if (height > 0f) size.height / height else 1f + ops.forEach { op -> drawCanvasOp(op, sx, sy) } + } + } + + Every coord then passes through `coord * sx` / `coord * sy` in the + draw step. Scalar sizes (stroke widths, circle radii, text sizes) + use the average `(sx + sy) / 2` so they don't squash when the + declared viewport is non-square. + + See `nxeigen_probe`'s + `android/app/src/main/java/com/example/nxeigen_probe/MobBridge.kt` + for the full working implementation. ## Op map equivalence diff --git a/lib/mob/certs.ex b/lib/mob/certs.ex new file mode 100644 index 00000000..fd63206e --- /dev/null +++ b/lib/mob/certs.ex @@ -0,0 +1,110 @@ +defmodule Mob.Certs do + @moduledoc """ + CA-certificate loading for mob apps. Companion to `Mob.DNS` — same + shape: a small wrapper documenting and working around something OTP + assumes about the OS that Android doesn't satisfy. + + ## Why this exists + + `:public_key.cacerts_load/0` looks for a system CA bundle at one of + the distro paths it knows (`/etc/ssl/certs/ca-certificates.crt`, + `/etc/pki/tls/certs/ca-bundle.crt`, `/etc/ssl/cert.pem`, …). On + Android none of those exist — the system trust store lives behind a + Java API that BEAM's `:public_key` doesn't reach. Subsequent calls + to `:public_key.cacerts_get/0` therefore raise with `no_cacerts_found`, + and any library that consults it (Req → Mint → `:ssl`, Finch, anything + using OTP-26+ default `:ssl` opts) crashes on the first TLS connect. + + Adding insult: in some OTP versions `pubkey_os_cacerts.conv_error_reason/1` + has no clause for `no_cacerts_found`, so the surface error is a + `FunctionClauseError` — opaque to the unsuspecting reader. The fix is + the same regardless: load a PEM bundle into `:public_key` once at boot. + + Hex itself bakes its own DER bundle, so the BEAM can `mix.install/2` + without this fix; every other Elixir HTTP library can't. + + ## What to do + + Bundle a CA PEM in your app priv (e.g. copy `castore`'s `cacerts.pem`) + and call `Mob.Certs.load_cacerts!/1` once at boot, *before* anything + tries TLS: + + def on_start do + Mob.Certs.load_cacerts!(Application.app_dir(:my_app, "priv/cacerts.pem")) + # …rest of startup… + end + + The bundle is the app's choice — security: who do you trust. The + conventional source is the `castore` hex package (a current Mozilla + trust store), copied into `priv/` at build time. + + iOS isn't affected — Darwin exposes the trust store via the paths + Erlang knows about, so `:public_key.cacerts_load/0` (no arg) works + there. Calling `load_cacerts!/1` on iOS at the bundled-PEM path is a + harmless extra load; cross-platform apps can call it unconditionally. + + ## Scope + + - Loads CA certificates from a PEM file path. + - Wraps `:public_key.cacerts_load/1` so failure shapes are predictable + (`{:error, reason}` rather than the OTP-version-dependent + `FunctionClauseError` you sometimes see otherwise). + - Pure Elixir. No NIF, no platform branch. + """ + + @doc """ + Load CA certs from a PEM file into Erlang's `:public_key` cacert store. + + Idempotent: re-loading the same bundle just re-merges its certs into + the in-process trust store; no duplication, no error. + + Returns `:ok` on success or `{:error, reason}` if the file can't be + read or parsed. + + iex> Mob.Certs.load_cacerts("priv/cacerts.pem") + :ok + + """ + @spec load_cacerts(Path.t()) :: :ok | {:error, term()} + def load_cacerts(path) when is_binary(path) do + case :public_key.cacerts_load(String.to_charlist(path)) do + :ok -> :ok + {:error, _} = err -> err + end + end + + @doc """ + Same as `load_cacerts/1`, but raises on failure. + + Use this at boot when failing-to-load is unrecoverable — i.e. when the + app needs HTTPS at all to function. Most callers want this variant. + """ + @spec load_cacerts!(Path.t()) :: :ok + def load_cacerts!(path) when is_binary(path) do + case load_cacerts(path) do + :ok -> + :ok + + {:error, reason} -> + raise "Mob.Certs.load_cacerts!/1 failed for #{inspect(path)}: " <> + inspect(reason) + end + end + + @doc """ + True if any CA certificates are loaded in the `:public_key` store. + + Useful for diagnostics and tests. `:public_key.cacerts_get/0` raises + when nothing is loaded; `loaded?/0` catches that and returns `false` + instead. + """ + @spec loaded?() :: boolean() + def loaded? do + case :public_key.cacerts_get() do + [_ | _] -> true + [] -> false + end + rescue + _ -> false + end +end diff --git a/lib/mob/composite.ex b/lib/mob/composite.ex new file mode 100644 index 00000000..a76d6bfd --- /dev/null +++ b/lib/mob/composite.ex @@ -0,0 +1,153 @@ +defmodule Mob.Composite do + @moduledoc """ + Pure-Elixir composite components: the third expansion pass. + + A composite is a TAG that expands to a built-in widget tree — no native + code. UI-kit authors register an expander per tag atom and users write + `<MishkaCombobox … />` in `~MOB`; this pass replaces the node with the + expander's output before `Mob.List.expand` / `Mob.Component.expand` run + (so a composite may itself emit `<List>` or `Mob.UI.native_view`). + + ## Registering + + Via a plugin manifest (the `expand:` ui_components form, MOB_PLUGINS.md): + + ui_components: [ + %{tag: "MishkaCombobox", atom: :mishka_combobox, + expand: {Mishka.Combobox, :expand}} + ] + + …registered automatically at boot. Or at runtime (plain Hex UI kits with + no manifest — call from the host's `on_start/0`): + + Mob.Composite.register(:mishka_combobox, {Mishka.Combobox, :expand}) + + ## The expander contract + + def expand(props, children, ctx) + + `props` are the node's props with EVENT TARGETS AUTO-INJECTED: any `on_*` + prop written as a bare atom or string (`on_select="combo_select"`) arrives + as `{screen_pid, :combo_select}` — no `self()` threading. `children` are + the (already composite-expanded) child nodes; `ctx` is + `%{screen: pid, platform: platform}`. Return a node map or a list of nodes + (the `~MOB` sigil output). Output is re-expanded to a fixpoint (composites + can build on composites) with a depth guard of #{20}. + + Composites are stateless by design — state lives in the screen (or in a + `Mob.Component` if a part of the tree needs its own process). Hot-pushable: + pure Elixir, same rule as any screen module. + """ + + require Logger + + @pt_key {__MODULE__, :expanders} + @max_depth 20 + + @doc """ + Registers an expander for a composite tag atom. Overwrites any existing + registration for `atom`. + """ + @spec register(atom(), {module(), atom()}) :: :ok + def register(atom, {mod, fun}) when is_atom(atom) and is_atom(mod) and is_atom(fun) do + :persistent_term.put(@pt_key, Map.put(expanders(), atom, {mod, fun})) + :ok + end + + @doc "The registered expanders (`%{atom => {module, function}}`)." + @spec expanders() :: %{atom() => {module(), atom()}} + def expanders, do: :persistent_term.get(@pt_key, %{}) + + @doc false + # Test seam: drop all registrations. + @spec reset() :: :ok + def reset do + :persistent_term.put(@pt_key, %{}) + :ok + end + + @doc """ + The expansion pass. Walks the tree; nodes whose `:type` has a registered + expander are replaced by the expander output (recursively, to a fixpoint). + A crashing expander logs and renders nothing (an empty Column) rather than + taking the screen down. + """ + @spec expand(map() | [map()], pid()) :: map() | [map()] + def expand(tree, screen_pid) do + do_expand(tree, screen_pid, expanders(), 0) + end + + defp do_expand(nodes, pid, exp, depth) when is_list(nodes) do + nodes + |> Enum.map(&do_expand(&1, pid, exp, depth)) + |> List.flatten() + end + + defp do_expand(%{type: type} = node, pid, exp, depth) do + case Map.fetch(exp, type) do + {:ok, {mod, fun}} when depth < @max_depth -> + node + |> run_expander(mod, fun, pid) + |> do_expand(pid, exp, depth + 1) + + {:ok, _} -> + Logger.error( + "[mob_composite] #{inspect(type)} exceeded the expansion depth guard " <> + "(#{@max_depth}) — circular composite? Rendering nothing for this node." + ) + + empty_node() + + :error -> + children = node |> Map.get(:children, []) |> do_expand(pid, exp, depth) + Map.put(node, :children, children) + end + end + + defp do_expand(other, _pid, _exp, _depth), do: other + + defp run_expander(node, mod, fun, pid) do + props = node |> Map.get(:props, %{}) |> inject_event_targets(pid) + children = Map.get(node, :children, []) + + try do + apply(mod, fun, [props, children, %{screen: pid}]) + rescue + e -> + Logger.error( + "[mob_composite] #{inspect(mod)}.#{fun}/3 for #{inspect(node.type)} crashed: " <> + Exception.format(:error, e, __STACKTRACE__) + ) + + empty_node() + end + end + + # `on_*` props written as a bare atom or string become `{screen_pid, tag}` — + # the event-target shape every built-in widget expects — so composite users + # (and composite authors passing them through) never thread `self()`. + # Already-shaped `{pid, tag}` values pass through untouched. + @doc false + @spec inject_event_targets(map(), pid()) :: map() + def inject_event_targets(props, pid) when is_map(props) do + Map.new(props, fn + {key, value} = pair -> + if event_key?(key) do + case value do + v when is_atom(v) and not is_nil(v) and not is_boolean(v) -> {key, {pid, v}} + v when is_binary(v) -> {key, {pid, String.to_atom(v)}} + _ -> pair + end + else + pair + end + end) + end + + defp event_key?(key) when is_atom(key), + do: key |> Atom.to_string() |> String.starts_with?("on_") + + defp event_key?(_), do: false + + defp empty_node, do: %{type: :column, props: %{}, children: []} +end diff --git a/lib/mob/device.ex b/lib/mob/device.ex index 2e8ae41d..62451975 100644 --- a/lib/mob/device.ex +++ b/lib/mob/device.ex @@ -21,7 +21,8 @@ defmodule Mob.Device do - `:app` — `:will_resign_active`, `:did_become_active`, `:did_enter_background`, `:will_enter_foreground`, `:will_terminate` - - `:display` — `:screen_off`, `:screen_on` + - `:display` — `:screen_off`, `:screen_on`, + `{:orientation_changed, :portrait | :portrait_upside_down | :landscape_left | :landscape_right}` - `:audio` — `:audio_interrupted`, `:audio_resumed`, `:audio_route_changed` - `:appearance` — `{:color_scheme_changed, :light | :dark}` - `:power` — `{:battery_state_changed, :unplugged | :charging | :full | :unknown}`, @@ -41,11 +42,27 @@ defmodule Mob.Device do Mob.Device.foreground?() # boolean Mob.Device.os_version() # binary Mob.Device.model() # binary + Mob.Device.orientation() # :portrait | :landscape_left | ... + + ## Orientation + + `orientation/0` reports the current interface orientation; subscribe to + `:display` to get `{:mob_device, :orientation_changed, orientation}` when the + device rotates. + + Mob.Device.orientation() # :portrait + Mob.Device.lock_orientation(:landscape) # force landscape (either side) + Mob.Device.unlock_orientation() # follow the sensor again + + Locking forces the orientation regardless of the OS auto-rotate-lock setting + (Android `setRequestedOrientation`; iOS supported-orientations + a geometry + request). It is global to the app — a screen that wants to be landscape-only + should `lock_orientation/1` on enter and `unlock_orientation/0` on leave. """ use GenServer - @categories [:app, :display, :audio, :appearance, :power, :thermal, :memory] + @categories [:app, :display, :audio, :appearance, :power, :thermal, :memory, :network] @default_categories [:app, :display, :audio, :appearance, :memory] @app_events [ @@ -55,14 +72,16 @@ defmodule Mob.Device do :will_enter_foreground, :will_terminate ] - @display_events [:screen_off, :screen_on] + @display_events [:screen_off, :screen_on, :orientation_changed] @audio_events [:audio_interrupted, :audio_resumed, :audio_route_changed] @appearance_events [:color_scheme_changed] @power_events [:battery_state_changed, :battery_level_changed, :low_power_mode_changed] @thermal_events [:thermal_state_changed] @memory_events [:memory_warning] + @network_events [:connectivity_changed] - @type category :: :app | :display | :audio | :appearance | :power | :thermal | :memory + @type category :: + :app | :display | :audio | :appearance | :power | :thermal | :memory | :network @type event :: atom() # ── Public API ──────────────────────────────────────────────────────────── @@ -122,6 +141,72 @@ defmodule Mob.Device do @spec thermal_state() :: :nominal | :fair | :serious | :critical def thermal_state, do: :mob_nif.device_thermal_state() + @typedoc "The transport currently carrying the network path." + @type transport :: :wifi | :cellular | :wired | :other | :none + + @typedoc """ + A per-platform signal that this platform does not expose — distinct from a + known `false`. e.g. `validated` on iOS, `constrained` on Android. + """ + @type unavailable :: :unavailable + + @typedoc """ + A connectivity snapshot. `online`/`transport`/`expensive` are cross-platform. + `validated` and `constrained` are each exposed by only one platform; the other + reports `:unavailable` rather than a misleading `false`. + """ + @type network_state :: %{ + online: boolean(), + transport: transport(), + expensive: boolean(), + validated: boolean() | unavailable(), + constrained: boolean() | unavailable() + } + + @doc """ + Current network connectivity snapshot. + + `online` is true when the OS reports a usable default network *path* is up. + It does **not** guarantee the internet is reachable — a captive-portal or + not-yet-validated network reports `online: true` on both platforms (iOS + `NWPath` satisfied / Android default `NetworkCallback`). `transport` names + the active interface — `:wifi | :cellular | :wired | :other | :none`; it is + `:none` exactly when `online` is false. `expensive` is true on metered links + (cellular, personal hotspot), so defer large transfers when it is set. + + `validated` and `constrained` are single-platform signals — each platform + reports `:unavailable` (not a misleading `false`) for the one it can't answer: + + * `validated` — **Android only** (`NET_CAPABILITY_VALIDATED`): the OS actively + probed and confirmed real internet reachability; `false` on a captive portal + or before validation completes. `:unavailable` on iOS (NWPath has no probe). + * `constrained` — **iOS only** (`nw_path_is_constrained`): Low Data Mode is on. + `:unavailable` on Android (no per-network equivalent). + + # iOS + #=> %{online: true, transport: :wifi, expensive: false, + # validated: :unavailable, constrained: false} + # Android + #=> %{online: true, transport: :wifi, expensive: false, + # validated: true, constrained: :unavailable} + + Subscribe to the `:network` category to receive + `{:mob_device, :connectivity_changed, state}` when this changes — `state` is + the same full map shape returned here (all five keys, `validated`/`constrained` + included). + """ + @spec network_state() :: network_state() + def network_state, do: :mob_nif.device_network_state() + + @doc "True if the device currently has a usable network path." + @spec online?() :: boolean() + def online? do + case network_state() do + %{online: online} -> online == true + _ -> false + end + end + @doc "True if Low Power Mode (iOS) / Power Save Mode (Android) is on." @spec low_power_mode?() :: boolean() def low_power_mode?, do: :mob_nif.device_low_power_mode() == true @@ -138,6 +223,78 @@ defmodule Mob.Device do @spec model() :: String.t() def model, do: to_string(:mob_nif.device_model()) + @typedoc "A concrete interface orientation." + @type orientation :: + :portrait | :portrait_upside_down | :landscape_left | :landscape_right | :unknown + + @typedoc """ + A lock request. `:landscape` / `:portrait` allow either side of that axis; + the four concrete values pin a single orientation. + """ + @type lock :: + :portrait + | :portrait_upside_down + | :landscape + | :landscape_left + | :landscape_right + + @valid_locks [:portrait, :portrait_upside_down, :landscape, :landscape_left, :landscape_right] + + @doc """ + Current interface orientation — `:portrait | :portrait_upside_down | + :landscape_left | :landscape_right`, or `:unknown` if it can't be determined. + """ + @spec orientation() :: orientation() + def orientation, do: :mob_nif.device_orientation() + + @doc """ + Lock the app to `orientation`, overriding the OS auto-rotate setting. + + Accepts `:portrait`, `:portrait_upside_down`, `:landscape` (either side), + `:landscape_left`, or `:landscape_right`. Returns `{:error, :invalid}` for + anything else. Global to the app; pair with `unlock_orientation/0`. + """ + @spec lock_orientation(lock()) :: :ok | {:error, :invalid} + def lock_orientation(orientation) when orientation in @valid_locks do + :mob_nif.device_lock_orientation(orientation) + :ok + end + + def lock_orientation(_), do: {:error, :invalid} + + @doc "Release an orientation lock; the app follows the sensor again." + @spec unlock_orientation() :: :ok + def unlock_orientation do + :mob_nif.device_lock_orientation(:unspecified) + :ok + end + + @doc false + # Pure predicate behind lock_orientation/1's guard — exposed for tests. + @spec valid_lock?(atom()) :: boolean() + def valid_lock?(orientation), do: orientation in @valid_locks + + @doc """ + Keep the screen awake — disable the auto-dim / auto-lock idle timer while + `on?` is `true`, release it with `false`. Useful for video, reading, + navigation, or any screen the user watches without touching. No permission + required on either platform. + + The keep-awake flag is app-scoped and the OS clears it when the app is + backgrounded, so re-assert it on resume (e.g. from the `:app` + `did_become_active` event) if you need it to persist across a background/ + foreground cycle. On a device with no active window (e.g. before first + render) it's a no-op. + + iOS: `UIApplication.isIdleTimerDisabled`. Android: the window's + `FLAG_KEEP_SCREEN_ON`. + """ + @spec keep_awake(boolean()) :: :ok + def keep_awake(on?) when is_boolean(on?) do + :mob_nif.device_keep_awake(on?) + :ok + end + @doc """ Hands a URL to the OS to open in the default browser/handler. @@ -150,6 +307,34 @@ defmodule Mob.Device do :ok end + @doc """ + Opens an OS settings screen for this app. + + `target` is one of: + + * `:app` — the app's details / permissions page (both platforms). The + go-to when a runtime permission was *permanently* denied and the user + must re-enable it by hand. + * `:notifications` — the app's notification settings (Android). iOS has no + granular deep-link, so it opens the app page. + * `:exact_alarm` — the "Alarms & reminders" special-access screen + (Android 12+). iOS opens the app page. + + iOS exposes only the single app settings page, so `target` is honored on + Android and falls back to the app page on iOS. Fire-and-forget: a valid target + returns `:ok` immediately (an unavailable screen is a no-op, not a raise); an + unknown target returns `{:error, :invalid}` without touching the NIF. + """ + @spec open_settings(:app | :notifications | :exact_alarm) :: :ok | {:error, :invalid} + def open_settings(target \\ :app) + + def open_settings(target) when target in [:app, :notifications, :exact_alarm] do + :mob_nif.open_settings(Atom.to_string(target)) + :ok + end + + def open_settings(_target), do: {:error, :invalid} + # ── GenServer ───────────────────────────────────────────────────────────── @impl true @@ -162,10 +347,10 @@ defmodule Mob.Device do {:error, :nif_not_loaded} -> # Expected when running on the host (tests, IEx without device). + # `maybe_set_dispatcher/0` only ever returns `:ok` or this + # specific `:nif_not_loaded` shape — broader `{:error, reason}` + # catch-all was unreachable and the 1.20 type checker flagged it. :ok - - {:error, reason} -> - :logger.warning("Mob.Device: NIF dispatcher not set: #{inspect(reason)}") end {:ok, %{subscribers: %{}, monitors: %{}}} @@ -233,7 +418,12 @@ defmodule Mob.Device do :mob_nif.device_set_dispatcher(self()) :ok rescue - _ -> {:error, :nif_not_loaded} + # Tolerate the two failure modes that mean "the NIF isn't here": + # UndefinedFunctionError when the stub itself is unloadable, and + # ErlangError when the stub is loaded but device_set_dispatcher + # raises (e.g. because :erlang.load_nif/2 hasn't run yet on a + # host-mode build). Anything else really is a bug worth crashing on. + _ in [UndefinedFunctionError, ErlangError] -> {:error, :nif_not_loaded} end end @@ -296,6 +486,7 @@ defmodule Mob.Device do event in @power_events -> :power event in @thermal_events -> :thermal event in @memory_events -> :memory + event in @network_events -> :network true -> :unknown end end diff --git a/lib/mob/dist.ex b/lib/mob/dist.ex index 7abc30f2..09933b85 100644 --- a/lib/mob/dist.ex +++ b/lib/mob/dist.ex @@ -3,7 +3,11 @@ defmodule Mob.Dist do Platform-aware Erlang distribution startup. On iOS, distribution is started at BEAM launch via flags in mob_beam.m - (`-name mob_demo@127.0.0.1`), so nothing extra is needed here. + (`-name mob_demo@127.0.0.1`), so nothing extra is needed here. The + iOS node name is auto-suffixed per-simulator (from `SIMULATOR_UDID`) + and can be overridden via `MOB_NODE_SUFFIX` (forwarded by + `mix mob.deploy --node-suffix` via simctl's `SIMCTL_CHILD_*` + mechanism, planned). On Android, starting distribution at BEAM launch races with Android's hwui thread pool initialization (~125ms window), corrupting an internal mutex and diff --git a/lib/mob/dns.ex b/lib/mob/dns.ex new file mode 100644 index 00000000..ad2f3c47 --- /dev/null +++ b/lib/mob/dns.ex @@ -0,0 +1,327 @@ +defmodule Mob.DNS do + @moduledoc """ + Hostname → IP resolution that works around BEAM's broken DNS path + on iOS and physical Android devices. + + ## Why this exists + + BEAM resolves hostnames by spawning an external helper called + `inet_gethost` (a port program). On macOS, Linux, Windows that + works fine. On mobile it doesn't, for two distinct reasons: + + - **iOS** — the app sandbox forbids `execve` of any binary the app + didn't get a special pass for. `inet_gethost` never runs. + - **Physical Android** — `inet_gethost` *does* run (mob ships it as + `libinet_gethost.so`, allowed to `execve` by the `apk_data_file` + SELinux label), but Bionic's getaddrinfo from the execve'd child + process returns `:nxdomain` for hostnames the same app's in-process + HTTPS stack resolves fine. The Android emulator happens not to hit + this (its DNS proxy at `10.0.2.3` is reachable to anything), so + the fault doesn't show in the simulator. Confirmed on a Moto G + Power 5G 2024 (Android 14): app-uid TCP-by-IP succeeds, but BEAM's + `:inet.getaddr/2` fails with `:nxdomain`. + + In either case `:inet.getaddr/2` (and therefore Req, Finch, Mint, + ReqLLM, and basically every Elixir HTTP library) fails the moment + a request hits a hostname rather than a literal IP. + + This module side-steps the problem by calling the OS resolver + (`getaddrinfo`) **in-process via a NIF** — same address space, same + uid as the rest of the app — then seeding `:inet_db` with the result + so subsequent BEAM-level lookups for the same host succeed from the + in-process file table. + + ## How to use it + + ### Robust everywhere, incl. cellular: `resolve/1` · `preresolve/1` + + `resolve/1` calls the OS's `getaddrinfo` via a NIF — Darwin's on iOS, + Bionic's on Android — then seeds `:inet_db`'s `:file` table, so + subsequent `:inet.getaddr/2` lookups (Req / Finch / Mint) find the + result. Because it uses the OS resolver, it works wherever the OS + does — **including iOS cellular** (carrier's DNS) and **physical + Android devices** where the forked `inet_gethost` path is broken. + This is the recommended path on both platforms. + + Preresolve your known hosts at startup — that's all most apps need: + + def on_start do + Mob.DNS.preresolve(["api.example.com", "cdn.example.com"]) + # …rest of startup… + end + + For a host not known until request time, call `resolve/1` just + before the request. Idempotent and cheap. + + ### General fallback (WiFi-friendly): `configure_pure_beam/1` + + Flips the lookup chain to `[:file, :dns]` and seeds nameservers so + *any* hostname resolves via raw DNS from inside BEAM — useful when + you can't enumerate hosts up front: + + def on_start do + if :mob_nif.platform() == :ios, do: Mob.DNS.configure_pure_beam() + end + + Two caveats that make this the *fallback*, not the default: + + * **Don't reset the chain to include `:native` on iOS** — + exec'ing `inet_gethost` there is *fatal*, it crashes the BEAM. + On physical Android `:native` is non-fatal but unreliable + (returns `:nxdomain` for hostnames that do resolve in-process); + `Mob.DNS.resolve/1` is the recommended path there too. + * **It can't resolve on cellular by default.** Its default + nameservers are public (Google / Cloudflare), which carriers + **commonly block** → `:nxdomain`. iOS exposes no reliable API to + read the carrier's resolvers, so there's nothing dependable to + seed instead. On cellular, **prefer `preresolve`/`resolve`** + above, or pass `:nameservers` you know are reachable. + + The two compose: `:file` is consulted before `:dns`, so anything you + `resolve/1` wins over the `configure_pure_beam` fallback. + + ## Scope and limitations + + - **IPv4 only.** Most cloud endpoints serve A records; IPv6 is a + follow-up if it becomes useful. + - **One IP per host.** If the hostname has multiple A records, + the first one is used. BEAM caches the result; failover isn't + automatic. If your endpoint cycles IPs frequently you may need + to re-resolve. + - **No automatic refresh.** Mappings stay in `:inet_db` until + the BEAM exits. If a backend's IP changes mid-session, the + cached entry will be stale — call `resolve/1` again to + refresh. + - **Doesn't help raw NIF networking.** If a third-party NIF calls + libc `getaddrinfo` itself, it never goes through BEAM's DNS + layer and doesn't need (or benefit from) this fix — it already + works. Only `:inet`-mediated lookups (which covers almost all + Elixir HTTP libraries) need our help. + - **Background-app network restrictions still apply.** Android's + App Standby / battery optimizer can block *all* outbound network + from a backgrounded app, including TCP-by-IP — resolving a name + won't help if the OS is silently dropping the connect(). Use a + foreground service or keep the app foregrounded for sustained + DNS / connectivity. + - **Host dev (Mac, Linux) doesn't need this.** The NIF isn't + loaded off-device; callers get `{:error, :nif_not_loaded}` and + should fall back to BEAM's normal path (which works on dev). + + ## Errors + + {:ok, {a, b, c, d}} # success + {:error, :badarg} # host arg invalid + {:error, :nxdomain} # no such hostname + {:error, :timeout} # resolver TRY_AGAIN + {:error, :no_address} # resolved but no IPv4 + {:error, {:gai, code}} # raw getaddrinfo error code + {:error, :nif_not_loaded} # called off-device (host tests) + """ + + @typedoc "Hostname to resolve. Latin-1 only — we're not in a domain that uses IDN." + @type host :: String.t() | charlist() + + @typedoc "The error shapes `resolve/1` can return." + @type error_reason :: + :badarg + | :nxdomain + | :timeout + | :no_address + | :nif_not_loaded + | {:gai, integer()} + + @doc """ + Resolve `host` to an IPv4 address and seed `:inet_db` so subsequent + `:inet.getaddr/2` lookups (and thus Req / Finch / Mint) find it. + + Idempotent — calling for the same host twice is harmless. + + See module doc for usage, scope, and error shapes. + """ + @spec resolve(host()) :: {:ok, :inet.ip4_address()} | {:error, error_reason()} + def resolve(host) when is_binary(host), do: resolve(String.to_charlist(host)) + + def resolve(host) when is_list(host) do + case safe_nif_call(host) do + {:ok, {_, _, _, _} = ip} -> + :inet_db.add_host(ip, [host]) + ensure_file_lookup_first() + {:ok, ip} + + {:error, _} = err -> + err + end + end + + @doc """ + Resolve a list of hostnames. Returns a map of host → result so the + caller can see which ones failed. + + Useful at app startup for the known-fixed set of backends your app + talks to. + + %{ + "api.example.com" => {:ok, {93, 184, 216, 34}}, + "auth.example.com" => {:error, :nxdomain} + } + """ + @spec preresolve([host()]) :: %{host() => {:ok, :inet.ip4_address()} | {:error, error_reason()}} + def preresolve(hosts) when is_list(hosts) do + Map.new(hosts, fn host -> {host, resolve(host)} end) + end + + @doc """ + Configure BEAM's DNS path so `:inet.getaddr/2` (and Req / Finch / + Mint / `gen_tcp:connect/3` with a hostname) works without per-host + setup. + + Sets the lookup chain to `[:file, :dns]` and seeds fallback + nameservers. Both ops are idempotent. + + ## Why + + BEAM's default `:native` lookup spawns `inet_gethost`, which iOS + refuses to `execve`. The `:dns` lookup, by contrast, performs raw + UDP/TCP DNS queries from inside BEAM via `gen_udp` / `gen_tcp` — + no port program, no fork. iOS doesn't block sockets, so the `:dns` + path Just Works. + + After calling this, the whole `:inet`-mediated HTTP stack stops + needing a per-host `resolve/1` call. `:file` stays first in the + chain so any host you do `resolve/1` manually still wins — the two + paths compose. + + ## When NOT to default to this + + Reach for per-host `resolve/1` (which uses libc `getaddrinfo` via + the NIF, going through Apple's resolver) when you need any of: + + * VPN-pushed DNS for internal hostnames + * `.local` / mDNS service discovery + * Search-domain expansion (single-label hostnames like `https://api/`) + * Captive-portal-aware lookup + * Encrypted-DNS-at-OS-level (DoH / DoT configured in iOS Settings) + + These all require Apple's resolver, which only the NIF path + consults. The pure-BEAM `:dns` path queries whatever nameservers + you seed and nothing else. + + ## Cellular caveat + + This won't resolve on cellular with the defaults: the seeded public + resolvers (8.8.8.8 / 1.1.1.1) are **commonly blocked by carriers** → + `:nxdomain`. iOS exposes no reliable API to read the carrier's + resolvers, so there's nothing dependable to seed instead. For hosts + you can name, prefer `preresolve/1` / `resolve/1` (they use the OS + resolver and work on cellular); otherwise pass `:nameservers` you + know are reachable. + + ## Opts + + * `:nameservers` — list of nameserver IP tuples (IPv4 or IPv6). + Defaults to `[{8, 8, 8, 8}, {1, 1, 1, 1}]` (Google + Cloudflare). + Pass any list, including `[]` to skip seeding (e.g. if your + app's `:kernel` env already configures them). Common + alternatives: + + * `[{9, 9, 9, 9}]` — Quad9 (privacy-leaning, no logging) + * `[{10, 0, 0, 1}, {10, 0, 0, 2}]` — your corporate resolvers + + ## Idempotent + + Calling this twice is a no-op on the second call — duplicate + nameservers aren't added, the lookup chain isn't reordered. + + ## Examples + + # Default — most apps need nothing more + Mob.DNS.configure_pure_beam() + + # Override the fallback nameservers + Mob.DNS.configure_pure_beam(nameservers: [{9, 9, 9, 9}]) + + # Set the lookup chain but skip nameserver seeding + Mob.DNS.configure_pure_beam(nameservers: []) + """ + @spec configure_pure_beam([{:nameservers, [:inet.ip_address()]}]) :: :ok + def configure_pure_beam(opts \\ []) do + nameservers = Keyword.get(opts, :nameservers, [{8, 8, 8, 8}, {1, 1, 1, 1}]) + + set_lookup_chain([:file, :dns]) + Enum.each(nameservers, &add_ns_if_missing/1) + + :ok + end + + @doc """ + True when `host` is already seeded in `:inet_db`. + + Useful for short-circuiting in caller code that wants to avoid an + unnecessary NIF call — but `resolve/1` is idempotent, so calling + it again is also fine. + """ + @spec resolved?(host()) :: boolean() + def resolved?(host) when is_binary(host), do: resolved?(String.to_charlist(host)) + + def resolved?(host) when is_list(host) do + case :inet.gethostbyname(host) do + {:ok, _} -> true + {:error, _} -> false + end + end + + # ── internals ───────────────────────────────────────────────────────── + + # Wrap the NIF call so we surface a structured error when running + # outside the device (host tests, IEx on the Mac before any deploy). + # Without this rescue, callers get an UndefinedFunctionError that's + # hard to interpret. + defp safe_nif_call(host) do + :mob_nif.resolve_ipv4(host) + rescue + UndefinedFunctionError -> {:error, :nif_not_loaded} + ErlangError -> {:error, :nif_not_loaded} + end + + # `:inet_db.set_lookup/1` controls the order BEAM tries lookup + # methods. Default on iOS includes `:native` (the broken + # `inet_gethost` path). We push `:file` to the front so seeded + # entries are found first. Idempotent: only modifies if `:file` + # isn't already in front. + defp ensure_file_lookup_first do + current = :inet_db.res_option(:lookup) + + case current do + [:file | _] -> + :ok + + _ -> + with_file = [:file | List.delete(current, :file)] + :inet_db.set_lookup(with_file) + :ok + end + end + + # Set the lookup chain to exactly `chain` if it isn't already. + # Used by `configure_pure_beam/1` to flip to `[:file, :dns]`. + defp set_lookup_chain(chain) do + if :inet_db.res_option(:lookup) != chain do + :inet_db.set_lookup(chain) + end + + :ok + end + + # Add a nameserver to `:inet_db` if not already configured. + # `:inet_db.res_option(:nameservers)` returns `[{ip, port}]`; + # `add_ns/1` adds at default port 53. + defp add_ns_if_missing(ns) do + existing = :inet_db.res_option(:nameservers) + + unless Enum.any?(existing, fn {ip, _port} -> ip == ns end) do + :inet_db.add_ns(ns) + end + + :ok + end +end diff --git a/lib/mob/event/target.ex b/lib/mob/event/target.ex index c9947601..cea31051 100644 --- a/lib/mob/event/target.ex +++ b/lib/mob/event/target.ex @@ -94,10 +94,15 @@ defmodule Mob.Event.Target do end def resolve({:via, mod, key} = via, _scope) when is_atom(mod) do + # GenServer.whereis/1 narrows to `pid | nil` for `{:via, _, _}` + # inputs (the registry callbacks normalize their result before + # returning). The historical `{_name, _node}` arm came from + # treating `GenServer.whereis/1`'s full @spec — that variant + # only fires for `{name, node}` inputs, which this clause never + # passes. case GenServer.whereis(via) do nil -> {:error, {:via_not_resolvable, mod, key}} pid when is_pid(pid) -> {:ok, pid} - {_name, _node} = remote -> {:error, {:remote_not_supported, remote}} end end diff --git a/lib/mob/files.ex b/lib/mob/files.ex index 6ac8068b..786c0afc 100644 --- a/lib/mob/files.ex +++ b/lib/mob/files.ex @@ -15,13 +15,247 @@ defmodule Mob.Files do mime: "application/pdf", size: 102400} iOS: `UIDocumentPickerViewController`. Android: `OpenMultipleDocuments`. + + ## Filtering by file type + + Pass `:types` to `pick/2` to limit what the picker offers: + + Mob.Files.pick(socket, types: ["livemd"]) # one extension + Mob.Files.pick(socket, types: [:images, :pdf]) # semantic groups + Mob.Files.pick(socket, types: [{:mime, "application/pdf"}]) + + Each entry is one of: + + * an extension string — `"livemd"` or `".livemd"` (the leading dot is + optional). This is the common case and matches how apps think about the + files they own. + * a MIME string — any value containing a slash, e.g. `"application/pdf"` + or a wildcard `"text/*"`. + * a semantic atom — `:images`, `:video`, `:audio`, `:pdf`, `:text`. + * `{:extension, ext}` / `{:mime, type}` / `{:uti, id}` for an explicit + kind. `{:uti, "dev.livebook.livemd"}` targets an iOS Uniform Type + Identifier directly. + * `:any` (the default) — offer everything. + + ### Platform asymmetry — read this before relying on it + + The two platforms filter differently, and a custom extension exposes the gap: + + * **iOS** filters by `UTType`, which it can derive from an extension even + for an unregistered custom type. So `types: ["livemd"]` *strictly* limits + the picker to `.livemd` files. + * **Android** SAF filters by **MIME type only** — it has no extension + filter. A custom extension with no registered MIME (`.livemd`) cannot be + narrowed at the picker, so the picker stays wide and the user can still + tap the "wrong" file. + + Because of this, enforce the filter on the **result** too. `pick/2` narrows + the picker where the OS allows; `accept/2` rejects anything that slipped + through where it doesn't, giving consistent semantics on both platforms: + + def handle_info({:files, :picked, items}, socket) do + case Mob.Files.accept(items, ["livemd"]) do + [%{path: path} | _] -> {:noreply, open(socket, path)} + [] -> {:noreply, put_flash(socket, :error, "Please choose a .livemd file")} + end + end + + `accept/2` matches on the result's `name`/`mime`, so it enforces extensions, + MIME types, and semantic groups. A `{:uti, _}` spec can't be checked from the + result and is treated as already-enforced by the iOS picker. + + ## "Open with" — files handed to us by another app + + When the user opens a file *into* the app from elsewhere — e.g. a `.livemd` + emailed to them and tapped — the OS launches (or foregrounds) the app with + that file, provided the app declares the document type. This is a separate, + build-time mechanism from the runtime `:types` picker filter above: + + * iOS: `CFBundleDocumentTypes` (+ an imported UTI) in `Info.plist`, and an + `application:openURL:options:` handler that calls `mob_handle_opened_url`. + * Android: an `<intent-filter>` for `ACTION_VIEW` / `ACTION_SEND` matching + the mime type / extension; the Mob activity forwards it automatically. + + Retrieve it with `take_opened_document/0` from your root screen's `mount/3`. """ + @typedoc "A single entry in the `:types` list. See the moduledoc for the full forms." + @type type_spec :: + :any + | :images + | :video + | :audio + | :pdf + | :text + | String.t() + | {:extension, String.t()} + | {:mime, String.t()} + | {:uti, String.t()} + + @doc """ + Open the system document picker. + + Pass `types: [...]` to limit what's offered (see the moduledoc). Defaults to + `:any`. Results arrive asynchronously as `{:files, :picked, items}` / + `{:files, :cancelled}` to the calling process. + """ @spec pick(Mob.Socket.t(), keyword()) :: Mob.Socket.t() def pick(socket, opts \\ []) do - types = Keyword.get(opts, :types, ["*/*"]) - types_json = :json.encode(types) - :mob_nif.files_pick(types_json) + envelope = opts |> Keyword.get(:types, :any) |> normalize_types() + :mob_nif.files_pick(IO.iodata_to_binary(:json.encode(envelope))) socket end + + @doc """ + Normalize a `:types` value into the canonical envelope sent to the native + picker — a list of `%{"kind" => kind, "value" => value}` maps. + + `:any` (or `"*/*"`, anywhere in the list) collapses the whole filter to `[]`, + meaning "offer everything". Exposed so the wire contract with the iOS/Android + native layers is testable and documented in one place. + """ + @spec normalize_types([type_spec()] | type_spec()) :: [%{String.t() => String.t()}] + def normalize_types(types) do + specs = types |> List.wrap() |> Enum.map(&normalize_spec/1) + if Enum.member?(specs, :any), do: [], else: specs + end + + @doc """ + Keep only the items in `items` that satisfy `types` (see `matches?/2`). + + Use this in your `{:files, :picked, items}` handler to enforce a type filter + the picker could not (notably a custom extension on Android SAF). + """ + @spec accept([map()], [type_spec()] | type_spec()) :: [map()] + def accept(items, types), do: Enum.filter(items, &matches?(&1, types)) + + @doc """ + True if a picked/opened `item` map satisfies `types`. + + Returns `true` when `types` is empty/`:any`, or when none of the specs are + checkable from the result (e.g. only `{:uti, _}` hints, which rely on the iOS + picker having already filtered). Otherwise the item must match at least one + spec by extension, MIME, or semantic group. + """ + @spec matches?(map(), [type_spec()] | type_spec()) :: boolean() + def matches?(item, types) do + specs = normalize_types(types) + enforceable = Enum.filter(specs, &enforceable?/1) + + cond do + specs == [] -> true + enforceable == [] -> true + true -> Enum.any?(enforceable, &spec_matches?(&1, item)) + end + end + + @doc """ + Return the document another app asked us to open, or `:none`. + + Call once from your root screen's `mount/3`. The item has the same shape as + `pick/2` results: + + %{path: "/tmp/demo.livemd", name: "demo.livemd", + mime: "text/markdown", size: 1234} + + The copied file lives in the app's tmp dir, so read or move it promptly. This + call also registers the calling process to receive any file opened *later* + while the app is already running, delivered as: + + handle_info({:files, :opened, item}, socket) + + Returns `:none` off-device or when nothing is pending. See the moduledoc for + the platform manifest/Info.plist wiring "open with" requires. + """ + @spec take_opened_document() :: map() | :none + def take_opened_document do + case safe_take_opened() do + json when is_binary(json) -> decode_opened_item(json) + _ -> :none + end + end + + # ── type-spec normalization ─────────────────────────────────────────────── + + defp normalize_spec(:any), do: :any + defp normalize_spec("*/*"), do: :any + + defp normalize_spec(group) when group in [:images, :video, :audio, :pdf, :text], + do: %{"kind" => "semantic", "value" => Atom.to_string(group)} + + defp normalize_spec({:extension, ext}) when is_binary(ext), + do: %{"kind" => "extension", "value" => strip_dot(ext)} + + defp normalize_spec({:mime, type}) when is_binary(type), + do: %{"kind" => "mime", "value" => type} + + defp normalize_spec({:uti, id}) when is_binary(id), + do: %{"kind" => "uti", "value" => id} + + defp normalize_spec(spec) when is_binary(spec) do + if String.contains?(spec, "/"), + do: %{"kind" => "mime", "value" => spec}, + else: %{"kind" => "extension", "value" => strip_dot(spec)} + end + + defp strip_dot("." <> rest), do: rest + defp strip_dot(ext), do: ext + + # ── result enforcement ──────────────────────────────────────────────────── + + # UTI specs can't be checked from a result map (it carries name/mime, not a + # UTI), so they don't enforce — the iOS picker already filtered on them. + defp enforceable?(%{"kind" => kind}), do: kind in ["extension", "mime", "semantic"] + + defp spec_matches?(%{"kind" => "extension", "value" => ext}, item) do + name = item[:name] || item["name"] + is_binary(name) and String.downcase(Path.extname(name)) == "." <> String.downcase(ext) + end + + defp spec_matches?(%{"kind" => "mime", "value" => pattern}, item) do + mime = item[:mime] || item["mime"] + is_binary(mime) and mime_match?(pattern, mime) + end + + defp spec_matches?(%{"kind" => "semantic", "value" => group}, item) do + mime = item[:mime] || item["mime"] + is_binary(mime) and mime_match?(semantic_mime(group), mime) + end + + defp spec_matches?(_spec, _item), do: false + + defp mime_match?("*/*", _mime), do: true + + defp mime_match?(pattern, mime) do + case String.split(pattern, "/") do + [type, "*"] -> String.starts_with?(String.downcase(mime), String.downcase(type) <> "/") + _exact -> String.downcase(pattern) == String.downcase(mime) + end + end + + defp semantic_mime("images"), do: "image/*" + defp semantic_mime("video"), do: "video/*" + defp semantic_mime("audio"), do: "audio/*" + defp semantic_mime("pdf"), do: "application/pdf" + defp semantic_mime("text"), do: "text/*" + defp semantic_mime(_group), do: "*/*" + + # ── open-with ───────────────────────────────────────────────────────────── + + defp safe_take_opened do + :mob_nif.take_opened_document() + rescue + UndefinedFunctionError -> :none + ErlangError -> :none + end + + defp decode_opened_item(json) do + case :json.decode(json) do + %{"path" => path} = m -> + %{path: path, name: m["name"], mime: m["mime"], size: m["size"]} + + _ -> + :none + end + end end diff --git a/lib/mob/location.ex b/lib/mob/location.ex deleted file mode 100644 index b8f3c347..00000000 --- a/lib/mob/location.ex +++ /dev/null @@ -1,49 +0,0 @@ -defmodule Mob.Location do - @moduledoc """ - Device location (GPS / network). - - Requires `:location` permission (request via `Mob.Permissions.request/2`). - - Location updates arrive as: - - handle_info({:location, %{lat: lat, lon: lon, accuracy: acc, altitude: alt}}, socket) - handle_info({:location, :error, reason}, socket) - - iOS: `CLLocationManager`. Android: `FusedLocationProviderClient`. - """ - - @type accuracy :: :high | :balanced | :low - - @doc """ - Request a single location fix, then stop. - """ - @spec get_once(Mob.Socket.t()) :: Mob.Socket.t() - def get_once(socket) do - :mob_nif.location_get_once() - socket - end - - @doc """ - Start continuous location updates. - - Options: - - `accuracy: :high | :balanced | :low` (default `:balanced`) - - Call `stop/1` when done to save battery. - """ - @spec start(Mob.Socket.t(), keyword()) :: Mob.Socket.t() - def start(socket, opts \\ []) do - accuracy = Keyword.get(opts, :accuracy, :balanced) - :mob_nif.location_start(accuracy) - socket - end - - @doc """ - Stop continuous location updates. - """ - @spec stop(Mob.Socket.t()) :: Mob.Socket.t() - def stop(socket) do - :mob_nif.location_stop() - socket - end -end diff --git a/lib/mob/motion.ex b/lib/mob/motion.ex index 6bab25d9..af6245f1 100644 --- a/lib/mob/motion.ex +++ b/lib/mob/motion.ex @@ -1,40 +1,91 @@ defmodule Mob.Motion do @moduledoc """ - Accelerometer and gyroscope sensor data. + Accelerometer, gyroscope, and magnetometer (compass) sensor data. No permission required. Updates arrive at `handle_info` at the requested interval: handle_info({:motion, %{ - accel: {ax, ay, az}, # m/s² (gravity included) - gyro: {gx, gy, gz}, # rad/s + accel: {ax, ay, az}, # m/s² (gravity included); +g points UP at rest — see below + gyro: {gx, gy, gz}, # rad/s + mag: {mx, my, mz} | nil, # µT (microtesla), calibrated — key present only with :magnetometer + heading: float | nil, # degrees [0, 360) from MAGNETIC north — key present only with :magnetometer timestamp: unix_ms }}, socket) - If you only request one sensor, the other tuple will be `{0.0, 0.0, 0.0}`. + ## The `accel` convention - iOS: `CMMotionManager`. Android: `SensorManager`. + `accel` is the **specific force** (proper acceleration) in m/s², gravity + included, in the device's own axes: `+x` right, `+y` toward the top, `+z` out + of the screen. At rest it reads `+g` (≈ 9.81) on the axis pointing **away from + the ground** — held upright in portrait, `ay ≈ +9.81`; laid flat face-up, + `az ≈ +9.81`. Tilt the device and gravity redistributes across the axes; add + linear motion and it superimposes. This matches Android's `SensorManager` + `TYPE_ACCELEROMETER`, and iOS is normalized to the same sign and units (its + raw `CMMotionManager` stream is in G with the opposite gravity sign), so a + tilt- or shake-driven UI behaves identically on both platforms. + + ## The `:magnetometer` contract + + The `mag` and `heading` keys are present **exactly when you requested + `:magnetometer`** — on both platforms. When you did, they are **always** in the + map, and each is `nil` when there's no reading yet: the device has no + magnetometer at all, or the heading hasn't been fused. So match with `nil`, and + don't assume a value is present: + + case motion do + %{heading: deg} when is_number(deg) -> rotate_needle(deg) + %{heading: nil} -> show_calibration_hint() # no magnetometer, or not yet fused + end + + When you did **not** request `:magnetometer`, the map has neither key (the plain + accel/gyro stream) — so a consumer that never asked for the compass keeps + getting the exact same 3-key map, and pays no extra sensor/battery cost. + + It's **magnetic** north, not true north — true north needs location + declination + (out of scope; layer it with `Mob.Location`). Magnetometers drift until + calibrated, so prompt the user to wave the phone in a figure-8, and note that + many budget devices ship without one at all (there, `heading`/`mag` stay `nil`). + + iOS: `CMMotionManager` — device motion with the `XMagneticNorthZVertical` reference + frame when the magnetometer is requested and available (a calibrated field + a + fused heading on one stream); `nil`/`nil` when requested on a device without one. + Android: `SensorManager` — magnetometer + rotation-vector, registered only when + `:magnetometer` is requested. """ - @type sensor :: :accelerometer | :gyro + @type sensor :: :accelerometer | :gyro | :magnetometer @doc """ Start sensor updates. Options: - - `sensors: [:accelerometer] | [:gyro] | [:accelerometer, :gyro]` (default both) + - `sensors:` any subset of `[:accelerometer, :gyro, :magnetometer]` + (default `[:accelerometer, :gyro]`). Add `:magnetometer` for the compass — + the message then also carries `mag` + `heading`. - `interval_ms: integer` — update interval in milliseconds (default `100`) """ @spec start(Mob.Socket.t(), keyword()) :: Mob.Socket.t() def start(socket, opts \\ []) do + {sensors, interval_ms} = parse_opts(opts) + :mob_nif.motion_start(sensors, interval_ms) + socket + end + + @doc false + # The pure kernel of start/2: resolves opts to the `{sensor_strings, interval_ms}` + # the NIF expects, applying defaults. Extracted (public, hidden) so the arg + # building — including that `:magnetometer` survives normalization — is + # unit-testable without a loaded NIF. + @spec parse_opts(keyword()) :: {[String.t()], pos_integer()} + def parse_opts(opts) do sensors = - Keyword.get(opts, :sensors, [:accelerometer, :gyro]) + opts + |> Keyword.get(:sensors, [:accelerometer, :gyro]) |> Enum.map(&Atom.to_string/1) - interval_ms = Keyword.get(opts, :interval_ms, 100) - :mob_nif.motion_start(sensors, interval_ms) - socket + {sensors, Keyword.get(opts, :interval_ms, 100)} end @doc """ diff --git a/lib/mob/nav/registry.ex b/lib/mob/nav/registry.ex index 59cf4bcc..546565a9 100644 --- a/lib/mob/nav/registry.ex +++ b/lib/mob/nav/registry.ex @@ -32,20 +32,43 @@ defmodule Mob.Nav.Registry do """ @spec lookup(atom()) :: {:ok, module()} | {:error, :not_found} def lookup(name) when is_atom(name) do + case lookup_route(name) do + {:ok, module, _params} -> {:ok, module} + {:error, :not_found} -> {:error, :not_found} + end + end + + @doc """ + Look up the module AND route-bound params registered under `name`. + + Route-bound params let N routes share one parameterized screen module (the + data-driven-plugin pattern — e.g. mob_ash registers `/ash/post/list` as + `{MobAsh.ListScreen, %{resource: MyApp.Post}}`). Navigation merges them + UNDER the caller's `push_screen` params, then passes the result to `mount/3`. + """ + @spec lookup_route(atom()) :: {:ok, module(), map()} | {:error, :not_found} + def lookup_route(name) when is_atom(name) do case :ets.lookup(@table, name) do - [{^name, module}] -> {:ok, module} + [{^name, module, params}] -> {:ok, module, params} + # Entries written by pre-params code paths (or hot-loaded old beams). + [{^name, module}] -> {:ok, module, %{}} [] -> {:error, :not_found} end end @doc """ - Register a `name → module` mapping at runtime. + Register a `name → module` mapping at runtime, optionally with route-bound + `params` delivered to the screen's `mount/3` whenever this route is the + navigation destination (see `lookup_route/1`). Overwrites any existing entry for `name`. """ - @spec register(atom(), module()) :: :ok - def register(name, module) when is_atom(name) and is_atom(module) do - :ets.insert(@table, {name, module}) + @spec register(atom(), module(), map()) :: :ok + def register(name, module, params \\ %{}) + + def register(name, module, params) + when is_atom(name) and is_atom(module) and is_map(params) do + :ets.insert(@table, {name, module, params}) :ok end @@ -68,7 +91,7 @@ defmodule Mob.Nav.Registry do end defp register_nav(%{type: :stack, name: name, root: root}) do - :ets.insert(@table, {name, root}) + :ets.insert(@table, {name, root, %{}}) end defp register_nav(%{type: type, branches: branches}) diff --git a/lib/mob/notify.ex b/lib/mob/notify.ex deleted file mode 100644 index 9b8c3d39..00000000 --- a/lib/mob/notify.ex +++ /dev/null @@ -1,102 +0,0 @@ -defmodule Mob.Notify do - @moduledoc """ - Local and push notifications. - - Requires `:notifications` permission (request via `Mob.Permissions.request/2`). - - All notifications arrive via `handle_info` regardless of app state (foreground, - background, or relaunched after being killed). No special `mount/3` handling needed. - - ## Local notifications - - Mob.Notify.schedule(socket, - id: "reminder_1", - title: "Time to check in", - body: "Open the app to see today's updates", - at: ~U[2026-04-16 09:00:00Z], # or delay_seconds: 60 - data: %{screen: "reminders"} - ) - - # Cancel a pending notification - Mob.Notify.cancel(socket, "reminder_1") - - def handle_info({:notification, %{id: id, data: data, source: :local}}, socket), do: ... - - ## Push notifications (requires `mob_push` package on your server) - - # Call once after :notifications permission granted - Mob.Notify.register_push(socket) - - def handle_info({:push_token, :ios, token}, socket), do: ... - def handle_info({:push_token, :android, token}, socket), do: ... - - def handle_info({:notification, %{title: t, body: b, data: d, source: :push}}, socket), do: ... - - iOS: `UNUserNotificationCenter`. Android: `NotificationManager` + `AlarmManager` + FCM. - """ - - @doc """ - Schedule a local notification. - - Options: - - `id:` (required) — string identifier, used to cancel the notification - - `title:` (required) — notification title - - `body:` (required) — notification body text - - `at: %DateTime{}` — absolute trigger time (UTC) - - `delay_seconds: integer` — trigger after N seconds (alternative to `at:`) - - `data: %{}` — arbitrary map passed back in the `handle_info` payload - """ - @spec schedule(Mob.Socket.t(), keyword()) :: Mob.Socket.t() - def schedule(socket, opts) do - id = Keyword.fetch!(opts, :id) - title = Keyword.fetch!(opts, :title) - body = Keyword.fetch!(opts, :body) - data = Keyword.get(opts, :data, %{}) - - trigger_at = - case opts[:at] do - %DateTime{} = dt -> DateTime.to_unix(dt) - nil -> DateTime.to_unix(DateTime.utc_now()) + (opts[:delay_seconds] || 0) - end - - # Convert data map keys to strings for JSON serialisation - data_str = Map.new(data, fn {k, v} -> {to_string(k), v} end) - - opts_json = - :json.encode(%{ - "id" => id, - "title" => title, - "body" => body, - "trigger_at" => trigger_at, - "data" => data_str - }) - - :mob_nif.notify_schedule(opts_json) - socket - end - - @doc """ - Cancel a pending local notification by its id. - Has no effect if the notification has already been delivered. - """ - @spec cancel(Mob.Socket.t(), String.t()) :: Mob.Socket.t() - def cancel(socket, id) do - :mob_nif.notify_cancel(id) - socket - end - - @doc """ - Register this device for push notifications. - - The device token arrives as `{:push_token, platform, token_string}` where - `platform` is `:ios` or `:android`. - - Send this token to your server and use the `mob_push` library to send - notifications to it. - """ - @spec register_push(Mob.Socket.t()) :: Mob.Socket.t() - def register_push(socket) do - :mob_nif.notify_register_push() - socket - end -end diff --git a/lib/mob/permissions.ex b/lib/mob/permissions.ex index d1fe24cd..576c8c65 100644 --- a/lib/mob/permissions.ex +++ b/lib/mob/permissions.ex @@ -6,17 +6,34 @@ defmodule Mob.Permissions do handle_info({:permission, capability, :granted | :denied}, socket) - Capabilities that require this: + Capabilities that core handles directly: - `:camera` - `:microphone` - `:photo_library` - - `:location` - `:notifications` + Plugins can add their own capabilities (e.g. a `mob_location` plugin owns + `:location`): the plugin registers a native handler that the platform + permission registry dispatches to. `request/2` therefore accepts any atom and + lets the native layer decide whether it is a known capability — an unrecognized + one returns `badarg` from the NIF (surfacing as an `ArgumentError`). + Capabilities that need *no* permission: haptics, clipboard, share sheet, file picker. + + > **Beyond `request/2`**: each capability also needs a matching + > `Info.plist` key (iOS) and `AndroidManifest.xml` entry. Without + > them the dialog is silently suppressed and you get no event. See + > the [permissions guide](permissions.html) for the per-capability + > table and the most common failure modes — it's the first place + > to check when "the dialog never appears". """ - @type capability :: :camera | :microphone | :photo_library | :location | :notifications + @typedoc """ + A permission capability. The atoms core handles directly are listed below; + plugins may register additional capabilities at runtime, so any atom is + accepted by `request/2` and validated natively. + """ + @type capability :: :camera | :microphone | :photo_library | :notifications | atom() @doc """ Request an OS permission from the user. @@ -30,12 +47,13 @@ defmodule Mob.Permissions do Safe to call if the permission is already granted — the result still arrives via `handle_info` with the current status. - Capabilities that do not require permission (haptics, clipboard, share sheet, - file picker) will raise `FunctionClauseError` — do not call `request/2` for them. + The capability must be one core handles or one a plugin has registered. A + capability that needs no permission (haptics, clipboard, share sheet, file + picker) — or any other unrecognized atom — returns `badarg` from the NIF, + surfacing as an `ArgumentError`; do not call `request/2` for those. """ @spec request(Mob.Socket.t(), capability()) :: Mob.Socket.t() - def request(socket, capability) - when capability in [:camera, :microphone, :photo_library, :location, :notifications] do + def request(socket, capability) when is_atom(capability) do :mob_nif.request_permission(capability) socket end diff --git a/lib/mob/photos.ex b/lib/mob/photos.ex deleted file mode 100644 index eb869e93..00000000 --- a/lib/mob/photos.ex +++ /dev/null @@ -1,28 +0,0 @@ -defmodule Mob.Photos do - @moduledoc """ - Photo / video library picker. - - On iOS 14+ no permission is required (the picker itself is sandboxed). - On Android, `READ_MEDIA_IMAGES` / `READ_MEDIA_VIDEO` may be needed. - - Results arrive as: - - handle_info({:photos, :picked, items}, socket) - handle_info({:photos, :cancelled}, socket) - - Each item in `items` is: - - %{path: "/tmp/mob_pick_xxx.jpg", type: :image | :video, - width: 1920, height: 1080} - - iOS: `PHPickerViewController`. Android: `PickMultipleVisualMedia`. - """ - - @spec pick(Mob.Socket.t(), keyword()) :: Mob.Socket.t() - def pick(socket, opts \\ []) do - max = Keyword.get(opts, :max, 1) - types = Keyword.get(opts, :types, [:image]) |> Enum.map(&Atom.to_string/1) - :mob_nif.photos_pick(max, types) - socket - end -end diff --git a/lib/mob/plugins.ex b/lib/mob/plugins.ex new file mode 100644 index 00000000..0cb1fb4d --- /dev/null +++ b/lib/mob/plugins.ex @@ -0,0 +1,455 @@ +defmodule Mob.Plugins do + @moduledoc """ + On-device access to the activated plugins' tier-3/4 contributions. + + Tiers 3 (multi-screen) and 4 (sub-app) are pure-Elixir and runtime-wired: the + host needs to know, while running, which screens / lifecycle hooks / settings / + notification handlers the activated plugins declared. mob_dev bakes that into + the host's `priv/generated/mob_plugins.exs` at build time (see + `mix mob.regen_plugin_manifest`); this module reads it once at boot and feeds + the data to the core wiring (`Mob.App` registers the screens, the lifecycle + dispatcher calls the hooks, the notification dispatch consults the handlers, and the + settings store namespaces by plugin). + + When no tier-3/4 plugin is active (or the manifest hasn't been regenerated) the + file is absent and every accessor returns the empty set — tiers 0-2 are + unaffected. + """ + + require Logger + + @empty %{ + screens: [], + lifecycle: [], + settings: [], + notification_handlers: [], + nifs: [], + composites: [], + styles: [], + default_style: nil + } + @pt_key {__MODULE__, :manifest} + @pt_asset_root {__MODULE__, :asset_root} + @rel_path ["generated", "mob_plugins.exs"] + + @doc """ + Reads the host app's generated manifest and caches it in `:persistent_term`. + + `otp_app` is the host application name (e.g. `:mob_plugin_demo`); the file is + resolved under its `priv/`. Called once from `Mob.App.start/0`. Returns the + loaded manifest (also retrievable later via `manifest/0`). + """ + @spec load(atom()) :: map() + def load(otp_app) when is_atom(otp_app) do + manifest = read(otp_app) + install(manifest) + cache_asset_root(otp_app) + manifest + end + + # The host bundle dir plugin images are copied to at build (native_build's + # apply_plugin_images!), cached so resolve_image/1 can return absolute paths + # the native Image loader can read. An unresolvable priv dir caches nothing — + # resolve_image then errors rather than fabricating a relative path. + defp cache_asset_root(otp_app) do + case :code.priv_dir(otp_app) do + {:error, _} -> :ok + dir -> install_asset_root(Path.join(to_string(dir), "generated/plugin_assets")) + end + end + + @doc false + # `load/1` plumbing + test seam. The empty string means "not cached". + @spec install_asset_root(String.t()) :: :ok + def install_asset_root(root) when is_binary(root) do + :persistent_term.put(@pt_asset_root, root) + end + + @doc """ + Boot-time entry point: load the host's manifest and register the activated + plugins' screens so they're navigable. Called from `Mob.App.start/0`. + + `nil` (no resolvable host app — e.g. on host BEAM / tests) is a no-op. Safe + to call when no tier-3/4 plugin is active: the manifest is empty and nothing + is registered. + """ + @spec boot(atom() | nil) :: :ok + def boot(nil), do: :ok + + def boot(otp_app) when is_atom(otp_app) do + load(otp_app) + ensure_nif_modules_loaded() + register_screens() + register_composites() + apply_default_style() + :ok + end + + @doc false + # Force each activated plugin's NIF module to load. On iOS a plugin's + # permission handler self-registers in its NIF `load` callback, which only + # fires when the Erlang NIF module is first loaded; a screen requesting that + # permission in `mount/3` runs before any plugin NIF call, so without this the + # handler is absent and `Mob.Permissions.request/2` hits `:badarg`. Loading + # eagerly at boot mirrors Android's boot-time `MobPluginBootstrap`. `load_nif` + # failure is tolerated by each NIF module's `on_load`, so a host build with no + # native linked is a no-op. Returns the per-module load results (public + with + # a return value for testing; `boot/1` ignores it). + @spec ensure_nif_modules_loaded() :: [{atom(), {:module, module()} | {:error, term()}}] + def ensure_nif_modules_loaded do + for mod <- nifs(), is_atom(mod), do: {mod, Code.ensure_loaded(mod)} + end + + @doc """ + Registers each manifest screen into `Mob.Nav.Registry` under an atom derived + from its `default_route`, so the host (or the plugin's own screens) can + navigate to it by route. The module is also directly navigable. The host + still chooses *where* to surface a plugin screen in its `navigation/1` + structure — registration only makes the destination resolvable. + """ + @spec register_screens() :: :ok + def register_screens do + for %{module: mod, default_route: route} = entry <- screens(), + is_atom(mod), + not is_nil(mod), + is_binary(route), + route != "" do + # Optional :params on the entry become ROUTE-BOUND params: navigation to + # the route atom merges them under the push params and hands them to + # mount/3 — how N generated routes share one parameterized screen module + # (e.g. mob_ash's %{resource: ...}). Non-map values are ignored. + params = + case entry[:params] do + p when is_map(p) -> p + _ -> %{} + end + + Mob.Nav.Registry.register(String.to_atom(route), mod, params) + end + + :ok + end + + @doc """ + Starts the tier-4 plugin supervisor (runs each plugin's `lifecycle.on_start`, + starts its `supervised` children, and the lifecycle event dispatcher). Called + from `Mob.App.start/0` after the host's own `on_start/0`. No-op when no plugin + declares a `:lifecycle`. + """ + @spec start_lifecycle() :: :ok + def start_lifecycle do + if lifecycle() == [] do + :ok + else + case Mob.Plugins.Supervisor.start_link([]) do + {:ok, _} -> :ok + {:error, {:already_started, _}} -> :ok + end + end + end + + # ── Settings (tier 4) ───────────────────────────────────────────────────── + + @doc """ + Reads a plugin setting, falling back to the schema default when unset. + + Backed by `Mob.State` (the persistent K/V store) under a per-plugin namespaced + key. Returns `nil` for an unknown plugin/key. + """ + @spec get_setting(atom(), atom()) :: term() + def get_setting(plugin, key) when is_atom(plugin) and is_atom(key) do + case setting_spec(plugin, key) do + {:ok, %{default: default}} -> + Mob.State.get(setting_key(plugin, key), default) + + # A schema entry without a :default is malformed; fall back to nil rather + # than crashing the reader (the manifest is build-time generated, but a + # hand-edited or partial entry must not take down a settings read). + {:ok, _entry} -> + Logger.warning( + "[Mob.Plugins] settings schema for #{inspect(plugin)}.#{key} has no :default; reading nil" + ) + + Mob.State.get(setting_key(plugin, key), nil) + + :error -> + nil + end + end + + @doc """ + Writes a plugin setting after validating its value against the schema `type`. + + Returns `:ok`, `{:error, {:invalid_type, type}}`, or `{:error, :unknown_setting}`. + """ + @spec put_setting(atom(), atom(), term()) :: :ok | {:error, term()} + def put_setting(plugin, key, value) when is_atom(plugin) and is_atom(key) do + case setting_spec(plugin, key) do + {:ok, %{type: type}} -> + if valid_setting?(type, value) do + Mob.State.put(setting_key(plugin, key), value) + :ok + else + {:error, {:invalid_type, type}} + end + + # A schema entry without a :type can't be validated; treat as malformed + # rather than crashing the writer. + {:ok, _entry} -> + Logger.warning( + "[Mob.Plugins] settings schema for #{inspect(plugin)}.#{key} has no :type; rejecting write" + ) + + {:error, :unknown_setting} + + :error -> + {:error, :unknown_setting} + end + end + + @doc """ + Resolves the screen a host pushes to let users edit a plugin's settings. + + A tier-4 plugin owns its settings UX; the host only needs the entry point, so + it calls this to get the module to `push_screen/2`. Returns `:error` when the + plugin declared no `editor_screen`. + """ + @spec settings_editor(atom()) :: {:ok, module()} | :error + def settings_editor(plugin) when is_atom(plugin) do + case Enum.find(settings(), &(&1.plugin == plugin)) do + %{editor_screen: mod} when is_atom(mod) -> {:ok, mod} + _ -> :error + end + end + + defp setting_key(plugin, key), do: {:plugin_setting, plugin, key} + + defp setting_spec(plugin, key) do + with %{schema: schema} when is_list(schema) <- Enum.find(settings(), &(&1.plugin == plugin)), + %{} = entry <- Enum.find(schema, &(is_map(&1) and Map.get(&1, :key) == key)) do + {:ok, entry} + else + _ -> :error + end + end + + defp valid_setting?(:boolean, v), do: is_boolean(v) + defp valid_setting?(:string, v), do: is_binary(v) + defp valid_setting?(:integer, v), do: is_integer(v) + defp valid_setting?(_other, _v), do: false + + # ── Notifications (tier 4) ──────────────────────────────────────────────── + + @doc """ + Routes a notification payload to the first matching plugin handler. + + Walks `notification_handlers/0` in order; the first handler whose `:match` + (a map prefix-matched against the payload, or a `{M,F,arity}` predicate) wins, + and its `{M,F,arity}` handler is invoked with the payload. Returns `:handled` + or `:unhandled` (the host's own `handle_info` takes the unhandled case). + + This is the pure routing core; the central notification delivery that feeds it + is wired natively (Phase 3). + """ + @spec dispatch_notification(map()) :: :handled | :unhandled + def dispatch_notification(payload) when is_map(payload) do + Enum.find_value(notification_handlers(), :unhandled, fn handler -> + if is_map(handler) and notification_match?(handler[:match], handler[:plugin], payload) do + case invoke_handler(handler[:handler], handler[:plugin], payload) do + # A matched-but-malformed entry must not swallow the notification: + # keep scanning so a later handler (or the host screen) still gets it. + :malformed -> nil + :ok -> :handled + end + end + end) + end + + # A misbehaving handler or predicate must not crash the host screen GenServer + # (dispatch runs synchronously inside it) — log and continue, mirroring the + # lifecycle dispatcher's crash isolation. + defp invoke_handler({m, f, _arity}, plugin, payload) when is_atom(m) and is_atom(f) do + apply(m, f, [payload]) + :ok + rescue + e -> + Logger.error( + "[mob_plugins] #{inspect(plugin)} notification handler crashed: " <> + Exception.format(:error, e, __STACKTRACE__) + ) + + :ok + end + + defp invoke_handler(other, plugin, _payload) do + Logger.error( + "[mob_plugins] #{inspect(plugin)} notification handler is malformed " <> + "(expected {module, function, arity}): #{inspect(other)}" + ) + + :malformed + end + + defp notification_match?(match, _plugin, payload) when is_map(match) do + Enum.all?(match, fn {k, v} -> Map.get(payload, k) == v end) + end + + defp notification_match?({m, f, _arity}, plugin, payload) do + apply(m, f, [payload]) == true + rescue + e -> + Logger.error( + "[mob_plugins] #{inspect(plugin)} notification predicate crashed: " <> + Exception.format(:error, e, __STACKTRACE__) + ) + + false + end + + defp notification_match?(_other, _plugin, _payload), do: false + + @doc "Caches an already-built manifest (used by `load/1` and tests)." + @spec install(map()) :: :ok + def install(manifest) when is_map(manifest) do + :persistent_term.put(@pt_key, Map.merge(@empty, manifest)) + end + + @doc "The cached manifest, or the empty set if nothing has been loaded." + @spec manifest() :: map() + def manifest, do: :persistent_term.get(@pt_key, @empty) + + @doc "Activated plugins' screen declarations (`%{plugin, module, default_route}`)." + @spec screens() :: [map()] + def screens, do: manifest().screens + + @doc "Activated plugins' lifecycle declarations." + @spec lifecycle() :: [map()] + def lifecycle, do: manifest().lifecycle + + @doc "Activated plugins' settings declarations." + @spec settings() :: [map()] + def settings, do: manifest().settings + + @doc "Activated plugins' notification handlers, in dispatch order." + @spec notification_handlers() :: [map()] + def notification_handlers, do: manifest().notification_handlers + + @doc """ + Activated plugins' NIF module atoms. `boot/1` loads each at startup so an iOS + plugin NIF's `load` callback fires eagerly (registering any permission handler + it owns) before a screen can request that permission. + """ + @spec nifs() :: [atom()] + def nifs, do: Map.get(manifest(), :nifs, []) + + @doc """ + Activated plugins' pure-Elixir composite components + (`%{atom, expand: {M, F}}`) — the manifest `expand:` ui_components form. + """ + @spec composites() :: [map()] + def composites, do: Map.get(manifest(), :composites, []) + + @doc """ + Registers each manifest-declared composite expander into `Mob.Composite`. + Malformed entries are skipped (the build validator is where shape errors + surface; a stale hand-edited manifest must not crash boot). + """ + @spec register_composites() :: :ok + def register_composites do + for %{atom: atom, expand: {mod, fun}} <- composites(), + is_atom(atom) and is_atom(mod) and is_atom(fun) do + Mob.Composite.register(atom, {mod, fun}) + end + + :ok + end + + @doc "Activated token-only style packages (`%{name, theme}`), from MOB_STYLES.md's lane." + @spec styles() :: [map()] + def styles, do: Map.get(manifest(), :styles, []) + + @doc "The configured `:default_style` name (or nil — neutral baseline)." + @spec default_style() :: atom() | nil + def default_style, do: Map.get(manifest(), :default_style, nil) + + @doc """ + Applies the default style's theme at boot (`Mob.Theme.set/1` with the style + package's theme module). No default → no-op (neutral baseline / the host's + own `use Mob.App, theme:`). A broken theme module logs instead of failing + boot — the app renders baseline rather than not at all. + """ + @spec apply_default_style() :: :ok + def apply_default_style do + with name when not is_nil(name) <- default_style(), + %{theme: theme_mod} <- Enum.find(styles(), &(&1[:name] == name)) do + try do + Mob.Theme.set(theme_mod) + rescue + e -> + Logger.error( + "[mob_plugins] default style #{inspect(name)} theme #{inspect(theme_mod)} " <> + "failed to apply: " <> Exception.format(:error, e, __STACKTRACE__) + ) + end + end + + :ok + end + + @doc """ + Resolves a `plugin://<plugin>/<file>` image reference to its on-device bundle + path (`assets/plugin/<plugin>/<file>`), the convention `native_build` copies + plugin images to. The renderer calls this when an image `src` uses the + `plugin://` scheme; a non-plugin URL returns `:passthrough` so normal image + handling continues. Returns `:error` for a malformed `plugin://` reference. + """ + @spec resolve_image(String.t()) :: {:ok, String.t()} | :passthrough | :error + def resolve_image("plugin://" <> rest = ref) do + case String.split(rest, "/", parts: 2) do + [plugin, file] when plugin != "" and file != "" -> + case :persistent_term.get(@pt_asset_root, "") do + "" -> + Logger.warning( + "[mob_plugins] #{ref} requested before the plugin asset root was " <> + "cached (boot incomplete?) — cannot resolve" + ) + + :error + + root -> + {:ok, Path.join([root, "assets", "plugin", plugin, file])} + end + + _ -> + :error + end + end + + def resolve_image(_other), do: :passthrough + + @doc """ + Reads + evaluates the manifest for `otp_app` without caching. Returns the + empty set when the priv dir or file is absent, or the file is malformed (a + missing manifest must never crash boot). + """ + @spec read(atom()) :: map() + def read(otp_app) when is_atom(otp_app) do + case :code.priv_dir(otp_app) do + {:error, _} -> @empty + dir -> read_path(Path.join([to_string(dir) | @rel_path])) + end + end + + @doc "Reads + evaluates a manifest from an explicit path (empty set on any failure)." + @spec read_path(Path.t()) :: map() + def read_path(path) do + if File.exists?(path) do + {evaluated, _bindings} = Code.eval_file(path) + if is_map(evaluated), do: Map.merge(@empty, evaluated), else: @empty + else + @empty + end + rescue + _ -> @empty + end +end diff --git a/lib/mob/plugins/lifecycle.ex b/lib/mob/plugins/lifecycle.ex new file mode 100644 index 00000000..38a5f13d --- /dev/null +++ b/lib/mob/plugins/lifecycle.ex @@ -0,0 +1,55 @@ +defmodule Mob.Plugins.Lifecycle do + @moduledoc """ + Dispatches OS foreground/background transitions to the tier-4 plugins' + `lifecycle.on_resume` / `lifecycle.on_background` hooks. + + Subscribes to `Mob.Device`'s `:app` events and, on `:did_become_active` / + `:did_enter_background`, invokes each plugin's corresponding MFA. A plugin + that didn't declare a hook is simply skipped. Supervised by + `Mob.Plugins.Supervisor`. + """ + + use GenServer + + require Logger + + @spec start_link(term()) :: GenServer.on_start() + def start_link(_arg), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + + @impl GenServer + def init(:ok) do + Mob.Device.subscribe(:app) + {:ok, Mob.Plugins.lifecycle()} + end + + @impl GenServer + def handle_info({:mob_device, :did_become_active}, lifecycles) do + run_hooks(lifecycles, :on_resume) + {:noreply, lifecycles} + end + + def handle_info({:mob_device, :did_enter_background}, lifecycles) do + run_hooks(lifecycles, :on_background) + {:noreply, lifecycles} + end + + def handle_info(_msg, lifecycles), do: {:noreply, lifecycles} + + defp run_hooks(lifecycles, key) do + for lc <- lifecycles, mfa = lc[key], not is_nil(mfa) do + invoke(mfa, lc[:plugin], key) + end + end + + # A misbehaving hook must not take down the dispatcher (and with it every + # other plugin's hooks) — log and continue. + defp invoke({m, f, a}, plugin, key) do + apply(m, f, a) + rescue + e -> + Logger.error( + "[mob_plugins] #{inspect(plugin)} #{key} crashed: " <> + Exception.format(:error, e, __STACKTRACE__) + ) + end +end diff --git a/lib/mob/plugins/supervisor.ex b/lib/mob/plugins/supervisor.ex new file mode 100644 index 00000000..2b2e373e --- /dev/null +++ b/lib/mob/plugins/supervisor.ex @@ -0,0 +1,45 @@ +defmodule Mob.Plugins.Supervisor do + @moduledoc """ + Supervises the tier-4 plugins' lifecycle. + + On init it runs each plugin's `lifecycle.on_start` MFA in order (an error + return bubbles up and fails boot loud, per the spec), then supervises the + plugins' declared `lifecycle.supervised` child specs alongside the + `Mob.Plugins.Lifecycle` event dispatcher. Started from `Mob.App.start/0` + (just before the host's own `on_start/0`, so a host `on_start` that blocks on + a run loop can't starve plugin startup — the framework services a plugin's + `on_start` needs are already up), and only when a plugin declares a + `:lifecycle` (see `Mob.Plugins.start_lifecycle/0`). + """ + + use Supervisor + + @spec start_link(term()) :: Supervisor.on_start() + def start_link(_arg), do: Supervisor.start_link(__MODULE__, :ok, name: __MODULE__) + + @impl Supervisor + def init(:ok) do + lifecycles = Mob.Plugins.lifecycle() + run_on_start!(lifecycles) + + children = supervised_children(lifecycles) ++ [Mob.Plugins.Lifecycle] + Supervisor.init(children, strategy: :one_for_one) + end + + defp run_on_start!(lifecycles) do + for %{on_start: {m, f, a}} = lc <- lifecycles do + case apply(m, f, a) do + {:error, reason} -> + raise "plugin #{inspect(lc[:plugin])} on_start failed: #{inspect(reason)}" + + _ok -> + :ok + end + end + end + + # Flattens every plugin's `lifecycle.supervised` child specs into one list. + defp supervised_children(lifecycles) do + for lc <- lifecycles, child <- Map.get(lc, :supervised, []), do: child + end +end diff --git a/lib/mob/registry.ex b/lib/mob/registry.ex index 45e3e31c..ba2ce4bb 100644 --- a/lib/mob/registry.ex +++ b/lib/mob/registry.ex @@ -98,8 +98,6 @@ defmodule Mob.Registry do # ── Private ─────────────────────────────────────────────────────────────── defp build_initial do - Enum.reduce(@builtins, %{}, fn {name, mappings}, acc -> - Map.put(acc, name, Map.new(mappings)) - end) + Map.new(@builtins, fn {name, mappings} -> {name, Map.new(mappings)} end) end end diff --git a/lib/mob/renderer.ex b/lib/mob/renderer.ex index b013102e..3caf057c 100644 --- a/lib/mob/renderer.ex +++ b/lib/mob/renderer.ex @@ -225,7 +225,9 @@ defmodule Mob.Renderer do colors: Theme.color_map(theme), spacing: Theme.spacing_map(theme), radii: Theme.radius_map(theme), - type_scale: theme.type_scale + type_scale: theme.type_scale, + flags: Theme.flags_map(theme), + platform: platform } nif.clear_taps() @@ -254,14 +256,30 @@ defmodule Mob.Renderer do defp prepare(%{type: type, props: props, children: children}, nif, platform, ctx) do defaults = Map.get(@component_defaults, type, %{}) with_defaults = Map.merge(defaults, props) + with_theme_flags = inject_theme_flags(type, with_defaults, ctx) %{ "type" => Atom.to_string(type), - "props" => prepare_props(with_defaults, nif, platform, ctx), + "props" => prepare_props(with_theme_flags, nif, platform, ctx), "children" => Enum.map(children, &prepare(&1, nif, platform, ctx)) } end + # When the active theme has `glass: true`, mark surface-style nodes so the + # native side can swap solid fills for a translucent material (Liquid Glass + # on iOS 26+; ultraThinMaterial on iOS 17–25). Only iOS reads the flag + # today; Android receives it but ignores it (Material 3 doesn't have a + # first-class glassy surface yet). + # + # A node is "surface-style" if it has a `background:` set — that's what + # the user perceives as a card / sheet. Other nodes (text, scroll, etc.) + # pass through untouched. + defp inject_theme_flags(:box, props, %{flags: %{glass: true}}) do + if Map.has_key?(props, :background), do: Map.put(props, :glass, true), else: props + end + + defp inject_theme_flags(_type, props, _ctx), do: props + defp prepare_props(props, nif, platform, ctx) do # 1. Merge any %Mob.Style{} under the :style key (inline props win) {style, base} = Map.pop(props, :style) @@ -488,6 +506,17 @@ defmodule Mob.Renderer do Map.get(ctx.radii, value, value) end + # A `src` of the form `plugin://<plugin>/<file>` (e.g. an Image shipped by a + # tier-3 plugin) resolves to the absolute on-device bundle path the native + # Image loader reads. A non-plugin src (URL, file path, theme atom) is left + # untouched. + defp resolve_token(:src, "plugin://" <> _ = value, _ctx) do + case Mob.Plugins.resolve_image(value) do + {:ok, path} -> path + _ -> value + end + end + defp resolve_token(_key, value, _ctx), do: value # Two-step color resolution: diff --git a/lib/mob/scanner.ex b/lib/mob/scanner.ex deleted file mode 100644 index a024563d..00000000 --- a/lib/mob/scanner.ex +++ /dev/null @@ -1,47 +0,0 @@ -defmodule Mob.Scanner do - @moduledoc """ - QR code and barcode scanner. - - Requires `:camera` permission (request via `Mob.Permissions.request/2`). - - Opens a full-screen camera preview. When a code is detected the view dismisses - automatically and the result is delivered to `handle_info`: - - handle_info({:scan, :result, %{type: :qr, value: "https://..."}}, socket) - handle_info({:scan, :cancelled}, socket) - - iOS: `AVCaptureMetadataOutput`. Android: `CameraX` + ML Kit `BarcodeScanning`. - - > **Android dependency:** add to `app/build.gradle`: - > `implementation 'com.google.mlkit:barcode-scanning:17.2.0'` - > `implementation 'androidx.camera:camera-camera2:1.3.0'` - > `implementation 'androidx.camera:camera-lifecycle:1.3.0'` - > `implementation 'androidx.camera:camera-view:1.3.0'` - """ - - @type format :: - :qr - | :ean13 - | :ean8 - | :code128 - | :code39 - | :upca - | :upce - | :pdf417 - | :aztec - | :data_matrix - - @doc """ - Open the barcode scanner. - - Options: - - `formats: [format]` — list of barcode formats to detect (default `[:qr]`) - """ - @spec scan(Mob.Socket.t(), keyword()) :: Mob.Socket.t() - def scan(socket, opts \\ []) do - formats = Keyword.get(opts, :formats, [:qr]) |> Enum.map(&Atom.to_string/1) - formats_json = :json.encode(formats) - :mob_nif.scanner_scan(formats_json) - socket - end -end diff --git a/lib/mob/screen.ex b/lib/mob/screen.ex index 45ca0dc3..5ebe7719 100644 --- a/lib/mob/screen.ex +++ b/lib/mob/screen.ex @@ -1,10 +1,16 @@ defmodule Mob.Screen do @moduledoc """ - The behaviour and process wrapper for a Mob screen. - - A screen is a supervised GenServer. Its state is a `Mob.Socket`. Lifecycle - callbacks (`mount`, `render`, `handle_event`, `handle_info`, `terminate`) map - directly to the GenServer lifecycle. + Behaviour and GenServer wrapper for a Mob screen. + + Each screen runs as a supervised GenServer whose state is a `Mob.Socket`. + Putting one process per screen — instead of one big process for the whole + app — gives you isolation: a buggy `handle_event` crashes its own screen + and the supervisor restarts it without taking down navigation, audio, + background services, or the BEAM itself. Lifecycle callbacks (`mount`, + `render`, `handle_event`, `handle_info`, `terminate`) map directly to the + GenServer lifecycle, so the BEAM's existing concurrency tools (selective + receive, monitors, hot code push) work on screens without any Mob-specific + scaffolding. ## Usage @@ -112,10 +118,46 @@ defmodule Mob.Screen do "Add a handle_event/3 clause to handle it." end + @before_compile Mob.Screen defoverridable dump_state: 1, load_state: 2, handle_info: 2, terminate: 2, handle_event: 3 end end + defmacro __before_compile__(env) do + template = Path.rootname(env.file) <> ".mob.heex" + + cond do + Module.defines?(env.module, {:render, 1}) -> + quote(do: :ok) + + File.exists?(template) -> + source = + template + |> File.read!() + |> String.split("\n") + |> Enum.map_join("\n", &(" " <> &1)) + + render_ast = + Code.string_to_quoted!(""" + def render(assigns) do + import Mob.Sigil + + ~MOB\"\"\" + #{source} + \"\"\" + end + """) + + quote do + @external_resource unquote(template) + unquote(render_ast) + end + + true -> + quote(do: :ok) + end + end + # ── GenServer wrapper ───────────────────────────────────────────────────── use GenServer @@ -384,6 +426,16 @@ defmodule Mob.Screen do handle_info(msg, state) end + # Peripheral.* events: a few carry JSON-encoded device records under tags + # like `:devices_json`, `:permission_granted_json`, etc. The transport's + # own module knows how to decode them; we dispatch through its + # `normalize_message/1` (a no-op for events without JSON payloads) before + # the user's handle_info sees them. + def handle_info({:peripheral, :vendor_usb, _tag, _session, _payload} = msg, state) do + normalized = Mob.VendorUsb.normalize_message(msg) + handle_info(normalized, state) + end + # System back gesture (Android hardware/swipe, iOS edge-pan). # Handled here — before the user's handle_info — so every screen gets back # navigation for free without implementing anything. @@ -455,7 +507,21 @@ defmodule Mob.Screen do {:noreply, {module, socket, nav_history, render_mode}} end - def handle_info(message, {module, socket, nav_history, render_mode}) do + # Plugin notification routing: the activated plugins' handlers get first crack + # at every `{:notification, payload}`. A plugin whose `:match` matches handles + # it and the host screen does not also see it; an unmatched notification falls + # through to the screen's own `handle_info` like any other message. + def handle_info({:notification, payload} = message, {_module, _socket, _nh, _rm} = state) + when is_map(payload) do + case Mob.Plugins.dispatch_notification(payload) do + :handled -> {:noreply, state} + :unhandled -> forward_to_screen(message, state) + end + end + + def handle_info(message, state), do: forward_to_screen(message, state) + + defp forward_to_screen(message, {module, socket, nav_history, render_mode}) do {:noreply, new_socket} = module.handle_info(message, socket) {module, new_socket, nav_history, transition} = @@ -491,14 +557,14 @@ defmodule Mob.Screen do {module, socket, nav_history, :none} {:push, dest, params} -> - new_module = resolve_module(dest) + {new_module, route_params} = resolve_destination(dest) platform = socket.__mob__.platform new_base = Mob.Socket.new(new_module, platform: platform) |> Mob.Socket.assign(:safe_area, socket.assigns.safe_area) - {:ok, mounted} = new_module.mount(params, %{}, new_base) + {:ok, mounted} = new_module.mount(Map.merge(route_params, params), %{}, new_base) saved = {module, clear_nav_action(socket)} {new_module, mounted, [saved | nav_history], :push} @@ -532,14 +598,14 @@ defmodule Mob.Screen do end {:reset, dest, params} -> - new_module = resolve_module(dest) + {new_module, route_params} = resolve_destination(dest) platform = socket.__mob__.platform new_base = Mob.Socket.new(new_module, platform: platform) |> Mob.Socket.assign(:safe_area, socket.assigns.safe_area) - {:ok, mounted} = new_module.mount(params, %{}, new_base) + {:ok, mounted} = new_module.mount(Map.merge(route_params, params), %{}, new_base) {new_module, mounted, [], :reset} {:switch_tab, _tab} -> @@ -549,16 +615,25 @@ defmodule Mob.Screen do end defp resolve_module(dest) when is_atom(dest) do + {module, _route_params} = resolve_destination(dest) + module + end + + # Resolves a navigation destination to {module, route_params}. A loaded + # module navigates directly (no route-bound params); a registered route atom + # may carry params (Mob.Nav.Registry.register/3 — the data-driven-plugin + # pattern), merged UNDER the caller's push params at the mount call site. + defp resolve_destination(dest) when is_atom(dest) do case Code.ensure_loaded(dest) do {:module, ^dest} -> # dest is a loaded module — use it directly - dest + {dest, %{}} _ -> # dest is a registered screen name atom — look up in registry - case Mob.Nav.Registry.lookup(dest) do - {:ok, module} -> - module + case Mob.Nav.Registry.lookup_route(dest) do + {:ok, module, route_params} -> + {module, route_params} {:error, :not_found} -> raise ArgumentError, @@ -651,6 +726,9 @@ defmodule Mob.Screen do {tree, active_component_keys} = module.render(socket.assigns) + # Third expansion pass FIRST: pure-Elixir composites may themselves emit + # <List> nodes / native_view components for the later passes. + |> Mob.Composite.expand(self()) |> Mob.List.expand(list_renderers, self()) |> Mob.Component.expand(self(), platform) diff --git a/lib/mob/screen_case.ex b/lib/mob/screen_case.ex new file mode 100644 index 00000000..c4f80064 --- /dev/null +++ b/lib/mob/screen_case.ex @@ -0,0 +1,315 @@ +defmodule Mob.ScreenCase do + @moduledoc """ + The blessed way to unit-test a `Mob.Screen` in the BEAM, no device or + emulator required. The screen-level analog of `Phoenix.LiveViewTest`. + + A `Mob.Screen` is a GenServer-shaped module: `mount/3` builds state, + `handle_event/3` and `handle_info/2` mutate it, and `render/1` turns the + assigns into a **view tree** (plain data: `%{type:, props:, children:}`). That + last part is the key difference from LiveView: the screen produces a typed + data structure, not an HTML string, so assertions are tree queries against + real data instead of brittle string matching. + + This module drives those callbacks directly (the same thing the on-device + runtime does) and gives you query helpers whose vocabulary matches `Mob.Test` + (the device-side driver): `assigns/1`, `tree/1`, `find/3`, `flatten/1`. So a + test reads the same whether it runs here in milliseconds or, later, against a + real device. + + defmodule MyApp.CounterScreenTest do + use Mob.ScreenCase + + test "increment bumps the count and the rendered text" do + view = mount_screen(MyApp.CounterScreen) + assert assigns(view).count == 0 + + view = render_event(view, "increment") + assert assigns(view).count == 1 + assert text(view) =~ "Count: 1" + assert find(view, :button, tag: "increment") + + # cheap native-contract check: every node the screen emits is a + # type the Compose / SwiftUI layer actually renders. + assert_renderable(view) + end + end + + ## What this does and does not catch + + This is tier 1 of the testing pyramid: it exercises **logic, state, and the + shape of the view tree**, fast and deterministically. `assert_renderable/2` + adds a tier-2 **contract** check (does the tree only use renderable node + types). Neither runs the native layer, so they cannot catch a node that + renders wrong or behaves wrong on a real iOS/Android build. That needs a + device test driven through `Mob.Test`. Weight your suite heavily toward this + module, with a thin band of device tests for the things only hardware proves. + """ + + use ExUnit.CaseTemplate + + using do + quote do + import Mob.ScreenCase + end + end + + # Many screens read or write `Mob.State` in `mount/3` (the home screen reads + # the theme, for one), and it is DETS-backed, so without it open every such + # screen crashes in `mix test` with a `:dets` argument error. Start it per + # test against a throwaway data dir, the same way ConnCase starts the Ecto + # sandbox, so screen tests just work and never touch the app's real dev state. + setup do + if Process.whereis(Mob.State) == nil do + tmp = Path.join(System.tmp_dir!(), "mob_screen_case_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + System.put_env("MOB_DATA_DIR", tmp) + ExUnit.Callbacks.start_supervised!(Mob.State) + ExUnit.Callbacks.on_exit(fn -> File.rm_rf(tmp) end) + end + + :ok + end + + defmodule View do + @moduledoc """ + A screen under test. Two backends, same query/assertion surface: + + * `source: :beam` — `module` + `socket`, driven in-process (the default, + built by `mount_screen/3`). + * `source: :device` — a `node` running the app, read over `Mob.Test`'s + Erlang-distribution RPC (built by `device_view/1`, gated behind + `@tag :on_device`). + + `tree/1`, `assigns/1`, `find/3`, `text/1`, `assert_renderable/2` and + `navigated_to/1` work against either, so an assertion reads the same whether + it ran here in milliseconds or against real hardware. + """ + @enforce_keys [:source] + defstruct [:source, :module, :socket, :node] + end + + # Renderable node types, derived at compile time from the same authoritative + # source the ~MOB sigil validates against (priv/tags/{ios,android}.txt, one + # PascalCase tag per line, converted to the snake_case `:type` atom the same + # way the sigil does). Plus `:native_view`, the runtime-only escape hatch that + # plugin / custom components serialize to and which has no template tag. + @renderable_types ( + read = fn name -> + path = Application.app_dir(:mob, "priv/tags/#{name}") + + case File.read(path) do + {:ok, body} -> + body + |> String.split("\n", trim: true) + |> Enum.reject(&(&1 == "" or String.starts_with?(&1, "#"))) + |> Enum.map(&(&1 |> Macro.underscore() |> String.to_atom())) + + _ -> + [] + end + end + + (read.("ios.txt") ++ read.("android.txt") ++ [:native_view]) + |> MapSet.new() + ) + + @doc """ + The set of node types the native layer can render: the core component tags + plus `:native_view`. The contract surface `assert_renderable/2` checks against. + """ + @spec renderable_types() :: MapSet.t(atom()) + def renderable_types, do: @renderable_types + + # ── Driving a screen ─────────────────────────────────────────────────────── + + @doc """ + Mount a screen and return a `View` handle. Calls `Mob.Socket.new/1` then the + screen's `mount/3`, asserting it returns `{:ok, socket}`. + """ + @spec mount_screen(module(), map(), map()) :: View.t() + def mount_screen(module, params \\ %{}, session \\ %{}) when is_atom(module) do + socket = Mob.Socket.new(module) + + case module.mount(params, session, socket) do + {:ok, %Mob.Socket{} = socket} -> + %View{source: :beam, module: module, socket: socket} + + other -> + raise ArgumentError, + "#{inspect(module)}.mount/3 must return {:ok, socket}, got: #{inspect(other)}" + end + end + + @doc """ + Wrap a running device `node` as a `View` so the query/assertion helpers read + it over `Mob.Test`'s Erlang-distribution RPC. The device-backed counterpart to + `mount_screen/3`. Use behind `@tag :on_device`; get the `node` from + `mix mob.connect` / `Mob.Test`. Driving (navigate, tap) stays on `Mob.Test`; + this is for asserting against the live screen with the same helpers. + + @tag :on_device + test "the live home screen is renderable" do + node = :"my_app_android@127.0.0.1" + Mob.Test.navigate(node, MyApp.HomeScreen) + view = device_view(node) + assert_renderable(view) + assert navigated_to(view) == MyApp.HomeScreen + end + """ + @spec device_view(node()) :: View.t() + def device_view(node) when is_atom(node), do: %View{source: :device, node: node} + + @doc """ + Dispatch a `handle_event/3` (the explicit-event style) and return the updated + `View`. In-BEAM only; on a device, drive with `Mob.Test.tap/2`. + """ + @spec render_event(View.t(), String.t(), map()) :: View.t() + def render_event( + %View{source: :beam, module: module, socket: socket} = view, + event, + params \\ %{} + ) + when is_binary(event) do + {:noreply, socket} = module.handle_event(event, params, socket) + %{view | socket: socket} + end + + @doc """ + Deliver a message to the screen's `handle_info/2` and return the updated + `View`. This is how taps reach a screen on device (a `Button`'s `on_tap` + sends a message), so it is the in-BEAM equivalent of a tap. In-BEAM only. + """ + @spec render_info(View.t(), term()) :: View.t() + def render_info(%View{source: :beam, module: module, socket: socket} = view, message) do + {:noreply, socket} = module.handle_info(message, socket) + %{view | socket: socket} + end + + @doc "The screen's current assigns. Mirrors `Mob.Test.assigns/1` (and uses it on device)." + @spec assigns(View.t()) :: map() + def assigns(%View{source: :beam, socket: socket}), do: socket.assigns + def assigns(%View{source: :device, node: node}), do: Mob.Test.assigns(node) + + @doc """ + The screen the last event navigated to, or `nil` if none. + + Returns the destination **module** on both backends, so the same assertion + reads identically whether the test ran in-BEAM or against a device: + + assert navigated_to(view) == MyApp.CounterScreen + + * in-BEAM: the destination of the nav action recorded on the socket by + `Mob.Socket.push_screen/3` and friends. Destination-bearing actions + (`{:push, Dest, _}`, `{:reset, Dest, _}`, `{:pop_to, Dest}`) return + `Dest`; destinationless ones (`{:pop}`, `{:pop_to_root}`, + `{:switch_tab, tab}`) return the raw action unchanged. + * on device: the screen currently showing (`Mob.Test.screen/1`). + """ + @spec navigated_to(View.t()) :: term() | nil + def navigated_to(%View{source: :beam, socket: socket}) do + case Map.get(socket.__mob__, :nav_action) do + {:push, dest, _params} -> dest + {:reset, dest, _params} -> dest + {:pop_to, dest} -> dest + other -> other + end + end + + def navigated_to(%View{source: :device, node: node}), do: Mob.Test.screen(node) + + # ── Querying the rendered tree ─────────────────────────────────────────────── + + @doc "The current view tree, from in-BEAM render or the device. Mirrors `Mob.Test.tree/1`." + @spec tree(View.t() | map()) :: map() + def tree(%View{source: :beam, module: module, socket: socket}), + do: module.render(socket.assigns) + + def tree(%View{source: :device, node: node}), do: Mob.Test.tree(node) + def tree(%{type: _} = node), do: node + + @doc "Every node in the tree, depth-first. Mirrors `Mob.Test.flatten_tree/1`." + @spec flatten(View.t() | map()) :: [map()] + def flatten(view_or_tree), do: do_flatten(tree(view_or_tree)) + + defp do_flatten(%{type: _} = node) do + children = Map.get(node, :children, []) || [] + [node | Enum.flat_map(List.wrap(children), &do_flatten/1)] + end + + defp do_flatten(_), do: [] + + @doc """ + All nodes of `type` whose props are a superset of `props`. Mirrors + `Mob.Test.find/2`, but matches on the typed tree rather than a substring. + + find_all(view, :button, tag: "increment") + """ + @spec find_all(View.t() | map(), atom(), keyword()) :: [map()] + def find_all(view_or_tree, type, props \\ []) when is_atom(type) do + want = Map.new(props) + + view_or_tree + |> flatten() + |> Enum.filter(fn node -> + node.type == type and props_match?(Map.get(node, :props, %{}), want) + end) + end + + @doc "The first node matching `find_all/3`, or `nil`." + @spec find(View.t() | map(), atom(), keyword()) :: map() | nil + def find(view_or_tree, type, props \\ []) do + view_or_tree |> find_all(type, props) |> List.first() + end + + @doc "Concatenated text of every `:text` node in the tree, joined by spaces." + @spec text(View.t() | map()) :: String.t() + def text(view_or_tree) do + view_or_tree + |> find_all(:text) + |> Enum.map(&(&1.props[:text] || "")) + |> Enum.join(" ") + end + + defp props_match?(have, want) do + Enum.all?(want, fn {k, v} -> Map.get(have, k) == v end) + end + + # ── The native contract check (tier 2) ────────────────────────────────────── + + @doc """ + Assert every node in the tree is a type the native layer can render. Returns + the tree on success so it composes; flunks (with the offending types) if a + node uses a type that has no Compose / SwiftUI renderer. + + This catches the "you emitted a node the native side can't draw" class of bug + at `mix test` time, no device needed. Pass extra types a plugin or your own + app registers via `:extra`: + + assert_renderable(view, extra: [:gauge]) + """ + @spec assert_renderable(View.t() | map(), keyword()) :: map() + def assert_renderable(view_or_tree, opts \\ []) do + tree = tree(view_or_tree) + allowed = MapSet.union(@renderable_types, MapSet.new(Keyword.get(opts, :extra, []))) + + offenders = + tree + |> do_flatten() + |> Enum.map(& &1.type) + |> Enum.uniq() + |> Enum.reject(&MapSet.member?(allowed, &1)) + + if offenders == [] do + tree + else + ExUnit.Assertions.flunk(""" + view tree uses node type(s) the native layer cannot render: #{inspect(offenders)} + + Renderable types come from mob's priv/tags/{ios,android}.txt (plus :native_view). + If one of these is a plugin or custom component, pass it via + `assert_renderable(view, extra: #{inspect(offenders)})`. Otherwise it is + likely a typo or a component with no registered native renderer. + """) + end + end +end diff --git a/lib/mob/sigil.ex b/lib/mob/sigil.ex index 6dbaba28..9192f903 100644 --- a/lib/mob/sigil.ex +++ b/lib/mob/sigil.ex @@ -31,6 +31,48 @@ defmodule Mob.Sigil do </Column> \""" + ## Assigns shorthand + + Inside a `{...}` expression, `@foo` rewrites to `assigns.foo` (matching + Phoenix HEEx), so a `render(assigns)` body reads cleanly: + + ~MOB(<Text text={@title} />) # same as text={assigns.title} + + `@foo` only works where a variable named `assigns` is in scope — i.e. a + screen's or component's `render(assigns)`. Reusable helper functions take + positional arguments instead, so use the argument directly there: + + # Screen render — assigns is in scope, @ works: + def render(assigns), do: ~MOB(<Text text={@title} />) + + # Helper — no assigns; interpolate the argument, don't use @: + def label(title), do: ~MOB(<Text text={title} />) + + Using `@foo` where no `assigns` exists raises a `CompileError` naming the + fix, rather than a cryptic "undefined variable assigns". + + ## Control attributes — `:if` and `:for` + + Two LiveView-style directives wrap an element without extra ceremony. + Both take a `{expr}` value and may read `@assigns`. + + # Conditional — omitted entirely when the expression is falsy + ~MOB(<Badge text="New" :if={@unread > 0} />) + + # Comprehension — one element per item; splices into the parent + ~MOB\""" + <Column> + <Row :for={user <- @users}> + <Text text={user.name} /> + </Row> + </Column> + \""" + + Combine them — `:if` then acts as a comprehension filter (an element is + produced only for items where the condition holds): + + <Text text={n} :for={n <- @nums} :if={rem(n, 2) == 0} /> + ## Tag whitelist Tags are validated against `priv/tags/ios.txt` and `priv/tags/android.txt` at @@ -82,17 +124,27 @@ defmodule Mob.Sigil do |> reduce({List, :to_string, []}) |> label("tag name starting with uppercase letter") - # Attribute name + # Attribute name. An optional leading `:` marks a control attribute + # (`:if`, `:for`) — LiveView-style directives handled specially by the AST + # builder rather than emitted as a prop. attr_name = - ascii_char([?a..?z, ?A..?Z, ?_]) + optional(ascii_char([?:])) + |> ascii_char([?a..?z, ?A..?Z, ?_]) |> ascii_string([?a..?z, ?A..?Z, ?0..?9, ?_], min: 0) |> reduce({List, :to_string, []}) |> label("attribute name") # String attribute value: "..." + # + # `utf8_string/2` (not `ascii_string/2`) so non-ASCII bytes in the + # template source — em-dash, en-dash, smart quotes, accented letters, + # emoji — are matched as UTF-8 codepoints and emitted as UTF-8 binary + # segments. `ascii_string([not: ?"])` accepts those bytes but its + # `integer` body re-encodes each one as a Latin-1 codepoint then UTF-8 + # (so `–` E2 80 93 comes out as C3 A2 C2 80 C2 93 — double-encoded). string_value = ignore(ascii_char([?"])) - |> ascii_string([not: ?"], min: 0) + |> utf8_string([not: ?"], min: 0) |> ignore(ascii_char([?"])) |> tag(:string_val) @@ -167,11 +219,15 @@ defmodule Mob.Sigil do # Balanced brace content: captures everything between an outer { } pair, # preserving inner { } pairs recursively. Returns a single joined string. # Used by expr_value and expr_child so that {%{a: 1}} and {fn -> ... end} work. + # `utf8_string/2` (not `ascii_string/2`) — same reason as `string_value` + # above: brace content can be arbitrary Elixir source including literal + # non-ASCII strings, and `ascii_string` would double-encode those bytes + # before they reach `Code.string_to_quoted!/2`. defparsec( :brace_content, repeat( choice([ - ascii_string([not: ?{, not: ?}], min: 1), + utf8_string([not: ?{, not: ?}], min: 1), string("{") |> parsec(:brace_content) |> string("}") @@ -228,9 +284,11 @@ defmodule Mob.Sigil do defp build_ast({:self_closing, parts}, caller) do {tag, attrs} = split_tag_attrs(parts) + {control, attrs} = split_control_attrs(attrs, caller) type = resolve_type(tag, caller) props = build_props_ast(attrs, caller) - quote do: %{type: unquote(type), props: unquote(props), children: []} + node = quote do: %{type: unquote(type), props: unquote(props), children: []} + wrap_control(node, control, caller) end defp build_ast({:element, parts}, caller) do @@ -250,15 +308,82 @@ defmodule Mob.Sigil do description: "~MOB: mismatched tags <#{tag}> ... </#{close_name}>" end + {control, attrs} = split_control_attrs(attrs, caller) type = resolve_type(tag, caller) props = build_props_ast(attrs, caller) children_ast = build_children_ast(rest2, caller) - quote do: %{type: unquote(type), props: unquote(props), children: unquote(children_ast)} + node = + quote do: %{type: unquote(type), props: unquote(props), children: unquote(children_ast)} + + wrap_control(node, control, caller) end defp split_tag_attrs([tag | attrs]), do: {tag, attrs} + # Partition control attributes (`:if`, `:for`) out of the normal attr list. + # Returns `{%{if: value_tag, for: value_tag}, remaining_attrs}`. Control + # attrs never become props; they wrap the node in `if`/`for` instead. + defp split_control_attrs(attrs, caller) do + {control, rev_normal} = + Enum.reduce(attrs, {%{}, []}, fn + {:attr, [":if", value_tag]}, {control, normal} -> + {Map.put(control, :if, value_tag), normal} + + {:attr, [":for", value_tag]}, {control, normal} -> + {Map.put(control, :for, value_tag), normal} + + {:attr, [":" <> bad, _value]}, _acc -> + raise CompileError, + file: caller.file, + line: caller.line, + description: + "~MOB: unknown control attribute :#{bad} (only :if and :for are supported)" + + attr, {control, normal} -> + {control, [attr | normal]} + end) + + {control, Enum.reverse(rev_normal)} + end + + # Wrap a node's AST in a `:for` comprehension and/or `:if` guard. When both + # are present, `:if` acts as a comprehension filter (LiveView semantics): + # the element is produced for each item where the condition holds. A bare + # `:if` that fails yields `nil`, which `wrap_child/1` drops from its parent. + defp wrap_control(node_ast, control, caller) do + # Branch on attr *presence* (the value-tag), never on the parsed expr — + # `:if={false}` parses to the literal `false`, which would otherwise + # short-circuit a truthiness check and skip the wrapping entirely. + cond do + control[:for] && control[:if] -> + for_ast = parse_control_expr(:for, control[:for], caller) + if_ast = parse_control_expr(:if, control[:if], caller) + quote do: for(unquote(for_ast), unquote(if_ast), do: unquote(node_ast)) + + control[:for] -> + for_ast = parse_control_expr(:for, control[:for], caller) + quote do: for(unquote(for_ast), do: unquote(node_ast)) + + control[:if] -> + if_ast = parse_control_expr(:if, control[:if], caller) + quote do: if(unquote(if_ast), do: unquote(node_ast)) + + true -> + node_ast + end + end + + defp parse_control_expr(_which, {:expr_val, [expr_str]}, caller), + do: parse_expr(expr_str, caller) + + defp parse_control_expr(which, {:string_val, _}, caller) do + raise CompileError, + file: caller.file, + line: caller.line, + description: "~MOB: :#{which} requires a {expr} value, e.g. :#{which}={...}" + end + defp build_props_ast(attrs, caller) do pairs = Enum.map(attrs, fn {:attr, [name, value_tag]} -> @@ -272,32 +397,96 @@ defmodule Mob.Sigil do defp build_value_ast({:string_val, [str]}, _caller), do: str - defp build_value_ast({:expr_val, [expr_str]}, caller) do - Code.string_to_quoted!(String.trim(expr_str), file: caller.file, line: caller.line) + defp build_value_ast({:expr_val, [expr_str]}, caller), do: parse_expr(expr_str, caller) + + # Parse a `{expr}` source string into an AST, expanding LiveView-style + # `@foo` references into `assigns.foo`. Applies to attribute values, + # `{expr}` children, and `:if`/`:for` control expressions alike. + defp parse_expr(expr_str, caller) do + expr_str + |> String.trim() + |> Code.string_to_quoted!(file: caller.file, line: caller.line) + |> expand_assigns(caller) + end + + # Rewrite every `@name` (the unary `@` operator on a bare identifier) to + # `assigns.name`, mirroring HEEx. Nested forms like `@user.name` rewrite + # too, since prewalk reaches the inner `@user` node first. + # + # `@foo` only makes sense where a variable named `assigns` is in scope — a + # screen/component `render(assigns)`. In an ordinary helper (positional args, + # the idiomatic composite pattern) there is no `assigns`, and a bare rewrite + # would compile to a cryptic "undefined variable assigns". So before + # rewriting, verify `assigns` is bound in the caller (same guard Phoenix's + # `~H` uses) and raise a message that names the fix. Only `@`-using templates + # trigger this — a static `~MOB(<Text text="hi"/>)` needs no assigns. + defp expand_assigns(ast, caller) do + Macro.prewalk(ast, fn + {:@, _meta, [{name, _, ctx}]} when is_atom(name) and (is_atom(ctx) or is_nil(ctx)) -> + unless Macro.Env.has_var?(caller, {:assigns, nil}) do + raise CompileError, + file: caller.file, + line: caller.line, + description: + "~MOB: @#{name} requires a variable named \"assigns\" in scope " <> + "(e.g. inside `render(assigns)`). In a helper function, take the " <> + "value as an argument and interpolate it directly — `{#{name}}` " <> + "instead of `@#{name}`." + end + + # Build `assigns.name` with a nil-context `assigns` var so it + # resolves to the caller's binding (same as a literal `assigns` + # parsed from source), not a hygienic Mob.Sigil-scoped variable. + # `no_parens: true` marks it as map-field access, not `assigns.name()`. + {{:., [], [Macro.var(:assigns, nil), name]}, [no_parens: true], []} + + other -> + other + end) end defp build_children_ast(children, caller) do child_asts = Enum.map(children, fn {:expr_child, [expr_str]} -> - quoted = - Code.string_to_quoted!(String.trim(expr_str), file: caller.file, line: caller.line) - - quote do - case unquote(quoted) do - list when is_list(list) -> list - node -> [node] - end - end + quoted = parse_expr(expr_str, caller) + + # Emit a call to `wrap_child/1` rather than an inline `case`. + # The inline version generates a `case` per call site whose + # `is_list(list)` clause is type-narrowed to "unreachable" + # whenever the user's expression has a static-shape return + # (e.g. `nav_button("Foo", :bar)` is always a map). The + # warning is correct in isolation but the multi-shape + # tolerance is the WHOLE POINT of {expr} children — users + # can write `Enum.map(items, &row/1)` and get list-flattened + # behaviour. Dispatching via a helper hides the + # type-narrowing from the per-call-site warning while + # preserving both shapes' runtime behaviour. + quote do: Mob.Sigil.wrap_child(unquote(quoted)) node_tuple -> + # A node may now be a bare map, a `:for` list, or a `:if` nil after + # control-attr wrapping. Route it through wrap_child/1 so all three + # normalize to a list before the surrounding List.flatten. ast = build_ast(node_tuple, caller) - quote do: [unquote(ast)] + quote do: Mob.Sigil.wrap_child(unquote(ast)) end) quote do: List.flatten(unquote(child_asts)) end + @doc """ + Normalizes a child's value to a list of UI-node maps for the + surrounding sigil. Single nodes wrap into a one-element list; lists + pass through; `nil` (a `:if` that didn't render) drops to `[]`. + Public so the sigil-generated AST can call it by FQ name; not part + of the application API. + """ + @spec wrap_child(list() | map() | nil) :: list() + def wrap_child(list) when is_list(list), do: list + def wrap_child(nil), do: [] + def wrap_child(node), do: [node] + defp resolve_type(tag, caller) do atom = tag |> Macro.underscore() |> String.to_atom() diff --git a/lib/mob/socket.ex b/lib/mob/socket.ex index b1e7c677..8ebcf13a 100644 --- a/lib/mob/socket.ex +++ b/lib/mob/socket.ex @@ -4,9 +4,8 @@ defmodule Mob.Socket do Holds two things: - `assigns` — the public data map your `render/1` function reads via - `assigns.foo` (the `~MOB` sigil does not support Phoenix HEEx's - `@foo` shortcut — `@foo` inside `render/1` resolves to a module - attribute, which is almost always `nil`) + `assigns.foo`, or the `@foo` shorthand inside a `~MOB` template + (the sigil rewrites `@foo` to `assigns.foo`, matching Phoenix HEEx) - `__mob__` — internal Mob metadata (screen module, platform, view refs, nav stack) You interact with a socket via `assign/2` and `assign/3`. Never mutate `__mob__` @@ -81,6 +80,37 @@ defmodule Mob.Socket do %{socket | assigns: Map.merge(assigns, Map.new(kw))} end + @doc """ + Update an existing assign by applying `fun` to its current value. + + socket = update(socket, :count, fn count -> count + 1 end) + + Raises `KeyError` if `key` is not already assigned. Mirrors + `Phoenix.LiveView.update/3`. + """ + @spec update(t(), atom(), (term() -> term())) :: t() + def update(%__MODULE__{assigns: assigns} = socket, key, fun) + when is_atom(key) and is_function(fun, 1) do + %{socket | assigns: Map.put(assigns, key, fun.(Map.fetch!(assigns, key)))} + end + + @doc """ + Assign `key` only if it is not already present, computing the value lazily. + + socket = assign_new(socket, :user, fn -> fetch_user(id) end) + + `fun` runs only when `key` is absent, so it's the cheap way to set a default + or memoize a lookup across re-renders. Mirrors `Phoenix.LiveView.assign_new/3`. + """ + @spec assign_new(t(), atom(), (-> term())) :: t() + def assign_new(%__MODULE__{assigns: assigns} = socket, key, fun) + when is_atom(key) and is_function(fun, 0) do + case assigns do + %{^key => _} -> socket + _ -> %{socket | assigns: Map.put(assigns, key, fun.())} + end + end + @doc """ Store the root view ref returned by the renderer into `__mob__.root_view`. Called internally after the initial render. diff --git a/lib/mob/speech.ex b/lib/mob/speech.ex new file mode 100644 index 00000000..d7c8f59a --- /dev/null +++ b/lib/mob/speech.ex @@ -0,0 +1,59 @@ +defmodule Mob.Speech do + @moduledoc """ + Text-to-speech. No permission required on either platform. + + ## Usage + + def handle_event("read_aloud", _params, socket) do + Mob.Speech.speak(socket, socket.assigns.article_text) + {:noreply, socket} + end + + Stop mid-utterance: + + Mob.Speech.stop_speaking(socket) + + ## Options + + | Option | Type | Meaning | Default | + |----------|---------|------------------------------------------|---------| + | `:rate` | float | Speech rate (0.0–1.0, platform-scaled) | system | + | `:pitch` | float | Pitch multiplier (0.5–2.0) | 1.0 | + | `:voice` | binary | BCP-47 language/voice id (e.g. `"en-US"`)| system | + + Calling `speak/3` while speech is in progress enqueues the new utterance. + iOS uses `AVSpeechSynthesizer`; Android uses `TextToSpeech`. + """ + + @opt_keys [:rate, :pitch, :voice] + + @doc """ + Speak `text` aloud. Fire-and-forget; returns the socket unchanged so it can be + used inline without disrupting a `handle_event`/`handle_info` return value. + + Mob.Speech.speak(socket, "Returns a list.", rate: 0.5) + """ + @spec speak(Mob.Socket.t(), binary(), keyword()) :: Mob.Socket.t() + def speak(socket, text, opts \\ []) when is_binary(text) and is_list(opts) do + :mob_nif.tts_speak(text, :json.encode(speak_opts(opts))) + socket + end + + @doc "Stop any in-progress speech immediately. Returns the socket." + @spec stop_speaking(Mob.Socket.t()) :: Mob.Socket.t() + def stop_speaking(socket) do + :mob_nif.tts_stop() + socket + end + + @doc false + # Whitelist + stringify known options into a JSON-encodable map. Unknown keys + # are dropped so a typo can't reach the native layer as a surprise option. + # Public-but-undocumented so the encoding can be unit-tested without the NIF. + @spec speak_opts(keyword()) :: %{optional(String.t()) => term()} + def speak_opts(opts) do + opts + |> Keyword.take(@opt_keys) + |> Map.new(fn {k, v} -> {Atom.to_string(k), v} end) + end +end diff --git a/lib/mob/state.ex b/lib/mob/state.ex index ebf58f15..1e1839b8 100644 --- a/lib/mob/state.ex +++ b/lib/mob/state.ex @@ -167,12 +167,6 @@ defmodule Mob.State do # ── Private ──────────────────────────────────────────────────────────────── defp state_path do - data_dir = - System.get_env("MOB_DATA_DIR") || - System.get_env("HOME") || - Path.join(File.cwd!(), "priv/repo") - - File.mkdir_p!(data_dir) - Path.join(data_dir, "mob_state.dets") + Path.join(Mob.data_dir(), "mob_state.dets") end end diff --git a/lib/mob/test.ex b/lib/mob/test.ex index fb7bced6..5b20157a 100644 --- a/lib/mob/test.ex +++ b/lib/mob/test.ex @@ -28,6 +28,17 @@ defmodule Mob.Test do # Lists Mob.Test.select(node, :my_list, 0) # select first row + # Visual capture + scroll (in-process, over dist — no adb/xcrun) + {:ok, png} = Mob.Test.screenshot(node) + Mob.Test.scroll_info(node, "feed") # offset/content/viewport + Mob.Test.scroll_to(node, "feed", :bottom) + Mob.Test.screenshot_tour(node, "feed") # page top→bottom, capture each + + # Element positions without a screenshot (elements need an :id) + Mob.Test.element_frames(node) # %{id => {x, y, w, h}} + Mob.Test.frame(node, "save") # {x, y, w, h} + Mob.Test.tap_id(node, "save") # drive by id at real coords + # Device API simulation Mob.Test.send_message(node, {:permission, :camera, :granted}) Mob.Test.send_message(node, {:camera, :photo, %{path: "/tmp/photo.jpg", width: 1920, height: 1080}}) @@ -847,6 +858,308 @@ defmodule Mob.Test do end end + # ── In-process visual capture + scroll control ─────────────────────────────── + # + # Remote-driving primitives: a connected agent gets pixels and deterministic + # scroll over Erlang distribution, with no adb / xcrun / idb. These call + # mob_nif directly via RPC. screenshot returns the raw image bytes, which + # cross the dist boundary fine (the same path camera frames already take). + + @doc """ + Capture the running app's own window in-process and return the image bytes. + + Returns `{:ok, binary}` (PNG or JPEG) or `{:error, reason}`. The bytes come + back over Erlang distribution — no `adb screencap` / `xcrun simctl io`, so it + works against a remote device an agent can only reach over dist. + + Options: + + * `:format` — `:png` (default) or `:jpeg` + * `:quality` — `0..100`, JPEG only (default `90`) + * `:scale` — output scale factor (default `1.0`); `0.5` halves resolution + + Captures only the app's own surface, not system layers or other processes. + Secure text fields (iOS) and `FLAG_SECURE` windows (Android) render blank by + OS policy. A backgrounded app has no live window, so this fails when the app + is not foregrounded. + + {:ok, png} = Mob.Test.screenshot(node) + File.write!("/tmp/shot.png", png) + + {:ok, jpg} = Mob.Test.screenshot(node, format: :jpeg, quality: 60, scale: 0.5) + """ + @spec screenshot(node(), keyword()) :: {:ok, binary()} | {:error, term()} + def screenshot(node, opts \\ []) do + %{format: format, quality: quality, scale: scale} = normalize_screenshot_opts(opts) + + case :rpc.call(node, :mob_nif, :screenshot, [format, quality, scale]) do + bin when is_binary(bin) -> {:ok, bin} + {:error, _} = err -> err + other -> {:error, other} + end + end + + @doc false + # Pure: keyword opts -> the {format, quality, scale} args the NIF expects. + @spec normalize_screenshot_opts(keyword()) :: + %{format: :png | :jpeg, quality: 0..100, scale: float()} + def normalize_screenshot_opts(opts) do + format = + case Keyword.get(opts, :format, :png) do + f when f in [:png, :jpeg] -> + f + + other -> + raise ArgumentError, + "screenshot format must be :png or :jpeg, got: #{Kernel.inspect(other)}" + end + + quality = opts |> Keyword.get(:quality, 90) |> clamp_int(0, 100) + scale = opts |> Keyword.get(:scale, 1.0) |> Kernel.*(1.0) + %{format: format, quality: quality, scale: scale} + end + + @doc """ + Read a scroll view's current offset and extent, addressed by its `:id` prop + (the same `:id` you set on a `type: :scroll` or `type: :list` node). + + Returns a map, or `{:error, reason}`: + + %{ + offset: {x, y}, # current scroll position + content: {w, h}, # full scrollable content size + viewport: {w, h}, # visible area + max_offset: {x, y}, # offset at the bottom/right edge + kind: :pixel | :index + } + + `:kind` is `:pixel` for pixel-precise scroll views (iOS `UIScrollView`, + Android `verticalScroll`). It is `:index` for item-indexed lists (Android + `LazyColumn`), where the y components count items, not pixels, and `viewport` + height is the number of visible items. `scroll_to/4` and `screenshot_tour/3` + work in whichever unit `:kind` reports, so paging stays coherent either way. + + Mob.Test.scroll_info(node, "feed") + #=> %{offset: {0.0, 0.0}, content: {393.0, 2400.0}, viewport: {393.0, 756.0}, + # max_offset: {0.0, 1644.0}, kind: :pixel} + """ + @spec scroll_info(node(), String.t() | atom()) :: map() | {:error, term()} + def scroll_info(node, id) do + case :rpc.call(node, :mob_nif, :scroll_info, [to_string(id)]) do + json when is_binary(json) -> decode_scroll_info(json) + {:error, _} = err -> err + other -> {:error, other} + end + end + + # The NIF returns a flat JSON object on both platforms (iOS builds it via + # NSJSONSerialization, Android via the Kotlin bridge). Decode to the + # tuple-shaped public map. + defp decode_scroll_info(json) do + m = :json.decode(json) + + %{ + offset: {f(m["offset_x"]), f(m["offset_y"])}, + content: {f(m["content_w"]), f(m["content_h"])}, + viewport: {f(m["viewport_w"]), f(m["viewport_h"])}, + max_offset: {f(m["max_x"]), f(m["max_y"])}, + kind: if(m["kind"] == "index", do: :index, else: :pixel) + } + end + + defp f(n) when is_number(n), do: n * 1.0 + defp f(_), do: 0.0 + + @doc """ + Scroll a view (by `:id`) to a target position. Reads `scroll_info/2` first to + resolve and clamp the absolute offset, then drives the native scroll view. + + `target`: + + * `{x, y}` — absolute offset (pixels, or item index on an `:index` list) + * `:top` / `:bottom` — the extremes + * `{:page, n}` — `n` viewport-heights down from the top (works on both + `:pixel` and `:index` views) + + Returns `:ok` or `{:error, reason}`. + + Mob.Test.scroll_to(node, "feed", :bottom) + Mob.Test.scroll_to(node, "feed", {:page, 2}) + Mob.Test.scroll_to(node, "feed", {0.0, 500.0}) + """ + @spec scroll_to(node(), String.t() | atom(), tuple() | atom(), keyword()) :: + :ok | {:error, term()} + def scroll_to(node, id, target, _opts \\ []) do + with %{} = info <- scroll_info(node, id), + {x, y} <- resolve_scroll_target(target, info) do + raw_scroll_to(node, id, x, y) + end + end + + defp raw_scroll_to(node, id, x, y) do + case :rpc.call(node, :mob_nif, :scroll_to, [to_string(id), x * 1.0, y * 1.0]) do + :ok -> :ok + {:error, _} = err -> err + other -> {:error, other} + end + end + + @doc false + # Pure: turn a target (:top | :bottom | {:page, n} | {x, y}) into an absolute + # {x, y} offset clamped to the scroll view's extent. A "page" is one viewport + # height in whatever unit `:kind` uses (pixels or item count). + @spec resolve_scroll_target(tuple() | atom(), map()) :: {float(), float()} + def resolve_scroll_target(target, %{max_offset: {mx, my}, viewport: {_vw, vh}} = info) do + {ox, _oy} = Map.get(info, :offset, {0.0, 0.0}) + + {x, y} = + case target do + :top -> {0.0, 0.0} + :bottom -> {mx, my} + {:page, n} when is_number(n) -> {ox, n * vh} + {x, y} when is_number(x) and is_number(y) -> {x, y} + end + + {clamp(x * 1.0, 0.0, mx), clamp(y * 1.0, 0.0, my)} + end + + @doc """ + Walk a scroll view top→bottom, capturing a screenshot at each page. Returns a + list of `{offset, image_binary}` pairs — the agent's "see the whole long + screen" path, entirely over dist. + + Options: + + * `:format` / `:quality` / `:scale` — passed through to `screenshot/2` + * `:overlap` — `0.0..0.9`, fraction of a viewport to overlap between pages + (default `0.0`) + * `:settle_ms` — pause after each scroll before capturing (default `150`) + + pages = Mob.Test.screenshot_tour(node, "feed", format: :jpeg, quality: 60) + for {{_x, y}, bin} <- pages, do: File.write!("/tmp/page_\#{trunc(y)}.jpg", bin) + """ + @spec screenshot_tour(node(), String.t() | atom(), keyword()) :: + [{{float(), float()}, binary()}] | {:error, term()} + def screenshot_tour(node, id, opts \\ []) do + settle_ms = Keyword.get(opts, :settle_ms, 150) + shot_opts = Keyword.take(opts, [:format, :quality, :scale]) + + with %{} = info <- scroll_info(node, id) do + info + |> tour_offsets(opts) + |> Enum.reduce_while([], fn {x, y} = off, acc -> + case raw_scroll_to(node, id, x, y) do + :ok -> + Process.sleep(settle_ms) + + case screenshot(node, shot_opts) do + {:ok, bin} -> {:cont, [{off, bin} | acc]} + {:error, _} = err -> {:halt, err} + end + + {:error, _} = err -> + {:halt, err} + end + end) + |> case do + {:error, _} = err -> err + list when is_list(list) -> Enum.reverse(list) + end + end + end + + @doc false + # Pure: the list of {x, y} offsets a top→bottom tour should visit. Steps by + # one viewport (minus `:overlap`) and always pins a final page to the bottom. + @spec tour_offsets(map(), keyword()) :: [{float(), float()}] + def tour_offsets(%{max_offset: {_mx, my}, viewport: {_vw, vh}} = info, opts) do + {ox, _oy} = Map.get(info, :offset, {0.0, 0.0}) + overlap = opts |> Keyword.get(:overlap, 0.0) |> clamp(0.0, 0.9) + step = max(vh * (1.0 - overlap), 1.0) + + my + |> tour_ys(step) + |> Enum.map(fn y -> {ox, y} end) + end + + defp tour_ys(my, _step) when my <= 0.0, do: [0.0] + + defp tour_ys(my, step) do + count = ceil(my / step) + + 0..count + |> Enum.map(fn i -> min(i * step * 1.0, my * 1.0) end) + |> Enum.uniq() + end + + defp clamp(v, lo, hi), do: v |> max(lo) |> min(hi) + defp clamp_int(v, lo, hi) when is_integer(v), do: v |> max(lo) |> min(hi) + defp clamp_int(v, lo, hi), do: v |> trunc() |> max(lo) |> min(hi) + + # ── Element frames (positions without a screenshot) ───────────────────────── + + @doc """ + Return the on-screen frame of every rendered element that carries an `:id`, + as `%{id => {x, y, w, h}}` in logical units (points on iOS, dp on Android). + + This is the screenshot-free way for an agent to know *where* things are: give + the elements you want to inspect or drive an `:id`, and their live positions + come back as a small structured map — no image bytes, no accessibility + activation. The renderer also sets the `:id` as the element's accessibility + identifier, so the same tags are visible to external tools (XCUITest, etc.). + + Pairs with `tap_id/2` to drive by id at real coordinates. + + Mob.Test.element_frames(node) + #=> %{"save" => {24.0, 720.0, 327.0, 48.0}, "row_3" => {0.0, 300.0, 393.0, 56.0}} + """ + @spec element_frames(node()) :: + %{optional(String.t()) => {float(), float(), float(), float()}} | {:error, term()} + def element_frames(node) do + case :rpc.call(node, :mob_nif, :element_frames, []) do + json when is_binary(json) -> decode_frames(json) + {:error, _} = err -> err + other -> {:error, other} + end + end + + defp decode_frames(json) do + json + |> :json.decode() + |> Map.new(fn {id, [x, y, w, h]} -> {id, {f(x), f(y), f(w), f(h)}} end) + end + + @doc """ + Frame `{x, y, w, h}` of the element with `id`, or `nil` if it has no tracked + position. See `element_frames/1`. + + Mob.Test.frame(node, "save") #=> {24.0, 720.0, 327.0, 48.0} + """ + @spec frame(node(), String.t() | atom()) :: + {float(), float(), float(), float()} | nil | {:error, term()} + def frame(node, id) do + case element_frames(node) do + %{} = frames -> frames[to_string(id)] + {:error, _} = err -> err + end + end + + @doc """ + Tap the element with `id` at the center of its tracked frame — driving by id + without a screenshot or coordinate guess. The element must carry an `:id` + (see `element_frames/1`). + + Mob.Test.tap_id(node, "save") + """ + @spec tap_id(node(), String.t() | atom()) :: :ok | {:error, term()} + def tap_id(node, id) do + case frame(node, id) do + {x, y, w, h} -> tap_xy(node, x + w / 2, y + h / 2) + nil -> {:error, :not_found} + {:error, _} = err -> err + end + end + # ── Native UI (requires MCP tools) ─────────────────────────────────────────── @doc """ @@ -951,7 +1264,13 @@ defmodule Mob.Test do {:ok, elements} rescue - _ -> {:error, :parse_error} + # The iOS accessibility-tree payload is opaque JSON whose shape + # has historically drifted across iOS versions. Narrow to the + # concrete decode/extraction failures we can predict, so a real + # bug (e.g. an arithmetic error inside the mapper) still raises + # instead of getting silently downgraded to :parse_error. + _ in [KeyError, ArgumentError, MatchError, FunctionClauseError, Protocol.UndefinedError] -> + {:error, :parse_error} end {reason, _code} -> diff --git a/lib/mob/theme.ex b/lib/mob/theme.ex index d1a38d22..ed681a31 100644 --- a/lib/mob/theme.ex +++ b/lib/mob/theme.ex @@ -12,11 +12,11 @@ defmodule Mob.Theme do Named themes are plain modules that export `theme/0`. Pass the module to `use Mob.App`: - use Mob.App, theme: Mob.Theme.Obsidian + use Mob.App, theme: MobThemes.Obsidian # (the mob_themes style package) Override individual tokens without leaving the theme: - use Mob.App, theme: {Mob.Theme.Obsidian, primary: :rose_500} + use Mob.App, theme: {MobThemes.Obsidian, primary: :rose_500} Anyone can publish a theme as a Hex package — any module with `theme/0` returning a `Mob.Theme.t()` works: @@ -31,8 +31,8 @@ defmodule Mob.Theme do Or change the theme at runtime (e.g. for accessibility or user preference): - Mob.Theme.set(Mob.Theme.Obsidian) - Mob.Theme.set({Mob.Theme.Obsidian, type_scale: 1.2}) + Mob.Theme.set(MobThemes.Obsidian) + Mob.Theme.set({MobThemes.Obsidian, type_scale: 1.2}) Mob.Theme.set(primary: :pink_500) ## Base theme @@ -106,7 +106,20 @@ defmodule Mob.Theme do radius_sm: 6, radius_md: 10, radius_lg: 16, - radius_pill: 100 + radius_pill: 100, + + # ── Material / effect flags ──────────────────────────────────────────── + # When true, surface-style nodes (currently `Box` with a `background:` set) + # render with a translucent material instead of a solid fill: + # + # * iOS 26+: Liquid Glass via `.glassEffect()` + # * iOS 17–25: graceful fallback to `.ultraThinMaterial` background + # * Android: no-op (the flag is plumbed but Material 3's glassy-surface + # story isn't first-class yet — left as a follow-up) + # + # Off by default; opt in via a preset (`MobThemes.ObsidianGlass`, the mob_themes package) or by + # passing `glass: true` to `Mob.Theme.build/1`. + glass: false ] @type t :: %__MODULE__{} @@ -135,13 +148,15 @@ defmodule Mob.Theme do Set the active theme. Accepts: - A compiled `%Mob.Theme{}` struct - - A theme module (`Mob.Theme.Obsidian`) + - A theme module (any module exporting `theme/0`, e.g. `MobThemes.Obsidian`) - A `{module, overrides}` tuple - A keyword list of overrides against the neutral base """ @spec set(t() | module() | {module(), keyword()} | keyword()) :: :ok def set(%__MODULE__{} = theme) do Application.put_env(:mob, :theme, theme) + notify_native(theme) + :ok end def set(mod) when is_atom(mod) do @@ -160,6 +175,54 @@ defmodule Mob.Theme do @spec current() :: t() def current, do: Application.get_env(:mob, :theme, default()) + @doc """ + Returns the active theme's palette resolved to ARGB integers — semantic + tokens (`:primary`, `:on_surface`, …) walked through the theme's color + map and then through `Mob.Renderer.colors/0`. Used to push concrete + values to the native side (`Mob.Theme.set/1` does this automatically; + callers usually don't need to invoke this directly). + """ + @spec resolved_palette(t()) :: %{atom() => non_neg_integer()} + def resolved_palette(theme \\ current()) do + palette = Mob.Renderer.colors() + + theme + |> color_map() + |> Map.new(fn {key, value} -> {key, resolve_color(value, palette)} end) + end + + defp resolve_color(value, palette) when is_atom(value) do + case Map.get(palette, value) do + nil -> value + int -> int + end + end + + defp resolve_color(value, _palette) when is_integer(value), do: value + defp resolve_color(value, _palette), do: value + + # Push the resolved palette + theme flags to the native side so Compose + # MaterialTheme / SwiftUI environment can follow runtime theme changes. + # Wrapped in try/rescue/catch because the NIF isn't loaded on the host + # BEAM (tests, IEx without a device) and we don't want `Mob.Theme.set/1` + # to crash in those contexts. + defp notify_native(theme) do + payload = Map.put(resolved_palette(theme), :_glass, theme.glass) + json = IO.iodata_to_binary(:json.encode(stringify_keys(payload))) + + try do + :mob_nif.set_theme(json) + rescue + _ -> :ok + catch + _, _ -> :ok + end + end + + defp stringify_keys(map) do + Map.new(map, fn {k, v} -> {Atom.to_string(k), v} end) + end + @doc """ Returns the current OS appearance: `:light` or `:dark`. @@ -208,6 +271,10 @@ defmodule Mob.Theme do Map.new(@spacing_base, fn {k, v} -> {k, round(v * scale)} end) end + @doc false + @spec flags_map(t()) :: %{atom() => boolean()} + def flags_map(%__MODULE__{glass: glass}), do: %{glass: glass} + @doc false @spec radius_map(t()) :: %{atom() => non_neg_integer()} def radius_map(%__MODULE__{} = t) do diff --git a/lib/mob/theme/birch.ex b/lib/mob/theme/birch.ex deleted file mode 100644 index 98993b8c..00000000 --- a/lib/mob/theme/birch.ex +++ /dev/null @@ -1,62 +0,0 @@ -defmodule Mob.Theme.Birch do - @moduledoc """ - Birch theme for Mob — warm parchment surfaces with a chestnut-brown accent. - - A light warm theme. Calm and readable — works well for content-heavy apps, - reading interfaces, and anywhere you want a natural, unhurried feel. - - ## Usage - - defmodule MyApp do - use Mob.App, theme: Mob.Theme.Birch - end - - ## Overrides - - use Mob.App, theme: {Mob.Theme.Birch, primary: :brown_400} - - ## Publishing your own theme - - Any module that exports `theme/0 :: Mob.Theme.t()` works as a Mob theme. - You can publish yours as a standalone Hex package and users import it the - same way: - - use Mob.App, theme: AcmeCorp.Theme.Light - """ - - @doc "Returns the compiled Birch theme struct." - @spec theme() :: Mob.Theme.t() - def theme do - Mob.Theme.build( - # ── Brand ────────────────────────────────────────────────────────────── - # 0xFF7C4A1E — warm chestnut - primary: :brown_600, - # warm cream — readable on chestnut - on_primary: 0xFFFFF4E8, - # muted sage green — complements chestnut - secondary: 0xFF5C7A52, - # warm cream - on_secondary: 0xFFFFF4E8, - - # ── Surfaces ─────────────────────────────────────────────────────────── - # warm parchment - background: 0xFFF5EFE0, - # dark coffee — high contrast on parchment - on_background: 0xFF2C1A08, - # slightly darker warm card - surface: 0xFFEDE6D5, - # elevated card - surface_raised: 0xFFE0D7C3, - # dark coffee - on_surface: 0xFF2C1A08, - # warm gray-brown — placeholders / captions - muted: 0xFF8A7A6A, - - # ── Utility ──────────────────────────────────────────────────────────── - error: :red_500, - on_error: :white, - # warm beige divider - border: 0xFFCCBCA8 - ) - end -end diff --git a/lib/mob/theme/citrus.ex b/lib/mob/theme/citrus.ex deleted file mode 100644 index bcdc35e4..00000000 --- a/lib/mob/theme/citrus.ex +++ /dev/null @@ -1,62 +0,0 @@ -defmodule Mob.Theme.Citrus do - @moduledoc """ - Citrus theme for Mob — warm charcoal with a lime-green accent. - - High-contrast and energetic. Works well for utility apps, dashboards, - and anywhere you want punchy, readable UI with an earthy warmth. - - ## Usage - - defmodule MyApp do - use Mob.App, theme: Mob.Theme.Citrus - end - - ## Overrides - - use Mob.App, theme: {Mob.Theme.Citrus, primary: :lime_300} - - ## Publishing your own theme - - Any module that exports `theme/0 :: Mob.Theme.t()` works as a Mob theme. - You can publish yours as a standalone Hex package and users import it the - same way: - - use Mob.App, theme: AcmeCorp.Theme.Dark - """ - - @doc "Returns the compiled Citrus theme struct." - @spec theme() :: Mob.Theme.t() - def theme do - Mob.Theme.build( - # ── Brand ────────────────────────────────────────────────────────────── - # 0xFFA3E635 — bright lime green - primary: :lime_400, - # near-black with green tint — max contrast - on_primary: 0xFF141A00, - # 0xFFF59E0B — warm amber accent - secondary: :amber_500, - # near-black with warm tint - on_secondary: 0xFF1A1000, - - # ── Surfaces ─────────────────────────────────────────────────────────── - # near-black, olive-tinted - background: 0xFF111209, - # warm cream - on_background: 0xFFF0EDCF, - # dark warm card background - surface: 0xFF1C1E0F, - # slightly elevated card - surface_raised: 0xFF252715, - # warm cream - on_surface: 0xFFF0EDCF, - # muted olive — placeholder / secondary text - muted: 0xFF7A7A4A, - - # ── Utility ──────────────────────────────────────────────────────────── - error: :red_400, - on_error: :white, - # warm olive-tinted divider - border: 0xFF323420 - ) - end -end diff --git a/lib/mob/theme/obsidian.ex b/lib/mob/theme/obsidian.ex deleted file mode 100644 index b11bf12e..00000000 --- a/lib/mob/theme/obsidian.ex +++ /dev/null @@ -1,59 +0,0 @@ -defmodule Mob.Theme.Obsidian do - @moduledoc """ - Obsidian theme for Mob — deep blacks with a violet accent. - - ## Usage - - defmodule MyApp do - use Mob.App, theme: Mob.Theme.Obsidian - end - - ## Overrides - - Pass a keyword list as the second element of a tuple to override - individual tokens while keeping the rest of the Obsidian palette: - - use Mob.App, theme: {Mob.Theme.Obsidian, primary: :rose_500} - - ## Publishing your own theme - - Any module that exports `theme/0 :: Mob.Theme.t()` works as a Mob theme. - You can publish yours as a standalone Hex package and users import it the - same way: - - use Mob.App, theme: AcmeCorp.Theme.Dark - """ - - @doc "Returns the compiled Obsidian theme struct." - @spec theme() :: Mob.Theme.t() - def theme do - Mob.Theme.build( - # ── Brand ────────────────────────────────────────────────────────────── - # 0xFF7C3AED - primary: :violet_600, - on_primary: :white, - # 0xFFA78BFA — lighter for accents/tags - secondary: :violet_400, - on_secondary: :white, - - # ── Surfaces ─────────────────────────────────────────────────────────── - # near-black, blue-tinted - background: 0xFF0D0D1A, - # lavender-tinted white - on_background: 0xFFE8E6FF, - # dark card background - surface: 0xFF16162A, - # slightly elevated card - surface_raised: 0xFF1E1E38, - on_surface: 0xFFE8E6FF, - # muted text / placeholders - muted: 0xFF6B6B8E, - - # ── Utility ──────────────────────────────────────────────────────────── - error: :red_400, - on_error: :white, - # subtle purple-tinted divider - border: 0xFF2D2D4A - ) - end -end diff --git a/lib/mob/torch.ex b/lib/mob/torch.ex new file mode 100644 index 00000000..e47457ea --- /dev/null +++ b/lib/mob/torch.ex @@ -0,0 +1,50 @@ +defmodule Mob.Torch do + @moduledoc """ + Rear-camera torch (flashlight) on/off. No permission required on either + platform — the torch is toggled directly, without opening a camera session. + + ## Usage + + def handle_event("toggle_light", _params, socket) do + on? = not socket.assigns.light_on + {:noreply, socket |> Mob.Torch.set(on?) |> assign(:light_on, on?)} + end + + `on/1` and `off/1` are conveniences over `set/2`. + + On a device with **no rear flash** (most tablets, the iOS simulator) this is a + **no-op, not an error** — check the hardware yourself if you need to hide the + control. The torch is a shared hardware resource: the OS turns it off when the + app is backgrounded, and an active camera capture can override it. This module + is fire-and-forget and does not read the state back — the app owns the on/off + boolean and should re-assert it after resuming if it needs to persist. + + iOS: `AVCaptureDevice.torchMode` via `lockForConfiguration`. Android: + `CameraManager.setTorchMode` on the rear camera that reports a flash unit. + """ + + @doc "Turn the torch on. Returns the socket unchanged." + @spec on(Mob.Socket.t()) :: Mob.Socket.t() + def on(socket), do: set(socket, true) + + @doc "Turn the torch off. Returns the socket unchanged." + @spec off(Mob.Socket.t()) :: Mob.Socket.t() + def off(socket), do: set(socket, false) + + @doc """ + Set the torch on (`true`) or off (`false`). Returns the socket unchanged so it + can be used inline in a `handle_event`/`handle_info` return. + """ + @spec set(Mob.Socket.t(), boolean()) :: Mob.Socket.t() + def set(socket, on?) when is_boolean(on?) do + :mob_nif.torch(state_atom(on?)) + socket + end + + @doc false + # The wire atom the NIF expects for a given on/off boolean. Public (hidden) so + # the mapping is unit-testable without a loaded NIF. + @spec state_atom(boolean()) :: :on | :off + def state_atom(true), do: :on + def state_atom(false), do: :off +end diff --git a/lib/mob/ui.ex b/lib/mob/ui.ex index a546144f..729fdcf2 100644 --- a/lib/mob/ui.ex +++ b/lib/mob/ui.ex @@ -82,8 +82,8 @@ defmodule Mob.UI do @doc """ Returns a `:camera_preview` component node. Renders a live camera feed inline. - Call `Mob.Camera.start_preview/2` before mounting this component, and - `Mob.Camera.stop_preview/1` when done. + Call `MobCamera.start_preview/2` (the `mob_camera` plugin) before mounting this + component, and `MobCamera.stop_preview/1` when done. Props: * `:facing` — `:back` (default) or `:front` @@ -164,4 +164,95 @@ defmodule Mob.UI do children: [] } end + + @doc """ + Returns a `:gpu_view` leaf node — a fragment-shader-driven GPU surface + backed by `MTKView` + Metal on iOS. The native side compiles the + supplied shader (Metal Shading Language) into a render pipeline, binds + the supplied uniforms in declaration order at fragment buffer slot 0, + and renders a full-screen quad at the display refresh rate. + + Android support (`GLSurfaceView` + GLES 3.0) is not in v1. + + ## Props + + * `:id` — required atom that identifies the GPU view across re-renders + (so the native side keeps the same Metal pipeline / texture cache). + * `:width` / `:height` — pt/dp, required. + * `:shader` — either a string of Metal Shading Language source (iOS), + or a map `%{ios: "...MSL..."}` (escape hatch — same as the string + form; the map form exists so future platforms can be added without + breaking the API). + * `:uniforms` — an **ordered list of values** packed into the shader's + `Uniforms` struct in declaration order. Each element is one of: + * a number — `float` (or `uint` if integer-typed at the BEAM level) + * a 2-element list `[a, b]` — `float2` + * a 4-element list `[a, b, c, d]` — `float4` + (`float3` deliberately not supported in v1 — its 16-byte + alignment with 12-byte size makes the layout API messier than + it's worth here.) + + Shader compile errors are caught natively and surfaced as a translucent + overlay on top of the GpuView with the error message. + + ## Why a list, not a map + + Elixir map iteration order is **not stable** across runtimes or map + sizes — `%{a: 1, b: 2, c: 3}` can iterate in any order. The natural + MSL layout for a `Uniforms` struct is positional, so we mirror that + on the BEAM side. List position 0 → first struct member, etc. + + A map form is still accepted as a backward-compat fallback but will + pack in whatever order the runtime decides, so the shader-side struct + has to match an unstable order — not recommended. + + ## Example — Mandelbrot at the display's refresh rate + + @shader File.read!("priv/shaders/mandelbrot.metal") + + Mob.UI.gpu_view( + id: :mandelbrot, + width: 350, + height: 350, + shader: @shader, + # MSL: struct Uniforms { float2 center; float zoom; uint max_iter; }; + uniforms: [[cx, cy], zoom, max_iter] + ) + + ## What the framework auto-provides + + The host emits a built-in vertex shader that draws a full-screen quad + and produces a `VertexOut { float4 position [[position]]; float2 uv; }`. + Your fragment shader receives that as `[[stage_in]]` and reads + `in.uv` (0..1 across the view) plus the user uniforms at buffer slot 0. + Don't redeclare `VertexOut`, `vertex_main`, or the metal_stdlib include + in your shader — the host prepends them. + + ## Required fragment entry point + + Your shader must export `fragment_main`: + + fragment half4 fragment_main(VertexOut in [[stage_in]], + constant Uniforms& u [[buffer(0)]]) { ... } + """ + @spec gpu_view(keyword() | map()) :: map() + def gpu_view(props) when is_list(props), do: gpu_view(Map.new(props)) + + def gpu_view(%{} = props) do + %{ + type: :gpu_view, + props: + Map.take(props, [ + :id, + :width, + :height, + :shader, + :uniforms, + :on_tap, + :on_drag, + :on_pinch + ]), + children: [] + } + end end diff --git a/lib/mob/vendor_usb.ex b/lib/mob/vendor_usb.ex new file mode 100644 index 00000000..78d9b103 --- /dev/null +++ b/lib/mob/vendor_usb.ex @@ -0,0 +1,334 @@ +defmodule Mob.VendorUsb do + @moduledoc """ + Raw USB host access via vendor bulk endpoints. **Android only.** + + No permission required at the OS-permission level, but Android prompts the + user to grant per-device access via the system dialog when you call + `request_permission/2`. The grant is per app + device + session; granting + "always" only sticks if the user ticks the checkbox. + + iOS calls return the socket unchanged and emit + `{:peripheral, :vendor_usb, :error, nil, :unsupported}`. See + `Mob.Ble` for iOS-friendly equivalent transports. + the (forthcoming) `Mob.Midi` or `Mob.Ble`. + + ## Lifecycle + + ``` + list_devices/1 → {:peripheral, :vendor_usb, :devices, _, [device, …]} + request_permission/2 → {:peripheral, :vendor_usb, :permission_granted, _, device} + {:peripheral, :vendor_usb, :permission_denied, _, device} + open/2 → {:peripheral, :vendor_usb, :opened, session, device} + {:peripheral, :vendor_usb, :error, nil, reason} + bulk_write/4 → {:peripheral, :vendor_usb, :write_complete, session, %{bytes: n}} + (or :error for failures) + start_reading/3 → {:peripheral, :vendor_usb, :data, session, binary} + (delivered repeatedly; use stop_reading/2 to halt) + stop_reading/2 + close/2 → {:peripheral, :vendor_usb, :closed, session, reason} + ``` + + Any unsolicited `{:peripheral, :vendor_usb, :disconnected, session, reason}` + may arrive at any time (cable unplug, device removed). After + `:disconnected`, the session handle is dead — drop your reference and call + `list_devices/1` again to reacquire. + + ## Example: a USB echo demo + + This shape works for any USB device that exposes bulk IN/OUT + endpoints. Substitute the VID/PID and frame format for your device. + + defmodule MyApp.UsbScreen do + use Mob.Screen + alias Mob.VendorUsb + + @my_vid 0x1234 + @my_pid 0x5678 + + def mount(_p, _s, socket) do + {:ok, + socket + |> Mob.Socket.assign(:devices, []) + |> Mob.Socket.assign(:session, nil) + |> VendorUsb.list_devices(vendor_id: @my_vid)} + end + + def handle_info({:peripheral, :vendor_usb, :devices, _, devices}, socket) do + {:noreply, Mob.Socket.assign(socket, :devices, devices)} + end + + def handle_info({:peripheral, :vendor_usb, :permission_granted, _, dev}, socket) do + {:noreply, VendorUsb.open(socket, dev, interface: 0)} + end + + def handle_info({:peripheral, :vendor_usb, :opened, session, _dev}, socket) do + socket = + socket + |> Mob.Socket.assign(:session, session) + |> VendorUsb.start_reading(session) + |> VendorUsb.bulk_write(session, "hello") + + {:noreply, socket} + end + + def handle_info({:peripheral, :vendor_usb, :data, _session, binary}, socket) do + IO.inspect(binary, label: "from device") + {:noreply, socket} + end + + def handle_info({:peripheral, :vendor_usb, :disconnected, _, _}, socket) do + {:noreply, Mob.Socket.assign(socket, :session, nil)} + end + end + + ## Framing is your problem + + This module is byte-level. USB bulk endpoints do *not* preserve message + boundaries — the bytes you wrote in one `bulk_write/4` call may arrive + on the other end split across multiple chunks, or coalesced with later + writes. Likewise, `:data` events deliver whatever the OS happens to + hand back from a read; do not assume one event corresponds to one + logical message. + + If your device uses a framed protocol (length-prefix, COBS, SLIP, + delimiters, fixed-size records), implement the framer in a layer + above this one. A reasonable pattern is a `GenServer` that owns the + session, accumulates incoming chunks into a buffer, and drains + complete frames out for higher-level consumers. + + ## Device shape + + Devices arrive as maps: + + %{ + vendor_id: 0x1234, + product_id: 0x5678, + manufacturer: "Acme Inc.", + product: "Widget 9000", + serial: "SN-000001", + # opaque handle the OS uses to refer to this device. Treat as a + # binary; do not parse. Pass back to `request_permission/2` etc. + ref: "/dev/bus/usb/001/002" + } + + ## Session handles + + `open/2` delivers an integer session handle. Session handles are valid + until `:disconnected` or `close/2`. They are *not* persistent across app + restarts — re-enumerate after launch. + + ## Buffer ownership + + Binaries you pass to `bulk_write/4` are copied into a native-side buffer + before the NIF returns. Binaries delivered via `:data` are owned by the + BEAM — they will outlive the underlying USB read buffer. + + ## Limits + + Maximum write size per call: 16 KiB. Larger writes are rejected with + `{:error, :payload_too_large}`. Read chunks are bounded by the USB max + packet size for the endpoint (typically 64 B Full Speed, 512 B High + Speed); the native read loop coalesces packets into BEAM-side binaries + bounded by `:read_chunk_bytes` (default 4 KiB). + """ + + @type device :: %{ + vendor_id: non_neg_integer(), + product_id: non_neg_integer(), + manufacturer: String.t() | nil, + product: String.t() | nil, + serial: String.t() | nil, + ref: String.t() + } + + @type session :: integer() + + @max_write_bytes 16 * 1024 + + @doc """ + Enumerate connected USB devices. + + Result: `{:peripheral, :vendor_usb, :devices, nil, [device, …]}` + + Options: + * `:vendor_id` — filter to a single VID + * `:product_id` — filter to a single PID (only meaningful with VID) + + Filtering happens native-side; an empty result is a real "no matching + device", not a permission/availability issue. + """ + @spec list_devices(Mob.Socket.t(), keyword()) :: Mob.Socket.t() + def list_devices(socket, opts \\ []) do + filter = + %{} + |> maybe_put_filter("vendor_id", Keyword.get(opts, :vendor_id)) + |> maybe_put_filter("product_id", Keyword.get(opts, :product_id)) + + json = :json.encode(filter) + :mob_nif.vendor_usb_list_devices(json) + socket + end + + defp maybe_put_filter(map, _key, nil), do: map + defp maybe_put_filter(map, key, val), do: Map.put(map, key, val) + + @doc """ + Ask the OS to prompt the user to grant access to a specific device. + + `device` is the map returned by `list_devices/1`. Only the `:ref` field + is consulted, but it is convenient to pass the whole map. + + Result: + * `{:peripheral, :vendor_usb, :permission_granted, nil, device}` + * `{:peripheral, :vendor_usb, :permission_denied, nil, device}` + + Idempotent. If the user has already granted access, the granted message + fires immediately without showing a dialog. + """ + @spec request_permission(Mob.Socket.t(), device()) :: Mob.Socket.t() + def request_permission(socket, %{ref: ref} = _device) when is_binary(ref) do + :mob_nif.vendor_usb_request_permission(ref) + socket + end + + @doc """ + Open a permitted device and claim an interface. + + Options: + * `:interface` — interface number (default `0`) + * `:endpoint_in` — bulk IN endpoint address (e.g. `0x81`); if omitted, + the first bulk IN endpoint on the interface is auto-selected + * `:endpoint_out` — bulk OUT endpoint address (e.g. `0x01`); if + omitted, the first bulk OUT endpoint on the interface is + auto-selected + + Result: + * `{:peripheral, :vendor_usb, :opened, session, device}` + * `{:peripheral, :vendor_usb, :error, nil, reason}` — common reasons: + `:no_permission`, `:device_gone`, `:interface_busy`, + `:no_bulk_endpoints` + """ + @spec open(Mob.Socket.t(), device(), keyword()) :: Mob.Socket.t() + def open(socket, %{ref: ref}, opts \\ []) when is_binary(ref) do + fields = + %{"ref" => ref, "interface" => Keyword.get(opts, :interface, 0)} + |> maybe_put_filter("endpoint_in", Keyword.get(opts, :endpoint_in)) + |> maybe_put_filter("endpoint_out", Keyword.get(opts, :endpoint_out)) + + json = :json.encode(fields) + :mob_nif.vendor_usb_open(json) + socket + end + + @doc """ + Send bytes to the device's bulk OUT endpoint. + + `data` may be a binary or iolist; it is flattened and copied native-side + before the NIF returns. Maximum size: #{@max_write_bytes} bytes. + + Options: + * `:timeout_ms` — write timeout (default `1000`) + + Result: + * `{:peripheral, :vendor_usb, :write_complete, session, %{bytes: n}}` + * `{:peripheral, :vendor_usb, :error, session, reason}` + """ + @spec bulk_write(Mob.Socket.t(), session(), iodata(), keyword()) :: Mob.Socket.t() + def bulk_write(socket, session, data, opts \\ []) when is_integer(session) do + bin = IO.iodata_to_binary(data) + + cond do + byte_size(bin) == 0 -> + socket + + byte_size(bin) > @max_write_bytes -> + send(self(), {:peripheral, :vendor_usb, :error, session, :payload_too_large}) + socket + + true -> + timeout = Keyword.get(opts, :timeout_ms, 1000) + :mob_nif.vendor_usb_bulk_write(session, bin, timeout) + socket + end + end + + @doc """ + Start a continuous read loop on the bulk IN endpoint. + + After this call, every chunk read native-side is delivered as + `{:peripheral, :vendor_usb, :data, session, binary}` to the calling + process. Stop with `stop_reading/2`. + + Options: + * `:read_chunk_bytes` — soft cap on per-message coalescing (default + `4096`). Smaller values reduce latency; larger reduce overhead. + + Idempotent: calling twice is a no-op. + """ + @spec start_reading(Mob.Socket.t(), session(), keyword()) :: Mob.Socket.t() + def start_reading(socket, session, opts \\ []) when is_integer(session) do + chunk = Keyword.get(opts, :read_chunk_bytes, 4096) + :mob_nif.vendor_usb_start_reading(session, chunk) + socket + end + + @doc "Stop the read loop started by `start_reading/3`." + @spec stop_reading(Mob.Socket.t(), session()) :: Mob.Socket.t() + def stop_reading(socket, session) when is_integer(session) do + :mob_nif.vendor_usb_stop_reading(session) + socket + end + + @doc """ + Close a device session, releasing the interface and freeing the file + descriptor. Idempotent. Always emits + `{:peripheral, :vendor_usb, :closed, session, :ok}`. + """ + @spec close(Mob.Socket.t(), session()) :: Mob.Socket.t() + def close(socket, session) when is_integer(session) do + :mob_nif.vendor_usb_close(session) + socket + end + + # ── Event normalization ──────────────────────────────────────────────── + # + # The Android NIF delivers a few high-cardinality events with their + # payloads as JSON binaries (`:devices_json`, `:permission_granted_json`, + # `:permission_denied_json`, `:opened_json`) to keep the C/JNI side + # simple. `Mob.Screen` calls `normalize_message/1` once before the + # screen's `handle_info/2` runs, so user code only sees the public event + # shape documented at the top of this module. + + @doc false + @spec normalize_message(term()) :: term() + def normalize_message({:peripheral, :vendor_usb, :devices_json, _, json}) + when is_binary(json) do + devices = json |> :json.decode() |> Enum.map(&device_from_map/1) + {:peripheral, :vendor_usb, :devices, nil, devices} + end + + def normalize_message({:peripheral, :vendor_usb, :permission_granted_json, _, json}) do + {:peripheral, :vendor_usb, :permission_granted, nil, device_from_map(:json.decode(json))} + end + + def normalize_message({:peripheral, :vendor_usb, :permission_denied_json, _, json}) do + {:peripheral, :vendor_usb, :permission_denied, nil, device_from_map(:json.decode(json))} + end + + def normalize_message({:peripheral, :vendor_usb, :opened_json, session, json}) do + {:peripheral, :vendor_usb, :opened, session, device_from_map(:json.decode(json))} + end + + def normalize_message(other), do: other + + defp device_from_map(map) when is_map(map) do + %{ + vendor_id: Map.get(map, "vendor_id"), + product_id: Map.get(map, "product_id"), + manufacturer: Map.get(map, "manufacturer"), + product: Map.get(map, "product"), + serial: Map.get(map, "serial"), + ref: Map.get(map, "ref") + } + end +end diff --git a/mix.exs b/mix.exs index 78c29568..d78748b9 100644 --- a/mix.exs +++ b/mix.exs @@ -4,12 +4,13 @@ defmodule Mob.MixProject do def project do [ app: :mob, - version: "0.5.18", + version: "0.7.20", elixir: "~> 1.19", start_permanent: Mix.env() == :prod, elixirc_paths: elixirc_paths(Mix.env()), compilers: compilers(Mix.env()) ++ Mix.compilers(), deps: deps(), + aliases: aliases(), unused: [ ignore: [ # GenServer / behaviour callbacks (mix_unused can't see them). @@ -54,37 +55,92 @@ defmodule Mob.MixProject do def application do [ - extra_applications: [:logger] + # :public_key is needed by Mob.Certs at runtime; Elixir 1.19+ strips + # unused OTP applications from the code path, so it must be declared + # here even though mob doesn't *start* it directly. + extra_applications: [:logger, :public_key] ] end + # Two hexdocs-only injections: (1) classify unstyled <pre><code> blocks as + # Elixir so they get syntax-highlighted, and (2) render ```mermaid fenced + # blocks as SVG (ex_doc has no built-in mermaid support; GitHub renders + # them natively, so READMEs work there without this). + defp before_closing_body_tag(:html) do + """ + <script> + // Default unstyled code blocks to Elixir highlighting. + document.querySelectorAll("pre code").forEach(el => { + if (!el.className) el.className = "language-elixir"; + }); + </script> + <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script> + <script> + document.addEventListener("DOMContentLoaded", function () { + mermaid.initialize({ startOnLoad: false }); + let id = 0; + for (const codeEl of document.querySelectorAll("pre code.mermaid")) { + const preEl = codeEl.closest("pre"); + const graphEl = document.createElement("div"); + const graphId = "mermaid-graph-" + id++; + mermaid.render(graphId, codeEl.textContent).then(({ svg, bindFunctions }) => { + graphEl.innerHTML = svg; + if (bindFunctions) bindFunctions(graphEl); + preEl.replaceWith(graphEl); + }); + } + }); + </script> + """ + end + + defp before_closing_body_tag(_), do: "" + defp docs do [ main: "readme", + before_closing_body_tag: &before_closing_body_tag/1, logo: "assets/logo/logo_full_color.png", source_url: "https://github.com/genericjam/mob", - source_url_pattern: "https://github.com/genericjam/mob/blob/main/%{path}#L%{line}", + source_url_pattern: "https://github.com/genericjam/mob/blob/master/%{path}#L%{line}", extras: [ "README.md": [title: "Mob"], + "CHANGELOG.md": [title: "Changelog"], + "MOB_PLUGINS.md": [title: "Plugins — Manifest Reference"], + "MOB_PLUGIN_SECURITY.md": [title: "Plugins — Security & Trust"], + "MOB_STYLES.md": [title: "Styles — Manifest Reference"], "guides/why_beam.md": [title: "Why the BEAM?"], "guides/getting_started.md": [title: "Getting Started"], + "guides/packages.md": [title: "First-Party Packages"], "guides/architecture.md": [title: "Architecture & Prior Art"], "guides/screen_lifecycle.md": [title: "Screen Lifecycle"], + "guides/events.md": [title: "Events"], + "guides/event_model.md": [title: "Event Model"], + "guides/background_execution.md": [title: "Background Execution"], "guides/components.md": [title: "Components"], "guides/styling.md": [title: "Styling & Native Rendering"], "guides/theming.md": [title: "Theming"], "guides/navigation.md": [title: "Navigation"], "guides/device_capabilities.md": [title: "Device Capabilities"], + "guides/mobile_surface_matrix.md": [title: "Mobile Surface Matrix"], + "guides/permissions.md": [title: "Permissions"], + "guides/native_extensions.md": [title: "Native Extensions (NIFs, features)"], + "guides/plugins.md": [title: "Writing a Plugin"], + "guides/dns_on_ios.md": [title: "DNS on iOS"], "guides/push_notifications.md": [title: "Push Notifications"], "guides/data.md": [title: "Data & Persistence"], "guides/testing.md": [title: "Testing"], "guides/tooling.md": [title: "Tooling & Formatting"], "guides/publishing.md": [title: "Publishing to App Store / TestFlight"], "guides/troubleshooting.md": [title: "Troubleshooting"], + "guides/support_matrix.md": [title: "Device Support Matrix"], + "guides/liveview.md": [title: "LiveView Mode"], + "guides/ios_physical_device.md": [title: "iOS Physical Devices"], "guides/agentic_coding.md": [title: "Agentic Coding"] ], groups_for_extras: [ - Guides: ~r/guides\/.*/ + Guides: ~r/guides\/.*/, + Plugins: ["MOB_PLUGINS.md", "MOB_PLUGIN_SECURITY.md", "MOB_STYLES.md"] ], groups_for_modules: [ Core: [Mob, Mob.App, Mob.Screen, Mob.ScreenState, Mob.Socket, Mob.State], @@ -92,48 +148,30 @@ defmodule Mob.MixProject do Mob.UI, Mob.Style, Mob.Renderer, + Mob.Composite, Mob.Theme, - Mob.Theme.Obsidian, - Mob.Theme.Citrus, - Mob.Theme.Birch + Mob.Theme.Light, + Mob.Theme.Dark, + Mob.Theme.Adaptive ], Navigation: [Mob.Nav.Registry], + Plugins: [Mob.Plugins, Mob.Plugins.Supervisor, Mob.Plugins.Lifecycle], "Device APIs": [ Mob.Haptic, Mob.Clipboard, Mob.Share, Mob.Permissions, - Mob.Biometric, - Mob.Location, - Mob.Camera, - Mob.Photos, Mob.Files, Mob.Audio, - Mob.Motion, - Mob.Scanner, - Mob.Notify + Mob.Motion ], "Testing & Debugging": [Mob.Test], Tooling: [Mob.Formatter], Internals: [Mob.Dist, Mob.NativeLogger, Mob.List, Mob.Sigil] - ], - before_closing_body_tag: &before_closing_body_tag/1 + ] ] end - defp before_closing_body_tag(:html) do - """ - <script> - // Ensure code blocks with language hints are highlighted - document.querySelectorAll("pre code").forEach(el => { - if (!el.className) el.className = "language-elixir"; - }); - </script> - """ - end - - defp before_closing_body_tag(_), do: "" - defp elixirc_paths(:test), do: ["lib", "test/onboarding", "test/onboarding/support"] defp elixirc_paths(_), do: ["lib"] @@ -145,20 +183,47 @@ defmodule Mob.MixProject do lib src priv android ios assets mix.exs mix.lock - README.md LICENSE + README.md CHANGELOG.md LICENSE ) ] end + defp aliases do + # `mix setup` after cloning installs deps and activates the shared git + # hooks (.githooks): format / Credo --strict / compile run on every push + # and the full suite when mix.exs changes — the same gate CI enforces. + [setup: ["deps.get", "cmd git config core.hooksPath .githooks"]] + end + defp deps do [ + {:ex_ast, "~> 0.12", only: [:dev, :test], runtime: false}, + {:reach, "~> 2.7", only: [:dev, :test], runtime: false}, + {:recon, "~> 2.5", only: [:dev, :test]}, # HTML/HEEx template engine — same one Phoenix uses # {:phoenix_live_view, "~> 1.0", optional: true}, # add when HEEx rendering lands {:nimble_parsec, "~> 1.0"}, {:ex_doc, "~> 0.34", only: :dev, runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:jump_credo_checks, "~> 0.1.0", only: [:dev, :test], runtime: false}, + # ex_slop — Credo check that catches AI-generated Elixir patterns + # (blanket rescue, narrator-style docs, redundant Enum chains, etc). + # Wired in via .credo.exs as `{ExSlop, []}` in the enabled list. + {:ex_slop, "~> 0.4", only: [:dev, :test], runtime: false}, {:erlfmt, "~> 1.8", only: :dev, runtime: false}, + # mix_audit — CVE scan over mix.lock against the Erlef advisory feed. + # Invocation note: `mix deps.audit` alone fails with + # `YamlElixir.read_from_file/1 is undefined` because mix_audit doesn't + # ensure_all_started its yaml_elixir transitive dep before parsing + # the advisory files. CI works around this with `mix do app.start + + # deps.audit` (the app.start prefix starts the host app, which + # transitively starts yaml_elixir via the runtime tree). + {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, + # Known Elixir 1.20-rc.4 dep warning (cosmetic, dev-only): + # lib/mix_unused/filter.ex:61 — `_.._ inside match is deprecated`. + # No upstream fix shipped yet (0.4.1 is latest, from 2024). Bump + # this version + drop this comment once mix_unused ships a 1.20-clean + # release. {:mix_unused, "~> 0.4", only: :dev, runtime: false}, {:ecto_sqlite3, "~> 0.18", only: :test} ] diff --git a/mix.lock b/mix.lock index c3fda888..dd33b468 100644 --- a/mix.lock +++ b/mix.lock @@ -2,24 +2,32 @@ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, - "db_connection": {:hex, :db_connection, "2.10.0", "8ff756471e41765bd5563b633f73e9a94bbc138816e8644bb17d0d91bf260a95", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02cdd01b45efb1b550e68edbbea41be32de9b24bb07e1ea0e9cbc522ac377e54"}, - "decimal": {:hex, :decimal, "2.4.0", "a9c6bce0ee76fa75b9d3375bdaab8695d946de648e23e1c3280f7e77f7b279d9", [:mix], [], "hexpm", "70c8f058a7413c4f13026ac55455499c8136cacbd8e51528a10826dedeb82584"}, + "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, + "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, - "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, - "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, - "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.23.0", "79da75815627582f081f00d418c130c4cf587672b720b54e7a8798c6d46b5415", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "e97041bcec746ed525df7d9ad996fbae3b0660767f99fbe9e9b58d6208729703"}, - "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"}, + "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"}, + "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.24.0", "3ec0138e0f75bc5cda7d7b890b346e75eace6389716a6ebad597efc0138c527e", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.14", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "7c5e52b44691170927b92302ff0766fd505c8014520298d3542250260164240d"}, + "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, "erlfmt": {:hex, :erlfmt, "1.8.0", "6df9379029a09f60b5c07d631c376f31d32dbf36a59f021b4a56f0b8825db468", [:rebar3], [], "hexpm", "f783ca8a8367c92f96ec75c8fee2c636efd0f39ac45ff57d8d825a71b4b957d3"}, - "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, - "exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"}, + "ex_ast": {:hex, :ex_ast, "0.12.9", "5ba5445ca99ff33a516be390f77092d5bfbabdaea0babe61eeef992323688ccb", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "bca9f5092196c63dc96d3d2fcded8b9387e2e8cdb21546c8ba461e321121d423"}, + "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, + "ex_slop": {:hex, :ex_slop, "0.4.2", "142aba9a82eddfb258e39c45d59392ab3cdb6b5a3ad401b09b362b7134fc54eb", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "c7f5316f755f83566e7a0a049f6fedfcd5ff916fce83c6ebfdf806be62fd7a69"}, + "exqlite": {:hex, :exqlite, "0.37.0", "701e7e02679e8c1bb6da331ea93d83b481c714b0831e82e2f8a73375b3d93a9e", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a44816dd0d234fba68c47a3609af61d306d24ef517a89bfaee4d6a811792d913"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "jump_credo_checks": {:hex, :jump_credo_checks, "0.1.0", "8ff038eb868d36bfce6b47916619c68df99ea802adae2a95702b59463120ebb1", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "bb76a8bff31a1f42289a9ba03f4f5666e6ae061168f6f13280550ad072851a1f"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "mix_unused": {:hex, :mix_unused, "0.4.1", "9f8d759a300a79d2077d6baf617f3a5af6935d50b0f113c09295b265afc3e411", [:mix], [{:libgraph, ">= 0.0.0", [hex: :libgraph, repo: "hexpm", optional: false]}], "hexpm", "fa21f688a88e0710e3d96ac1c8e5a6181aea8a75c8a4214f0edcfeb069b831a3"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "reach": {:hex, :reach, "2.7.5", "2148096233ebf84f1b9c79d23134c3262f546303af07ee21f7e9d7ed281ff616", [:mix], [{:boxart, "~> 0.3.3", [hex: :boxart, repo: "hexpm", optional: true]}, {:ex_ast, "~> 0.12.0", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:ex_dna, "~> 1.5", [hex: :ex_dna, repo: "hexpm", optional: true]}, {:libgraph, "~> 0.16.0", [hex: :libgraph, repo: "hexpm", optional: false]}, {:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: true]}, {:makeup_js, "~> 0.1", [hex: :makeup_js, repo: "hexpm", optional: true]}, {:quickbeam, "~> 0.10", [hex: :quickbeam, repo: "hexpm", optional: true]}], "hexpm", "b31fd7cf23a649a6f76f11168b2ef296845441d6498474634ec673dee2d60567"}, + "recon": {:hex, :recon, "2.5.6", "9052588e83bfedfd9b72e1034532aee2a5369d9d9343b61aeb7fbce761010741", [:mix, :rebar3], [], "hexpm", "96c6799792d735cc0f0fd0f86267e9d351e63339cbe03df9d162010cefc26bb0"}, + "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, } diff --git a/nif_future.md b/nif_future.md index 47661a3f..f9093b1f 100644 --- a/nif_future.md +++ b/nif_future.md @@ -136,3 +136,147 @@ the kind of thing the build rebuild can address as part of "what gets shipped to the device" rather than as point patches in app code or per-consumer monkey-patches. Worth keeping them in mind as test cases when the new build pipeline reaches the deploy step. + +--- + +## 4. iOS device build skips `copy_project_python_wheels` (verified 2026-05-11, **FIXED 2026-05-11 19:10 PT on branch `fix/ios-wheel-copy`** — re-verified end-to-end on iPhone SE 3rd gen) + +**Resolution (2026-05-11 evening, branch `fix/ios-wheel-copy`, +commit `78ebf2e` on `deps/mob_dev`)**: `bundle_otp_runtime/4` in +`lib/mob_dev/native_build.ex` now calls a new +`copy_ios_safe_project_python_wheels/1` right after the python rsync +into `<App>.app/otp/python/`. The helper mirrors the Android +`copy_project_python_wheels/1` pattern but filters out wheels that +contain any `.so` extension — today's `priv/python_wheels/` ships +Chaquopy-compatible Android binaries under names like +`_cffi_backend.so` and `_rust.so` (no "android" in the filename), so a +name-based filter misses them. "Has any `.so`" matches the current +reality: pure-Python wheels (rns, lxmf, pyserial, pycparser) land, +Android-only ones get skipped with a `[ios-wheels] skipped` log line. +RNS falls back to its internal crypto provider when `cryptography` +isn't importable, so the pure-Python subset is enough to bring the +Reticulum stack up. + +Note: `ios/build_device.sh:179` still nukes +`<OTP_ROOT>/python/Python.framework` and +`<OTP_ROOT>/python/lib/python3.13` on every build — so a +stage-into-the-cache workaround would not survive. Doing the wheel +copy in `bundle_otp_runtime/4` (which runs AFTER the rsync into the +`.app`) sidesteps that. + +**Verification (iPhone SE 3rd gen 00008110-001E1C3A34F8401E)**: +- `Pigeon.app/otp/python/lib/python3.13/site-packages/` now contains + `RNS/`, `LXMF/`, `serial/`, `pycparser/`, `chaquopy/` (metadata-only) + plus their `*.dist-info/` directories. +- BEAM boot trace (via temporary `Pigeon.App.on_start` file logger): + `on_start enter` → `backend=Pigeon.Transport.Reticulum` → + `python init start` → `python init ok` (+124 ms) → + `transport start (…)` → `transport started ok` (+2.5 s). +- Process stays alive (`xcrun devicectl device info processes` + shows Pigeon running). Previously exited cleanly at the + `{:ok, _transport_sup} = …` pattern match. + +The historical 2026-05-11 morning + 2026-05-11 17:40 PT notes +below are kept for context. + +--- + +### Earlier note: 2026-05-11 17:40 PT — "did not actually land" + +The 2026-05-11 morning note claimed the iOS device path was wired to +`copy_project_python_wheels/1` via `maybe_setup_pythonx_sim/5` / +`maybe_setup_pythonx_device/5`. Re-check on 2026-05-11 17:40 PT showed +neither helper nor either call site existed in `deps/mob_dev` HEAD — +the prior fix attempt didn't land. That's what triggered the current +fix on branch `fix/ios-wheel-copy`. + +--- + +### Earlier note that turned out to be inaccurate + +`mob_dev` `lib/mob_dev/native_build.ex` — +`copy_project_python_wheels/1` generalised (param renamed +`assets_root` → `python_root`, docstring covers both platforms) and +wired into both `maybe_setup_pythonx_sim/5` (right after the +lib-dynload `copy_dir!`) and `maybe_setup_pythonx_device/5` (right +after the lib-dynload `cp_r!`). Both call sites pass +`<otp_root>/python` as the root — same `lib/python3.13/site-packages/` +suffix as Android, so the helper works unchanged. **Re-check on +2026-05-11 evening shows neither helper nor either call site exists +in `deps/mob_dev` HEAD; whatever was intended did not land.** + +--- + +### Original report + + +**Refines item 3 above** — the cryptography cross-compile spike isn't +actually required. RNS gracefully falls back to its internal pure- +Python crypto provider when `cryptography` isn't importable (see +`RNS/Cryptography/Provider.py`), and `lxmf` is pure-Python on top of +RNS. So the wheel set we actually need on iOS is just `rns + lxmf` +(both pure-Python, ~few MB total), plus `pyserial` + `pycparser` if +any project uses them. + +**The gap**: Android's `copy_python_assets/1` already does +`copy_project_python_wheels(assets_root)` after dropping stdlib + +lib-dynload into the APK. iOS *simulator's* `ios/build.sh` (the +project-local one mob_dev does NOT regenerate) was patched in the +Pigeon session to do the same into `<otp_root>/python/lib/python3.13/ +site-packages/`. iOS *device's* auto-generated `build_device.sh` +(produced by `MobDev.NativeBuild.generate_build_device_sh/2`) bundles +Python.framework + stdlib + lib-dynload but never copies +`priv/python_wheels/*` in. Result: device boots, hits `import RNS`, +crashes with `ModuleNotFoundError: No module named 'RNS'`, app +appears stuck on the launch spinner. + +**Workaround for manual dev cycles**: after a build, find the staged +`Pigeon.app` (under `$TMPDIR/mob_ios_device_*`), copy +`priv/python_wheels/{rns,lxmf,pyserial,pycparser}/.` into +`Pigeon.app/otp/python/lib/python3.13/site-packages/`, re-sign with +the in-build `mob_device.entitlements` file, then `xcrun devicectl +device install app`. Verified working on iPhone SE 3rd gen +(00008110-001E1C3A34F8401E) on 2026-05-11. + +**What the build rebuild should do**: add a wheel-copy step to the +iOS device path mirroring Android's. The cleanest spot is right +after the `cp -R "$PYTHON_LIB_DYNLOAD" "$OTP_ROOT/python/lib/ +python3.13/lib-dynload"` line in the build_device.sh template (or +its Zig successor — `ios/build_device.zig` is where this naturally +lives after Phase 2 iter 12). Same shape as Android, same wheel +source (`priv/python_wheels/<wheel>/`), same destination layout +(`<otp_bundle>/python/lib/python3.13/site-packages/<wheel-contents>`). + +--- + +## 5. iOS device default relay host (verified 2026-05-11) + +**Symptom**: Pigeon (or any mob app using a Mac-based dev relay) on +physical iOS gets `[Errno 61] Connection refused` for the relay +TCPInterface. `127.0.0.1` resolves to the *phone's* loopback, not the +developer's Mac — different from the iOS simulator (which shares the +host network stack via XPC) and Android emulator (which has the +`10.0.2.2` host-loopback alias). + +**Where the bad default came from**: `Pigeon.App.on_start/0` +hard-codes a platform-aware default of `127.0.0.1` for iOS and +`10.0.2.2` for Android via `Pigeon.PythonPaths.detect/1`. Both are +*simulator/emulator* defaults; neither works on real hardware. + +**Workaround for now**: rely on AutoInterface multicast over LAN +(verified working — iPhone SE 3rd gen reached the bridge via shared +Wi-Fi). Set `PIGEON_RELAY_HOST` to the Mac's actual LAN IP when +explicit relay routing is needed. + +**What's needed**: detect "physical device" vs "simulator/emulator" +at build time (or compute the Mac's LAN IP and stamp it into the +build env) so the in-app default is right by default. The detection +is already in `Pigeon.PythonPaths.detect/1` (returns `:ios` for +both sim and device today — that's the bug); split into `:ios_sim` +vs `:ios_device` or surface the Mac's LAN IP via a build-time env +var the way `MOB_IOS_TEAM_ID` etc. flow today. + +Both items 4 and 5 are small mob_dev template changes. Either land +them as point fixes in build_device.sh / build_device.zig templates, +or fold them into Phase 2 iter 12d's bundle-assembly + provisioning +move into Mix proper. diff --git a/plugin_extraction_plan.md b/plugin_extraction_plan.md new file mode 100644 index 00000000..e3f2fd8f --- /dev/null +++ b/plugin_extraction_plan.md @@ -0,0 +1,664 @@ +# Plugin extraction plan + +This is the rolling tracker for migrating core mob modules into plugins +under `MOB_PLUGINS.md` and `MOB_STYLES.md`. It captures sequencing, +open questions, and the rationale for what stays vs what leaves core. + +## Scope — the lanes mob is in + +Mob's design lanes are explicit. Picking a small number of lanes the +framework excels at beats spreading thin across every possible +direction. The lanes: + +1. **Elixir-first.** The first-class authoring language. The + `~MOB"""..."""` sigil, screen lifecycle macros, theme structs, + and tooling (`mix mob.*`) target Elixir developers. Ergonomic + surface gets prioritized for Elixir. +2. **BEAM-native.** Apps live as supervised BEAM processes on + device. Distribution, hot-push, screen GenServers, the OTP + release model. The framework's value proposition rests on this. +3. **Gen AI enabled.** Both the app surface (clean integration of + LLM clients, on-device inference via NIFs and Pythonx, agent + patterns for end-user features) and the development surface + (mob is built to be hospitable to AI-pair-programmers — the + existing `guides/agentic_coding.md` is the entry point). + +### What's explicitly parked + +These directions are valid use cases that mob is not actively +designing for. The door is left open for ambitious plugin authors +to attempt them, but the framework's design decisions don't try +to enable them at the cost of clarity in the chosen lanes. + +- **Full-language frontends in non-BEAM languages** (entire app + written in Python via Pythonx, JS via QuickJS, Lua, etc.). The + hooks exist conceptually — see `MOB_PLUGINS.md` "Future: + full-language plugins." Anyone determined enough can build it; + the spec doesn't currently provide a turn-key path. +- **Native-only apps using BEAM as a backend service.** This is + inverted from mob's architecture (mob owns the runtime; native + is the rendering surface). Use Phoenix / a separate Erlang + service if that's the shape you want. +- **Web/PWA targets.** Mob is mobile-first. Phoenix LiveView is + already excellent for web; we're not competing. +- **Cross-platform pixel-identical UI by default.** Each platform + uses native primitives. Pixel parity is achievable via style + plugins (see `MOB_STYLES.md`) but isn't the default behavior. + +The lanes determine which extensions are first-class and which +rely on community initiative. A plugin in-lane gets framework +infrastructure designed to support it (theme presets, Gen AI +integrations, NIF language packs). A plugin out-of-lane has to +build more of its own scaffolding — and that's fine, but the +framework won't co-evolve to make it easier. + +The plan has three phases: + +1. **Prototype phase** — build greenfield prototype plugins at each + tier (plus one style) as local `path:` deps. Validates the + manifest schema, compile-time merge, native dispatch table, and + style cascade against fresh code. No core churn. +2. **Vetting infrastructure** — design and partially implement the + trust model from `MOB_PLUGIN_SECURITY.md` in parallel with phase 1. + Real extractions wait for this to land. +3. **Extraction waves** — once the infrastructure proves itself, + migrate existing core modules into plugins in sequenced waves. Each + wave produces a real Hex package, vetted via the new tooling, + replacing the in-core module with a no-op deprecation stub for one + minor cycle. + +Each phase has its own checklist below. Tick boxes as work lands. + +There's a **Phase 0** below — preconditions that must be true before +the rest is productive. Do those first. + +## Phase 0 — Preconditions + +Done before Phase 1 begins. None of these are large; collectively +they unblock the rest. + +- [ ] Design docs committed to master: `MOB_PLUGINS.md`, + `MOB_STYLES.md`, `MOB_PLUGIN_SECURITY.md`, this file. Push to + origin so the design is reviewable by anyone tracking the repo. +- [ ] Premature implementation reverted from any active branches + (the in-flight Swift edits to `MobToggle` / `MobTextField` were + reverted on the material-3 worktree; the worktree itself should + be retired or repurposed — its branch name no longer matches the + work). +- [ ] `plugins/` directory created at the working host's level + (initial host: `mob_m3_test`). This is where the Phase 1 + prototype `path:` deps will live. +- [ ] **Rustler env-var fix tested and confirmed working on a + physical Android device.** This unblocks the tier-1.5 Rust NIF + prototype in Phase 1 and the eventual `mob_rustler` extraction + in Wave 1.5. Brief: `agent_briefs/rustler_env_var_test.md`. +- [ ] `MobDev.Plugin.host_config/3` API stubbed (can be a one-line + `Application.get_env/3` wrapper for now — the point is the call + surface exists so Phase 1 prototypes can use it). The spec-v2 + generator prototypes need this. + +These preconditions are independent and parallelizable. Items 1-3 +and 5 are author-driven (Kevin or in-conversation work). Item 4 is +delegated to the agent brief. + +### Phase 0 exit criteria + +- [ ] Design corpus visible on `origin/master`. +- [ ] `plugins/` directory exists, ready to receive `path:`-deps. +- [ ] At least one rustler-based NIF demo deploys to a physical + Android device and resolves `enif_*` symbols correctly. +- [ ] `MobDev.Plugin.host_config/3` callable from a generated + context (verified by a trivial test reading a known config key). + +## Phase 1 — Prototype plugins + +Six local-only packages under `plugins/` in the working directory, +wired into `mob_m3_test` (or a dedicated demo host) via `path:` deps. +The intent is to exercise every code path in the manifest schema +before touching real code. + +### `plugins/mob_palette_demo` (Tier 0) + +Pure Elixir helper. No manifest required. + +- [ ] Hex package with one module: `MobPaletteDemo.suggest_complement/1` +- [ ] Depends on `:mob` (`~> 0.6`). +- [ ] No `priv/mob_plugin.exs` at all — proves tier-0 path works. + +**Validates:** `mix mob.plugins` correctly reports "no manifest, treated +as regular dep." The framework's compile step does nothing special. + +### `plugins/mob_demo_haptic_extras` (Tier 1) + +NIF + Elixir wrapper. The native code is trivial (returns a constant) +so the focus is on the build pipeline. + +- [ ] `priv/mob_plugin.exs` with `:nifs`, `:ios.frameworks`, + `:android.gradle_deps`. +- [ ] Minimal NIF in `priv/native/jni/haptic_extras.c` (one function + returning `:ok`). +- [ ] Elixir wrapper `MobDemoHapticExtras` that loads the NIF. + +**Validates:** static-NIF merge into `libpigeon.so` (Android) and the +host's iOS binary. The host can call the wrapper from any screen. +Confirms the no-dlopen rule survives plugins. + +### `plugins/mob_demo_signature_pad` (Tier 2) + +New `<SignaturePad>` component. The drawing is a no-op (renders a +single colored rectangle) — focus is on `:ui_components` registration. + +- [ ] Manifest's `:ui_components` declares the tag/atom + view names. +- [ ] iOS: `priv/native/ios/MobSignaturePadView.swift` — simple + `RoundedRectangle` view that reads `bg_color` and `corner_radius` + from the node. +- [ ] Android: `priv/native/android/MobSignaturePad.kt` — same shape. +- [ ] Host (`mob_m3_test`) renders `<SignaturePad bg_color={:primary} + corner_radius={:radius_lg} />` on a test screen. + +**Validates:** the native dispatch table picks up the plugin's view +class names, the renderer routes correctly, props flow through. + +### `plugins/mob_demo_kv_browser` (Tier 3) + +Multi-screen plugin — a browse-screen for the contents of +`Mob.Storage`. Real-ish utility, simple enough to ship. + +- [ ] Manifest's `:screens`, `:migrations`, `:assets` populated. +- [ ] Two screens: `MobDemoKvBrowser.ListScreen` and + `MobDemoKvBrowser.DetailScreen`. +- [ ] One trivial Ecto migration in `priv/repo/migrations/` (with + `repo_namespace: "mob_demo_kv_browser_"`). +- [ ] One bundled font in `priv/assets/fonts/`. +- [ ] Host wires the screens into `App.navigation/1` after activation. + +**Validates:** screen module discovery + route declaration, migration +prefix collision rules, asset merging, the `mix mob.add_plugin` +interactive flow (if implemented). + +### `plugins/mob_demo_uptime_kit` (Tier 4) + +Embedded sub-app. Pings a hardcoded URL every 30s (interval is a +setting) and exposes a status screen + a notification handler. + +- [ ] Manifest with full `:lifecycle`, `:settings`, `:notifications` + sections. +- [ ] `MobDemoUptimeKit.PingWorker` GenServer under the host's + supervisor. +- [ ] `MobDemoUptimeKit.SettingsScreen` editor. +- [ ] Notification handler reacts to a fake `%{type: "uptime_alert"}` + push. + +**Validates:** supervisor wiring, settings schema validation, +notification dispatch by handler-match, `on_resume` / `on_background` +lifecycle hooks. + +### `plugins/mob_style_neutral_loud` (Style) + +A deliberately-loud style for visible verification of the dispatch. +Replaces the `<Toggle>` thumb with a hot-pink square, the `<Button>` +with a thick black border + yellow fill. Easy to see "is the override +working?" at a glance. + +- [ ] `priv/mob_style.exs` with `:theme`, `:component_views` for + `:toggle` and `:button`. +- [ ] Theme struct module `MobStyleNeutralLoud.Theme` with garish but + valid token values. +- [ ] iOS + Android primitives following the prop contracts for Toggle + and Button. +- [ ] Host activates as `config :mob, :styles, [:mob_style_neutral_loud]` + + `config :mob, :default_style, :mob_style_neutral_loud`. +- [ ] Per-element opt-out tested: `<Toggle style={nil}/>` should fall + back to baseline. +- [ ] Cherry-pick tested: install the loud style + a hypothetical + `mob_style_neutral_quiet` (token-only, no overrides), verify + per-element `style:` props swap correctly. + +**Validates:** `MOB_STYLES.md` end-to-end — cascade resolution, native +registry keyed by `(style_name, atom)`, baseline fallback, the prop +contract. + +### `plugins/mob_demo_ash_resources` (Code-generated tier 3+) + +Validates the spec-v2 `:screens_generator` and `MobDev.Plugin.host_config/3` +path. This must work before we let other parties (Ash maintainers, in +particular) build on the plugin system — if it can't be done, the +extensibility story is incomplete. + +- [ ] Minimal Ash-shaped domain in the host (one stub resource — + `MobM3Test.Note` with `:title` / `:body` attributes, in-memory + storage, no real database needed for the prototype). +- [ ] `priv/mob_plugin.exs` with `plugin_spec_version: 2` and + `screens_generator: {MobDemoAshResources.ScreenGenerator, :generate, []}`. +- [ ] `MobDemoAshResources.ScreenGenerator.generate/0` reads host + config via `MobDev.Plugin.host_config(:mob_m3_test, :ash_resources, [])` + and returns a list of three generated screens per resource. +- [ ] Generated modules created with `Module.create/3` at compile + time. Each screen renders a placeholder UI (`<List>` of attribute + rows for the list screen; a `<Form>`-shaped thing for the form + screen — actual Ash integration is out of scope here, this is + about the codegen path). +- [ ] Host wires the generated routes into `App.navigation/1`. +- [ ] Removing the resource from `:ash_resources` removes the screens + on next compile (no stale modules). +- [ ] Adding a second resource doubles the screen count on next + compile, verifying the generator runs each time. + +**Validates:** compile-time generator invocation, the +`MobDev.Plugin.host_config` API surface, `Module.create/3` for +generated screens, spec-v2 versioning, the path Ash (or any other +code-generating ecosystem) would actually use. + +### Phase 1 exit criteria + +- [ ] All six prototypes deploy and render on iOS sim, iOS device, + Android emulator, Android physical device. +- [ ] `mix mob.plugins` and `mix mob.styles` list and describe each + correctly. +- [ ] Hot-push deploys an Elixir-only change in the tier-3 plugin + without rebuild (the part that's hot-pushable). +- [ ] Removing a plugin from `config :mob, :plugins` cleanly removes + its contributions from the next build. +- [ ] The contract test suite from `MOB_STYLES.md` runs against + `mob_style_neutral_loud` and passes. + +## Phase 2 — Vetting infrastructure (parallel) + +See `MOB_PLUGIN_SECURITY.md` for the trust model design. +Implementation tasks belong here. + +- [ ] `mix mob.audit_plugins` — scan activated plugin sources for + flagged patterns (`Code.eval_string`, `:erlang.binary_to_term/2` + with untrusted input, undeclared file/network access). +- [ ] Plugin manifest signing — extend `mix hex.publish` flow with + a mob-specific signature over the manifest + native source tree. +- [ ] Capability enforcement at compile time — refuse to merge iOS + frameworks or Android permissions for plugins that don't declare + them in the manifest. +- [ ] Source-hash pinning in `mix.lock` for plugin `priv/native/` trees. +- [ ] Plugin allowlist / concerns feed — fetched by `mix mob.doctor`. +- [ ] Wire vetting status into `mix mob.plugins` output (alongside + installed-but-not-activated, hot-pushability, etc.). + +Phase 2 doesn't have to be fully shipped before Phase 3 begins, but +the *trust model and manifest-signing format* should be locked before +extracting modules that previously enjoyed implicit trust as part of +core. + +### Phase 2 exit criteria + +- [ ] `mix mob.audit_plugins` runs against the Phase 1 prototypes + and produces correct results (clean findings for the well-behaved + ones; flagged findings for any deliberately-crafted test cases). +- [ ] Manifest signing format locked: format documented in + `MOB_PLUGIN_SECURITY.md` is stable; reference implementation + signs + verifies a prototype manifest end-to-end. +- [ ] Capability enforcement demonstrably refuses to merge an + undeclared iOS framework or Android permission. Test by adding + an undeclared framework to a prototype's source and confirming + the build fails with a clear error. +- [ ] `:acknowledge_unsafe_plugins` flow works: building with an + unsigned plugin without the acknowledgement fails; with it, + succeeds + prints the persistent banner. +- [ ] `mix mob.plugins` output shows signing/audit/vetting status + for each installed plugin. + +## Phase 3 — Extraction waves + +Once Phase 1 + Phase 2's manifest signing is in place, migrate +existing modules out of core. Each extraction: + +1. Creates a new package repo mirroring the existing module's API + (path-dep on mob while dogfooding; Hex constraint at publish). +2. Ships a test suite: manifest validates via the real mob_dev + Validator, NIF-stub arity agreement, API parity with the old + core surface (the mob_camera suite is the template). +3. **Strips core with NO deprecation shim** — policy decided during + Waves 1-2 (location, camera): nothing else consumes these core + modules yet, master is integration not release, and the whole + strip lands behind one breaking major-semver Hex release. The + original one-minor-shim plan is superseded. +4. Updates `mob_new`'s generated project templates (perms/plist/ + Kotlin strips) and, where applicable, the wizard opt-in. +5. Declares any AndroidManifest fragments the plugin system can't + contribute in the manifest's `host_requirements` key (printed by + every native build). + +Each wave produces multiple plugins. Run in parallel within a wave. + +### Wave 1 — proves the extraction shape + +Just one plugin, the heaviest non-essential dep: + +- [x] `mob_bluetooth` ← extracts `lib/mob/bt.ex` + `lib/mob/bt/{hfp,hid,spp}.ex` (548 LoC + native) + - Already documented as the canonical tier-1 example in `MOB_PLUGINS.md`. + - Permissions: `BLUETOOTH_CONNECT`, `BLUETOOTH_SCAN`, `NSBluetoothAlwaysUsageDescription`. + - **In progress (2026-05-28).** Session A (Elixir only, NIF stayed in core) shipped. The + full extraction turned out to be a THREE-layer native move — zig (`nif_bt_*` + + `mob_deliver_bt_*`), C JNI thunks (`beam_jni.c`), and ~450 lines of real Kotlin + (`MobBridge.kt` `bt_*`) — needing two net-new plugin-system capabilities first. See the + decision trail (in mob_dev `decisions/`, the plugin infra repo): + - `2026-05-28-bt-full-three-layer-extraction.md` — scope decision + alternatives weighed. + - `2026-05-28-zig-plugin-nifs.md` — zig plugin NIF compile path. **Built + live-verified + on device** (trivial `mob_demo_zig_extras` NIF: `answer/0 => 42`). + - `2026-05-28-android-plugin-bridge-classes.md` — plugin-owned Kotlin bridge class + + `nativeRegister` jclass caching + `MobPluginBootstrap` startup hook. Foundation built + (Merge accessors + `-Dplugin_jni_sources` compile); bridge_kt copy + bootstrap codegen + in progress; trivial-bridge device proof pending before the bt move. + - Branches (uncommitted): infra on mob_dev `plugin-host-config`; core strip will land on + mob `bt-nif-session-b` worktree; Elixir lives in the `mob_bluetooth` repo (`session-b-nif`). + +### Wave 2 — privacy-heavy capabilities + +Each needs `Mob.Permissions` integration. Demonstrates the +permission opt-in story. + +- [x] `mob_camera` ← `lib/mob/camera.ex` (165 LoC + heavy iOS/Android). + **Merged to master 2026-06-11.** Capture + frame-stream + `:camera` + permission moved; the `camera_preview` VIEW stays in core (weak-extern + `g_preview_session`) until the plugin native-view-bound-to-state + capability lands. `:microphone` stays in core for audio. FileProvider + declared via `host_requirements`. +- [x] `mob_location` ← `lib/mob/location.ex` (65 LoC + native). + **Merged 2026-06-06.** The Wave-2 pattern-setter: drove the extensible + permission registry + per-platform NIF tagging + ObjC plugin-NIF path. +- [x] `mob_notify` ← `lib/mob/notify.ex` (107 LoC + native). Extracted + + Moto-G-verified 2026-06-11 (all stages but live-push: scheduled notification + fires through the plugin-alarm → host-receiver chain, cancel works; core + keeps ALL delivery via the new mob_notify_set_screen_pid export + the + generated io.mob.plugin.MobNotifyHub seam). iPhone build + live APNs/FCM + push pending. Stage 1a shipped same day (contract fixtures both + sides + tests); see the repo's EXTRACTION.md. Greenlit + 2026-06-11. Scheduling + push REGISTRATION move; the `{:notification, + ...}` delivery plumbing stays in core (shared with the tier-4 plugin + dispatcher). Companion to the published server-side `mob_push` package — + deliberately a SEPARATE package (different runtime, zero shared deps); + the wire contract (payload shape, `{:push_token, platform, token}`) + gets vendored contract-test fixtures in BOTH repos. The FCM + `.MobFirebaseService` host `<service>` is a `host_requirements` entry. +- [x] `mob_photos` ← `lib/mob/photos.ex` (33 LoC + native). Extracted + + Moto-G-verified 2026-06-11 (cancel AND picked round-trips green; Android + picker now honors `max:` via ActivityResultRegistry). iPhone build pending. +- [x] `mob_biometric` ← `lib/mob/biometric.ex` (28 LoC + native). Extracted + + Moto-G-verified 2026-06-11 (round-trip delivers :not_available as the + degradation predicts). iPhone build pending. + Extraction surfaced two latent core gaps, documented in the repo: Android + biometric was degraded (FragmentActivity cast nulls on the ComponentActivity + host → always :not_available) and NSFaceIDUsageDescription was missing from + templates (now in the plugin manifest). + +### Wave 3 — specialty + +- [ ] `mob_vendor_usb` ← `lib/mob/vendor_usb.ex` (334 LoC). The + air_cart_max use case. Very specialized. +- [x] `mob_scanner` ← `lib/mob/scanner.ex` (47 LoC). Extracted + + Moto-G-verified 2026-06-11 — the Wave-3 opener and the first + plugin→plugin relationship: scanner has NO :camera capability entry + (mob_camera's registry handler owns it; activate both). Its Activity is + an AndroidManifest fragment → host_requirements; both Kotlin classes + live in one bridge_kt file (the copy channel is one file per plugin — + an android.kotlin_files capability is the noted alternative). iPhone + pass pending. +- [ ] `mob_webview` ← `lib/mob/webview.ex` (48 LoC + heavy native). +- [ ] `mob_canvas` ← `lib/mob/canvas.ex` (272 LoC + draw ops). + +### Wave 4 — theme presets as style packages + +These extract under `MOB_STYLES.md`, not `MOB_PLUGINS.md`. Can run +in parallel with Wave 1. + +- [ ] `mob_theme_material3` ← `lib/mob/theme/material3.ex` (191 LoC) + + custom native primitives for Toggle/TextField/Button to match + M3 spec pixel-perfectly. +- [x] `mob_theme_citrus` ← `lib/mob/theme/citrus.ex`. Extracted + + Moto-G-verified 2026-06-11 — the FIRST style package, and the styles + lane's tokens-only slice now EXISTS (it was design-only): MobDev.Style + (priv/mob_style.exs loader + 4-field validator + config :mob, :styles / + :default_style activation, misconfig fails the BUILD) riding the plugin + runtime manifest; core applies the default style's theme at boot. + Cascade / per-element style props / native overrides (the mob_m3 tier) + remain unbuilt by design. +- [x] ALL remaining presets consolidated into ONE package — `mob_themes` + (Kevin 2026-06-11: themes are decent defaults, not sacred; they don't + each need their own plugin). Obsidian (package default) + ObsidianGlass + + Citrus + Birch + Material3, Moto-G-verified (boot default + all five + flip live). The per-theme rows below are superseded; material3's + pixel-perfect native primitives still await the native style tier. + +**Stays in core (baseline):** `theme/light.ex`, `theme/dark.ex`, +`theme/adaptive.ex`, `theme/adaptive_watcher.ex`. These are the +no-style-installed path described in `MOB_STYLES.md`. + +### Wave 5 — small fries (optional) + +Only if a minimal core is desired. Skip otherwise. + +- [ ] `mob_audio` ← `lib/mob/audio.ex` (107 LoC) +- [ ] `mob_motion` ← `lib/mob/motion.ex` (48 LoC) +- [ ] `mob_share` ← `lib/mob/share.ex` (25 LoC) +- [ ] `mob_haptic` ← `lib/mob/haptic.ex` (41 LoC) +- [ ] `mob_clipboard` ← `lib/mob/clipboard.ex` (46 LoC) + +### Spec-v2 lane: REALIZED — mob_ash (2026-06-11) + +The Ash integration the v2 spec was designed around now exists as a real +package (GenericJam/mob_ash, private) and is Moto-G-verified end to end: +host declares Ash resources + :ash_domains, the audited generator emits +list/detail/new routes per resource onto three shared parameterized +screens, and the resource module rides each route as route-bound nav +params. Three abstraction gaps were found and fixed in the process — +route-bound params (mob Nav.Registry.register/3), generator host-app +discovery (Mix.Project, not hardcoded), and the regen task loading host +code before running generators (generators may touch host MODULES, not +just config). Everything else — manifest extra-key passthrough, a +heavyweight pure-Elixir runtime dep (ash), hot-pushable tier-3, the +host-config audit — held without modification. + +### Wave 6 — Gen AI plugins (in-lane, new packages) + +These are *not* extractions from core — they're new plugins that +flesh out the "Gen AI enabled" design lane stated above. Listed +here so the lane has concrete deliverables. Each is independently +useful, ships on its own timeline, and validates that the plugin +system handles AI-shaped capabilities cleanly. + +- [ ] `mob_llm` — generic LLM client. One protocol, multiple + providers (Anthropic, OpenAI, Bedrock, local-via-mob_pythonx). + Tier-1 (NIF-free; just HTTP + streaming). Becomes the canonical + way mob apps call cloud LLMs. +- [ ] `mob_speech` — speech-to-text + text-to-speech. STT via + Whisper-on-device (mob_pythonx + Whisper.cpp via NIF, or + iOS/Android system APIs). TTS via system APIs. Tier-1/2. +- [ ] `mob_local_llm` — on-device LLM inference. Backends: llama.cpp + (via Zig NIF), MLX on iOS (already partially in mob's existing + ML work). Tier-1.5 (language-pack pattern, since it ships an + inference runtime). +- [ ] `mob_embeddings` — vector embeddings + vector store. Local + vector DB (sqlite-vec via NIF) + remote embedding APIs. Tier-1. +- [ ] `mob_rag` — RAG pattern helpers. Depends on `mob_llm` + + `mob_embeddings`. Tier-0/1. +- [ ] `mob_agent_kit` — multi-step agent loop primitives (tool use, + conversation state, tool registry). Tier-3 (ships screens for + agent inspection + chat UI). Depends on `mob_llm`. + +The framework's job is to make sure these compose cleanly — a mob +app should be able to install `mob_llm` + `mob_speech` + +`mob_agent_kit` and have them work together without per-pair glue. + +### Phase 3 exit criteria + +Per-wave exit criteria — a wave isn't done until all of these hold +for every plugin in it: + +- [ ] Plugin is a real Hex package (or stable git tag), versioned, + documented, hexdocs published. +- [ ] In-core module replaced with a deprecation shim that + re-exports from the new plugin for one minor cycle (with a + deprecation warning), then removed in the cycle after. +- [ ] Contract tests demonstrate parity with the previous in-core + behavior — same API surface, same return shapes. +- [ ] `mob_new` generator updated to depend on the plugin via the + wizard's opt-in question. +- [ ] CHANGELOG and migration notes published. + +Phase 3 as a whole is done when every wave's plugins are landed + +the deprecation shims are removed. Realistically a multi-quarter +process; expect to release intermediate mob versions during. + +## What stays in core, finalised + +Re-stated here so the boundary is explicit. + +**UI runtime:** `renderer.ex`, `ui.ex`, `sigil.ex`, `component.ex`, +`component_registry.ex`, `component_server.ex`, `nav/`. + +**App lifecycle:** `app.ex`, `screen.ex`, `screen_state.ex`, +`socket.ex`, `state.ex`. + +**Distribution + diagnostics:** `dist.ex`, `dns.ex`, `event/`, +`event.ex`, `live_view.ex`, `native_logger.ex`, `diag.ex`, +`formatter.ex`, `list.ex`, `registry.ex`. + +**Storage primitives:** `storage.ex` + `storage/`. + +**Files:** `files.ex` — small, universal. + +**Permissions coordinator:** `permissions.ex` — every privacy-gated +plugin depends on it. Stays as the central point of integration. + +**Device detection:** `device.ex`, `device/android.ex`, +`device/ios.ex`. + +**Theme baseline:** `theme.ex` + `theme/{light,dark,adaptive,adaptive_watcher}.ex`. +These are the no-style-installed path. The neutral baseline that hand-coding +users rely on. + +**Test harness:** `test.ex` — plugins themselves need this to be +testable. + +## Open questions parked deliberately + +- **Inter-plugin dependencies.** `mob_scanner` likely depends on + `mob_camera`. Does Hex's existing dep resolution handle it + cleanly, or do we need a `:requires` field in the manifest? Likely + the former; verify before Wave 2. +- **Hot-push under multi-plugin loads.** With 5+ plugins active, + does `mix mob.push` correctly diff per-plugin and skip native + rebuilds for plugins that didn't change? Test during Phase 1 + with the prototypes. +- **CI matrix.** Each plugin's CI is independent. The host app's CI + matrix grows by `(plugins choose 2) + 1` if we want to test pairwise + combinations. Probably overkill; ship "no plugins" + "all plugins" + + per-plugin and accept the gaps. +- **Re-installation of capabilities the user already wrote against.** + When `lib/mob/bt.ex` extracts to `mob_bluetooth`, every existing + app using `Mob.Bt` breaks unless we keep an alias. A one-cycle + deprecation shim that re-exports the moved module from core + buys time. Decide per-extraction. +- **Plugin discoverability.** Once there are 20+ plugins on Hex, + users need a curated entry point. `awesome-mob` or + `mob.docs/plugins` index, refreshed weekly from Hex. +- **Version-skew between plugin and core.** A plugin built against + mob 0.6.x might break on 0.7.x. The `mob_version` requirement + already enforces this at compile time, but the user UX when the + constraint fails needs polish — clear "this plugin needs an + update" message + suggested action. + +## Risk register + +Top risks worth tracking. Listed with current mitigation thinking; revisit when each phase begins. + +- **Phase 1 surfaces a manifest design flaw.** Building the seven + prototypes is the test of whether `MOB_PLUGINS.md` / `MOB_STYLES.md` + hold up. *Mitigation:* prototypes are local `path:` deps, not + published — the manifest spec can revise via spec_version bump + before any plugin is in user hands. +- **Native build complexity for tier-1.5 (Rust, Python) plugins.** + Cross-target toolchain coordination is hairy. *Mitigation:* the + rustler env-var fix (Phase 0) confirms the static-link path works + end-to-end; Pythonx is already running on-device in the existing + codebase, so the extraction is reorganization, not new R&D. +- **Plugin combinatorial blow-up.** With 20+ plugins on Hex, pairwise + testing is intractable. *Mitigation:* per `MOB_STYLES.md`, the + cascade is computed Elixir-side and native dispatch is a flat + table — plugins compose by construction, not by ad-hoc glue. CI + matrix is "no plugins" + "all common combinations" + per-plugin. +- **Migration friction for existing Mob apps.** When `lib/mob/bt.ex` + moves to `mob_bluetooth`, every app using `Mob.Bt` breaks. + *Mitigation:* one-cycle deprecation shim re-exporting the moved + module. Concrete pattern documented per-wave in Phase 3. +- **Supply-chain trust expectations exceed what we can guarantee.** + `MOB_PLUGIN_SECURITY.md` is explicit that mob is not a sandbox and + the curated allowlist isn't gatekept entry. *Mitigation:* + prominently surface the trust model + non-promises in user docs; + don't oversell. +- **filmor / rustler upstream relationship.** The PR may stall or + not land; the env-var approach may need iteration. *Mitigation:* + the `GenericJam/rustler` fork already exists; users can pin to it + via `[patch.crates-io]` indefinitely if upstream doesn't merge. + Worst case is a maintained fork. +- **Time to value.** Phase 3 is multi-quarter. Users and contributors + may lose interest if there's nothing concrete to point at. + *Mitigation:* Phase 1 prototypes produce visible deliverables in + weeks, not months. The `mob_m3` style package (a fast Wave 4 win) + is a flagship that motivates the work. + +## Kickoff checklist + +Day-1 concrete actions, in order. Tick as work starts. + +- [ ] Push the design corpus (this file + `MOB_PLUGINS.md` + + `MOB_STYLES.md` + `MOB_PLUGIN_SECURITY.md`) from local master to + `origin/master`. The commits are already landed locally; this + publishes them. +- [ ] Hand off `agent_briefs/rustler_env_var_test.md` to a coding + agent. It runs in parallel; results come back independently. +- [ ] Create `mob_m3_test/plugins/` directory. +- [ ] Scaffold `plugins/mob_palette_demo` (tier 0 — easiest). Confirm + `mix.exs` + `mix compile` accept it as a `path:` dep with no + manifest. This validates the lowest-friction plugin shape. +- [ ] Stub `MobDev.Plugin.host_config/3` in `mob_dev` as a one-line + wrapper around `Application.get_env/3`. Commit + bump mob_dev + patch version. +- [ ] Decide the working host for Phase 1: `mob_m3_test` (current + test app) or a dedicated `mob_plugin_demo` repo. The latter + decouples plugin-system iteration from theme work but adds repo + overhead. Default to the current `mob_m3_test` unless there's a + reason to split. + +After this checklist, Phase 1 prototypes start landing one at a +time. Order suggestion (easiest → hardest): + +1. `mob_palette_demo` (tier 0 — no manifest) +2. `mob_demo_haptic_extras` (tier 1 — NIF baseline) +3. `mob_demo_signature_pad` (tier 2 — new component) +4. `mob_style_neutral_loud` (style — exercises `MOB_STYLES.md`) +5. `mob_demo_kv_browser` (tier 3 — multi-screen) +6. `mob_demo_uptime_kit` (tier 4 — sub-app) +7. `mob_demo_ash_resources` (code-generated — depends on + `host_config/3` stub) +8. `mob_demo_rust_nif` (tier 1.5 — depends on Phase 0 rustler fix) + +Items 7 and 8 are unblocked by Phase 0 work; you can start in any +order once Phase 0 lands. + +## Status + +(2026-06-11) + +Phase 0: DONE. +Phase 1: DONE — all five tiers built, device-verified (iPhone + Moto G), + prototypes live in `mob_plugin_demo/plugins/`; tiers 0-4 + custom fonts + merged to all masters 2026-06-06. +Phase 2: DONE — signing/trust/acknowledge shipped; cross-plugin conflict + detection (registry + completeness meta-test + property fuzzer); + `mix mob.plugins` / `mix mob.validate_plugin` live. +Phase 3: IN PROGRESS. + Wave 1 (mob_bluetooth): DONE, all masters 2026-06-01. + Wave 2: camera + location DONE (see wave notes); notify/photos/biometric + greenlit 2026-06-11, in flight. + Waves 3-6: not started. Note `mob_scanner` (Wave 3) is unblocked now + that `mob_camera` is merged. +Publish gate: NOTHING on Hex yet — the core strips are breaking, so the + whole arc ships behind a major-semver release (see RELEASE.md + the + per-repo release.yml pipelines). diff --git a/priv/tags/android.txt b/priv/tags/android.txt index bfa77a0f..06fbf07c 100644 --- a/priv/tags/android.txt +++ b/priv/tags/android.txt @@ -23,3 +23,4 @@ Toggle Video CameraPreview WebView +GpuView diff --git a/priv/tags/ios.txt b/priv/tags/ios.txt index 5934b74a..0380a373 100644 --- a/priv/tags/ios.txt +++ b/priv/tags/ios.txt @@ -22,3 +22,4 @@ Toggle Video CameraPreview WebView +GpuView diff --git a/src/mob_nif.erl b/src/mob_nif.erl index bcfd676c..c7fed542 100644 --- a/src/mob_nif.erl +++ b/src/mob_nif.erl @@ -1,256 +1,376 @@ %% mob_nif.erl — Erlang NIF stub module. %% ERL_NIF_INIT in mob_nif.c / mob_nif.m registers functions under this module name. -module(mob_nif). --export([platform/0, - color_scheme/0, - log/1, log/2, - set_transition/1, - set_root/1, - register_tap/1, - clear_taps/0, - exit_app/0, - safe_area/0, - %% Device utilities (no permission required) - haptic/1, - clipboard_put/1, - clipboard_get/0, - share_text/1, - open_url/1, - %% Permissions - request_permission/1, - %% Biometric - biometric_authenticate/1, - %% Location - location_get_once/0, - location_start/1, - location_stop/0, - %% Camera - camera_capture_photo/1, - camera_capture_video/1, - camera_start_preview/1, - camera_stop_preview/0, - %% Photo library - photos_pick/2, - %% File picker - files_pick/1, - %% Audio recording - audio_start_recording/1, - audio_stop_recording/0, - %% Audio playback - audio_play/2, - audio_stop_playback/0, - audio_set_volume/1, - %% Motion sensors - motion_start/2, - motion_stop/0, - %% QR / barcode scanner - scanner_scan/1, - %% Notifications - notify_schedule/1, - notify_cancel/1, - notify_register_push/0, - take_launch_notification/0, - %% Storage - storage_dir/1, - storage_save_to_photo_library/1, - storage_save_to_media_store/2, - storage_external_files_dir/1, - %% Alerts / overlays - alert_show/3, - action_sheet_show/2, - toast_show/2, - %% WebView - webview_eval_js/1, - webview_post_message/1, - webview_can_go_back/0, - webview_go_back/0, - %% Native view components - register_component/1, - deregister_component/1, - %% Background execution - background_keep_alive/0, - background_stop/0, - %% Device state - battery_level/0, - %% Device lifecycle (Mob.Device) - device_set_dispatcher/1, - device_battery_state/0, - device_thermal_state/0, - device_low_power_mode/0, - device_foreground/0, - device_os_version/0, - device_model/0, - %% Test harness — native UI inspection and interaction - ui_tree/0, - ui_view_tree/0, - ui_debug/0, - screen_info/0, - tap/1, - ax_action/2, - ax_action_at_xy/3, - tap_xy/2, - type_text/1, - delete_backward/0, - key_press/1, - clear_text/0, - long_press_xy/3, - swipe_xy/4]). +-export([ + platform/0, + color_scheme/0, + log/1, log/2, + set_transition/1, + set_root/1, + set_theme/1, + register_tap/1, + clear_taps/0, + exit_app/0, + safe_area/0, + %% Device utilities (no permission required) + haptic/1, + torch/1, + clipboard_put/1, + clipboard_get/0, + share_text/1, + open_url/1, + open_settings/1, + %% Permissions + request_permission/1, + %% Biometric + %% Photo library + %% File picker + files_pick/1, + %% Audio recording + audio_start_recording/1, + audio_stop_recording/0, + %% Audio input metering (mic level probe) + audio_start_input_metering/0, + audio_input_level/0, + audio_stop_input_metering/0, + %% Audio playback + audio_play/2, + audio_play_at/3, + audio_stop_playback/0, + audio_set_volume/1, + %% Audio output probes — verify sound is actually working (see Mob.Audio) + audio_output_status/0, + audio_output_level/1, + %% Text-to-speech (no permission required) + tts_speak/2, + tts_stop/0, + %% Motion sensors + motion_start/2, + motion_stop/0, + %% QR / barcode scanner + %% Notifications + take_launch_notification/0, + take_opened_document/0, + %% Storage + storage_dir/1, + storage_save_to_photo_library/1, + storage_save_to_media_store/2, + storage_external_files_dir/1, + %% Alerts / overlays + alert_show/3, + action_sheet_show/2, + toast_show/2, + %% WebView + webview_eval_js/1, + webview_post_message/1, + webview_can_go_back/0, + webview_go_back/0, + %% Native view components + register_component/1, + deregister_component/1, + %% Device state + battery_level/0, + %% Device lifecycle (Mob.Device) + device_set_dispatcher/1, + device_battery_state/0, + device_thermal_state/0, + device_network_state/0, + device_low_power_mode/0, + device_foreground/0, + device_os_version/0, + device_model/0, + device_orientation/0, + device_lock_orientation/1, + device_keep_awake/1, + %% Test harness — native UI inspection and interaction + ui_tree/0, + ui_view_tree/0, + ui_debug/0, + screen_info/0, + tap/1, + ax_action/2, + ax_action_at_xy/3, + tap_xy/2, + type_text/1, + delete_backward/0, + key_press/1, + clear_text/0, + long_press_xy/3, + swipe_xy/4, + %% Test harness — in-process visual capture and scroll control + %% (remote-driving: agent gets pixels + deterministic scroll over dist, + %% no adb/xcrun). See Mob.Test.screenshot/2, scroll_info/2, scroll_to/3. + screenshot/3, + scroll_info/1, + scroll_to/3, + element_frames/0, + %% Peripheral.VendorUsb (Android USB host; iOS returns :unsupported) + vendor_usb_list_devices/1, + vendor_usb_request_permission/1, + vendor_usb_open/1, + vendor_usb_bulk_write/3, + vendor_usb_start_reading/2, + vendor_usb_stop_reading/1, + vendor_usb_close/1, + %% Bluetooth Classic (Android; iOS returns :unsupported) + bt_list_paired/0, + bt_start_discovery/0, + bt_cancel_discovery/0, + bt_pair/1, + bt_unpair/1, + bt_disconnect/1, + bt_hfp_connect/1, + bt_hfp_subscribe_vendor_at/2, + bt_hfp_send_vendor_at/3, + bt_hfp_start_sco/1, + bt_hfp_stop_sco/1, + bt_hfp_send_audio/2, + bt_spp_connect/1, + bt_spp_write/2, + bt_hid_connect/1, + bt_hid_subscribe_raw/1, + %% DNS — see Mob.DNS and guides/dns_on_ios.md + resolve_ipv4/1 +]). --nifs([platform/0, - color_scheme/0, - log/1, log/2, - set_transition/1, - set_root/1, - register_tap/1, - clear_taps/0, - exit_app/0, - safe_area/0, - haptic/1, - clipboard_put/1, - clipboard_get/0, - share_text/1, - open_url/1, - request_permission/1, - biometric_authenticate/1, - location_get_once/0, - location_start/1, - location_stop/0, - camera_capture_photo/1, - camera_capture_video/1, - camera_start_preview/1, - camera_stop_preview/0, - photos_pick/2, - files_pick/1, - audio_start_recording/1, - audio_stop_recording/0, - audio_play/2, - audio_stop_playback/0, - audio_set_volume/1, - motion_start/2, - motion_stop/0, - scanner_scan/1, - notify_schedule/1, - notify_cancel/1, - notify_register_push/0, - take_launch_notification/0, - background_keep_alive/0, - background_stop/0, - battery_level/0, - device_set_dispatcher/1, - device_battery_state/0, - device_thermal_state/0, - device_low_power_mode/0, - device_foreground/0, - device_os_version/0, - device_model/0, - ui_tree/0, - ui_view_tree/0, - ui_debug/0, - screen_info/0, - tap/1, - ax_action/2, - ax_action_at_xy/3, - tap_xy/2, - type_text/1, - delete_backward/0, - key_press/1, - clear_text/0, - long_press_xy/3, - swipe_xy/4, - %% Storage - storage_dir/1, - storage_save_to_photo_library/1, - storage_save_to_media_store/2, - storage_external_files_dir/1, - %% Alerts / overlays - alert_show/3, - action_sheet_show/2, - toast_show/2, - %% WebView - webview_eval_js/1, - webview_post_message/1, - webview_can_go_back/0, - webview_go_back/0, - %% Native view components - register_component/1, - deregister_component/1]). +-nifs([ + platform/0, + color_scheme/0, + log/1, + log/2, + set_transition/1, + set_root/1, + set_theme/1, + register_tap/1, + clear_taps/0, + exit_app/0, + safe_area/0, + haptic/1, + torch/1, + clipboard_put/1, + clipboard_get/0, + share_text/1, + open_url/1, + open_settings/1, + request_permission/1, + files_pick/1, + audio_start_recording/1, + audio_stop_recording/0, + audio_start_input_metering/0, + audio_input_level/0, + audio_stop_input_metering/0, + audio_play/2, + audio_play_at/3, + audio_stop_playback/0, + audio_set_volume/1, + audio_output_status/0, + audio_output_level/1, + tts_speak/2, + tts_stop/0, + motion_start/2, + motion_stop/0, + take_launch_notification/0, + take_opened_document/0, + battery_level/0, + device_set_dispatcher/1, + device_battery_state/0, + device_thermal_state/0, + device_network_state/0, + device_low_power_mode/0, + device_foreground/0, + device_os_version/0, + device_model/0, + device_orientation/0, + device_lock_orientation/1, + device_keep_awake/1, + ui_tree/0, + ui_view_tree/0, + ui_debug/0, + screen_info/0, + tap/1, + ax_action/2, + ax_action_at_xy/3, + tap_xy/2, + type_text/1, + delete_backward/0, + key_press/1, + clear_text/0, + long_press_xy/3, + swipe_xy/4, + screenshot/3, + scroll_info/1, + scroll_to/3, + element_frames/0, + %% Storage + storage_dir/1, + storage_save_to_photo_library/1, + storage_save_to_media_store/2, + storage_external_files_dir/1, + %% Alerts / overlays + alert_show/3, + action_sheet_show/2, + toast_show/2, + %% WebView + webview_eval_js/1, + webview_post_message/1, + webview_can_go_back/0, + webview_go_back/0, + %% Native view components + register_component/1, + deregister_component/1, + %% Peripheral.VendorUsb + vendor_usb_list_devices/1, + vendor_usb_request_permission/1, + vendor_usb_open/1, + vendor_usb_bulk_write/3, + vendor_usb_start_reading/2, + vendor_usb_stop_reading/1, + vendor_usb_close/1, + %% Bluetooth Classic + bt_list_paired/0, + bt_start_discovery/0, + bt_cancel_discovery/0, + bt_pair/1, + bt_unpair/1, + bt_disconnect/1, + bt_hfp_connect/1, + bt_hfp_subscribe_vendor_at/2, + bt_hfp_send_vendor_at/3, + bt_hfp_start_sco/1, + bt_hfp_stop_sco/1, + bt_hfp_send_audio/2, + bt_spp_connect/1, + bt_spp_write/2, + bt_hid_connect/1, + bt_hid_subscribe_raw/1, + %% DNS — in-process getaddrinfo so iOS apps bypass BEAM's + %% broken inet_gethost path. See `Mob.DNS` for the Elixir + %% wrapper and `guides/dns_on_ios.md` for the why. + resolve_ipv4/1 +]). -on_load(init/0). init() -> erlang:load_nif("mob_nif", 0). -platform() -> erlang:nif_error(not_loaded). -color_scheme() -> erlang:nif_error(not_loaded). -log(_Msg) -> erlang:nif_error(not_loaded). -log(_Level, _Msg) -> erlang:nif_error(not_loaded). -set_transition(_Trans) -> erlang:nif_error(not_loaded). -set_root(_Json) -> erlang:nif_error(not_loaded). -register_tap(_Pid) -> erlang:nif_error(not_loaded). -clear_taps() -> erlang:nif_error(not_loaded). -exit_app() -> erlang:nif_error(not_loaded). -safe_area() -> erlang:nif_error(not_loaded). -haptic(_Type) -> erlang:nif_error(not_loaded). -clipboard_put(_Text) -> erlang:nif_error(not_loaded). -clipboard_get() -> erlang:nif_error(not_loaded). -share_text(_Text) -> erlang:nif_error(not_loaded). -open_url(_Url) -> erlang:nif_error(not_loaded). -request_permission(_Cap) -> erlang:nif_error(not_loaded). -biometric_authenticate(_Reason) -> erlang:nif_error(not_loaded). -location_get_once() -> erlang:nif_error(not_loaded). -location_start(_Accuracy) -> erlang:nif_error(not_loaded). -location_stop() -> erlang:nif_error(not_loaded). -camera_capture_photo(_Quality) -> erlang:nif_error(not_loaded). -camera_capture_video(_MaxDuration)-> erlang:nif_error(not_loaded). -camera_start_preview(_OptsJson) -> erlang:nif_error(not_loaded). -camera_stop_preview() -> erlang:nif_error(not_loaded). -photos_pick(_Max, _Types) -> erlang:nif_error(not_loaded). -files_pick(_MimeTypes) -> erlang:nif_error(not_loaded). -audio_start_recording(_OptsJson) -> erlang:nif_error(not_loaded). -audio_stop_recording() -> erlang:nif_error(not_loaded). -audio_play(_Path, _OptsJson) -> erlang:nif_error(not_loaded). -audio_stop_playback() -> erlang:nif_error(not_loaded). -audio_set_volume(_Volume) -> erlang:nif_error(not_loaded). +platform() -> erlang:nif_error(not_loaded). +color_scheme() -> erlang:nif_error(not_loaded). +log(_Msg) -> erlang:nif_error(not_loaded). +log(_Level, _Msg) -> erlang:nif_error(not_loaded). +set_transition(_Trans) -> erlang:nif_error(not_loaded). +set_root(_Json) -> erlang:nif_error(not_loaded). + +%% set_theme(JsonBinary) — push resolved theme palette to the native side. +%% Lets Compose's MaterialTheme / SwiftUI environment follow runtime +%% Mob.Theme.set/1 calls instead of being baked into the host project's +%% MainActivity / app entry point. Called from Mob.Theme.set/1; the host +%% bridge consumes via MobBridge.setTheme(json) on Android (no-op on iOS, +%% which doesn't currently route Mob.Theme through native chrome). +set_theme(_Json) -> erlang:nif_error(not_loaded). +register_tap(_Pid) -> erlang:nif_error(not_loaded). +clear_taps() -> erlang:nif_error(not_loaded). +exit_app() -> erlang:nif_error(not_loaded). +safe_area() -> erlang:nif_error(not_loaded). +haptic(_Type) -> erlang:nif_error(not_loaded). +torch(_State) -> erlang:nif_error(not_loaded). +clipboard_put(_Text) -> erlang:nif_error(not_loaded). +clipboard_get() -> erlang:nif_error(not_loaded). +share_text(_Text) -> erlang:nif_error(not_loaded). +open_url(_Url) -> erlang:nif_error(not_loaded). +open_settings(_Target) -> erlang:nif_error(not_loaded). +request_permission(_Cap) -> erlang:nif_error(not_loaded). +files_pick(_MimeTypes) -> erlang:nif_error(not_loaded). +audio_start_recording(_OptsJson) -> erlang:nif_error(not_loaded). +audio_stop_recording() -> erlang:nif_error(not_loaded). +audio_start_input_metering() -> erlang:nif_error(not_loaded). +audio_input_level() -> erlang:nif_error(not_loaded). +audio_stop_input_metering() -> erlang:nif_error(not_loaded). +audio_play(_Path, _OptsJson) -> erlang:nif_error(not_loaded). +audio_play_at(_Path, _OptsJson, _AtWallMs) -> erlang:nif_error(not_loaded). +audio_stop_playback() -> erlang:nif_error(not_loaded). +audio_set_volume(_Volume) -> erlang:nif_error(not_loaded). +audio_output_status() -> erlang:nif_error(not_loaded). +audio_output_level(_Source) -> erlang:nif_error(not_loaded). +tts_speak(_Text, _OptsJson) -> erlang:nif_error(not_loaded). +tts_stop() -> erlang:nif_error(not_loaded). motion_start(_Sensors, _Interval) -> erlang:nif_error(not_loaded). -motion_stop() -> erlang:nif_error(not_loaded). -scanner_scan(_FormatsJson) -> erlang:nif_error(not_loaded). -notify_schedule(_OptsJson) -> erlang:nif_error(not_loaded). -notify_cancel(_Id) -> erlang:nif_error(not_loaded). -notify_register_push() -> erlang:nif_error(not_loaded). -take_launch_notification() -> erlang:nif_error(not_loaded). -background_keep_alive() -> erlang:nif_error(not_loaded). -background_stop() -> erlang:nif_error(not_loaded). -battery_level() -> erlang:nif_error(not_loaded). -device_set_dispatcher(_Pid) -> erlang:nif_error(not_loaded). -device_battery_state() -> erlang:nif_error(not_loaded). -device_thermal_state() -> erlang:nif_error(not_loaded). -device_low_power_mode() -> erlang:nif_error(not_loaded). -device_foreground() -> erlang:nif_error(not_loaded). -device_os_version() -> erlang:nif_error(not_loaded). -device_model() -> erlang:nif_error(not_loaded). -ui_tree() -> erlang:nif_error(not_loaded). -ui_view_tree() -> erlang:nif_error(not_loaded). -ui_debug() -> erlang:nif_error(not_loaded). -screen_info() -> erlang:nif_error(not_loaded). -tap(_Label) -> erlang:nif_error(not_loaded). -ax_action(_Match, _Action) -> erlang:nif_error(not_loaded). -ax_action_at_xy(_X, _Y, _Action) -> erlang:nif_error(not_loaded). -tap_xy(_X, _Y) -> erlang:nif_error(not_loaded). -type_text(_Text) -> erlang:nif_error(not_loaded). -delete_backward() -> erlang:nif_error(not_loaded). -key_press(_Key) -> erlang:nif_error(not_loaded). -clear_text() -> erlang:nif_error(not_loaded). -long_press_xy(_X, _Y, _Ms) -> erlang:nif_error(not_loaded). -swipe_xy(_X1, _Y1, _X2, _Y2) -> erlang:nif_error(not_loaded). -storage_dir(_Location) -> erlang:nif_error(not_loaded). -storage_save_to_photo_library(_Path) -> erlang:nif_error(not_loaded). -storage_save_to_media_store(_Path, _Type) -> erlang:nif_error(not_loaded). -storage_external_files_dir(_Type) -> erlang:nif_error(not_loaded). -alert_show(_Title, _Message, _ButtonsJson) -> erlang:nif_error(not_loaded). -action_sheet_show(_Title, _ButtonsJson) -> erlang:nif_error(not_loaded). -toast_show(_Message, _Duration) -> erlang:nif_error(not_loaded). -webview_eval_js(_Code) -> erlang:nif_error(not_loaded). -webview_post_message(_Json) -> erlang:nif_error(not_loaded). -webview_can_go_back() -> erlang:nif_error(not_loaded). -webview_go_back() -> erlang:nif_error(not_loaded). -register_component(_Pid) -> erlang:nif_error(not_loaded). -deregister_component(_Handle) -> erlang:nif_error(not_loaded). +motion_stop() -> erlang:nif_error(not_loaded). +take_launch_notification() -> erlang:nif_error(not_loaded). +take_opened_document() -> erlang:nif_error(not_loaded). +battery_level() -> erlang:nif_error(not_loaded). +device_set_dispatcher(_Pid) -> erlang:nif_error(not_loaded). +device_battery_state() -> erlang:nif_error(not_loaded). +device_thermal_state() -> erlang:nif_error(not_loaded). +device_network_state() -> erlang:nif_error(not_loaded). +device_low_power_mode() -> erlang:nif_error(not_loaded). +device_foreground() -> erlang:nif_error(not_loaded). +device_os_version() -> erlang:nif_error(not_loaded). +device_model() -> erlang:nif_error(not_loaded). +device_orientation() -> erlang:nif_error(not_loaded). +device_lock_orientation(_Orientation) -> erlang:nif_error(not_loaded). +device_keep_awake(_On) -> erlang:nif_error(not_loaded). +ui_tree() -> erlang:nif_error(not_loaded). +ui_view_tree() -> erlang:nif_error(not_loaded). +ui_debug() -> erlang:nif_error(not_loaded). +screen_info() -> erlang:nif_error(not_loaded). +tap(_Label) -> erlang:nif_error(not_loaded). +ax_action(_Match, _Action) -> erlang:nif_error(not_loaded). +ax_action_at_xy(_X, _Y, _Action) -> erlang:nif_error(not_loaded). +tap_xy(_X, _Y) -> erlang:nif_error(not_loaded). +type_text(_Text) -> erlang:nif_error(not_loaded). +delete_backward() -> erlang:nif_error(not_loaded). +key_press(_Key) -> erlang:nif_error(not_loaded). +clear_text() -> erlang:nif_error(not_loaded). +long_press_xy(_X, _Y, _Ms) -> erlang:nif_error(not_loaded). +swipe_xy(_X1, _Y1, _X2, _Y2) -> erlang:nif_error(not_loaded). +%% In-process visual capture + scroll control (see Mob.Test). +%% screenshot(Format, Quality, Scale) -> Binary (PNG/JPEG bytes) | {error, Reason} +%% Format :: png | jpeg, Quality :: 0..100 (jpeg), Scale :: float +%% scroll_info(Id) -> #{offset, content, viewport, max_offset, kind} | {error, Reason} +%% scroll_to(Id, X, Y) -> ok | {error, Reason} +screenshot(_Format, _Quality, _Scale) -> erlang:nif_error(not_loaded). +scroll_info(_Id) -> erlang:nif_error(not_loaded). +scroll_to(_Id, _X, _Y) -> erlang:nif_error(not_loaded). +%% element_frames() -> JSON binary {"id":[x,y,w,h],...} of on-screen frames for +%% every rendered node that carries an :id (logical points iOS / dp Android). +%% Lets an agent locate + drive elements by id without a screenshot. +element_frames() -> erlang:nif_error(not_loaded). +storage_dir(_Location) -> erlang:nif_error(not_loaded). +storage_save_to_photo_library(_Path) -> erlang:nif_error(not_loaded). +storage_save_to_media_store(_Path, _Type) -> erlang:nif_error(not_loaded). +storage_external_files_dir(_Type) -> erlang:nif_error(not_loaded). +alert_show(_Title, _Message, _ButtonsJson) -> erlang:nif_error(not_loaded). +action_sheet_show(_Title, _ButtonsJson) -> erlang:nif_error(not_loaded). +toast_show(_Message, _Duration) -> erlang:nif_error(not_loaded). +webview_eval_js(_Code) -> erlang:nif_error(not_loaded). +webview_post_message(_Json) -> erlang:nif_error(not_loaded). +webview_can_go_back() -> erlang:nif_error(not_loaded). +webview_go_back() -> erlang:nif_error(not_loaded). +register_component(_Pid) -> erlang:nif_error(not_loaded). +deregister_component(_Handle) -> erlang:nif_error(not_loaded). +%% Peripheral.VendorUsb +vendor_usb_list_devices(_FilterJson) -> erlang:nif_error(not_loaded). +vendor_usb_request_permission(_Ref) -> erlang:nif_error(not_loaded). +vendor_usb_open(_OptsJson) -> erlang:nif_error(not_loaded). +vendor_usb_bulk_write(_Session, _Bytes, _TimeoutMs) -> erlang:nif_error(not_loaded). +vendor_usb_start_reading(_Session, _ChunkBytes) -> erlang:nif_error(not_loaded). +vendor_usb_stop_reading(_Session) -> erlang:nif_error(not_loaded). +vendor_usb_close(_Session) -> erlang:nif_error(not_loaded). +%% Bluetooth Classic +bt_list_paired() -> erlang:nif_error(not_loaded). +bt_start_discovery() -> erlang:nif_error(not_loaded). +bt_cancel_discovery() -> erlang:nif_error(not_loaded). +bt_pair(_DeviceAndPinJson) -> erlang:nif_error(not_loaded). +bt_unpair(_DeviceJson) -> erlang:nif_error(not_loaded). +bt_disconnect(_Session) -> erlang:nif_error(not_loaded). +bt_hfp_connect(_DeviceJson) -> erlang:nif_error(not_loaded). +bt_hfp_subscribe_vendor_at(_Session, _CompanyIdsJson) -> erlang:nif_error(not_loaded). +bt_hfp_send_vendor_at(_Session, _Cmd, _Args) -> erlang:nif_error(not_loaded). +bt_hfp_start_sco(_Session) -> erlang:nif_error(not_loaded). +bt_hfp_stop_sco(_Session) -> erlang:nif_error(not_loaded). +bt_hfp_send_audio(_Session, _Pcm) -> erlang:nif_error(not_loaded). +bt_spp_connect(_DeviceJson) -> erlang:nif_error(not_loaded). +bt_spp_write(_Session, _Bytes) -> erlang:nif_error(not_loaded). +bt_hid_connect(_DeviceJson) -> erlang:nif_error(not_loaded). +bt_hid_subscribe_raw(_Session) -> erlang:nif_error(not_loaded). +resolve_ipv4(_Host) -> erlang:nif_error(not_loaded). diff --git a/test/mob/app_test.exs b/test/mob/app_test.exs new file mode 100644 index 00000000..3d28ac90 --- /dev/null +++ b/test/mob/app_test.exs @@ -0,0 +1,19 @@ +defmodule Mob.AppTest do + use ExUnit.Case, async: false + + describe "configure_ios_inet_db/0" do + test "is a no-op on host BEAM where the NIF isn't loaded" do + lookup_before = :inet_db.res_option(:lookup) + + assert :ok = Mob.App.configure_ios_inet_db() + + assert :inet_db.res_option(:lookup) == lookup_before + end + + test "idempotent — repeated calls don't crash or stack state" do + assert :ok = Mob.App.configure_ios_inet_db() + assert :ok = Mob.App.configure_ios_inet_db() + assert :ok = Mob.App.configure_ios_inet_db() + end + end +end diff --git a/test/mob/audio_test.exs b/test/mob/audio_test.exs index c2798158..96b8545a 100644 --- a/test/mob/audio_test.exs +++ b/test/mob/audio_test.exs @@ -71,4 +71,111 @@ defmodule Mob.AudioTest do assert_raise FunctionClauseError, fn -> Audio.set_volume(socket, nil) end end end + + describe "play_at_opts/1" do + test "default volume is 1.0" do + assert Audio.play_at_opts([]) == %{"volume" => 1.0} + end + + test "volume is passed through as float" do + assert Audio.play_at_opts(volume: 0.5) == %{"volume" => 0.5} + end + + test "integer volume is coerced to float" do + opts = Audio.play_at_opts(volume: 1) + assert opts["volume"] === 1.0 + end + + test "does NOT include a loop key — play_at is single-shot" do + # Scheduled playback is one-shot by design. Looping a sample-aligned + # buffer requires re-scheduling each iteration on the audio hardware + # clock, which is not what we want for orchestra cues. + refute Map.has_key?(Audio.play_at_opts([]), "loop") + end + + test "keys are strings, not atoms" do + opts = Audio.play_at_opts([]) + assert Map.has_key?(opts, "volume") + refute Map.has_key?(opts, :volume) + end + end + + describe "play_at/4 guard" do + test "rejects a non-integer at_wall_ms" do + socket = Mob.Socket.new(MyScreen) + + assert_raise FunctionClauseError, fn -> + Audio.play_at(socket, "/tmp/x.wav", 1.5) + end + end + + test "rejects a string at_wall_ms" do + socket = Mob.Socket.new(MyScreen) + + assert_raise FunctionClauseError, fn -> + Audio.play_at(socket, "/tmp/x.wav", "now") + end + end + + test "rejects nil at_wall_ms" do + socket = Mob.Socket.new(MyScreen) + + assert_raise FunctionClauseError, fn -> + Audio.play_at(socket, "/tmp/x.wav", nil) + end + end + end + + describe "decode_status/1" do + test "maps the native 4-tuple to a status map" do + assert Audio.decode_status({0.8, 0.0, 1.0, 0.0}) == + %{volume: 0.8, muted: false, route: :speaker, other_audio: false} + end + + test "muted and other_audio are booleans from the 0/1 flags" do + status = Audio.decode_status({0.0, 1.0, 0.0, 1.0}) + assert status.muted == true + assert status.other_audio == true + assert status.route == :none + end + + test "route codes decode to atoms (float codes from the NIF too)" do + assert Audio.decode_status({0.5, 0.0, 2.0, 0.0}).route == :headphones + assert Audio.decode_status({0.5, 0.0, 3.0, 0.0}).route == :bluetooth + assert Audio.decode_status({0.5, 0.0, 4.0, 0.0}).route == :receiver + end + + test "an unknown route code is :unknown, not a crash" do + assert Audio.decode_status({0.5, 0.0, 99.0, 0.0}).route == :unknown + end + + test "a non-tuple (e.g. NIF not loaded) yields a safe default" do + assert Audio.decode_status(:error) == + %{volume: 0.0, muted: false, route: :unknown, other_audio: false} + end + end + + describe "decode_level/1" do + test "passes through {rms, peak} when there is signal" do + assert Audio.decode_level({-18.0, -6.0}) == {-18.0, -6.0} + end + + test "a peak at or below -120 dB reads as :silent" do + assert Audio.decode_level({-160.0, -160.0}) == :silent + assert Audio.decode_level({-130.0, -120.0}) == :silent + end + + test "an atom result becomes {:error, atom}" do + # output probes (mob#54): :not_playing / :needs_record_audio; + # input metering (mob#67): :not_metering; shared: :unsupported_on_platform + assert Audio.decode_level(:needs_record_audio) == {:error, :needs_record_audio} + assert Audio.decode_level(:not_playing) == {:error, :not_playing} + assert Audio.decode_level(:not_metering) == {:error, :not_metering} + assert Audio.decode_level(:unsupported_on_platform) == {:error, :unsupported_on_platform} + end + + test "an unexpected shape becomes {:error, :unknown}" do + assert Audio.decode_level(42) == {:error, :unknown} + end + end end diff --git a/test/mob/background_test.exs b/test/mob/background_test.exs deleted file mode 100644 index 48a3cc9b..00000000 --- a/test/mob/background_test.exs +++ /dev/null @@ -1,134 +0,0 @@ -defmodule Mob.BackgroundTest do - use ExUnit.Case, async: true - - # ── Unit tests — no device required ────────────────────────────────────────── - # - # These verify the public API contract: function existence, arity, and return - # type. They do not exercise the NIF (which requires a running iOS app). - - describe "module API" do - setup do - Code.ensure_loaded(Mob.Background) - :ok - end - - # The "raises when called outside iOS" tests below invoke the actual - # functions, which both proves they exist and exercises behaviour. Bare - # `function_exported?/3` checks were removed — they only confirm the type - # system; the call-and-rescue tests give the same coverage with real - # behaviour assertions. - - test "keep_alive/0 raises when called outside iOS (delegates to :mob_nif)" do - # In the test environment mob_nif.so is absent so the module either fails - # to load (UndefinedFunctionError) or the on_load stub fires (ErlangError). - # Either way it must raise — there is no pure-Elixir fallback. - raised = - try do - Mob.Background.keep_alive() - false - rescue - ErlangError -> true - UndefinedFunctionError -> true - end - - assert raised, "expected keep_alive/0 to raise outside iOS" - end - - test "stop/0 raises when called outside iOS (delegates to :mob_nif)" do - raised = - try do - Mob.Background.stop() - false - rescue - ErlangError -> true - UndefinedFunctionError -> true - end - - assert raised, "expected stop/0 to raise outside iOS" - end - end - - # ── On-device integration tests ─────────────────────────────────────────────── - # - # These run against a real iOS device connected via Erlang distribution. - # They are excluded from the default `mix test` run; execute explicitly with: - # - # mix test --only on_device - # - # Prerequisites: - # 1. An iOS device running the mob app (mix mob.connect) - # 2. The device node reachable (e.g. mob_provision_ios@10.0.0.x) - # 3. UIBackgroundModes: [audio] declared in the app's Info.plist - # - # Set the node via the MOB_TEST_NODE environment variable: - # - # MOB_TEST_NODE=mob_provision_ios@10.0.0.120 mix test --only on_device - - @ios_node System.get_env("MOB_TEST_NODE") && - System.get_env("MOB_TEST_NODE") |> String.to_atom() - - defp rpc(fun), do: :rpc.call(@ios_node, :mob_nif, fun, [], 5000) - - defp node_reachable? do - case :rpc.call(@ios_node, :erlang, :node, [], 2000) do - {:badrpc, _} -> false - _ -> true - end - end - - @tag :on_device - test "keep_alive/0 returns :ok" do - assert rpc(:background_keep_alive) == :ok - after - rpc(:background_stop) - end - - @tag :on_device - test "keep_alive/0 is idempotent — calling twice does not crash" do - assert rpc(:background_keep_alive) == :ok - assert rpc(:background_keep_alive) == :ok - after - rpc(:background_stop) - end - - @tag :on_device - test "stop/0 returns :ok" do - rpc(:background_keep_alive) - assert rpc(:background_stop) == :ok - end - - @tag :on_device - test "stop/0 without a prior keep_alive does not crash" do - assert rpc(:background_stop) == :ok - end - - @tag :on_device - test "keep_alive → stop → keep_alive cycle works" do - assert rpc(:background_keep_alive) == :ok - assert rpc(:background_stop) == :ok - assert rpc(:background_keep_alive) == :ok - after - rpc(:background_stop) - end - - # This is the test that validates the whole feature. After keep_alive is active - # and the screen is locked, the node must remain reachable via distribution. - # We use idevicediagnostics to lock the screen programmatically (requires USB). - @tag :on_device - @tag :screen_lock - test "node remains reachable for 10 s with screen locked" do - assert rpc(:background_keep_alive) == :ok - - IO.puts("\n [background_test] Locking screen via idevicediagnostics...") - {_, rc} = System.cmd("idevicediagnostics", ["sleep"], stderr_to_stdout: true) - assert rc == 0, "idevicediagnostics sleep failed — is a USB device connected?" - - :timer.sleep(10_000) - - assert node_reachable?(), - "Node #{@ios_node} became unreachable within 10 s of screen lock. " <> - "Is UIBackgroundModes: [audio] in Info.plist?" - after - rpc(:background_stop) - end -end diff --git a/test/mob/canvas_test.exs b/test/mob/canvas_test.exs index e2c539b4..0530ccfc 100644 --- a/test/mob/canvas_test.exs +++ b/test/mob/canvas_test.exs @@ -245,4 +245,40 @@ defmodule Mob.CanvasTest do assert via_helper == via_literal end end + + describe "coordinate system contract (pinned, for the renderer)" do + # These tests don't render anything — the actual viewport scaling + # happens in the host app's MobBridge.kt / MobBridge.swift. The + # tests pin the *contract* the renderer must honor, so that + # contract is captured in code and a future renderer rewrite has + # something concrete to test against. + + test "coordinates are canvas-local logical units, not pixels" do + # A draw op at (width/2, height/2) must land in the center of + # the canvas regardless of the canvas's actual rendered pixel + # size. The wire format carries the logical numbers; the host + # renderer applies (size.pixels / declared_logical_units) per + # axis. See Mob.Canvas @moduledoc "Coordinate system" section. + op = Canvas.circle(320, 240, 10, color: :primary, fill: true) + assert op.x == 320 + assert op.y == 240 + # No density / scale information is encoded into the wire — the + # renderer derives it from the actual composable size at draw + # time. + refute Map.has_key?(op, :density) + refute Map.has_key?(op, :scale) + end + + test "scalar sizes (stroke width, radius, text size) are logical units too" do + # The renderer must scale these by (sx + sy) / 2 so they don't + # squash when the viewport is non-square. The wire carries the + # raw numbers; no per-axis hint. + stroke = Canvas.line(0, 0, 100, 100, color: :primary, width: 4) + assert stroke.width == 4 + refute Map.has_key?(stroke, :width_px) + + circ = Canvas.circle(50, 50, 12, color: :primary) + assert circ.r == 12 + end + end end diff --git a/test/mob/certs_test.exs b/test/mob/certs_test.exs new file mode 100644 index 00000000..bedc0809 --- /dev/null +++ b/test/mob/certs_test.exs @@ -0,0 +1,146 @@ +defmodule Mob.CertsTest do + use ExUnit.Case, async: false + + # `:public_key`'s cacert store is global to the BEAM; tests can't be + # async since they mutate it. + # + # `:public_key.cacerts_clear/0` returns the in-memory cache to empty, + # but `:public_key.cacerts_get/0` will then re-load from the OS trust + # store on the next call. On macOS that means ~150 system certs come + # back — the test host is never in a "no certs at all" state. We + # therefore can't assert `loaded?/0 == false` before loading. Instead, + # the happy-path tests prove the wrapper added *our* cert by looking + # for ISRG Root X1's subject in the resulting list. + + alias Mob.Certs + + # ISRG Root X1 (Let's Encrypt). Public root cert, expires 2035, embedded + # here so the test suite doesn't need a fixture file or a network fetch. + @test_pem """ + -----BEGIN CERTIFICATE----- + MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw + TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh + cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 + WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu + ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY + MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc + h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ + 0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U + A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW + T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH + B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC + B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv + KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn + OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn + jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw + qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI + rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV + HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq + hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL + ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ + 3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK + NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 + ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur + TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC + jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc + oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq + 4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA + mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d + emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= + -----END CERTIFICATE----- + """ + + # Distinctive substring in ISRG Root X1's subject DN (UTF-8 bytes for + # "ISRG Root X1"). Used to verify the test cert actually landed in + # `:public_key.cacerts_get/0`. + @isrg_subject_marker "ISRG Root X1" + + setup do + # Elixir 1.19+ strips unused OTP apps from the code path. mob now lists + # :public_key in extra_applications so users get it transitively, but + # the test runtime needs an explicit ensure_all_started before tests + # can call :public_key.* directly. + {:ok, _} = Application.ensure_all_started(:public_key) + + path = + Path.join(System.tmp_dir!(), "mob_certs_test_#{System.unique_integer([:positive])}.pem") + + pem = + @test_pem |> String.split("\n", trim: true) |> Enum.map(&String.trim/1) |> Enum.join("\n") + + File.write!(path, pem <> "\n") + + on_exit(fn -> _ = File.rm(path) end) + + {:ok, pem_path: path} + end + + describe "load_cacerts/1" do + test "returns :ok and adds the cert to :public_key's store", %{pem_path: path} do + assert :ok = Certs.load_cacerts(path) + assert isrg_in_store?() + end + + test "is idempotent across repeated loads", %{pem_path: path} do + assert :ok = Certs.load_cacerts(path) + first_count = isrg_count() + + assert :ok = Certs.load_cacerts(path) + # Re-loading the same PEM doesn't duplicate the same cert. + assert isrg_count() == first_count + end + + test "returns {:error, reason} for a non-existent path" do + assert {:error, _} = Certs.load_cacerts("/does/not/exist.pem") + end + + test "returns {:error, reason} for a path that isn't a PEM" do + not_pem = Path.join(System.tmp_dir!(), "mob_certs_not_a_pem.txt") + File.write!(not_pem, "this is not a certificate\n") + on_exit(fn -> File.rm(not_pem) end) + + assert {:error, _} = Certs.load_cacerts(not_pem) + end + end + + describe "load_cacerts!/1" do + test "returns :ok on success", %{pem_path: path} do + assert :ok = Certs.load_cacerts!(path) + assert isrg_in_store?() + end + + test "raises on a non-existent path" do + assert_raise RuntimeError, ~r/Mob.Certs.load_cacerts!\/1 failed/, fn -> + Certs.load_cacerts!("/does/not/exist.pem") + end + end + end + + describe "loaded?/0" do + test "true after a successful load", %{pem_path: path} do + :ok = Certs.load_cacerts(path) + assert Certs.loaded?() + end + end + + # The host (Mac/Linux) usually has OS certs that auto-load, so we don't + # try to assert `loaded?/0 == false` here — that's an Android-specific + # behavior covered by integration testing on-device. What we can verify + # is that *our* cert ends up in the store. + defp isrg_in_store? do + isrg_count() > 0 + end + + # `:public_key.cacerts_get/0` returns `[{:cert, DerBin, OtpCert} | ...]`. + # Look for the marker in the DER blob — ASN.1 encodes the cert's subject + # CN as PrintableString or UTF8String, so the literal "ISRG Root X1" + # appears as plain ASCII bytes embedded in the DER. + defp isrg_count do + :public_key.cacerts_get() + |> Enum.count(fn {:cert, der, _otp_cert} -> + :binary.match(der, @isrg_subject_marker) != :nomatch + end) + rescue + _ -> 0 + end +end diff --git a/test/mob/composite_test.exs b/test/mob/composite_test.exs new file mode 100644 index 00000000..a2f85588 --- /dev/null +++ b/test/mob/composite_test.exs @@ -0,0 +1,170 @@ +defmodule Mob.CompositeTest do + use ExUnit.Case, async: false + + # ── Fixture kit ───────────────────────────────────────────────────────────── + + defmodule Kit do + @moduledoc false + + # A card: title + wrapped children (proves children pass-through). + def card(props, children, _ctx) do + %{ + type: :column, + props: %{padding: Map.get(props, :padding, 8)}, + children: [ + %{type: :text, props: %{text: Map.fetch!(props, :title)}, children: []} + | children + ] + } + end + + # A combobox: TextField + rows (proves auto-injected event targets reach + # built-in event props untouched in shape). + def combobox(props, _children, _ctx) do + %{ + type: :column, + props: %{}, + children: [ + %{ + type: :text_field, + props: %{value: Map.get(props, :query, ""), on_change: props[:on_change]}, + children: [] + } + | for opt <- Map.get(props, :options, []) do + %{type: :button, props: %{text: opt, on_tap: props[:on_select]}, children: []} + end + ] + } + end + + # A composite BUILT FROM another composite (fixpoint). + def fancy_card(props, children, _ctx) do + %{ + type: :demo_card, + props: Map.put(props, :title, "Fancy: " <> props[:title]), + children: children + } + end + + # Endless self-recursion (depth guard). + def forever(props, children, _ctx) do + %{type: :forever, props: props, children: children} + end + + def boom(_props, _children, _ctx), do: raise("kit bug") + end + + setup do + Mob.Composite.reset() + on_exit(fn -> Mob.Composite.reset() end) + :ok + end + + test "an unregistered tree passes through untouched" do + tree = %{ + type: :column, + props: %{}, + children: [%{type: :text, props: %{text: "x"}, children: []}] + } + + assert Mob.Composite.expand(tree, self()) == tree + end + + test "a registered composite expands to its widget tree, children preserved" do + :ok = Mob.Composite.register(:demo_card, {Kit, :card}) + + tree = %{ + type: :demo_card, + props: %{title: "Hello"}, + children: [%{type: :text, props: %{text: "inner"}, children: []}] + } + + expanded = Mob.Composite.expand(tree, self()) + assert expanded.type == :column + assert [%{props: %{text: "Hello"}}, %{props: %{text: "inner"}}] = expanded.children + end + + test "composites nest to a fixpoint (a composite emitting a composite)" do + :ok = Mob.Composite.register(:demo_card, {Kit, :card}) + :ok = Mob.Composite.register(:fancy_card, {Kit, :fancy_card}) + + tree = %{type: :fancy_card, props: %{title: "T"}, children: []} + expanded = Mob.Composite.expand(tree, self()) + assert expanded.type == :column + assert [%{props: %{text: "Fancy: T"}}] = expanded.children + end + + test "on_* props written as strings/atoms arrive as {screen_pid, tag}" do + :ok = Mob.Composite.register(:demo_combobox, {Kit, :combobox}) + + tree = %{ + type: :demo_combobox, + props: %{query: "ap", options: ["apple"], on_change: "q_changed", on_select: :picked}, + children: [] + } + + me = self() + expanded = Mob.Composite.expand(tree, me) + [field, button] = expanded.children + assert field.props.on_change == {me, :q_changed} + assert button.props.on_tap == {me, :picked} + end + + test "already-shaped {pid, tag} event props pass through untouched" do + :ok = Mob.Composite.register(:demo_combobox, {Kit, :combobox}) + target = {self(), :explicit} + + tree = %{type: :demo_combobox, props: %{options: [], on_change: target}, children: []} + expanded = Mob.Composite.expand(tree, self()) + [field] = expanded.children + assert field.props.on_change == target + end + + test "composites inside ordinary children expand too" do + :ok = Mob.Composite.register(:demo_card, {Kit, :card}) + + tree = %{ + type: :scroll, + props: %{}, + children: [%{type: :demo_card, props: %{title: "deep"}, children: []}] + } + + assert %{children: [%{type: :column}]} = Mob.Composite.expand(tree, self()) + end + + test "the depth guard stops circular composites and logs" do + :ok = Mob.Composite.register(:forever, {Kit, :forever}) + + log = + ExUnit.CaptureLog.capture_log(fn -> + expanded = Mob.Composite.expand(%{type: :forever, props: %{}, children: []}, self()) + assert expanded == %{type: :column, props: %{}, children: []} + end) + + assert log =~ "depth guard" + end + + test "a crashing expander logs and renders an empty node, not a screen crash" do + :ok = Mob.Composite.register(:bad, {Kit, :boom}) + + log = + ExUnit.CaptureLog.capture_log(fn -> + expanded = Mob.Composite.expand(%{type: :bad, props: %{}, children: []}, self()) + assert expanded.type == :column + end) + + assert log =~ "crashed" + end + + test "manifest-declared composites register at boot (Mob.Plugins.register_composites)" do + Mob.Plugins.install(%{composites: [%{atom: :demo_card, expand: {Kit, :card}, plugin: :kit}]}) + assert :ok = Mob.Plugins.register_composites() + assert Mob.Composite.expanders()[:demo_card] == {Kit, :card} + end + + test "malformed manifest composite entries are skipped without raising" do + Mob.Plugins.install(%{composites: [%{atom: "not_an_atom", expand: :nope}, :garbage]}) + assert :ok = Mob.Plugins.register_composites() + assert Mob.Composite.expanders() == %{} + end +end diff --git a/test/mob/data_dir_test.exs b/test/mob/data_dir_test.exs new file mode 100644 index 00000000..c3c9c03f --- /dev/null +++ b/test/mob/data_dir_test.exs @@ -0,0 +1,39 @@ +defmodule Mob.DataDirTest do + # async: false — mutates the shared MOB_DATA_DIR environment variable. + use ExUnit.Case, async: false + + setup do + prev = System.get_env("MOB_DATA_DIR") + on_exit(fn -> restore("MOB_DATA_DIR", prev) end) + :ok + end + + defp restore(var, nil), do: System.delete_env(var) + defp restore(var, val), do: System.put_env(var, val) + + test "data_dir/0 returns MOB_DATA_DIR and creates it" do + base = Path.join(System.tmp_dir!(), "mob_data_dir_test_#{System.unique_integer([:positive])}") + File.rm_rf!(base) + System.put_env("MOB_DATA_DIR", base) + + assert Mob.data_dir() == base + assert File.dir?(base) + after + :ok + end + + test "data_dir/0 falls back to $HOME when MOB_DATA_DIR is unset" do + System.delete_env("MOB_DATA_DIR") + assert Mob.data_dir() == System.get_env("HOME") + end + + test "data_dir/1 returns and creates a subdirectory" do + base = Path.join(System.tmp_dir!(), "mob_data_dir_test_#{System.unique_integer([:positive])}") + File.rm_rf!(base) + System.put_env("MOB_DATA_DIR", base) + + sub = Mob.data_dir("audio_cache") + assert sub == Path.join(base, "audio_cache") + assert File.dir?(sub) + end +end diff --git a/test/mob/device_test.exs b/test/mob/device_test.exs index 039ce991..5d290acc 100644 --- a/test/mob/device_test.exs +++ b/test/mob/device_test.exs @@ -14,10 +14,7 @@ defmodule Mob.DeviceTest do start_supervised!({Mob.Device.Android, []}) {:ok, pid} = - case GenServer.start_link(Device, [], name: :"device_#{System.unique_integer([:positive])}") do - {:ok, p} -> {:ok, p} - other -> other - end + GenServer.start_link(Device, [], name: :"device_#{System.unique_integer([:positive])}") on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) @@ -57,6 +54,7 @@ defmodule Mob.DeviceTest do test "maps display events" do assert Device.category_for(:screen_off) == :display assert Device.category_for(:screen_on) == :display + assert Device.category_for(:orientation_changed) == :display end test "maps audio events" do @@ -77,11 +75,57 @@ defmodule Mob.DeviceTest do assert Device.category_for(:color_scheme_changed) == :appearance end + test "maps :connectivity_changed to :network" do + assert Device.category_for(:connectivity_changed) == :network + end + + test ":network is a valid category and included in subscribe(:all)" do + assert :network in Device.categories() + end + test "unknown events fall through to :unknown" do assert Device.category_for(:no_such_event) == :unknown end end + describe "orientation lock" do + test "valid_lock?/1 accepts the five lock values" do + for o <- [:portrait, :portrait_upside_down, :landscape, :landscape_left, :landscape_right] do + assert Device.valid_lock?(o), "expected #{o} to be a valid lock" + end + end + + test "valid_lock?/1 rejects anything else" do + refute Device.valid_lock?(:unspecified) + refute Device.valid_lock?(:sideways) + refute Device.valid_lock?(nil) + end + + test "lock_orientation/1 rejects an invalid value before touching the NIF" do + # Invalid values short-circuit to {:error, :invalid} (the guard fails) + # rather than raising nif_error, so this is safe to assert on the host. + assert Device.lock_orientation(:sideways) == {:error, :invalid} + assert Device.lock_orientation("landscape") == {:error, :invalid} + end + + test "keep_awake/1 rejects a non-boolean before touching the NIF" do + # The is_boolean/1 guard fails on the host before the NIF is reached, + # so this is safe to assert without a loaded NIF. + assert_raise FunctionClauseError, fn -> Device.keep_awake(:yes) end + assert_raise FunctionClauseError, fn -> Device.keep_awake(1) end + end + end + + describe "open_settings/1" do + test "rejects an unknown target before touching the NIF" do + # Unknown targets short-circuit to {:error, :invalid} (no clause matches the + # guard), so this is safe to assert on the host without a loaded NIF. + assert Device.open_settings(:bogus) == {:error, :invalid} + assert Device.open_settings("app") == {:error, :invalid} + assert Device.open_settings(nil) == {:error, :invalid} + end + end + describe "subscription fan-out" do test "subscriber receives events for its categories", %{dispatcher: d} do :ok = GenServer.call(d, {:subscribe, self(), [:app]}) @@ -132,6 +176,26 @@ defmodule Mob.DeviceTest do refute_receive {:mob_device, :color_scheme_changed, :dark}, 50 end + test ":network subscriber receives :connectivity_changed with the state map", + %{dispatcher: d} do + :ok = GenServer.call(d, {:subscribe, self(), [:network]}) + + state = %{online: true, transport: :wifi, expensive: false} + send(d, {:mob_device, :connectivity_changed, state}) + assert_receive {:mob_device, :connectivity_changed, ^state}, 100 + + offline = %{online: false, transport: :none, expensive: false} + send(d, {:mob_device, :connectivity_changed, offline}) + assert_receive {:mob_device, :connectivity_changed, ^offline}, 100 + end + + test ":connectivity_changed is filtered out for subscribers in unrelated categories", + %{dispatcher: d} do + :ok = GenServer.call(d, {:subscribe, self(), [:thermal]}) + send(d, {:mob_device, :connectivity_changed, %{online: true, transport: :wifi}}) + refute_receive {:mob_device, :connectivity_changed, _}, 50 + end + test "multiple subscribers all receive matching events", %{dispatcher: d} do task1 = Task.async(fn -> @@ -249,7 +313,15 @@ defmodule Mob.DeviceTest do # credo's VacuousTest heuristic doesn't see `apply(Device, @fun, [])` as a # call into application code, but it is — through indirection. - for fun <- [:battery_level, :battery_state, :thermal_state, :os_version, :model] do + for fun <- [ + :battery_level, + :battery_state, + :thermal_state, + :network_state, + :online?, + :os_version, + :model + ] do @fun fun # credo:disable-for-next-line Jump.CredoChecks.VacuousTest test "#{fun}/0 raises when NIF not loaded" do diff --git a/test/mob/dns_test.exs b/test/mob/dns_test.exs new file mode 100644 index 00000000..91504bd3 --- /dev/null +++ b/test/mob/dns_test.exs @@ -0,0 +1,212 @@ +defmodule Mob.DNSTest do + use ExUnit.Case, async: false + + # `:inet_db` is process-shared; tests can't be async because they + # mutate the lookup chain + host table. Save and restore. + + alias Mob.DNS + + setup do + original_lookup = :inet_db.res_option(:lookup) + original_ns = :inet_db.res_option(:nameservers) + + on_exit(fn -> + # Restore the lookup order so other tests aren't affected. + :inet_db.set_lookup(original_lookup) + + # Restore nameservers — `configure_pure_beam/1` adds {8.8.8.8, 53} + # and {1.1.1.1, 53} by default, which would leak across tests. + # `set_resolv_conf("")` clears all then `add_ns/1` per restored entry. + for {ip, port} <- :inet_db.res_option(:nameservers) do + :inet_db.del_ns(ip, port) + end + + for {ip, port} <- original_ns do + :inet_db.add_ns(ip, port) + end + + # Best-effort host-table cleanup for the names we used. + for host <- + ~c"a.test a.test.local b.test missing.test bogus.test" + |> List.to_string() + |> String.split() do + :inet_db.del_host(String.to_charlist(host)) + end + end) + + :ok + end + + # ── resolve/1 — host tests work without the NIF loaded ────────────────── + + describe "resolve/1 when the NIF isn't loaded (host / CI)" do + test "returns {:error, :nif_not_loaded} for a binary host" do + assert {:error, :nif_not_loaded} = DNS.resolve("api.example.com") + end + + test "returns {:error, :nif_not_loaded} for a charlist host" do + assert {:error, :nif_not_loaded} = DNS.resolve(~c"api.example.com") + end + + test ":inet_db is NOT polluted when the NIF fails" do + # Important: a failed resolve must not leave a half-seeded entry. + _ = DNS.resolve("a.test") + refute DNS.resolved?("a.test"), "host must not be seeded after NIF failure" + end + end + + # ── resolve/1 — happy path simulated by directly seeding inet_db ──────── + # + # We can't easily intercept the NIF call without a runtime DI seam, but + # we can pin the post-condition: when an IP IS in inet_db (regardless + # of who put it there), `resolved?/1` reports true and BEAM's lookup + # finds it. Combined with the NIF-error tests above, the wrapper logic + # is fully covered modulo the trivial `enif_make_*` mapping in C. + + describe "resolved?/1" do + test "false for a host that's not in inet_db" do + refute DNS.resolved?("never.seeded.test") + end + + test "true after seeding inet_db AND setting :file-first lookup" do + # This is the post-condition `resolve/1` establishes on a real + # device. Replicate it manually here since the NIF doesn't run + # in host tests. + :inet_db.set_lookup([:file, :native]) + :inet_db.add_host({203, 0, 113, 7}, [~c"manual.seeded.test"]) + + assert DNS.resolved?("manual.seeded.test") + end + + test "accepts both binary and charlist forms" do + :inet_db.set_lookup([:file, :native]) + :inet_db.add_host({203, 0, 113, 8}, [~c"both.forms.test"]) + + assert DNS.resolved?("both.forms.test") + assert DNS.resolved?(~c"both.forms.test") + end + + test "false when an entry exists in inet_db but the lookup chain skips :file" do + # Defensive — pin the chain-dependence semantics. If someone + # manually adds a host but the chain doesn't include :file, + # resolved?/1 (and any Req/Finch lookup) correctly reports + # "not findable." resolve/1 sets the chain, so users following + # the documented path won't hit this. + :inet_db.set_lookup([:native]) + :inet_db.add_host({203, 0, 113, 9}, [~c"chain.bypass.test"]) + + refute DNS.resolved?("chain.bypass.test") + end + end + + # ── preresolve/1 ─────────────────────────────────────────────────────── + + describe "preresolve/1" do + test "returns a host → result map covering every input" do + result = DNS.preresolve(["a.test", "b.test"]) + + assert map_size(result) == 2 + assert Map.has_key?(result, "a.test") + assert Map.has_key?(result, "b.test") + end + + test "preserves per-host failures rather than failing the whole batch" do + result = DNS.preresolve(["a.test", "b.test"]) + + # On the host without the NIF every entry is :nif_not_loaded. + for {_host, outcome} <- result do + assert {:error, :nif_not_loaded} = outcome + end + end + + test "empty list → empty map" do + assert DNS.preresolve([]) == %{} + end + end + + # ── Lookup-chain side effects ────────────────────────────────────────── + # + # On host BEAM the default lookup is `[:native]` — adding a host to the + # file table is NOT enough on its own; you also need `:file` in the + # chain. This is exactly the situation `resolve/1` works around by + # pushing `:file` to the front. Pin the contract. + + describe ":inet_db lookup chain" do + test "seeding a host alone is not enough — the chain must include :file" do + # Same seed as the "happy path" tests above, but WITHOUT mutating + # the lookup chain. On a default-config BEAM, `resolved?/1` should + # report false because `:native` doesn't see the file table. + :inet_db.add_host({203, 0, 113, 99}, [~c"chain.test"]) + + # If this ever flips to true on a future OTP, it means the default + # chain changed to include `:file`. Update the comment and + # consider whether `resolve/1` still needs `ensure_file_lookup_first/0`. + refute DNS.resolved?("chain.test") + end + + test "after pushing :file to the front, the seeded host IS findable" do + # This is the post-condition `resolve/1` establishes. Replicating + # it confirms the wrapper's chain-mutation strategy is sound. + :inet_db.add_host({203, 0, 113, 99}, [~c"chain.test"]) + :inet_db.set_lookup([:file | :inet_db.res_option(:lookup)]) + + assert DNS.resolved?("chain.test") + end + end + + # ── configure_pure_beam/1 ────────────────────────────────────────────── + # + # Flips BEAM's lookup chain to `[:file, :dns]` and seeds nameservers so + # `:inet.getaddr/2` resolves via raw DNS queries from inside BEAM + # instead of the iOS-broken `:native` (inet_gethost) path. Pure state + # mutation on `:inet_db`; nothing to mock. + + describe "configure_pure_beam/1" do + test "sets the lookup chain to [:file, :dns]" do + DNS.configure_pure_beam(nameservers: []) + assert :inet_db.res_option(:lookup) == [:file, :dns] + end + + test "seeds Google + Cloudflare DNS by default" do + DNS.configure_pure_beam() + nameservers = :inet_db.res_option(:nameservers) + ips = Enum.map(nameservers, fn {ip, _port} -> ip end) + assert {8, 8, 8, 8} in ips + assert {1, 1, 1, 1} in ips + end + + test "honors a custom :nameservers list" do + DNS.configure_pure_beam(nameservers: [{9, 9, 9, 9}]) + ips = :inet_db.res_option(:nameservers) |> Enum.map(fn {ip, _port} -> ip end) + assert {9, 9, 9, 9} in ips + refute {8, 8, 8, 8} in ips + end + + test ":nameservers: [] sets the lookup chain but skips ns seeding" do + # Snapshot ns count before so we don't false-positive on a leftover + # from another test (setup restores, but order isn't guaranteed). + before = length(:inet_db.res_option(:nameservers)) + DNS.configure_pure_beam(nameservers: []) + assert :inet_db.res_option(:lookup) == [:file, :dns] + assert length(:inet_db.res_option(:nameservers)) == before + end + + test "is idempotent — calling twice doesn't duplicate nameservers" do + DNS.configure_pure_beam(nameservers: [{8, 8, 8, 8}]) + first = length(:inet_db.res_option(:nameservers)) + DNS.configure_pure_beam(nameservers: [{8, 8, 8, 8}]) + second = length(:inet_db.res_option(:nameservers)) + assert first == second + end + + test "preserves manually-seeded :file entries (composes with resolve/1)" do + # The whole point of `:file` being first in the chain — manually- + # resolved hosts (Apple-resolver-backed) still win over the :dns + # fallback, so a user can use configure_pure_beam as a default and + # selectively call resolve/1 for VPN/mDNS hosts. + :inet_db.add_host({203, 0, 113, 50}, [~c"compose.test"]) + DNS.configure_pure_beam(nameservers: []) + assert DNS.resolved?("compose.test") + end + end +end diff --git a/test/mob/files_test.exs b/test/mob/files_test.exs new file mode 100644 index 00000000..0e56fe06 --- /dev/null +++ b/test/mob/files_test.exs @@ -0,0 +1,161 @@ +defmodule Mob.FilesTest do + use ExUnit.Case, async: true + + alias Mob.Files + + # `pick/2` itself calls `:mob_nif.files_pick/1`, which raises + # `nif_error(not_loaded)` on host. The host-testable surface is the pure + # logic it delegates to: `normalize_types/1` (the wire envelope sent to the + # native picker) and `matches?/2` / `accept/2` (result enforcement). Those + # are what these tests pin. + + describe "normalize_types/1 — extension specs" do + test "bare extension string" do + assert Files.normalize_types(["livemd"]) == [%{"kind" => "extension", "value" => "livemd"}] + end + + test "leading dot is stripped" do + assert Files.normalize_types([".livemd"]) == [%{"kind" => "extension", "value" => "livemd"}] + end + + test "explicit {:extension, _} tuple" do + assert Files.normalize_types([{:extension, "csv"}]) == + [%{"kind" => "extension", "value" => "csv"}] + end + end + + describe "normalize_types/1 — mime specs" do + test "a value containing a slash is treated as MIME" do + assert Files.normalize_types(["application/pdf"]) == + [%{"kind" => "mime", "value" => "application/pdf"}] + end + + test "wildcard MIME is preserved" do + assert Files.normalize_types(["text/*"]) == [%{"kind" => "mime", "value" => "text/*"}] + end + + test "explicit {:mime, _} tuple" do + assert Files.normalize_types([{:mime, "image/png"}]) == + [%{"kind" => "mime", "value" => "image/png"}] + end + end + + describe "normalize_types/1 — semantic + uti specs" do + test "semantic atoms" do + assert Files.normalize_types([:images, :pdf]) == + [ + %{"kind" => "semantic", "value" => "images"}, + %{"kind" => "semantic", "value" => "pdf"} + ] + end + + test "uti tuple" do + assert Files.normalize_types([{:uti, "dev.livebook.livemd"}]) == + [%{"kind" => "uti", "value" => "dev.livebook.livemd"}] + end + end + + describe "normalize_types/1 — the :any escape hatch" do + test ":any collapses to an empty (no-filter) envelope" do + assert Files.normalize_types(:any) == [] + assert Files.normalize_types([:any]) == [] + end + + test "legacy \"*/*\" string collapses too" do + assert Files.normalize_types(["*/*"]) == [] + end + + test ":any anywhere in the list clears the whole filter" do + assert Files.normalize_types(["livemd", :any]) == [] + end + + test "a bare spec is wrapped into a list" do + assert Files.normalize_types("livemd") == [%{"kind" => "extension", "value" => "livemd"}] + end + end + + test "the envelope is JSON-encodable (the wire contract with native)" do + envelope = Files.normalize_types(["livemd", "application/pdf", :images]) + decoded = :json.decode(IO.iodata_to_binary(:json.encode(envelope))) + + assert decoded == [ + %{"kind" => "extension", "value" => "livemd"}, + %{"kind" => "mime", "value" => "application/pdf"}, + %{"kind" => "semantic", "value" => "images"} + ] + end + + describe "matches?/2 — extension enforcement" do + test "accepts a matching extension, case-insensitively" do + assert Files.matches?(%{name: "demo.livemd", mime: "text/plain"}, ["livemd"]) + assert Files.matches?(%{name: "DEMO.LIVEMD", mime: "text/plain"}, ["livemd"]) + end + + test "rejects a non-matching extension" do + refute Files.matches?(%{name: "photo.png", mime: "image/png"}, ["livemd"]) + end + + test "works on string-keyed items (decoded from native JSON)" do + assert Files.matches?(%{"name" => "demo.livemd", "mime" => "text/plain"}, ["livemd"]) + end + end + + describe "matches?/2 — mime + semantic enforcement" do + test "exact MIME match" do + assert Files.matches?(%{name: "r.pdf", mime: "application/pdf"}, [ + {:mime, "application/pdf"} + ]) + + refute Files.matches?(%{name: "r.txt", mime: "text/plain"}, [{:mime, "application/pdf"}]) + end + + test "wildcard MIME match" do + assert Files.matches?(%{name: "a.png", mime: "image/png"}, ["image/*"]) + refute Files.matches?(%{name: "a.txt", mime: "text/plain"}, ["image/*"]) + end + + test "semantic group maps to a MIME wildcard" do + assert Files.matches?(%{name: "a.png", mime: "image/png"}, [:images]) + refute Files.matches?(%{name: "a.pdf", mime: "application/pdf"}, [:images]) + end + end + + describe "matches?/2 — degenerate / non-enforceable cases" do + test ":any / empty types accepts everything" do + assert Files.matches?(%{name: "x.bin", mime: "application/octet-stream"}, :any) + assert Files.matches?(%{name: "x.bin", mime: "application/octet-stream"}, []) + end + + test "a uti-only filter is treated as already-enforced by the iOS picker" do + assert Files.matches?( + %{name: "x.bin", mime: "application/octet-stream"}, + [{:uti, "dev.livebook.livemd"}] + ) + end + + test "matches if ANY spec matches" do + item = %{name: "demo.livemd", mime: "text/plain"} + assert Files.matches?(item, ["csv", "livemd"]) + end + end + + describe "accept/2" do + test "keeps only matching items" do + items = [ + %{name: "a.livemd", mime: "text/plain"}, + %{name: "b.png", mime: "image/png"}, + %{name: "c.livemd", mime: "text/plain"} + ] + + assert Files.accept(items, ["livemd"]) == [ + %{name: "a.livemd", mime: "text/plain"}, + %{name: "c.livemd", mime: "text/plain"} + ] + end + + test "with :any keeps everything" do + items = [%{name: "a.png", mime: "image/png"}] + assert Files.accept(items, :any) == items + end + end +end diff --git a/test/mob/motion_accel_test.exs b/test/mob/motion_accel_test.exs new file mode 100644 index 00000000..01c43536 --- /dev/null +++ b/test/mob/motion_accel_test.exs @@ -0,0 +1,47 @@ +defmodule Mob.MotionAccelTest do + use ExUnit.Case, async: true + + # The accel normalization lives inside the CoreMotion callback in ios/mob_nif.m: + # ObjC reading live hardware, so no host test can exercise it — and the iOS + # Simulator delivers no CMMotionManager data, so it isn't integration-testable off + # a physical device either. But the CONVENTION it must honor is a hard contract: + # `Mob.Motion` promises `accel` in m/s² with Android's sign — specific force, i.e. + # +g on the up-axis at rest. CoreMotion natively reports G (~1.0) with the opposite + # gravity sign (its gravity vector points down), so the iOS NIF MUST subtract + # gravity AND scale by g. A silent revert to `+ gravity` (iOS's own convention) or + # a dropped scale factor makes every tilt/shake UI wrong on iOS — the exact 0.7.x + # regression this guards. Same source-level strategy as Mob.NifDeclarationTest: + # pin a native invariant that runtime tests can't reach. + @objc Path.expand("../../ios/mob_nif.m", __DIR__) + @g "9.80665" + + describe "iOS accel convention (ios/mob_nif.m source guard)" do + setup do + %{lines: @objc |> File.read!() |> String.split("\n")} + end + + for axis <- ~w(x y z) do + test "a#{axis} = (userAcceleration.#{axis} - gravity.#{axis}) * g", %{lines: lines} do + axis = unquote(axis) + + line = Enum.find(lines, &(&1 =~ ~r/\bdouble a#{axis}\s*=/)) + assert line, "no `double a#{axis} = ...` line in ios/mob_nif.m" + + assert line =~ "userAcceleration.#{axis}", + "a#{axis} must derive from userAcceleration.#{axis}: #{line}" + + # SUBTRACT gravity, never add it. `+ motion.gravity` is iOS's own + # total-acceleration convention — sign-flipped from Android — and is the bug. + assert line =~ ~r/-\s*motion\.gravity\.#{axis}/, + "a#{axis} must SUBTRACT gravity (Android specific-force sign), not add: #{line}" + + refute line =~ ~r/\+\s*motion\.gravity\.#{axis}/, + "a#{axis} must not ADD gravity (that reintroduces the inverted-sign bug): #{line}" + + # Convert CoreMotion's G to the documented m/s². + assert line =~ @g, + "a#{axis} must scale G to m/s² (× #{@g}): #{line}" + end + end + end +end diff --git a/test/mob/motion_test.exs b/test/mob/motion_test.exs new file mode 100644 index 00000000..a9d07665 --- /dev/null +++ b/test/mob/motion_test.exs @@ -0,0 +1,29 @@ +defmodule Mob.MotionTest do + use ExUnit.Case, async: true + + # start/2 itself calls into the NIF (unavailable on the host), so we test its + # pure kernel, parse_opts/1 — where the sensor list + interval are resolved. + describe "parse_opts/1" do + test "defaults to accelerometer + gyro at 100ms" do + assert Mob.Motion.parse_opts([]) == {["accelerometer", "gyro"], 100} + end + + test "adds magnetometer when requested (the compass path)" do + assert Mob.Motion.parse_opts(sensors: [:accelerometer, :gyro, :magnetometer]) == + {["accelerometer", "gyro", "magnetometer"], 100} + end + + test "honors a magnetometer-only request" do + assert Mob.Motion.parse_opts(sensors: [:magnetometer]) == {["magnetometer"], 100} + end + + test "honors a custom interval while keeping default sensors" do + assert Mob.Motion.parse_opts(interval_ms: 150) == {["accelerometer", "gyro"], 150} + end + + test "preserves requested sensor order as strings" do + assert Mob.Motion.parse_opts(sensors: [:gyro, :magnetometer, :accelerometer]) == + {["gyro", "magnetometer", "accelerometer"], 100} + end + end +end diff --git a/test/mob/nav/registry_test.exs b/test/mob/nav/registry_test.exs index 284e8687..cd4644d2 100644 --- a/test/mob/nav/registry_test.exs +++ b/test/mob/nav/registry_test.exs @@ -94,4 +94,33 @@ defmodule Mob.Nav.RegistryTest do assert {:ok, ProfileScreen} = Mob.Nav.Registry.lookup(:home) end end + + describe "register/3 + lookup_route/1 (route-bound params)" do + test "params registered with the route come back via lookup_route" do + {:ok, pid} = Mob.Nav.Registry.start_link(SimpleApp) + on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) + + :ok = Mob.Nav.Registry.register(:"/ash/post/list", ProfileScreen, %{resource: Post}) + + assert Mob.Nav.Registry.lookup_route(:"/ash/post/list") == + {:ok, ProfileScreen, %{resource: Post}} + + # lookup/1 stays params-blind for existing callers + assert Mob.Nav.Registry.lookup(:"/ash/post/list") == {:ok, ProfileScreen} + end + + test "register/2 entries resolve with empty route params" do + {:ok, pid} = Mob.Nav.Registry.start_link(SimpleApp) + on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) + + :ok = Mob.Nav.Registry.register(:detail, ProfileScreen) + assert Mob.Nav.Registry.lookup_route(:detail) == {:ok, ProfileScreen, %{}} + end + + test "app-navigation seeded routes resolve with empty route params" do + {:ok, pid} = Mob.Nav.Registry.start_link(SimpleApp) + on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) + assert {:ok, _module, %{}} = Mob.Nav.Registry.lookup_route(:home) + end + end end diff --git a/test/mob/nav/screen_nav_test.exs b/test/mob/nav/screen_nav_test.exs index 822b2025..5ea302f8 100644 --- a/test/mob/nav/screen_nav_test.exs +++ b/test/mob/nav/screen_nav_test.exs @@ -21,6 +21,12 @@ defmodule Mob.Nav.ScreenNavTest do def handle_event("reset_to_profile", _, socket), do: {:noreply, Mob.Socket.reset_to(socket, @profile)} + + def handle_event("go_bound", _, socket), + do: {:noreply, Mob.Socket.push_screen(socket, :bound_settings)} + + def handle_event("go_bound_override", _, socket), + do: {:noreply, Mob.Socket.push_screen(socket, :bound_settings, %{from: :push_wins})} end defmodule ProfileScreen do @@ -129,6 +135,25 @@ defmodule Mob.Nav.ScreenNavTest do end end + describe "route-bound params (Registry.register/3 — the data-driven-plugin pattern)" do + test "a bare push to a params route delivers the route-bound params to mount" do + :ok = Mob.Nav.Registry.register(:bound_settings, SettingsScreen, %{from: :route_bound}) + {:ok, pid} = Mob.Screen.start_link(HomeScreen, %{}) + Mob.Screen.dispatch(pid, "go_bound", %{}) + assert Mob.Screen.get_current_module(pid) == SettingsScreen + assert Mob.Screen.get_socket(pid).assigns.from == :route_bound + GenServer.stop(pid) + end + + test "explicit push params override route-bound params on key conflict" do + :ok = Mob.Nav.Registry.register(:bound_settings, SettingsScreen, %{from: :route_bound}) + {:ok, pid} = Mob.Screen.start_link(HomeScreen, %{}) + Mob.Screen.dispatch(pid, "go_bound_override", %{}) + assert Mob.Screen.get_socket(pid).assigns.from == :push_wins + GenServer.stop(pid) + end + end + # ── pop_screen ───────────────────────────────────────────────────────────── describe "pop_screen/1" do diff --git a/test/mob/nif_declaration_test.exs b/test/mob/nif_declaration_test.exs new file mode 100644 index 00000000..e451d70a --- /dev/null +++ b/test/mob/nif_declaration_test.exs @@ -0,0 +1,58 @@ +defmodule Mob.NifDeclarationTest do + # `load_nif/2` fails — and purges mob_nif, crashing every app at boot — if the + # native NIF tables register a function that the module's `-nifs([])` attribute + # doesn't declare. This can't be caught by a host test (NIFs never load on the + # host), so guard it at the source: every {name, arity} in the iOS and Android + # native tables MUST appear in `-nifs([])` in src/mob_nif.erl. Regression for + # 0.7.6, where device_orientation/0 + device_lock_orientation/1 were added to + # the native tables and -export but not -nifs, so on_load failed everywhere. + use ExUnit.Case, async: true + + @root Path.expand("../..", __DIR__) + @erl Path.join(@root, "src/mob_nif.erl") + @zig Path.join(@root, "android/jni/mob_nif.zig") + @objc Path.join(@root, "ios/mob_nif.m") + + defp declared_nifs do + src = File.read!(@erl) + # The block between `-nifs([` and the closing `]).` + [_, block] = String.split(src, "-nifs([", parts: 2) + [block, _] = String.split(block, "]).", parts: 2) + + Regex.scan(~r/^\s*([a-z_0-9]+)\/(\d+)/m, block) + |> Enum.map(fn [_, name, arity] -> {name, String.to_integer(arity)} end) + |> MapSet.new() + end + + defp native_nifs(:android) do + Regex.scan(~r/\.name = "([a-z_0-9]+)", \.arity = (\d+)/, File.read!(@zig)) + |> Enum.map(fn [_, name, arity] -> {name, String.to_integer(arity)} end) + |> MapSet.new() + end + + defp native_nifs(:ios) do + Regex.scan(~r/\{"([a-z_0-9]+)",\s*(\d+),\s*nif_/, File.read!(@objc)) + |> Enum.map(fn [_, name, arity] -> {name, String.to_integer(arity)} end) + |> MapSet.new() + end + + test "-nifs([]) is non-empty (parser sanity)" do + assert MapSet.size(declared_nifs()) > 50 + end + + for platform <- [:android, :ios] do + test "every #{platform} native NIF is declared in -nifs([])" do + declared = declared_nifs() + native = native_nifs(unquote(platform)) + + assert MapSet.size(native) > 50, "parsed too few #{unquote(platform)} NIFs — parser broke" + + undeclared = MapSet.difference(native, declared) + + assert MapSet.equal?(undeclared, MapSet.new()), + "#{unquote(platform)} registers NIFs missing from -nifs([]) in src/mob_nif.erl " <> + "(load_nif will fail → mob_nif purged → boot crash): " <> + Enum.map_join(undeclared, ", ", fn {n, a} -> "#{n}/#{a}" end) + end + end +end diff --git a/test/mob/nif_stub_test.exs b/test/mob/nif_stub_test.exs new file mode 100644 index 00000000..9599d61d --- /dev/null +++ b/test/mob/nif_stub_test.exs @@ -0,0 +1,125 @@ +defmodule Mob.NifStubTest do + use ExUnit.Case, async: true + + # Pins the contract between `-nifs([...])`, `-export([...])`, and the + # function clauses in `src/mob_nif.erl`. This caught a real bug: + # `resolve_ipv4/1` was added to `-nifs` and defined as a stub, but + # forgotten in `-export`. The Erlang compiler accepts that quietly + # (just emits a "function unused" warning); the failure mode surfaces + # only on-device when `erlang:load_nif/2` rejects the NIF library + # with `{bad_lib, "Function not found mob_nif:<name>/<arity>"}`, + # the module is purged, and every call to mob_nif becomes `:undef`. + # + # The test parses the .erl source rather than reading the .beam + # because OTP doesn't expose the `-nifs` declaration in the standard + # attributes chunk. + + @source Path.expand("../../src/mob_nif.erl", __DIR__) + + setup_all do + src = File.read!(@source) + {:ok, exports: parse_block(src, "-export"), nifs: parse_block(src, "-nifs")} + end + + test "the -nifs declaration is non-empty (sanity)", %{nifs: nifs} do + assert length(nifs) > 0 + end + + test "every -nifs entry has a matching -export entry", %{ + exports: exports, + nifs: nifs + } do + # The Erlang compiler doesn't enforce this. Forgetting an export + # for a name in -nifs is silently accepted at build time but blows + # up at runtime as `bad_lib: Function not found mob_nif:<name>/<n>` + # on the device — and because that's an on_load failure, the + # module is purged and ALL mob_nif calls become :undef. The first + # symptom you see is `mob_nif:log/1` failing during BEAM boot. + missing = nifs -- exports + + assert missing == [], + "Names in -nifs but not in -export — the iOS-device NIF load will " <> + "fail with `Function not found` and the module will be purged.\n" <> + "Missing: #{inspect(missing)}" + end + + test "every -nifs entry has a stub clause that raises nif_error", %{ + nifs: nifs + } do + # Each NIF must have a fallback definition `<name>(_, _, ...) -> + # erlang:nif_error(not_loaded).`. Without it, callers on host / + # in tests / before the NIF loads hit `:undef` instead of the + # documented `:not_loaded` atom. + src = File.read!(@source) + + missing = + Enum.filter(nifs, fn {name, arity} -> + # Match `<name>(args) -> erlang:nif_error(not_loaded).` — args + # are typically `_Foo, _Bar` matching the arity. + pattern = + ~r/^#{Regex.escape(Atom.to_string(name))}\([^)]*\)\s*->\s*erlang:nif_error\(not_loaded\)\.$/m + + not (Regex.match?(pattern, src) and + arity_matches?(src, name, arity)) + end) + + assert missing == [], + "Names in -nifs without a stub clause raising nif_error(not_loaded):\n" <> + inspect(missing) + end + + # ── helpers ────────────────────────────────────────────────────────────── + + # Parse the contents of `-<keyword>([ name/arity, ... ]).` into a + # list of `{:name, arity}` tuples. Lines may be wrapped, the inner + # list may have comments (skipped) and a trailing comma is allowed. + defp parse_block(src, keyword) do + case Regex.run(~r/#{Regex.escape(keyword)}\(\[(.*?)\]\)\./s, src) do + [_, inner] -> + inner + |> String.split("\n") + |> Enum.map(&strip_comment/1) + |> Enum.join(" ") + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.map(&parse_name_arity/1) + |> Enum.reject(&is_nil/1) + + _ -> + [] + end + end + + defp strip_comment(line) do + case String.split(line, "%", parts: 2) do + [code, _comment] -> code + [code] -> code + end + end + + defp parse_name_arity(entry) do + case Regex.run(~r/^([a-z][a-z0-9_]*)\/(\d+)$/, entry) do + [_, name, arity] -> {String.to_atom(name), String.to_integer(arity)} + _ -> nil + end + end + + # The regex in the stub-clause test only checks that *some* clause + # for `name` exists. This narrows to "name with an arity-matching + # arg list." + defp arity_matches?(src, name, arity) do + name_str = Atom.to_string(name) + # Count commas + 1 = arity (or 0 args = no commas, name() form). + pattern = + if arity == 0 do + ~r/^#{Regex.escape(name_str)}\(\)\s*->\s*erlang:nif_error\(not_loaded\)\.$/m + else + # Match `name(_A1, _A2, ...)` with exactly `arity` underscore-prefixed args. + args = Enum.map(1..arity, fn _ -> "_[A-Za-z0-9_]*" end) |> Enum.join(",\\s*") + ~r/^#{Regex.escape(name_str)}\(#{args}\)\s*->\s*erlang:nif_error\(not_loaded\)\.$/m + end + + Regex.match?(pattern, src) + end +end diff --git a/test/mob/permissions_test.exs b/test/mob/permissions_test.exs new file mode 100644 index 00000000..9546e5c8 --- /dev/null +++ b/test/mob/permissions_test.exs @@ -0,0 +1,21 @@ +defmodule Mob.PermissionsTest do + use ExUnit.Case, async: true + + # request/2 is a thin wrapper over the request_permission NIF (not loaded on + # the host), so the testable kernel is the arity/guard: it accepts any atom + # capability (so plugin-registered caps pass through to the native registry) + # and rejects non-atoms before reaching the NIF. + + test "accepts any atom capability (delegates validity to the native layer)" do + # Atom args clear the guard and reach the NIF stub, which errors with + # not-loaded on the host — proving the guard let the call through rather + # than rejecting the capability in Elixir. + for cap <- [:camera, :location, :some_plugin_cap] do + assert_raise UndefinedFunctionError, fn -> Mob.Permissions.request(%{}, cap) end + end + end + + test "rejects a non-atom capability via the guard" do + assert_raise FunctionClauseError, fn -> Mob.Permissions.request(%{}, "camera") end + end +end diff --git a/test/mob/plugins_test.exs b/test/mob/plugins_test.exs new file mode 100644 index 00000000..6dbe765e --- /dev/null +++ b/test/mob/plugins_test.exs @@ -0,0 +1,236 @@ +defmodule Mob.PluginsTest do + use ExUnit.Case, async: false + + @sample %{ + screens: [%{plugin: :p, module: P.Home, default_route: "/p"}], + lifecycle: [%{plugin: :p, on_start: {P, :start, []}}], + settings: [%{plugin: :p, schema: [%{key: :x, type: :boolean, default: true}]}], + notification_handlers: [%{plugin: :p, match: %{type: "t"}, handler: {P, :h, 1}}], + nifs: [:p_nif], + composites: [], + styles: [%{name: :mob_theme_x, theme: ThemeX}], + default_style: nil + } + + describe "read_path/1" do + test "reads + evaluates a manifest .exs file" do + path = write_manifest(inspect(@sample)) + assert Mob.Plugins.read_path(path) == @sample + end + + test "returns the empty set when the file is absent" do + assert Mob.Plugins.read_path("/no/such/mob_plugins.exs") == + %{ + screens: [], + lifecycle: [], + settings: [], + notification_handlers: [], + nifs: [], + composites: [], + styles: [], + default_style: nil + } + end + + test "returns the empty set (never crashes) on a malformed file" do + path = write_manifest("%{screens: [,,]}") + assert Mob.Plugins.read_path(path).screens == [] + end + + test "fills in missing sections from the empty set" do + path = write_manifest(inspect(%{screens: [%{plugin: :p, module: P, default_route: "/p"}]})) + manifest = Mob.Plugins.read_path(path) + assert manifest.lifecycle == [] + assert manifest.notification_handlers == [] + end + end + + describe "install/1 + accessors" do + test "caches a manifest and exposes each section" do + Mob.Plugins.install(@sample) + + assert Mob.Plugins.screens() == @sample.screens + assert Mob.Plugins.lifecycle() == @sample.lifecycle + assert Mob.Plugins.settings() == @sample.settings + assert Mob.Plugins.notification_handlers() == @sample.notification_handlers + assert Mob.Plugins.nifs() == @sample.nifs + end + + test "merges partial manifests against the empty set" do + Mob.Plugins.install(%{screens: [%{plugin: :q, module: Q, default_route: "/q"}]}) + assert [%{plugin: :q}] = Mob.Plugins.screens() + assert Mob.Plugins.notification_handlers() == [] + assert Mob.Plugins.nifs() == [] + end + end + + describe "ensure_nif_modules_loaded/0" do + test "calls Code.ensure_loaded on each declared NIF module" do + # A loadable module (already on disk) + a bogus one mirroring a host build + # where a plugin NIF's native lib isn't linked (load tolerated, not fatal). + Mob.Plugins.install(%{nifs: [Mob.Socket, :no_such_nif_module_zzz]}) + + results = Mob.Plugins.ensure_nif_modules_loaded() + + assert {Mob.Socket, {:module, Mob.Socket}} in results + assert {:no_such_nif_module_zzz, {:error, :nofile}} in results + end + + test "is a no-op when no plugin declares a NIF" do + Mob.Plugins.install(%{}) + assert Mob.Plugins.nifs() == [] + assert Mob.Plugins.ensure_nif_modules_loaded() == [] + end + end + + describe "register_screens/0" do + setup do + # Nav.Registry seeds from an App module's navigation/1; a bare stub is enough. + start_supervised!({Mob.Nav.Registry, __MODULE__.StubApp}) + :ok + end + + defmodule StubApp do + def navigation(_), do: %{type: :stack, name: :root, root: Root} + end + + test "registers each plugin screen under its route, resolvable via the registry" do + Mob.Plugins.install(%{ + screens: [ + %{plugin: :kv, module: Kv.ListScreen, default_route: "/kv/list"}, + %{plugin: :kv, module: Kv.DetailScreen, default_route: "/kv/detail"} + ] + }) + + assert :ok = Mob.Plugins.register_screens() + assert Mob.Nav.Registry.lookup(:"/kv/list") == {:ok, Kv.ListScreen} + assert Mob.Nav.Registry.lookup(:"/kv/detail") == {:ok, Kv.DetailScreen} + end + + test "boot/1 with nil host app is a no-op" do + assert :ok = Mob.Plugins.boot(nil) + end + + test "skips a screen entry whose module is nil (would resolve to nil at nav)" do + Mob.Plugins.install(%{screens: [%{plugin: :p, module: nil, default_route: "/p"}]}) + assert :ok = Mob.Plugins.register_screens() + assert Mob.Nav.Registry.lookup(:"/p") == {:error, :not_found} + end + + test "an entry's :params map becomes route-bound params on the registered route" do + Mob.Plugins.install(%{ + screens: [ + %{plugin: :ash, module: P.Shared, default_route: "/ash/a", params: %{resource: A}}, + %{plugin: :ash, module: P.Shared, default_route: "/ash/b", params: %{resource: B}} + ] + }) + + assert :ok = Mob.Plugins.register_screens() + assert Mob.Nav.Registry.lookup_route(:"/ash/a") == {:ok, P.Shared, %{resource: A}} + assert Mob.Nav.Registry.lookup_route(:"/ash/b") == {:ok, P.Shared, %{resource: B}} + # lookup/1 stays params-blind (back-compat) + assert Mob.Nav.Registry.lookup(:"/ash/a") == {:ok, P.Shared} + end + + test "a malformed :params (non-map) registers with empty route params" do + Mob.Plugins.install(%{ + screens: [%{plugin: :p, module: P.Home, default_route: "/p2", params: :oops}] + }) + + assert :ok = Mob.Plugins.register_screens() + assert Mob.Nav.Registry.lookup_route(:"/p2") == {:ok, P.Home, %{}} + end + + test "skips a screen entry with a nil or empty default_route" do + Mob.Plugins.install(%{ + screens: [ + %{plugin: :p, module: P.Home, default_route: nil}, + %{plugin: :q, module: Q.Home, default_route: ""} + ] + }) + + assert :ok = Mob.Plugins.register_screens() + end + end + + defmodule FixtureTheme do + @moduledoc false + def theme, do: Mob.Theme.build(primary: :lime_400, background: 0xFF111209) + end + + describe "apply_default_style/0" do + test "applies the default style's theme module at boot" do + Mob.Plugins.install(%{ + styles: [%{name: :fixture_style, theme: FixtureTheme}], + default_style: :fixture_style + }) + + before = Mob.Theme.current() + on_exit(fn -> Mob.Theme.set(before) end) + + assert :ok = Mob.Plugins.apply_default_style() + assert Mob.Theme.current() == FixtureTheme.theme() + end + + test "no default style is a no-op" do + Mob.Plugins.install(%{styles: [], default_style: nil}) + assert :ok = Mob.Plugins.apply_default_style() + end + + test "a broken theme module logs instead of failing boot" do + Mob.Plugins.install(%{ + styles: [%{name: :bad, theme: NoSuch.Theme}], + default_style: :bad + }) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert :ok = Mob.Plugins.apply_default_style() + end) + + assert log =~ "failed to apply" + end + end + + describe "resolve_image/1" do + setup do + on_exit(fn -> Mob.Plugins.install_asset_root("") end) + end + + test "maps a plugin:// reference to an absolute path under the cached asset root" do + Mob.Plugins.install_asset_root("/bundle/priv/generated/plugin_assets") + + assert Mob.Plugins.resolve_image("plugin://kv/icon.png") == + {:ok, "/bundle/priv/generated/plugin_assets/assets/plugin/kv/icon.png"} + end + + test "errors (never a relative path) when the asset root has not been cached" do + log = + ExUnit.CaptureLog.capture_log(fn -> + assert Mob.Plugins.resolve_image("plugin://kv/icon.png") == :error + end) + + assert log =~ "asset root" + end + + test "passes through a non-plugin URL" do + assert Mob.Plugins.resolve_image("https://x/y.png") == :passthrough + assert Mob.Plugins.resolve_image("local.png") == :passthrough + end + + test "errors on a malformed plugin:// reference" do + Mob.Plugins.install_asset_root("/bundle") + assert Mob.Plugins.resolve_image("plugin://kv") == :error + assert Mob.Plugins.resolve_image("plugin:///icon.png") == :error + end + end + + defp write_manifest(contents) do + dir = Path.join(System.tmp_dir!(), "mob_plugins_test_#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + path = Path.join(dir, "mob_plugins.exs") + File.write!(path, contents) + on_exit(fn -> File.rm_rf!(dir) end) + path + end +end diff --git a/test/mob/plugins_tier4_test.exs b/test/mob/plugins_tier4_test.exs new file mode 100644 index 00000000..9ca34fa0 --- /dev/null +++ b/test/mob/plugins_tier4_test.exs @@ -0,0 +1,250 @@ +defmodule Mob.PluginsTier4Test do + use ExUnit.Case, async: false + + # Named hook functions (apply/3 needs MFAs, not closures). Each forwards to the + # test process registered under :tier4_test so a test can assert it ran. + defmodule Hooks do + def notify(payload), do: send_test({:notified, payload}) + def matches_chat?(payload), do: Map.get(payload, :kind) == "chat" + def resumed, do: send_test(:resumed) + def backgrounded, do: send_test(:backgrounded) + def started, do: send_test(:on_start) && :ok + def started_error, do: {:error, :boom} + def crash(_payload), do: raise("boom in handler") + def crash_pred(_payload), do: raise("boom in predicate") + def send_test(msg), do: send(Process.whereis(:tier4_test), msg) + end + + defmodule Worker do + use GenServer + def start_link(_), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + def init(:ok), do: {:ok, :ok} + end + + setup do + Process.register(self(), :tier4_test) + on_exit(fn -> Mob.Plugins.install(%{}) end) + :ok + end + + describe "settings" do + setup do + tmp = Path.join(System.tmp_dir!(), "mob_set_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + System.put_env("MOB_DATA_DIR", tmp) + start_supervised!(Mob.State) + on_exit(fn -> File.rm_rf!(tmp) end) + + Mob.Plugins.install(%{ + settings: [ + %{ + plugin: :chat, + schema: [ + %{key: :sound, type: :boolean, default: true}, + %{key: :channel, type: :string, default: "#general"} + ], + editor_screen: Chat.SettingsScreen + } + ] + }) + end + + test "get_setting falls back to the schema default, then reads written values" do + assert Mob.Plugins.get_setting(:chat, :sound) == true + assert :ok = Mob.Plugins.put_setting(:chat, :sound, false) + assert Mob.Plugins.get_setting(:chat, :sound) == false + end + + test "put_setting validates the value type" do + assert {:error, {:invalid_type, :boolean}} = Mob.Plugins.put_setting(:chat, :sound, "nope") + assert {:error, :unknown_setting} = Mob.Plugins.put_setting(:chat, :missing, 1) + end + + test "get_setting returns nil for an unknown plugin/key" do + assert Mob.Plugins.get_setting(:nope, :x) == nil + end + + test "settings_editor returns the editor screen module" do + assert Mob.Plugins.settings_editor(:chat) == {:ok, Chat.SettingsScreen} + assert Mob.Plugins.settings_editor(:nope) == :error + end + + test "a schema entry missing :default does not crash get_setting" do + Mob.Plugins.install(%{settings: [%{plugin: :p, schema: [%{key: :x, type: :boolean}]}]}) + assert Mob.Plugins.get_setting(:p, :x) == nil + end + + test "a schema entry missing :type does not crash put_setting" do + Mob.Plugins.install(%{settings: [%{plugin: :p, schema: [%{key: :x, default: true}]}]}) + assert {:error, :unknown_setting} = Mob.Plugins.put_setting(:p, :x, false) + end + + test "a non-list schema does not crash setting reads/writes" do + Mob.Plugins.install(%{settings: [%{plugin: :p, schema: %{key: :x}}]}) + assert Mob.Plugins.get_setting(:p, :x) == nil + assert {:error, :unknown_setting} = Mob.Plugins.put_setting(:p, :x, 1) + end + end + + describe "dispatch_notification/1" do + setup do + Mob.Plugins.install(%{ + notification_handlers: [ + %{plugin: :chat, match: %{type: "msg"}, handler: {Hooks, :notify, 1}}, + %{plugin: :chat, match: {Hooks, :matches_chat?, 1}, handler: {Hooks, :notify, 1}} + ] + }) + end + + test "first matching handler (map prefix) wins and is invoked with the payload" do + assert :handled = Mob.Plugins.dispatch_notification(%{type: "msg", body: "hi"}) + assert_received {:notified, %{type: "msg", body: "hi"}} + end + + test "a predicate match also routes" do + assert :handled = Mob.Plugins.dispatch_notification(%{kind: "chat"}) + assert_received {:notified, %{kind: "chat"}} + end + + test "no match is unhandled" do + assert :unhandled = Mob.Plugins.dispatch_notification(%{type: "other"}) + refute_received {:notified, _} + end + + test "a handler that raises is isolated (logs, does not propagate)" do + Mob.Plugins.install(%{ + notification_handlers: [ + %{plugin: :boom, match: %{type: "x"}, handler: {Hooks, :crash, 1}} + ] + }) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert :handled = Mob.Plugins.dispatch_notification(%{type: "x"}) + end) + + assert log =~ "notification handler crashed" + end + + test "a matching entry with a malformed handler is skipped; a later handler still receives it" do + Mob.Plugins.install(%{ + notification_handlers: [ + %{plugin: :bad, match: %{type: "x"}, handler: :not_an_mfa}, + %{plugin: :good, match: %{type: "x"}, handler: {Hooks, :notify, 1}} + ] + }) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert :handled = Mob.Plugins.dispatch_notification(%{type: "x"}) + end) + + assert log =~ "malformed" + assert_received {:notified, %{type: "x"}} + end + + test "a matching entry missing :handler is skipped without raising" do + Mob.Plugins.install(%{ + notification_handlers: [%{plugin: :bad, match: %{type: "x"}}] + }) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert :unhandled = Mob.Plugins.dispatch_notification(%{type: "x"}) + end) + + assert log =~ "malformed" + end + + test "a non-map handler entry is skipped without raising" do + Mob.Plugins.install(%{ + notification_handlers: [ + :garbage, + %{plugin: :good, match: %{type: "x"}, handler: {Hooks, :notify, 1}} + ] + }) + + assert :handled = Mob.Plugins.dispatch_notification(%{type: "x"}) + assert_received {:notified, %{type: "x"}} + end + + test "a predicate that raises is isolated (treated as no-match, does not propagate)" do + Mob.Plugins.install(%{ + notification_handlers: [ + %{plugin: :boom, match: {Hooks, :crash_pred, 1}, handler: {Hooks, :notify, 1}} + ] + }) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert :unhandled = Mob.Plugins.dispatch_notification(%{type: "x"}) + end) + + assert log =~ "notification predicate crashed" + refute_received {:notified, _} + end + end + + describe "Lifecycle dispatcher" do + test "routes did_become_active / did_enter_background to the plugin hooks" do + state = [ + %{ + plugin: :chat, + on_resume: {Hooks, :resumed, []}, + on_background: {Hooks, :backgrounded, []} + } + ] + + {:noreply, ^state} = + Mob.Plugins.Lifecycle.handle_info({:mob_device, :did_become_active}, state) + + assert_received :resumed + + {:noreply, ^state} = + Mob.Plugins.Lifecycle.handle_info({:mob_device, :did_enter_background}, state) + + assert_received :backgrounded + end + + test "a plugin without a hook is skipped; unrelated messages are ignored" do + state = [%{plugin: :chat}] + + assert {:noreply, ^state} = + Mob.Plugins.Lifecycle.handle_info({:mob_device, :did_become_active}, state) + + assert {:noreply, ^state} = Mob.Plugins.Lifecycle.handle_info(:whatever, state) + refute_received :resumed + end + end + + describe "Supervisor" do + setup do + start_supervised!({Mob.Device, []}) + :ok + end + + test "runs on_start, starts supervised children + the lifecycle dispatcher" do + Mob.Plugins.install(%{ + lifecycle: [%{plugin: :chat, on_start: {Hooks, :started, []}, supervised: [Worker]}] + }) + + assert :ok = Mob.Plugins.start_lifecycle() + assert_received :on_start + assert Process.whereis(Worker) + assert Process.whereis(Mob.Plugins.Lifecycle) + end + + test "a failing on_start bubbles up (fails boot loud)" do + Mob.Plugins.install(%{lifecycle: [%{plugin: :bad, on_start: {Hooks, :started_error, []}}]}) + + Process.flag(:trap_exit, true) + assert {:error, _} = Mob.Plugins.Supervisor.start_link([]) + end + + test "start_lifecycle is a no-op when no plugin declares a lifecycle" do + Mob.Plugins.install(%{}) + assert :ok = Mob.Plugins.start_lifecycle() + refute Process.whereis(Mob.Plugins.Lifecycle) + end + end +end diff --git a/test/mob/release_screenshot_test.exs b/test/mob/release_screenshot_test.exs new file mode 100644 index 00000000..a24f4f89 --- /dev/null +++ b/test/mob/release_screenshot_test.exs @@ -0,0 +1,65 @@ +defmodule Mob.ReleaseScreenshotTest do + use ExUnit.Case, async: true + + # Security invariant, pinned at the source (the native harness can't be exercised on + # the host — same rationale as Mob.NifDeclarationTest). The iOS test harness is + # compiled out of release builds (`#if !MOB_RELEASE`) because its synthetic-input NIFs + # (tap/type/…) use PRIVATE UIKit/IOKit selectors the App Store auto-rejects. + # `screenshot/3` uses only public APIs (UIGraphicsImageRenderer + drawViewHierarchy), + # so it is carved into a release-OPT-IN guard (`MOB_ENABLE_SCREENSHOT`) — a host can + # ship it so an agent can SEE the screen to error-correct in a release build. + # + # This test pins the boundary: screenshot MAY be opted into release, but the private + # synthetic-input NIFs must NEVER be — they stay strictly `#if !MOB_RELEASE`. If a + # future edit slipped one into the opt-in guard, a release build could ship + # private-selector code and get the app pulled from the store. + @objc Path.expand("../../ios/mob_nif.m", __DIR__) + + # NIFs whose iOS implementations synthesize input via private selectors/IOHIDEvent. + @private_input ~w(tap tap_xy type_text key_press delete_backward clear_text + long_press_xy swipe_xy ax_action ax_action_at_xy) + + # Map each `{"name", arity, nif_…}` registration entry to its nearest enclosing + # `#if` condition, tracking nesting with a stack so it's robust across the file. + defp registration_guards do + @objc + |> File.read!() + |> String.split("\n") + |> Enum.reduce({%{}, []}, fn line, {acc, stack} -> + cond do + m = Regex.run(~r/^\s*#\s*if\S*\s+(.*)$/, line) -> + {acc, [Enum.at(m, 1) | stack]} + + Regex.match?(~r/^\s*#\s*endif/, line) -> + {acc, Enum.drop(stack, 1)} + + m = Regex.run(~r/^\s*\{"([a-z_0-9]+)",\s*\d+,\s*nif_/, line) -> + {Map.put(acc, Enum.at(m, 1), List.first(stack) || ""), stack} + + true -> + {acc, stack} + end + end) + |> elem(0) + end + + test "screenshot is release-opt-in (guarded by MOB_ENABLE_SCREENSHOT)" do + guards = registration_guards() + assert guards["screenshot"], "screenshot NIF not found in the registration table" + + assert guards["screenshot"] =~ "MOB_ENABLE_SCREENSHOT", + "screenshot must sit behind a MOB_ENABLE_SCREENSHOT opt-in guard; got: #{guards["screenshot"]}" + end + + test "private synthetic-input NIFs stay strictly debug-only, never release-opt-in" do + guards = registration_guards() + + for name <- @private_input, guard = guards[name] do + refute guard =~ "MOB_ENABLE_SCREENSHOT", + "#{name} uses private selectors and must NOT be release-opt-in; guard was: #{guard}" + + assert guard =~ "!MOB_RELEASE", + "#{name} must stay behind `#if !MOB_RELEASE`; guard was: #{guard}" + end + end +end diff --git a/test/mob/renderer_test.exs b/test/mob/renderer_test.exs index 5b0ccf69..fd8d26a4 100644 --- a/test/mob/renderer_test.exs +++ b/test/mob/renderer_test.exs @@ -109,6 +109,38 @@ defmodule Mob.RendererTest do assert Enum.at(decoded["children"], 1)["props"]["text"] == "B" end + test "a plugin:// image src resolves to its bundle path" do + Mob.Plugins.install_asset_root("/bundle/priv/generated/plugin_assets") + on_exit(fn -> Mob.Plugins.install_asset_root("") end) + tree = %{type: :image, props: %{src: "plugin://kv/icon.png"}, children: []} + Renderer.render(tree, :android, MockNIF) + {:set_root, [json]} = Enum.find(MockNIF.calls(), fn {f, _} -> f == :set_root end) + decoded = :json.decode(json) + + assert decoded["props"]["src"] == + "/bundle/priv/generated/plugin_assets/assets/plugin/kv/icon.png" + end + + test "an unresolvable plugin:// image src passes through unchanged (asset root not cached)" do + tree = %{type: :image, props: %{src: "plugin://kv/icon.png"}, children: []} + + ExUnit.CaptureLog.capture_log(fn -> + Renderer.render(tree, :android, MockNIF) + end) + + {:set_root, [json]} = Enum.find(MockNIF.calls(), fn {f, _} -> f == :set_root end) + decoded = :json.decode(json) + assert decoded["props"]["src"] == "plugin://kv/icon.png" + end + + test "a non-plugin image src is left untouched" do + tree = %{type: :image, props: %{src: "https://x/y.png"}, children: []} + Renderer.render(tree, :android, MockNIF) + {:set_root, [json]} = Enum.find(MockNIF.calls(), fn {f, _} -> f == :set_root end) + decoded = :json.decode(json) + assert decoded["props"]["src"] == "https://x/y.png" + end + test "on_tap pid is replaced by integer handle" do pid = self() tree = %{type: :button, props: %{text: "Tap", on_tap: pid}, children: []} @@ -144,6 +176,22 @@ defmodule Mob.RendererTest do assert is_integer(decoded["props"]["on_tap"]) end + test "on_drag {pid, tag} is replaced by integer handle" do + pid = self() + tree = %{type: :canvas, props: %{on_drag: {pid, :draw}}, children: []} + Renderer.render(tree, :ios, MockNIF) + {:set_root, [json]} = Enum.find(MockNIF.calls(), fn {f, _} -> f == :set_root end) + decoded = :json.decode(json) + assert is_integer(decoded["props"]["on_drag"]) + end + + test "register_tap is called for an on_drag handle" do + pid = self() + tree = %{type: :canvas, props: %{on_drag: {pid, :draw}}, children: []} + Renderer.render(tree, :ios, MockNIF) + assert Enum.any?(MockNIF.calls(), fn {f, _} -> f == :register_tap end) + end + test "on_change {pid, tag} is replaced by integer handle" do pid = self() tree = %{type: :text_field, props: %{value: "hi", on_change: {pid, :name}}, children: []} @@ -544,6 +592,22 @@ defmodule Mob.RendererTest do assert decoded["props"]["return_key"] == "next" end + test "secure boolean is passed through unchanged" do + tree = %{type: :text_field, props: %{value: "", secure: true}, children: []} + Renderer.render(tree, :android, MockNIF) + {:set_root, [json]} = Enum.find(MockNIF.calls(), fn {f, _} -> f == :set_root end) + decoded = :json.decode(json) + assert decoded["props"]["secure"] == true + end + + test "secure defaults to absent when unset" do + tree = %{type: :text_field, props: %{value: ""}, children: []} + Renderer.render(tree, :android, MockNIF) + {:set_root, [json]} = Enum.find(MockNIF.calls(), fn {f, _} -> f == :set_root end) + decoded = :json.decode(json) + refute Map.has_key?(decoded["props"], "secure") + end + test "register_tap receives {pid, tag} for tagged taps" do pid = self() tree = %{type: :button, props: %{text: "Tap", on_tap: {pid, :my_action}}, children: []} @@ -866,6 +930,16 @@ defmodule Mob.RendererTest do # color props. These tests pin the wire shape AND the resolution behavior. describe "canvas draw-op encoding" do + setup do + # Two tests in this describe call `Mob.Theme.set(primary: :emerald_500)` + # to verify draw-op token resolution. Without an on_exit reset the + # mutated theme persisted into later tests (e.g. the "style token + # resolution" describe's `assert background == 0xFF2196F3` started + # asserting against whatever color the theme leaked). + on_exit(fn -> Application.delete_env(:mob, :theme) end) + :ok + end + defp canvas_draw(ops) do tree = %{type: :canvas, props: %{width: 100, height: 100, draw: ops}, children: []} MockNIF.reset() @@ -945,4 +1019,86 @@ defmodule Mob.RendererTest do assert op["cap"] == "round" end end + + describe "theme glass flag" do + setup do + on_exit(fn -> Mob.Theme.set(%Mob.Theme{}) end) + :ok + end + + defp box_with_background do + %{ + type: :box, + props: %{background: :surface}, + children: [%{type: :text, props: %{text: "card"}, children: []}] + } + end + + defp set_root_json do + MockNIF.calls() + |> Enum.find_value(fn + {:set_root, [json]} -> :json.decode(json) + _ -> nil + end) + end + + test "Box with a background gets glass: true when theme.glass is on" do + Mob.Theme.set(glass: true) + Renderer.render(box_with_background(), :ios, MockNIF) + + tree = set_root_json() + assert tree["type"] == "box" + assert tree["props"]["glass"] == true + end + + test "Box with no background does NOT get glass: true (nothing to swap)" do + Mob.Theme.set(glass: true) + + Renderer.render( + %{type: :box, props: %{}, children: []}, + :ios, + MockNIF + ) + + tree = set_root_json() + refute Map.has_key?(tree["props"], "glass") + end + + test "non-box surface-style nodes are NOT marked (text, scroll, column)" do + Mob.Theme.set(glass: true) + + Renderer.render( + %{ + type: :column, + props: %{background: :surface}, + children: [%{type: :text, props: %{text: "x", background: :surface}, children: []}] + }, + :ios, + MockNIF + ) + + tree = set_root_json() + refute Map.has_key?(tree["props"], "glass") + refute Map.has_key?(hd(tree["children"])["props"], "glass") + end + + test "default theme (glass: false) emits no glass prop" do + Mob.Theme.set(%Mob.Theme{}) + Renderer.render(box_with_background(), :ios, MockNIF) + + tree = set_root_json() + refute Map.has_key?(tree["props"], "glass") + end + + test "a glass: true theme triggers the flag at the boundary" do + # (MobThemes.ObsidianGlass — now in the mob_themes style package — is + # the shipped example of a glass theme; the renderer contract is the + # flag itself.) + Mob.Theme.set(%Mob.Theme{glass: true}) + Renderer.render(box_with_background(), :ios, MockNIF) + + tree = set_root_json() + assert tree["props"]["glass"] == true + end + end end diff --git a/test/mob/screen_case_test.exs b/test/mob/screen_case_test.exs new file mode 100644 index 00000000..28e89817 --- /dev/null +++ b/test/mob/screen_case_test.exs @@ -0,0 +1,201 @@ +defmodule Mob.ScreenCaseTest do + use Mob.ScreenCase, async: true + + # A realistic fixture: core node types only, an explicit event, and a + # tap-via-message path with a catch-all (the shape real screens use). + defmodule CounterScreen do + use Mob.Screen + + def mount(params, _session, socket) do + {:ok, Mob.Socket.assign(socket, :count, Map.get(params, :start, 0))} + end + + def render(assigns) do + %{ + type: :column, + props: %{}, + children: [ + %{type: :text, props: %{text: "Count: #{assigns.count}"}, children: []}, + %{type: :button, props: %{tag: "increment", label: "Add one"}, children: []} + ] + } + end + + def handle_event("increment", _params, socket) do + {:noreply, Mob.Socket.assign(socket, :count, socket.assigns.count + 1)} + end + + def handle_info({:tap, :inc}, socket) do + {:noreply, Mob.Socket.assign(socket, :count, socket.assigns.count + 1)} + end + + def handle_info(_message, socket), do: {:noreply, socket} + end + + # Renders a node type the native layer has no renderer for. + defmodule BadScreen do + use Mob.Screen + + def mount(_params, _session, socket), do: {:ok, socket} + + def render(_assigns) do + %{type: :column, props: %{}, children: [%{type: :hologram, props: %{}, children: []}]} + end + end + + # Pushes another screen, both from an explicit event and from a tap message. + defmodule NavScreen do + use Mob.Screen + + def mount(_params, _session, socket), do: {:ok, socket} + def render(_assigns), do: %{type: :column, props: %{}, children: []} + + def handle_event("go", _params, socket) do + {:noreply, Mob.Socket.push_screen(socket, CounterScreen)} + end + + def handle_info({:tap, :go}, socket) do + {:noreply, Mob.Socket.push_screen(socket, CounterScreen)} + end + + def handle_info(_message, socket), do: {:noreply, socket} + end + + describe "mount_screen/3 + assigns/1" do + test "mounts with initial assigns" do + assert assigns(mount_screen(CounterScreen)).count == 0 + end + + test "passes params through to mount/3" do + assert assigns(mount_screen(CounterScreen, %{start: 5})).count == 5 + end + end + + describe "render_event/3" do + test "dispatches handle_event and updates state + rendered text" do + view = CounterScreen |> mount_screen() |> render_event("increment") + assert assigns(view).count == 1 + assert text(view) =~ "Count: 1" + end + + test "is chainable" do + view = + CounterScreen |> mount_screen() |> render_event("increment") |> render_event("increment") + + assert assigns(view).count == 2 + end + end + + describe "render_info/2 (the tap path)" do + test "delivers a message that handle_info acts on" do + view = CounterScreen |> mount_screen() |> render_info({:tap, :inc}) + assert assigns(view).count == 1 + end + + test "an unhandled message hits the catch-all and noops" do + view = CounterScreen |> mount_screen() |> render_info(:whatever) + assert assigns(view).count == 0 + end + end + + describe "tree queries" do + setup do + {:ok, view: mount_screen(CounterScreen)} + end + + test "find/3 matches by type and a prop subset", %{view: view} do + assert %{type: :button, props: %{label: "Add one"}} = find(view, :button, tag: "increment") + assert find(view, :button, tag: "nope") == nil + end + + test "find_all/3 returns every match", %{view: view} do + assert length(find_all(view, :text)) == 1 + end + + test "flatten/1 walks the whole tree depth-first", %{view: view} do + assert Enum.map(flatten(view), & &1.type) == [:column, :text, :button] + end + + test "text/1 concatenates :text nodes", %{view: view} do + assert text(view) == "Count: 0" + end + + test "query helpers also accept a raw tree, not just a View", %{view: view} do + raw = tree(view) + assert find(raw, :button, tag: "increment") + assert text(raw) == "Count: 0" + end + end + + describe "assert_renderable/2" do + test "passes for a tree of core node types" do + assert %{type: :column} = assert_renderable(mount_screen(CounterScreen)) + end + + test "flunks on a type with no native renderer" do + view = mount_screen(BadScreen) + + assert_raise ExUnit.AssertionError, ~r/hologram/, fn -> + assert_renderable(view) + end + end + + test ":extra allows a plugin/custom type through" do + assert assert_renderable(mount_screen(BadScreen), extra: [:hologram]) + end + + test "renderable_types includes core tags and the native_view escape hatch" do + types = renderable_types() + assert MapSet.member?(types, :column) + assert MapSet.member?(types, :text) + assert MapSet.member?(types, :native_view) + end + end + + describe "navigated_to/1" do + test "nil before any navigation" do + assert navigated_to(mount_screen(CounterScreen)) == nil + end + + test "records a push from an explicit event as the destination module" do + view = NavScreen |> mount_screen() |> render_event("go") + assert navigated_to(view) == CounterScreen + end + + test "records a push from a tap (handle_info) as the destination module" do + view = NavScreen |> mount_screen() |> render_info({:tap, :go}) + assert navigated_to(view) == CounterScreen + end + end + + # tree/1's :device clause must route to Mob.Test.tree/1 (the logical render + # tree, shape %{type, props, children}) — NOT Mob.Test.view_tree/1, which is + # the native accessibility tree (shape %{type, label, value, frame}) the query + # helpers can't read. The @tag :on_device test below is excluded by default, + # so this regression shipped undetected once; this pins the dispatch with no + # device by exploiting the two functions' divergent behavior against a down + # node: Mob.Test.tree/1 does `rpc(node, :inspect).tree` and so raises + # BadMapError on the `{:badrpc, :nodedown}` it gets back, whereas + # Mob.Test.view_tree/1 returns that tuple without raising. + describe "tree/1 :device dispatch" do + test "routes to Mob.Test.tree/1, not Mob.Test.view_tree/1" do + view = device_view(:"nonexistent_node@127.0.0.1") + assert_raise BadMapError, fn -> tree(view) end + end + end + + # The same assertion helpers, pointed at a live device over Mob.Test instead + # of an in-process socket. Excluded by default (needs hardware + a connected + # node); shown here as the worked example of the device backend. + describe "device backend (@tag :on_device)" do + @tag :on_device + test "the same assertions run against a live device node" do + node = :"mob_screen_case_demo@127.0.0.1" + Mob.Test.navigate(node, CounterScreen) + + view = device_view(node) + assert_renderable(view) + assert navigated_to(view) == CounterScreen + end + end +end diff --git a/test/mob/screen_collocation_test.exs b/test/mob/screen_collocation_test.exs new file mode 100644 index 00000000..a3866f79 --- /dev/null +++ b/test/mob/screen_collocation_test.exs @@ -0,0 +1,37 @@ +defmodule Mob.ScreenCollocationTest do + use ExUnit.Case, async: true + + test "use Mob.Screen compiles a sibling .mob.heex template into render/1" do + dir = + Path.join(System.tmp_dir!(), "mob_screen_collocation_#{System.unique_integer([:positive])}") + + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf!(dir) end) + + module = Module.concat([:"CollocatedScreen#{System.unique_integer([:positive])}"]) + source = Path.join(dir, "collocated_screen.ex") + template = Path.join(dir, "collocated_screen.mob.heex") + + File.write!(template, """ + <Column> + <Text text={assigns.title} /> + </Column> + """) + + File.write!(source, """ + defmodule #{inspect(module)} do + use Mob.Screen + + def mount(_params, _session, socket), do: {:ok, socket} + end + """) + + Code.compile_file(source) + + assert %{ + type: :column, + props: %{}, + children: [%{type: :text, props: %{text: "Hello"}, children: []}] + } = module.render(%{title: "Hello"}) + end +end diff --git a/test/mob/sigil_test.exs b/test/mob/sigil_test.exs index a934225a..4fbff475 100644 --- a/test/mob/sigil_test.exs +++ b/test/mob/sigil_test.exs @@ -38,6 +38,40 @@ defmodule Mob.SigilTest do end end + # ── UTF-8 handling ────────────────────────────────────────────────────────── + + # Regression: `ascii_string([not: ?"])` in the parser double-encoded any + # byte ≥128 (each source byte was treated as a Latin-1 codepoint and + # re-encoded as UTF-8). Switching to `utf8_string/2` matches by codepoint + # and preserves the source bytes verbatim. + describe "UTF-8 in template source" do + test "en-dash literal in string attr is preserved byte-for-byte" do + node = ~MOB(<Text text="Agenda – May 23" />) + assert node.props.text == "Agenda – May 23" + assert byte_size(node.props.text) == 17 + end + + test "em-dash literal in string attr is preserved" do + node = ~MOB(<Text text="Live — coding" />) + assert node.props.text == "Live — coding" + end + + test "middle dot, smart quotes, accents preserved" do + node = ~MOB(<Text text="Track 1 · café · “quoted”" />) + assert node.props.text == "Track 1 · café · “quoted”" + end + + test "emoji preserved" do + node = ~MOB(<Text text="🚀 ship it" />) + assert node.props.text == "🚀 ship it" + end + + test "non-ASCII literal inside {expr} string is preserved" do + node = ~MOB(<Text text={"Agenda – May 23"} />) + assert node.props.text == "Agenda – May 23" + end + end + # ── self-closing: expression attributes ───────────────────────────────────── describe "self-closing with expression attrs" do @@ -199,6 +233,15 @@ defmodule Mob.SigilTest do node = ~MOB(<TextField value="x" />) assert node.type == :text_field end + + test "GpuView resolves to :gpu_view (and is on the iOS whitelist)" do + # If GpuView drops off priv/tags/ios.txt, the sigil emits a + # compile-time warning and the test breaks loudly via the stderr + # capture used elsewhere in this file. For the type atom alone, + # this just checks the snake_case conversion. + node = ~MOB(<GpuView />) + assert node.type == :gpu_view + end end # ── parity with raw maps ───────────────────────────────────────────────────── @@ -228,6 +271,193 @@ defmodule Mob.SigilTest do end end + # ── @assigns sugar ──────────────────────────────────────────────────────────── + + describe "@assign shorthand" do + test "@name in an attr expands to assigns.name" do + assigns = %{name: "Alice"} + node = ~MOB(<Text text={@name} />) + assert node.props.text == "Alice" + end + + test "@user.name (nested access) expands the inner @user" do + assigns = %{user: %{name: "Bob"}} + node = ~MOB(<Text text={@user.name} />) + assert node.props.text == "Bob" + end + + test "@count inside a larger expression expands" do + assigns = %{count: 3} + node = ~MOB(<Text text={"n=#{@count}"} />) + assert node.props.text == "n=3" + end + + test "non-@ expressions are untouched" do + local = "plain" + node = ~MOB(<Text text={local} />) + assert node.props.text == "plain" + end + end + + # ── @assign guard: assigns must be in scope ─────────────────────────────────── + + describe "@assign guard" do + test "@foo with no assigns in scope raises a CompileError naming the fix" do + err = + assert_raise CompileError, fn -> + Code.compile_string(~S[import Mob.Sigil; ~MOB(<Text text={@title} />)]) + end + + msg = Exception.message(err) + assert msg =~ ~s(requires a variable named "assigns") + # The message points at the concrete fix: interpolate the argument. + assert msg =~ "{title}" + end + + test ":if={@flag} with no assigns in scope also raises" do + assert_raise CompileError, ~r/requires a variable named "assigns"/, fn -> + Code.compile_string(~S[import Mob.Sigil; ~MOB(<Text text="x" :if={@flag} />)]) + end + end + + test "a static template (no @) compiles fine without assigns" do + # No @ ⇒ no guard ⇒ no assigns needed. + assert Code.compile_string(~S[import Mob.Sigil; ~MOB(<Text text="hi" />)]) == [] + end + + test "a helper using a positional arg (no @) needs no assigns" do + # The idiomatic composite pattern: interpolate the argument directly. + [{mod, _}] = + Code.compile_string(~S''' + defmodule Mob.SigilTest.GuardPositional do + import Mob.Sigil + def label(title), do: ~MOB(<Text text={title} />) + end + ''') + + assert mod.label("Hi").props.text == "Hi" + :code.purge(mod) + :code.delete(mod) + end + end + + # ── :if control attribute ───────────────────────────────────────────────────── + + describe ":if directive" do + test ":if={true} keeps the node" do + node = ~MOB(<Text text="shown" :if={true} />) + assert node.type == :text + end + + test ":if={false} yields nil at the root" do + node = ~MOB(<Text text="hidden" :if={false} />) + refute node + end + + test ":if={false} child drops out of its parent" do + node = ~MOB""" + <Column> + <Text text="a" :if={true} /> + <Text text="b" :if={false} /> + <Text text="c" /> + </Column> + """ + + assert Enum.map(node.children, & &1.props.text) == ["a", "c"] + end + + test ":if reads @assigns" do + # Enum.member?/2 keeps `show` typed boolean() (not the literal false), + # so the compiler doesn't flag the generated `if`'s else branch as dead. + assigns = %{show: Enum.member?([], :x)} + node = ~MOB(<Text text="x" :if={@show} />) + refute node + end + + test ":if with a string value raises CompileError" do + assert_raise CompileError, ~r/:if requires a \{expr\}/, fn -> + Code.compile_string(~S[import Mob.Sigil; ~MOB(<Text text="x" :if="true" />)]) + end + end + end + + # ── :for control attribute ──────────────────────────────────────────────────── + + describe ":for directive" do + test ":for at the root produces a list of nodes" do + nodes = ~MOB""" + <Text text={label} :for={label <- ["1", "2", "3"]} /> + """ + + assert length(nodes) == 3 + assert Enum.map(nodes, & &1.props.text) == ["1", "2", "3"] + end + + test ":for child splices into its parent" do + node = ~MOB""" + <Column> + <Text text="header" /> + <Text text={label} :for={label <- ["a", "b"]} /> + </Column> + """ + + assert Enum.map(node.children, & &1.props.text) == ["header", "a", "b"] + end + + test ":for reads @assigns" do + assigns = %{items: ["x", "y"]} + nodes = ~MOB(<Text text={i} :for={i <- @items} />) + assert Enum.map(nodes, & &1.props.text) == ["x", "y"] + end + + test ":for over an empty list yields no children" do + node = ~MOB""" + <Column> + <Text text={i} :for={i <- []} /> + </Column> + """ + + assert node.children == [] + end + + test ":for on a container element repeats the whole subtree" do + node = ~MOB""" + <Column> + <Row :for={label <- ["a", "b"]}> + <Text text={label} /> + </Row> + </Column> + """ + + assert length(node.children) == 2 + assert Enum.all?(node.children, &(&1.type == :row)) + assert Enum.map(node.children, &hd(&1.children).props.text) == ["a", "b"] + end + + test ":if on a container element drops the whole subtree" do + node = ~MOB""" + <Column> + <Row :if={false}> + <Text text="gone" /> + </Row> + <Text text="kept" /> + </Column> + """ + + assert Enum.map(node.children, & &1.type) == [:text] + end + + test ":for with :if filters (LiveView comprehension semantics)" do + node = ~MOB""" + <Column> + <Text text={to_string(n)} :for={n <- 1..4} :if={rem(n, 2) == 0} /> + </Column> + """ + + assert Enum.map(node.children, & &1.props.text) == ["2", "4"] + end + end + # ── compile-time errors ─────────────────────────────────────────────────────── describe "compile-time errors" do diff --git a/test/mob/socket_test.exs b/test/mob/socket_test.exs index 1246ec43..cb6806e5 100644 --- a/test/mob/socket_test.exs +++ b/test/mob/socket_test.exs @@ -48,6 +48,47 @@ defmodule Mob.SocketTest do end end + describe "update/3" do + test "applies the function to the current value" do + socket = + Socket.new(MyScreen) |> Socket.assign(:count, 1) |> Socket.update(:count, &(&1 + 1)) + + assert socket.assigns.count == 2 + end + + test "raises if the key is not assigned" do + assert_raise KeyError, fn -> + Socket.new(MyScreen) |> Socket.update(:missing, &(&1 + 1)) + end + end + + test "leaves other assigns untouched" do + socket = + Socket.new(MyScreen) + |> Socket.assign(a: 1, b: 2) + |> Socket.update(:a, &(&1 * 10)) + + assert socket.assigns.a == 10 + assert socket.assigns.b == 2 + end + end + + describe "assign_new/3" do + test "assigns and runs the fun when the key is absent" do + socket = Socket.new(MyScreen) |> Socket.assign_new(:user, fn -> "computed" end) + assert socket.assigns.user == "computed" + end + + test "keeps the existing value and does not run the fun" do + socket = + Socket.new(MyScreen) + |> Socket.assign(:user, "existing") + |> Socket.assign_new(:user, fn -> raise "assign_new ran when key was present" end) + + assert socket.assigns.user == "existing" + end + end + describe "assign/2 — keyword list" do test "sets multiple assigns at once" do socket = Socket.new(MyScreen) |> Socket.assign(count: 0, name: "test") diff --git a/test/mob/speech_test.exs b/test/mob/speech_test.exs new file mode 100644 index 00000000..3a7a2411 --- /dev/null +++ b/test/mob/speech_test.exs @@ -0,0 +1,25 @@ +defmodule Mob.SpeechTest do + use ExUnit.Case, async: true + + # The speak/3 and stop_speaking/1 paths call into mob_nif, which isn't loaded + # on the host (same as Mob.Haptic / Mob.Clipboard — untested for that reason). + # The testable logic is the option whitelisting/encoding feeding the NIF. + + test "speak_opts whitelists known keys and stringifies them" do + assert Mob.Speech.speak_opts(rate: 0.5, pitch: 1.2, voice: "en-US") == + %{"rate" => 0.5, "pitch" => 1.2, "voice" => "en-US"} + end + + test "speak_opts drops unknown options so typos can't reach the native layer" do + assert Mob.Speech.speak_opts(rate: 0.5, bogus: 1, foo: :bar) == %{"rate" => 0.5} + end + + test "speak_opts on an empty list JSON-encodes to an empty object" do + assert Mob.Speech.speak_opts([]) == %{} + assert IO.iodata_to_binary(:json.encode(Mob.Speech.speak_opts([]))) == "{}" + end + + test "speak/3 requires binary text" do + assert_raise FunctionClauseError, fn -> Mob.Speech.speak(%{}, :not_a_binary) end + end +end diff --git a/test/mob/test_test.exs b/test/mob/test_test.exs index 3018e781..7d8c720b 100644 --- a/test/mob/test_test.exs +++ b/test/mob/test_test.exs @@ -158,4 +158,103 @@ defmodule Mob.TestTest do assert length(sample_tree().children) > 0 end end + + # ── screenshot + scroll pure helpers ────────────────────────────────────── + + describe "normalize_screenshot_opts/1" do + test "defaults to png, quality 90, scale 1.0" do + assert %{format: :png, quality: 90, scale: 1.0} = M.normalize_screenshot_opts([]) + end + + test "passes jpeg through and clamps quality to 0..100" do + assert %{format: :jpeg, quality: 60} = + M.normalize_screenshot_opts(format: :jpeg, quality: 60) + + assert %{quality: 100} = M.normalize_screenshot_opts(quality: 250) + assert %{quality: 0} = M.normalize_screenshot_opts(quality: -5) + end + + test "floatifies an integer scale" do + assert %{scale: 2.0} = M.normalize_screenshot_opts(scale: 2) + end + + test "raises on an unsupported format" do + assert_raise ArgumentError, ~r/:png or :jpeg/, fn -> + M.normalize_screenshot_opts(format: :gif) + end + end + end + + describe "resolve_scroll_target/2" do + defp pixel_info do + %{ + offset: {0.0, 200.0}, + content: {393.0, 2400.0}, + viewport: {393.0, 756.0}, + max_offset: {0.0, 1644.0}, + kind: :pixel + } + end + + test ":top and :bottom resolve to the extremes" do + assert M.resolve_scroll_target(:top, pixel_info()) == {0.0, 0.0} + assert M.resolve_scroll_target(:bottom, pixel_info()) == {0.0, 1644.0} + end + + test "{:page, n} steps n viewport-heights from the top, keeping x" do + # 1 page = one viewport height (756) + assert M.resolve_scroll_target({:page, 1}, pixel_info()) == {0.0, 756.0} + # 3 pages would be 2268 but clamps to max_offset y (1644) + assert M.resolve_scroll_target({:page, 3}, pixel_info()) == {0.0, 1644.0} + end + + test "absolute {x, y} is clamped to the extent" do + assert M.resolve_scroll_target({0.0, 500.0}, pixel_info()) == {0.0, 500.0} + assert M.resolve_scroll_target({0.0, 9999.0}, pixel_info()) == {0.0, 1644.0} + assert M.resolve_scroll_target({0.0, -10.0}, pixel_info()) == {0.0, 0.0} + end + + test "works in item units for an :index list (page = visible item count)" do + index_info = %{ + offset: {0.0, 0.0}, + content: {0.0, 100.0}, + viewport: {0.0, 8.0}, + max_offset: {0.0, 92.0}, + kind: :index + } + + # one page = 8 items + assert M.resolve_scroll_target({:page, 1}, index_info) == {0.0, 8.0} + assert M.resolve_scroll_target(:bottom, index_info) == {0.0, 92.0} + end + end + + describe "tour_offsets/2" do + test "pages from 0 to max_offset by viewport height, pinning a final bottom page" do + offsets = M.tour_offsets(pixel_info(), []) + ys = Enum.map(offsets, fn {_x, y} -> y end) + + assert List.first(ys) == 0.0 + assert List.last(ys) == 1644.0 + # 1644 / 756 -> ceil 3 steps: 0, 756, 1512, 1644 + assert ys == [0.0, 756.0, 1512.0, 1644.0] + end + + test "overlap shrinks the step" do + ys = M.tour_offsets(pixel_info(), overlap: 0.5) |> Enum.map(fn {_x, y} -> y end) + # step = 756 * 0.5 = 378 + assert Enum.at(ys, 1) == 378.0 + assert List.last(ys) == 1644.0 + end + + test "keeps the current x offset across pages" do + info = %{pixel_info() | offset: {40.0, 0.0}} + assert Enum.all?(M.tour_offsets(info, []), fn {x, _y} -> x == 40.0 end) + end + + test "a non-scrollable view yields a single page at the top" do + info = %{pixel_info() | max_offset: {0.0, 0.0}} + assert M.tour_offsets(info, []) == [{0.0, 0.0}] + end + end end diff --git a/test/mob/theme_test.exs b/test/mob/theme_test.exs index 929814df..e5f282dd 100644 --- a/test/mob/theme_test.exs +++ b/test/mob/theme_test.exs @@ -1,6 +1,11 @@ defmodule Mob.ThemeTest do use ExUnit.Case, async: true + defmodule PresetFixture do + @moduledoc false + def theme, do: Mob.Theme.build(primary: :violet_600, background: 0xFF17151F) + end + alias Mob.Theme describe "build/1" do @@ -26,24 +31,24 @@ defmodule Mob.ThemeTest do end test "module theme returns a Theme struct" do - t = Mob.Theme.Obsidian.theme() + t = PresetFixture.theme() assert %Theme{} = t assert t.primary == :violet_600 end test "set/1 accepts a theme module" do on_exit(fn -> Application.delete_env(:mob, :theme) end) - Theme.set(Mob.Theme.Obsidian) + Theme.set(PresetFixture) assert Theme.current().primary == :violet_600 end test "set/1 accepts {module, overrides}" do on_exit(fn -> Application.delete_env(:mob, :theme) end) - Theme.set({Mob.Theme.Obsidian, primary: :rose_500}) + Theme.set({PresetFixture, primary: :rose_500}) t = Theme.current() assert t.primary == :rose_500 - # still Obsidian background - assert t.background == 0xFF0D0D1A + # the preset's other tokens survive the override + assert t.background == 0xFF17151F end end @@ -89,6 +94,63 @@ defmodule Mob.ThemeTest do end end + describe "resolved_palette/1" do + test "resolves atom tokens through theme + palette to ARGB ints" do + m = Theme.resolved_palette(Theme.build(primary: :emerald_500, on_primary: :white)) + # emerald_500 = 0xFF10B981, white = 0xFFFFFFFF + assert m.primary == 0xFF10B981 + assert m.on_primary == 0xFFFFFFFF + end + + test "passes raw ARGB ints through unchanged" do + m = Theme.resolved_palette(Theme.build(primary: 0xFF7C3AED, surface: 0xFF16162A)) + assert m.primary == 0xFF7C3AED + assert m.surface == 0xFF16162A + end + + test "leaves unknown atom values intact (no palette match)" do + m = Theme.resolved_palette(Theme.build(primary: :corporate_pink)) + assert m.primary == :corporate_pink + end + + test "default theme resolves every token to an int" do + m = Theme.resolved_palette(Theme.default()) + + for {key, value} <- m do + assert is_integer(value), "expected #{key} to resolve to integer, got #{inspect(value)}" + end + end + end + + describe "set/1 native push" do + setup do + on_exit(fn -> Mob.Theme.set(%Mob.Theme{}) end) + :ok + end + + # set/1 calls :mob_nif.set_theme which isn't loaded on the host BEAM. + # The notify_native helper has to catch that without bubbling — anything + # else would break apps that call Mob.Theme.set/1 in unit tests. + test "does not crash on host BEAM (NIF stub absent)" do + assert :ok = Mob.Theme.set(Mob.Theme.build(primary: :emerald_500)) + assert :ok = Mob.Theme.set(PresetFixture) + end + end + + describe "flags_map/1" do + test "returns glass: false on default theme" do + assert Theme.flags_map(Theme.default()) == %{glass: false} + end + + test "reflects glass: true override" do + assert Theme.flags_map(Theme.build(glass: true)) == %{glass: true} + end + end + + # ObsidianGlass (and the glass-preset identity test) moved to the + # mob_themes style package with the theme itself; the renderer's glass-flag + # contract is covered in renderer_test.exs with an inline glass theme. + describe "color_map/1" do test "maps semantic names to their values" do m = Theme.color_map(Theme.default()) diff --git a/test/mob/torch_test.exs b/test/mob/torch_test.exs new file mode 100644 index 00000000..35e5edfd --- /dev/null +++ b/test/mob/torch_test.exs @@ -0,0 +1,22 @@ +defmodule Mob.TorchTest do + use ExUnit.Case, async: true + + # set/2 calls into the NIF (unavailable on the host), so we test the pure + # wire-atom mapping that decides what the native layer receives, plus the + # boolean guard. + describe "state_atom/1" do + test "true maps to :on" do + assert Mob.Torch.state_atom(true) == :on + end + + test "false maps to :off" do + assert Mob.Torch.state_atom(false) == :off + end + end + + describe "set/2 guard" do + test "rejects a non-boolean before reaching the NIF" do + assert_raise FunctionClauseError, fn -> Mob.Torch.set(%{}, :yes) end + end + end +end diff --git a/test/mob/ui_test.exs b/test/mob/ui_test.exs index d55de24a..90ace7fe 100644 --- a/test/mob/ui_test.exs +++ b/test/mob/ui_test.exs @@ -98,4 +98,116 @@ defmodule Mob.UITest do assert UI.canvas(width: 100, height: 100, draw: ops).props.draw == ops end end + + describe "gpu_view/1" do + @shader """ + fragment half4 fragment_main(VertexOut in [[stage_in]], + constant Uniforms& u [[buffer(0)]]) { + return half4(in.uv, 0.0, 1.0); + } + """ + + test "type is :gpu_view" do + node = UI.gpu_view(id: :mandelbrot, width: 350, height: 350, shader: @shader, uniforms: []) + assert node.type == :gpu_view + end + + test "children is always empty — gpu_view is a leaf node" do + node = UI.gpu_view(id: :mandelbrot, width: 350, height: 350, shader: @shader, uniforms: []) + assert node.children == [] + end + + test "props carries id / width / height / shader / uniforms verbatim" do + uniforms = [[1.0, 2.0], 3.0, 256] + + props = + UI.gpu_view( + id: :foo, + width: 200, + height: 150, + shader: @shader, + uniforms: uniforms + ).props + + assert props.id == :foo + assert props.width == 200 + assert props.height == 150 + assert props.shader == @shader + assert props.uniforms == uniforms + end + + test "accepts shader as the map escape-hatch form" do + shader_map = %{ios: @shader} + props = UI.gpu_view(id: :x, width: 100, height: 100, shader: shader_map, uniforms: []).props + assert props.shader == shader_map + end + + test "unrecognized props are omitted" do + props = + UI.gpu_view( + id: :x, + width: 100, + height: 100, + shader: @shader, + uniforms: [], + background: "#000" + ).props + + refute Map.has_key?(props, :background) + end + + test "accepts a plain map and produces identical output to the keyword form" do + kw = + UI.gpu_view(id: :x, width: 100, height: 100, shader: @shader, uniforms: [1.0]) + + m = + UI.gpu_view(%{id: :x, width: 100, height: 100, shader: @shader, uniforms: [1.0]}) + + assert kw == m + end + + test "shape is renderer-compatible — %{type:, props:, children:}" do + node = UI.gpu_view(id: :x, width: 100, height: 100, shader: @shader, uniforms: []) + assert Map.keys(node) |> Enum.sort() == [:children, :props, :type] + end + + test "carries on_tap / on_drag / on_pinch when supplied" do + tap = {self(), :tapped} + drag = {self(), :dragged} + pinch = {self(), :pinched} + + props = + UI.gpu_view( + id: :x, + width: 100, + height: 100, + shader: @shader, + uniforms: [], + on_tap: tap, + on_drag: drag, + on_pinch: pinch + ).props + + assert props.on_tap == tap + assert props.on_drag == drag + assert props.on_pinch == pinch + end + + test "uniforms list preserves declaration order (no Map iteration surprises)" do + # The whole point of accepting a list — order is pinned to position, + # not to whatever the runtime decides. The shader-side `Uniforms` + # struct can declare its members in the same order and read them + # verbatim. A map form does not give this guarantee (verified + # empirically against the iPhone Mandelbrot demo, where + # `%{center: ..., zoom: ..., max_iter: ...}` iterated as + # `[:zoom, :max_iter, :center]` on the device BEAM and produced + # black output until we switched to a list). + uniforms = [[1.0, 2.0], 3.0, 256, [4.0, 5.0, 6.0, 7.0]] + + props = + UI.gpu_view(id: :x, width: 100, height: 100, shader: @shader, uniforms: uniforms).props + + assert props.uniforms == uniforms + end + end end diff --git a/test/mob/vendor_usb_test.exs b/test/mob/vendor_usb_test.exs new file mode 100644 index 00000000..f937d787 --- /dev/null +++ b/test/mob/vendor_usb_test.exs @@ -0,0 +1,132 @@ +defmodule Mob.VendorUsbTest do + use ExUnit.Case, async: true + + alias Mob.VendorUsb + + describe "normalize_message/1 — devices_json" do + test "decodes a list of device records" do + json = + IO.iodata_to_binary( + :json.encode([ + %{ + "vendor_id" => 0x1234, + "product_id" => 0x5678, + "manufacturer" => "Acme Inc.", + "product" => "Widget 9000", + "serial" => "SN-000001", + "ref" => "/dev/bus/usb/001/002" + } + ]) + ) + + assert {:peripheral, :vendor_usb, :devices, nil, [device]} = + VendorUsb.normalize_message({:peripheral, :vendor_usb, :devices_json, nil, json}) + + assert device.vendor_id == 0x1234 + assert device.product_id == 0x5678 + assert device.manufacturer == "Acme Inc." + assert device.product == "Widget 9000" + assert device.serial == "SN-000001" + assert device.ref == "/dev/bus/usb/001/002" + end + + test "empty list passes through cleanly" do + assert {:peripheral, :vendor_usb, :devices, nil, []} = + VendorUsb.normalize_message( + {:peripheral, :vendor_usb, :devices_json, nil, + IO.iodata_to_binary(:json.encode([]))} + ) + end + + test "tolerates missing optional string fields" do + json = + IO.iodata_to_binary( + :json.encode([ + %{ + "vendor_id" => 0x1234, + "product_id" => 0x5678, + "ref" => "/dev/bus/usb/001/002" + } + ]) + ) + + assert {:peripheral, :vendor_usb, :devices, nil, [device]} = + VendorUsb.normalize_message({:peripheral, :vendor_usb, :devices_json, nil, json}) + + assert device.manufacturer == nil + assert device.product == nil + assert device.serial == nil + end + end + + describe "normalize_message/1 — permission events" do + test "permission_granted_json becomes :permission_granted with a device map" do + json = + IO.iodata_to_binary( + :json.encode(%{ + "vendor_id" => 0x1234, + "product_id" => 0x5678, + "ref" => "/dev/bus/usb/001/002" + }) + ) + + assert {:peripheral, :vendor_usb, :permission_granted, nil, device} = + VendorUsb.normalize_message( + {:peripheral, :vendor_usb, :permission_granted_json, nil, json} + ) + + assert device.ref == "/dev/bus/usb/001/002" + end + + test "permission_denied_json becomes :permission_denied" do + json = IO.iodata_to_binary(:json.encode(%{"ref" => "/dev/bus/usb/001/002"})) + + assert {:peripheral, :vendor_usb, :permission_denied, nil, device} = + VendorUsb.normalize_message( + {:peripheral, :vendor_usb, :permission_denied_json, nil, json} + ) + + assert device.ref == "/dev/bus/usb/001/002" + end + end + + describe "normalize_message/1 — opened_json" do + test "decodes opened with session id and device payload" do + json = + IO.iodata_to_binary( + :json.encode(%{ + "vendor_id" => 0x1234, + "product_id" => 0x5678, + "ref" => "/dev/bus/usb/001/002" + }) + ) + + assert {:peripheral, :vendor_usb, :opened, 7, device} = + VendorUsb.normalize_message({:peripheral, :vendor_usb, :opened_json, 7, json}) + + assert device.vendor_id == 0x1234 + end + end + + describe "normalize_message/1 — passthrough" do + test "non-JSON peripheral events pass through unchanged" do + msg = {:peripheral, :vendor_usb, :data, 7, <<1, 2, 3>>} + assert VendorUsb.normalize_message(msg) == msg + end + + test "write_complete passes through unchanged" do + msg = {:peripheral, :vendor_usb, :write_complete, 7, %{bytes: 4}} + assert VendorUsb.normalize_message(msg) == msg + end + + test "error event passes through unchanged" do + msg = {:peripheral, :vendor_usb, :error, 7, :write_timeout} + assert VendorUsb.normalize_message(msg) == msg + end + + test "unrelated messages pass through unchanged" do + msg = {:something, :else} + assert VendorUsb.normalize_message(msg) == msg + end + end +end