diff --git a/.credo.exs b/.credo.exs index 97861be..8c6984e 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/"] @@ -22,11 +26,11 @@ # jump_credo_checks — LiveView specific {Jump.CredoChecks.AssertElementSelectorCanNeverFail, []}, {Jump.CredoChecks.AvoidSocketAssignsInTest, []}, - {Jump.CredoChecks.LiveViewFormCanBeRehydrated, []}, + {Jump.CredoChecks.LiveViewFormCanBeRehydrated, []} ], disabled: [ # Pipes with single function calls are fine in this codebase - {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.SinglePipe, []} ] } } diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d304ff3 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..4364368 --- /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/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c1ea259 --- /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 0000000..1230547 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,125 @@ +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 + + # mob.adopt's native (--android/--ios) tests render from mob_new's + # templates — mob_new is the single source, not duplicated here. Provide + # a mob_new checkout so MobDev.Adopt.Generator resolves them via + # MOB_NEW_DIR (mirrors a user having the mob_new archive installed). + - name: Checkout mob_new (native adopt templates) + uses: actions/checkout@v4 + with: + repository: GenericJam/mob_new + path: mob_new + + - 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 + + # Some HotPush tests inspect `_build/dev/lib/*/ebin/*.beam` to verify + # which runtime BEAMs the `mix mob.push` helper would ship to a device. + # Under MIX_ENV=test the dev tree doesn't exist; pre-populate it so + # those tests have something to look at. + - name: Compile :dev tree (for HotPush tests) + env: + MIX_ENV: dev + run: mix compile + + - name: Format check + run: mix format --check-formatted + + - name: Credo (strict) + run: mix credo --strict + + - name: Tests + env: + # mob.adopt native tests resolve mob_new's templates from this checkout. + MOB_NEW_DIR: ${{ github.workspace }}/mob_new + # `:macos_only` — tests that shell out to /usr/libexec/PlistBuddy + # and similar macOS-bound tools. + # `:requires_zig` — the StaticNifs zig-ast-check round-trip; we + # don't install Zig in CI yet (it's a 30 MB download for one test). + # Both run by default on a maintainer's laptop; CI explicitly skips. + run: mix test --exclude macos_only --exclude requires_zig + + security_scan: + name: Security scan + needs: test + runs-on: ubuntu-latest + if: always() + # Informational: a finding shouldn't block the workflow gate while we + # establish a baseline. Flip to `--strict` 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 + + # osv-scanner is an optional external tool the scanner consumes when + # available. Missing tools are reported as soft warnings (not failures). + - name: Install osv-scanner + run: | + curl -sSfL https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64 \ + -o /usr/local/bin/osv-scanner + chmod +x /usr/local/bin/osv-scanner + osv-scanner --version + + - name: mix mob.security_scan + # The hex_deps layer applies to mob_dev itself; the gradle / swift / + # bundled_runtime layers no-op (mob_dev has no native surface). + run: mix mob.security_scan diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..b4ba75b --- /dev/null +++ b/.tool-versions @@ -0,0 +1,3 @@ +elixir 1.20.0-otp-29 +erlang 29.0 +zig 0.17.0-dev.269+ebff43698 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..870099f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,261 @@ +# AGENTS.md — mob_dev + +You're in **mob_dev**, the build/deploy/devices toolkit. Read +[`~/code/mob/AGENTS.md`](../mob/AGENTS.md) first for the system view, the +three-repo topology, the cross-cutting pre-empt-failure rules, and the +**"Don't write this slop"** list (AI-generated patterns to avoid at write +time, not after credo flags them). The notes below are mob_dev-specific. + +## What this repo is + +Mix tasks (`mob.deploy`, `mob.connect`, `mob.devices`, `mob.emulators`, +`mob.provision`, `mob.doctor`, `mob.battery_bench_*`) plus their backing +modules (`MobDev.Discovery.{Android,IOS}`, `MobDev.NativeBuild`, +`MobDev.OtpDownloader`, `MobDev.Deployer`, `MobDev.Emulators`). + +The **release tooling** lives at `scripts/release/` — shell scripts for +cross-compiling OTP for Android arm64/arm32, iOS sim, and iOS device, then +staging the tarballs and uploading to GitHub Releases. Patches we apply to +OTP source for iOS-device compatibility live at +`scripts/release/patches/` (`forker_start` skip, EPMD `NO_DAEMON` guard). +See `build_release.md` for the full release walkthrough. + +## TDD is the practice here + +Write tests before or alongside new code. Every new function should have +corresponding tests before the task is considered done. The test suite must +stay green at all times. + +```bash +mix test # all tests +mix test --exclude integration # skip the device-dependent ones +``` + +## Things that bite specifically in mob_dev + +- **Compile-time regex literals are unsafe** on Elixir 1.19 / OTP 28.0. Use + `Regex.compile!("...", "flags")` for runtime compilation. Already swept in + 0.3.17 — don't reintroduce. +- **`mix mob.deploy --device `** resolves the id via discovery before + deciding which platform to build. The narrowing logic is in + `narrow_platforms_for_device/2` and is the single source of truth for both + build and deploy. Bypass it and you'll get either spurious "No device + matched" warnings (deploy) or builds for the wrong platform (build). +- **`xcodebuild` errors get rewritten** to actionable hints by + `diagnose_xcodebuild_failure/1` in `mob.provision`. Apple's verbatim text is + preserved alongside our hint so the snippet stays google-able. Add new + pattern matches there when you encounter a new Apple error string. +- **APNs push token never arrives on iOS device** if the binary's codesigning + entitlements omit `aps-environment`. `NativeBuild.codesign_ios_device_app/3` + auto-mirrors the value from the embedded provisioning profile into the + fallback entitlements. If the profile was provisioned without push, no + mirroring happens — either re-provision with push enabled or create + `ios/.entitlements` with `aps-environment: development`. Test the + plist text via `NativeBuild.fallback_entitlements_plist/3`. +- **OTP tarball schema changes need bumping `valid_otp_dir?/2`** in + `otp_downloader.ex` so existing caches auto-redownload. Don't bump the OTP + hash — the schema check is the right knob. +- **The release scripts assume `~/code/otp` exists** with the right cross-compile + output. The patches in `scripts/release/patches/` are applied automatically + by `xcompile_ios_device.sh`, idempotently — re-running is safe. +- **`mob.add_nif` is the entry point for new NIFs.** Don't add `:static_nifs` + entries by hand to `mob.exs` — the task already does the AST-aware append, + generates the Elixir stub via Igniter, and re-runs `mob.regen_driver_tab` + so `priv/generated/driver_tab_*.zig` stays in sync. `--type` of `c`, + `zigler`, `rustler` also drops the right native skeleton; `elixir-only` + (default) leaves the C/Zig/Rust to you. The stubs for zigler/rustler + carry an explicit static-link warning — those backends produce dlopen'd + `.so` by default, which is wrong for Mob's iOS App Store / + Android-RTLD_LOCAL constraints. The host-dev path works; on-device + shipping needs the user to wire the archive into ios/build.zig + + android/jni/ themselves. Reach for `--type c` if static linking matters + more than the source language. +- **`mob.regen_driver_tab` reads `:static_nifs` from `mob.exs`** via + `Config.Reader`, NOT from `Application.get_env`. mob.exs is not + auto-imported into Mix application env (this matches every other + mob_dev task that consumes mob.exs values). If you add a new task that + reads `:static_nifs`, use `MobDev.Config.load_mob_config()` to stay + consistent — using `Application.get_env(:mob_dev, :static_nifs, [])` + silently misses the user's entries. +- **`mob.enable` is now Igniter-driven (Phase 4).** Per-feature + handlers live in `MobDev.Enable.Igniter` and return + `igniter -> igniter`. When adding a new feature: add a clause to + the `@valid_features` list in `mob.enable.ex`, a `dispatch/3` + clause, and an `enable_/2` function in + `MobDev.Enable.Igniter`. Use `Igniter.update_file` for text-level + patches (plist, AndroidManifest, JS, HEEX) and AST-aware helpers + (`Igniter.Project.Module.create_module`, `Project.Deps.add_dep`, + `Project.Config.modify_config_code`) for Elixir source. Always + emit `Igniter.add_notice` when a platform dir is missing — silent + skips were a recurring user-confusion source in the legacy task. +- **File discovery in `Enable.Igniter` is Igniter-aware.** Helpers + like `find_ios_plist/1` and `find_android_manifest/1` check disk + first then fall back to `Rewrite.paths(igniter.rewrite)` / + `Igniter.exists?/2`, so `Igniter.test_project(files: %{...})` in + tests works without writing to disk. Don't bypass this with raw + `File.exists?/1` — `mix mob.enable` tests will pass on disk but + break Igniter test virtualization. +- **`mix mob.enable` reads app name via `Igniter.Project.Application.app_name/1`**, + NOT `File.cwd!() <> "/mix.exs"`. Under `test_project`, disk reads + see mob_dev's own mix.exs (wrong app). Falls back to the legacy + on-disk read only when Igniter has no mix.exs source. +- **`mix mob.adopt` is the install-into-existing-Phoenix task** (Igniter, + like `mob.enable`). The orchestrator (`Mix.Tasks.Mob.Adopt`) gates on + `MobDev.AdoptGuard.check/2` then composes the sub-installers + `mob.adopt.{deps,bridge,screen,mob_app,mob_exs,native,finalize}` — each a + task module under `lib/mix/tasks/mob/adopt/`. Pre-1.0 it *refuses* (adds + Igniter issues, no file changes) on unblessed shapes; widen the guard, not + the silent-proceed path. Shared Elixir-source content + LV-bridge patches + live in `MobDev.Adopt.Patcher`; assigns / dep-resolution / Pythonx wiring + in `MobDev.Adopt.Generator`. **The native Android/iOS trees come from + mob_new's `priv/templates/mob.new/`** — `Generator.templates_root/1` + resolves a `:mob_new` dep, then `$MOB_NEW_DIR`, then `~/code/mob_new`. Both + `Adopt.{Patcher,Generator}` are duplicated from mob_new's + `LiveViewPatcher` / `ProjectGenerator` (mob_new is a self-contained + archive, can't depend on mob_dev); Phase 5 of `build_system_migration.md` + reunifies them. See `decisions/2026-06-19-mob-adopt-lives-in-mob_dev.md`. + `MobDev.AdoptGuard.check/2` / `mode_from/1` and the `Adopt.Patcher` / + `Adopt.Generator` helpers are public for testing — don't privatise. + +## Public-but-undocumented seams + +A few helpers are public specifically to enable testing (the parsing and +narrowing functions). Don't make them private: + +- `Discovery.Android.parse_devices_output/1` +- `Discovery.IOS.parse_simctl_json/1`, `parse_simctl_text/1`, `parse_runtime_version/1`, + `build_simctl_launch_args/2`, `restart_app_physical/3`, and + `build_devicectl_launch_args/2` +- `OtpDownloader.valid_otp_dir?/2`, `ios_device_extras_present?/1` +- `PythonAppleSupport.valid_dir?/1` +- `NativeBuild.narrow_platforms_for_device/2`, `ios_toolchain_available?/0`, `read_sdk_dir/1`, `fallback_entitlements_plist/3` +- `NativeBuild.pythonx_in_project?/1`, `python_apple_support_env/2` +- `NativeBuild.build_all_with_outcome/1`, `build_outcome/1`, `build_outcome/2`, + `ios_phase_decision/3`, `resolve_android_update_targets/2`, + `install_android_updates/3`, `install_and_deliver_android/4`, and + `install_and_deliver_android_runtime/8`, `release_android_deploy_lock/2`, + `interpret_adb_update/2`, `android_otp_dir_from_abi_probe/4`, + `android_package_listed?/2`, `deliver_android_otp_release/7`, and + `push_otp_runas/6` (typed sequencing and update-only Android safety seams; + the deprecated direct mutators intentionally fail closed) +- `AndroidDeployLock.valid?/2`, `acquire/4`, `verify_owner/3`, `transition/4`, + `release/2`, `status/3`, and `cleanup_committed_tombstone/3` (the shared, + exact-target Android mutation lease and its bounded recovery surface) +- `HotPush.prepare/1`, `push_prepared/2`, `push_prepared/3`, + `validate_prepared_snapshot/1`, and `push_prepared_fenced/3` (immutable BEAM + snapshot and lease-fenced RPC seams; raw Android pushes intentionally reject) +- `Mix.Tasks.Mob.DeployLock.inspect_or_cleanup/4` (hermetic task decision seam; + production still requires an explicit exact `--device`) +- `Deployer.collect_android_beam_dirs/0`, `prepare_android_payload/2`, + `valid_android_payload?/2`, `cleanup_android_payload/1`, and + `deploy_all_with_lease/1`, `execute_ios_restart/1`, and + `interpret_ios_restart_result/1`, `restart_ios_simulator/4`, and + `restart_ios_physical/4` (immutable final-pass payload, shared-lease + integration, and authoritative iOS restart seams) +- `Deployer.select_canonical_android_devices/2`, + `classify_android_package_probe/2`, `deploy_android_device/4`, + `ensure_erts_on_device/3`, `verify_elixir_runtime_version_android/5`, + `setup_exqlite_android_runas/4`, `push_beams_android_runas/3`, and + `restart_android/3` (exact-target and per-mutation fencing seams; ordinary + `--device` matching remains user-friendly) +- `Mix.Tasks.Mob.Deploy.run/2`, `resolve_target_platforms!/4`, `execute_native_deploy!/6`, + `deploy_after_native_build!/3`, `deploy_after_native_build!/4`, + `deploy_after_native_build!/5`, `deploy_after_native_build!/6`, + `ensure_deploy_succeeded!/1`, and `report_deploy_result!/2` (typed + orchestration/result seams) +- `NativeBuild.__prune_plugin_artifacts__/2` (the plugin-removal prune; ledger-tracked per merge concern) +- `Enable.inject_pythonx_dep/1`, `inject_pythonx_uv_init_gate/2`, `python_paths_module_template/1` +- `Emulators.parse_simctl_json/1`, `find_emulator_binary/1` +- `Provision.diagnose_xcodebuild_failure/1` + +If you make any of these private, every downstream test breaks loudly — but +you'll lose the ability to evolve the parsers safely. + +## Destructive-task conventions + +Apply consistently to every Mix task that mutates device state +(`mix mob.uninstall` today; `mix mob.deploy --all-devices`, +`mix mob.connect`, future ones). + +**Emulator vs physical safety pattern (from `mix mob.uninstall`):** + +- `--all-devices` sweeps **emulators and simulators only**. NEVER + physical devices. Phones are someone's personal property and have + real-data blast radius; emulators are throwaway dev fixtures. +- `--all-physical` is the opt-in for sweeping physical devices. + Composes with `--all-devices` for "literally everything." +- `--device ` is the explicit-id escape hatch — the user typed + the id, that's consent; bypasses the type filter regardless of + whether the device is emulator or physical. +- **Auto-detect** (no flags, exactly one device connected) only + fires for a non-physical device. A solo phone connected with no + flags → error with a hint pointing at `--all-physical` or + `--device`. + +The predicate to route on is `MobDev.Device.physical?/1`. The +selection logic lives in `MobDev.Uninstaller.select_devices/3` +(public for testing); same shape should appear in any new task +needing the same fan-out behavior. Pin the headline guarantee in +each task's tests — "personal iPhone + dev emulators + `--all-devices` +must leave the iPhone alone." + +Android **native** deploys resolve a non-empty connected serial set (narrowed +by `--device ` when supplied) and run only the data-preserving +`adb -s install -r ` update path. They never force-stop first, +uninstall, or fall back to a clean install. A failed update must prevent the +final `MobDev.Deployer` pass. Before the first device mutation, freeze and hash +the APK, OTP archives, BEAM/priv payload, optional exqlite payload, restart +arguments, and exact canonical serial set. One phase-bound +`AndroidDeployLock` covers that complete set across native install/OTP work and +the final BEAM/restart pass. Prove the entire set immediately before every +mutation, halt later targets on the first failure, and retain the exact lease +on any ambiguous reply. Only a fully successful final pass may advance to a +committed phase and release it. Build-only APIs must remain artifact-only and +must never acquire a device lease or install an APK. + +For a mixed native Android+iOS deploy, complete, commit, release, and clean the +entire Android transaction before beginning the iOS build or install. An exact +typed `:not_attempted` Android disposition may proceed to iOS; any malformed, +failed, retained, or ambiguous Android outcome suppresses iOS and fails closed. + +Never recover by clearing app data, uninstalling, deleting an active lock, or +blindly retrying. `mix mob.deploy_lock --device ` is read-only; +`--cleanup-committed` may remove only one exact record-only tombstone already +in a committed phase and must prove the final clear state. + +Fast Android BEAM deploys use an exact-set shared lease. Distribution is used +only when every frozen target is already connected; otherwise the entire set +uses the fenced filesystem/restart path rather than splitting authority. + +**TODO:** apply the full physical-device *selection* pattern to the fast +`mix mob.deploy` BEAM fan-out. Its mutations are now exact-set fenced, but the +broad selector can still include a personal phone. When that fan-out grows, +factor `select_devices/3` plus the flag plumbing into a +shared `MobDev.TaskTargets` (or similar) module so the rules don't +drift between tasks. + +## Naming gotcha: `mix mob.install` vs `mix mob.uninstall` + +These look like inverses but aren't. Future agents touching either +should know: + +- **`mix mob.install`** — first-run **project setup**. Downloads the + OTP runtime, generates icons, writes `mob.exs`. Per-project, runs + once. Doesn't touch any device. +- **`mix mob.uninstall`** — per-**device** app removal. Sweeps + connected devices and removes installed `.app` / `.apk` bundles. + Doesn't undo `mix mob.install`'s project setup. + +A user reading the task list will plausibly type `mix mob.uninstall` +expecting it to undo `mix mob.install`. If we ever want true +symmetry, the device-cleanup task wants a clearer name (e.g. +`mob.app.uninstall` or `mob.devices.clear`) and `mob.uninstall` +could become the project-cleanup inverse of `mob.install`. Until we +make that call, the help text in both task @moduledoc blocks +should call out the scope difference explicitly. Don't quietly +rename — users have muscle memory by now. + +## Keep this file up to date + +When you change repo conventions, add a public seam, or hit a new gotcha — +update this file in the same commit. Stale guidance is worse than none. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6ed1878 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,673 @@ +# Changelog + +All notable changes to **mob_dev** 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_dev](https://hexdocs.pm/mob_dev). + +--- + +## [0.6.23] - 2026-07-12 + +### Fixed +- **`cpp_archive` / nx_eigen NDK resolution honors `ANDROID_HOME`.** + `MobDev.Plugin.CppArchive` and `MobDev.NxEigenNif` hardcoded + `~/Library/Android/sdk/ndk/` (+ the `darwin-x86_64` host), ignoring + `ANDROID_HOME` / `ANDROID_SDK_ROOT` — so a `lang: :cpp_archive` plugin build + failed with `NDK toolchain not found` wherever the NDK lives elsewhere (CI, a + shared SDK), unlike the main `NativeBuild` path. Extracted shared + `MobDev.NdkVersion.{root,host,toolchain_bin,sysroot}/0` (on the existing + env-aware `sdk_root/0`) and routed `cpp_archive`, `nx_eigen_nif`, and + `native_build`'s `ndk_sysroot` through them — one source of truth, so the NDK + path can't diverge again. (MOB-89) + +--- + +## [0.6.22] - 2026-07-11 + +### Added +- **`ios_release_screenshot` config — opt mob's public-API `screenshot` NIF into + release builds.** Companion to mob 0.7.20 (mob#71), which carves `screenshot/3` + into `#if !MOB_RELEASE || defined(MOB_ENABLE_SCREENSHOT)`. Setting + `config :mob_dev, ios_release_screenshot: true` exports `MOB_ENABLE_SCREENSHOT=1`, + and the generated `release_device.sh` compiles `mob_nif.m` with + `${MOB_ENABLE_SCREENSHOT:+-DMOB_ENABLE_SCREENSHOT}` — so a release build can ship the + screenshot NIF (letting an agent SEE a shipped app's screen to error-correct) while + the private synthetic-input NIFs (tap/type) stay stripped. Default is unchanged and + byte-identical (screenshot stays stripped); enabling it is a deliberate choice because + the NIF captures the app's own window with no OS prompt or indicator. (#39) + +## [0.6.21] - 2026-07-08 + +### Fixed +- **iOS app icons are flattened opaque so App Store upload validation accepts + them.** A transparent source icon (a common design — a rounded badge on + transparent corners) produced transparent iOS icons and tripped altool error + 90717 ("Invalid large app icon … can't be transparent or contain an alpha + channel"). `IconGenerator.write_ios_icons` now composites an alpha-bearing + source onto an opaque background (`Image.flatten!`) before resizing — using the + explicit `--adaptive-bg` colour when given, else the same colour sampled for + the Android adaptive background, so both platforms stay consistent; an opaque + source is left untouched. **Android icons keep their transparency** (adaptive + foregrounds + legacy launcher icons need it), and the bundled fallback + `mob_logo` iOS assets were pre-flattened. Verified: a transparent-badge icon + now passes App Store validation and reaches TestFlight. (#37) + +--- + +## [0.6.20] - 2026-07-07 + +### Fixed +- **iOS release builds now compile + link activated-plugin NIFs.** `mix + mob.release --ios` produced a binary that failed to link for any app with NIF + plugins — `Undefined symbols: __nif_init, referenced from + _erts_static_nif_tab in driver_tab_ios.o`. iOS statically links every NIF into + the single app binary (no `dlopen` under the App Store sandbox), so the + generated `driver_tab_ios.c` references each activated plugin's + `_nif_init`, but the release path (`release.ex` → `release_device.sh`) + hand-compiled a fixed object list and never touched plugins (the dev path + already compiled them via `build.zig -Dplugin_c_nifs`). `release_env/2` now + emits `MOB_PLUGIN_IOS_NIF_SOURCES` + `MOB_PLUGIN_IOS_FRAMEWORKS` from + `MobDev.Plugin.activated()` (via the new pure, tested + `MobDev.Release.plugin_ios_build_env/1`); `release_device.sh` compiles each + plugin NIF source with `-DSTATIC_ERLANG_NIF_LIBNAME=` + `-fmodules` + (framework autolink) and links the objects plus the declared frameworks. + Verified end-to-end: a 10-plugin app links and the IPA validates against its + App Store distribution profile. Scope: covers `nif_sources` (`lang: :c | + :objc`) + `ios_frameworks`; plugin `swift_files` and `:cpp_archive` static + archives on the release path remain a follow-up. (#36) +- **Test: `MobDev.Release.HelpersTest`'s git fixture no longer inherits the + ambient git environment.** Run inside a git hook (`.githooks/pre-push`), git + exports `GIT_DIR`/`GIT_WORK_TREE`/`GIT_INDEX_FILE`; the fixture's `git` + commands inherited them and operated on the outer repo instead of their + tmpdir, crashing setup — so the suite passed standalone but failed only on + push. The fixture now clears those vars on every `git` invocation. (#36) + +--- + +## [0.6.19] - 2026-07-07 + +### Added +- **Plugins can contribute AndroidManifest `` components and `res/` + files.** Two new optional `android:` manifest keys — + `manifest_application_snippets` (XML fragments spliced into the app's + `` block, idempotent per `android:name`) and `res_files` + (plugin-relative paths copied into the app `res/` tree at their derived + `res//` destination, path-contained + host-clobber-guarded + + signed). This closes the gap that forced plugins needing a + ``/``/`` + resource (e.g. `mob_nfc`'s HCE + `HostApduService` + `apduservice.xml`) to make it a manual `host_requirement`. + New `MobDev.Plugin.Merge.android_manifest_snippets/1` + `android_res_files/1` + gatherers (classified in the cross-plugin conflict surface), manifest-schema + validation, and `NativeBuild` splice + copy (ledger-pruned like `bridge_kt`). + (MOB-39) + +### Changed +- **Plugin contributions merged into host-owned build files are now reversible + (going forward).** Plugin ``s, `` components, and + Gradle deps are fenced in a regenerated-each-build managed region + (`MobDev.Plugin.ManagedBlock`) in `AndroidManifest.xml` / `build.gradle`, so + removing a plugin drops its **fenced** contributions (no more dangling + `` / orphan permission / orphan dep) while hand-authored content + outside the fence is untouched. Idempotent (`strip(place(x)) == x`); de-dupes + against existing entries; `strip` is orphan-BEGIN-safe (never deletes host + lines between a stray marker and a real region). **Forward-only:** entries an + older mob_dev already appended *unfenced* are indistinguishable from + hand-authored ones, so they're left in place (not double-added, not + auto-removed) — regenerate the app, or remove them by hand, to fence them. + (MOB-40) + +### Fixed +- **`mix mob.new_plugin` no longer scaffolds plugins pinned to the abandoned + `mob ~> 0.6`.** `MobDev.Plugin.Scaffold` hard-coded `{:mob, "~> 0.6"}` in the + generated `mix.exs` and `mob_version: "~> 0.6"` in every tier's manifest, so a + freshly scaffolded plugin could not activate against the published mob 0.7.x + (`installed :mob 0.7.x does not satisfy mob_version "~> 0.6"`). The requirement + is now derived at scaffold time from the mob actually resolved in the project + (`Scaffold.detect_mob_requirement/0`), falling back to a single + `@fallback_mob_requirement` constant (`"~> 0.7"`) when mob isn't loadable. + `mix.exs` and the manifest always agree. A `Scaffold` test pins the default and + asserts a generated manifest validates against a matching mob version, so the + pin can't silently lag a future mob release. (#21) + +--- + +## [0.6.18] - 2026-07-05 + +### Added +- **`mix mob.provision` can authenticate via an App Store Connect API key**, so + an unattended user — a CI runner or a headless agent account with no GUI login + — can provision without a signed-in Xcode Apple ID account. Set `APP_STORE_CONNECT_KEY_ID`, + `APP_STORE_CONNECT_ISSUER_ID`, and `APP_STORE_CONNECT_API_KEY_PATH` (the downloaded `AuthKey_.p8`) + and the task passes them to `xcodebuild` as `-authenticationKeyID` / + `-authenticationKeyIssuerID` / `-authenticationKeyPath`. All three or none + (a partial set raises, naming what's missing); with none set the signed-in + Xcode account is used exactly as before. Pure, tested `Mix.Tasks.Mob.Provision.asc_auth_args/1`. + Signing still needs the certificate + private key in an unlocked keychain — the + API key only authorizes the profile/device calls. (#31) + +## [0.6.17] - 2026-06-29 + +### Fixed +- **`mix mob.connect` crashed on a Mac set up only for iOS.** The dist-port + collision check shelled out to `adb forward --list`, and `System.cmd("adb", …)` + *raises* `:enoent` for a missing binary rather than returning a non-zero exit — + inside a linked `Task`, so the crash propagated and killed the whole connect. + `MobDev.Tunnel.run_adb/1` now resolves `adb` via `System.find_executable/1` + first (mirroring `Discovery.Android.list_devices/0`) and returns `{:error, …}` + when it's absent, so the port scan degrades to "no forwards" and iOS-only + Macs work with no Android platform-tools installed. (#29) + +### Added +- **`mix mob.connect --ios-only` / `--android-only`** restrict discovery to one + platform — handy when a phone for another project is plugged in. Set it + project-wide in `mob.exs` with `config :mob_dev, platforms: [:ios]`. New pure + helpers `MobDev.Config.parse_platforms/1` and + `Mix.Tasks.Mob.Connect.resolve_platforms/2`. (#29) + +--- + +## [0.6.16] - 2026-06-24 + +### Added +- **Plugins can contribute array-valued iOS plist keys** (e.g. + `UIBackgroundModes`). `apply_plugin_plist_keys!` previously skipped any + non-scalar `ios.plist_keys` value as unsupported; a list value now **merges** + into the host Info.plist array (creating it if absent, appending only the + missing string entries, deduped) instead of clobbering it. So one plugin can + add `bluetooth-central` while another (e.g. `mob_background`) keeps `audio`. + Merge decision extracted to the pure, tested `NativeBuild.plist_array_additions/2`. + +--- + +## [0.6.15] - 2026-06-23 + +### Security +- **Bumped `req` 0.5.18 → 0.6.2** (pulls `finch` 0.22.0 → 0.23.0), clearing + EEF-CVE-2026-49755 (HIGH) and EEF-CVE-2026-49756 (LOW) flagged by + `mix mob.security_scan`. `req` is a transitive dep (via `igniter`); the bump + stays within `igniter`'s `~> 0.5` requirement. + +--- + +## [0.6.14] - 2026-06-23 + +### Added +- **`:extra_static_libs` hook on `:static_nifs` entries** — a Mob app can now + link external per-ABI static archives alongside its project NIF archives. + Some project NIFs intentionally declare `extern` symbols and don't host-link + their backing archive (avoiding a host/device archive mismatch during `mix + compile`); this lets the native app link resolve those symbols against the + correct per-ABI `.a`. Entries require concrete per-ABI keys (`:ios_sim`, + `:ios_device`, `:android_arm64`, `:android_arm32`, `:android_x86_64`), and the + matching archive paths are appended to the existing `-Dproject_rust_libs=` + link argument. `-D_static=true` is emitted only on the ABIs where the + entry applies, and iOS project-NIF filtering is now per-ABI so a device-only + guarded entry doesn't leak into simulator args. Also passes Zigler 0.16's + required generated-build flags when re-driving staged Zig NIF builds and + resolves Zigler dotfile sources with `match_dot: true`. Verified on a physical + SM-T577U (arm64-v8a) tablet via a Ghostty VT NIF. (#24) + +--- + +## [0.6.13] - 2026-06-20 + +### Changed +- **`mix mob.deploy --native` now preserves on-device app data when the signing + key matches.** The Android install path previously ran an unconditional + `adb uninstall` before `adb install`, which wiped `MOB_DATA_DIR` (on-device + identity, screen stores) on **every** native deploy — even an in-place update + signed with the same (e.g. committed) debug keystore. It now attempts + `adb install -r` first and only falls back to uninstall + install when the + in-place update is genuinely rejected (`INSTALL_FAILED_UPDATE_INCOMPATIBLE` + from a signature mismatch, `INSTALL_FAILED_VERSION_DOWNGRADE`, etc.). Apps that + pin a committed debug keystore now keep their identity across `--native` + redeploys. Decision logic extracted to `NativeBuild.needs_clean_reinstall?/2` + and unit-tested. + +### Fixed +- **`mix mob.deploy --native --android` now fails fast with the real cause when + `zig` is missing** (landed in code before 0.6.13; previously undocumented). + The Android JNI build is driven by `build.zig`; with `zig` off PATH it used to + print a yellow "skipping build.zig step" warning and fall through to a CMake + fallback that references C sources mob 0.7+ no longer ships, dying ~150 lines + later with a misleading `Cannot find source file: .../mob_nif.c`. It now aborts + before Gradle with an actionable message (install zig 0.15.x, verify with + `mix mob.doctor`) when `build.zig` is present, `zig` is absent, and the legacy + C sources are gone. Decision extracted to `NativeBuild.zig_build_plan/3`. (#20) + +--- + +## [0.6.12] - 2026-06-19 + +### Fixed +- **16 KB page-size alignment enforced at build time for every app.** Android + 15+ devices use 16 KB memory pages and Google Play requires every bundled + `.so` to have 16 KB-aligned LOAD segments. The `-Wl,-z,max-page-size=16384` + link flag lives in the app's `build.zig`, which is copied once at `mix mob.new` + and never regenerated — so apps generated before the template carried the flag + kept linking 4 KB-aligned `.so` and failed Play. `mix mob.deploy --native` now + reads the app's `build.zig` and, if its `-shared` link lacks the flag, injects + it before linking (idempotent — a no-op when already present, e.g. the current + mob_new template or a hand-fixed app). Pure core in `inject_page_size_flag/1`. + +--- + +## [0.6.11] - 2026-06-19 + +### Added +- **`mix mob.adopt`** — installs Mob into an *existing* Phoenix project, + the Igniter-based install-into-existing counterpart to `mix mob.new` + (which generates from scratch). Composable: the orchestrator runs the + sub-installers `mob.adopt.{deps,bridge,screen,mob_app,mob_exs,native,finalize}`, + each invokable independently. Default LV-bridge mode wires `window.mob` + through a LiveView `phx-hook` and generates a `mob_app.ex` that boots the + host Phoenix endpoint on-device (SQLite Repo assumed); `--no-live-view` + generates a thin-client shell whose WebView opens a deployed server. + Pre-1.0: refuses loudly (via Igniter issues) on umbrella / non-Phoenix / + heavily-customised `app.js` or root layout / non-SQLite LV hosts rather + than risk breaking the app. The native Android/iOS trees (`--android` / + `--ios`) render from mob_new's templates and require the **mob_new archive + installed** (`mix archive.install hex mob_new`) — mob_new stays the single + source of native templates; the Elixir-side adoption needs no archive. + Contributed as [mob_new#8](https://github.com/GenericJam/mob_new/pull/8) + by [@ken-kost](https://github.com/ken-kost) and relocated here — adopt is + an Igniter task that mutates an existing project (like `mob.add_nif` / + `mob.enable`), so it belongs in mob_dev (a Hex dep), not in mob_new (a + self-contained Mix archive that can't carry Igniter). See + `decisions/2026-06-19-mob-adopt-lives-in-mob_dev.md`. The shared + patcher/generator helpers are duplicated from mob_new into + `MobDev.Adopt.{Patcher,Generator}` pending the Phase-5 Igniter + reunification. + +--- + +## [0.6.10] - 2026-06-19 + +### Added +- **Plugin `:cpp_archive` NIFs** — ship a C++ static library (e.g. an Nx + backend) from a plugin. Manifest-driven cross-compile + `--whole-archive` + static link (rule #11), with `_nif_init` symbol verification and + duplicate-init cross-validation. (#18) + +### Fixed +- cpp_archive builds now **fail fast with a named error** on an unsupported + Android ABI (e.g. the x86_64 emulator) instead of silently skipping — which + previously deferred an unresolved `_nif_init` to an on-device link + failure. +- The plugin manifest now requires a lowercase `:module` atom for + `:cpp_archive` entries (fail-loud instead of fail-open / `libnil.a`). + +--- + +## [0.6.9] - 2026-06-18 + +### Fixed +- **`mix mob.publish --android` now commits when Google requires + `changesNotSentForReview=true`.** Uploading a release while the app is under + policy review (or otherwise can't auto-send for review) made the Play Edits + `:commit` fail with HTTP 400 ("Please set the query parameter + changesNotSentForReview to true"), discarding the whole edit so nothing + reached the track. `commit_edit/3` now detects that 400 and retries the commit + with `?changesNotSentForReview=true`; the changes land on the track and are + sent for review from the Play Console UI. Verified uploading Io v13 to the + internal track. + +--- + +## [0.6.8] - 2026-06-18 + +### Added +- **`mix mob.connect --only ` (alias `--device`/`-d`, repeatable).** + Restricts the run to devices whose serial/udid contains the given substring. + Without it, connect attaches to *every* running device, so one slow or locked + device (typically a plugged-in physical iPhone whose app restart blocks) could + stall the whole session before any node connected. Verified end-to-end against + a single Android phone: `mix mob.connect --only ZY22CRLMWK` tunnels, restarts, + and connects `livebook_mob_android_zy22crlmwk@127.0.0.1` on its serial-derived + port 9633, then RPC into the device BEAM works (read live state, eval code). + +--- + +## [0.6.7] - 2026-06-18 + +### Fixed +- **`mix mob.connect` reliability — dist ports keyed by device serial, not run + index.** The Mac runs one shared EPMD; assigning ports as `9100 + index` meant + *every* project's first device claimed 9100, so two phones (or two projects' + device-0) registered the same port and `adb forward tcp:9100` could only reach + one — the other silently timed out. Ports are now derived from the device + serial (`Tunnel.serial_base_port/1`, a crc32 hash into 9100..9899) and bumped + past any port another live node/forward already holds (`assign_dist_port/2`). + A given phone always gets the same unique port across runs and projects, and + deploy and connect agree on it. `Tunnel.setup/2` → `setup/1` (port is now + serial-derived, not index-passed). +- **Stale-tunnel cleanup.** `Tunnel.setup` removes the device's own old forwards + first (scoped to that serial), so prior runs no longer leave duplicate/wrong + forwards that poison the next connect. +- **Real diagnostics on connect failure.** A timed-out node now reports *why* — + app not running / Standby-killed, dist never registered in EPMD, registered at + a different port, no forward, or cookie mismatch — instead of a black-box + "timed out". + +--- + +## [0.6.6] - 2026-06-18 + +### Fixed +- **Android native build skips ABIs the app's `build.zig` doesn't handle** + instead of hard-failing. mob_dev builds arm64-v8a/armeabi-v7a/x86_64 by + default, but an app's app-owned `build.zig` (copied at `mix mob.new` time) + may predate x86_64 support (mob_new < 0.4.5) and reject `-Dabi=x86_64`. That + used to fail the whole native build — aborting *before* the + `io.mob.plugin.MobPluginBootstrap` regen, so the next gradle build then failed + on an unresolved bootstrap. Now each ABI is pre-flighted against the build.zig + (`build_zig_supports_abi?/2`) and unsupported ones are skipped with a warning + (gradle `abiFilters` wouldn't ship them anyway). Real failures of a SUPPORTED + ABI still halt the build. + +--- + +## [0.6.5] - 2026-06-17 + +### Changed +- **Default OTP runtime → Elixir 1.20.1** (`@otp_hash` `7d46fdd4` → `5c9c69fc`). + Same OTP-29 / erts-17.0 / OpenSSL 3.4.0 base; the bundled Elixir stdlib + (elixir/logger/eex) is swapped rc.5 → 1.20.1 across all five tarballs + (android, android-arm32, android-x86_64, ios-sim, ios-device), published as + the `otp-5c9c69fc` release on GenericJam/mob. `bundled_versions.exs` adds the + `5c9c69fc` bundle and flips `active_hash`. Backward compatible — apps pick up + 1.20.1 on their next `mix mob.deploy` (recompile against the new runtime). + NOTE: the major.minor skew check treats rc.5 and 1.20.1 as both "1.20", so it + does NOT warn on this transition; the stdlib swap is what makes beams load. + +--- + +## [0.6.4] - 2026-06-16 + +### Added +- **Android x86_64 emulator support** (resolves GenericJam/mob#20). The `x86_64` + ABI is now wired throughout: `OtpDownloader.ensure_android("x86_64")` (#11), + the x86_64 native build path (`zig_build_android_objects`, `ensure_jni_libs`, + `otp_dir_for_abi`), and an `android_x86_64` target in `mix mob.release.otp` + plus the `scripts/release/*x86_64*` build scripts. The + `otp-android-x86_64-.tar.gz` runtime is published on the `otp-` + release. This is the slice x86_64 Linux / CI hosts need, where ARM emulation + isn't available. + +## [0.6.3] - 2026-06-16 + +### Fixed +- **exqlite NIF symlink now picks the device's actual ABI.** The Android + deployer hardcoded `lib/arm64` for the `sqlite3_nif.so` symlink target, so on + a 32-bit (`armeabi-v7a`) device the link dangled, exqlite was + `:nif_not_loaded`, and any generated app using ecto_sqlite3 crashed on boot. + It now probes `lib//libsqlite3_nif.so` (Android extracts only the active + ABI). Found + fixed verifying the showcase on a 32-bit Moto E — SQLite + migrations now run and the app boots. + +## [0.6.2] - 2026-06-15 + +### Fixed +- **Create an app-level driver_tab when a NIF-bearing plugin is active.** A + generated app ships no driver_tab and links against mob's core static-NIF + table — which has no plugin entries. So a plugin's `_nif_init` linked + but never registered, and the NIF was `:nif_not_loaded` on device (the home + rendered, the Kotlin/permission bridge worked, but the actual capability call + crashed). `regen_driver_tab!` now creates `priv/generated/driver_tab_*.zig` + (core + plugin entries) when plugins contribute NIFs and the app has none. + Found + fixed verifying the showcase app on a physical Android phone and the + iOS simulator (location demo now returns a real fix on both). + +## [0.6.1] - 2026-06-15 + +### Fixed +- **Prune orphaned plugin artifacts when a plugin is removed.** Plugin tier-3 + merges copy files into the host tree (bridge Kotlin into the Kotlin sourceSet, + migrations, images); these lingered after a plugin was dropped, and an + orphaned bridge `.kt` could break the Gradle compile. `NativeBuild`'s kotlin / + migration / image merges now ledger what they write (per concern, under + `priv/generated/.mob_plugin_artifacts/`) and delete what a prior build + produced but the current one no longer does — so add/remove of a plugin is + clean in both directions. + +## [0.6.0] - 2026-06-12 + +### Added +- **Style packages, tokens-only tier**: `MobDev.Style` (priv/mob_style.exs loader + validator), `config :mob, :styles`/`:default_style` activation, runtime-manifest emission (misconfiguration fails the build), and `mix mob.styles`. +- **`ui_components` `expand:` form honored** (pure-Elixir composites): validated native-XOR-expand; expand-only plugins classify tier 2 but hot-push; the runtime manifest carries `composites` for boot registration. +- **`mix mob.doctor`**: pre-plugin build.zig detection (missing `-Dplugin_*` options) and the host_requirements lane. +- **`host_requirements` manifest key** printed by every native build; `mix mob.new_plugin` scaffolds starter test suites for every tier. + +### Changed +- **Native builds auto-regenerate the static-NIF driver_tab** (was a checked-in artifact whose staleness produced runtime `:nif_not_loaded`). +- **`mix mob.regen_plugin_manifest` loads the host app first** — spec-v2 generators may call host modules (mob_ash), not just read config. + +### Fixed +- **Dep detection uses `Mix.Project.deps_paths`**, not stale `_build` dirs — ends the spurious MLX-404 downloads for apps that never dep emlx. +- ExSlop is registered as a credo PLUGIN (it had silently never run). + +## [0.5.17] + +### Added +- **`mix mob.new_plugin` scaffolds a starter test suite for every tier** (`test/test_helper.exs` + `test/_test.exs`): tiers 1–4 get stdlib-only structural manifest checks (required keys, NIF stub loadable, `native_dir` exists, screen modules compile) with a pointer at `mix mob.validate_plugin` for the full validator; tier 0 gets a compile smoke test. New plugins start covered instead of starting at zero tests. +- **Plugin `host_requirements` manifest key.** A plugin can declare human-readable host-app obligations the build can't automate (e.g. the AndroidManifest `` fragment mob_screencast needs, or a capture `FileProvider`). The manifest validator enforces the shape, `MobDev.Plugin.Merge.host_requirements/1` gathers them, and every native build prints them as a warning block — previously forgetting the manual step built + booted clean and only failed at first feature use. +- **`mix mob.doctor` detects pre-plugin build files.** When plugins are activated but a `build.zig`/`build_device.zig` declares no `b.option` for the `-Dplugin_*` flags the native build emits, doctor warns with the exact missing options — previously Zig rejected the unknown flag half a build in. + +### Changed +- **`mix mob.deploy --native` regenerates the static-NIF driver table on every build** (whatever formats the project uses, zig and/or c), exactly like the runtime plugin manifest: it's derived state. A stale checked-in `driver_tab_*` used to link a newly activated plugin's `_nif_init` without registering it, so every NIF call raised `:nif_not_loaded` at runtime with nothing pointing at the cause. + +### Added +- **`mix mob.new_plugin` scaffolds tiers 3 (multi-screen) and 4 (sub-app)**, not just 0–2. Tier 3 emits two `Mob.Screen` modules + a `:screens`/`:migrations` manifest + a namespaced Ecto migration; tier 4 emits a lifecycle module + supervised worker + notification handler + settings editor screen + a `:lifecycle`/`:settings`/`:notifications` manifest. Generated manifests validate and modules compile against real mob. +- **Cross-plugin conflict detection.** `MobDev.Plugin.Validator.conflict_surface/0` classifies every merge gatherer; `cross_validate/1` fails the build when two activated plugins clash on any shared resource — screen route, component atom, iOS/Android native view key, migration `repo_namespace`, NIF module, Swift/JNI source basename, Android bridge class, iOS plist key, supervised worker name, or notification match. A completeness meta-test forces every new shared-resource field to be classified; a property-based fuzzer checks detection is sound + complete across random N-plugin sets. A single plugin declaring a cross-platform NIF (one iOS + one Android entry sharing a `:module`) is correctly not flagged. + +### Changed +- **`mix mob.deploy --native` regenerates the runtime plugin manifest (`priv/generated/mob_plugins.exs`) on every build**, not only when `config :mob, :plugins` changes. Adding/changing a plugin's tier-3/4 sections previously shipped a stale manifest (the new sections silently didn't activate on device); it is now derived state, always rebuilt before bundling — like the driver table. + +### Fixed +- **iOS simulator deploy now boots from a clean `mix mob.deploy --native`** (was device-only). Three gaps made the sim deploy incomplete vs the device path, so the sim crashed on boot even though the device worked: + - The Elixir-distribution apps `elixir`/`logger` were staged only under `lib//ebin`, which the sim's `mob_beam.m` doesn't add to the code path — boot failed at `ensure_all_started(:elixir)` with "elixir.app not found". They're now flattened into the flat BEAMS_DIR alongside `eex` (which already needed this), where the path resolves. + - `priv/` was only partially staged (`repo/migrations`), so `Application.app_dir(:, "priv/cacerts.pem")` was `:enoent` and `Mob.Certs.load_cacerts!` crashed the boot. The whole `priv/` is now rsynced into the flat dir (cacerts, `mix`/`hex` ebins, vendored static, …), matching the device release. + - `Paths.sim_runtime_dir/0` fell back to `/tmp/otp-ios-sim` for zig-based projects (no `ios/build.sh`), but the runtime is synced to `~/.mob/runtime/ios-sim` — so the launcher and staging disagreed. It now recognizes `ios/build.zig` and returns the default runtime dir. + Verified: a clean `mob.deploy --native` to an iPhone 11 Pro Max sim boots Io, Phoenix endpoint up, embedded Livebook home renders — no manual runtime fixups. + +## [0.5.16] + +### Fixed +- **Gate the plugin flags on the iOS *device* build path too.** 0.5.15 gated the plugin-flag emission for Android and the iOS *simulator* build, but `zig_build_binary_ios_device` still emitted `-Dplugin_swift_files`/`-Dplugin_frameworks` (and generated the bootstrap, making `plugin_swift_files` always non-empty) unconditionally — so `mix mob.deploy --native` to a physical iPhone broke on an app scaffolded before the plugin system (`invalid option: -Dplugin_swift_files`). The device path now mirrors the sim path: bootstrap + flags only when plugins are activated. Verified `mix mob.deploy --native` to a physical iPhone — full OTP, Phoenix endpoint up, LiveView connected, embedded Livebook home rendered. + +## [0.5.15] + +### Fixed +- **`mix mob.release --android --no-slim` now actually ships the full OTP tree.** The Android release stripped OTP libs unconditionally (`OtpAssetBundle.build/2` was called with no opts), so `--no-slim` was silently ignored on Android. `slim` is now threaded `build_aab → OtpAssetBundle.build(slim:)`; with `slim: false` the OTP tree ships untouched. Required for apps that run arbitrary user code at runtime (e.g. an embedded Livebook host doing `Mix.install`) — stripping any OTP lib (`inets`, `ssl`, `xmerl`, `runtime_tools`, …) is a latent crash when a user's deps need it. Default stays `slim: true`. +- **iOS `--no-slim` release passes App Store validation.** The always-on Apple-policy strip cleared `erts-*/bin` and `priv/bin` but missed standalone executables inside OTP libs (e.g. `erl_interface/bin/erl_call`), which App Store validation rejects (90171). Now `lib/*/bin/*` executables are stripped too (always on), keeping every lib's `.beam`/`.app` — so a full-OTP `--no-slim` bundle is still Apple-compliant. +- **Native builds no longer break on pre-plugin app scaffolding.** `native_build.ex` emitted `-Dplugin_c_nifs`/`-Dplugin_zig_nifs`/`-Dplugin_jni_sources` (Android) and `-Dplugin_swift_files`/`-Dplugin_frameworks` (iOS) unconditionally, but an app scaffolded before the plugin system has no such options in its `build.zig` and Zig rejects the unknown `-D` flag. These flags (and the iOS plugin bootstrap) are now emitted only when plugins are activated; a plugin-aware `build.zig` defaults them to `""` so behaviour is unchanged there. + +## [0.5.14] + +### Fixed +- **iOS release: `erl_errno_id_unknown` shim written with a literal `\n`.** The weak-stub line in `release_device.sh` used `printf '%s\\n'` inside the `~S` (raw) heredoc, so bash received both backslashes and `printf` wrote a literal backslash-`n` into `erl_errno_id_compat.c` — clang then rejected the trailing `}\n` and `mix mob.release --ios` failed. Now `printf '%s\n'` (one backslash) emits a real newline. Regression guard added to `release_script_test`. +- **iOS release: clear preflight when `priv/generated/driver_tab_ios.c` is missing.** The release links a per-app static-NIF driver table, but the dev build uses the built-in Zig table and `mix mob.regen_driver_tab` defaults to Zig, so a project that never ran it with `--format c` died deep in `release_device.sh` with a cryptic `cc: no such file`. `build_ipa` now fails early with the exact command to run (`mix mob.regen_driver_tab --format c`). + +## [0.5.13] + +### Fixed +- **iOS deploy now ships the whole `priv/`, not just `priv/repo/migrations` + `priv/static`.** `MobDev.Release`'s iOS bundler copied only migrations and `priv/static`, so apps that bundle extra runtime assets under `priv/` — `:mix`/`:hex` ebins for on-device `Mix.install`, or a vendored library's own `priv/` (e.g. Livebook's `priv/static` + `priv/livebook`) — silently never reached the device. Now rsyncs all of `priv/`, matching the Android deployer. Unblocks on-device `Mix.install` and embedded Livebook on iOS. Verified on a physical iPhone: `priv/mix/ebin` (103 beams) and `priv/livebook/static` present on device, embedded Livebook serves, and `Mix.install([{:short_uuid, "~> 0.1"}])` returns `:ok`. + +## [0.5.12] + +### Changed +- **OTP runtime bumped to `7d46fdd4` (Elixir 1.20.0-rc.5).** `@otp_hash` now points at the `otp-7d46fdd4` release: all four platform tarballs (ios-sim, ios-device, android, android-arm32) bundle Elixir 1.20.0-rc.5 matched to the OTP-29 erts. Completes the 1.19.5 to 1.20 runtime migration the bundled-versions manifest was already staged for. Verified end-to-end on a physical iPhone and a physical Android (Moto G): `System.version` 1.20.0-rc.5, OTP 29, `Mix.install([{:short_uuid, "~> 0.1"}])` returns `:ok` with the dep compiled on-device. + +### Fixed +- **iOS `{spawn, }` now works** (erts `erts_open_driver`). The iOS-build `#ifdef __IOS__` guard returned BADARG for any `open_port` whose spawn_type included the EXECUTABLE bit (i.e. plain `{spawn, Name}`), firing before the linked-in-driver name lookup. This broke `ram_file` (`{spawn, "ram_file_drv"}`), and therefore `file:open(_, [:ram])`, `erl_tar` in-memory extract, `hex_tarball.unpack`, and `Mix.install` on iOS. The guard now fires only when no linked-in driver matched the name. Bundled in the `7d46fdd4` OTP tarballs. + +## [0.5.11] + +### Added +- **`mob.exs :project_swift_sources` config key** — optional list of extra Swift sources to compile into the iOS app module alongside Mob's bridge sources. Threaded into both `zig_build_binary_ios_sim` and `zig_build_binary_ios_device` as `-Dproject_swift_sources=`. Comma-containing entries are rejected at the boundary; nil/[] is a no-op. Pairs with mob_new's `project_swift_sources` build hook (mob_new#5). Originally proposed by @dl-alexandre. + +## [0.5.10] + +### Added +- **`mix mob.deploy --dist-port N` and `--node-suffix S` flags** — manual + override path for the BEAM-distribution surface. When set, all targeted + devices use the same value (use with `--device ` to be explicit). + Nil falls back to per-device auto-allocation + (`Tunnel.dist_port(idx)` + `Discovery.Android.device_node_suffix` / + SIMULATOR_UDID-derived suffix). Resolves the + `register/listen error: no_reg_reply_from_epmd` symptom seen when running + multiple sims/emulators of the same app concurrently for cross-platform + visual comparison. +- `MobDev.Device` struct gains a `:node_suffix` field for plumbing the + override per-device alongside `:dist_port`. Nil keeps auto-derive. +- `MobDev.Discovery.IOS.launch_app/3` accepts `:node_suffix` opt and + forwards as `SIMCTL_CHILD_MOB_NODE_SUFFIX` to the launched sim. Companion + to `mob 0.6.10`'s `MOB_NODE_SUFFIX` support in `mob_beam.m`. +- `MobDev.Discovery.IOS.build_simctl_env/2` — pure helper extracted from + `launch_app/3` so override behaviour is unit-testable without spawning + `simctl`. 7 new tests cover dist_port + node_suffix override paths. + +### Changed +- `MobDev.Connector.restart_app/1` pattern-matches `:node_suffix` from + `Device` in both Android + iOS-sim variants, threading the value to the + launchers. +- `MobDev.Deployer.deploy_all/1` accepts top-level `:dist_port` + + `:node_suffix` opts; threaded through `deploy_android` and + `deploy_ios_simulator`. + +## [0.5.9] + +### Changed +- `mix mob.enable tflite` now injects `{:nx_tflite_mob, "~> 0.0.3"}` + (Hex) instead of the GitHub-branch form. `nx_tflite_mob` v0.0.3 went + live on Hex with 16 integration tests + a reproducible Mac host + build path (see + [its CHANGELOG](https://github.com/GenericJam/nx_tflite_mob/blob/main/CHANGELOG.md)). + Downstream Mob apps now get version-pinned deps + clean + `mix deps.tree` output, instead of a transient `github:` checkout. + +### Notes +- The Mac host-build path in `nx_tflite_mob` is for that package's own + test suite, not for downstream consumers — production phone builds + via `mix mob.deploy --native` continue to use the prebuilt Android + AAR + iOS xcframework that mob_dev's `MobDev.TfliteDownloader` + fetches. + +## [0.5.8] + +### Added +- **End-to-end `mix mob.enable tflite`** — what 0.5.7 promised as + "lands in 0.5.8". `MobDev.NativeBuild` now auto-detects the + `:nx_tflite_mob` dep and threads the full TFLite path through + Android + iOS sim + iOS device build pipelines: + - `maybe_build_tflite/1` → `MobDev.TfliteDownloader.ensure/1` + + `MobDev.TfliteNif.build/2` for each target arch + - `tflite_zig_args_android/1` emits `-Dtflite_static=true + -Dtflite_lib=…` for the per-ABI Android link + - `tflite_zig_args_ios/1` emits `-Dtflite_static=true + -Dtflite_dir=… -Dtflite_framework_dir=…` for the iOS link + - `copy_tflite_runtime_lib_android/2` drops + `libtensorflowlite_jni.so` into `android/app/src/main/jniLibs//` + during the assemble step +- `copy_tflite_frameworks_ios/3` (kept as future-compat hook) — see + the iOS-deploy-fix gotcha below +- 13 new tests covering the public NativeBuild plumbing + (`native_build_tflite_test.exs`), bringing the TFLite suite to 76 + passing total + +### Fixed +- **iOS deploy: TFLite framework binaries are MH_OBJECT, not + MH_DYLIB.** TFLite's iOS xcframework slices ship their binaries as + filetype=1 relocatable objects, which the linker statically pulls + into the app's main Mach-O at build time. Trying to embed them as + runtime `.framework` bundles tripped iOS install twice during this + cut: first on missing per-framework Info.plist (which CocoaPods + generates), then on "code signature version no longer supported" + (iOS 26+ rejects v1 signatures, and codesign only makes v3 sigs + for MH_EXECUTE/MH_DYLIB). The fix is to do nothing — the framework + search-path arg already covers everything at build time. +- **Resolve `:nx_tflite_mob` via `Mix.Project.deps_paths()`** rather + than `Path.join(deps_path, "nx_tflite_mob")`. The latter assumes + the dep landed in `deps/` (hex / git deps do), but `path:` deps + consume in-place from the user's source tree. + +### Verified on real hardware +- Moto G Power 5G (BXM-8-256, Android 15): 75-117 ms YOLOv8n via + NNAPI / `mtk-gpu_shim` +- iPhone SE 3rd gen (A15, iOS 26.4): 24 ms YOLOv8n via Core ML → ANE + (FP16 model; 214/385 nodes delegated) + +## [0.5.7] + +### Added +- `mix mob.enable tflite` — wires TensorFlow Lite into a Mob project on + iOS AND Android. Adds `{:nx_tflite_mob, ...}` to deps and generates + `.TfliteInit` (returns per-platform default delegate opts — + NNAPI/`mtk-gpu_shim` on Android, Core ML delegate on iOS). The + static-NIF table entry `%{module: :tflite_nif, guard: "MOB_STATIC_TFLITE_NIF"}` + is registered in `MobDev.StaticNifs.default_nifs/0`, so the zig + build picks it up automatically once `tflite_static=true` is set. +- `MobDev.TfliteDownloader` — fetches `tensorflow-lite-2.16.1.aar` + (Maven Central, Android) and `TensorFlowLiteC-2.17.0.tar.gz` + (dl.google.com, iOS) into `~/.mob/cache/`. Honours `MOB_CACHE_DIR` + for test redirection and `MOB_TFLITE_LOCAL_TARBALL_DIR` for offline + iteration. +- `MobDev.TfliteNif` — cross-compiles `tflite_nif.c` (from the + `:nx_tflite_mob` dep) per-arch and archives as `libtflite_nif.a` for + static linking. Mirrors `MobDev.NxEigenNif` shape. Validates the + produced symbol (`tflite_nif_nif_init`) before declaring success. + +Bundle size impact: ~3-4 MB extracted (Android `libtensorflowlite_jni.so`), +~20-30 MB on iOS (TensorFlowLiteC + CoreML + Metal frameworks). Apps +that don't enable TFLite pay zero size cost — the guard keeps the +static-NIF table entry inactive. + +End-to-end deploy (`mob.deploy --native` auto-build + runtime-lib +embedding) lands in 0.5.8; this release ships the building blocks. + +## [0.5.6] + +### Added +- `CLAUDE.md` "Release flow" section pointing at the canonical process + in [`mob/RELEASE.md`](https://github.com/GenericJam/mob/blob/master/RELEASE.md) + (URL form so it resolves without a local mob checkout). mob_dev + specifics: the pre-push hook additionally runs `mix mob.security_scan` + here (this is the only repo that ships the scanner), and the OTP + tarball workflow stays separate from `mix.exs` version bumps. +- `.githooks/pre-push` — same script shipped in mob (cheap preflight + always, release preflight when `mix.exs` changed). The + `mob.security_scan` step is gated via `mix help` availability so the + same hook script works in all three repos. + +## [0.5.5] + +### Fixed +- Android 15 segfault on launch (Pixel 7+, after the OS rolled out via OTA). Bumps `@otp_hash` from `550d7b78` → `d9045670` to pick up OTP tarballs cross-compiled with `-Wl,-z,max-page-size=16384`. Without the flag, every `.so` in the bundled OTP runtime (`crypto.so`, `asn1rt_nif.so`, `dyntrace.so`, etc.) shipped with 4KB-aligned ELF `PT_LOAD` segments. Android 15 enforces 16KB alignment on devices with 16KB-page kernels and refuses to load misaligned libs, crashing the app at startup. New tarballs are 16KB-aligned (`Align=0x4000`). + +## [0.5.4] + +### Fixed +- HexDocs source links pointed at the non-existent `main` branch — corrected to `master` so each `` glyph in generated docs opens the actual source file. + +### Added +- `.github/workflows/test.yml` — runs `mix test`, `mix format --check-formatted`, `mix credo --strict`, and `mix mob.security_scan` 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. + +## [0.5.3] + +### Changed +- `guides/nifs.md` — rewrote the "Nx backends on mobile" section to match the current state (`mix mob.enable nxeigen` now real, `mix mob.enable mlx` includes the Metal GPU path on iOS device, EXLA "why not" preserved). +- `guides/nifs.md` — restructured the multi-Rust-NIF section to lead with [filmor's](https://github.com/rusterlium/rustler/issues/686) preferred shape (one Rustler crate per app, multiple `#[rustler::nif]` functions inside it). Multi-crate static linking remains supported and documented as an escape hatch, with the specific tradeoffs called out. + +## [0.5.2] + +### Added +- `mix mob.enable nxeigen` — wires NxEigen (Eigen C++ CPU backend) into a Mob app. Builds as a C++ `:static_nifs` entry, cross-compiled per arch (`arm64-ios`, `arm64-iossim`, `arm64-android`, `armv7a-android`). FFT support uses Eigen's bundled kissfft. +- EMLX Metal GPU enabled on iOS device. `lib/mob_dev/mlx_downloader.ex` now fetches the Metal-enabled `libmlx.a` + `mlx.metallib` bundle; `lib/mob_dev/native_build.ex#maybe_bundle_mlx_metallib/2` copies the precompiled kernel library into the .app at build time, so `EMLX.Backend` with `device: :gpu` works on device without runtime kernel compilation. +- `scripts/release/mlx/ios_device_metal.sh` + supporting build scripts for producing the Metal-enabled tarball; `scripts/release/mlx/patches/0001-ios-metal-build.patch` patches MLX 0.25.1's CMakeLists to switch SDK from `macosx` to `iphoneos` based on `CMAKE_SYSTEM_NAME`. + +## [0.5.1] and earlier + +Earlier releases predate this changelog; consult the [tag list](https://github.com/genericjam/mob_dev/tags) and the per-tag commit messages for history. diff --git a/CLAUDE.md b/CLAUDE.md index 7246453..2150524 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,84 @@ # mob_dev — Agent Instructions +**Read [`AGENTS.md`](AGENTS.md) first**, then [`~/code/mob/AGENTS.md`](../mob/AGENTS.md) +for the system view. They cover repo topology, public-but-undocumented +seams (parsers/predicates kept public for testing), and the cross-repo +pre-empt-failure rules. This file goes deeper on Claude Code-specific +workflow. + +> **Keep AGENTS.md up to date** when you add a public seam, change a +> convention, or hit a gotcha that should have been on the list. Same +> commit as the change — not a follow-up. + +For the in-flight build-system refactor (Mix → Igniter → Zig build), +see [`~/code/mob/build_system_migration.md`](../mob/build_system_migration.md) — +multi-month sequenced plan; phase ownership lives there. + +## Worktrees + +**Default assumption: work happens in a git worktree.** The user runs +multiple agents in parallel; each task in its own worktree prevents conflicts +between agents and keeps `master` clean while work is in flight. + +If you're assigned a task and worktree usage **isn't mentioned**, ask: + +> "Should I use a worktree for this?" + +The user will answer: + +- **yes** — long task, or other agents may be working in parallel; create a + worktree (use `EnterWorktree` or spawn the work via Agent with + `isolation: "worktree"`) +- **no** — quick change with no parallel agent work; work in-place on the + current branch + +If the user explicitly says "use worktrees" up front, do so without asking. +If the task is trivially small (single-file doc edit, one-line config change) +and clearly won't conflict with anything, working in-place is acceptable — +but if in doubt, ask. + +## Recurring gotchas — read these before debugging device issues + +**iOS sim launches, BEAM dies fast, sim returns to home screen.** Almost +always a host-port collision with `adb`, not a BEAM bug. When an Android +device is connected, `adb forward tcp:9100 tcp:9100` binds `127.0.0.1:9100` +on the Mac. iOS sims share the Mac's network stack, so `MobDev.Tunnel.dist_port(0) += 9100` is already taken. The OTP boot exits cleanly on `eaddrinuse` and there +is no crash report. First diagnostic: + +```bash +lsof -nP -iTCP:9100-9199 -sTCP:LISTEN | grep adb +``` + +…and read `Documents/beam_stdout.log` inside the sim's app container — look +for `Protocol 'inet_tcp': register/listen error: eaddrinuse`. Workaround: +`mix mob.deploy --device --dist-port 9200`. Full writeup in +`guides/troubleshooting.md` ("iOS simulator: BEAM dies silently…"). This +trap has bitten the iOS sim path several times — Android tooling and iOS +sims compete for the same `127.0.0.1` namespace; check host-port collisions +before suspecting sim or BEAM bugs. + +**iOS sim stuck on "Starting BEAM…" forever.** Read +`beam_stdout.log` inside the sim's Documents dir. If you see: + +``` +step 2 => {error,{"no such file or directory","elixir.app"}} +step 5 => {error,undef} +``` + +…it's a runtime-path mismatch. `MobDev.Paths.sim_runtime_dir/0` falls back +to `/tmp/otp-ios-sim` when `ios/build.sh` is missing (zig-based iOS builds), +but the build syncs OTP + Elixir stdlib to `~/.mob/runtime/ios-sim`. Workaround +when launching manually: + +```bash +SIMCTL_CHILD_MOB_SIM_RUNTIME_DIR="$HOME/.mob/runtime/ios-sim" \ + xcrun simctl launch com.example. +``` + +Real fix: `sim_runtime_dir/0` should detect `ios/build.zig` and use +`default_runtime_dir()` for it, so build and launch agree. + ## TDD is the practice here Write tests before or alongside new code. Every new function should have @@ -11,6 +90,107 @@ mix test # run all tests mix test --watch # (with mix_test_watch dep, if added) ``` +**Tests are not just for runtime code.** Every Mix task and every build +tool in this repo gets the same treatment as application code: + +- Argument parsing, flag handling, `--help` output +- Output formatting (preview, summary, error messages) +- Decision logic (which device, which build target, which strip set) +- External-tool output classification (adb, simctl, devicectl, gh, xcrun) + +The goal is to **find bugs in CI before users hit them.** Real failure +modes encountered this session that were caught (or should have been +caught) by tests: + +- `mix mob.uninstall --all-devices` crashing on `nil and bool` because + the test suite only covered `--help` and `format_summary/4`, not the + decision path. Backfilled `should_skip_prompt?/2` as a pure helper. +- `mix mob.deploy --device defd4bdc` passing the prefix straight to + `xcrun simctl install` which only accepts full UDIDs. Now + `NativeBuild.resolve_booted_udid/2` is pure-and-tested. +- The "Failed on 5 device(s)" mis-tally when skipped-not-installed + was bucketed as failed. Caught only by manual driving until + `format_summary/4` and `categorize_results/1` got extracted. + +**Pattern to apply:** + +1. Identify the pure decision/transform inside a Mix task or + build-tool function. +2. Extract it to a `def` (not `defp`) — `@doc false` if it's + for-testing-only, or fully documented if useful to callers. +3. Test the matrix: happy path, every error branch, edge cases + surfaced by real-world output (paste actual `adb` / + `xcrun` / `gh` output into fixtures rather than guessing format). +4. The Mix task and external-tool I/O wrappers stay thin and + unstubbed; the testable kernel is what you assert on. + +If something in mob_dev isn't tested today, that's a bug-discovery +opportunity in waiting — list it as a follow-up rather than letting +the next user find it. + +## Pre-commit checklist + +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** — 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 priv/android/crypto.erl # Erlang formatting +mix mob.security_scan --strict # surface new CVEs / drift before they ship +``` + +Available but **not run by default** (refactoring queues, not blockers): + +```bash +mix ex_dna # code duplication report (22 clones baseline, ~581 dup lines) +mix reach.check --smells # 132 style/refactor findings +mix reach.check --dead-code # 71 findings (some macro DSL false positives) +mix reach # interactive HTML architecture report +``` + +Auto-fix: +```bash +mix erlfmt --write priv/android/crypto.erl +``` + +`mix mob.security_scan` covers Hex deps, Android Gradle deps, iOS +Swift Package deps, the **bundled OpenSSL/OTP/Elixir/SQLite versions** +(via fingerprint of `~/.mob/cache/otp-*-{hash}/` against +`priv/security/bundled_versions.exs`), and C/Kotlin/Swift static +analysis. See [`README.md`](README.md#security-scan-mix-mobsecurity_scan) +for the full layer list and the one-time `brew install` of external +scanners. + +## Release flow + +Canonical process lives in +[`mob/RELEASE.md`](https://github.com/GenericJam/mob/blob/master/RELEASE.md) +— trigger model (mix.exs as source of truth), patch-bump default with +mandatory permission, CHANGELOG conventions, per-step idempotency of +`release.yml`. **mob_dev specifics:** + +- The pre-push hook (below) additionally runs `mix mob.security_scan` + in this repo — the scanner ships from here, so we get the + highest-fidelity check before pushing. +- OTP runtime tarballs (`otp-` releases on the `mob` repo) are + built and published manually via `scripts/release/` — they are NOT + driven by `mix.exs` bumps. See `## Releasing a new OTP runtime` + below for the tarball workflow. The `mix.exs` bump that ships a + `@otp_hash` change in `lib/mob_dev/otp_downloader.ex` follows the + standard release flow. + +**Pre-push hook**: `.githooks/pre-push` runs `mix format +--check-formatted`, `mix credo --strict`, `mix compile +--warnings-as-errors` on every push (fast). When the push touches +`mix.exs` it additionally runs the full test suite + `mix +mob.security_scan` as the release preflight. Activate once per clone +or worktree: + +```bash +git config core.hooksPath .githooks +``` + ## What to test **Always testable (pure functions, no hardware):** @@ -67,4 +247,137 @@ updating the hash in `otp_downloader.ex`). - `lib/mob_dev/icon_generator.ex` — robot avatar generation + platform icon resizing - `lib/mix/tasks/mob.new.ex` — `mix mob.new APP_NAME` - `lib/mix/tasks/mob.icon.ex` — `mix mob.icon [--source PATH]` +- `lib/mix/tasks/mob/adopt.ex` — `mix mob.adopt` orchestrator (install Mob into an existing Phoenix project) +- `lib/mix/tasks/mob/adopt/` — the adopt sub-installers (`deps`, `bridge`, `screen`, `mob_app`, `mob_exs`, `native[/android,/ios]`, `finalize`) +- `lib/mob_dev/adopt_guard.ex` — `MobDev.AdoptGuard`, the pre-1.0 detect-and-refuse for `mob.adopt` +- `lib/mob_dev/adopt/patcher.ex` / `lib/mob_dev/adopt/generator.ex` — `MobDev.Adopt.{Patcher,Generator}`, the shared LV-bridge patches + EEx assigns/dep-resolution (duplicated from mob_new; see the adopt ADR) - `priv/templates/mob.new/` — EEx templates for generated project files + +## Connecting an IEx session to a running mob app (Mac → device BEAM) + +Drive any running mob app from a Mac-side IEx via Erlang +distribution. Beats `adb shell input tap` for anything +state-related — you get full RPC into the device BEAM. + +### The happy path (single device) + +```bash +cd /path/to/your_mob_app + +mix mob.connect # starts IEx connected to all devices +# or +mix mob.connect --no-iex # sets up tunnels, prints node names, exits +``` + +Then from any other IEx (or one-shot script) on the Mac: + +```bash +elixir --name probe@127.0.0.1 --cookie mob_secret -e ' +node = :"your_app_android_@127.0.0.1" +Node.connect(node) +:rpc.call(node, YourApp.Module, :function, [args]) +' +``` + +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 — node naming (FIXED 2026-05-28, commit `7497f4b`) + +`mob_dev` now derives the Android dist node-name suffix from the device +**serial** (matching what `Mob.Dist` actually registers), not the IP. +Emulators get distinct suffixes like `emulator_5554` / `emulator_5556`, +so two emulators no longer collide in EPMD. The bug was in +`discovery/android.ex` `enrich/1` — it had a duplicated half-implementation +of `device_node_suffix/1` that was IP-based, while the correct serial-based +helper already existed and was used in `restart_app/4`. See ADR +`decisions/2026-05-28-android-node-name-by-serial.md`. + +Physical (USB/Wi-Fi) Android is unchanged: still keyed off `ro.serialno`. +iOS untouched. + +### Dist ports are serial-derived (FIXED 0.6.7, 2026-06-18) + +Dist ports are no longer assigned by per-run index (which made *every* +project's first device claim 9100 → cross-project collisions in the one +shared Mac EPMD → silent timeouts). `Tunnel.serial_base_port/1` maps a +device serial to a stable port in `9100..9899` (crc32 hash), bumped past +any port a live node/forward already holds (`assign_dist_port/2`). The +device-side BEAM listens on that same port (via `MOB_DIST_PORT`), so the +forward is 1:1 and EPMD's broadcast matches. `setup` also removes the +device's own stale forwards first. A given phone always gets the same +unique port across runs/projects, and deploy + connect agree on it. + +If `mix mob.connect` still fails, it now tells you *why* (app not running / +Standby-killed, dist not registered, port mismatch, no forward, cookie +mismatch) instead of a bare "timed out". To inspect by hand: + +```bash +epmd -names # registered nodes + their ports +adb forward --list # host→device forwards (should be 1:1, no dupes) +``` + +For physical-device-on-Wi-Fi targets (iPhone, real Android), the +node name uses the device IP directly (`@10.0.0.120`) and dist +goes through real network — no adb-forward dance required. + +### Inspecting state that contains opaque resources + +Several mob/Pigeon operations return values containing opaque NIF +resources (e.g. `Pythonx.Object`, ETS table refs). These cannot +cross Erlang distribution: `:rpc.call/4` will fail with `:badrpc` +on the way back. Pattern: do the resource-touching work *on the +device side* and return primitives (strings, maps, ints). + +Example — bad (returns `Pythonx.Object`, dies on dist boundary): + +```elixir +:rpc.call(node, Pythonx, :eval, [src, %{}]) # returns {Pythonx.Object, _}; cannot serialize +``` + +Good — wrap in a helper module compiled into the app: + +```elixir +defmodule YourApp.IexHelpers do + def python_state do + {obj, _} = Pythonx.eval("...", %{}) + Jason.decode!(Pythonx.decode(obj)) # plain map; safe to ship + end +end +``` + +Then `:rpc.call(node, YourApp.IexHelpers, :python_state, [])` works. +Pigeon has `Pigeon.IexHelpers` exactly for this purpose — copy +that pattern when adding device-side debugging surfaces. + +### What to reach for first + +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/README.md b/README.md index e006665..be2eb2f 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,25 @@ end | Task | Description | |------|-------------| | `mix mob.new APP_NAME` | Generate a new Mob project (see `mob_new` archive) | +| `mix mob.adopt` | Install Mob into an **existing** Phoenix project (Igniter-based; composes `mob.adopt.{deps,bridge,screen,mob_app,mob_exs,native,finalize}`). The install-into-existing counterpart to `mix mob.new` | | `mix mob.install` | First-run setup: download OTP runtime, generate icons, write `mob.exs` | | `mix mob.deploy` | Compile and push BEAMs to all connected devices | | `mix mob.deploy --native` | Also build and install the native APK/iOS app | +| `mix mob.deploy_lock --device ID` | Inspect one exact Android deploy lease; optionally clean only a verified committed tombstone | +| `mix mob.deploy --slim` | Same, but with the App Store strip pass applied (slow, lets you verify a slim build before TestFlight — see [`guides/slim_release.md`](guides/slim_release.md)) | +| `mix mob.release` | Build a signed `.ipa` / `.aab` for App Store / TestFlight / Play Store (slim by default) | +| `mix mob.release --security-gate` | Same, but runs `mix mob.security_scan` first and aborts on any critical/high/medium finding ([details](guides/security_scan.md)) | +| `mix mob.audit_otp` | Reachability audit of the bundled OTP runtime (find strip candidates) | +| `mix mob.security_scan` | Scan for known CVEs across every surface — Hex, Gradle, Swift, bundled OpenSSL/OTP/SQLite, C/Kotlin/Swift source ([details](guides/security_scan.md)) | +| `mix mob.security_scan.log` | Scheduled-run wrapper: writes `SECURITY_SCAN.md` + appends to `SECURITY_HISTORY.md` for cron / GitHub Actions ([details](guides/security_scan.md)) | | `mix mob.connect` | Tunnel + restart + open IEx connected to device nodes (`--name` for multiple sessions) | | `mix mob.watch` | Auto-push BEAMs on file save | | `mix mob.watch_stop` | Stop a running `mix mob.watch` | | `mix mob.devices` | List connected devices and their status | | `mix mob.push` | Hot-push only changed modules (no restart) | +| `mix mob.enable <feature>...` | Wire up an optional Mob feature — platform-manifest entries, Elixir stubs, dep injections ([see below](#mix-mobenable-feature)) | +| `mix mob.add_nif <name>` | Scaffold a statically-linked NIF — Elixir stub + `mob.exs` `:static_nifs` append + optional native skeleton ([see below](#mix-mobadd_nif-name)) | +| `mix mob.regen_driver_tab` | Regenerate `priv/generated/driver_tab_{ios,android}.zig` from `mob.exs`'s `:static_nifs` (default; pass `--format c` for the hand-editable C variant; composed automatically into `mob.add_nif`) | | `mix mob.server` | Start the dev dashboard at `localhost:4040` | | `mix mob.icon` | Regenerate app icons | | `mix mob.routes` | Validate navigation destinations across the codebase | @@ -79,10 +90,127 @@ Pushing 14 BEAM file(s) to 2 device(s)... iPhone 15 Pro → pushing... ✓ (dist, no restart) ``` -If dist is not reachable (first deploy, app not running), it falls back to `adb push` + restart. Mixed deploys work — one device can hot-push while another restarts. +If every frozen Android target is already reachable over distribution, the +whole exact set hot-pushes. Otherwise the whole Android set uses the fenced +`adb push` + restart path; Mob never splits one Android transaction across two +authorities. A mixed iOS/Android command handles each platform in its own +ordered, committed phase. **Requirements:** The app must call `Mob.Dist.ensure_started/1` at startup, and the cookie must match the one in `mob.exs` (default `:mob_secret`). +### Android native updates preserve app data + +`mix mob.deploy --native --android` is deliberately update-only. Every selected +device must already contain the configured package, and Mob uses only the +serial-scoped equivalent of `adb install -r`. It never clears app data, +uninstalls the package, or turns a rejected update into a fresh install. + +Before the first device write, Mob snapshots and verifies the exact APK, OTP, +BEAM, `priv`, and optional exqlite payloads. A phase-bound lease covers the +sorted canonical device set so a concurrent deploy or hot push cannot change a +subset mid-transaction. The lease advances only after the native payload and +then the final authoritative BEAM/restart pass have each completed on every +target. Replayed, widened, stale, or wrong-phase work fails closed. + +For a mixed Android+iOS native command, Android is deliberately serialized +first: it must commit, release its exact-set lease, and clean its immutable +staging before iOS build/install begins. A typed result that proves Android was +not attempted may continue to iOS; every failed, retained, malformed, or +ambiguous Android result suppresses iOS. Fast Android BEAM deploys are also +exact-set transactions. + +If transport authority becomes ambiguous after a write, Mob intentionally +retains the device-side lease or release tombstone and stops later targets. Do +not recover by uninstalling the app or deleting its data. Inspect the bounded +lease status, resolve the interrupted operation, and remove only a verified +committed release tombstone; an active or malformed lease requires manual +diagnosis. + +```sh +mix mob.deploy_lock --device <exact-adb-serial> +mix mob.deploy_lock --device <exact-adb-serial> --cleanup-committed +``` + +The first command is read-only. The second refuses every state except one exact, +record-only tombstone that already carries a committed phase, and proves the +device returned to a clear state after the single cleanup attempt. + +## `mix mob.enable <feature>` + +Wires up an optional Mob feature in one command — platform-manifest +entries, Elixir stubs, and dep injections, all rolled into a single +Igniter diff that's shown before any file is touched. Multiple +features in one invocation are fine; the diff covers all of them. + +```bash +mix mob.enable camera # iOS Info.plist + Android <uses-permission> +mix mob.enable camera photo_library # multiple features in one diff +mix mob.enable file_sharing # iOS plist keys + Android FileProvider XML +mix mob.enable location # iOS plist + Android ACCESS_FINE_LOCATION +mix mob.enable notifications # creates ios/<app>.entitlements with aps-environment +mix mob.enable liveview # generates lib/<app>/mob_screen.ex + assets + mob.exs +mix mob.enable pythonx # adds :pythonx dep + generates <App>.PythonPaths +``` + +Per-feature surface: + +| Feature | iOS | Android | Elixir | +|----------------|--------------------------------------------------|----------------------------------------------------------|-------------------------------------------------| +| `camera` | `NSCameraUsageDescription` in Info.plist | `<uses-permission android.permission.CAMERA>` | — | +| `photo_library`| `NSPhotoLibraryAddUsageDescription` in Info.plist| (none — API 29+ runtime-only) | — | +| `location` | `NSLocationWhenInUseUsageDescription` | `ACCESS_FINE_LOCATION` permission | — | +| `file_sharing` | `UIFileSharingEnabled` + `LSSupports…` plist keys| `<provider FileProvider>` + `res/xml/file_provider_paths.xml` | — | +| `notifications`| Creates `ios/<app>.entitlements` with `aps-environment` | (runtime-only — request `POST_NOTIFICATIONS`) | (none) | +| `liveview` | (none) | `networkSecurityConfig` allowing loopback | Generates `<App>.MobScreen`; injects `MobHook` into `assets/js/app.js` + bridge element into `root.html.heex`; sets `:liveview_port` in `mob.exs` | +| `python` | (handled by `mob.deploy --native`) | (handled by `mob.deploy --native`) | Adds `{:pythonx, "~> 0.4"}` to mix.exs via AST; generates `<App>.PythonPaths` | + +Idempotent — re-running with already-applied features is a no-op. +Diff preview surfaces every change before commit; missing platform +dirs (`ios/`, `android/`, `assets/`) become notices instead of +silent skips. + +## `mix mob.add_nif <name>` + +Scaffolds a statically-linked NIF in one command. Picks up the +[StaticNifs](`MobDev.StaticNifs`) schema, drops native + Elixir +templates appropriate to the chosen backend, appends the entry to +`mob.exs`, and re-runs `mix mob.regen_driver_tab` so +`priv/generated/driver_tab_{ios,android}.zig` reflects the new entry — +all visible as a single Igniter diff before commit. + +```bash +mix mob.add_nif audio_engine # default --type elixir-only (you write the C) +mix mob.add_nif audio_engine --type c # also drops c_src/audio_engine.c +mix mob.add_nif audio_engine --type zigler # use Zig (~Z sigil) — adds :zigler dep +mix mob.add_nif audio_engine --type rustler # use Rust — adds :rustler dep + native/audio_engine/ Cargo crate +mix mob.add_nif audio_engine --module MyApp.Audio # custom Elixir module name +``` + +Always created: + +- `lib/<app>/nifs/<name>.ex` (or `<your-module>.ex`) — Elixir stub. Each + function returns `:erlang.nif_error(:nif_not_loaded)` so a missing + native side errors loudly instead of silently returning a stub value. +- `mob.exs` — `:static_nifs` list under `config :mob_dev,` gains + `%{module: :<name>, archs: [:all]}`. Idempotent — re-running with the + same name leaves the list intact. + +Conditional, per `--type`: + +| `--type` | Extra files | Hex deps added | +|--------------|-----------------------------------------------------|----------------| +| `elixir-only` (default) | none — you write the C and wire it yourself | none | +| `c` | `c_src/<name>.c` (skeleton with `ERL_NIF_INIT`) | none | +| `zigler` | none (Zig source lives inline in the stub via `~Z`) | `:zigler ~> 0.15` | +| `rustler` | `native/<name>/{Cargo.toml,src/lib.rs,.gitignore}` | `:rustler ~> 0.32` | + +For the contract per backend — how Rustler, Zigler, Pythonx normally +work, what Mob changes for static linking, where the bundled Python +runtime comes from on each platform, and which patches are +transient — see [`guides/nifs.md`](guides/nifs.md). Read it before +filing a "my NIF builds host-dev but not on device" issue; it +probably answers the question. + ## Navigation validation (`mix mob.routes`) Validates all `push_screen`, `reset_to`, and `pop_to` destinations across `lib/**/*.ex` via AST analysis. Module destinations are verified with `Code.ensure_loaded/1`. @@ -103,13 +231,143 @@ mix mob.routes --strict # exit non-zero (for CI) Dynamic destinations (`push_screen(socket, var)`) and registered name atoms (`:main`) are skipped with a note. +## Security scan (`mix mob.security_scan`) + +Audits a Mob app for known CVEs across **every surface a Mob app actually +ships** — including the bundled OpenSSL, OTP runtime, and SQLite that +ordinary scanners can't see (because they're statically linked into the +app binary, not declared in any lockfile). + +### What it scans + +| Layer | Tool(s) | Covers | +| ----- | ------- | ------ | +| `hex_deps` | [`mix_audit`](https://hexdocs.pm/mix_audit/) + [`osv-scanner`](https://google.github.io/osv-scanner/) | Hex dependencies in `mix.lock` | +| `gradle_deps` | `osv-scanner` | Android Gradle dependencies (when `gradle.lockfile` is enabled) | +| `swift_deps` | `osv-scanner` | iOS `Package.resolved` / `Podfile.lock` | +| `bundled_runtime` | `BundledVersions` manifest + binary fingerprint | OpenSSL, ERTS, Elixir, exqlite, SQLite *baked into the OTP tarball* — drift detection between the manifest and the actual binaries | +| `c_source` | [`semgrep`](https://semgrep.dev/) + [`flawfinder`](https://dwheeler.com/flawfinder/) | Mob's NIF C/Objective-C plus the exqlite NIF wrapper | +| `kotlin_source` | [`detekt`](https://detekt.dev/) | Kotlin/Java under `android/app/src/main/` | +| `swift_source` | [`swiftlint`](https://github.com/realm/SwiftLint) | Swift under `ios/` | + +The bundled-runtime layer is what makes this task interesting — it +opens `libcrypto.a` from the cached OTP tarball and reads the OpenSSL +version banner directly out of the static archive. Generic dep +scanners can't do this because the OpenSSL version isn't in any +lockfile. See [`priv/security/bundled_versions.exs`](priv/security/bundled_versions.exs) +for the manifest of what versions ship in each tarball. + +### Usage + +```bash +mix mob.security_scan # full scan, pretty terminal output +mix mob.security_scan --json # machine-readable JSON to stdout +mix mob.security_scan --skip kotlin,c_source # skip named layers +mix mob.security_scan --strict # exit 1 if any high+ finding +mix mob.security_scan --write-report SECURITY_SCAN.md # also write a markdown report +``` + +### One-time tool installs + +Each layer soft-degrades when its scanner isn't installed. Install on +macOS with: + +```bash +brew install osv-scanner semgrep flawfinder detekt swiftlint +``` + +`mix_audit` is a Hex dependency of `mob_dev`; no separate install +needed. The OpenSSL/SQLite/OTP fingerprinting is pure Elixir — no +external `strings(1)` or similar required. + +### Scheduled changelog (`mix mob.security_scan.log`) + +For "did we get better or worse this week?" you want a *changelog*, +not a snapshot. `mix mob.security_scan.log` is the scheduled-run +companion: each invocation writes three files at the project root: + +| File | Purpose | +| ---- | ------- | +| `SECURITY_SCAN.md` | Current-state snapshot (overwritten each run). The "what's the situation right now" file. | +| `SECURITY_HISTORY.md` | Append-only changelog. Each run prepends one entry: timestamp, severity counts, and the **New / Resolved / Still present** delta against the previous run. Findings still present from earlier runs carry their `first seen N days ago` patch-lag suffix. | +| `.security_scan/state.json` | Internal sidecar that records the last-known finding set + per-finding `first_seen_at` timestamps. Diff computation depends on it. | + +**Commit all three.** The state file is what makes the changelog +meaningful across machines and CI runs — without it, every run +reports every finding as "new" and the timeline loses signal. + +A typical entry looks like: + +```markdown +## 2026-05-07T13:59:24Z + +**Project:** `/path/to/app` +**Total findings:** 2 (0 critical, 2 high, ...) + +### New since last scan (1) +- **HIGH** `mob/otp-tarball@ios_sim` `[MOB-DRIFT-ios_sim-elixir]` — manifest=1.19.5 binary=1.20.0-rc.4 + +### Resolved since last scan (1) ✓ +- **HIGH** `phoenix@1.8.5` `[EEF-CVE-2026-32689]` — Long-poll NDJSON body splitting + +### Still present from last scan (1) +- **CRITICAL** `openssl@3.4.0` ... _(first seen 22 days ago)_ +``` + +#### Cron / GitHub Actions wiring + +The task is designed for unattended invocation. A simple cron entry: + +```bash +# daily at 06:00 local +0 6 * * * cd /path/to/project && mix mob.security_scan.log >> /tmp/security_scan.log 2>&1 +``` + +A GitHub Actions workflow that opens a PR with the updated files: + +```yaml +name: security-scan +on: + schedule: [{cron: "0 6 * * *"}] + workflow_dispatch: +jobs: + scan: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: {elixir-version: "1.19", otp-version: "28"} + - run: brew install osv-scanner semgrep flawfinder detekt swiftlint + - run: mix deps.get + - run: mix mob.security_scan.log + - uses: peter-evans/create-pull-request@v6 + with: + title: "security: weekly scan update" + branch: security-scan-update + add-paths: | + SECURITY_SCAN.md + SECURITY_HISTORY.md + .security_scan/state.json +``` + +### Updating after rebuilding the OTP tarballs + +When you rebuild the bundled OTP runtime ([`build_release.md`](build_release.md)), +update the **`priv/security/bundled_versions.exs`** manifest to match +the new versions baked into the tarball. The bundled-runtime scan +fingerprints the cached binaries and emits a `:high` "drift" finding +if the manifest disagrees with what's on disk — that's the exact +failure mode the manifest exists to catch. + ## Battery benchmarks Measure BEAM idle power draw with specific tuning flags. Both tasks share the same presets and flag interface. ### Android (`mix mob.battery_bench_android`) -Deploys an APK and measures drain via the hardware charge counter (`dumpsys battery`). Reports mAh every 10 seconds. +Deploys an APK and measures drain via the hardware charge counter (`dumpsys +battery`). Reports mAh every 10 seconds. Uses the same probe / observer / +CSV-log / preflight infrastructure as the iOS bench. **WiFi ADB required** — a USB cable charges the device and skews measurements. @@ -118,7 +376,36 @@ Deploys an APK and measures drain via the hardware charge counter (`dumpsys batt adb -s SERIAL tcpip 5555 adb connect PHONE_IP:5555 # then unplug +``` + +#### Two-step workflow (recommended) + +Same pattern as iOS — push BEAM flags via `mix mob.deploy`, then bench +with `--no-build`. Saves the Gradle rebuild (~30+ seconds) when only +changing flags. + +```bash +mix mob.deploy --beam-flags "" --android # tuned (Nerves) +mix mob.deploy --beam-flags "-S 4:4 -A 8" --android # untuned variant + +mix mob.battery_bench_android --no-build --device 192.168.1.42:5555 +``` + +The bench will: +- Run preflight checks (adb device, app installed, BEAM reachable, RPC + responsive, NIF version, keep-alive NIF) +- Subscribe to `Mob.Device` events on the running app for ground-truth + screen/app-state tracking +- Write a per-tick CSV log to `_build/bench/run_android_<ts>.csv` +- Auto-reconnect with backoff if the dist connection flaps +- Print a probe-based summary at the end with success rate, reconnect + count, time-by-state, screen-on/off durations, and **taint warnings** + +#### Single-step Gradle path + +Still supported when you want a clean rebuild: +```bash mix mob.battery_bench_android # default: Nerves-tuned BEAM, 30 min mix mob.battery_bench_android --no-beam # baseline: no BEAM at all mix mob.battery_bench_android --preset untuned # raw BEAM, no tuning @@ -127,24 +414,124 @@ mix mob.battery_bench_android --duration 3600 --device 192.168.1.42:5555 mix mob.battery_bench_android --no-build # re-run without rebuilding ``` +#### Recovering from bad flags + +`mix mob.deploy --beam-flags "..."` saves to `mob.exs` so the flags persist +across runs. If a flag combination crashes the BEAM, every subsequent +deploy re-applies them. Push an empty string to clear: + +```bash +mix mob.deploy --beam-flags "" --android +``` + ### iOS (`mix mob.battery_bench_ios`) -Deploys to a physical iPhone/iPad and reads battery via `ideviceinfo`. Reports mAh (if `BatteryMaxCapacity` is available) or percentage points. +Deploys to a physical iPhone/iPad and reads battery via `ideviceinfo` (USB) +or via Erlang RPC over WiFi. Reports mAh (if `BatteryMaxCapacity` is +available) or percentage points. + +**Prerequisites:** `brew install libimobiledevice`, Xcode 15+, device +trusted on this Mac, phone on the same WiFi as the Mac. -**Prerequisites:** `brew install libimobiledevice`, Xcode 15+, device trusted on this Mac. +#### Two-step workflow (recommended) + +For Mob projects (which use `ios/build_device.sh` rather than a full Xcode +project), you can't rebuild + bench in one command — the bench task's +built-in `xcodebuild` path doesn't support the Mob build system. Instead, +do the two steps separately: ```bash -mix mob.battery_bench_ios # default: Nerves-tuned BEAM, 30 min -mix mob.battery_bench_ios --no-beam # baseline: no BEAM at all -mix mob.battery_bench_ios --preset untuned # raw BEAM, no tuning -mix mob.battery_bench_ios --flags "-sbwt none -S 1:1" -mix mob.battery_bench_ios --duration 3600 --device UDID -mix mob.battery_bench_ios --no-build # re-run without rebuilding +# Step 1 — deploy with whatever BEAM flags you want. +# This pushes the .beam files PLUS a runtime mob_beam_flags file that +# the launcher reads at startup. No native rebuild required (~5 seconds). +mix mob.deploy --beam-flags "" --ios # tuned (Nerves defaults) +mix mob.deploy --beam-flags "-S 6:6 -A 16" --ios # untuned variant +mix mob.deploy --ios # uses flags saved in mob.exs + +# Step 2 — run the bench with --no-build, since we already deployed. +mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 +mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 --duration 600 +mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 --skip-preflight +``` + +Find your phone's WiFi IP in **Settings → Wi-Fi → (i) → IP Address**. + +`--wifi-ip` is strongly recommended — without it the bench tries to +auto-discover the device, which is flaky for WiFi-only setups (we've seen +it pick up the Mac's own EPMD or simulator nodes). + +#### What the bench shows you + +A live trace per 10-second poll, with state per tick: + +``` +[02:33:00] 0.5/30 min — screen:off app:running rpc:ok battery:100% (−0.0 %) +``` + +A CSV log in `_build/bench/run_<ts>.csv` (every sample, every state). + +A probe-based summary at the end with success rate, reconnect count, +longest gap, time-by-state, screen-on/off durations, and **taint warnings** +that catch invalid runs (screen turned on, app died, majority unreachable, +flapping connection). + +#### Recovering from bad flags + +`mix mob.deploy --beam-flags "..."` saves the flags to `mob.exs` so they +persist across runs. If a flag combination crashes the BEAM (e.g. +requesting more threads than iOS allows per process), every subsequent +`mix mob.deploy` re-applies the same bad flags and the app keeps crashing. + +To recover, push an empty flags string — clears `mob.exs` *and* the +runtime override file on every device: + +```bash +mix mob.deploy --beam-flags "" --ios +``` + +#### Flag prefix convention (iOS) + +The Mob iOS BEAM build is conservative about flag syntax. Match the +compile-time defaults' format — `-` prefix, space-separated values: + +``` +-S 1:1 -SDcpu 1:1 -SDio 1 -A 1 -sbwt none ← compile-time defaults (Nerves) +``` + +When in doubt, copy that pattern. We've observed `+S 6:6 +A 64 +SDio 8` +crashing the BEAM at startup with no useful log line — likely because the +combined thread count exceeds iOS's per-process limit. Build untuned +configs incrementally: + +```bash +# Smallest delta from defaults — multi-scheduler but everything else minimal: +mix mob.deploy --beam-flags "-S 2:2 -SDcpu 2:2 -SDio 2 -A 2" --ios +# Bench. If the app launches and runs, ramp up: +mix mob.deploy --beam-flags "-S 6:6 -SDcpu 6:6 -SDio 6 -A 8" --ios +``` + +#### Other options + +```bash +mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 --no-keep-alive +# Skips the silent-audio keep-alive call. Use when the keep-alive NIF is +# misbehaving or you want to verify how much drain comes from background +# audio session vs the BEAM itself. + +mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 --skip-preflight +# Bypass the pre-flight checks (useful when the checks are spuriously +# failing on devicectl noise or similar). + +mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 --no-csv +# Don't write the CSV log (run is purely live-trace + final summary). + +mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 --log-path /tmp/run.csv +# Override CSV location. ``` ### Presets and results -| Preset | Flags | mAh/hr (Moto G) | +| Preset | Flags | mAh/hr (Moto G, screen on, low brightness) | |--------|-------|----------------| | No BEAM | — | ~200 | | Nerves (default) | `-S 1:1 -SDcpu 1:1 -SDio 1 -A 1 -sbwt none` | ~202 | @@ -152,6 +539,49 @@ mix mob.battery_bench_ios --no-build # re-run without rebu The Nerves-tuned BEAM is essentially indistinguishable from a stock Android app at idle. The untuned BEAM costs ~25% more because schedulers spin-wait instead of sleeping. +**iOS results** are tracked separately in `mob/guides/why_beam.md` (different +device, different methodology — physical iPhone with screen on/off +distinction). The `--preset` shortcuts (`untuned`/`sbwt`/`nerves`) aren't +useful on iOS because they require a full Xcode rebuild (which Mob projects +don't have), so on iOS you set flags via `mix mob.deploy --beam-flags ...` +and bench with `--no-build`. + +### Battery-read precision (iOS) + +iOS clamps `UIDevice.batteryLevel` to **5% increments** as a privacy +measure. So a 1% drain over 30 minutes shows as `100% → 100%` in the +bench's RPC reads. To get a precise final number: + +1. After the bench finishes (and prints both summaries), the iOS bench now + prompts you to plug in USB and press Enter. This calls `ideviceinfo`'s + battery domain which returns 1% precision over USB. +2. You'll see fields like: + + ``` + === Precise battery (via ideviceinfo) === + BatteryCurrentCapacity: 99 + BatteryIsCharging: true + ExternalConnected: true + FullyCharged: false + ``` + +3. Compare to the start-of-run reading the bench printed at the top. + +You can also read precise battery any time by hand: + +```bash +ideviceinfo -u <UDID> -q com.apple.mobile.battery +``` + +This caveat doesn't apply to Android — `dumpsys battery` returns 1% +precision natively. + +### Duration unit + +`--duration N` is in **seconds** on both bench tasks. Default 1800 = 30 +minutes. The bench's live trace and summaries always show +`elapsed_min / total_min` for readability, but the CLI flag is seconds. + ## Working with an agent (Claude Code / LLM) Because OTP runs on the device, an agent can connect directly to the running app via Erlang distribution and inspect or drive it programmatically — no screenshots required. @@ -342,7 +772,51 @@ mix mob.push --all # force-push every module mix mob.deploy # push changed BEAMs, restart mix mob.deploy --native # full native rebuild + install ``` -```` + +## iOS push notifications (APNs) + +For APNs push tokens to be delivered, the app binary must have `aps-environment` +in its codesigning entitlements — the provisioning profile having it is not +sufficient. + +### Automatic (recommended) + +`mix mob.deploy --native` extracts `aps-environment` from the embedded +provisioning profile and mirrors it into the fallback entitlements when no +explicit entitlements file exists. If the provisioning profile was created +with push enabled, nothing extra is needed. + +### Explicit entitlements file + +Create `ios/<AppName>.entitlements` in your project root: + +```xml +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>application-identifier</key> + <string>TEAM_ID.com.example.myapp</string> + <key>com.apple.developer.team-identifier</key> + <string>TEAM_ID</string> + <key>get-task-allow</key> + <true/> + <key>aps-environment</key> + <string>development</string> +</dict> +</plist> +``` + +When this file is present, `mix mob.deploy --native` uses it verbatim (no +auto-mirroring). Use `development` for Xcode/mob development builds and +`production` for App Store / TestFlight production builds. + +### Verifying entitlements on a built app + +```bash +codesign -d --entitlements :- path/to/MyApp.app | plutil -p - +# Should include: "aps-environment" => "development" +``` ### Agent workflow example @@ -370,3 +844,15 @@ Mob.Test.assigns(node) # verify photo_path was stored ``` If you need to see the rendered UI, take a screenshot with the native MCP tool, then use `Mob.Test.find/2` to correlate what you see with the component tree. + +## 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. diff --git a/build_release.md b/build_release.md index 1b018da..fc3dc38 100644 --- a/build_release.md +++ b/build_release.md @@ -4,6 +4,11 @@ `GenericJam/mob`. This file documents how to build and publish those tarballs when upgrading OTP. +> **TL;DR — runnable form:** every step below is also implemented as a script +> under [`scripts/release/`](scripts/release/). See [`scripts/release/README.md`](scripts/release/README.md) +> for the typical full-release flow. The markdown carries the narrative; the +> scripts carry the imperative. + --- ## What's in each tarball @@ -21,25 +26,86 @@ erts-<vsn>/ libepcre.a (iOS only) libryu.a (iOS only) asn1rt_nif.a (iOS only) + crypto.a # OTP's crypto NIF, built with -DSTATIC_ERLANG_NIF + libcrypto.a # OpenSSL 3.x, statically linked into crypto.a internal/ liberts_internal_r.a libethread.a lib/ # OTP stdlib (kernel, stdlib, elixir, logger, ...) + # Includes lib/crypto-VSN, lib/public_key-VSN, lib/ssl-VSN + # — real crypto, not a shim. releases/ 29/ start_clean.boot start_sasl.boot ``` -### Android (`otp-android-<hash>.tar.gz`) +### Android arm64 (`otp-android-<hash>.tar.gz`) Built from a full cross-compiled OTP release for `aarch64-unknown-linux-android`. -Does **not** need headers or extra static libs — those stay on the build machine. +Needs the same extra static libs and headers as iOS (see Step 2). The ERTS helper binaries (`erl_child_setup`, `inet_gethost`, `epmd`) must be in `erts-<vsn>/bin/`; `mob_dev` copies them into the APK as `lib*.so` (required for SELinux `execve` permission on Android). +### Android arm32 (`otp-android-arm32-<hash>.tar.gz`) + +Built from a full cross-compiled OTP release for `arm-unknown-linux-androideabi` +(armeabi-v7a). Same structure as arm64. Required for 32-bit-only devices (e.g. +Motorola E 2020). + +`asn1rt_nif.a` must be compiled separately — it is not emitted by the OTP build +system for arm32. Build it with: + +```bash +NDK=~/Library/Android/sdk/ndk/27.2.12479018/toolchains/llvm/prebuilt/darwin-x86_64/bin +OTP_SRC=~/code/otp + +$NDK/armv7a-linux-androideabi21-clang \ + -march=armv7-a -mfloat-abi=softfp -mthumb \ + -fvisibility=hidden -fno-common -fno-strict-aliasing \ + -fstack-protector-strong -O2 \ + -I "$OTP_SRC/erts/arm-unknown-linux-androideabi" \ + -I "$OTP_SRC/erts/include/arm-unknown-linux-androideabi" \ + -I "$OTP_SRC/erts/emulator/beam" \ + -I "$OTP_SRC/erts/include" \ + -DHAVE_CONFIG_H \ + -DSTATIC_ERLANG_NIF_LIBNAME=asn1rt_nif \ + -c "$OTP_SRC/lib/asn1/c_src/asn1_erl_nif.c" \ + -o /tmp/asn1rt_nif_arm32.o + +$NDK/llvm-ar rc /tmp/asn1rt_nif_arm32.a /tmp/asn1rt_nif_arm32.o +$NDK/llvm-ranlib /tmp/asn1rt_nif_arm32.a +``` + +Then include it in the tarball at `erts-<vsn>/lib/asn1rt_nif.a` (see Step 2b). + +### iOS device (`otp-ios-device-<hash>.tar.gz`) + +Built from a cross-compiled OTP for `aarch64-apple-ios`. Same install-tree +contents as the simulator tarball (libs, headers, ERTS bin), **plus** EPMD +source files and iOS configure output that `mob_dev`'s `build_device.sh` +needs at native-build time to static-link EPMD into the app. + +The extra files added on top of the install tree: + +``` +erts/ + epmd/src/{epmd,epmd_srv,epmd_cli}.c # EPMD C sources + aarch64-apple-ios/config.h # generated by ./configure for iOS arm64 + include/ # cross-platform ERTS headers + include/internal/ # ERTS-internal headers (ethread, etc.) +``` + +The path layout — `erts/...` next to `erts-<vsn>/...` — looks unusual but +intentional: `erts-<vsn>/` is the *install* tree (compiled artifacts), `erts/` +is a stripped-down *source* tree fragment carrying just what `build_device.sh` +needs to compile EPMD against the iOS arm64 SDK. + +`MobDev.OtpDownloader` validates these files are present after extraction; +older tarballs without them are treated as invalid and re-downloaded. + ### iOS simulator (`otp-ios-sim-<hash>.tar.gz`) Built from a cross-compiled OTP for `aarch64-apple-iossimulator`. Needs: @@ -61,7 +127,7 @@ Built from a cross-compiled OTP for `aarch64-apple-iossimulator`. Needs: ## Prerequisites -- A cross-compiled OTP build. The OTP source tree at `~/code/otp` (commit `73ba6e0f`) +- A cross-compiled OTP build. The OTP source tree at `~/code/otp` (commit `7721ab74`) has iOS simulator and Android targets already compiled. - `gh` CLI authenticated to the `GenericJam` GitHub account. @@ -71,22 +137,31 @@ Built from a cross-compiled OTP for `aarch64-apple-iossimulator`. Needs: ```bash cd ~/code/otp -git rev-parse --short HEAD # e.g. 73ba6e0f +git rev-parse --short HEAD # e.g. 7721ab74 ``` Use this hash everywhere below as `<hash>`. --- -## Step 2 — Build the Android tarball +## Step 2 — Build the Android tarballs (arm64 + arm32) + +The Android OTP release lives at the cross-compiled install dir (typically +`/tmp/otp-android` for arm64, `/tmp/otp-android-arm32` for arm32 — check +where the previous build left it). + +**Before tarballing**, make sure you have a project with exqlite compiled +(`_build/dev/lib/exqlite/ebin/` must exist — run `mix deps.get && mix compile` +from any app that uses ecto_sqlite3). The exqlite BEAMs are platform-independent +bytecode and can be bundled directly; the native `.so` is already in the APK and +is symlinked at runtime by `mob_beam.c`. -The Android OTP release lives at `bin/aarch64-unknown-linux-android/` and -the release dir (wherever `make install` put it — check the previous release -for the path, typically `/tmp/otp-android` or similar). +### arm64 ```bash OTP_SRC=~/code/otp OTP_RELEASE=/tmp/otp-android # adjust if different +EXQLITE_BUILD=~/code/mob_test_liveview/_build/dev/lib/exqlite # any project with exqlite HASH=<hash> STAGE=$(mktemp -d) @@ -108,11 +183,71 @@ cp "$OTP_SRC/erts/emulator/beam/erl_drv_nif.h" cp "$OTP_SRC/erts/include/aarch64-unknown-linux-android/erl_int_sizes_config.h" "$ERTS_INC/" cp "$OTP_SRC/erts/include/erl_fixed_size_int_types.h" "$ERTS_INC/" +# Add host Elixir stdlib (elixir, logger, eex) so the device starts with the +# correct version. Without this, Elixir version drift causes Regex.safe_run +# crashes when the host is upgraded between native deploys. +ELIXIR_LIB=$(elixir -e "IO.puts(:code.lib_dir(:elixir))" | xargs dirname) +for app in elixir logger eex; do + mkdir -p "$STAGE/lib/$app/ebin" + cp "$ELIXIR_LIB/$app/ebin/"* "$STAGE/lib/$app/ebin/" +done +echo "Bundled Elixir $(elixir --version | grep Elixir | awk '{print $2}')" + +# Add exqlite BEAMs. The .so NIF is in the APK (symlinked at runtime by mob_beam.c); +# only the ebin/ bytecode goes in the tarball. +EXQLITE_VSN=$(grep '"exqlite"' "$EXQLITE_BUILD/../../../mix.lock" | grep -o '"[0-9][^"]*"' | head -1 | tr -d '"') +EXQLITE_LIB="$STAGE/lib/exqlite-$EXQLITE_VSN" +mkdir -p "$EXQLITE_LIB/ebin" "$EXQLITE_LIB/priv" +cp "$EXQLITE_BUILD/ebin/"* "$EXQLITE_LIB/ebin/" +echo "Bundled exqlite $EXQLITE_VSN" + BASE=$(basename $STAGE) tar czf "/tmp/otp-android-$HASH.tar.gz" -C "$(dirname $STAGE)" "$BASE" # Verify tar tzf "/tmp/otp-android-$HASH.tar.gz" | grep "\.a$\|\.h$" +tar tzf "/tmp/otp-android-$HASH.tar.gz" | grep "lib/elixir/ebin/elixir.app" +tar tzf "/tmp/otp-android-$HASH.tar.gz" | grep "lib/exqlite" +``` + +### arm32 + +Repeat the same steps with the arm32 OTP release and the arm32 asn1rt_nif.a (see +prerequisites above for how to build it). The Elixir and exqlite BEAMs are the same +— bytecode is architecture-independent. + +```bash +OTP_RELEASE_ARM32=/tmp/otp-android-arm32 # adjust if different +STAGE32=$(mktemp -d) + +cp -r "$OTP_RELEASE_ARM32/." "$STAGE32" + +ERTS_LIB32="$STAGE32/erts-16.3/lib" +cp "$OTP_SRC/erts/emulator/zstd/obj/arm-unknown-linux-androideabi/opt/libzstd.a" "$ERTS_LIB32/" +cp "$OTP_SRC/erts/emulator/pcre/obj/arm-unknown-linux-androideabi/opt/libepcre.a" "$ERTS_LIB32/" +cp "$OTP_SRC/erts/emulator/ryu/obj/arm-unknown-linux-androideabi/opt/libryu.a" "$ERTS_LIB32/" +cp /tmp/asn1rt_nif_arm32.a "$ERTS_LIB32/asn1rt_nif.a" + +ERTS_INC32="$STAGE32/erts-16.3/include" +mkdir -p "$ERTS_INC32" +cp "$OTP_SRC/erts/emulator/beam/erl_nif.h" "$ERTS_INC32/" +cp "$OTP_SRC/erts/emulator/beam/erl_nif_api_funcs.h" "$ERTS_INC32/" +cp "$OTP_SRC/erts/emulator/beam/erl_drv_nif.h" "$ERTS_INC32/" +cp "$OTP_SRC/erts/include/arm-unknown-linux-androideabi/erl_int_sizes_config.h" "$ERTS_INC32/" +cp "$OTP_SRC/erts/include/erl_fixed_size_int_types.h" "$ERTS_INC32/" + +# Same Elixir and exqlite BEAMs (bytecode is arch-independent) +for app in elixir logger eex; do + mkdir -p "$STAGE32/lib/$app/ebin" + cp "$ELIXIR_LIB/$app/ebin/"* "$STAGE32/lib/$app/ebin/" +done +mkdir -p "$STAGE32/lib/exqlite-$EXQLITE_VSN/ebin" "$STAGE32/lib/exqlite-$EXQLITE_VSN/priv" +cp "$EXQLITE_BUILD/ebin/"* "$STAGE32/lib/exqlite-$EXQLITE_VSN/ebin/" + +BASE32=$(basename $STAGE32) +tar czf "/tmp/otp-android-arm32-$HASH.tar.gz" -C "$(dirname $STAGE32)" "$BASE32" +tar tzf "/tmp/otp-android-arm32-$HASH.tar.gz" | grep "lib/elixir/ebin/elixir.app" +tar tzf "/tmp/otp-android-arm32-$HASH.tar.gz" | grep "lib/exqlite" ``` --- @@ -146,6 +281,15 @@ cp "$OTP_SRC/erts/emulator/beam/erl_drv_nif.h" " cp "$OTP_SRC/erts/include/aarch64-apple-iossimulator/erl_int_sizes_config.h" "$ERTS_INC/" cp "$OTP_SRC/erts/include/erl_fixed_size_int_types.h" "$ERTS_INC/" +# Add host Elixir stdlib (same reasoning as Android — bake in so fresh installs +# start with the correct version and don't hit Regex.safe_run crashes). +ELIXIR_LIB=$(elixir -e "IO.puts(:code.lib_dir(:elixir))" | xargs dirname) +for app in elixir logger eex; do + mkdir -p "$STAGE/lib/$app/ebin" + cp "$ELIXIR_LIB/$app/ebin/"* "$STAGE/lib/$app/ebin/" +done +echo "Bundled Elixir $(elixir --version | grep Elixir | awk '{print $2}')" + # List any app-specific BEAM dirs to exclude (ls $OTP_ROOT | grep -vE "^(erts|lib|releases|misc|usr)$") BASE=$(basename $STAGE) tar czf "/tmp/otp-ios-sim-$HASH.tar.gz" \ @@ -157,13 +301,148 @@ tar czf "/tmp/otp-ios-sim-$HASH.tar.gz" \ # Verify tar tzf "/tmp/otp-ios-sim-$HASH.tar.gz" | grep "\.a$" tar tzf "/tmp/otp-ios-sim-$HASH.tar.gz" | grep "\.h$" +tar tzf "/tmp/otp-ios-sim-$HASH.tar.gz" | grep "lib/elixir/ebin/elixir.app" +``` + +--- + +## Step 3b — Build the iOS device tarball + +The iOS device runtime is the cross-compiled install dir for `aarch64-apple-ios`. +On top of the runtime, this tarball must ship EPMD source files and the iOS-arm64 +configure output so `mix mob.deploy --native` can static-link EPMD into the iOS +app at build time. + +### 3b.0 — Cross-compile OTP for iOS arm64 (only needed once per OTP version) + +> **One required source patch:** the iOS device sandbox blocks `fork()`, +> which the BEAM unconditionally calls at startup via `forker_start`. The +> simulator runs as a Mac process and allows fork, which is why iOS-sim +> builds work without modification. The patch lives at +> [`scripts/release/patches/0001-ios-device-skip-forker-fork.patch`](scripts/release/patches/0001-ios-device-skip-forker-fork.patch); +> `xcompile_ios_device.sh` applies it automatically (idempotent — detects +> if it's already in the source). Without it, the device app dies at +> launch with `Sandbox: <App>(<pid>) deny(1) process-fork` in the device +> log and tears down before any UI shows. + +This mirrors the iOS-sim cross-compile (which produced `erts/aarch64-apple-iossimulator/`) +but uses the device xcomp config instead. OTP ships both configs out of the box — +see `xcomp/erl-xcomp-arm64-ios.conf` (device) vs `erl-xcomp-arm64-iossimulator.conf` +(simulator). The device conf differs only in the host triple (`arm64-apple-ios`) +and SDK (`iphoneos`); flow is identical to the sim one. OTP's own walkthrough is +at `HOWTO/INSTALL-IOS.md`. + +```bash +cd ~/code/otp + +# Sanity check: the iPhoneOS SDK must be installed. +xcrun --sdk iphoneos --show-sdk-path + +# iOS doesn't allow shared libraries; this env var tells the build to emit +# libbeam.a (static) instead of libbeam.so. Same flag as for the sim build. +export RELEASE_LIBBEAM=yes + +# Configure for the iOS arm64 device target. +# +# `--with-ssl=$OPENSSL_PREFIX` points at a previously cross-compiled +# OpenSSL 3.x install (see scripts/release/openssl/ios_device.sh). +# `--disable-dynamic-ssl-lib` keeps OpenSSL static-linked into the +# crypto NIF; no separate libcrypto.so/.dylib ends up in the app. +# `--enable-static-nifs` registers crypto (and asn1rt_nif) in the +# BEAM's static_nif_tab[] so `erlang:load_nif("crypto", ...)` resolves +# `crypto_nif_init` via dlsym(RTLD_DEFAULT) — no dlopen of crypto.so. +# The latter is required: Android's RTLD_LOCAL default makes a dlopen'd +# crypto.so unable to see libpigeon.so's enif_* symbols. +./otp_build configure \ + --xcomp-conf=./xcomp/erl-xcomp-arm64-ios.conf \ + --with-ssl="$OPENSSL_PREFIX" \ + --disable-dynamic-ssl-lib \ + --enable-static-nifs + +# Build everything (ERTS, stdlib, and all OTP apps for the target). +./otp_build boot + +# Assemble the install tree at /tmp/otp-ios-device — this is the dir that +# the staging step below copies from. +make release RELEASE_ROOT=/tmp/otp-ios-device +``` + +After this completes you should have: +- `erts/aarch64-apple-ios/config.h` (the configure output the staging step bundles) +- `erts/emulator/{zstd,pcre,ryu}/obj/aarch64-apple-ios/opt/lib*.a` +- `lib/asn1/priv/lib/aarch64-apple-ios/asn1rt_nif.a` +- `/tmp/otp-ios-device/{bin,erts-<vsn>,lib,releases,...}` (the install tree) + +```bash +OTP_SRC=~/code/otp +OTP_RELEASE=/tmp/otp-ios-device # wherever the iOS-arm64 install lives +HASH=<hash> +STAGE=$(mktemp -d) + +# Copy the OTP runtime (install tree) +cp -r "$OTP_RELEASE/." "$STAGE" + +# Add extra static libs (same set as the sim tarball, but iOS-device arch) +ERTS_LIB="$STAGE/erts-16.3/lib" # update version as needed +cp "$OTP_SRC/erts/emulator/zstd/obj/aarch64-apple-ios/opt/libzstd.a" "$ERTS_LIB/" +cp "$OTP_SRC/erts/emulator/pcre/obj/aarch64-apple-ios/opt/libepcre.a" "$ERTS_LIB/" +cp "$OTP_SRC/erts/emulator/ryu/obj/aarch64-apple-ios/opt/libryu.a" "$ERTS_LIB/" +cp "$OTP_SRC/lib/asn1/priv/lib/aarch64-apple-ios/asn1rt_nif.a" "$ERTS_LIB/" + +# Add required headers +ERTS_INC="$STAGE/erts-16.3/include" +mkdir -p "$ERTS_INC" +cp "$OTP_SRC/erts/emulator/beam/erl_nif.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/emulator/beam/erl_nif_api_funcs.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/emulator/beam/erl_drv_nif.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/include/aarch64-apple-ios/erl_int_sizes_config.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/include/erl_fixed_size_int_types.h" "$ERTS_INC/" + +# Add Elixir stdlib (same as sim — bake in for version stability) +ELIXIR_LIB=$(elixir -e "IO.puts(:code.lib_dir(:elixir))" | xargs dirname) +for app in elixir logger eex; do + mkdir -p "$STAGE/lib/$app/ebin" + cp "$ELIXIR_LIB/$app/ebin/"* "$STAGE/lib/$app/ebin/" +done +echo "Bundled Elixir $(elixir --version | grep Elixir | awk '{print $2}')" + +# ── EPMD source + iOS-arm64 configure output ───────────────────────────────── +# build_device.sh static-links EPMD into the iOS app. The .c sources and the +# arch-specific config.h must be present alongside the install tree. The +# downloader's `valid_otp_dir?/2` checks for these and re-downloads if absent. +mkdir -p "$STAGE/erts/epmd/src" +cp "$OTP_SRC/erts/epmd/src/epmd.c" "$STAGE/erts/epmd/src/" +cp "$OTP_SRC/erts/epmd/src/epmd_srv.c" "$STAGE/erts/epmd/src/" +cp "$OTP_SRC/erts/epmd/src/epmd_cli.c" "$STAGE/erts/epmd/src/" +# epmd.c → epmd.h, epmd_int.h, both in erts/epmd/src/. +cp "$OTP_SRC/erts/epmd/src/"*.h "$STAGE/erts/epmd/src/" + +mkdir -p "$STAGE/erts/aarch64-apple-ios" +cp -r "$OTP_SRC/erts/aarch64-apple-ios/"* "$STAGE/erts/aarch64-apple-ios/" + +mkdir -p "$STAGE/erts/include" "$STAGE/erts/include/internal" +cp -r "$OTP_SRC/erts/include/"* "$STAGE/erts/include/" +cp -r "$OTP_SRC/erts/include/internal/"* "$STAGE/erts/include/internal/" + +# Tar it up — exclude any stray app build dirs left in OTP_RELEASE +BASE=$(basename $STAGE) +tar czf "/tmp/otp-ios-device-$HASH.tar.gz" -C "$(dirname $STAGE)" "$BASE" + +# Verify all four schema requirements: erts-*/ install, EPMD source files, +# iOS-arm64 config.h, and Elixir stdlib. +tar tzf "/tmp/otp-ios-device-$HASH.tar.gz" | grep "erts-16" | head -1 +tar tzf "/tmp/otp-ios-device-$HASH.tar.gz" | grep "erts/epmd/src/epmd.c" +tar tzf "/tmp/otp-ios-device-$HASH.tar.gz" | grep "erts/epmd/src/epmd_srv.c" +tar tzf "/tmp/otp-ios-device-$HASH.tar.gz" | grep "erts/epmd/src/epmd_cli.c" +tar tzf "/tmp/otp-ios-device-$HASH.tar.gz" | grep "erts/aarch64-apple-ios/config.h" +tar tzf "/tmp/otp-ios-device-$HASH.tar.gz" | grep "lib/elixir/ebin/elixir.app" ``` --- ## Step 4 — Publish the GitHub release -Tag format: `otp-<hash>` (e.g. `otp-73ba6e0f`). +Tag format: `otp-<hash>` (e.g. `otp-7721ab74`). ```bash HASH=<hash> @@ -172,12 +451,14 @@ HASH=<hash> gh release create "otp-$HASH" \ --repo GenericJam/mob \ --title "OTP pre-built runtime $HASH" \ - --notes "Pre-built OTP for Android (aarch64-linux-android) and iOS simulator (aarch64-apple-iossimulator). OTP source commit: $HASH." + --notes "Pre-built OTP for Android (aarch64 + arm32), iOS simulator (aarch64-apple-iossimulator), and iOS device (aarch64-apple-ios). OTP source commit: $HASH." -# Upload tarballs +# Upload tarballs (all four) gh release upload "otp-$HASH" \ "/tmp/otp-android-$HASH.tar.gz" \ + "/tmp/otp-android-arm32-$HASH.tar.gz" \ "/tmp/otp-ios-sim-$HASH.tar.gz" \ + "/tmp/otp-ios-device-$HASH.tar.gz" \ --repo GenericJam/mob # Verify @@ -191,6 +472,16 @@ gh release delete-asset "otp-$HASH" otp-ios-sim-$HASH.tar.gz --repo GenericJam/m gh release upload "otp-$HASH" /tmp/otp-ios-sim-$HASH.tar.gz --repo GenericJam/mob ``` +### Re-uploading without bumping the hash (schema-bump pattern) + +When the tarball *contents* change but the underlying OTP commit hasn't (e.g. +adding EPMD source to the iOS device tarball), the canonical move is to +re-upload at the same hash. `MobDev.OtpDownloader.valid_otp_dir?/2` is the +gate that decides whether a cached extracted dir is still acceptable — if you +add a new schema requirement there, existing users' caches fail validation +and the next `mix mob.deploy --native` re-downloads the asset automatically. +No hash bump, no user action needed. + --- ## Step 5 — Update OtpDownloader @@ -198,12 +489,41 @@ gh release upload "otp-$HASH" /tmp/otp-ios-sim-$HASH.tar.gz --repo GenericJam/mo Edit `lib/mob_dev/otp_downloader.ex` — update the hash and ERTS version: ```elixir -@otp_hash "73ba6e0f" # ← new hash +@otp_hash "7721ab74" # ← new hash ``` If the ERTS version changed (e.g. from 16.3 to 16.4), update `build_release.md` to match. +## Step 6 — Update the bundled-versions manifest + +Edit [`priv/security/bundled_versions.exs`](priv/security/bundled_versions.exs) +— update `:active_hash` and add (or modify) the `:bundles` entry for the new +hash with the versions baked into the new tarballs: + +```elixir +%{ + active_hash: "<new-hash>", + bundles: %{ + "<new-hash>" => %{ + erts: "16.3", # erts-* directory in the tarball + otp_release: "28", + elixir: "1.19.5", + openssl: "3.4.0", # `strings libcrypto.a | grep '^OpenSSL'` + exqlite_beam: "0.36.0", + openssl_release_date: "2024-10-22" + } + } +} +``` + +This file is the source of truth that `mix mob.security_scan` checks against. +The bundled-runtime scan layer fingerprints the cached tarball and raises if +the binary disagrees with what's declared here — drift between what we say +shipped and what actually shipped is exactly what this manifest is designed +to catch. Update it in the **same PR** as the OTP hash bump; otherwise the +scan will report a `:high` "manifest mismatch" finding for every Mob app. + --- ## Troubleshooting diff --git a/decisions/2026-05-21-project-prefix-config-keys.md b/decisions/2026-05-21-project-prefix-config-keys.md new file mode 100644 index 0000000..4d5eb77 --- /dev/null +++ b/decisions/2026-05-21-project-prefix-config-keys.md @@ -0,0 +1,20 @@ +# Build-flag config keys use the project_ prefix + +- Date: 2026-05-21 +- Status: accepted + +## Context +A contributor PR added an `:ios_swift_sources` `mob.exs` key to pass extra +Swift sources into the iOS Zig build. The Zig build flag is +`-Dproject_swift_sources`, matching the existing `project_c_nifs` / +`project_rust_libs` flags — but the `mob.exs` key used an `ios_` prefix +instead, an inconsistent naming surface. + +## Decision +Rename the config key to `:project_swift_sources` so the `mob.exs` key matches +the Zig flag name and the sibling `project_*` conventions. Swift is iOS-only by +language, so no platform prefix is needed. + +## Consequences +One consistent `project_*` family across config keys and build flags. Shipped +in mob_dev 0.5.11. diff --git a/decisions/2026-05-25-keep-compiler-in-slim-strip.md b/decisions/2026-05-25-keep-compiler-in-slim-strip.md new file mode 100644 index 0000000..0bd9ca2 --- /dev/null +++ b/decisions/2026-05-25-keep-compiler-in-slim-strip.md @@ -0,0 +1,26 @@ +# Keep the :compiler OTP app in the slimmed device runtime + +- Date: 2026-05-25 +- Status: accepted + +## Context +The iOS release slim-strip drops unused OTP apps to shrink the bundle, and +`compiler-*` looks unused at runtime — nothing the app calls references it +directly. But Ecto.Migrator compiles `.exs` migration files at runtime via +`Code.compile_file/1`, which needs the `:compiler` OTP app. Any app that runs +migrations on boot (the common Mob + ecto_sqlite3 pattern) depends on it. + +Stripping it surfaced as `{:badmatch, {:error, :enoent, :"compiler.app"}}` deep +in `:application_controller` during boot — the BEAM never reached +`Mob.Screen.start_root`, so the app hung on the splash with no obvious cause. + +## Decision +Remove `compiler` from the slim-strip prefix list in `release.ex`; keep it in +the device runtime. + +## Consequences +- A few MB larger bundle, in exchange for apps that run runtime migrations + actually booting. +- Documented inline at the strip list so it isn't "re-optimized" away later. +- Apps that don't compile code at runtime carry compiler unnecessarily — minor, + and not worth a per-app flag for the size saved. diff --git a/decisions/2026-05-25-real-crypto-ssl-on-device.md b/decisions/2026-05-25-real-crypto-ssl-on-device.md new file mode 100644 index 0000000..8186830 --- /dev/null +++ b/decisions/2026-05-25-real-crypto-ssl-on-device.md @@ -0,0 +1,37 @@ +# Ship real OpenSSL crypto + ssl on device, not md5/no-op shims + +- Date: 2026-05-25 +- Status: accepted + +## Context +Early device OTP runtimes were built `--without-ssl`, so the release scripts +compiled stand-in `:crypto` and `:ssl` modules into the app beam dir: an +md5-only crypto (`supports/1 -> []`, fake `generate_key`/`sign`) and an ssl +that only exported `start`/`stop`. They existed purely so +`ensure_all_started/1` wouldn't fail for HTTP-only loopback Phoenix. + +Those shims make real TLS impossible. On the Code-To-Cloud orchestra app the +phone fetches stems and streams SSE from `https://c0.boltbrain.ca`, so it needs +working TLS. With the shims, iOS hit `:ssl.versions/0 undefined` (the shim +lacks it) and Android's stubbed `crypto.supports/1 -> []` made `:ssl.versions/0` +raise — every HTTPS connect crashed. Meanwhile the current OTP tarballs *do* +ship real `crypto-5.9` + `ssl-11.7`, and the native builds already link the +OpenSSL static archive (`crypto.a` + `libcrypto.a`) and register the crypto NIF. + +## Decision +Use the real beams. Android (`release_android.ex`) gates on +`real_crypto_available?/1` — only stub when the runtime genuinely has no +`crypto.a`; otherwise keep the OpenSSL crypto. iOS (`release.ex`) stops +compiling the shim crypto/ssl into `BEAMS_DIR` (they shadowed the real +`lib/{crypto,ssl}-*/ebin` on the prepended `-pa` path), and links +`crypto.a`/`libcrypto.a` so the NIF resolves. + +## Consequences +- Real `verify_peer` TLS works on device; orchestra SSE + stem download connect. +- The shims are gone; `supports/1` returns real algorithms, `:ssl.versions/0` + works. +- Hard dependency: the device OTP tarball must ship `crypto.a` and the real + `crypto`/`ssl` beams. If a future `--without-ssl` tarball reappears, + `real_crypto_available?/1` falls back to the Android stub; iOS would need the + shim path restored. +- Apps that only need loopback HTTP are unaffected (the real beams still load). diff --git a/decisions/2026-05-26-elixir-version-skew-warning.md b/decisions/2026-05-26-elixir-version-skew-warning.md new file mode 100644 index 0000000..3540382 --- /dev/null +++ b/decisions/2026-05-26-elixir-version-skew-warning.md @@ -0,0 +1,29 @@ +# Warn (don't fail) on build vs device-runtime Elixir minor-version skew + +- Date: 2026-05-26 +- Status: accepted + +## Context +`.tool-versions` pinned Elixir 1.20.0-rc.5, but the device OTP tarball shipped +1.19.5. `x in list` compiles to `Enum.__in__/2` under 1.20, which 1.19.5 lacks, +so `Ecto.Migrator` hit `:undef` at boot — a black screen with no error message. +It cost hours to trace because nothing surfaced the mismatch; the build happily +produced an artifact that couldn't run. `mob_dev` already knows both versions at +build time: `System.version()` (the compiling Elixir) and the `elixir.app` vsn +inside the cached OTP tarball. + +## Decision +At OTP-dir resolution (`OtpDownloader.ensure/3`), compare the two at +**major.minor** granularity and print a loud stderr warning on mismatch — +**warn, not fail**. The pure comparison (`elixir_skew/2`) and the tarball reader +(`bundled_elixir_version/1`) are public + tested. rc/patch differences within a +minor (1.20.0-rc.5 vs 1.20.0, 1.19.5 vs 1.19.6) are beam-compatible and don't +warn. + +## Consequences +- The black-screen class of failure now announces itself in one build line. +- Warn over fail is deliberate: rc/patch toolchain transitions are routine and a + hard fail would block legitimate builds; the cost is that a warning can be + scrolled past (acceptable vs. blocking). +- Fires on every build while a skew persists — the nudge to align + `.tool-versions` with the tarball (or rebuild the tarball). diff --git a/decisions/2026-05-28-android-node-name-by-serial.md b/decisions/2026-05-28-android-node-name-by-serial.md new file mode 100644 index 0000000..de1f639 --- /dev/null +++ b/decisions/2026-05-28-android-node-name-by-serial.md @@ -0,0 +1,38 @@ +# Derive Android dist node name via `device_node_suffix/1`, not raw `ro.serialno` + +- Date: 2026-05-28 +- Status: accepted + +## Context +`mix mob.connect` printed Android node atoms nobody could connect to. The Mac +side (`Discovery.Android.enrich/1`) computed `node` directly from +`getprop ro.serialno` — which on AOSP emulators returns the placeholder +`EMULATOR36X5X10X0`, yielding `your_app_android_emulator36x5x10x0@127.0.0.1`. +The device side (`Mob.Dist`) receives `MOB_NODE_SUFFIX` from +`restart_app/4`, which already routes through `device_node_suffix/1` and +short-circuits emulator adb ids to the unique `emulator_5554` form. So the +device registered as `your_app_android_emulator_5554@127.0.0.1` and the +connection target was a different name — silent timeout, users had to read +`adb logcat` to find the real name. + +## Decision +`Discovery.Android.enrich/1` now calls `device_node_suffix/1` (the same +function `restart_app/4` uses to set `MOB_NODE_SUFFIX`) to derive the node +suffix. Both sides of the EPMD lookup now agree by construction. The +function already does the right thing for all three cases: emulator adb id +(short-circuits to `emulator_NNNN`), physical-USB serial (uses stable +`ro.serialno`), and physical WiFi-adb (uses `ro.serialno` so USB and WiFi +collapse to the same atom). + +## Consequences +- `mix mob.connect` against an Android emulator now connects on first try + without manual `adb logcat` archaeology. +- Two simultaneous emulators get distinct node atoms — no EPMD + `eaddrinuse` collision. +- Physical-Android behavior unchanged (both paths derive from + `ro.serialno`). +- iOS path unchanged — `enrich/1` is Android-only. +- One source of truth for Android node-suffix derivation; the old inline + fallback to `Device.node_name/1` on getprop failure is also folded into + `device_node_suffix/1` (which sanitizes the adb id directly when getprop + fails). diff --git a/decisions/2026-05-28-android-plugin-bridge-classes.md b/decisions/2026-05-28-android-plugin-bridge-classes.md new file mode 100644 index 0000000..e9d6dd5 --- /dev/null +++ b/decisions/2026-05-28-android-plugin-bridge-classes.md @@ -0,0 +1,78 @@ +# Android plugin bridge classes: compile plugin JNI/Kotlin + register the bridge jclass + +- Date: 2026-05-28 +- Status: accepted + +## Context + +Phase 3 Wave 1 Session B's full bt extraction needs a plugin to own a JVM +*bridge class* — a Kotlin class whose static methods the plugin's NIF invokes +via `CallStaticVoidMethod`, and whose `nativeDeliver*` externals resolve to the +plugin's own JNI thunks. mob has nothing for this today: + +- `MobDev.Plugin.Merge.android_sources/1` *gathers* `bridge_kt` + `jni_source` + paths, but `native_build.ex` never consumes them — the Android build does not + compile plugin-shipped JNI-thunk C or bridge Kotlin. The "Android native + merge" only wired permissions, gradle_deps, and `plugin_c_nifs` (NIF inits). +- The app's bridge jclass is cached by a single `JNI_OnLoad` in the project's + `beam_jni.c` that hardcodes `BRIDGE_CLASS = "com/example/<app>/MobBridge"`. + `JNI_OnLoad` is one-per-`.so` and core-owned; a plugin-owned Kotlin class in + its own package has no path to get its jclass cached. +- Existing plugins don't exercise this: `signature_pad` registers a render-time + Composable via `MobNativeViewRegistry`; the iOS `mob_register_plugins` + bootstrap is for `ui_components`. Neither is a startup static-bridge cache. + +## Decision + +Add an Android plugin **native-source + bridge-registration** pipeline, the +Kotlin analog of the iOS `mob_register_plugins` bootstrap. + +### Manifest (new `android` fields) +```elixir +android: %{ + jni_source: "priv/native/jni/mob_bluetooth_jni.c", # JNI thunks (Java_<pkg>_<Class>_*) + bridge_kt: "priv/native/android/MobBluetoothBridge.kt", # Kotlin impl + externs + bridge_class: "io.mob.bluetooth.MobBluetoothBridge" # FQN to register at startup +} +``` + +### Compilation +- `Merge.jni_sources/1` returns plugin `jni_source` absolute paths. + `native_build` emits `-Dplugin_jni_sources=<abs,paths>`; `build.zig` compiles + each as a plain C object (no `STATIC_ERLANG_NIF_LIBNAME` — these are JNI + thunks, not NIF inits) and links into the app `.so`. Same shape as + `plugin_c_nifs` minus the libname flag. +- Bridge Kotlin: `native_build` copies each plugin `bridge_kt` into the app + source tree at the package-derived path (`android/app/src/main/java/io/mob/ + bluetooth/MobBluetoothBridge.kt`) before `gradle assembleDebug`, so the app's + existing Kotlin sourceSet compiles it. (Copy-into-tree mirrors how the merge + already patches AndroidManifest.xml / build.gradle in place at build time.) + +### Bridge-class registration (the jclass cache) +- The plugin's Kotlin object exposes `@JvmStatic external fun nativeRegister()` + and a `@JvmStatic fun register() { nativeRegister() }`. +- The plugin's JNI thunk `Java_io_mob_bluetooth_MobBluetoothBridge_nativeRegister( + JNIEnv* env, jclass cls)` receives **its own class as `cls`** (JNI passes the + declaring class to static-method thunks) — so it caches `NewGlobalRef(cls)` + + looks up the bt_* method IDs with **no `FindClass` and no classloader + problem**. This sidesteps the `JNI_OnLoad`-singularity issue entirely. +- `Merge.bridge_classes/1` gathers `bridge_class` FQNs. `native_build` + generates `android/app/src/main/java/.../MobPluginBootstrap.kt`: + `object MobPluginBootstrap { fun registerAll() { io.mob.bluetooth.MobBluetoothBridge.register(); … } }` + and the mob_new `MainActivity` template calls `MobPluginBootstrap.registerAll()` + early in `onCreate` (analog of AppDelegate calling `mob_register_plugins()`). + +## Consequences + +- The plugin NIF keeps its bt method-id cache + jclass in its own zig globals + (not core's exported `Bridge`), fed by `nativeRegister`. The NIF's outbound + `CallStaticVoidMethod` uses that cache; inbound `mob_deliver_bt_*` are reached + by the plugin's own `Java_io_mob_bluetooth_*` thunks. Fully self-contained. +- New manifest fields → `Validator` should learn `bridge_class`/`jni_source` + shape (follow-up). Capability/permission enforcement already covers + AndroidManifest fragments. +- Prove with a trivial bridge plugin (mirror the zig-NIF prototype discipline) + before moving bt's ~450-line Kotlin. +- iOS: out of scope (bt is Android-only; Apple MFi gates it). +- Copy-into-tree for `bridge_kt` means an `mob.eject`/clean step should remove + generated plugin Kotlin + `MobPluginBootstrap.kt`; track as follow-up. diff --git a/decisions/2026-05-28-bt-full-three-layer-extraction.md b/decisions/2026-05-28-bt-full-three-layer-extraction.md new file mode 100644 index 0000000..956658e --- /dev/null +++ b/decisions/2026-05-28-bt-full-three-layer-extraction.md @@ -0,0 +1,68 @@ +# bt extraction: full three-layer move (zig + JNI thunks + Kotlin) + +- Date: 2026-05-28 +- Status: accepted + +## Context + +Phase 3 Wave 1 Session B (`mob_bluetooth` → tier-1) was scoped in the epic as +"move the bt zig NIF into the plugin." Investigation showed bt is a three-layer +native capability, all currently in mob core + the generated app / mob_new +templates: + +1. **zig** — `nif_bt_*` (16) + `mob_deliver_bt_*` (~33) in `mob_nif.zig` +2. **C** — 25 `Java_<app-pkg>_MobBridge_nativeDeliverBt*` thunks in `beam_jni.c` +3. **Kotlin** — 16 implemented `bt_*` methods (~450 lines of real + `BluetoothAdapter`/socket/HFP/SCO code) + 32 `external fun nativeDeliverBt*` + in `MobBridge.kt` + +The layers cross-reference at link time (C thunks call the zig exports; the NIF +calls the Kotlin statics on a cached jclass), and the JNI thunk names encode the +app package — so they can't simply be shipped from a plugin against the app's +`MobBridge`. + +## Decision + +Do the **full three-layer extraction**: move all of bt (zig + JNI thunks + +Kotlin) into `mob_bluetooth`, with the Kotlin re-homed to a plugin-owned class +`io.mob.bluetooth.MobBluetoothBridge` (its own package, so the thunk names +`Java_io_mob_bluetooth_*` are package-stable and shippable). Strip bt entirely +from mob core, `beam_jni.c`, `MobBridge.kt`, and the mob_new templates. + +This depends on two new capabilities, each with its own ADR: +- zig plugin NIFs — `2026-05-28-zig-plugin-nifs.md` +- Android plugin bridge classes — `2026-05-28-android-plugin-bridge-classes.md` + +### Alternatives considered +- **Hybrid (zig NIF in plugin, JVM stays in core).** Move only the zig layer; + the plugin caches bt method IDs on core's exported `Bridge.cls`, and + `beam_jni.c` keeps the bt thunks. *Rejected:* core/templates retain all bt + Kotlin + thunks, so it isn't a real extraction — every app still ships bt and + core still knows about it. Defeats the point of the plugin epic. +- **Defer; keep bt tier-0.** *Rejected by the user:* the zig-NIF capability was + already built + live-verified, and bt is the canonical tier-1 example the spec + points at; doing it properly drives the missing plugin-system capabilities + (zig NIFs, Android bridge classes) that every future native plugin needs + (`mob_local_llm`, sqlite-vec, etc.). + +## Consequences + +- Net-new plugin-system surface (zig NIF compile path + Android bridge-class + registration + plugin JNI/Kotlin compilation) ships before the bt move — a + larger effort than a code relocation, but it generalizes beyond bt. +- mob core's shared zig helpers (`binToCString`, `pidToJlong`, + `callBridgePidStr*`, `get_jenv`, …) stay in core; the plugin duplicates the + small pure helpers it needs and extern-links the exported `get_jenv` + `g_jvm`. + **Correction (2026-05-30, found during extraction):** `pidToJlong` / + `pidFromLong` / `callBridgePidStr*` are NOT bt-only — core keeps using them + for location/camera/audio/vendor_usb (`pidFromLong` 17×, `callBridgePidStr` + 24× post-strip). They are DUPLICATED into the plugin, not moved. The plugin's + bt method-id cache is its own `g_bt` struct + `g_bt_cls`, not core's exported + `Bridge`. The 32 (not 25) JNI delivery thunks ship as a verbatim-copied + `jni_source` C file with only the symbol prefix renamed to + `Java_io_mob_bluetooth_MobBluetoothBridge_*`. +- bt is Android-only (Apple MFi gates it; mob returns `:unsupported` on iOS), so + the iOS plugin-bridge path is out of scope for this wave. +- Discipline: prove each capability with a trivial prototype on device (the + trivial zig NIF already passed; a trivial bridge-class plugin is next) before + moving the ~1700 lines of real bt code. diff --git a/decisions/2026-05-28-plugin-plist-keys-merge.md b/decisions/2026-05-28-plugin-plist-keys-merge.md new file mode 100644 index 0000000..481f9e2 --- /dev/null +++ b/decisions/2026-05-28-plugin-plist-keys-merge.md @@ -0,0 +1,58 @@ +# Plugin Info.plist keys: project wins on conflict, plugins fill gaps + +- Date: 2026-05-28 +- Status: accepted + +## Context +Tier-1/tier-2 mob plugins can declare iOS `Info.plist` keys in their manifest +(`ios.plist_keys: %{...}`). The plugin extraction epic needs mob_dev's iOS +bundle path to merge these into the generated `.app/Info.plist` — without that, +plugins that depend on entitlements / privacy strings (e.g. a camera plugin +needing `NSCameraUsageDescription`) don't actually work end-to-end. + +The non-obvious question is **precedence**: when both the project's +`ios/Info.plist` and one (or more) activated plugins declare the same key, +who wins? + +Two reasonable answers: +- **Plugin wins** — guarantees the plugin works the way its author intended. + Project author must read every plugin's manifest to know which of their own + keys will be silently overridden. +- **Project wins** — the project author always sees the value they set. A + plugin's declaration acts as a *default* for keys the project hasn't + customised. + +## Decision +**Project's `ios/Info.plist` wins; plugins fill gaps.** Implemented in +`MobDev.NativeBuild.apply_plugin_plist_keys!/1` via PlistBuddy `Add`, which +fails (non-zero exit) when a key is already present. We swallow that failure +and treat it as "project already declared this — leave it alone." + +When multiple plugins declare the same key, `MobDev.Plugin.Merge.plist_keys/1` +already resolves it ("later plugins win on conflict" per its docstring), and +the resulting single value is what gets `Add`-attempted against the project +plist. + +Initial value-type support: `:string`, `:bool`, `:integer`. Other types log +a "skipping :<key>" message via `Mix.shell().info/1` rather than failing the +build — extending this is a future ergonomics task driven by real plugin +needs. + +## Consequences +- The project author's `ios/Info.plist` is always authoritative — no plugin can + silently change a privacy string the user set themselves. This matches the + least-surprise principle for app submission: what the author sees in + `ios/Info.plist` is what App Store Connect will see. +- Plugins ship sensible defaults that "just work" for the common case. A camera + plugin can include a generic `NSCameraUsageDescription` default; an app + author who never touches their `Info.plist` still gets a functioning camera + permission prompt. +- PlistBuddy's exit code conflates "key already present" with "value malformed" + / "type mismatch" / etc. We currently can't distinguish them, so genuine + failures pass silently. Acceptable for the first cut; a follow-up can parse + stderr to surface non-duplicate failures explicitly. +- Setting `CFBundleIdentifier`/`CFBundleExecutable`/`CFBundleName` in the + device path still uses `Set` (overwrite) — those are mob_dev's own + derivations, not plugin contributions, and must take precedence over any + literal value the project's `ios/Info.plist` happens to carry over from a + template. diff --git a/decisions/2026-05-28-zig-plugin-nifs.md b/decisions/2026-05-28-zig-plugin-nifs.md new file mode 100644 index 0000000..4dced1d --- /dev/null +++ b/decisions/2026-05-28-zig-plugin-nifs.md @@ -0,0 +1,81 @@ +# Zig plugin NIFs: named-module imports for mob-core bindings + +- Date: 2026-05-28 +- Status: accepted + +## Context + +Phase 3 Wave 1 Session B (promote `mob_bluetooth` to a tier-1 NIF) was +documented as a small "move the bt_* zig exports into the plugin, declare +`nifs:`, smoke" task. Investigation showed that premise was wrong: + +1. The bt NIF is ~1000 lines of **zig** (16 `nif_bt_*` wrappers, ~33 + `mob_deliver_bt_*` callbacks, atom cache, paired-list state machine, + term builders) living inside `mob/android/jni/mob_nif.zig`, not a + standalone file. +2. The tier-1 plugin NIF compile path is **C-only**: `MobDev.Plugin.Merge.nif_sources/1` + globs `<plugin>/<native_dir>/<module>.c`, `native_build.ex` emits + `-Dplugin_c_nifs`, and `build.zig` compiles those via `addCObject` + (`addCSourceFile`). The only thing it has ever compiled is the haptic + prototype, a trivial standalone C file. +3. `mob_nif.zig` resolves its bindings with **relative** imports — + `@import("mob_erts.zig")`, `@import("mob_zig.zig")` — to sibling files + in `mob/android/jni/`. A plugin `.zig` in a different directory cannot + use those relative paths. + +The driver-table registration side is already plugin-aware: +`Mix.Tasks.Mob.RegenDriverTab.resolved_nifs/0` merges +`MobDev.Plugin.Merge.nifs(activated())` into `erts_static_nif_tab`, and the +generated table calls each `<module>_nif_init()` over plain C ABI — which a +zig `export fn ... callconv(.c)` produces identically. So registration is +not the gap; **compilation + binding-import wiring** is. + +User decision (2026-05-28): extend the plugin infra to support zig NIFs +rather than hand-port the bt NIF to C. Rationale: faithful to mob's +zig-native native stack, and unblocks every future zig NIF plugin +(`mob_local_llm` via llama.cpp, sqlite-vec embeddings, etc.), not just bt. + +## Decision + +Add a parallel **zig** plugin-NIF path alongside the existing C one: + +1. **Manifest**: a plugin NIF entry may carry `lang: :zig` (default `:c` + preserves existing behavior). Source resolves to + `<plugin>/<native_dir>/<module>.zig`. +2. **`MobDev.Plugin.Merge`**: add `zig_nif_sources/1` mirroring + `nif_sources/1`; `nif_sources/1` stays C-only so the haptic prototype + path is unchanged. +3. **`native_build.ex`**: emit `-Dplugin_zig_nifs=<abs,paths>` alongside + `-Dplugin_c_nifs`. +4. **`build.zig`** (mob_plugin_demo, then the mob_new `build.zig.eex` + templates): a `plugin_zig_nifs` block compiles each source via + `addZigObject`, deriving the NIF libname from the basename. Unlike C, + zig needs no `-DSTATIC_ERLANG_NIF_LIBNAME`: the plugin source names its + own `export fn <module>_nif_init()` directly. +5. **Binding imports** (the crux): `addZigObject` wires **named module + imports** so a plugin `.zig` reaches mob-core bindings via + `@import("erts")` / `@import("jni")`, pointing at + `$MOB_DIR/android/jni/mob_erts.zig` and `mob_zig.zig`. Those modules' + own relative imports still resolve against their mob-core location, so + they keep working. +6. **Shared NIF helpers** (for entangled NIFs like bt that need + `binToCString`, `pidToJlong`, `callBridgePidStr*`, `get_jenv`, the + `Bridge`/MobBridge method-id cache — all currently private inside + `mob_nif.zig`): extract into an importable `mob_nif_shared.zig` exposed + as `@import("mob_nif")` to plugins. Done as a separate step after a + trivial zig NIF proves the compile + named-import path end-to-end. + +## Consequences + +- Distinct module instances: mob_nif.zig imports `erts` relatively while a + plugin imports it by name, so the two get separate Zig *type* identities + for `ErlNifEnv` etc. Safe here because the boundary is C-ABI only + (extern structs + extern `enif_*`); no Zig-level data structures cross. +- Sequencing: prove the pipeline with a trivial standalone zig NIF + (haptic's role for the C path) before tackling bt's shared-helper + extraction. A failure then localizes to compile-vs-entanglement. +- iOS counterpart (`build_device.zig` / iOS templates) deferred until the + Android path is verified on device; bt is Android-only (Apple MFi gates + it; mob returns `:unsupported` on iOS) so bt doesn't need the iOS path. +- `lang: :zig` is a new manifest field — `MobDev.Plugin.Validator` should + learn to accept it; follow-up. diff --git a/decisions/2026-05-31-plugin-activity-handoff.md b/decisions/2026-05-31-plugin-activity-handoff.md new file mode 100644 index 0000000..ffb7bd6 --- /dev/null +++ b/decisions/2026-05-31-plugin-activity-handoff.md @@ -0,0 +1,68 @@ +# Generic plugin Activity handoff via the MobActivityAware marker interface + +- Date: 2026-05-31 +- Status: accepted + +## Context + +A plugin's Android bridge class sometimes needs the host `Activity` (e.g. +`mob_bluetooth` needs it for adapter access and system pairing dialogs). The +first cut wired this with a hand-written line in `MainActivity.onCreate`: + + io.mob.plugin.MobPluginBootstrap.registerAll() + io.mob.bluetooth.MobBluetoothBridge.setActivity(this) // plugin-specific + +`registerAll()` is generated by mob_dev from `Merge.bridge_classes(activated)` +and calls each bridge's `register()` (cache jclass + method ids). The second +line is the leak: `MainActivity` (and the mob_new template) hard-codes a +specific plugin's class name. A second Activity-needing plugin would require +another hand-edit to both. `register()` (no Activity) and the Activity handoff +are genuinely separate concerns, so folding them into `register(activity)` +was rejected — not every plugin wants the Activity. + +Three ways for a plugin to signal "I need the Activity" were considered: + +- **Marker interface** — the bridge `object` implements `MobActivityAware`. +- **Manifest flag** — `android.wants_activity: true` in `mob_plugin.exs`. +- **Reflection** — the bootstrap probes for a `setActivity(Activity)` method. + +## Decision + +Marker interface. mob_dev generates a stable contract next to the bootstrap: + + package io.mob.plugin + interface MobActivityAware { fun setActivity(activity: Activity) } + +`registerAll` takes the Activity and hands it over uniformly — the same two +lines per bridge, the `as?` a no-op for bridges that don't opt in: + + fun registerAll(activity: Activity) { + <Class>.register() + (<Class> as? MobActivityAware)?.setActivity(activity) + ... + } + +A plugin opts in by implementing the interface (`object MobBluetoothBridge : +io.mob.plugin.MobActivityAware`). `MainActivity` collapses to one generic line +with no plugin names: `MobPluginBootstrap.registerAll(this)`. + +The contract is *generated* (alongside the always-generated bootstrap) rather +than shipped as a mob_new template file or a mob-core library type, so existing +apps and freshly-generated projects both get it with no template dependency. + +## Consequences + +- Adding an Activity-needing plugin requires zero host edits: implement the + interface in the bridge, and the generated bootstrap hands it the Activity. +- `MainActivity` and the mob_new template carry no plugin-specific names. +- Rejected the manifest flag: it threads a new field through Manifest / + Validator / Merge / generator and lets the manifest disagree with the code. + The interface encodes the same fact at the type level where the bridge is + defined, with compile-time safety. +- Rejected reflection: fragile under R8/ProGuard (keep-rules), slower, untyped. +- Implementing the interface edits a plugin's `bridge_kt` (a *signed* file), so + opting in requires re-signing the plugin (`mix mob.plugin.sign`). +- Activity recreation (rotation, process death) re-runs `onCreate` → + `registerAll(this)` re-hands the fresh Activity; `register()` is idempotent. +- Scope is the Activity only. Context/lifecycle needs would add a sibling + interface (e.g. `MobLifecycleAware` with default methods) later, not now. diff --git a/decisions/2026-05-31-verify-safe-atom-intern.md b/decisions/2026-05-31-verify-safe-atom-intern.md new file mode 100644 index 0000000..8742f4b --- /dev/null +++ b/decisions/2026-05-31-verify-safe-atom-intern.md @@ -0,0 +1,53 @@ +# Verify: intern envelope atoms so the :safe sig decode is deterministic + +- Date: 2026-05-31 +- Status: accepted + +## Context + +The build-time signature gate (`SignatureGate` → `Verify.verify_plugin/2`) +intermittently reported validly-signed plugins as `:invalid_signature`. The +same plugin, same files on disk, same code would verify `:ok` in one Mix +invocation and fail in the next. It was reproducible per-process: a script that +only called `Verify.load_signature/1` failed 100% of the time, while one that +also called `Sign.*` succeeded 100% of the time. + +Root cause: `Verify.load_signature/1` decodes `priv/mob_plugin.sig` with +`:erlang.binary_to_term(bytes, [:safe])`. The `:safe` flag refuses to *create* +atoms, so every atom in the encoded term must already exist in the runtime atom +table or the decode raises `badarg` (rescued → `:corrupt` → surfaced by the +gate as `:invalid_signature`). The signed envelope is +`%{signature: <64 bytes>, envelope_version: 1}`. `Verify` matched only +`%{signature: sig}`, so it interned `:signature` at load but never +`:envelope_version` — the sole interner of that atom was `Sign`. Because +`verify_plugin/2` calls `load_signature/1` *before* it touches `Sign`, the +decode's success depended on whether `Sign` had been loaded earlier in that +BEAM for unrelated reasons. Load order varies between builds → intermittent +rejection. + +`:safe` is the correct choice here: signature files are attacker-controlled +(the thing being verified), and `:safe` blocks atom-table-exhaustion and +unsafe-term decode bombs. So the fix must keep `:safe`, not drop it. + +## Decision + +Intern the envelope's atom keys at `Verify`-load time. A module-level literal +`@envelope_atoms [:signature, :envelope_version]` (referenced from +`decode_envelope_term!/1` and exposed via `envelope_atoms/0`) embeds those +atoms in `Verify`'s compiled atom chunk, so they are guaranteed present the +moment `Verify` is loaded — which is necessarily before any call to +`load_signature/1`. The `:safe` decode is retained. + +## Consequences + +- The signature gate is now deterministic regardless of module-load order; both + `mob_bluetooth` and `mob_demo_signature_pad` verify `:ok` on the trust path + with no `:acknowledge_unsafe_plugins` entry (confirmed across cold VMs). +- Any future field added to the signed envelope (in `Sign.build_payload/2` or + the envelope map in `sign_plugin/2`) whose key is an atom must also be added + to `@envelope_atoms`, or the same intermittent-decode bug returns for terms + that include it. The `verify_test.exs` guard pins the current set. +- True cold-VM reproduction is cross-process (atoms can't be un-interned in a + live VM), so the regression test guards the fix's mechanism in-process + (`envelope_atoms/0` membership + a decode that includes `:envelope_version`) + rather than re-triggering the original failure. diff --git a/decisions/2026-06-04-android-permission-provider-codegen.md b/decisions/2026-06-04-android-permission-provider-codegen.md new file mode 100644 index 0000000..5e94e5e --- /dev/null +++ b/decisions/2026-06-04-android-permission-provider-codegen.md @@ -0,0 +1,46 @@ +# Android MobPermissionProvider interface + bootstrap codegen + +- Date: 2026-06-04 +- Status: accepted + +## Context + +The extensible permission registry (see `mob/decisions/2026-06-04-plugin-permission-registry.md`) +needs mob_dev to generate the Android-side glue so a plugin bridge can supply +the cap→Android-permission-string mapping for a capability core no longer knows +about. iOS needs no codegen (runtime self-registration via an exported core +symbol); Android's flow is generic except for that mapping, so the codegen is +minimal. + +## Decision + +Mirror the existing `MobActivityAware` pattern exactly: + +- `MobDev.NativeBuild.__permission_provider_kotlin__/0` emits the marker + interface `io.mob.plugin.MobPermissionProvider { fun permissionsFor(cap: String): Array<String>? }`, + written next to `MobActivityAware` / `MobPluginBootstrap` by + `apply_plugin_android_kotlin!`. +- `__bootstrap_kotlin__/1` is extended: `registerAll(activity)` additionally + collects every bridge that `is MobPermissionProvider` into a list, and the + generated `MobPluginBootstrap` exposes + `permissionsFor(cap): Array<String>?` that walks the list, returning the + first non-null mapping. A plugin opts in purely by having its (already + registered) `bridge_class` implement the interface — no new manifest field. + +Core `MobBridge.request_permission` (mob_new template + demo copy) falls through +its `when(cap)` `else` branch to `io.mob.plugin.MobPluginBootstrap.permissionsFor(cap)`; +the generic checkSelfPermission / requestPermissions / onPermissionResult flow +is unchanged. + +The manifest's `permissions:` field is validated by `MobDev.Plugin.Manifest` +(tier-1, native) for documentation + tier classification, but Android codegen +does not read it — provider discovery is by interface at runtime. + +## Consequences + +- `MobPluginBootstrap` is always generated (even with no plugins, an empty + `permissionsFor` returning null), so core `MobBridge` can reference it + unconditionally — same guarantee `registerAll` already relied on. +- Symmetric with `MobActivityAware`: one emitter + one collection pass in the + bootstrap. +tests on the emitters; the generated Kotlin is ktlint-checked via + the mob_new generate-then-lint suite. diff --git a/decisions/2026-06-04-ios-plugin-c-nifs.md b/decisions/2026-06-04-ios-plugin-c-nifs.md new file mode 100644 index 0000000..f4dbb92 --- /dev/null +++ b/decisions/2026-06-04-ios-plugin-c-nifs.md @@ -0,0 +1,60 @@ +# iOS plugin C-NIF compile path + per-platform driver-table filtering + +- Date: 2026-06-04 +- Status: accepted + +## Context + +Wave 1 (mob_bluetooth) built the Android plugin-NIF path (`-Dplugin_c_nifs` / +`-Dplugin_zig_nifs` + Android `build.zig` compile blocks). The iOS counterpart +was deferred because bt was Android-only (iOS returned `:unsupported`). Wave 2 +(camera, location, notify, photos, biometric) is different: every one is +cross-platform with a real iOS NIF (ObjC/C — CoreLocation, AVFoundation, …). So +the iOS plugin-NIF compile path is now a hard prerequisite for Wave 2. + +The driver table was already platform-correct: `RegenDriverTab.resolved_nifs/0` +merges activated plugins' NIFs and `StaticNifs.generate(:ios, …)` emits them. +The only gap was the iOS *build* not compiling the plugin NIF source, so +`driver_tab_ios` referenced a `<module>_nif_init` the link couldn't find. + +Verifying surfaced a second issue: the iOS table included *every* activated +plugin's NIF, including the **zig** ones (mob_bluetooth, the zig_extras demo). +The iOS build has no zig plugin-NIF compile path, and bt's zig can't compile on +iOS anyway (it's full of Android JNI symbols). So the link failed on +`_mob_bluetooth_nif_nif_init` / `_mob_zig_extras_nif_nif_init`. + +## Decision + +Two changes: + +1. **iOS plugin C-NIF compile path.** `native_build.ex` emits `-Dplugin_c_nifs` + for both iOS builds (sim `build.zig`, device `build_device.zig`), gated to + non-empty like the Android path. `ios/build.zig` + `build_device.zig` (demo + + mob_new templates) gain a `plugin_c_nifs` compile block that mirrors the + existing `project_c_nifs` one: each absolute source path is compiled with + `-DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME=<basename>` and linked. + Scoped to **C** NIFs — Wave 2 is all ObjC/C; no plugin needs a zig NIF on + iOS, so the zig-on-iOS path is deliberately deferred (and documented in the + code) until one does. + +2. **Per-platform driver-table filtering.** `RegenDriverTab.resolved_nifs/1` + takes a platform and drops plugin NIFs the platform's build can't compile. + For `:ios` that means excluding `lang: :zig` plugin NIFs (the invariant: the + table only references symbols the build links). `:android` / `:all` keep + everything. The iOS and Android tables are now generated from different NIF + lists. + +## Consequences + +- A tier-1 C/ObjC plugin NIF compiles, links, and loads on iOS. Verified + end-to-end on a physical iPhone (SE 3rd gen, iOS 26.5): re-enabled the + pure-C `mob_demo_haptic_extras` (disabled solely because this path was + missing — this resolves that standing TODO), and `haptic_extras_nif.buzz/0` + returned `:ok` over RPC on `aarch64-apple-ios`. +- Android-only plugins (zig NIFs) no longer break the iOS link — they're absent + from `driver_tab_ios`, matching their `:unsupported`-on-iOS Elixir contract. +- `resolved_nifs/0` is preserved (delegates to `:all`) so mob.doctor and the + project-NIF classifier are unchanged. +- Follow-up if ever needed: a zig plugin-NIF compile path on iOS (add + `-Dplugin_zig_nifs` + an `addZigObject` block to `ios/build*.zig`, and relax + the `:ios` filter). No current plugin needs it. diff --git a/decisions/2026-06-05-plugin-runtime-manifest.md b/decisions/2026-06-05-plugin-runtime-manifest.md new file mode 100644 index 0000000..b9c4a3b --- /dev/null +++ b/decisions/2026-06-05-plugin-runtime-manifest.md @@ -0,0 +1,53 @@ +# Runtime plugin manifest + audited host_config for tiers 3/4 + +- Date: 2026-06-05 +- Status: accepted + +## Context + +Tiers 3 (multi-screen) and 4 (embedded sub-app) plugins are pure-Elixir and +**runtime-wired**: their screens, lifecycle hooks, settings schemas, and +notification handlers are ordinary Elixir compiled into the host release. But the +host has no on-device awareness of which plugins are active or what they declare — +`MobDev.Plugin.activated/0` reads `mob.exs` + deps at *compile time* only. Tiers +1/2 don't need this because their contributions are native symbols merged at +link time; tiers 3/4 need the data available to running Elixir on device. + +Spec-v2 also adds `:screens_generator` (compile-time codegen reading host config +via `host_config/3`, which was a stub). + +## Decision + +Mirror the existing `driver_tab` / `MobPluginBootstrap` build-time codegen, but +emit **serializable Elixir data** instead of native symbols. + +- `MobDev.Plugin.RuntimeManifest.build/1` gathers the tier-3/4 sections (via new + `Merge` gatherers, each tagged with the owning plugin) and runs spec-v2 + `:screens_generator`s, producing `%{screens, lifecycle, settings, + notification_handlers}`. +- `render/1` emits a self-describing `.exs` that evaluates back to the map; + `mix mob.regen_plugin_manifest` writes it to `priv/generated/mob_plugins.exs` + (with `--check` for drift, like `regen_driver_tab`). Core's `Mob.Plugins` + reads it at boot. +- **Only behavioral data lives in the manifest.** Migration files and font/image + assets are physically copied into the host at build time (native_build), not + carried here — their build-machine paths are meaningless on device. +- **No closures anywhere in tier-3/4 sections.** The manifest serializes to a + terms file, so notification `match` is a map or a `{Module, :function, arity}` + predicate reference, never an anonymous `fn`. The validator enforces this. +- `host_config/3` becomes audited: `with_host_config_audit/3` scopes a read set + to a plugin's declared `:host_config_keys`; a read of an undeclared key raises + and fails the build. Reads are recorded for `mix mob.audit_plugins`. + +## Consequences + +- A new generated artifact `priv/generated/mob_plugins.exs`, regenerated when + `config :mob, :plugins` changes (same deploy/regen gotcha as `driver_tab` — + run `mix mob.regen_plugin_manifest` after activating a tier-3/4 plugin). +- Notification matching can't use arbitrary closures; named predicate MFAs cover + the same need and stay serializable + auditable. +- `host_config/3`'s audit is opt-in via the scope — direct calls (tests, non- + generator code) stay a plain `Application.get_env/3`, so nothing else breaks. +- Migrations/assets file-bundling is deferred to the Phase 1 native_build work; + this commit is the pure-data foundation (validation + gatherers + manifest + builder + host_config audit), fully unit-tested, no native or device changes. diff --git a/decisions/2026-06-06-bundle-toolchain-elixir-stdlib-on-skew.md b/decisions/2026-06-06-bundle-toolchain-elixir-stdlib-on-skew.md new file mode 100644 index 0000000..3413c6d --- /dev/null +++ b/decisions/2026-06-06-bundle-toolchain-elixir-stdlib-on-skew.md @@ -0,0 +1,50 @@ +# Bundle the toolchain's Elixir stdlib, not a stale mob.exs pin, on version skew + +- Date: 2026-06-06 +- Status: accepted + +## Context + +`native_build.ex` `resolve_elixir_lib/1` decided which Elixir stdlib to bundle +into the device app from mob.exs `elixir_lib`, honoring the configured path as +long as it *existed on disk* — it never checked the lib's Elixir version. + +The app's `.beam` files are compiled by the toolchain that runs `mix` +(`System.version()`). Macros baked into those BEAMs (Ecto.Migration, regex +literals, …) emit calls into compiler internals that move between versions. A +stale or mispinned `elixir_lib` bundles a stdlib whose internals don't match the +compiled BEAMs. The skew is invisible until the app compiles an `.exs` at runtime +on-device (an Ecto migration) and dies with `undef`. + +Real instance: mob_plugin_demo's mob.exs pinned `1.20.0-rc.5` while the active +mise toolchain resolved to `1.20.0` final. `:elixir_quote.validate_quote/1` was +added between rc.5 and final, so the rc.5-compiled compiler on-device couldn't +expand the final-compiled `Ecto.Migration` macro — tier-3 plugin migrations +failed on iOS. Android never hit it: `sync_elixir_stdlib_android` auto-detects +the lib from the running BEAM (`:code.lib_dir`), so it always ships whatever +compiled the app. + +## Decision + +`resolve_elixir_lib/1` now reads the configured lib's version from +`<lib>/elixir/ebin/elixir.app` and compares it to `System.version()`: + +- match (or unreadable version) → honor the configured path +- version skew → warn loudly and fall back to `detect_elixir_lib()` (the + running-BEAM lib, which matches the compiler by definition) +- missing path → detect (unchanged) + +The decision is a pure kernel `__elixir_lib_decision__/3` with a pure message +builder `__elixir_lib_skew_warning__/4`, both `@doc false` and unit-tested across +the matrix (the skew case pins the exact rc.5-vs-final bug). The I/O wrapper stays +thin. + +## Consequences + +- A correct build is preferred over an honored-but-stale config; a mispinned + `elixir_lib` self-corrects with a warning instead of shipping a broken app. +- The warning tells the user to fix mob.exs, so the skew is surfaced at build + time rather than as an opaque on-device `undef`. +- The iOS bundle path now matches the Android sync's auto-detect behavior in + spirit (always ship the compiler's stdlib). +- Verified on a physical iPhone: tier-3 plugin `.exs` migrations compile and run. diff --git a/decisions/2026-06-14-prune-orphaned-plugin-artifacts.md b/decisions/2026-06-14-prune-orphaned-plugin-artifacts.md new file mode 100644 index 0000000..080682f --- /dev/null +++ b/decisions/2026-06-14-prune-orphaned-plugin-artifacts.md @@ -0,0 +1,46 @@ +# Prune orphaned plugin artifacts on removal + +- Date: 2026-06-14 +- Status: accepted + +## Context + +A plugin's tier-3 merges COPY files into the host tree: bridge Kotlin into the +Kotlin sourceSet (`android/app/src/main/java/<package>/`), migrations into +`priv/repo/migrations`, images into `priv/generated/plugin_assets`. The runtime +manifest (`mob_plugins.exs`) and the static-NIF `driver_tab` are recomputed from +the activated set on every `build_all`, so the NIF link surface is always clean +after a plugin is removed. The copied files were not — they lingered. An +orphaned bridge `.kt` is the worst case: Gradle compiles everything under +`src/main/java`, so a stale bridge referencing now-removed symbols can break the +build. This blocked telling users "add and remove plugins freely." + +## Decision + +Added `NativeBuild.__prune_plugin_artifacts__/2`: a per-concern ledger of the +relative paths each merge wrote, kept under +`priv/generated/.mob_plugin_artifacts/<scope>`. On each run a merge passes the +files it just produced; the helper deletes `(previous − current)` and persists +`current`. Wired into `apply_plugin_android_kotlin!` (`:android_kotlin`), +`apply_plugin_migrations!` (`:migrations`), and `apply_plugin_images!` +(`:images`). Each is restructured so the prune runs even when the current set is +empty (the all-removed case). + +Scoped per concern, and only invoked when that concern's merge runs, so an +iOS-only build never prunes Android artifacts. Generated glue at fixed paths +(bootstrap, activity-aware, permission-provider, notify-hub) is overwritten each +build and stays out of the ledger — only the orphan-prone per-plugin copies are +tracked. + +## Consequences + +- Add/remove of a plugin is now clean in both directions; the lean default + generated app can document removal as a normal workflow. +- Pruning a migration file does not roll back an already-applied migration + (schema_migrations keeps the record); it stops re-runs and keeps the dir + honest. A `mix ecto.rollback` of a removed plugin's migration would have no + file — acceptable, since you don't roll back a plugin you've dropped. +- Android `res/font/` orphans are deliberately NOT pruned here: that dir mixes + app fonts (`priv/fonts`) with plugin fonts, and orphan fonts are benign unused + resources, not a build break. Revisit if it becomes a real problem. +- The ledger lives in `priv/generated/` alongside the other derived build state. diff --git a/decisions/2026-06-17-otp-hash-elixir-1.20.1.md b/decisions/2026-06-17-otp-hash-elixir-1.20.1.md new file mode 100644 index 0000000..b2410ea --- /dev/null +++ b/decisions/2026-06-17-otp-hash-elixir-1.20.1.md @@ -0,0 +1,43 @@ +# OTP runtime hash bump to Elixir 1.20.1 (new hash, not clobber) + +- Date: 2026-06-17 +- Status: accepted + +## Context + +Io (livebook_mob) needed to move from Elixir 1.20.0-rc.5 to 1.20.1. The bundled +Elixir stdlib lives inside the pre-built OTP tarballs that `OtpDownloader` +fetches (named by `@otp_hash`), so moving an app to 1.20.1 means the published +tarballs must carry 1.20.1 — the local cache stdlib-swap that proves it works on +a device is not reproducible (a clean machine pulls the published rc.5). + +The active hash `7d46fdd4` is shared by every app on the current mob_dev +(air_cart_max, code_to_cloud, sloppy_joe, …). Clobbering its tarballs with +1.20.1 would silently change the Elixir under all of them. + +## Decision + +Publish a **new** OTP release `otp-5c9c69fc` (5 tarballs, same OTP-29/erts-17.0/ +OpenSSL base, Elixir stdlib swapped rc.5 → 1.20.1) rather than clobbering +`7d46fdd4`. Flip `@otp_hash` + `bundled_versions.exs` `active_hash` to +`5c9c69fc`; keep the `7d46fdd4` manifest entry for provenance. + +The new hash is deterministic, derived from the content +(`shasum` of "elixir-1.20.1-otp-29-base-7d46fdd4"), not an OTP git commit — the +OTP source didn't change, only the bundled Elixir. + +## Consequences + +- Updating to 1.20.1 is **opt-in per app**: only apps that `mix deps.update + mob_dev` to ≥0.6.5 get the new runtime. Apps pinned to older mob_dev keep + `7d46fdd4` (rc.5) untouched — no silent skew. +- The tarballs were rebuilt from **pristine** downloads of the published + `7d46fdd4` assets (extract → swap stdlib → re-tar), NOT from local caches, + which had been polluted by dev `--native` builds (e.g. a built-in + `sqlite3_nif.a`, crypto-shim writes). Always repack from the published asset. +- `security_scan` verified the new caches fingerprint clean against the manifest + (ERTS 17.0, Elixir 1.20.1, OpenSSL 3.4.0). Remove any locally stdlib-swapped + `7d46fdd4` caches afterward or they read as DRIFT vs the rc.5 manifest entry. +- Tarball repack is a stdlib swap, not an OTP rebuild — no `~/code/otp` / + xcompile needed. The `bundle_elixir_stdlib` set (elixir/logger/eex) is exactly + what changes between Elixir patch versions on the same erts. diff --git a/decisions/2026-06-18-dist-ports-by-serial.md b/decisions/2026-06-18-dist-ports-by-serial.md new file mode 100644 index 0000000..265d2aa --- /dev/null +++ b/decisions/2026-06-18-dist-ports-by-serial.md @@ -0,0 +1,45 @@ +# Dist ports keyed by device serial, not run index + +- Date: 2026-06-18 +- Status: accepted + +## Context + +`mix mob.connect` was effectively unreliable for its core promise ("run one +command, you're in IEx on the phone"). Root cause, found by inspecting live +state: the Mac runs ONE EPMD (port 4369) that every device — across every +project and every connect run — registers into, but dist ports were assigned by +per-run index (`9100 + index`). So `nxe_test`'s device-0 and `sloppy_joe`'s +device-0 both registered at 9100; `adb forward tcp:9100` can only point at one +device, so the other resolved to the wrong phone or nothing → a black-box +"timed out waiting for node". Stale forwards/registrations also accumulated and +were never cleaned. With no diagnostics, the failure was undebuggable, so the +workflow got abandoned in favour of agent-driven device control. + +## Decision + +1. **Serial-derived ports.** `Tunnel.serial_base_port/1` = `9100 + crc32(serial) + mod 800`. A given phone always maps to the same unique port regardless of + project/run, so deploy-time and connect-time ports agree and two projects on + two phones can't collide on 9100. `Tunnel.assign_dist_port/2` bumps past any + port a live node/forward already holds (cross-project or crc32 collision). + Both are pure + tested; the I/O (`ports_in_use/1`) is gathered by the caller. +2. **Cleanup.** `Tunnel.setup` removes the device's own stale forwards first, + scoped to that serial (never touches other devices'). +3. **Diagnostics.** On a failed wait, `Connector.connect_diagnosis/1` inspects + EPMD / forwards / app state and reports the actual cause. + +`Tunnel.setup/2` collapses to `setup/1` (callers: connector, hot_push, deployer) +since the port no longer comes from a run index. + +## Consequences + +- Verified on hardware: two phones got 9633 / 9721 (stable, collision-free) and + clean 1:1 forwards, with the old 9100/9101 dupes removed. +- crc32 into an 800-wide window: hash collisions between two simultaneously + connected phones are rare and handled by `assign_dist_port`'s bump. +- Does NOT remove the shared-EPMD-over-adb model itself. The deeper "never think + about ports" version is EPMD-less distribution (fixed port + custom `erl_epmd` + resolver) — a larger change, deferred. +- The `setup/2`→`setup/1` signature change is internal (no public Mix-task API + change). `Tunnel.dist_port/1` (index-based) is gone; use `serial_base_port/1`. diff --git a/decisions/2026-06-18-skip-unsupported-android-abi.md b/decisions/2026-06-18-skip-unsupported-android-abi.md new file mode 100644 index 0000000..bacb840 --- /dev/null +++ b/decisions/2026-06-18-skip-unsupported-android-abi.md @@ -0,0 +1,40 @@ +# Skip Android ABIs the app's build.zig can't compile + +- Date: 2026-06-18 +- Status: accepted + +## Context + +`native_build.ex` builds three Android ABIs (arm64-v8a, armeabi-v7a, x86_64) +unconditionally; x86_64 was added in 0.6.4. But the per-app `build.zig` is +app-owned (copied at `mix mob.new` time), and apps generated before mob_new +0.4.5 only handle arm64-v8a + armeabi-v7a — they exit 1 with +`unsupported -Dabi=x86_64`. + +The ABI loop `reduce_while`'d with `{:halt, {:error, …}}` on any failure, so a +single unsupported ABI failed the **entire** native build. Worse, that abort +happened before the `io.mob.plugin.MobPluginBootstrap` regeneration step, so the +generated bootstrap went missing and the next `gradle bundleRelease` failed on +an unresolved `MobPluginBootstrap` — a confusing second-order symptom. This bit +the Io (livebook_mob) 16 KB-page reship. + +## Decision + +Pre-flight each ABI against the app's build.zig and skip the ones it doesn't +declare, with a warning. `build_zig_supports_abi?/2` (pure, `@doc false` for +testing) checks for the ABI as a quoted string literal — every handled ABI +appears in the build.zig's `abi_to_target` / `ndk_arch_triple` switches. + +## Consequences + +- Apps with an older build.zig build cleanly for the ABIs they support; the + plugin bootstrap regen always runs. gradle `abiFilters` already excludes the + skipped ABI from the AAB, so nothing is lost. +- A real build failure of a **supported** ABI still halts (the predicate gates + the *attempt*, not the result) — we don't mask genuine errors. +- The proper long-term fix is for apps to regenerate their `build.zig` from + mob_new ≥ 0.4.5 (full x86_64 support); this just stops the default ABI set + from being a hard wall in the meantime. +- String-literal detection is intentionally simple; it can't be fooled into a + false positive that matters (if the ABI string is present, the switch handles + it; the build then succeeds or fails on its own merits). diff --git a/decisions/2026-06-19-mob-adopt-lives-in-mob_dev.md b/decisions/2026-06-19-mob-adopt-lives-in-mob_dev.md new file mode 100644 index 0000000..fef4fcd --- /dev/null +++ b/decisions/2026-06-19-mob-adopt-lives-in-mob_dev.md @@ -0,0 +1,82 @@ +# `mix mob.adopt` lives in mob_dev, not mob_new + +- Date: 2026-06-19 +- Status: accepted + +## Context + +`mix mob.adopt` installs Mob into an *existing* Phoenix project — the +install-into-existing counterpart to `mix mob.new` (which generates a project +from scratch). It was contributed against mob_new as +[mob_new#8](https://github.com/GenericJam/mob_new/pull/8) by @ken-kost, since +mob_new owns the project-generation surface. + +But adopt is an **Igniter** task: it mutates a user's existing mix.exs, +`app.js`, `root.html.heex`, and config in place, exactly like mob_dev's +existing `mix mob.add_nif` and `mix mob.enable`. mob_new ships as a +self-contained Mix **archive** (`mix archive.install hex mob_new`), and +archives bundle only their own beams — no runtime deps. `ArchiveSelfContainedTest` +pins that invariant. Igniter is a runtime dep, so it cannot live inside the +archive: adopt running from mob_new would have to either vendor Igniter or +crash on `UndefinedFunctionError` for every installed user (the same class of +bug that bit the original Sourceror-based dep injector — see mob_new issues.md +#1). mob_dev, by contrast, is a normal Hex dependency of the user's project, so +Igniter is already on the path. + +## Decision + +Relocate the whole adopt task tree into mob_dev, where `mob.add_nif` / +`mob.enable` already live: + +- `lib/mix/tasks/mob/adopt.ex` + `adopt/{deps,bridge,screen,mob_app,mob_exs,native,finalize}.ex` + (+ `native/{android,ios}.ex`). Task **names** are unchanged (`mix mob.adopt`, + `mix mob.adopt.deps`, …) — task names are global; only the home repo moved. +- `MobNew.AdoptGuard` → `MobDev.AdoptGuard`. +- The shared patcher/generator helpers adopt calls (`inject_deps`, + `inject_mob_hook`, `inject_mob_bridge_element`, `inject_ecto_sqlite3`, the + `mob_screen.ex` / `mob_app.ex` / `mob.exs` / `.erl` content generators; + `resolve_deps`, `assigns`, `templates_root`, `static_root`, `expand_path`, + the secret-key helpers, `apply_python_patches`) are **duplicated** from + mob_new's `LiveViewPatcher` / `ProjectGenerator` into + `MobDev.Adopt.Patcher` / `MobDev.Adopt.Generator`. Only the transitive + closure adopt actually exercises was copied. + +mob_new is **untouched** — `mix mob.new` still uses its own copies. This is a +duplication, not a move. + +The native Android/iOS trees render from mob_new's `priv/templates/mob.new/` +(the templates belong to the generator, not the build toolkit). Rather than +duplicate the template files, `mix mob.adopt --android/--ios` **requires the +mob_new archive installed** (`mix archive.install hex mob_new`) alongside +mob_dev as a project dep. Mix puts an installed archive on the code path, so +`Generator.templates_root/1` resolves them via `:code.priv_dir(:mob_new)` at +runtime — the same mechanism `mix mob.new` uses to load its own templates — +falling back to `$MOB_NEW_DIR` / `~/code/mob_new` for local development and +raising a clear "install the mob_new archive" message if none is reachable. + +## Consequences + +- Two copies of the patcher/generator helpers exist (mob_new + mob_dev) until + **Phase 5 of `build_system_migration.md`** reunifies them behind a single + Igniter-based path. Both copies preserve the runtime `Regex.compile!/1` + form (rule #9 — `~r//` literals call `:re.import/1`, removed in OTP 28.0); + no `~r//` literals were reintroduced. The mirror has the same drift risk as + `MobNew.NdkVersion` ↔ `MobDev.NdkVersion`; adopt's `Generator` calls + `MobDev.NdkVersion.recommended/0` directly rather than carrying yet another + mirror. +- adopt requires mob_new's templates at runtime for the native path. This is + an **accepted, documented contract** — the dual requirement (mob_new archive + installed + mob_dev as a dep) keeps mob_new the single source of native + templates and avoids duplicating/drifting them across repos. The Elixir-side + adoption (deps, LV bridge, `mob.exs`, MobScreen) is fully self-contained in + mob_dev and needs no archive. Phase 5 of `build_system_migration.md` may + revisit how templates are shared, but the cross-repo template source is + intentional, not a stopgap. +- The acceptance test (`test/acceptance/mob_adopt_acceptance_test.exs`, + `@tag :acceptance`) wires the generated project to mob_dev via a `path:` dep + (the checkout under test) + `:igniter`, then runs `mix mob.adopt`. It needs + `phx_new`, network for `deps.get`, and a mob_new checkout via `MOB_NEW_DIR`; + it skips cleanly when those are absent. +- adopt remains pre-1.0 detect-and-refuse: `AdoptGuard` adds Igniter issues + (no file changes) on umbrella / non-Phoenix / customised `app.js` or root + layout / non-SQLite LV hosts. Widen the guard, never the silent-proceed path. diff --git a/decisions/2026-06-22-scaffold-derives-mob-requirement.md b/decisions/2026-06-22-scaffold-derives-mob-requirement.md new file mode 100644 index 0000000..d34d6ee --- /dev/null +++ b/decisions/2026-06-22-scaffold-derives-mob-requirement.md @@ -0,0 +1,47 @@ +# Scaffolded plugins derive their mob version requirement + +- Date: 2026-06-22 +- Status: accepted + +## Context + +`mix mob.new_plugin` (via `MobDev.Plugin.Scaffold`) hard-coded the mob +dependency requirement in two places: `{:mob, "~> 0.6"}` in the generated +`mix.exs` and `mob_version: "~> 0.6"` in each tier's `priv/mob_plugin.exs` +manifest. Published mob is 0.7.x, so a freshly scaffolded plugin failed +activation with `installed :mob 0.7.x does not satisfy mob_version "~> 0.6"`, +and `mix deps.get` resolved a stale mob. Reported as issue #21, surfaced by the +mob_ci harness which dogfoods `mob.new_plugin --tier 0..4` and had to hand-bump +the generated fixtures. + +The literal also lived in five separate template strings, so the two pins could +drift apart and nothing caught a stale value before a user hit it. + +## Decision + +Derive the requirement instead of hard-coding it. + +- `Scaffold.mob_requirement/1` (pure) maps a concrete version to `"~> MAJOR.MINOR"`, + or returns the compiled `@fallback_mob_requirement` on `nil`. +- `Scaffold.detect_mob_requirement/0` (impure) prefers the version of `:mob` + actually resolved in the current project (`Application.spec(:mob, :vsn)` after + `Application.load/1`), falling back to the constant when mob isn't loadable + (scaffolding standalone, outside a host app). The `mix mob.new_plugin` task + calls this and threads the result into `Scaffold.files_for/3`; the templates + stay pure and unit-testable. +- A single `@fallback_mob_requirement "~> 0.7"` is the only literal; `mix.exs` + and all manifests interpolate the threaded value, so they can't disagree. +- A `Scaffold` test pins the default to a parseable `~> X.Y`, asserts every + tier's `mix.exs` and manifest agree, asserts it is not the abandoned `~> 0.6`, + and validates a generated manifest against a version that satisfies the default + (derived from the requirement, so the test tracks future bumps). + +## Consequences + +- A plugin scaffolded inside a mob 0.7.x app pins `"~> 0.7"`; one scaffolded with + no mob present gets the constant. Both activate against current mob. +- When mob's major.minor moves again, bump `@fallback_mob_requirement` — the + test guards against silently shipping the old floor, but the constant still + needs a human bump (mob_dev does not depend on mob, so CI here can't compare + against the latest published mob automatically). +- `files_for/2` callers keep working via the defaulted third argument. diff --git a/decisions/2026-07-04-blank-ios-plugin-bootstrap.md b/decisions/2026-07-04-blank-ios-plugin-bootstrap.md new file mode 100644 index 0000000..c2f4c7a --- /dev/null +++ b/decisions/2026-07-04-blank-ios-plugin-bootstrap.md @@ -0,0 +1,59 @@ +# Blank iOS apps must still emit the plugin bootstrap + +- Date: 2026-07-04 +- Status: accepted +- Issue: MOB-7 + +## Context + +`mix mob.new foo --blank --ios` + `mix mob.deploy --native --ios` failed to link: + +``` +Undefined symbols for architecture arm64: + "_mob_register_plugins", referenced from: + -[AppDelegate application:didFinishLaunchingWithOptions:] in AppDelegate.o +``` + +The generated `AppDelegate.m` (mob_new template) *always* declares and calls +`mob_register_plugins()`. That symbol is *defined* by the generated Swift +bootstrap (`MobDev.Plugin.IOSBootstrap.swift_source/1`, emitted via +`generate_ios_plugin_bootstrap/1`). But `native_build.ex` only generated the +bootstrap when `activated_plugins != []` — both the sim and device paths short- +circuited to `{"", ""}` for a plugin-less app. So a `--blank` app has a call with +no definition. Non-blank apps happened to work only because an activated plugin +triggered bootstrap generation (its empty body still defines the symbol). + +The original guard existed for a real reason: an app scaffolded *before* the +plugin system has no `plugin_swift_files` option in its `ios/build.zig`, so +passing `-Dplugin_swift_files` would be an unknown-option error. Its assumption — +"a plugin-less app never calls the bootstrap" — went stale when the AppDelegate +template was changed to always call it. + +## Decision + +Gate bootstrap generation on the **app's build file capability**, not on whether +plugins are activated. `build_file_supports_plugins?/1` (pure, tested) checks the +`ios/build.zig` / `ios/build_device.zig` for the `plugin_swift_files` token: + +- **Plugins activated** → plugins' Swift + bootstrap (unchanged). +- **No plugins, but build file supports `plugin_swift_files`** (current template, + whose AppDelegate always calls the symbol) → emit the empty bootstrap so + `mob_register_plugins` is defined. +- **No plugins, legacy build file** (no option, AppDelegate never calls it) → + empty flags, omitted — legacy apps keep building. + +The presence of the `plugin_swift_files` option and the AppDelegate's call to +`mob_register_plugins` are generated together, so the token is a sound proxy for +"this app expects the bootstrap symbol." Both iOS paths share one helper, +`ios_plugin_swift_and_frameworks/3`. + +## Consequences + +- `--blank --ios` apps link again; verified end-to-end on a physical iPhone SE + (build + install succeeded, no undefined symbol). +- A zero-plugin app now compiles one extra ~10-line Swift file (empty + `mob_register_plugins`) — negligible. +- Legacy pre-plugin scaffolds are unaffected (fall through to empty flags). +- Follow-up: the mob_new `AppDelegate.m.eex` could guard the call in a `#if` + instead, but keying off the build file keeps the fix entirely in mob_dev and + matches how the flags are already conditionally emitted. diff --git a/decisions/2026-07-05-asc-api-key-provisioning.md b/decisions/2026-07-05-asc-api-key-provisioning.md new file mode 100644 index 0000000..dff57fd --- /dev/null +++ b/decisions/2026-07-05-asc-api-key-provisioning.md @@ -0,0 +1,47 @@ +# App Store Connect API key for headless provisioning + +- Date: 2026-07-05 +- Status: accepted + +## Context + +`mix mob.provision` authenticates `xcodebuild -allowProvisioningUpdates` against +Apple using the **signed-in Xcode Apple ID account** (Xcode → Settings → +Accounts). That account is per-macOS-user and only settable through Xcode's GUI, +so an unattended user — a CI runner, or an isolated headless *agent* account with +no GUI login — can register the signing identity but cannot provision (create / +refresh profiles, register devices). The cert + private key path already works +headlessly (a keychain the codesign step can read); only the Apple-contact step +was gated on the interactive account. + +## Decision + +Support an **App Store Connect API key** (`.p8`) as an alternative auth path for +the `xcodebuild -allowProvisioningUpdates` call, selected via three env vars: + +- `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_API_KEY_PATH` + +`asc_auth_args/1` (pure, `@doc false`, tested) maps them to xcodebuild's +`-authenticationKeyID` / `-authenticationKeyIssuerID` / `-authenticationKeyPath`. + +- **Env vars, not a flag or mob.exs.** The key is a secret + machine-specific; env + keeps it out of args history and out of the repo, and is the natural fit for a + headless account's shell/launchd environment (and standard CI practice). +- **All three or none; partial raises.** A half-set key silently falling back to + account auth would be a confusing "why is it still asking for Xcode?" — so a + partial set is surfaced as an error naming what's missing. +- **Scoped to `mob.provision` only.** The native device build signs directly with + `codesign` + an existing profile (no `-allowProvisioningUpdates`), so it needs + only the keychain + profile, not the API key. Nothing else to thread it through. +- **Early `.p8` existence check** — clearer than an opaque xcodebuild failure. + +## Consequences + +- Unattended users provision by exporting the signing identity into an unlocked + keychain + setting the three env vars — no Xcode GUI account. Interactive users + are unaffected (none set ⇒ prior account-based behavior). +- The API key must have a role that can manage certificates/profiles/devices + (Admin or App Manager). Signing still requires the cert + private key in an + unlocked keychain — the key only authorizes the Apple-contact step. +- Follow-up: the runtime console preamble still prints "Xcode signed in" as step + 2; could branch on the env vars to show the API-key path instead (cosmetic). diff --git a/decisions/2026-07-07-ios-release-links-plugin-nifs.md b/decisions/2026-07-07-ios-release-links-plugin-nifs.md new file mode 100644 index 0000000..5898c06 --- /dev/null +++ b/decisions/2026-07-07-ios-release-links-plugin-nifs.md @@ -0,0 +1,65 @@ +# iOS release build compiles + links activated-plugin NIFs + +- Date: 2026-07-07 +- Status: accepted + +## Context + +`mix mob.release --ios` produced a binary that failed to link for any app with +NIF plugins: + +``` +Undefined symbols for architecture arm64: + "_mob_camera_nif_nif_init", referenced from: + _erts_static_nif_tab in driver_tab_ios.o + ... (one per activated plugin) +``` + +iOS statically links every NIF into the single app binary (no `dlopen` under the +App Store sandbox), so `priv/generated/driver_tab_ios.c` references each activated +plugin's `<module>_nif_init`. The **dev** path (`native_build.ex` → +`build.zig -Dplugin_c_nifs`, fed by `MobDev.Plugin.Merge.nif_sources/2`) compiles +those sources in, which is why all plugins work on-device in dev. But the +**release** path (`release.ex` → `release_device.sh`) is a separate hand-rolled +clang/swiftc script that predates plugin support: it compiled a fixed object list +(MobNode, mob_nif, mob_beam, driver_tab, …) and never touched plugins. Result: the +driver table declared the symbols, nothing defined them, link failed. + +Surfaced shipping Sloppy Joe (activates all 10 capability plugins) to the App +Store. Android was unaffected — it loads NIFs from per-ABI `.so`s, not a single +static binary. + +## Decision + +Bring the release path to parity with the dev path. `release_env/2` now emits two +env vars from `MobDev.Plugin.activated()`, via the pure, unit-tested +`Release.plugin_ios_build_env/1`: + +- `MOB_PLUGIN_IOS_NIF_SOURCES` — absolute paths of each activated plugin's iOS + C/ObjC NIF source (`Merge.nif_sources(activated, :ios)`). +- `MOB_PLUGIN_IOS_FRAMEWORKS` — the union of frameworks the plugins declare + (`Merge.ios_frameworks/1`). + +`release_device.sh` loops over the sources, compiling each with +`-DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME=<basename>` (so `ERL_NIF_INIT` +emits `<basename>_nif_init`, matching the driver table) and `-fmodules` (Clang +autolinks every framework the source `@import`s — a plugin often imports beyond its +manifest's declared set, e.g. Accelerate). The compiled objects join the swiftc +link line, and each declared framework is also passed explicitly. + +## Consequences + +- Any multi-plugin mob app can now produce a store-ready iOS binary. Verified: + Sloppy Joe's 10 ObjC NIF plugins compile + link, and the IPA code-signs + + validates against its App Store profile. +- Scope: this covers `nif_sources` (`lang: :c | :objc`) + `ios_frameworks`, which + is what every current plugin uses. The dev path also handles plugin + `swift_files` and `static_archives` (`:cpp_archive`, e.g. mob_nx_eigen); the + release path does **not** yet. No shipped plugin needs those on iOS today, so + they're a documented follow-up rather than untested code. A plugin that adds an + iOS Swift source or cpp-archive NIF would relink-fail the same way until then. +- Tests: `plugin_ios_build_env/1` gets a pure matrix (none / one / many / + platform-filtered) in `release_test.exs`; `release_script_test.exs` asserts the + script shape (compile loop, libname derivation, `$PLUGIN_OBJS` on the link, + framework flags) so a regression is caught at `mix test`, not in a TestFlight + round trip. diff --git a/decisions/2026-07-08-ios-icons-flattened-opaque.md b/decisions/2026-07-08-ios-icons-flattened-opaque.md new file mode 100644 index 0000000..028eec1 --- /dev/null +++ b/decisions/2026-07-08-ios-icons-flattened-opaque.md @@ -0,0 +1,55 @@ +# iOS app icons are flattened opaque; Android keeps transparency + +- Date: 2026-07-08 +- Status: accepted + +## Context + +App Store upload validation rejects an app whose 1024×1024 marketing icon +carries an alpha channel: + +``` +ITMS/altool error 90717: Invalid large app icon. The large app icon in the +asset catalog … can't be transparent or contain an alpha channel. +``` + +`MobDev.IconGenerator.write_ios_icons/2` simply `Image.thumbnail!`'d the source +into each iOS size, so a source PNG with transparency (a very common icon design +— a rounded badge on transparent corners) produced transparent iOS icons and +tripped 90717. The bundled fallback `mob_logo` iOS assets had the same problem. + +Android must **not** be flattened: adaptive-icon foreground layers and legacy +launcher icons rely on transparency, and a flat background renders badly on some +launchers/versions. So the fix has to be platform-specific, not a blanket strip. + +## Decision + +Flatten **iOS only**. `write_ios_icons/3` now runs the source through +`flatten_for_ios/2` before resizing: if the source has an alpha channel it's +composited onto an opaque background via `Image.flatten!/2`; otherwise it's left +untouched. The background colour is the explicit `:background_color` option when +given, else sampled from the source with the same `extract_background_color/1` +the adaptive Android background uses — so the opaque iOS icon and the Android +adaptive background share one colour. `mix mob.icon` threads `--adaptive-bg` to +both platforms. + +Android paths (`write_android_icons/2`, `write_adaptive_foregrounds/2`) are +unchanged and keep the source's transparency. + +The bundled fallback `mob_logo` iOS-size PNGs (used when the `image` dep is +absent, so they can't be flattened at runtime) were pre-flattened opaque in +`priv/mob_logo/`. iOS and Android icon sizes are disjoint files there, so the +Android-size assets keep their transparency. + +## Consequences + +- Any mob app that ships a transparent source icon (or the default placeholder) + now produces an App-Store-valid opaque iOS icon set, while Android keeps its + adaptive transparency. Verified on Sloppy Joe (rounded-badge icon): the + rebuilt IPA passed App Store validation and reached TestFlight. +- Tests (`icon_generator_test.exs`): a transparent source yields alpha-free iOS + icons and alpha-bearing Android icons; an explicit `:background_color` fills + the flattened icon; an opaque source is left unflattened. +- The iOS icons are full-bleed opaque squares (iOS applies its own corner mask), + which is the platform-correct treatment — not the rounded-badge-with-margin + look that suits transparent Android/desktop contexts. diff --git a/decisions/2026-07-24-release-otp-zip-variant-scoped-assets.md b/decisions/2026-07-24-release-otp-zip-variant-scoped-assets.md new file mode 100644 index 0000000..17dc766 --- /dev/null +++ b/decisions/2026-07-24-release-otp-zip-variant-scoped-assets.md @@ -0,0 +1,60 @@ +# Release otp.zip moves to the release-variant asset source set + +- Date: 2026-07-24 +- Status: accepted + +## Context + +`mix mob.deploy --native` on Sloppy Joe reported success and every +intermediate step verified correct (fresh BEAM staged, `tar cf` built the +right archive, `adb push` succeeded, `run-as tar xf` returned exit 0 with no +stderr) — yet the on-device `Elixir.SloppyJoe.DefaultScreens.beam` stayed at +its old size and content after every deploy. + +Root-caused by direct device inspection: the debug APK's `assets/otp.zip` +(bundled by `MobDev.ReleaseAndroid.build_zip/2`, normally a release-only +artifact) contained a stale snapshot of that exact BEAM file. `mix +mob.release --android` had written it to `android/app/src/main/assets/otp.zip` +— the **shared `main`** Gradle asset source set, which every build variant +merges, debug included. `MobBridge.kt`'s `extractOtpIfNeeded()` re-extracts +`otp.zip` whenever `PackageInfo.lastUpdateTime` changes (i.e. on every +reinstall, debug or release), wiping `<filesDir>/otp/` and restoring +whatever was bundled at the time of the *last release build* — silently +overwriting BEAMs the debug deploy had just pushed fresh via `adb`. + +The comment on `extractOtpIfNeeded()` already assumed debug builds carry no +`otp.zip` at all ("no asset zip exists and this method becomes a no-op") — +that assumption only holds if nothing else leaves one behind in `main`. + +## Decision + +`MobDev.ReleaseAndroid` now writes the release OTP bundle to +`android/app/src/release/assets/otp.zip` — a **build-variant-scoped** Gradle +source set. Gradle only merges `src/<variant>/assets/` into matching-variant +builds, so a release build can never again leave an asset that a debug build +picks up, regardless of whether anyone remembers to clean up afterward. + +Defense in depth for checkouts that already carry the old, dangerous file: +`MobDev.NativeBuild.remove_stale_release_otp_zip/1` runs at the start of +every debug `gradle_assemble/0`, deleting +`android/app/src/main/assets/otp.zip` if present. New release builds won't +recreate it there, but this heals any project that shipped a release before +this fix without requiring a manual `rm`. + +## Consequences + +- `mix mob.release --android` output path changes from + `src/main/assets/otp.zip` to `src/release/assets/otp.zip`. No other code + read the old path directly (confirmed via repo-wide grep); `bundleRelease` + picks up the new location automatically via Gradle's standard variant + source-set merging. +- Verified end-to-end on a physical device (Moto G Power, non-rooted, + `run-as` fallback push path): with the stale `src/main/assets/otp.zip` + removed, `mix mob.deploy --native` correctly left fresh BEAM content + on-device; restoring the stale file reproduced the staleness bug exactly, + confirming this was the sole cause (the beam push/tar/extraction mechanism + itself was never at fault). +- This was previously worked around ad hoc ("rm src/main/assets/otp.zip + before debug deploys") after an earlier session traced the same leftover + file to a *different* symptom (a debug crash-loop). That workaround is now + obsolete — the fix is structural, not a manual step to remember. diff --git a/guides/nifs.md b/guides/nifs.md new file mode 100644 index 0000000..91e6f28 --- /dev/null +++ b/guides/nifs.md @@ -0,0 +1,688 @@ +# Static NIFs + +Mob ships NIFs **statically linked** into the main app binary. This is +non-negotiable on mobile: + +- **iOS App Store** rejects bundled `.dylib` files outright. A + dlopen'd NIF can't pass review. +- **Android `RTLD_LOCAL`** hides the parent process's `enif_*` + symbols from a child library loaded by `System.loadLibrary` or + `dlopen`. A NIF that looks for `enif_make_atom` at load time + doesn't find it. + +Both platforms point at the same answer: link the NIF's init function +into the main binary alongside `libbeam.a`, and register it in a +**static NIF table** so `load_nif/2` resolves to the embedded code +instead of opening a shared library. + +`mix mob.add_nif <name>` is the single entry point. The four backends +(`c`, `rustler`, `zigler`, `elixir-only`) all produce the same shape +of artifact for the linker — a `lib<name>.a` (or `<name>.o`, for C) +exporting `<name>_nif_init`. What differs is the language you write +the NIF body in and the toolchain that produces the archive. + +This guide is the **contract per backend**: how each upstream library +normally works, and what Mob changes (or leaves alone) to make +on-device static linking work. The aim is that a user — or an agent +acting on their behalf — never has to read the build code to answer +"what does Mob do with my Rust crate?" + +For app-level integration of Python specifically (wheel handling, +asset extraction, the host-dev fallback), see +[`python_embedding.md`](python_embedding.md) — this guide covers only +the NIF-layer story. + +--- + +## Anatomy of a static NIF in Mob + +Every backend ends up at the same three artifacts in the same three +places. The differences below are about *how each gets generated*, +not *what it is at link time*. + +| Artifact | Where it lives | Who writes it | +|----------|---------------|---------------| +| Elixir stub module | `lib/<app>/nifs/<name>.ex` | `mob.add_nif` scaffolds; you fill in function signatures | +| Native source | `c_src/<name>.c`, `native/<name>/src/lib.rs`, or `~Z` block in the stub | depends on backend | +| Static archive | `lib<name>.a` (cross-compiled per arch) | `mob_dev` invokes the backend's toolchain | + +The link-time dispatch table — `priv/generated/driver_tab_ios.zig` +and `priv/generated/driver_tab_android.zig` — declares +`<name>_nif_init` as `extern fn` and adds it to +`erts_static_nif_tab[]`, which the BEAM consults instead of `dlopen` +when the Elixir stub calls `:erlang.load_nif/2`. The table is +regenerated from `mob.exs`'s `:static_nifs` list by +`mix mob.regen_driver_tab` (which `mob.add_nif` composes +automatically, so you rarely call it directly). + +[`MobDev.StaticNifs`](`MobDev.StaticNifs`) is the schema reference for +the manifest entries. + +--- + +## C + +### How `erl_nif` normally works + +A C NIF is a `.c` file that includes `<erl_nif.h>`, defines a few +functions matching the `ErlNifFunc` table, and ends with the +`ERL_NIF_INIT` macro. The Erlang VM compiles it as a shared library, +the Elixir module's `:erlang.load_nif/2` dlopens that library, and +the BEAM looks up the init symbol — which is hardcoded as plain +`nif_init` in dynamic mode. + +Upstream reference: [the `erl_nif` man page](https://www.erlang.org/doc/apps/erts/erl_nif.html) +and the [User's Guide tutorial](https://www.erlang.org/doc/system/nif.html). + +### How Mob handles it + +Mob compiles the same `.c` source as a regular `.o` file and links it +straight into the app binary alongside `libbeam.a`. Two compile-time +flags switch `<erl_nif.h>` from "dlopen mode" into "static mode": + +- `-DSTATIC_ERLANG_NIF` — selects the static-link dispatch path inside + `erl_nif.h`. Without it the `ERL_NIF_INIT` macro emits the dynamic + `nif_init` symbol, which collides across multiple NIFs. +- `-DSTATIC_ERLANG_NIF_LIBNAME=<name>` — overrides the init symbol + to `<name>_nif_init`. The static table declares it by that exact + name, so they have to match. Without the override, the macro + mangles to `Elixir.<...>_nif_init`, which isn't a valid C + identifier and fails to compile. + +That's all. The C code itself is identical to a portable NIF — no +Mob-specific includes, no special prologue. You can prototype a NIF +against any vanilla BEAM via `mix compile` then drop it into Mob +unchanged. + +Scaffold: + +```bash +mix mob.add_nif audio_engine --type c +``` + +Drops `c_src/audio_engine.c` with the macro pre-wired, generates the +Elixir stub, appends `%{module: :audio_engine, archs: [:all]}` to +`mob.exs`'s `:static_nifs`, and re-runs `mob.regen_driver_tab`. + +--- + +## Rust via Rustler + +### How Rustler normally works + +A standard Rustler project has a Cargo crate (typically at +`native/<crate>/`) declared with `crate-type = ["cdylib"]`. Cargo +builds a `.so` containing `#[rustler::nif]`-annotated functions +registered via the `rustler::init!(...)` macro. The Elixir side calls +`use Rustler, otp_app: :app, crate: "name"`, which: + +1. Invokes Cargo at compile time +2. Copies the produced `.so` to `priv/native/<crate>.so` +3. Wires `:erlang.load_nif/2` to that path so the BEAM dlopens it + +Upstream reference: [the Rustler crate](https://docs.rs/rustler/) and +the [Rustler GitHub README](https://github.com/rusterlium/rustler). + +### How Mob handles it + +Three differences from the standard flow. None of them touch the Rust +source code. + +**1. Dual `crate-type`.** Mob's scaffolded Cargo.toml has: + +```toml +crate-type = ["staticlib", "cdylib"] +``` + +The `cdylib` keeps host-dev's `mix compile` working — same +dlopen-the-`.so` path Rustler users know. The `staticlib` is what +mob_dev's cross-compile actually consumes: `cargo rustc --crate-type +staticlib --target <arch>` produces a `lib<name>.a` that's linked +into the main app binary on-device. + +**2. Symbol convention requires rustler 0.37+.** Rustler 0.37 changed +the static-NIF init symbol to derive from `CARGO_CRATE_NAME` as +`<crate>_nif_init`. Earlier versions hardcoded plain `nif_init`, +which would collide if you had multiple Rust NIFs in one app (linker +errors on duplicate symbols). Mob's `driver_tab` declares each NIF +by `<crate>_nif_init`, so the pin to 0.37 is load-bearing — don't +silently downgrade it. + +**3. Android dlsym workaround (transient).** Rustler 0.37's +`nif_filler` uses `dlopen(NULL)` to locate `enif_*` symbols at NIF +init. On Bionic that handle resolves to a namespace which doesn't +include the app's own `.so` siblings, even when they're marked +`RTLD_GLOBAL`. Every NIF init panics with `undefined symbol: +enif_priv_data`. + +The scaffolded Cargo.toml carries a `[patch.crates-io]` block +pointing at [GenericJam/rustler:genericjam-android-rtld-default](https://github.com/GenericJam/rustler/tree/genericjam-android-rtld-default), +which patches the Android branch to do `dladdr` + `dlopen(self, +RTLD_NOLOAD)` for an explicit self-handle. iOS/macOS/Linux paths in +the fork are unchanged. Tracker: [mob#7](https://github.com/GenericJam/mob/issues/7). +Drop the patch block (and bump the version pin) once upstream +rustler merges the fix. + +### What stays standard + +Everything in the Rust source. You can: + +- Put as many `.rs` files in `native/<name>/src/` as you want, organized + via standard `mod foo;` / `mod bar;` declarations. +- Add any Cargo dependency to `[dependencies]` — `serde`, `tokio`, + `bytes`, anything. Cargo handles resolution and linking. +- Write unit tests with `cargo test` and run them on the host like + any other crate. +- Use Rustler's full surface — `Term`, `Atom`, `Encoder`/`Decoder`, + `ResourceArc`, `NifTuple`, etc. — without modification. + +### Preferred shape: one Rust crate, many NIFs + +If you have several pieces of Rust functionality, the recommended +shape is **one Rustler crate per app, with multiple `#[rustler::nif]` +functions inside it**, registered by a single `rustler::init!(...)` +call. This is what Rustler is designed around — dynamic loading of one +shared library is the well-supported path, and our static-link pipeline +inherits the same single-`<crate>_nif_init` symbol model. + +```rust +// native/my_app/src/lib.rs +#[rustler::nif] fn audio_decode(bytes: Binary) -> NifResult<...> { ... } +#[rustler::nif] fn audio_encode(samples: Vec<f32>) -> NifResult<...> { ... } +#[rustler::nif] fn image_resize(buf: Binary, w: u32, h: u32) -> NifResult<...> { ... } + +rustler::init!("Elixir.MyApp.Native", [audio_decode, audio_encode, image_resize]); +``` + +```elixir +# Elixir-side wrappers can still live in topical modules: +defmodule MyApp.Audio do + defdelegate decode(bytes), to: MyApp.Native, as: :audio_decode + defdelegate encode(samples), to: MyApp.Native, as: :audio_encode +end +``` + +Why this is preferred (paraphrasing the upstream Rustler maintainer +[filmor](https://github.com/rusterlium/rustler/issues/686#issuecomment-2725879502)): +Rustler's design assumes one shared library loaded via `load_nif`. +Static linking is something we layer on top with a recompiled BEAM +(see `libbeam.a`) and Rustler 0.37's per-crate symbol mangling — the +narrower you stay to "one crate, one init", the more upstream +fixes and updates apply to you cleanly. + +Internal organisation inside the single crate is unrestricted — +`mod audio;`, `mod image;`, separate sub-modules with their own +sub-deps in `[dependencies]`, anything Cargo allows. + +### Multiple Rust crates per app (escape hatch) + +If you genuinely need multiple separate Rustler crates in one app +(e.g., crates whose Cargo dependency trees would conflict, or where +you want each Elixir-side module to own its own `:erlang.load_nif/2`), +Mob supports it: each `mob.exs :static_nifs` entry whose name matches +a `native/<name>/Cargo.toml` is cross-compiled to its own `lib<name>.a` +and linked. `nif_combo` ships three NIFs (`greet_c` + `greet_rust` + +`greet_zig`) in one app as a working example. + +Caveats compared to the single-crate preferred shape: + +- Each crate brings its own copy of any shared dependency unless you + set up a Cargo workspace (which the scaffold doesn't generate but + Cargo discovers transparently if you add `native/Cargo.toml`). +- Symbol collisions are avoided only because Rustler 0.37+ mangles + `nif_init` to `<crate>_nif_init` per `CARGO_CRATE_NAME` — silently + downgrading rustler will break the build at link time. +- Bug reports against rustler upstream are more likely to bounce + ("we don't support that configuration") since this is outside the + well-trodden dynamic-load path. The escape valves we ship (the + GenericJam rustler fork for Android, the `<crate>_nif_init` + driver_tab generator) are ours to maintain. + +Scaffold a new crate: + +```bash +mix mob.add_nif audio_engine --type rustler +``` + +If you're scaffolding a second Rustler-backed NIF, prefer adding the +new functions to your existing crate over running `mob.add_nif` +again. + +### Bringing in an existing Rust crate + +`mob.add_nif` is for scaffolding a new NIF from scratch. If you already +have a Rust crate written elsewhere — your own work, a multi-crate +project authored against vanilla Rustler, anything that produces NIFs +the standard way — Mob doesn't auto-import it, but the manual hookup +is short. The four steps below are the whole list. + +**1. Drop the crate(s) into `native/<name>/`.** Each crate gets its own +directory with its own `Cargo.toml` and `src/` tree. Mob doesn't care +about Rust-internal structure — files, submodules, `build.rs`, bench +targets, external deps, subdirectory module trees — that's all cargo's +business. Mob compiles each crate via: + +``` +cargo rustc --release --target <arch> --crate-type staticlib \ + --manifest-path native/<name>/Cargo.toml +``` + +Whatever cargo can build, Mob can ship. + +**2. Per Cargo.toml: add `staticlib` to `crate-type`.** A standard +Rustler crate has: + +```toml +[lib] +crate-type = ["cdylib"] +``` + +Mob needs: + +```toml +[lib] +crate-type = ["staticlib", "cdylib"] +``` + +The `cdylib` keeps host-dev (`mix compile` on Mac/Linux) working +through Rustler's normal dlopen path. The `staticlib` is what Mob's +cross-compile consumes for the on-device link. One-line edit per +crate. + +**3. Per Cargo.toml: add the Android dlsym patch.** Until upstream +rustler ships the dladdr+dlopen(NOLOAD) fix (see "When the upstream +lands its own fix" below), each crate's Cargo.toml needs: + +```toml +[patch.crates-io] +rustler = { git = "https://github.com/GenericJam/rustler.git", + branch = "genericjam-android-rtld-default" } +``` + +Without it, NIF init panics on Android with `undefined symbol: +enif_priv_data`. iOS/macOS/Linux are unaffected. + +**4. Register each crate in `mob.exs`.** Open `mob.exs` and add one +entry per crate to `:static_nifs`: + +```elixir +config :mob_dev, + static_nifs: [ + %{module: :dsp_utils, archs: [:all]}, + %{module: :phy_modem, archs: [:all]}, + %{module: :melpe, archs: [:all]} + ] +``` + +The `module:` atom matches the directory name under `native/` and +the `[lib] name = "..."` in that Cargo.toml. Then run: + +```bash +mix mob.regen_driver_tab +``` + +which rewrites `priv/generated/driver_tab_{ios,android}.zig` to +declare and dispatch each new init function. `mob.add_nif` calls this +for you; when bringing in crates by hand, run it explicitly. + +The Elixir-side stubs (`lib/<app>/nifs/<name>.ex`) are also up to you +to write. The pattern is: + +```elixir +defmodule MyApp.Nifs.PhyModem do + use Rustler, otp_app: :my_app, crate: "phy_modem" + + def modulate(_input), do: :erlang.nif_error(:nif_not_loaded) + # … one stub per #[rustler::nif] fn in the crate … +end +``` + +That's the full list. No further wiring is needed. `mix mob.deploy +--native` cross-compiles every registered crate, links each +`lib<name>.a` into the app binary, and `:erlang.load_nif/2` resolves +to the static dispatch table at startup. + +**Where to run mob commands from.** Mob resolves `native/<name>/` +relative to the current working directory. For a standalone project +that's the project root. **For an umbrella project, run mob commands +from the child app directory** (the one whose `apps/<app>/` contains +`mob.exs` + `ios/` + `android/` + `native/`), not from the umbrella +root. There is no umbrella-aware app selection (yet); running from +the wrong directory silently finds no `native/` entries and emits no +NIFs. + +**One-time toolchain prerequisites** — same as any cross-compiled +Rust project, not Mob-specific: + +```bash +rustup target add aarch64-apple-ios aarch64-apple-ios-sim aarch64-linux-android armv7-linux-androideabi +``` + +`mix mob.doctor` verifies these are installed and flags missing ones. + +**Caveat for external Cargo deps.** Pure-Rust dependencies +(`rustfft`, `serde`, `tokio`, etc.) cross-compile cleanly to all four +Mob targets and need no special handling. Crates that pull in C via +`build.rs` or `bindgen` may need the corresponding C library +available for the target — that's upstream's concern, same as in any +non-Mob cross-compile. If `cargo rustc --target aarch64-linux-android` +fails for a transitive C dep, that's not a Mob issue; check the dep's +own cross-compile instructions. + +--- + +## Zig via Zigler + +### How Zigler normally works + +[Zigler](https://hex.pm/packages/zigler) lets you embed Zig directly +in an Elixir module via the `~Z` sigil, compiles it through Zig's +build system at `mix compile` time, and exposes each `pub fn` as a +NIF function on the module. Standard usage produces a dynamically +loaded `.so` — same dlopen-at-load model as Rustler's default. + +Upstream reference: [the Zigler hex docs](https://hexdocs.pm/zigler/) +and the [Zigler GitHub README](https://github.com/E-xyza/zigler). + +### How Mob handles it + +Mob uses a fork of Zigler pinned via the scaffolded `mix.exs`: + +```elixir +{:zigler, github: "GenericJam/zigler", branch: "zig-016-port"} +``` + +Two reasons the fork exists, both invisible to Zig source code: + +**1. macOS 26 (Sequoia/Tahoe) compatibility.** Upstream Zigler pins +Zig 0.15.x, whose bundled `compiler_rt` references +`__availability_version_check` and friends that aren't present in +the macOS 26 SDK. Compiles fail with a cascade of POSIX +symbol-undefined errors on developer machines that have upgraded +past the SDK boundary. The fork ports `priv/beam/` to Zig 0.16.0, +which works against the macOS 26 SDK. + +**2. Static-NIF init symbol collision.** Zigler 0.16's emitted NIF +init function was a bare `nif_init` (same name Rustler ≤0.36 used). +When you statically link a Zig NIF alongside any other NIF in +mob's `driver_tab`, the linker merges them into one symbol and +silently corrupts per-module dispatch. The fork honors a +`-Dnif_init_alias=<name>_nif_init` flag so each Zig NIF gets a +unique exported init symbol, matching what `driver_tab` declares. + +Once upstream Zigler ships Zig 0.16 support natively and accepts a +per-NIF init alias, the fork dissolves. Track upstream issue #578 and +PR #579. + +The `mob.add_nif --type zigler` scaffold also runs `mix zig.get` +afterward so Zigler's `executable_path` lookup finds the cached Zig +0.16.0 before falling through to `PATH` (which on most mob +developer machines points at Zig 0.17-dev — wrong stdlib for the +port). + +### What stays standard + +The Zig source itself. Any `pub fn` becomes a NIF; Zigler's +type-mapping table works as documented upstream; resource types, +beam.send, etc. all behave identically to non-Mob Zigler projects. + +Scaffold: + +```bash +mix mob.add_nif audio_engine --type zigler +``` + +--- + +## Python via Pythonx + +Python is a different beast from C/Rust/Zig — it's not a single NIF +crate you compile, it's a whole interpreter that ships inside the +app. The static-link mechanics still apply (the **Pythonx** NIF is +statically linked, just like any other Rust NIF), but the Python +runtime + standard library + C extensions need to come from +somewhere, and there's no upstream that ships *one* binary +distribution covering iOS and Android. + +### How Pythonx normally works + +[Pythonx](https://hex.pm/packages/pythonx) embeds CPython into the +BEAM via a NIF (written in Rust, using +[PyO3](https://github.com/PyO3/pyo3)). On a developer machine, it +fetches CPython through [`uv`](https://github.com/astral-sh/uv) on +first run, installs it under a project-local cache, and `dlopen`s +`libpython` from there. `Pythonx.eval/2` runs Python code in that +in-process interpreter. + +Upstream reference: [Pythonx hex docs](https://hexdocs.pm/pythonx/) +and the [Pythonx GitHub README](https://github.com/livebook-dev/pythonx). + +### How Mob handles it + +**The Pythonx NIF itself** is treated like any other Rust NIF — +cross-compiled to `libpythonx.a`, linked into the main binary, +registered in `driver_tab`. Same static-link story as Rustler above. + +**The Python runtime** is the part that differs. There's no +`uv install` on iOS or Android (no shell, no PATH, sandboxed +filesystem), and Pythonx's normal fetch flow can't run on-device. Mob +ships a pre-built CPython distribution for each platform, bundled +into the app artifact and extracted on first launch. + +Both platforms target **Python 3.13** so user code is portable. The +sources differ because no single upstream ships both: + +**iOS — [BeeWare's Python-Apple-support](https://github.com/beeware/Python-Apple-support).** + +- Version pinned to `3.13-b13` (BeeWare's release tag — Python + 3.13 plus their `b13` build revision). +- Distribution shape: an `Python.xcframework` containing the + arch slices for iOS device + iOS simulator, plus the stdlib + and standard C extensions (`_ssl`, `_ctypes`, `_hashlib`, etc.). +- Tarball URL pattern: `https://github.com/beeware/Python-Apple-support/releases/download/3.13-b13/Python-3.13-iOS-support.b13.tar.gz` +- Implementation: [`MobDev.PythonAppleSupport`](`MobDev.PythonAppleSupport`) + in mob_dev. Cached at `~/.mob/cache/python-apple-support-<version>/`. + +**Android — [Chaquopy](https://chaquo.com/chaquopy/)'s target distribution.** + +- Version pinned to `3.13.9-0` (Chaquopy's `<python>.<patch>-<chaquopy-rev>` + versioning — Python 3.13.9 plus Chaquopy revision 0). +- Distribution shape: per-ABI zips (arm64-v8a + x86_64) containing + `libpython3.13.so` plus `libcrypto_python.so`, `libssl_python.so`, + `libsqlite3_python.so`, the lib-dynload C extensions, and a + separate stdlib zip. +- Maven Central URL pattern: `https://repo1.maven.org/maven2/com/chaquo/python/target/...` +- License: Apache 2.0 (as of 2025). +- Implementation: [`MobDev.PythonAndroidSupport`](`MobDev.PythonAndroidSupport`) + in mob_dev. Cached at `~/.mob/cache/python-android-support-<version>/`. + +**Why two sources?** BeeWare's `Python-Android-support` (the natural +sibling of `Python-Apple-support`) hasn't shipped a release since +Python 3.10. Chaquopy is currently the only actively-maintained +source of pre-built CPython binaries for Android 3.11+. We use it +for the binaries only — Chaquopy's Java↔Python bridge is bypassed +entirely; Pythonx talks to `libpython3.13.so` through the same FFI +contract on both platforms. + +**What this means in practice:** + +- Your Python code runs against CPython 3.13 on both platforms. The + stdlib is BeeWare's pure-Python copy on iOS and Chaquopy's pure- + Python copy on Android — both come straight from python.org's + 3.13 source tree, so they're functionally identical for stdlib + surface. +- Standard C extensions (`_ssl`, `socket`, `_hashlib`, …) are + present on both. Build flags differ slightly between BeeWare and + Chaquopy, so a determined user could find a corner case where + e.g. SSL cipher suites differ — but for nearly all code, the + platforms are interchangeable. +- Patch versions can drift mildly. Today iOS is on Python 3.13.x + (BeeWare b13's underlying CPython tag) and Android is on Python + 3.13.9 (Chaquopy's pin). Both move together in lockstep with our + manual end-to-end validation when either upstream cuts a new + release. +- **Third-party wheels are out of scope.** See + [`python_embedding.md`](python_embedding.md) for the "build your + own wheel" guidance per platform. + +### What stays standard + +Pythonx itself is unchanged. `Pythonx.eval/2`, `Pythonx.encode/1`, +`Pythonx.decode/1`, the `Pythonx.Object` resource — all behave +identically to a host-dev project. Your Python code never sees +that the interpreter came from a different upstream. + +Scaffold: + +```bash +mix mob.enable pythonx # not `mob.add_nif` — Pythonx is an enable, not a generic NIF +``` + +For the app-integration story (when Pythonx initializes, where +extracted files live, how to ship third-party wheels, host-dev +fallback), see [`python_embedding.md`](python_embedding.md). + +--- + +## Multiple NIFs per app, generally + +`mob.exs`'s `:static_nifs` is a list. Each entry follows the schema: + +```elixir +%{module: :nif_name, archs: [:all]} # link on all targets +%{module: :nif_name, archs: [:ios]} # link only on iOS targets +%{module: :nif_name, archs: [:android_arm64]} # narrow to one Android ABI +``` + +See [`MobDev.StaticNifs`](`MobDev.StaticNifs`) for the full schema, +arch atoms, and the per-arch `_nif_init` symbol-name mapping. The +order in the list determines link order, which matters if NIFs have +inter-symbol dependencies (rare) but is otherwise cosmetic. + +`mob.add_nif` always appends with `archs: [:all]` and assumes you'll +narrow that manually if needed. Hand-editing `mob.exs` and re-running +`mix mob.regen_driver_tab` is the supported way to adjust arch +guards after the fact. + +--- + +## Nx backends on mobile + +Nx applications often ask "which backend should I use on phone?" Three +real options, picked by what your app actually needs: + +| Backend | iOS device | iOS sim | Android arm64 | Android arm32 | Enable with | +|---|:---:|:---:|:---:|:---:|---| +| `Nx.BinaryBackend` (pure Elixir) | ✓ | ✓ | ✓ | ✓ | nothing — default | +| NxEigen (Eigen C++, CPU) | ✓ | ✓ | ✓ | ✓ | `mix mob.enable nxeigen` | +| EMLX (Apple MLX, CPU + Metal GPU) | ✓ | ✓ | — | — | `mix mob.enable mlx` | +| EXLA (Google XLA, JIT) | ✗ | ✗ | ✗ | ✗ | not viable on mobile — see below | + +### `Nx.BinaryBackend` + +Pure Elixir. Works everywhere with no setup. Slow for real numerics +work, but if your app is mostly stdlib ops or small tensors, it's the +zero-friction option. + +### NxEigen — the CPU choice that works on both platforms + +[NxEigen](https://github.com/cocoa-xu/nx_eigen) is a CPU Nx backend +over the [Eigen](https://eigen.tuxfamily.org) C++ template library. +Eigen is header-only, vectorised via SSE/NEON, and portable — clang +with any Darwin or Android NDK target will compile it. For Android +this is the only real numerics path (EMLX is Apple-only); for iOS +without a Metal GPU available it's a fine CPU peer to EMLX. + +Run `mix mob.enable nxeigen` in your app and follow the printed next +steps. Mob handles the cross-compile by treating NxEigen's NIF as a +C++ static-NIF entry — the build pipeline cross-compiles a +`libnx_eigen.a` per target arch (arm64-ios, arm64-android, +armv7a-android, etc.) and statically links it into the BEAM the same +way it does the framework's own NIFs. No per-target prebuilt downloads; +the source is two `.cpp` files + Eigen headers, compiled once per +arch. + +FFT support uses Eigen's bundled kissfft (header-only); a FFTW +variant can be substituted later if you need higher throughput. + +### EMLX — Metal GPU on iOS, CPU on iOS sim + +[EMLX](https://github.com/elixir-nx/emlx) is the Elixir wrapper around +Apple's MLX framework. iOS only — Android isn't possible (MLX is +hard-tied to Metal + the Apple BLAS). Run `mix mob.enable mlx` and +follow the printed next steps. The first cross-build downloads +~5 MB compressed (~30 MB on disk per arch) of pre-built `libmlx.a` + +`libemlx.a` into `~/.mob/cache/`. + +By default EMLX uses Apple Accelerate (CPU) — fast, no GPU requirement. +For Metal GPU on iOS device, opt in per backend reference: + +```elixir +Nx.global_default_backend({EMLX.Backend, device: :gpu}) +``` + +Mob's deploy pipeline ships the precompiled `mlx.metallib` (Metal +kernel library) inside the .app bundle so the GPU path works on device +without runtime kernel compilation. iOS simulator doesn't have Metal; +the `:gpu` backend silently falls back to CPU there. + +### Why not EXLA + +EXLA sounds attractive — XLA's JIT compiler is genuinely fast — but +isn't realistic on mobile today. Empirically: EXLA's BEAM modules +ship and the Elixir layer loads, but `EXLA.NIF` is unloadable because: + + 1. `mix compile` on the Mac builds `libexla.dylib` for **macOS arm64 + only** — nothing for iOS or Android targets. + 2. Mob's cross-compile pipeline doesn't touch EXLA's NIF (it only + drives entries declared in `mob.exs :static_nifs` or recognised + Rustler/Zigler crates; EXLA isn't structured as either). + 3. Even if we did cross-compile EXLA's NIF, the runtime would then + `dlopen libxla.so` from the `xla` Hex package — which only ships + prebuilts for desktop/server (`x86_64-linux`, `arm64-darwin`, + etc.). No `arm64-android` or `arm64-ios` prebuilt exists. + +Getting EXLA to work on either mobile platform is two missing +cross-compiles deep — building XLA itself for arm64-ios/android via +Bazel (no published recipe, multi-day project, hundreds of MB output), +then cross-compiling EXLA against that, then wiring both into the +deploy pipeline. Not on the roadmap. + +For ML model inference on mobile via Elixir, the natural future +addition is **TFLite** (TensorFlow Lite), which is Google's blessed +mobile path with native iOS + Android support. Would require writing +TFLite-bindings as a NIF; not in mob today. + +--- + +## Inspecting what got linked + +After `mix mob.deploy --native` finishes: + +```bash +# iOS sim binary, list every static NIF init exported +nm -gU ios/MobApp.app/MobApp | grep _nif_init + +# Android arm64 .so, same +llvm-readelf --dyn-syms android/app/build/intermediates/stripped_native_libs/debug/out/lib/arm64-v8a/lib<app>.so | grep _nif_init +``` + +Each `:static_nifs` entry should appear exactly once. A missing +symbol means the link step didn't see the archive (check +`-Dproject_rust_libs` in the zig invocation); a duplicate means +two scaffolds collided (rename one). + +--- + +## When the upstream lands its own fix + +The three transient items above will each have a clean exit: + +| Backend | Workaround | Drop when | +|---------|-----------|-----------| +| Rustler | `[patch.crates-io]` block in scaffold's Cargo.toml | upstream rustler ships the dladdr+dlopen(NOLOAD) fix for Android (tracker: [mob#7](https://github.com/GenericJam/mob/issues/7)) | +| Zigler | `github: "GenericJam/zigler", branch: "zig-016-port"` pin | upstream Zigler ships Zig 0.16 support AND honors a per-NIF `nif_init_alias` flag (tracker: upstream #578 / #579) | +| Python (Android) | Chaquopy as the source | BeeWare's `Python-Android-support` returns to active maintenance with a 3.11+ release | + +Until then these stay where they are — the scaffold writes them out +loudly, with comments pointing at this guide. diff --git a/guides/publishing_to_google_play.md b/guides/publishing_to_google_play.md new file mode 100644 index 0000000..ea93670 --- /dev/null +++ b/guides/publishing_to_google_play.md @@ -0,0 +1,789 @@ +# Publishing a Mob app to Google Play (Android) + +This is the full, step-by-step recipe for taking a Mob app from "runs on +my Android device via `mix mob.deploy --native`" to "uploaded to Google +Play, available on the internal testing track." + +It assumes you already have a working development setup — `mix mob.deploy` +runs your app on a connected Android device or emulator. + +> **Status (mob 0.5.12 / mob_dev 0.3.33):** End-to-end works. A real +> Mob app (Air Cart Maximizer) shipped through this exact pipeline on +> 2026-05-05. If you follow the steps below in order you should land a +> build on the internal testing track on your first or second attempt. +> +> The one-time setup (Part 1) is the painful part — Google's console is +> spread across three separate portals and the terminology is inconsistent. +> The per-release flow (Part 2) is a single command: `mix mob.republish --android`. + +## CLI wizard (recommended for first-time setup) + +Most of the Google Cloud steps in Part 1 can be automated. Run this instead +of following sections 1.4.1–1.4.5 manually: + +```bash +mix mob.setup.google_play +``` + +The wizard opens your browser for a one-time Google sign-in (no `gcloud` +CLI or external tools required — just a browser), then automatically: + +- Lets you pick your Google Cloud project +- Enables the Android Publisher API +- Creates the `play-publisher` service account +- Generates a JSON key and saves it to `~/.google_play/` +- Attempts to grant Release Manager access via the Play Developer API +- Prints the `mob.exs` config block to add + +**What the wizard cannot automate** (manual steps regardless of path): + +1. Creating the Google Play Developer account ($25, browser only) +2. Identity verification (government ID upload) +3. Creating the app record in Play Console (no create-app API) +4. Linking the Cloud project to Play Console: + Play Console → Setup → API access → Link to a Google Cloud project + +The wizard walks you through these four steps with exact instructions. + +> **Note:** The CLI wizard requires an OAuth client ID registered in Google +> Cloud Console. If the wizard reports `OAuth client not registered yet`, see +> the `MobDev.GooglePlay.OAuth` moduledoc for the one-time registration steps. + +If you prefer to do everything manually, or need to debug a specific step, +continue with the browser-based instructions below (Part 1). + +--- + +## Prerequisites + +- A Mob Android project that runs via `mix mob.deploy --native` +- An Android device you've run the app on (proves your native build works) +- `android/upload_jks.keystore` + `android/keystore.properties` filled in. + See [Setting up the upload keystore](#setting-up-the-upload-keystore) below. + +You'll touch two Google portals during setup: + +| Portal | URL | What lives here | +|---|---|---| +| Google Play Console | https://play.google.com/console | App listings, releases, testers, API access | +| Google Cloud Console | https://console.cloud.google.com | Service accounts, JSON keys | + +These are separate products. Easy to confuse — the Play Console is where +you manage your apps and grant publishing permissions; Google Cloud is +where you create the service account credentials. + +--- + +## Setting up the upload keystore + +Every release AAB must be signed with an **upload keystore** — a private +key that Google uses to verify future updates come from you. + +> **This key is forever.** If you lose it you cannot publish updates to +> your app. Back it up to 1Password or similar as soon as you create it. +> It must never be committed to git. + +### Generate the keystore (one-time) + +```bash +keytool -genkey -v \ + -keystore android/upload_jks.keystore \ + -alias upload \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -storetype JKS +``` + +Use a strong passphrase. When prompted for your name, org, etc., fill in +something real (it goes into the certificate, which Google sees). + +> **Format note:** Generate a JKS keystore, not PKCS12. Android's +> `bundletool` (the signing tool Gradle calls under the hood) rejects PKCS12 +> keystores with a misleading "keystore password was incorrect" error even +> when the password is right. JKS avoids this entirely. +> +> If you already have a PKCS12 keystore (`upload.keystore`), convert it: +> +> ```bash +> keytool -importkeystore \ +> -srckeystore android/upload.keystore -srcstoretype PKCS12 \ +> -destkeystore android/upload_jks.keystore -deststoretype JKS \ +> -srcalias upload -destalias upload +> ``` + +### Fill in `android/keystore.properties` + +Copy from the example and fill in your passphrase: + +```bash +cp android/keystore.properties.example android/keystore.properties +``` + +Edit `android/keystore.properties`: + +``` +storeFile=upload_jks.keystore +storePassword=your-passphrase +keyAlias=upload +keyPassword=your-passphrase +``` + +Both passwords are the same unless you explicitly set them differently +during `keytool -genkey`. + +Both `android/upload_jks.keystore` and `android/keystore.properties` are +in `.gitignore` — confirm they stay there: + +```bash +git check-ignore -v android/upload_jks.keystore android/keystore.properties +``` + +--- + +## Part 1 — One-time setup (per developer account + app) + +### 1.1 Create a Google Play Developer account + +Go to https://play.google.com/console and sign in with the Google account +you want to publish under. + +You'll be asked to pay a **one-time $25 USD registration fee**. This is +per developer account, not per app. + +> **ADC early access** — During registration or later, Google may show +> you the "Android Developer Console" at `get.google.com/adc-early-access`. +> This is a preview of a future replacement for the Play Console. +> **Ignore it** — you don't need it and it will not help you publish. +> Everything in this guide uses https://play.google.com/console. + +### 1.2 Complete identity verification + +Google now requires **identity verification** before your account can +publish apps. This is separate from the $25 fee and is free. + +You'll be asked to submit: +- A government-issued ID (passport or driver's license), OR +- Business registration documents (if registering as an organization) + +Submit via the prompts in the Play Console. Review typically takes a few +hours to one business day. You can't publish until verification clears. + +### 1.3 Create the app record in Play Console + +Once verified, you'll see the main developer dashboard at +https://play.google.com/console. + +Click **Create app** and fill in: + +| Field | Value | +|---|---| +| App name | Your public-facing name (e.g. `Air Cart Max`) | +| Default language | English (or your primary market) | +| App or game | App | +| Free or paid | Your choice | +| Declarations | Check both boxes | + +Click **Create app**. This gives you an app record with a stable +package ID slot — you'll fill in the AAB and store listing separately. + +### 1.4 Set up the Android API service account + +> **CLI shortcut:** `mix mob.setup.google_play` automates all of section 1.4 +> (except the Play Console link step in 1.4.1, which has no API). +> The manual steps below are the fallback if the wizard is unavailable or +> if you need to understand what's happening. + +`mix mob.publish --android` uses the Google Play Developer API to upload +AABs without going through the browser. The API authenticates with a +**service account** — a non-human Google account that holds upload +credentials. + +This involves steps in **both** the Play Console and Google Cloud Console. +Read the navigation notes carefully — it's easy to end up in the wrong +portal. + +#### 1.4.1 Link a Google Cloud project (Play Console) + +In **Play Console** (https://play.google.com/console): + +1. Make sure you're at the **account level** — the main developer + dashboard, not inside any specific app. If you see app-specific + menus in the sidebar, click your developer account name at the top + of the left nav to go back. +2. Left sidebar → **Setup** → **API access** +3. Click **Link to a Google Cloud project** → let Google create a new + project (the auto-created project is fine), or link an existing one. + +This one-time link is what allows service accounts in your Cloud project +to reach the Play API. + +#### 1.4.2 Enable the Android Publisher API (Google Cloud Console) + +Switch to **Google Cloud Console** (https://console.cloud.google.com): + +1. Make sure the correct project is selected in the top dropdown + (same project you linked in 1.4.1) +2. Left sidebar → **APIs & Services** → **Library** +3. Search for **Google Play Android Developer API** → click the result +4. Click **Enable** + +> **Do this before creating the service account key.** If you skip it, +> `mix mob.publish --android` will fail with: +> `HTTP 403: Google Play Android Developer API has not been used in project …` +> Enabling the API and waiting ~2 minutes fixes it. + +#### 1.4.3 Create a service account (Google Cloud Console) + +Still in **Google Cloud Console** (https://console.cloud.google.com): + +1. Make sure the correct project is selected in the top dropdown + (the same project you linked in step 1.4.1) +2. Left sidebar → **IAM & Admin** → **Service Accounts** +3. Click **+ Create Service Account** +4. Fill in a name (e.g. `play-publisher`) — click **Create and continue** +5. **Skip the role assignment step** — click **Continue**, then **Done** + + > **Important:** Do NOT assign a Google Cloud IAM role here (like + > "Cloud Deploy Releaser", "Resource Manager", "Editor", etc.). + > Those are GCP infrastructure roles, not Play Console roles. + > Play Console permissions are granted separately in step 1.4.4. + +6. You'll land back on the service accounts list. Click your new + service account → **Keys** tab → **Add key** → **Create new key** → + **JSON** → **Create** +7. A `.json` file downloads automatically. **This is a one-time + download** — Google does not store the private key. If you close + the page without downloading, delete the key and create a new one. + +Move the file into place: + +```bash +mkdir -p ~/.google_play +mv ~/Downloads/*.json ~/.google_play/aircartmax-service-account.json +chmod 600 ~/.google_play/aircartmax-service-account.json +``` + +The service account email is inside the file — you'll need it in the +next step: + +```bash +cat ~/.google_play/aircartmax-service-account.json | grep client_email +# "client_email": "play-publisher@your-project.iam.gserviceaccount.com" +``` + +#### 1.4.4 Grant the service account Play Console access + +This requires **two separate actions** in Play Console — both are needed. + +##### Part A — Grant access via API access page + +In **Play Console** (https://play.google.com/console), account level: + +1. Left sidebar → **Setup** → **API access** +2. Your service account appears in the list. Click **Grant access** next + to it. +3. You'll be taken to a permission screen. Set role to **Release manager**. +4. Click **Apply** → **Invite user** + +##### Part B — Invite via Users and permissions + +Still in **Play Console**, account level: + +1. Left sidebar → **Users and permissions** +2. Click **Invite new users** +3. **Email address**: paste the `client_email` from the JSON file + (looks like `play-publisher@your-project.iam.gserviceaccount.com`) +4. Under **Permissions**, find **Release manager** role and select it. + + If you don't see named roles and instead see a permission checklist, + enable at minimum: + - **Release apps to testing tracks and production** + - **Manage production releases** + + > **Do not select Google Cloud IAM roles** — if you see "Cloud Deploy + > Releaser", "Resource Manager", or anything Cloud-prefixed, you're in + > the wrong section. These are GCP infrastructure roles with no effect + > on Play publishing. + +5. Click **Apply** → **Invite user** + +Both paths must be completed. Doing only one results in HTTP 403 +"The caller does not have permission" when uploading. Service accounts +auto-accept invitations — permissions are active within a few minutes. + +#### 1.4.5 Configure `mob.exs` + +Add the Google Play config block to your `mob.exs`: + +```elixir +import Config + +config :mob_dev, + # ... your existing config ... + + google_play: [ + package_name: "com.example.myapp", # your applicationId + service_account_json: "~/.google_play/my-service-account.json", + track: "internal" # start here; promote later + ] +``` + +`mob.exs` is per-machine and should be in your `.gitignore`. The JSON +key file should also never be committed. + +The `track` field controls which Play track the AAB lands on: + +| Track | Who can install | Review required | +|---|---|---| +| `"internal"` | Up to 100 testers you invite by email (must have a Play account) | None — instant | +| `"alpha"` | Closed group, any Google account | None | +| `"beta"` | Open or closed, any Google account | None | +| `"production"` | Everyone | Google review (typically 1–3 days) | + +Start with `"internal"` and promote to `"production"` from the Play +Console web UI once you've verified the build works on real devices. + +### 1.5 Configure Android target SDK + +Google Play rejects AABs that don't target the current required SDK level. +As of 2026, that's **targetSdk 35**. + +In `android/app/build.gradle`: + +```gradle +android { + compileSdk 35 + + defaultConfig { + targetSdk 35 + minSdk 28 + ... + } +} +``` + +Keep `compileSdk` ≥ `targetSdk`. Update both together. + +### 1.6 Set the application ID + +The default `mix mob.new` scaffold uses `com.example.<app>` for +`applicationId`. You need to change this to a unique reverse-DNS +identifier under a domain you control. This is the permanent Play Store +package name — it cannot be changed after the first release. + +In `android/app/build.gradle`: + +```gradle +defaultConfig { + applicationId "com.yourcompany.yourapp" + ... +} +``` + +### 1.7 Set the app label + +The `android:label` in `AndroidManifest.xml` is what users see as the +app name on their device. The scaffold default is a concatenated string +without spaces: + +```xml +<application + android:label="Air Cart Max" <!-- spaces, human-readable --> + ...> +``` + +--- + +## Part 2 — Per-release flow + +Once Part 1 is done, every subsequent release is **one command**: + +```bash +mix mob.republish --android +``` + +That runs the three steps below in sequence. Use the wrapper for the +common path; drop down to individual commands if you need to troubleshoot +one in isolation. + +### 2.1 `mix mob.republish --android` (the wrapper) + +What it does, exactly: + +1. **Bump `versionCode`** — reads the current integer from + `android/app/build.gradle`, bumps by 1, writes back. Google Play + rejects AABs with a `versionCode` it has already seen for this app, + even if the upload never succeeded. +2. **`mix mob.release --android`** — builds the OTP zip + signed AAB. + See section 2.2. +3. **`mix mob.publish --android`** — uploads the AAB to Play. See + section 2.3. + +Flags: + +- `--android` — required. Mob is platform-agnostic; you must pick a side. +- `--track internal|alpha|beta|production` — override the track from + `mob.exs`. Useful for promoting a build: `mix mob.republish --android + --track production`. +- `--no-bump` — skip the versionCode bump (only useful if you bumped + manually and want to rebuild + re-upload without bumping again). + +### 2.2 `mix mob.release --android` (manual equivalent of step 2) + +Builds everything needed for a signed release AAB: + +1. Compiles the Elixir project (`mix compile`) +2. Downloads the Android OTP runtime from the Mob release cache if not + already present +3. Stages a temp tree: OTP runtime + app BEAMs (all runtime deps, + flattened) + `priv/` + exqlite BEAMs in the OTP lib structure +4. Runs `MobDev.OtpAssetBundle.build/2`: + - Strips unused OTP libs (megaco, runtime_tools, wx, observer, etc.) + - Strips standalone executables from `erts-*/bin/` (the ones Mob + actually needs — `erl_child_setup`, `inet_gethost`, `epmd` — are + already in the APK's `jniLibs/` as `.so` files) + - Strips static archives (`.a`) — already linked into the native lib + - Strips optional BEAM chunks to shrink file size + - Zips the tree to `android/app/src/main/assets/otp.zip` +5. Runs `./gradlew bundleRelease` to produce the signed `.aab` + +Output: `android/app/build/outputs/bundle/release/app-release.aab` + +> **Why does `otp.zip` matter?** +> `MobBridge.extractOtpIfNeeded()` in Kotlin extracts this zip into +> `<filesDir>/otp/` on first launch. This is how the BEAM runtime and +> your app's compiled code reach the device — Play Store installs can't +> `adb push` files the way development builds do. If `otp.zip` is absent, +> the app starts, finds no BEAM runtime to load, and crashes immediately. +> **Building via `./gradlew bundleRelease` directly (without first running +> `mix mob.release --android`) produces an AAB missing `otp.zip` — the +> app will crash on every device.** + +### 2.3 `mix mob.publish --android` (manual equivalent of step 3) + +Uploads the AAB at +`android/app/build/outputs/bundle/release/app-release.aab` to Google Play +via the Play Developer API. + +You can also pass an explicit path: + +```bash +mix mob.publish --android path/to/app-release.aab +``` + +Or override the track from `mob.exs`: + +```bash +mix mob.publish --android --track production +``` + +The upload creates a Play **edit** (a transaction-style change), uploads +the bundle, assigns it to the track, and commits — all atomically. If +any step fails, the edit is abandoned and the Play Console is unchanged. + +Successful output: + +``` +=== Uploading to Google Play === + AAB: /path/to/app-release.aab + Package: com.example.myapp + Track: internal + + Authenticating with Google... + Creating edit... + Uploading 38.3MB... + Assigning versionCode 3 to internal track... + Committing edit... + +✓ Upload accepted by Google Play + versionCode 3 is on the internal track. + +View it at: + https://play.google.com/console +``` + +### 2.4 If the wrapper isn't working — the manual three-step + +```bash +# 1. Bump versionCode (Play rejects re-uploads of the same versionCode). +# Edit android/app/build.gradle: versionCode N → versionCode N+1 + +# 2. Build the release AAB (otp.zip + Gradle). +mix mob.release --android + +# 3. Upload to Google Play. +mix mob.publish --android +``` + +Common recovery scenarios: + +- **`mix mob.release --android` failed** — fix the error, then run + `mix mob.release --android && mix mob.publish --android`. Don't bump + again — the bump already happened. +- **`mix mob.publish --android` failed** — unlike Apple, Google's API + uses an edit/commit model: if the upload fails before `commit`, the + versionCode is NOT consumed. You can re-run `mix mob.publish --android` + without bumping. +- **You bumped manually** — `mix mob.republish --android --no-bump` skips + the bump step. + +### 2.5 Add testers on the internal track + +In the Play Console → your app → **Testing** → **Internal testing**: + +1. Click **Testers** tab → **Create email list** (or use the default list) +2. Add tester email addresses +3. Each tester receives a link to opt in to the test +4. Once opted in, they install the app from the Play Store directly + +Internal testing requires no review. The build is available immediately +after upload. + +To promote to production, go to the Play Console → **Release** → +**Production** → **Create new release** → promote the internal testing +release. + +--- + +## Troubleshooting + +### "Your app currently targets API level 34 and must target at least API level 35" + +Play Console rejects AABs with `targetSdk < 35` (as of 2026). Update +`android/app/build.gradle`: + +```gradle +android { + compileSdk 35 + defaultConfig { + targetSdk 35 + ... + } +} +``` + +### "Version code X has already been used" + +Play rejects any AAB with a `versionCode` it's already seen for this app. +Bump the versionCode in `android/app/build.gradle` and rebuild: + +```bash +# Manual bump: +# Edit android/app/build.gradle: versionCode N → versionCode N+1 +mix mob.release --android +mix mob.publish --android + +# Or let mob.republish handle it: +mix mob.republish --android +``` + +### App installed from Play Store crashes on launch — ERTS helpers not found (`inet_gethost : enoent`) + +**Symptom**: App works fine when installed with `adb install` but crashes immediately +when installed from the Play Store (internal testing or production). Logcat shows +something like: + +``` +erl_child_setup: : no such file or directory +``` + +or the BEAM fails to start distribution with `inet_gethost : enoent`. + +**Root cause**: Play Store delivers apps as split APKs (one per device ABI). On +Android 6+, the system does **not** extract `.so` files from split APKs to +`nativeLibraryDir` — they remain compressed inside the split APK zip file. + +`mob_beam.c` creates symlinks from `erts-VER/bin/<name>` to +`<nativeLibraryDir>/lib<name>.so`. When installed from Play Store, +`nativeLibraryDir` is empty — all symlinks dangle and the BEAM's exec calls +fail with ENOENT. + +**Fix**: This is handled automatically by `MobBridge.extractBeamHelpersFromSplitApk()` +(in the generated `MobBridge.kt`). On first launch, if `nativeLibraryDir` is empty, +it locates the ABI-specific split APK from `ApplicationInfo.splitSourceDirs`, opens +it as a zip, and extracts: +- `lib/<abi>/liberl_child_setup.so` → `<filesDir>/otp/erts-VER/bin/erl_child_setup` +- `lib/<abi>/libinet_gethost.so` → `<filesDir>/otp/erts-VER/bin/inet_gethost` +- `lib/<abi>/libepmd.so` → `<filesDir>/otp/erts-VER/bin/epmd` +- `lib/<abi>/libsqlite3_nif.so` → `<filesDir>/otp/lib/exqlite-VER/priv/sqlite3_nif.so` + +`mob_beam.c` was also updated to detect pre-extracted helpers (stat check before +symlinking) so extraction and symlink creation don't conflict. + +If you regenerated your Android project from an older `mob.new` template, `MobBridge.kt` +may be missing `extractBeamHelpersFromSplitApk`. Re-generate or manually add the function — +see the current template for the canonical implementation. + +**Does not affect adb installs**: `adb install` unpacks a full APK where the system +does extract `.so` to `nativeLibraryDir` normally. The issue is Play Store split APK +delivery only. + +### App installs from Play Store, BEAM starts, but screen stays black — `crypto.app not found` + +**Symptom**: App is installed from Play Store, does not crash with a native signal, but +shows only a black screen. Logcat (filter: `adb logcat -s Elixir`) shows: + +``` +step 5 => {'EXIT',{{badmatch,{error,{crypto,{"no such file or directory","crypto.app"}}}},...}} +``` + +The BEAM started, but application boot failed when starting `:ecto_sqlite3` → `:ecto` +→ `:crypto`. + +**Root cause**: The Mob pre-built Android OTP release does not include the `:crypto` +OTP application. Cross-compiling OpenSSL for Android was not part of the initial OTP +build. Many common deps (ecto, phoenix_pubsub, plug_crypto, phoenix) declare +`:crypto` in their `{applications, [...]}` list in their `.app` files. When +`Application.ensure_all_started` walks the dep tree, the OTP application controller +tries to load `crypto.app` and fails — even if `crypto.beam` were present, the +controller requires the `.app` spec to register the application before starting it. + +**Fix**: `mix mob.release --android` (via `MobDev.ReleaseAndroid`) now handles this +automatically in two steps during staging: + +1. **Patch all `.app` files** (`patch_crypto_deps!/1`): Walks every `*.app` file in + the staging tree and removes `:crypto` from each `{applications, [...]}` list. + This prevents `ensure_all_started` from even trying to start `:crypto` as a dep. + +2. **Inject a crypto stub** (`add_crypto_stub!/2`): Compiles + `mob_dev/priv/android/crypto.erl` — a minimal module that implements only + `crypto:strong_rand_bytes/1` via `:rand` (the only function Ecto calls at runtime, + for UUID generation). Also writes a `crypto.app` spec with no `{mod, ...}` entry, + so the app controller can load and "start" `:crypto` without invoking any NIF + initialization. + +This is transparent as of the version where these functions were added. If you are on +an older `mob_dev` that doesn't have `patch_crypto_deps!/1`, upgrade `mob_dev` or +check `release_android.ex` for the current staging pipeline. + +**Note on cryptographic strength**: The stub uses `:rand` seeded at BEAM start — +not a cryptographically secure RNG. This is acceptable for a local-only mobile app +that has no TLS or encryption use cases. If your app does require real crypto, you +will need to cross-compile OpenSSL and include the real `:crypto` NIF. + +### App crashes immediately on launch (before any UI appears) + +**Almost certainly a missing `otp.zip`.** This happens when you build the +AAB with `./gradlew bundleRelease` directly instead of going through +`mix mob.release --android`. + +The app boots, `MobBridge.extractOtpIfNeeded()` finds no `assets/otp.zip`, +returns early, and then `mob_start_beam` tries to start BEAM from an +empty `<filesDir>/otp/` directory — crash. + +Fix: always build release AABs with: + +```bash +mix mob.release --android # stages otp.zip, THEN runs gradlew +``` + +Never run `./gradlew bundleRelease` directly for a release build. + +### "keystore password was incorrect" during Gradle release build + +This is a misleading error. The likely cause is that your keystore is in +PKCS12 format and `bundletool` (which Gradle uses to sign AABs) doesn't +handle PKCS12 reliably even when the password is correct. + +Convert to JKS: + +```bash +keytool -importkeystore \ + -srckeystore android/upload.keystore -srcstoretype PKCS12 \ + -destkeystore android/upload_jks.keystore -deststoretype JKS \ + -srcalias upload -destalias upload +``` + +Then update `android/keystore.properties` to use `upload_jks.keystore`. + +### "Cannot read service account file: no such file or directory" + +The file path in `mob.exs` `google_play.service_account_json` doesn't +exist. Check with: + +```bash +cat mob.exs | grep service_account +ls ~/.google_play/ +``` + +If the JSON file is missing, go to Google Cloud Console → IAM & Admin → +Service Accounts → your service account → Keys → Add key → Create new +key → JSON. + +### "HTTP 401" or "Request had invalid authentication credentials" + +The service account JSON key is invalid or revoked. Generate a new one: +Google Cloud Console → Service Accounts → your account → Keys → Add key. + +Also confirm the `client_email` from the JSON is invited in Play Console → +Users and permissions with a Release manager role. + +### "HTTP 403: Google Play Android Developer API has not been used in project … before or it is disabled" + +The Android Publisher API isn't enabled in the linked Google Cloud project. + +1. Go to https://console.cloud.google.com → correct project selected +2. **APIs & Services** → **Library** → search **Google Play Android Developer API** → **Enable** +3. Wait ~2 minutes for the change to propagate, then retry. + +This is a one-time step per Cloud project. + +### "HTTP 403" or "The caller does not have permission" + +The service account exists and the API is enabled, but Play Console +publishing permission isn't fully wired. There are **two** required steps — +missing either one causes this error. + +**Step 1 — Grant access via API access page:** + +Play Console (account level) → **Setup** → **API access** → find your +service account in the list → click **Grant access** → set role to +**Release manager** → Apply. + +**Step 2 — Invite via Users and permissions:** + +Play Console (account level) → **Users and permissions** → **Invite new +users** → paste `client_email` from the JSON → role **Release manager** → +Apply → Invite user. + +Both steps are required. Doing only the Users and permissions invite (Step 2) +without the API access Grant (Step 1) is the most common cause of this error. + +Also confirm you're working in Play Console (https://play.google.com/console), +not Google Cloud Console — GCP IAM roles have no effect on Play publishing. + +### "I see Cloud roles like 'Cloud Deploy Releaser' when trying to grant access" + +You're in the wrong portal. That's Google Cloud Console IAM — those roles +control GCP infrastructure (Kubernetes, Cloud Run, etc.), not Play Store +publishing. + +Go to https://play.google.com/console → Users and permissions → Invite new +users. The roles here (Release manager, Finance, etc.) are Play-specific. + +### "I can't find API access in the Play Console" + +API access is at the **account level**, not inside any specific app. If +you're inside an app's settings you'll only see app-level menus. + +Click your **developer account name** or the back arrow at the top of the +left sidebar to reach the main developer dashboard. Then: Setup → API access. + +### "There's a new ADC (Android Developer Console) asking for additional verification" + +That's `get.google.com/adc-early-access` — a preview of Google's future +replacement for the Play Console. It may ask for additional verification +or fees as part of its early-access terms. + +You don't need it. Publish using https://play.google.com/console, which +uses the $25 account you already registered. The ADC early access is +completely optional. + +### "My build is on internal testing but testers can't find the app" + +Testers must opt in via a link before the Play Store will show them the +app. In the Play Console → Testing → Internal testing → **Testers** tab, +there's a **copy link** button. Send that link to each tester. They click +it, choose to become a tester, and then the Play Store shows them the app. + +Internal testing is tied to specific email addresses — testers must be +signed in to Play with the invited email. diff --git a/guides/publishing_to_testflight.md b/guides/publishing_to_testflight.md new file mode 100644 index 0000000..94b916e --- /dev/null +++ b/guides/publishing_to_testflight.md @@ -0,0 +1,809 @@ +# Publishing a Mob app to TestFlight (iOS) + +This is the full, step-by-step recipe for taking a Mob app from "runs on +my iPhone via `mix mob.deploy --native`" to "uploaded to App Store +Connect, ready for TestFlight beta testing". + +It assumes you already have a working development setup — `mix mob.deploy` +runs your app on a connected iPhone. If you don't, work through +[Getting Started](https://hexdocs.pm/mob/getting_started.html) first. + +> **Status (mob 0.5.12 / mob_dev 0.3.30):** End-to-end works. A real +> Mob app (Air Cart Maximizer) shipped through this exact pipeline on +> 2026-05-02. If you follow the steps below in order you should land a +> build in TestFlight on your first or second attempt. +> +> Apple's validator runs in **two separate stages** — an upload +> validator (catches obvious bundle problems) and a secondary scanner +> (runs after upload, emails you if it finds anything). Most of the +> troubleshooting at the bottom of this guide is for errors from the +> second stage. They typically don't show up until after `mix mob.publish` +> reports success. See [Two-stage validation](#part-3--two-stage-validation) +> for the model. + +--- + +## Prerequisites + +- macOS with Xcode (Xcode 16+ tested; Xcode 26 produces App Store-grade + builds without quirks) +- An Apple Developer Program membership ($99/year) — TestFlight requires + this; the free tier can sideload but not publish +- An iPhone you've successfully run the app on via `mix mob.deploy --native` + (proves your dev signing works end-to-end) +- iOS 17.0 simulator deployment target. If your project was generated + by `mix mob.new` from a mob_new before 0.1.30, your `ios/build.sh` + may say `version-min=16.0` — bump it: + + ```bash + sed -i '' \ + -e 's/version-min=16.0/version-min=17.0/g' \ + -e 's/arm64-apple-ios16.0-simulator/arm64-apple-ios17.0-simulator/g' \ + -e 's/--minimum-deployment-target 16.0/--minimum-deployment-target 17.0/g' \ + ios/build.sh + ``` + + iOS 17 was released September 2023; older targets fail because the + framework's Swift code uses iOS 17+ APIs (modern `onChange(of:_:)` + closure form). + +You'll also create things in three Apple web portals during this guide: + +| Portal | URL | What lives here | +|---|---|---| +| Apple Developer | https://developer.apple.com/account/resources/identifiers/list | App IDs, certificates, provisioning profiles | +| App Store Connect | https://appstoreconnect.apple.com/apps | App records, TestFlight, App Store API keys | +| App Store Connect API | https://appstoreconnect.apple.com/access/integrations/api | API keys for `altool` upload auth | + +These are different portals serving different parts of the process. +Easy to confuse them — the bundle ID lives in the developer portal, the +app record (where the bundle ID is *attached* to a public-facing app) +lives in App Store Connect. + +--- + +## Part 1 — One-time setup (per app) + +You only do these once per Mob app. After this, every release is just +`mix mob.release` → `mix mob.publish`. + +### 1.1 Pick a real bundle ID + +The bundle ID generated by `mix mob.new` defaults to `com.example.<app>` +which Apple won't accept for App Store distribution. You need a +reverse-DNS identifier under a domain you control. + +Examples: + +- `com.beyondagronomy.aircartmax` (your team owns `beyondagronomy.com`) +- `ca.larocque.aircartmax` (Canadian convention, your name) +- `com.genericjam.somecoolapp` (your dev handle) + +Bundle IDs are forever — once Apple registers it under your team you +can't transfer it cleanly. Pick something you'll be happy with in 5 years. + +### 1.2 Update the bundle ID + display name in your project + +**iOS** — edit `ios/Info.plist`: + +```xml +<key>CFBundleIdentifier</key> +<string>com.beyondagronomy.aircartmax</string> + +<!-- Optional but nice: how the app shows up on the home screen. + Without this, iOS uses CFBundleName which is usually the technical + PascalCase name. --> +<key>CFBundleDisplayName</key> +<string>Air Cart Maximizer</string> + +<!-- Apple's convention: integer build number + semver public version. + Bump CFBundleVersion every upload (even rebuilds of the same + version). CFBundleShortVersionString is what users see. --> +<key>CFBundleVersion</key> +<string>1</string> +<key>CFBundleShortVersionString</key> +<string>1.0.0</string> +``` + +**Android** — edit `android/app/build.gradle`: + +```gradle +defaultConfig { + // The Kotlin `namespace` above can stay as the generator default + // (`com.example.foo`). Renaming it would mean moving every .kt + // file's package declaration. Android allows applicationId and + // namespace to differ — only applicationId is user-visible. + applicationId "com.beyondagronomy.aircartmax" + versionCode 1 + versionName "1.0.0" + ... +} +``` + +### 1.3 Keep usage strings in Info.plist (counterintuitive — read this) + +Your first instinct will be to strip the `NSCameraUsageDescription`, +`NSMicrophoneUsageDescription`, etc. that `mix mob.new` scaffolds — +"my offline calculator doesn't use the camera, why declare it?" + +**Don't strip them.** Apple's secondary validator (the post-upload +scanner that emails you) flags ITMS-90683 if any code in the bundle +references a sensitive-data API and the corresponding usage string is +missing. The Mob framework's NIFs in `mob_nif.m` reference all of these +APIs (camera, mic, location, photo library, motion) regardless of +whether your specific app calls them — Apple's scanner sees the API +references and demands the strings. + +You have two valid paths: + +**Path A — keep all the usage strings** (recommended, easiest) + +Leave the strings as scaffolded. They only trigger user-visible +permission prompts when the API is actually *called* at runtime — and +your app never calls them, so users never see a prompt. From the App +Store reviewer's perspective the strings are framework-required boilerplate. + +If your app legitimately doesn't use a capability, write an honest string +that says so — App Store reviewers appreciate the clarity: + +```xml +<key>NSCameraUsageDescription</key> +<string>This app does not use the camera. The permission is declared +because of a framework dependency only.</string> +<key>NSMicrophoneUsageDescription</key> +<string>This app does not use the microphone. The permission is declared +because of a framework dependency only.</string> +<key>NSLocationWhenInUseUsageDescription</key> +<string>This app does not use your location. The permission is declared +because of a framework dependency only.</string> +<key>NSPhotoLibraryUsageDescription</key> +<string>This app does not access your photo library. The permission is +declared because of a framework dependency only.</string> +<key>NSMotionUsageDescription</key> +<string>This app does not use motion sensors. The permission is declared +because of a framework dependency only.</string> +``` + +**Path B — strip strings AND opt out of the corresponding NIFs** (future) + +The clean fix is to compile the unused capability NIFs out of release +builds via per-feature flags. mob doesn't currently expose this surface, +but it's planned. When that lands, this section will get a "Path C — +opt out per capability" subsection and Path A will become "if you don't +care about the strings." + +For everything else — `UIBackgroundModes`, custom URL schemes, +`UIRequiredDeviceCapabilities` — strip what your app legitimately +doesn't need. Only the privacy usage strings are subject to the +ITMS-90683 weirdness. + +### 1.4 Register the App ID at Apple + +Apple's developer portal needs to know your bundle ID exists before +anything else (App Store Connect, distribution profiles, the API) +can reference it. + +> **Why we don't automate this**: `mix mob.provision --distribution` +> uses `xcodebuild -allowProvisioningUpdates`, which manages existing +> profiles and certs but won't register a brand-new bundle ID for +> distribution. The Apple-blessed automation path goes through the App +> Store Connect API, which mob_dev hasn't yet integrated. + +1. Go to https://developer.apple.com/account/resources/identifiers/list +2. Click `+` → **App IDs** → Continue → **App** → Continue +3. Fill in: + - **Description**: a human-readable name (e.g. `Air Cart Maximizer`) + - **Bundle ID**: select **Explicit**, paste your bundle ID exactly + as it appears in `Info.plist` + - **Capabilities**: leave at defaults unless your app needs special + entitlements (push notifications, app groups, iCloud, etc.). Most + Mob apps don't need any. +4. Click Continue → Register + +The registration is instant. The bundle ID will now appear in +selectors throughout Apple's portals. + +### 1.5 Create the Apple Distribution certificate + +You need a distribution-signing cert in your Mac's keychain. The Apple +Development cert your team already has (used for `mix mob.deploy`) is +not the same thing — App Store builds need an Apple Distribution cert. + +In Xcode: + +1. Settings (`⌘,`) → Accounts +2. Click your team in the left list +3. Click **Manage Certificates...** +4. Click `+` in the bottom-left → **Apple Distribution** +5. Done + +Xcode generates a CSR locally, uploads it, downloads the signed cert, +and installs it in your login keychain. One time only. + +You can verify with: + +```bash +security find-identity -v -p codesigning | grep "Apple Distribution" +``` + +### 1.6 Create an App Store provisioning profile + +A provisioning profile binds a cert + an App ID + an entitlements set +into a signed blob your Mac can use to sign builds. + +> **Why we don't automate this either**: the same `xcodebuild +> -allowProvisioningUpdates` limitation. Once a profile exists, mob_dev +> can fetch it; creating one for a brand-new App ID under manual +> signing is the gap. + +1. Go to https://developer.apple.com/account/resources/profiles/list +2. Click `+` +3. Distribution → **App Store** → Continue +4. App ID: select your bundle ID from step 1.4 → Continue +5. Certificates: select the Apple Distribution cert from step 1.5 → Continue +6. Profile name: anything. Common conventions: + - `<AppName> App Store` (e.g. `AirCartMax App Store`) + - The Apple-default `iOS Team Store Provisioning Profile: <bundle_id>` +7. Generate → **Download** + +`mix mob.provision --distribution` discovers profiles by parsing the +files in `~/Library/Developer/Xcode/UserData/Provisioning Profiles/` +and matching by bundle ID + team — so the profile name doesn't matter. + +### 1.7 Install the profile + +Double-click the downloaded `.mobileprovision` file. macOS installs it +into `~/Library/Developer/Xcode/UserData/Provisioning Profiles/` with +a UUID-based filename. + +You can verify with: + +```bash +ls ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/ +``` + +### 1.8 Run `mix mob.provision --distribution` + +```bash +cd path/to/your/app +mix mob.provision --distribution +``` + +This: + +1. Reads bundle ID from `ios/Info.plist` +2. Auto-detects your team ID from your existing dev profile +3. Verifies the Apple Distribution cert is in keychain +4. **Discovers the App Store profile UUID** by scanning the local + profiles directory for a non-dev/non-ad-hoc profile matching your + bundle ID + team — works regardless of what you named the profile +5. Generates `ios/Provision.xcodeproj` (a minimal Xcode project) wired + up with manual signing, the discovered profile UUID, and the Apple + Distribution identity +6. Runs `xcodebuild archive` — proves the cert + profile + bundle ID + form a valid signing chain end-to-end +7. Confirms the profile is in place + +Output should end with: + +``` +✓ App Store provisioning profile ready +✓ Provisioning complete! +Next step: mix mob.release +``` + +### 1.9 Create the App Store Connect app record + +The bundle ID + your developer account is one half of the picture. The +*app record* (where store metadata, TestFlight builds, and screenshots +live) is the other half — and it's in a different portal. + +1. Go to https://appstoreconnect.apple.com/apps +2. Click `+` → **New App** +3. Fill in: + - **Platforms**: iOS + - **Name**: the public-facing name (60 char limit, must be unique + across the entire App Store — try variants if it's taken) + - **Primary Language**: pick whatever's appropriate + - **Bundle ID**: pick from the dropdown — your bundle ID from step + 1.4 should appear here. If it doesn't, either step 1.4 didn't + succeed or you need to wait a minute for Apple's portals to sync + - **SKU**: anything you want. Doesn't need to match the bundle ID; + a common convention is `<short-name>-001`. The SKU is for your + internal tracking. + - **User Access**: Full Access +4. Create + +You don't need to fill in screenshots, descriptions, age ratings, etc. +yet — those are required for App Store *review*, not for TestFlight +beta testing. + +### 1.10 Create an App Store Connect API key + +The upload step (`mix mob.publish`) uses Apple's `altool` with an API +key for authentication — much smoother than older app-specific-password +flows. + +1. Go to https://appstoreconnect.apple.com/access/integrations/api +2. Switch to the **Team Keys** tab (not Individual Keys) +3. Note the **Issuer ID** at the top of the page — copy it somewhere safe +4. Click `+` +5. Fill in: + - **Name**: anything (e.g. `Mob CLI Upload`) + - **Access**: **App Manager** (the minimum role that can upload builds) +6. **Generate** + +Now Apple shows you a one-time download: + +7. Click **Download API Key** — you get `AuthKey_<KEY_ID>.p8`. **This + is the only chance to download it.** Apple does not store the + private key. If you close this page without downloading, revoke the + key and create a new one. +8. Note the **Key ID** (10 chars, also visible in the table after you + download) — copy it somewhere safe +9. Move the file somewhere persistent and lock it down: + +```bash +mkdir -p ~/.appstoreconnect +mv ~/Downloads/AuthKey_*.p8 ~/.appstoreconnect/ +chmod 600 ~/.appstoreconnect/AuthKey_*.p8 # owner read/write only +``` + +### 1.11 Configure `mob.exs` + +Add the API key block to your `mob.exs`: + +```elixir +import Config + +config :mob_dev, + # ... your existing config ... + + app_store_connect: [ + key_id: "ABC123XYZ4", # 10-char Key ID + issuer_id: "69a6de76-aaaa-bbbb-cccc-1234567890ab", # team Issuer ID + key_path: "~/.appstoreconnect/AuthKey_ABC123XYZ4.p8" + ] +``` + +`mob.exs` is per-machine and should be in your `.gitignore` (the file +itself says so at the top). Don't commit the `.p8` either — treat it +like an SSH private key. + +That's the one-time setup. Everything below is the per-release flow. + +--- + +## Part 2 — Per-release flow + +Once Part 1 is done, every subsequent release is **one command**: + +```bash +mix mob.republish --ios # bump build number, mob.release, mob.publish --ios +``` + +That wraps the three steps below. Use the wrapper for the common path; +drop down to the individual commands if you need to troubleshoot one +in isolation, or you've bumped the build number some other way and +just want to rebuild + upload. + +### 2.1 (Optional) `mix mob.provision --distribution` + +Idempotent — re-runs against existing artifacts and tells you if +anything's missing or stale. App Store profiles expire annually; this +is the command to refresh one. Skip it if you ran it recently and +nothing's changed. `mix mob.republish` does NOT re-run this — bake it +into your annual calendar. + +### 2.2 `mix mob.republish --ios` (the wrapper) + +What it does, exactly: + +1. **Bump `CFBundleVersion`** — reads the current value from + `ios/Info.plist`, integer-bumps by 1, writes back. Refuses if the + current value isn't a clean integer (e.g. someone wrote `"1.0"` — + that's `CFBundleShortVersionString`'s job, not `CFBundleVersion`'s). +2. **`mix mob.release`** — builds the `.ipa`. See section 2.3 for what + happens here. +3. **`mix mob.publish --ios`** — uploads via `xcrun altool`. See + section 2.4 for what happens here. + +Flags: + +- `--ios` — required. Mob is platform-agnostic; you must pick. +- `--android` — errors with "not yet implemented" (Android publish + pipeline is on the roadmap). +- `--no-bump` — skip step 1 (Apple will reject the same build number, + so this is mostly useful for testing the pipeline itself). +- `--verbose` — passes through to `mix mob.publish` to show altool's + per-chunk upload progress. + +### 2.3 `mix mob.release` (manual equivalent of step 2 of `mix mob.republish --ios`) + +Builds a release-signed `.ipa` at `_build/mob_release/<App>.ipa`: + +- Compiles BEAMs, strips them down to the apps your release actually + uses, drops the unused OTP libs (megaco, runtime_tools, erl_interface, + os_mon, wx, et, eunit, etc.) from the bundle +- Removes `.so`/`.a`/standalone executables from the bundle (Apple's + one-Mach-O-per-`.app` policy) — the static archives are linked into + the main binary instead +- Builds native sources with `-DMOB_RELEASE` to drop the Erlang + distribution surface, EPMD, AND the test harness (whose synthetic + touch NIFs use private UIKit selectors that App Store auto-rejects) +- Synthesizes the full set of `DT*` build-environment plist keys + (`DTSDKName`, `DTSDKBuild`, `DTPlatformName`, `DTPlatformVersion`, + `DTPlatformBuild`, `DTXcode`, `DTXcodeBuild`, `DTCompiler`, + `BuildMachineOSBuild`) plus `MinimumOSVersion`, `UIDeviceFamily`, + and `CFBundleSupportedPlatforms` +- Signs the `.app` with your distribution identity (no `get-task-allow`) +- Packages with `ditto -c -k --keepParent --norsrc --noextattr --noqtn` + to preserve the `_CodeSignature/CodeResources` symlink and avoid + `__MACOSX/`/`._<file>` AppleDouble pollution + +The resulting `.ipa` is typically ~19.8 MB for a basic Mob app +(down from ~45 MB before the slim build was added). The full breakdown +of what gets stripped, why, and how to bisect a broken slim build is in +[`slim_release.md`](slim_release.md). + +To validate a slim build runs on device _before_ the TestFlight +round-trip, use: + +```bash +mix mob.deploy --slim # dev build with the same strips applied +``` + +### 2.4 `mix mob.publish --ios` (manual equivalent of step 3 of `mix mob.republish --ios`) + +Uploads `_build/mob_release/<App>.ipa` to App Store Connect via +`xcrun altool --upload-app` with API-key auth. + +Platform flag is required (`--ios` or `--android`) — Mob refuses to +default to either side so it's obvious from the command which store +you're hitting. `--android` errors with "not yet implemented". + +The upload is silent for several minutes — `altool` doesn't print +progress unless you pass `--verbose`. **This is normal**, not a hang. +To verify it's still alive: + +```bash +ps aux | grep -E "altool|java" | grep -v grep +``` + +Should show altool + a child Java process (altool's actual upload +engine). CPU and TIME columns climbing means it's working. + +If you want to see real-time progress: + +```bash +mix mob.publish --ios --verbose +``` + +You'll likely see noise like: + +``` +[SSZipArchive] Set attributes failed for directory: ...Info.plist +[SSZipArchive] Error setting directory file modification date attribute +``` + +**Harmless** — Info.plist is a file, not a directory, and altool's +warning is bogus. Long-standing Apple-side noise. + +Successful upload ends with: + +``` +UPLOAD SUCCEEDED with no errors +Delivery UUID: 6a1711f4-2f11-4023-9711-9ddcef583a73 +✓ Upload accepted by App Store Connect +``` + +This is **not the same as "your build is in TestFlight"** — see Part 3 +below. + +### 2.5 If the wrapper isn't working — the manual three-step + +If `mix mob.republish` fails partway and you need to recover, or you'd +rather run each step yourself, here's the long form. Each command is +exactly what `mix mob.republish --ios` runs internally: + +```bash +# 1. Bump the build number (Apple rejects re-uploads of the same number). +# Reads CFBundleVersion, integer-bumps by 1, writes back. +CURRENT=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" ios/Info.plist) +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $((CURRENT + 1))" ios/Info.plist + +# 2. Build the IPA. +mix mob.release + +# 3. Upload to App Store Connect. +mix mob.publish --ios +``` + +The bump is a separate first step intentionally — the build number is +baked into the binary at compile time, not added later. If you skip +the bump and go straight to `mix mob.release`, the resulting `.ipa` +will have the SAME build number as the previous one and Apple will +reject the upload at validation time. + +Common recovery scenarios: + +- **`mix mob.release` failed** — fix the build error, then run `mix + mob.release && mix mob.publish --ios`. Don't bump again — the bump + already happened. +- **`mix mob.publish --ios` failed mid-upload** — the bump is + "consumed" from Apple's POV (they've seen that build number now, + even if upload didn't complete). Bump again then re-publish: + `mix mob.republish --ios`. +- **You bumped manually and want to re-build with the new number** — + `mix mob.republish --ios --no-bump` skips the bump step. + +### 2.6 Add testers in TestFlight + +App Store Connect → your app → **TestFlight** tab → **Internal Testing** +group → `+` to add testers by email. + +Internal testers (up to 100, must be users on your App Store Connect +team) get the build immediately, no review. + +External testers (up to 10,000, no team membership required) need a +one-time **Beta App Review** per major version (~24h typical), then get +the build via a public link or by email invite. + +For the first round of TestFlight beta testing, internal is usually +the fastest path — you and a couple of trusted testers can be added as +admins on your App Store Connect team. + +--- + +## Part 3 — Two-stage validation + +**The single most non-obvious thing about App Store uploads.** Apple +runs your build through TWO completely separate validators, and +"upload succeeded" only means you cleared the first one. + +``` +mix mob.publish + │ + ▼ +┌─ Stage 1: Upload validator ──────────────────────────────┐ +│ Runs while altool uploads. Catches obvious bundle │ +│ problems (missing required keys, wrong file structure, │ +│ disallowed content like .so/.a in the bundle, signature │ +│ issues). If it fails, altool exits non-zero and prints │ +│ the errors. mix mob.publish reports a failure. │ +└──────────────────────────────────────────────────────────┘ + │ "UPLOAD SUCCEEDED" → mix mob.publish exits 0 + ▼ +┌─ Apple processes the build (5–15 min) ───────────────────┐ +│ App Store Connect ingests the .ipa, generates assets, │ +│ runs the secondary validator against the ingested copy. │ +└──────────────────────────────────────────────────────────┘ + │ + ▼ +┌─ Stage 2: Secondary scanner ─────────────────────────────┐ +│ Static-analyses the binary for symbol references │ +│ (private API usage, missing usage strings for referenced │ +│ APIs), checks DT* keys against an allow-list of accepted │ +│ Xcode/SDK versions, etc. Issues are emailed to your │ +│ team's primary contact and visible in App Store Connect │ +│ → app → TestFlight tab → the build's "View Details" │ +│ link. │ +└──────────────────────────────────────────────────────────┘ + │ + ▼ +Build either appears in TestFlight as "Ready to Test" or shows +"Missing Compliance" / "Invalid Binary" with errors to fix. +``` + +**Practical consequence**: when `mix mob.publish` reports success, the +real test is what arrives in your inbox 5-15 minutes later. If you +don't see the build in the TestFlight tab after ~20 minutes, check +your email for an "App Store Connect" message titled "We noticed one +or more issues with a recent delivery". Those are stage-2 errors. + +The pipeline mob_dev 0.3.30 ships clears all the stage-1 errors and +the common stage-2 errors (missing usage strings, missing +`CFBundleSupportedPlatforms`, missing DT* keys, missing +`UIDeviceFamily`) — your first stage-2 surprise will likely be +something app-specific, not framework-level. + +The full list of stage-2 error codes Apple uses lives in the +[Troubleshooting](#troubleshooting) section below. + +--- + +## Troubleshooting + +### `xcodebuild: error: The flag -scheme is required when specifying -archivePath but not -exportArchive` + +Xcode 16 tightened the rules: `-archivePath` paired with `-target` now +errors out. mob_dev 0.3.27+ uses `-scheme` for archive actions. If you +hit this on an older mob_dev, upgrade. + +### `BuildProductsPath couldn't be opened` + +Xcode 26's archive action needs its own DerivedData layout for +intermediate paths. Don't override `SYMROOT`/`OBJROOT` for the archive +action. mob_dev 0.3.27+ handles this. + +### `MobProvision has conflicting provisioning settings. MobProvision is automatically signed for development, but a conflicting code signing identity Apple Distribution has been manually specified.` + +You're on an older mob_dev that uses automatic signing for the Release +config. The wildcard Apple Development profile your team owns satisfies +your specific bundle, so automatic signing never enters distribution +mode — even with `archive` action. The fix is manual signing for Release +plus a discovered `PROVISIONING_PROFILE_SPECIFIER`. mob_dev 0.3.27+ +does this. + +### `No profile for team '<X>' matching '<profile name>' found` + +`xcodebuild` is looking for a specific profile name and not finding it. +Two common causes: + +- The App ID isn't registered yet at Apple Developer (step 1.4) — the + profile can't exist until the App ID does +- An older mob_dev hardcoded the Apple-default profile name; if you + named your profile something else, the lookup fails + +mob_dev 0.3.27+ discovers profiles by UUID (parsing the local +profiles directory) so the profile name doesn't matter. + +### `Distribution profile can't be auto-created for an unregistered App ID` + +mob_dev's diagnostic for the "No profile for team..." case when the +App ID hasn't been registered. Walks you through step 1.4. Once the App +ID exists, re-run `mix mob.provision --distribution`. + +### `[SSZipArchive] Set attributes failed for directory: ...Info.plist` + +altool noise during IPA validation. Harmless — `Info.plist` is a file, +not a directory, and the warning is bogus. Long-standing altool issue +that Apple hasn't cleaned up. + +### `mix mob.publish` appears to hang for several minutes + +Not hung. altool is silent during upload unless `--verbose` is set. +Expect 2–10 minutes of no output for a 60–80MB IPA. Use the `ps aux` +check above to confirm it's still running. + +### `Missing :app_store_connect in mob.exs` + +You haven't done step 1.10/1.11. Get an API key and add the +`app_store_connect:` config block to `mob.exs`. + +### `One-time download` warning was missed for the API key + +If you closed the API key creation page without downloading the `.p8`, +the private key is gone — Apple doesn't store it. Go back to the API +key list, find the row, revoke it, create a fresh one. Costs nothing. + +### Stage-2 email: `ITMS-90683 Missing purpose string in Info.plist` + +You'll get an email from Apple titled "We noticed one or more issues +with a recent delivery". Body says: + +``` +The Info.plist file for the "<App>.app" bundle should contain a +NSCameraUsageDescription key with a user-facing purpose string +explaining clearly and completely why your app needs the data. +``` + +Apple's static analyser found a reference to a sensitive-data API +(camera, microphone, location, photos, motion, contacts, …) in your +binary and the matching `NS<X>UsageDescription` key isn't present in +Info.plist. + +For Mob apps this almost always comes from the framework's NIFs in +`mob_nif.m`, not your app code. See [Section 1.3](#13-keep-usage-strings-in-infoplist-counterintuitive--read-this). +**Don't strip usage strings even when your app doesn't use the capability.** + +The email lists ALL the missing strings, separated into "required to +fix" (will block the build from TestFlight) and "wanted to make you +aware of" (warnings — not blocking but worth fixing for App Store +review later). + +Fix → bump `CFBundleVersion` → `mix mob.release` → `mix mob.publish`. + +### Stage-2: error 90562 — `CFBundleSupportedPlatforms` missing + +``` +Invalid Bundle. Info.plist should specify CFBundleSupportedPlatforms +with an array containing a single platform. +``` + +mob_dev 0.3.30+ adds this defensively. Older versions don't — +upgrade. + +### Stage-2: error 90534 — Unsupported SDK or Xcode version + +``` +Your app was built with an SDK or version of Xcode that isn't supported. +``` + +Apple cross-references `DTSDKBuild` + `DTXcodeBuild` in your bundle +Info.plist against an allow-list of accepted Xcode releases. Two ways +this hits: + +1. Your `DT*` keys are missing or wrong. mob_dev 0.3.30+ synthesizes + the full set; older versions don't — upgrade. +2. Your Xcode itself is too old (or a beta that hasn't been moved to + the accepted list yet). Update Xcode to the current release. + +### Stage-2: error 90102 — `UIDeviceFamily` missing + +``` +The UIDeviceFamily key must be present when requiring a MinimumOSVersion +of at least 3.2. +``` + +mob_dev 0.3.30+ adds this defensively (defaults to `[1]` = +iPhone-only). For universal apps that also support iPad, set +`UIDeviceFamily` explicitly in your `ios/Info.plist`: + +```xml +<key>UIDeviceFamily</key> +<array> + <integer>1</integer> + <integer>2</integer> +</array> +``` + +### Stage-1: errors 90065 / 90507 / 90530 — Info.plist gaps + +``` +Invalid MinimumOSVersion. +Missing Info.plist value. A value for the key 'DTPlatformName' is required. +Missing Deployment Target. +``` + +mob_dev 0.3.30+ synthesizes these. Older versions don't. + +### Stage-1: error 90071 — CodeResources not a symbolic link + +``` +The CodeResources file must be a symbolic link to _CodeSignature/CodeResources. +``` + +mob_dev 0.3.30+ uses `ditto` with the right flags; older versions +used `zip` which flattens the symlink. + +### Stage-1: error 90171 — `.so` / `.a` / standalone binary in bundle + +``` +The "<App>.app/otp/lib/<otp_lib>/priv/lib/<thing>.so" binary file is not +permitted. Your app cannot contain standalone executables or libraries, +other than a valid CFBundleExecutable of supported bundles. +``` + +Apple's bundle policy: one Mach-O per `.app`. mob_dev 0.3.30+ strips +all `.so`/`.a`/standalone executables from the bundled OTP tree +(static archives are linked into the main binary; unused OTP libs +like megaco/runtime_tools/erl_interface are dropped entirely). Older +versions copy the OTP tree wholesale and trip this rule. + +### Stage-1: error 50 — non-public selectors + +``` +The app references non-public selectors in Payload/<App>.app/<App>: +_addTouch:forDelayedDelivery:, _clearTouches, _hidEvent, +_initWithEvent:touches:, _setHIDEvent:, ... +``` + +Mob's test harness uses private UIKit selectors for synthetic touch +injection. mob 0.5.12+ wraps the harness in `#if !MOB_RELEASE` so it +compiles out of release builds. mob_dev 0.3.29+ defines `MOB_RELEASE` +when compiling `mob_nif.m` for release. Both upgrades together clear +this; either one alone won't. + +### "I see no email but the build isn't in TestFlight after 20 minutes" + +App Store Connect → your app → **Activity** tab. Pending or rejected +builds show up here with a status. "Invalid Binary" usually means a +stage-2 error and the email is on its way (or in spam). "Processing" +means Apple is still ingesting — wait another 10 minutes. + +### "Build appears in TestFlight as `Missing Compliance`" + +Click into the build → answer the encryption-export-compliance question +(most apps qualify for the standard exemption). Not blocking for +internal testers but blocks external testing. diff --git a/guides/python_embedding.md b/guides/python_embedding.md new file mode 100644 index 0000000..126b8e4 --- /dev/null +++ b/guides/python_embedding.md @@ -0,0 +1,382 @@ +# Embedded CPython + +`mix mob.enable pythonx` adds [Pythonx](https://hex.pm/packages/pythonx) +support to a Mob app and bundles a real CPython interpreter into both +the iOS and Android app artifacts. Once enabled you can call +`Pythonx.eval/2` from BEAM to run Python code that ships inside the +app — no network, no sandbox-escape required, same API on either +platform. + +This guide is the contract between what Mob owns and what it doesn't. +Read the **scope** section before deciding to use it. + +--- + +## Scope + +Mob's Python support is deliberately narrow. + +**In scope (Mob owns this):** + +- A working CPython 3.13 interpreter on: + - iOS device + iOS simulator (BeeWare's + [`Python-Apple-support`](https://github.com/beeware/Python-Apple-support)) + - Android arm64 emulator + arm64 device ([Chaquopy](https://chaquo.com/chaquopy/)'s + prebuilt distribution) +- The Python standard library (the pure-Python bits) +- Standard arch-specific C extensions: `_ssl`, `_ctypes`, `_hashlib`, + `_socket`, `_md5`, `_sha*`, `_decimal`, `_ctypes_test`, … +- Build pipeline: cross-compiling `libpythonx.so` (the Pythonx NIF) + for each target, bundling the interpreter + stdlib + dynload + extensions, codesigning on iOS, asset extraction on Android first + launch. + +**Out of scope (Mob does not own this):** + +- **Third-party wheels.** Anything beyond the standard library — + `cryptography`, `numpy`, `RNS`, etc. — requires a cross-compiled + wheel for the right target. + - iOS: [BeeWare's `mobile-forge`](https://github.com/beeware/mobile-forge) + ships some pre-built; for anything else you build the wheel + yourself. + - Android: Chaquopy has its own wheel pipeline (see + [Chaquopy's docs on package compatibility](https://chaquo.com/chaquopy/doc/current/android.html)). + + Mob does not manage a wheel registry, does not know what wheels are + compatible, and does not field bug reports about specific Python + packages failing to import. +- **Android x86/x86_64.** The Android pipeline is wired for + `arm64-v8a` only. Most modern devices and the default Android + Studio emulator are arm64; if you specifically need to run on a + legacy x86 emulator you'll need to extend the build yourself. +- **App Store / Play Store review for Python apps.** The Mob + templates pass review for vanilla apps; CPython embedding adds + dynamic libraries store reviewers may flag. We've validated dev + signing on physical devices on both platforms; an actual + TestFlight or Play Console upload of a Pythonx-enabled app hasn't + been smoke-tested by the Mob team yet. + +If you need wheels or non-arm64 Android, that's fine — you just can't +expect Mob to be the place that solves it for you. + +--- + +## Quick start + +### From scratch + +```bash +mix mob.new my_app --python +cd my_app +mix mob.deploy --native --device <udid> +``` + +### In an existing project + +```bash +cd my_app +mix mob.enable pythonx +mix deps.get +mix mob.deploy --native --device <udid> +``` + +The first `mob.deploy --native` downloads the per-platform CPython +distribution into `~/.mob/cache/` and reuses it across projects: + +| Platform | Source | Cache key | +|---|---|---| +| iOS | BeeWare's `Python-Apple-support` (~70 MB) | `python-apple-support-<vsn>/` | +| Android | Chaquopy prebuilts (~30 MB after pruning) | `python-android-support-<vsn>/` | + +`mix mob.enable pythonx` runs a freshness check on existing projects +and warns if `ios/build.sh`, `android/.../CMakeLists.txt`, or +`MainActivity.kt` are missing the build-time hooks the deploy +expects. If you see that warning, regenerate from the latest +`mob_new` archive or copy the bracketed regions across. + +--- + +## Wiring it up + +`mix mob.enable pythonx` writes: + +- `lib/<app>/python_paths.ex` — a pure detection module that returns + `:desktop` / `{:ios, paths}` / `{:android, paths}` / + `{:partial, missing}` based on what artifacts it finds at runtime. +- A `:pythonx, :uv_init` block in `config/config.exs`. The same + config lands at compile and runtime, so Pythonx's + `validate_compile_env` check is satisfied unconditionally — no + env-var gate. + +You still need to wire `Pythonx.init/4` into your app's `on_start/0` +for the mobile branches. The recipe: + +```elixir +defmodule MyApp.App do + use Mob.App + + @impl Mob.App + def navigation(_platform), do: stack(:main, root: MyApp.HomeScreen) + + @impl Mob.App + def on_start do + case MyApp.PythonPaths.detect(to_string(:code.root_dir())) do + :desktop -> + # Desktop: uv handles venv setup at app start. This is the + # only branch that calls ensure_all_started — on device, + # `Pythonx.UvInit.init/1` would shell out to `uv` and fail. + {:ok, _} = Application.ensure_all_started(:pythonx) + + {:ios, %{dl_path: dl, home_path: home}} -> + # iOS: bundle is at <App>.app/otp/python/. Pythonx.init/4 + # loads the NIF directly against the bundled framework. + Pythonx.init(dl, home, dl, sys_paths: []) + + {:android, %{dl_path: dl, home_path: home}} -> + # Android: MainActivity has unpacked Chaquopy's distribution + # to filesDir/python/ and exported MOB_PYTHON_HOME + + # MOB_PYTHON_DL before BEAM startup. Add stdlib explicitly + # because Chaquopy's layout doesn't auto-resolve it. + Pythonx.init(dl, home, dl, + sys_paths: [Path.join([home, "lib", "python3.13"])]) + + {:partial, missing} -> + # The directory exists but artifacts are missing. Means the + # build pipeline broke between cross-compile and bundling. + # Surface this — don't let the screen try to call into a + # half-initialized interpreter. + Logger.error("Python bundle incomplete; missing: #{inspect(missing)}") + end + + Mob.Screen.start_root(MyApp.HomeScreen) + end +end +``` + +After `Pythonx.init/4` returns, `Pythonx.eval/2` is callable from any +screen. A minimal HomeScreen that proves the interpreter works: + +```elixir +defmodule MyApp.HomeScreen do + use Mob.Screen + + def mount(_params, _session, socket) do + version = + try do + {result, _} = Pythonx.eval("import sys; sys.version", %{}) + Pythonx.decode(result) |> to_string() |> String.split("\n", parts: 2) |> hd() + rescue + e -> "eval failed: " <> Exception.message(e) + end + + {:ok, Mob.Socket.assign(socket, :python_version, version)} + end + + def render(assigns) do + ~MOB""" + <Column padding={:space_lg}> + <Label text={"Python: " <> @python_version} /> + </Column> + """ + end +end +``` + +--- + +## How it works + +### iOS + +Three pieces ship in your `<App>.app` bundle: + +1. **`<App>.app/otp/python/Python.framework/Python`** — the CPython + interpreter binary (a Mach-O dylib with libpython statically + linked, plus libssl/libcrypto for `_ssl` / `_hashlib`). +2. **`<App>.app/otp/python/lib/python3.13/`** — the pure-Python + standard library (`os.py`, `urllib/`, `email/`, …) following the + `PYTHONHOME` contract. +3. **`<App>.app/otp/python/lib/python3.13/lib-dynload/*.so`** — + arch-specific compiled C extensions, codesigned individually with + your dev/distribution identity. + +The fourth piece — **`<App>.app/otp/lib/pythonx-VSN/priv/libpythonx.so`** — +is the Pythonx NIF, cross-compiled for iphoneos/iphonesimulator arm64 +during `mix mob.deploy --native`. It dlopens +`Python.framework/Python` at runtime when `Pythonx.init/4` is called. + +`mix mob.deploy --native` orchestrates this: + +| Step | Module | +|---|---| +| Download + cache BeeWare bundle | `MobDev.PythonAppleSupport.ensure/0` | +| Detect Pythonx in user's project | `MobDev.NativeBuild.pythonx_in_project?/1` | +| Install pythonx as OTP lib + cross-compile `libpythonx.so` | `MobDev.NativeBuild.maybe_setup_pythonx_device/5` (Mix-driven, calls `xcrun -sdk iphoneos clang++` directly) | +| Generate `enif_keepalive.c` (174 enif_* refs) | `MobDev.NativeBuild.generate_enif_keepalive/3` | +| Bundle framework + stdlib + lib-dynload into `<otp_root>/python/` | inside `maybe_setup_pythonx_device/5` | +| Codesign every dylib bottom-up | `MobDev.NativeBuild.codesign_ios_device_app/3` (`codesign` per `.so` + framework binary, then `.app`) | + +### Android + +Three pieces ship in your APK: + +1. **`jniLibs/arm64-v8a/libpython3.13.so`** + **`libpythonx.so`** — + the CPython interpreter and the Pythonx NIF, packaged into the + APK's native lib directory. Android's installer auto-extracts to + the app's `nativeLibraryDir`. `libpythonx.so` was cross-compiled + with the NDK against a stub `libpython3.13.so` carrying the right + SONAME, so the dynamic loader's `NEEDED` entry resolves at + runtime. +2. **`assets/python/lib/python3.13/`** — the stdlib, packed into the + APK as assets. `MainActivity.extractPythonAssetsIfNeeded()` + unpacks to `filesDir/python/lib/python3.13/` on first launch + (idempotent via a `.extracted` marker). +3. **`assets/python/lib/python3.13/lib-dynload/<abi>/*.so`** — + arch-specific C extensions. Chaquopy ships them per-abi; + `MainActivity.flattenLibDynload()` picks the device's primary ABI + and moves the files up to `lib-dynload/` to match CPython's + expected flat layout. + +`MainActivity.onCreate` exports `MOB_PYTHON_DL` (path to +`libpython3.13.so` in `nativeLibraryDir`) and `MOB_PYTHON_HOME` (path +to the extracted stdlib root) before calling `nativeStartBeam`. The +`<App>.PythonPaths.build_android_paths/0` function reads those vars +back and feeds them to `Pythonx.init/4`. + +| Step | Module | +|---|---| +| Download + cache Chaquopy distribution | `MobDev.PythonAndroidSupport.ensure/0` | +| Detect Pythonx in user's project | `MobDev.NativeBuild.pythonx_in_project?/1` | +| Build stub `libpython3.13.so` for cross-link | `MobDev.NativeBuild.build_libpython_android_test_stub_so/1` | +| Cross-compile `libpythonx.so` (NDK) | `aarch64-linux-android28-clang++` | +| Generate `enif_keepalive.c` (NDK llvm-nm scan) | `MobDev.NativeBuild.generate_android_enif_keepalive/2` | +| Install Pythonx as OTP lib (mirrors exqlite) | `MobDev.NativeBuild.install_pythonx_otp_lib_android/2` | +| Bundle stdlib + lib-dynload as assets | `MobDev.NativeBuild.bundle_python_android_assets/2` | +| Symlink Pythonx NIF into nativeLibraryDir | `mob_beam.c` (at app launch) | + +The build script's Pythonx work is gated on +`if [ -d "_build/dev/lib/pythonx" ]` — projects that have never run +`mix mob.enable pythonx` see the gate as a no-op and pay no overhead. + +--- + +## Bundle size + +| Piece | iOS | Android | +|---|---|---| +| Interpreter binary | ~5.2 MB (`Python.framework/Python`) | ~6 MB (`libpython3.13.so`) | +| Stdlib | ~61 MB | ~22 MB (Chaquopy ships pyc-only) | +| C extensions (lib-dynload) | ~3 MB (68 extensions) | ~2 MB (per-arch only) | +| `libpythonx.so` (Pythonx NIF) | ~150 KB | ~150 KB | +| **Total Python overhead** | **~70 MB** | **~30 MB** | + +This is one-time, not per-feature. If your app already ships Python, +adding more Python code (your own `.py` files, additional imports +from stdlib) doesn't grow the bundle further. + +A vanilla Mob app (no Python) is ~3 MB. Apply this only when you +actually want to call Python from BEAM. + +--- + +## When not to use this + +- **You only want one or two pure functions implemented in Python.** + Port them. The bundle cost dwarfs the productivity win for small + uses. +- **You want a Python web framework or async runtime.** BEAM is the + better runtime for that on Mob — Phoenix is a dep away. +- **You're targeting App Store / Play Store distribution and your + timeline is tight.** We've validated dev signing on physical + devices; store review of a CPython-bundled app is unproven by the + Mob team. Budget time for unknown rejection categories. + +--- + +## Going further: third-party wheels + +If you need a Python package beyond stdlib (the original push for +this feature was Reticulum, which depends on `cryptography`): + +**iOS** + +1. Use [BeeWare's `mobile-forge`](https://github.com/beeware/mobile-forge) + to cross-compile the wheel for iOS. +2. Place the wheel in your project at `priv/python_wheels/<name>.whl`. +3. Patch your `mix mob.deploy --native` flow to extract the wheel + into `<App>.app/otp/python/lib/python3.13/site-packages/` and + codesign any `.so` files inside it before the final app sign. + +**Android** + +1. Build the wheel via Chaquopy's own pipeline, or grab a prebuilt + one from their package index. +2. Place the wheel at `priv/python_wheels/<name>.whl`. +3. Extract into `assets/python/lib/python3.13/site-packages/` (so it + gets bundled at build time and unpacked by + `extractPythonAssetsIfNeeded`). + +Mob does not currently script step 3 on either platform. You'll need +to maintain a post-`mob.deploy --native` patch step in your project +that handles the wheels you need. Each wheel is its own per-platform +compatibility problem; Mob explicitly does not own that surface. + +If you find yourself building this for your own project and it +generalizes well, please share your approach upstream — we may +revisit the scope decision if there's a clean abstraction that +doesn't lock Mob into wheel ecosystem maintenance. + +--- + +## Troubleshooting + +**App crashes silently on launch with no Python output.** First check +for OTP version mismatch — your local Erlang must match the device +runtime's ERTS (currently OTP 29 / erts-17.0). `mise` reads +`.tool-versions`; if `mise current` disagrees with `mise exec -- erl`, +your shell hasn't picked up the project's pinned version. + +**`Pythonx.eval` raises `ModuleNotFoundError: No module named '_ctypes'`.** + +- *iOS:* the arch-specific `lib-dynload/` wasn't bundled. Check that + `<App>.app/otp/python/lib/python3.13/lib-dynload/` exists in your + build output — `mix mob.deploy --native` should print + `lib-dynload: 68 extensions` during the bundling step. +- *Android:* `flattenLibDynload` didn't run, or the device's primary + ABI didn't match what was bundled. Check + `filesDir/python/lib/python3.13/lib-dynload/` after first launch + and confirm `_ctypes.cpython-313.so` is present (flat, not nested + under `arm64-v8a/`). + +**Python `Failed to import encodings`.** The stdlib path doesn't +match `PYTHONHOME`'s `lib/python3.13/` expectation. On Android, the +extracted assets must land at `filesDir/python/lib/python3.13/` +(*not* `filesDir/python/stdlib/`); the `MainActivity` template ships +the right layout — if you patched it, double-check. + +**Build fails with `pythonx in deps but PYTHON_APPLE_SUPPORT not +set`.** You ran `bash ios/build_device.sh` directly. Use +`mix mob.deploy --native` instead — it calls +`MobDev.PythonAppleSupport.ensure/0` to download the BeeWare bundle +and exposes the path to the script. + +**Android `dlopen` fails with `library "libpython3.13.so" not +found`.** The Pythonx NIF's `NEEDED` entry isn't resolving at +runtime. This usually means `libpython3.13.so` didn't get packaged +into `jniLibs/arm64-v8a/`. Check the APK with +`unzip -l build/outputs/apk/debug/app-debug.apk | grep libpython`. + +**Codesign failure on `libpythonx.so` or a lib-dynload `.so` (iOS).** +Likely your signing identity doesn't match the team in your +provisioning profile. `mix mob.doctor` flags this; otherwise, +regenerate provisioning via `mix mob.provision`. + +**Compile-env mismatch error: `the application :pythonx has a +different value set for key :uv_init during runtime compared to +compile time`.** Old projects had a `MOB_TARGET=ios`-gated +`:uv_init` block in `config/config.exs`. Mob no longer uses a gate — +delete the `if System.get_env("MOB_TARGET") in [nil, ""] do` +wrapper and leave the `config :pythonx, :uv_init, ...` block at +top level. The mobile-vs-desktop split lives in `on_start/0` now +(the desktop branch calls `Application.ensure_all_started(:pythonx)`, +mobile branches don't). diff --git a/guides/security_scan.md b/guides/security_scan.md new file mode 100644 index 0000000..d1a3e76 --- /dev/null +++ b/guides/security_scan.md @@ -0,0 +1,251 @@ +# Security scanning + +Mob ships three commands for tracking known vulnerabilities across +every surface a Mob app actually compiles into the binary: + +| Command | When to use it | +| ------- | -------------- | +| `mix mob.security_scan` | One-off scan. Pretty terminal output for the human at the keyboard. | +| `mix mob.security_scan.log` | Scheduled run (cron / GitHub Actions). Writes a current snapshot, prepends a delta entry to a changelog, and persists state across runs. | +| `mix mob.release --security-gate` | Release-time gate. Runs the scan, aborts the build on any critical/high/medium finding. | + +All three sit on top of the same scan engine — pick the wrapper +that matches your trigger. + +--- + +## What gets scanned + +Seven layers, each running independently and aggregated into one +report. A missing external scanner is a soft warning (the layer +reports `tool missing`), not a failure. + +| Layer | Tool(s) | Surface area | +| ----- | ------- | ------------ | +| `hex_deps` | [`mix_audit`](https://hexdocs.pm/mix_audit/) + [`osv-scanner`](https://google.github.io/osv-scanner/) | Hex deps in `mix.lock`. Two sources because they miss different things — the Erlef CNA feed (osv-scanner) tends to surface CVE-numbered advisories Mirego's curated database (mix_audit) hasn't ingested yet. | +| `gradle_deps` | `osv-scanner` | Android Gradle deps. Best results when you've turned on Gradle dependency locking — see "Enabling Gradle dependency locking" below. | +| `swift_deps` | `osv-scanner` | iOS Swift Package Manager (`Package.resolved`) and CocoaPods (`Podfile.lock`). | +| `bundled_runtime` | manifest + binary fingerprint | OpenSSL, ERTS, Elixir, exqlite, SQLite **baked into Mob's pre-built OTP tarball**. Generic dep scanners can't see these because they're in static archives, not lockfiles. | +| `c_source` | [`semgrep`](https://semgrep.dev/) + [`flawfinder`](https://dwheeler.com/flawfinder/) | Mob's NIF C/Objective-C plus the exqlite NIF wrapper. Excludes the SQLite amalgamation (huge, battle-tested, would generate thousands of low-value findings). | +| `kotlin_source` | [`detekt`](https://detekt.dev/) | Kotlin/Java under `android/app/src/main/`. Set `MOB_DETEKT_CONFIG=path` to use a security-focused rule config. | +| `swift_source` | [`swiftlint`](https://github.com/realm/SwiftLint) | Swift under `ios/`. Mob's iOS bridge is mostly Objective-C, which is covered by `c_source` instead. | + +The `bundled_runtime` layer is the one that makes Mob's scan +unusual. It opens `libcrypto.a` from the cached OTP tarball, scans +the `.rodata` section for the OpenSSL version banner, and emits a +`:high` finding if the binary disagrees with what +[`priv/security/bundled_versions.exs`](../priv/security/bundled_versions.exs) +claims shipped. That manifest is the source of truth for what's +inside the static archives Mob distributes; the fingerprinter is +the receipt that proves the manifest is honest. + +--- + +## One-time setup + +```bash +brew install osv-scanner semgrep flawfinder detekt swiftlint +``` + +`mix_audit` is a Hex dependency of `mob_dev`, no extra install. +The OpenSSL/SQLite/OTP fingerprinting is pure Elixir — no external +`strings(1)` or similar required. + +Each layer soft-degrades when its scanner is missing, so install +incrementally as you want fuller coverage. `osv-scanner` is the +highest-value install (it drives three layers). + +--- + +## `mix mob.security_scan` + +The interactive entry point. Run it whenever you want to know "what +does this project look like right now?". + +```bash +mix mob.security_scan # full scan, terminal output +mix mob.security_scan --json # machine-readable to stdout +mix mob.security_scan --skip kotlin_source,c_source +mix mob.security_scan --strict # exit 1 on any critical/high/medium +mix mob.security_scan --write-report SECURITY_SCAN.md +``` + +Output is severity-coloured, sorted critical → unknown, with +`fixed_in` versions when known and clickable advisory URLs. + +--- + +## `mix mob.security_scan.log` + +The scheduled-run wrapper. Designed for cron, GitHub Actions, or +any other recurring trigger. Each run writes three files at the +project root: + +| File | Role | +| ---- | ---- | +| `SECURITY_SCAN.md` | Current-state snapshot, overwritten each run. | +| `SECURITY_HISTORY.md` | Append-only changelog, newest entry on top. Each entry has **New since last scan**, **Resolved since last scan**, and **Still present from last scan** sections. Persisting findings carry a `_(first seen N days ago)_` patch-lag suffix. | +| `.security_scan/state.json` | JSON sidecar that records the last-known finding set + per-finding `first_seen_at` timestamps. | + +**Commit all three.** The state file is what makes the changelog +meaningful across machines and CI runs — without it, every run +reports every finding as "new" and the timeline loses signal. + +### A typical history entry + +```markdown +## 2026-05-07T13:59:24Z + +**Project:** `/Users/me/myapp` +**Total findings:** 2 (0 critical, 2 high, 0 medium, 0 low, 0 unknown) + +### New since last scan (1) +- **HIGH** `mob/otp-tarball@ios_sim` `[MOB-DRIFT-ios_sim-elixir]` — + Bundled-versions drift: Elixir manifest=1.19.5 binary=1.20.0-rc.4 + +### Resolved since last scan (1) ✓ +- **HIGH** `phoenix@1.8.5` `[EEF-CVE-2026-32689]` — + Long-poll NDJSON body splitting causes unbounded memory allocation + +### Still present from last scan (1) +- **CRITICAL** `openssl@3.4.0` ... _(first seen 22 days ago)_ +``` + +### Cron entry + +```bash +# daily at 06:00 local +0 6 * * * cd /path/to/project && mix mob.security_scan.log >> /tmp/security_scan.log 2>&1 +``` + +### GitHub Actions workflow + +Opens a PR each week with the three files updated: + +```yaml +name: security-scan +on: + schedule: [{cron: "0 6 * * 1"}] + workflow_dispatch: +jobs: + scan: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: {elixir-version: "1.19", otp-version: "28"} + - run: brew install osv-scanner semgrep flawfinder detekt swiftlint + - run: mix deps.get + - run: mix mob.security_scan.log + - uses: peter-evans/create-pull-request@v6 + with: + title: "security: weekly scan update" + branch: security-scan-update + add-paths: | + SECURITY_SCAN.md + SECURITY_HISTORY.md + .security_scan/state.json +``` + +Each scan becomes a small, reviewable PR that explicitly tells you +"this week we fixed N, found M new, still owe these K." + +--- + +## `mix mob.release --security-gate` + +The release gate. Wraps `mix mob.security_scan --strict` around +your release build so a vulnerable artifact never reaches signing. + +```bash +mix mob.release --android --security-gate +mix mob.release --ios --security-gate +``` + +If the scan surfaces any critical/high/medium finding, the release +aborts with exit code 1 — nothing is built, nothing is signed. +Low/unknown findings are non-blocking (you can review them in +`SECURITY_SCAN.md`). + +The same flag works alongside the existing release flags: + +```bash +mix mob.release --ios --slim --security-gate +``` + +The success printouts of `mix mob.release` mention `--security-gate` +as a tip whenever the gate wasn't used, so the option stays visible. + +--- + +## Updating after rebuilding the OTP tarballs + +When Mob's pre-built OTP runtime is rebuilt +([`build_release.md`](../build_release.md)), +[`priv/security/bundled_versions.exs`](../priv/security/bundled_versions.exs) +must be updated to declare the new versions baked into each +tarball. The `bundled_runtime` layer fingerprints the cached +binaries on disk and emits a `:high` "manifest drift" finding if +the manifest disagrees with what's actually shipping — that's the +exact failure mode the manifest exists to catch. Update it in the +**same PR** as the OTP hash bump. + +--- + +## Enabling Gradle dependency locking + +By default, Gradle doesn't lock transitive dependencies, which +means `osv-scanner` only sees the deps you've declared in +`build.gradle`. To get full transitive coverage, opt into Gradle's +dependency locking: + +```gradle +// android/build.gradle +allprojects { + configurations.all { + resolutionStrategy.activateDependencyLocking() + } +} + +// android/app/build.gradle +dependencyLocking { + lockAllConfigurations() +} +``` + +Then run: + +```bash +cd android && ./gradlew :app:dependencies --write-locks +``` + +`gradle.lockfile` will appear under `android/app/`. Commit it. +Subsequent scans will pick up every transitive dep and surface +its CVEs. + +--- + +## Tradeoffs and limitations + +- **No live OpenSSL/SQLite CVE feed.** OpenSSL retired its JSON + feed and OSV.dev doesn't index native libraries as packages. + The `bundled_runtime` layer instead reports the exact OpenSSL + and SQLite versions it found and points you at the upstream + advisory pages (`openssl-library.org/news/vulnerabilities/`, + `sqlite.org/cves.html`) so you can verify manually. The + manifest-drift detection is real and runs every scan — that + catches the most common failure mode (your manifest is lying + about what shipped). + +- **`xcodebuild analyze` is not in the Swift layer.** The Clang + Static Analyzer is the gold standard for Objective-C and Swift + but requires a buildable Xcode project (signing, provisioning, + the works). `swiftlint` is the pragmatic substitute that doesn't + need a build. If you want clang-analyze in CI, run it as a + separate step. + +- **SQLite amalgamation is excluded from C-source scanning.** It's + ~9MB of well-tested code; running general C rules over it would + produce thousands of low-value findings. Use the + `bundled_runtime` layer to track its version against the SQLite + advisory page instead. diff --git a/guides/slim_release.md b/guides/slim_release.md new file mode 100644 index 0000000..104344d --- /dev/null +++ b/guides/slim_release.md @@ -0,0 +1,402 @@ +# Slim Release — Cutting the iOS bundle from 45 MB to 19.8 MB + +This guide is the working record of how the Mob iOS bundle went from +**45 MB** (the size that kept TestFlight rejecting builds for being +"unnecessarily large") down to **19.8 MB** — a 56% reduction, all +without dropping a single feature. + +It also documents the toolchain we built along the way, why the +defaults are asymmetric between dev and release, and how to bisect a +broken build using the per-step `[SLIM:<tag>]` traceability markers. + +> The decision to eventually extract this work as a standalone Hex +> package (`lean_release`) is tracked in +> [`lean_release_extraction.md`](../lean_release_extraction.md). For +> now, everything ships inside `mob_dev`. + +--- + +## TL;DR + +```bash +mix mob.deploy # dev iteration — slim OFF (fast, ~2-3s saved per build) +mix mob.deploy --slim # dev iteration — slim ON (verify before TestFlight) +mix mob.release # App Store / TestFlight — slim ON +mix mob.release --no-slim # App Store / TestFlight — slim OFF (debugging only) +``` + +Search for `[SLIM:<step>]` in the build log to see how many KB each +strip pass shaved off. If a step breaks the build, this is your bisect +log. + +--- + +## The 45 → 19.8 → ?? MB journey + +| Build | IPA size | What changed | +| ------ | -------- | ----------------------------------------------------------------------- | +| Before | 45 MB | Vanilla `mix mob.release` — full OTP runtime, no strips | +| Step 1 | 37 MB | Apple-policy strips made traceable + made always-on | +| Step 2 | 27 MB | Added `prefix_libs`, `foreign_apps`, `dedup_versions`, `src_and_headers`| +| Step 3 | 19.8 MB | `beam_chunks` strip (Dbgi/Docs chunks, `:beam_lib.strip_release/1`) | +| Step 4 | TBD | Pass 1: C-side `-Os -ffunction-sections -fdata-sections` + `--gc-sections` | + +### Pass 1: C-side dead-section elimination (2026-05-06) + +Inspired by the [GRiSP nano writeup (2025-06-11)](https://www.grisp.org/blog/posts/2025-06-11-grisp-nano-codebeam-sto) +where the same flags were the single biggest C-side shrink for fitting +the BEAM into 16 MB of OctoSPI DRAM. The mechanic: + +- `-Os` (size-optimised) over the default `-O2` / `-O3`. +- `-ffunction-sections -fdata-sections` puts every function and global + data object in its own ELF/Mach-O section. +- `-Wl,--gc-sections` (GNU ld, used on Android) and `-Wl,-dead_strip` + (ld64, used on iOS) drop sections nothing references at link time. + +Together they let the linker delete unused functions from `libbeam.a`, +`libcrypto.a`, our `crypto.a` static NIF wrapper, and our own +`libpigeon.so` / iOS app binary. This is the C-side analog of what +`beam_lib:strip_release/1` does for `.beam` files. + +Applied to: +- All four OTP cross-compiles (`xcomp/erl-xcomp-*-android.conf`, + `xcomp/erl-xcomp-arm64-ios{,simulator}.conf`). +- All four OpenSSL cross-compile scripts in `scripts/release/openssl/`. +- The custom `build_crypto_static_*.sh` scripts that recompile + crypto's NIF C sources with `-DSTATIC_ERLANG_NIF -fPIC`. +- The Android `CMakeLists.txt` template in `mob_new` and + `mob_dev/lib/mob_dev/native_build.ex`'s generated iOS-device link. +- The iOS `build.sh.eex` template (sim-build link). + +Size delta recorded in `~/code/mob/crypto_plan.md` once the rebuild + +republish cycle completes. + +Sample `[SLIM:...]` log from a recent release build: + +``` +[SLIM:prefix_libs] 120824 KB → 74756 KB (-46068 KB) +[SLIM:foreign_apps] 74756 KB → 74020 KB (-736 KB) +[SLIM:dedup_versions] 74020 KB → 64012 KB (-10008 KB) +[SLIM:src_and_headers] 64012 KB → 47840 KB (-16172 KB) +[SLIM:beam_chunks] 47840 KB → 27552 KB (-20288 KB) +``` + +Note that the first column above is the OTP _runtime tree_ (~120 MB +unzipped on disk), not the IPA. The IPA is the codesigned `.app` +zipped with `ditto` after these strips, plus the main Mach-O. + +--- + +## What gets stripped (and why) + +Every step is a separate bash invocation routed through the +`slim_step <tag> <command>` helper. Each prints a tagged size delta so +the build log makes the impact of each step obvious — and so a +regression in a single step can be bisected from the log alone. + +### `apple_binaries` (always on, never gated) + +Apple's App Store validator (error 90171) rejects bundles with `.so`, +`.a`, or any standalone Mach-O other than the `CFBundleExecutable`. +We strip all of these from the bundled OTP runtime. This is the **one +strip that runs even with `--no-slim`** — it's not optional for +shipping to TestFlight or the App Store. + +What goes: + +- `*.so` and `*.a` everywhere under `OTP_BUNDLE` +- `priv/bin/*` (memsup, cpu_sup, et al.) +- `erts-*/bin/*` (erl_call, erlexec, et al.) + +### `prefix_libs` + +Strips OTP applications by name prefix that no Mob app ever loads. +The biggest single saving — typically 40–50 MB. + +The current strip set: + +``` +megaco runtime_tools erl_interface os_mon wx et eunit +observer debugger diameter edoc tools snmp dialyzer +syntax_tools parsetools xmerl reltool inets ftp tftp +common_test mnesia eldap odbc +compiler ssh # 2026-05-06 +``` + +The 2026-05-06 additions came from `Mob.Diag.loaded_snapshot/0` against +a running pigeon iOS-sim build: 0 of 59 `compiler-9.0.5` modules and +0 of 43 `ssh-5.5.1` modules ever loaded. ~4.4 MB win, no observed +regressions on either platform. **Risk floor:** any mob app that calls +`Code.eval_string/1`, `Code.compile_string/1`, `:erl_eval.eval_str/1`, +or starts an `:ssh` client/server breaks. None of the apps in this +tree do; new apps with those needs need to drop the strip in +`MobDev.Release` / `MobDev.NativeBuild`. + +**Empirical-snapshot-driven additions are the way.** Apps that +shouldn't have made the strip set show up in the next loaded_snapshot +diff. The dance: deploy → `mob.snapshot_loaded` → see what's loaded +that shouldn't be vs. shipped that's never used → iterate. + +Adding to this list is a one-line change in `lib/mob_dev/release.ex` +(and the matching `lib/mob_dev/native_build.ex` for dev parity, plus +the test list in `test/mob_dev/release_script_test.exs`). Any prefix +on this list will balloon the bundle if removed — only do that if a +specific app actually depends on it at runtime. + +### `foreign_apps` + +If you've ever run `mix mob.release` from a checkout that shares a +dep cache with another project, you may end up with `toy_*`, +`test_*`, `mob_test`, or `scratch_*` apps shipping in the bundle. +These are not OTP apps and definitely not _your_ app — they leaked in +from another release tree. This strip drops them. + +### `dedup_versions` + +`asn1-5.4` and `asn1-5.4.3`. `public_key-1.18` and `public_key-1.20.2` +and `public_key-1.20.3`. The OTP runtime cache accumulates older +versions of libraries when you upgrade. Only the newest version is +ever loaded; older versions are dead weight. This step keeps the +highest version of each library and removes the rest. + +The duplicate detection logic is in +`MobDev.OtpAudit.collapse_duplicates/1` — covered by unit tests in +`test/mob_dev/otp_audit_test.exs`. + +### `src_and_headers` + +`src/*.erl` and `include/*.hrl` are compile-time only. They are not +loaded at runtime. They sit in the bundle out of habit because OTP's +release packager copies the whole library directory. We drop them. + +This is a ~16 MB win on a typical Mob app. + +### `beam_chunks` + +`:beam_lib.strip_release/1` removes the `Dbgi`, `Docs`, `Atom`, and +`Locals` chunks from every `.beam` file in the bundle. Roughly 30% +saved per `.beam` — usually 15–25 MB total across the OTP runtime +tree. + +Mix has a `strip_beams: true` release option that does the same thing, +but `mix mob.release` does not go through `mix release` (we build a +custom `.ipa` that bypasses Mix's release packaging entirely), so we +call `:beam_lib.strip_release/1` directly on the bundle. + +### `xcrun strip -x` (main binary symbol strip) + +Run after the OTP strips, before codesigning. Removes non-global +symbols from the main Mach-O. Smaller win (~1–2 MB) but mandatory for +clean App Store submission. + +--- + +## Asymmetric defaults (dev OFF, release ON) + +The slim pass adds 5–10 seconds per build: + +- `:beam_lib.strip_release/1` spawns an `erl` process and walks every + `.beam` in the bundle. +- `xcrun strip -x` rewrites the main Mach-O. +- Cache cleanup (`dedup_versions`, `foreign_apps`) does I/O across the + full lib tree. + +For dev iteration, that's 5–10 seconds you don't get back. For App +Store delivery, that's 5–10 seconds against a 20-minute TestFlight +round-trip plus the cost of confusing testers with a second build +number — easy trade. + +So the defaults are flipped between paths: + +| Task | Slim default | Override flag | +| ------------------------ | ------------ | ------------------- | +| `mix mob.deploy` | OFF | `--slim` | +| `mix mob.deploy --native`| OFF | `--slim` | +| `mix mob.release` | ON | `--no-slim` | + +The opt-in for dev exists so you can verify a slim build runs on +device _before_ you round-trip it through TestFlight. The opt-out for +release exists for the case where you're debugging a strip-induced +regression and need symbols + Dbgi chunks to do it. + +--- + +## Plumbing + +The slim flag travels from the Mix task to the bash script through +three hops: + +1. `mix mob.deploy` / `mix mob.release` parse `--slim` / `--no-slim`, + defaulting to `false` / `true` respectively. +2. The Mix task calls `MobDev.NativeBuild.build_all(slim: bool)` (dev) + or `MobDev.Release.build_ipa(slim: bool)` (release). +3. Both stash the value in the process dictionary + (`Process.put(:mob_slim, bool)`), and the env-var builder reads it + into `MOB_SLIM=0` or `MOB_SLIM=1`. +4. The generated bash script gates the slim block on + `if [ "${MOB_SLIM:-1}" = "1" ]; then` (release default 1) or + `if [ "${MOB_SLIM:-0}" = "1" ]; then` (dev default 0). + +The bash default differs between paths so that if `MOB_SLIM` is unset +entirely (e.g. someone runs the generated script by hand), the right +behavior happens for that path. + +--- + +## Per-step traceability — the `[SLIM:<tag>]` log + +Every strip step is wrapped with the `slim_step` bash function: + +```bash +slim_step() { + local label=$1 + local before=$(du -sk "$OTP_BUNDLE" 2>/dev/null | awk '{print $1}') + shift + "$@" + local after=$(du -sk "$OTP_BUNDLE" 2>/dev/null | awk '{print $1}') + local delta=$((before - after)) + printf "[SLIM:%s] %s KB → %s KB (-%s KB)\n" "$label" "$before" "$after" "$delta" +} +``` + +Sample output: + +``` +[SLIM:prefix_libs] 120824 KB → 74756 KB (-46068 KB) +``` + +If a build breaks _and_ the breakage is in the slim pass, the +`[SLIM:<tag>]` log tells you exactly which step did it. The build log +should be the first place you look: + +```bash +grep '\[SLIM:' build.log +``` + +If the failing step's `[SLIM:<tag>]` line is missing entirely, the +breakage happened _during_ that step — look at the lines just above +the next `[SLIM:...]` line for the actual error. If every step is +present but the build still fails, the breakage is downstream +(codesigning, ditto packaging, plist surgery). + +--- + +## Tool inventory + +These all live under `mix mob.*` and are documented per-task with +`mix help <task>`. The slim build itself is always-on infrastructure; +the rest are diagnostic tools you reach for when investigating bundle +size or stripping aggressiveness. + +### `mix mob.audit_otp` + +Static reachability analysis. Walks every `.beam` file under an OTP +root, extracts the `imports` chunk, computes the transitive closure +from your app's entry-point modules. Anything not reachable is a +strip candidate. + +```bash +mix mob.audit_otp +``` + +The output reports unreachable libraries (candidates for the +`prefix_libs` set), duplicate library versions (caught by +`dedup_versions`), and foreign apps from cache pollution (caught by +`foreign_apps`). + +This is how the original 10 MB of cruft was found. + +Implementation: `MobDev.OtpAudit` (covered by unit tests in +`test/mob_dev/otp_audit_test.exs`). + +### `mix mob.trace_otp` + +Empirical trace of what an app actually loads at runtime. Wraps +`:erlang.trace_pattern/3` to capture every MFA called by a synthetic +harness that exercises the basic Elixir/OTP surface (collections, +strings, processes, OTP behaviours, errors). The result is a set of +59 modules / 447 MFAs that we treat as the floor — anything not in +this set is at least worth questioning. + +This is the empirical complement to `mix mob.audit_otp`'s static +analysis. The static call graph misses dynamic dispatch +(`apply/3`, GenServer callbacks, behaviour callbacks, hot-loaded +modules); the trace catches them. + +### `mix mob.verify_strip` + +Eager-loads every `.beam` file in the deployed bundle on a connected +device (via Erlang distribution). If the strip pass dropped something +the app can no longer load, this surfaces it before a user does. + +```bash +mix mob.connect # in one terminal +mix mob.verify_strip # in another +``` + +Implementation: `Mob.Diag.verify_loaded_modules/0` (lives in `mob` +itself, not `mob_dev`, so it ships in every app). + +### `mix mob.snapshot_loaded` + +Snapshot of which modules are actually loaded right now on a +connected device, plus the list of `.beam` files in the bundle that +have NOT been loaded. Useful for spotting strip candidates that +slipped through `audit_otp` because the import graph was wrong, or +for confirming a rare code path actually loads what you expected. + +--- + +## Tests + +Coverage as of 2026-05-02: + +| File | Tests | Covers | +| --------------------------------------------- | ----: | ----------------------------------------------------------------------------------- | +| `test/mob_dev/release_script_test.exs` | 29 | Bash shape: Apple-policy strips, `slim_step` helper, `[SLIM:tag]` markers, codesign | +| `test/mob_dev/otp_audit_test.exs` | 10 | Synthetic OTP tree: discovery, dedup, foreign-app detection, size accounting | +| `test/mob_dev/otp_asset_bundle_test.exs` | 7 | Default strip set sanity, `build/3` error paths | +| `test/mob_dev/otp_trace_test.exs` | 4 | Trace harness phases, MFA collector normalization | + +Run the slim-related subset: + +```bash +mix test test/mob_dev/release_script_test.exs \ + test/mob_dev/otp_audit_test.exs \ + test/mob_dev/otp_asset_bundle_test.exs \ + test/mob_dev/otp_trace_test.exs +``` + +Gaps still on the list (none blocking): + +- `Mob.Diag.verify_loaded_modules/0` lives in the `mob` repo and + needs a connected device; no synthetic-fixture test yet. +- `Mob.Diag.loaded_snapshot/0` same. +- Live-device round-trip of `mix mob.verify_strip` against a slim + build, blocked at time of writing by an `eaddrinuse` flake on the + EPMD tunnel. + +--- + +## Adding a new strip step + +The pattern is: + +1. Decide what to strip and why. Look for prior art in + `MobDev.OtpAudit` reports — most strip ideas come from finding + something unreachable that nobody noticed. +2. Add the step to `MobDev.Release.release_device_sh/0` (release + path) AND `MobDev.NativeBuild.maybe_slim_otp_bundle/2` (dev + path — Phase 2 iter 13a moved the dev slim pipeline into Mix), + wrapped in `slim_step <tag> ...`. +3. Add a string-shape test in `test/mob_dev/release_script_test.exs` + under the "MOB_SLIM gating and per-step traceability" describe + block — every tag in the strip set should appear in the + `Enum.each` list for "every strip step routes through slim_step". +4. Update the table at the top of this guide with the new size + delta. + +The dev and release paths must stay in lockstep on which strips run +(both gated on `MOB_SLIM`); divergence means a slim build that works +on the dev path can break on TestFlight, which is exactly the trap +this asymmetric-defaults setup is designed to avoid. diff --git a/lean_release_extraction.md b/lean_release_extraction.md new file mode 100644 index 0000000..f5cb30b --- /dev/null +++ b/lean_release_extraction.md @@ -0,0 +1,358 @@ +# Decision: Extract `lean_release` once API stabilizes + +## Context + +`MobDev.OtpAudit` and `Mix.Tasks.Mob.AuditOtp` (added 2026-05-02) do +something genuinely novel: lib-level reachability analysis of a Mix +release tree, with cache-cruft + duplicate-version detection. The audit +tool already paid for itself — caught ~10 MB of cruft shipping in every +Mob iOS release that nobody had noticed before. + +The same tool is useful to **anyone** shipping an Elixir release — +Burrito, Bakeware, Nerves, plain `mix release` — not just Mob. The +existing prior art in the ecosystem is `strip_beams: true` (debug-info +stripping, ~30% wins) and hard-coded strip lists in Nerves; nobody +publishes a tool that does empirical reachability + cache hygiene. + +## Decision + +**Build the next phases (empirical trace harness, `mix mob.release +--slim`) in mob_dev. Extract to its own repo + Hex package once the +public API stabilizes.** Working name: `lean_release`. + +## Why not extract today + +The API will reshape as the trace-harness work lands: + +- `OtpAudit.report` will gain a `:trace_data` field +- `OtpAudit.audit/2` will gain a `:trace_input` opt +- `report.strippable_libs` will get a confidence tier (static-only vs + static+trace vs hardcoded baseline) +- The Mix task will likely split into `audit_otp` (read-only), + `trace_otp` (instrument + capture), `slim_release` (strip + verify) + +Anyone consuming a published v0.1 today would hit constant breaking +changes. Without external contributors yet, the public commitment buys +us nothing and costs friction. + +## Why extract eventually + +- Mob is a niche framework; the audit tool is general-purpose +- Burrito + Bakeware ship full OTP unmodified; they'd benefit +- Nerves uses hardcoded strip lists; an audit-driven approach is better +- `lean_release` shows up on Hex, gets discovered by anyone hitting + release-size pain +- A clean public artifact attracts collaborators on the harder + empirical-trace work + +## When to extract + +Trigger conditions (any one of these): + +1. The API hasn't changed in 2 consecutive Mob releases +2. Someone external asks "is this published?" +3. The empirical-trace harness lands and produces actionable results +4. We have at least one non-Mob app using the audit (e.g. ran it + manually against another Elixir release) + +## Prior art and references + +### `mix_unused` (Hauleth) + +`https://hexdocs.pm/mix_unused/Mix.Tasks.Compile.Unused.html` — +community pointer when this work was discussed. Static AST analysis of +**project source**, flags public functions never called. + +- Different layer than `OtpAudit`. We do app/module reachability across + the whole release; `mix_unused` does dead-public-function detection + inside one project. They stack, they don't compete. +- Blind to dynamic dispatch (`apply/2`, `apply/3`, runtime module + lookup). Mob and its apps use a fair bit of this — render-tree + dispatch, NIF stub lookup, component registry — so expect false + positives needing an `ignore` list. +- Trial plan when work resumes: install in `mob_dev` first (least + dynamic, highest signal), then `square_triangle`, then `mob` itself. + Decide whether the ignore-list maintenance pays for itself before + wiring it into `mix mob.doctor`. + +### Peer Stritzinger / GRiSP — closest prior art for shrinking + +Stritzinger has been doing the same thing we're doing, at one-tenth our +scale. Headline result (mid-2025, Code BEAM Stockholm): **BEAM boots in +16 MB on GRiSP Nano.** Reaches an Erlang shell, runs OTP, TCP/IP, USB. + +References: + +- `https://github.com/grisp/rebar3_grisp` — their build plugin. Most + useful artifact: shows how they decide which OTP modules to include + and how they assemble a stripped ERTS. This is the rebar3 analog of + what `mix lean_release.slim` should do. Read the source when starting + the slim-release implementation, not before — it'll inform the design + but isn't load-bearing for the audit work. +- `https://www.grisp.org/resources` — current talk index. The 2025 + Stockholm talk ("Squeezing the BEAM into 16MB" or similar) is the + current technical reference; the 2017-era YouTube video is older and + superseded. +- Open question whether `lean_release` should reuse any GRiSP code or + just the techniques. They're rebar3-native; we're Mix-native. Likely + a re-implementation, not a port. + +### Outreach + +When `lean_release` is closer to extraction (per the trigger +conditions above), reach out to Stritzinger directly. He's an active +community member; the GRiSP work is the closest prior art in +Erlang-land. Trading notes is likely valuable both ways — our trace +harness (empirical reachability from a running app) is something +embedded developers don't need but Phoenix/LiveView shops would use. + +## Naming + +`lean_release` — descriptive, available on Hex, reads well in +`mix lean_release.audit` / `mix lean_release.slim`. + +Considered + rejected: `beam_diet` (cute but unprofessional), `unship` +(too clever), `release_inspector` (boring), `otp_audit` (we're already +calling our internal module that, fine for internal but generic on Hex). + +## Pre-extraction checklist (when the trigger fires) + +- [ ] Move `MobDev.OtpAudit` → `LeanRelease.Audit` (or just `LeanRelease`) +- [ ] Move `Mix.Tasks.Mob.AuditOtp` → `Mix.Tasks.LeanRelease.Audit` +- [ ] Add `Mix.Tasks.LeanRelease.Slim` (strip command) +- [ ] Generic path discovery: look for `_build/prod/rel/<app>/lib` + (standard Mix release output) instead of mob-specific dirs +- [ ] Mob keeps a thin `Mix.Tasks.Mob.AuditOtp` shim that adds mob's + release-tree path to LeanRelease's search list +- [ ] mob_dev gains `{:lean_release, "~> 0.1"}` dep +- [ ] README + guide on hexdocs +- [ ] Initial Hex release as 0.1.0 + +## When work resumes — quick start + +Before doing anything else: + +1. Read this whole file, including the prior art section above. +2. Re-run `mix mob.audit_otp` against a current Mob iOS release to + establish the baseline (saved cruft total, current strip list). +3. Decide whether the next phase is: (a) `mix_unused` evaluation, + (b) empirical-trace harness, or (c) `mix mob.release --slim`. + Pick one; don't fan out. + +## Progress log + +### 2026-05-11 — Slim pass extracted from `MobDev.NativeBuild` + +`MobDev.OtpAudit.Slim` now owns the in-place strip pass that +`mix mob.deploy --slim` runs. The hardcoded prefix list is its source +of truth (`Slim.hardcoded_prefixes/0`); per-app `mob.exs` overrides +(`:slim` sub-keyword with `:keep_libs` / `:drop_libs`) let users +expand or restrict the strip set without code changes. 22 unit tests +against fixture trees pin every phase. + +**Deliberately deferred:** audit-driven auto-expansion of the strip +set. A baseline `mix mob.audit_otp` run against `~/code/pigeon` showed +audit.strippable_libs catches `exqlite` (1.3 MB) as unreachable — a +true false positive, since exqlite loads via `:erlang.load_nif` which +the static call graph can't see. Auto-union is blocked on either +(a) tighter foreign-app detection (cross-reference `_build/dev/lib/` +to distinguish leftover cache from real runtime deps) or (b) trace +data from `MobDev.OtpTrace` providing the empirical reachability +signal. Both are higher-leverage next steps than `mix_unused`. + +**Same baseline run also surfaced:** the audit's `looks_like_user_app?` +heuristic missed obvious foreign apps (`pigeon`, `push_notify`, +`phase2q_lv`, `phase2q_smoke`, `pythonx_ios_spike`) because the +prefix list is hardcoded too narrowly (`test_`, `toy_`, `mob_test`). +Tightening that is its own task — should land before the audit-driven +slim union since it removes false positives there too. + +**Headline numbers from the baseline run (against `~/code/pigeon`'s +cached iOS device tree):** + +| Slice | KB | +|------------------------------------|-----------| +| Total shipped | 103.0 MB | +| Reachable (kernel/stdlib/etc seed) | 25.5 MB | +| Strippable (audit, 0 reachable) | 17.3 MB | +| Duplicate versions | 8.0 MB | +| Hardcoded baseline only catches | ~28 MB extra (megaco, snmp, compiler, …) | +| Unreachable modules INSIDE partly-used libs | ~52 MB (megaco 64/65 dead, snmp 83/90 dead, …) | + +That last row is the prize per-module stripping would unlock, but +it's also the riskiest: it requires confident "this module is never +called" answers that only trace data provides. + +### 2026-05-11 (cont'd) — Audit improvements: foreign-app allow-list + trace input + +Two related improvements landed in close succession after the Slim +extraction. + +**Foreign-app allow-list (`:project_deps`):** `OtpAudit.audit/2` +now accepts a list of atoms naming the project's runtime deps. Any +lib in the bundle that isn't OTP-shipped, isn't Elixir-shipped, +isn't the app under test, and isn't in `:project_deps` is classified +as foreign and lands in `report.foreign_apps` (out of +`report.strippable_libs`). `mix mob.audit_otp` auto-derives +`:project_deps` from `_build/dev/lib/` — Mix's view of what's +installed. The legacy name-pattern heuristic +(`test_/toy_/mob_test/scratch_`) is preserved when `:project_deps` +is omitted, for backwards compat. This catches the pigeon / +push_notify / phase2q_lv / etc. false-negative cluster the baseline +audit surfaced. + +**Trace input (`:trace_input`):** `OtpAudit.audit/2` accepts a +runtime-traced module set (MapSet, list, OtpTrace.result, or +remote-trace shape — normalizer handles all four) and exposes +`report.trace_strippable_libs` — libs whose modules are entirely +absent from the trace. Each lib_report grows `:modules_traced` and +`:untraced_modules`. The intersection `strippable_libs ∩ +trace_strippable_libs` is the high-confidence strip set; the +trace-only difference is the "static graph reaches it but trace +says never called" set that unlocks megaco / snmp / diameter / +compiler / etc. + +`mix mob.audit_otp --trace-json path/to/trace.json` reads a JSON +file written by `mix mob.trace_otp --json` and feeds it through. +The CLI report now shows a "Trace-strippable" section split into +"both static + trace" (high confidence) and "trace-only" (unlocked +by trace), with statically-reachable module counts on the +trace-only entries so the user can see how aggressive each strip +would be. + +**Mob_new wheel-filter cherry-pick (parallel work):** between the +two audit steps, a `.so`-filter for iOS wheels was cherry-picked +from a parallel pigeon-side branch into `NativeBuild`: +`copy_ios_safe_project_python_wheels/2` skips wheels containing +any `.so` (cffi, cryptography ship Android-only binaries). 10 +tests pinning the filter behaviour came along. Unrelated to the +audit work but landed in the same session. + +### 2026-05-12 — First real device trace + safety guardrail + +Captured a 60-second trace against pigeon running on iPhone 17 Pro +simulator (`pigeon_ios_8a4250e9@127.0.0.1`), saved at +`/tmp/pigeon_trace.json`. 60s of UI driving → 133 modules / 1287 +MFAs touched. + +Feeding that trace through `OtpAudit.audit/2 + Slim.compute_strip_set/1` +surfaced a real safety issue: the trace correctly flagged megaco, +snmp, compiler, diameter, mnesia, inets, etc. as never-called +(~36 MB of safe new strip targets) — but ALSO flagged crypto, sasl, +public_key, asn1 as never-called, which would crash any non-trivial +app the moment it tried TLS or completed OTP boot. + +Those four are essential-but-rarely-called from the trace's +perspective: sasl runs at boot before tracing opens; crypto/ssl/ +public_key/asn1 fire on TLS handshakes the UI driving didn't +exercise. + +**Fix landed:** `Slim.@always_keep_libs` hardcoded guardrail +(`kernel stdlib erts elixir logger sasl crypto public_key asn1 ssl`). +`audit_expansion/1` subtracts this set after building the expansion +union. The guardrail's scope is strictly the audit-driven expansion; +the hardcoded baseline doesn't touch any always-keep lib by design. + +**User escape hatches preserved:** +- `:keep_libs` — wins over everything, last word. +- `:drop_libs` — adds to the strip set after the guardrail filters, + so a user who knows their app has zero TLS / no boot-time sasl + ref can force-strip a guarded lib with eyes open. + +Verified on real pigeon data: of 16 trace-only strippable +candidates, 12 land in the strip set (~36 MB savings), 4 +(public_key, crypto, asn1, sasl) are kept by the guardrail. + +### 2026-05-12 (cont'd) — Multi-trace union + exqlite stale-lock guard + +Two more cleanups landed before pausing for device-driving: + +**Multi-trace union (`union_trace_jsons/2`):** `MobDev.OtpAudit` +gained a public helper that reads N trace JSONs and returns a +unioned MapSet. Caller supplies an `on_read_error/2` callback so +`mix mob.audit_otp` (CLI) can `Mix.raise` on a typo while the slim +build path (`NativeBuild.maybe_run_audit`) just warns and skips +that trace. + +`mix mob.audit_otp` now accepts `--trace-json` repeated +(OptionParser `:keep`). `mob.exs` accepts `slim: [trace_jsons: +["a.json", "b.json"]]` in addition to the single `:trace_json` +(both shapes coexist for back-compat). + +Defensive: all-reads-fail returns nil rather than empty set +(would have let the audit-driven expansion strip every partly-used +lib). Pin'd in tests. + +**Exqlite stale-lock guard:** `install_exqlite_otp_lib` now uses +`install_exqlite_decision/2` (public for tests) that returns +`:noop | :stale | {:install, vsn}`. Surfaced by pigeon's +iOS-device deploy: mix.lock had an exqlite entry left over from +a long-removed `ecto_sqlite3` dep, but `_build/dev/lib/exqlite` +was empty. Old code crashed in `File.cp!`; new code logs +"`[exqlite] stale mix.lock entry — skipping`" and proceeds. + +### What's next + +1. ~~**Capture multi-mode traces.**~~ Done 2026-05-12. Capture as + many windows as you like, point `trace_jsons:` at all of them. + The audit unions them automatically. + +2. **Per-module stripping inside partly-used libs.** Still the + biggest un-claimed prize (~52 MB of dead modules inside libs + the static graph keeps alive). Now needs only: + - Comprehensive multi-trace coverage (multi-trace exists; you + just need to capture boot + UI + auth + idle + every screen + and feed them all) + - `.app` file rewriting — drop stripped modules from the + `{modules, [...]}` list or the application controller will + try to load them at boot + - Backup safety from `mix mob.verify_strip` (already exists — + eager-loads every shipped `.beam`) + Material regression risk — defer until the multi-trace flow + has driven a few apps end-to-end. + +3. **`mix_unused` evaluation** — still orthogonal, still anytime. + +4. **Drive the flow.** The bonus territory is done; what's left is + exercise. Capture multiple traces against real apps, set + `slim: [audit: true, trace_jsons: [...]]`, deploy, watch for + crashes / unexpected strips. Bugs surfaced this way are the + next round of work. + +### How to use trace-augmented slim today + +```bash +cd ~/code/<mob_app> +mix mob.connect --no-iex # discover node, set up tunnels +mix mob.trace_otp \ + --remote <node>@127.0.0.1 \ + --duration 60000 \ + --json /tmp/mob_trace.json # drive the app during the window +``` + +Then in `mob.exs`: + +```elixir +config :mob_dev, + slim: [ + audit: true, + trace_json: "/tmp/mob_trace.json", + # Optional: force-keep if the trace's coverage is incomplete: + keep_libs: ["specific_lib_you_need"], + # Optional: force-strip a guarded lib if you're sure: + drop_libs: ["crypto"] # only do this if you have ZERO TLS + ] +``` + +Inspect via `mix mob.audit_otp --trace-json /tmp/mob_trace.json` to +preview the audit + trace classification before letting Slim strip. + +### Known caveats + +- Pigeon's iOS device build (physical iPhone) currently fails on + `MobDev.NativeBuild.install_exqlite_otp_lib/1` because pigeon + doesn't depend on exqlite (only `mix mob.new`-generated projects + do). The slim work above used the simulator deploy, which doesn't + hit that path. Filed as future work — guard `install_exqlite_otp_lib` + with `File.exists?` so non-exqlite projects can deploy to physical + iOS too. diff --git a/lib/mix/tasks/mob.add_nif.ex b/lib/mix/tasks/mob.add_nif.ex new file mode 100644 index 0000000..a24c0f0 --- /dev/null +++ b/lib/mix/tasks/mob.add_nif.ex @@ -0,0 +1,789 @@ +defmodule Mix.Tasks.Mob.AddNif do + @moduledoc """ + Scaffolds a new statically-linked NIF in the current Mob project. + + mix mob.add_nif <name> # default: --type elixir-only + mix mob.add_nif <name> --type c # also drops c_src/<name>.c skeleton + mix mob.add_nif <name> --type zigler # Zigler-backed (inline ~Z) + mix mob.add_nif <name> --type rustler # Rustler-backed (Cargo crate at native/<name>/) + mix mob.add_nif <name> --module MyApp.Nifs.Audio # custom Elixir module name + + Three things change after a successful run: + + * `lib/<app>/nifs/<name>.ex` — Elixir stub module exporting the NIF + function placeholders. Each function returns `:erlang.nif_error/1` so + the BEAM raises a clear "NIF not loaded" error if the native side + hasn't loaded yet, instead of silently returning the stub's body. + + * `mob.exs` — `:static_nifs` list under `config :mob_dev,` gains + `%{module: :<name>, archs: [:all]}`. If `:static_nifs` doesn't exist + yet the entry creates it. The schema lives in + `MobDev.StaticNifs` — see its module doc for arch values and + per-arch guards. + + * (with `--type c`) `c_src/<name>.c` — a minimal C skeleton with the + ERL_NIF_INIT macro pre-wired to the registered name. Wire it into + your build (Android CMakeLists.txt or iOS build.zig) by adding the + file to the static archive that links into `libbeam.a`. + + `mix mob.regen_driver_tab` is composed into the same Igniter run, so + `priv/generated/driver_tab_{ios,android}.c` updates appear in the same + diff as the stub and `mob.exs` edit. One command, everything wired. + + ## Why a static NIF and not a `dlopen`'d `.so` + + iOS App Store rejects bundled `.dylib` files. Android's loader uses + `RTLD_LOCAL` by default, which hides `enif_*` symbols from a + dlopen'd child library. Both platforms force the same answer: link + the NIF init function into the main binary alongside the BEAM, and + register it in the static NIF table at link time. See + `MobDev.StaticNifs` for the full rationale. + + ## Phase 3 of the build-system migration + + This task is the first Igniter-backed surface in mob_dev — it + validates the AST-aware code-generation approach before the heavier + Phase 4 (`mob.enable` migration) and Phase 5 (`mob.new --liveview` + rewrite) follow. + """ + @shortdoc "Scaffold a new statically-linked NIF" + + use Igniter.Mix.Task + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :mob, + schema: [type: :string, module: :string, demo: :boolean], + defaults: [type: "elixir-only", demo: false], + positional: [:name] + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + %{name: name} = igniter.args.positional + options = igniter.args.options + demo? = options[:demo] == true + + with :ok <- validate_name(name), + :ok <- validate_type(options[:type]), + :ok <- validate_demo(demo?, options[:type]) do + module = resolve_module(igniter, name, options[:module]) + + type = options[:type] + + igniter + |> add_elixir_stub(module, name, type, demo?) + |> add_static_nif_entry(name) + |> maybe_add_c_skeleton(name, type, demo?) + |> maybe_add_zigler_dep(type) + |> maybe_add_rustler(name, type, demo?) + |> maybe_add_demo_screen(module, name, type, demo?) + # Run regen automatically after Igniter commits — keeps the user-facing + # flow to a single command (`mix mob.add_nif foo`) instead of "add the + # NIF, then remember to run regen". `add_task` queues the task to run + # AFTER all Igniter file writes are applied, so the just-modified + # mob.exs is on disk by the time regen reads it. + |> Igniter.add_task("mob.regen_driver_tab") + |> maybe_add_demo_notice(module, name, demo?) + else + {:error, msg} -> Igniter.add_issue(igniter, msg) + end + end + + # ── Validation ──────────────────────────────────────────────────────────── + + defp validate_name(name) when is_binary(name) do + cond do + not Regex.match?(~r/^[a-z][a-z0-9_]*$/, name) -> + {:error, + "NIF name must be snake_case starting with a letter (got: #{inspect(name)}). " <> + "Examples: audio_engine, sqlite3_nif, my_nif."} + + String.length(name) > 64 -> + {:error, "NIF name too long (max 64 chars; got #{String.length(name)})"} + + true -> + :ok + end + end + + defp validate_type(type) when type in ["elixir-only", "c", "zigler", "rustler"], do: :ok + + defp validate_type(other) do + {:error, "Unknown --type #{inspect(other)}. Supported: elixir-only, c, zigler, rustler."} + end + + defp validate_demo(true, "elixir-only") do + # --demo generates a screen that calls the NIF and shows the result. + # An elixir-only stub has nothing to call, so the demo would render + # a permanent "NIF not loaded" — confusing rather than illustrative. + # Force the user to pick a native backend so the demo actually works. + {:error, + "--demo requires a native backend so the screen has something to call.\n" <> + " Use --type c, --type zigler, or --type rustler."} + end + + defp validate_demo(_demo?, _type), do: :ok + + # ── Module name resolution ──────────────────────────────────────────────── + + defp resolve_module(_igniter, _name, explicit) when is_binary(explicit) do + Module.concat([explicit]) + end + + defp resolve_module(igniter, name, nil) do + app = Igniter.Project.Application.app_name(igniter) + base = app |> to_string() |> Macro.camelize() + nif_camel = name |> Macro.camelize() + Module.concat([base, "Nifs", nif_camel]) + end + + # ── Elixir stub ─────────────────────────────────────────────────────────── + + defp add_elixir_stub(igniter, module, name, type, demo?) do + {exists?, igniter} = Igniter.Project.Module.module_exists(igniter, module) + + if exists? do + # Re-run idempotency: leave the existing module alone. If the user has + # added their own functions to the stub we don't want to clobber them. + igniter + else + Igniter.Project.Module.create_module( + igniter, + module, + stub_body(module, name, type, demo?) + ) + end + end + + # When `demo?` is true, the example function in every backend is + # replaced by `greet/0` returning a known string ("Hello from C!" / + # "Hello from Zig!" / "Hello from Rust!"). The demo screen knows to + # call `greet/0`; this keeps the screen template type-agnostic. + defp stub_body(module, name, "rustler", demo?) do + app = module |> Module.split() |> List.first() |> Macro.underscore() + + """ + @moduledoc \"\"\" + Statically-linked NIF stub for `:#{name}` (rustler-backed). + + The Rust source lives at `native/#{name}/src/lib.rs`. Rustler + invokes Cargo to build it into a NIF library at compile time and + binds each `#[rustler::nif]`-annotated function as a function on + this module. Replace the example `add_one/1` with your real surface. + + Static linking on-device (iOS device + Android) is handled by + `mob_dev` automatically — it cross-compiles the crate as a + staticlib and links the `.a` into the main app binary alongside + libbeam.a. Host-dev (`mix phx.server`, simulator) keeps using + Rustler's standard cdylib dlopen path. See + `guides/nifs.md` in mob_dev for the full per-backend contract, + including the transient Android dlsym patch in the generated + Cargo.toml. + \"\"\" + + use Rustler, otp_app: :#{app}, crate: "#{name}" + + # Function stubs. Rustler replaces these at load time with the + # bindings generated from `native/#{name}/src/lib.rs`. Until then, + # the `:erlang.nif_error/1` body raises clearly instead of silently + # returning the stub value. + #{rustler_function_stub(demo?)} + """ + |> indent_for_module() + |> wrap_module(module) + end + + defp stub_body(module, name, "zigler", demo?) do + app = module |> Module.split() |> List.first() |> Macro.underscore() + + """ + @moduledoc \"\"\" + Statically-linked NIF stub for `:#{name}` (zigler-backed). + + The Zig source is inlined via `~Z` below. Zigler compiles it through + its build pipeline and exposes each `pub fn` as a NIF function on this + module. Replace the example `add_one/1` with your real surface. + + Static linking on-device (iOS device + Android) is handled by + `mob_dev` automatically — Zigler's compiled archive is linked into + the main app binary. Host-dev (`mix phx.server`, simulator) keeps + using Zigler's standard dlopen path. See `guides/nifs.md` in + mob_dev for the full per-backend contract. + + `mob.add_nif --type zigler` pins a fork (`{:zigler, github: + "GenericJam/zigler", branch: "zig-016-port"}`) and runs + `mix zig.get` automatically. Two reasons, both transient: + macOS 26's SDK rejects symbols `compiler_rt` references in Zig + 0.15.x, and Zig 0.16's bare `nif_init` symbol collided with + Rustler's at static-link time. Both reasons evaporate when + upstream Zigler ships Zig 0.16 with a per-NIF init alias — + tracker: upstream issues #578 / #579. + \"\"\" + + use Zig, otp_app: :#{app} + + ~Z\"\"\" + #{zigler_inline_code(demo?)} + \"\"\" + """ + |> indent_for_module() + |> wrap_module(module) + end + + defp stub_body(module, name, _type, demo?) do + """ + @moduledoc \"\"\" + Statically-linked NIF stub for `:#{name}`. + + The init function `#{name}_nif_init` is registered in the per-app + `priv/generated/driver_tab_{ios,android}.c` static table (regenerated + by `mix mob.regen_driver_tab` from `mob.exs`'s `:static_nifs`). At + BEAM startup the runtime resolves `load_nif/2` against that table + and binds the C functions you implement in `c_src/#{name}.c` (or + your zigler/rustler equivalent) to these placeholders. + + Until then, every function below raises `:erlang.nif_error(:nif_not_loaded)` + so a missing native side surfaces as a loud, traceable error instead + of a silent stub return. + \"\"\" + + @on_load :load_nif + + @doc false + def load_nif do + # Static NIFs don't read a path — the second arg to load_nif must + # still be passed but is ignored when the module is in the static + # NIF table. Pass 0 by convention. + :erlang.load_nif(~c"#{name}", 0) + end + + # ── Public NIF surface ─────────────────────────────────────────── + # Add one stub per native function. Each must return + # `:erlang.nif_error/1` so a not-yet-implemented native side errors + # loudly instead of returning the stub body. + + #{c_function_stub(demo?)} + """ + |> indent_for_module() + |> wrap_module(module) + end + + # Per-backend example-function helpers. Kept after the last `stub_body/4` + # clause so the compiler's "clauses grouped" warning stays clean. + + defp rustler_function_stub(true), do: "def greet(), do: :erlang.nif_error(:nif_not_loaded)" + + defp rustler_function_stub(false), + do: "def add_one(_input), do: :erlang.nif_error(:nif_not_loaded)" + + defp zigler_inline_code(true) do + """ + /// Demo NIF — replace with your real Zig surface. + pub fn greet() []const u8 { + return "Hello from Zig!"; + } + """ + |> String.trim_trailing() + end + + defp zigler_inline_code(false) do + """ + /// Example NIF — replace with your real Zig surface. + pub fn add_one(input: i64) i64 { + return input + 1; + } + """ + |> String.trim_trailing() + end + + defp c_function_stub(true) do + """ + @doc \"\"\" + Demo NIF entry point — returns "Hello from C!" when the native + side is loaded. + \"\"\" + def greet(), do: :erlang.nif_error(:nif_not_loaded) + """ + |> String.trim_trailing() + end + + defp c_function_stub(false) do + """ + @doc \"\"\" + Example NIF entry point. Replace with your own functions. + \"\"\" + def hello(_arg), do: :erlang.nif_error(:nif_not_loaded) + """ + |> String.trim_trailing() + end + + defp wrap_module(body, _module), do: body + + defp indent_for_module(body), do: body + + # ── mob.exs :static_nifs append ─────────────────────────────────────────── + + defp add_static_nif_entry(igniter, name) do + entry_ast = Sourceror.parse_string!(~s|%{module: :#{name}, archs: [:all]}|) + initial_ast = Sourceror.parse_string!(~s|[%{module: :#{name}, archs: [:all]}]|) + nif_atom = String.to_atom(name) + + igniter + |> Igniter.create_or_update_elixir_file("mob.exs", "import Config\n", &{:ok, &1}) + |> Igniter.update_elixir_file("mob.exs", fn zipper -> + # NOTE: `modify_config_code` is the LOW-level entry point and does NOT + # unwrap `{:code, ast}` tuples (that's a `configure/6`-only convenience). + # Pass the raw AST node directly here, otherwise the tuple shows up + # as `{:code, [...]}` literally in the generated mob.exs. + Igniter.Project.Config.modify_config_code( + zipper, + [:static_nifs], + :mob_dev, + initial_ast, + updater: fn zipper -> append_to_list(zipper, entry_ast, nif_atom) end + ) + end) + end + + defp append_to_list(zipper, entry_ast, nif_atom) do + code = zipper |> Sourceror.Zipper.node() |> Sourceror.to_string() + + cond do + String.contains?(code, "module: :#{nif_atom}") -> + # Already present — leave as-is so re-runs are idempotent. + {:ok, zipper} + + true -> + case zipper |> Sourceror.Zipper.node() do + list when is_list(list) -> + {:ok, Sourceror.Zipper.replace(zipper, list ++ [entry_ast])} + + # Sourceror sometimes hands us a literal AST node (the existing + # value isn't a list yet). Replace with a fresh single-element list. + _ -> + {:ok, Igniter.Code.Common.replace_code(zipper, [entry_ast])} + end + end + end + + # ── Optional C skeleton ─────────────────────────────────────────────────── + + defp maybe_add_c_skeleton(igniter, name, "c", demo?) do + path = "c_src/#{name}.c" + + if File.exists?(path) do + # Re-run idempotency: don't overwrite a C file the user may have edited. + igniter + else + module = resolve_module(igniter, name, igniter.args.options[:module]) + Igniter.create_new_file(igniter, path, c_skeleton(name, module, demo?)) + end + end + + defp maybe_add_c_skeleton(igniter, _name, _other, _demo?), do: igniter + + # ── Optional Hex dep (zigler/rustler) ───────────────────────────────────── + + defp maybe_add_zigler_dep(igniter, "zigler") do + # Use the GenericJam/zigler fork's zig-016-port branch — Zigler's + # upstream 0.15.2 pin breaks on macOS 26 (Sequoia/Tahoe) because + # Zig 0.15.x's stdlib references absent libSystem symbols. The fork + # ports priv/beam/ to Zig 0.16.0 (which works on macOS 26). + # + # Once upstream Zigler ships 0.16, this flips back to a hex pin. + # Track upstream issue #578 + closed PR #579. + # + # `mix zig.get` is still queued so Zigler's executable_path lookup + # finds the cached Zig before falling through to PATH (which on + # mob developer machines points at 0.17-dev, the wrong stdlib for + # the port — needs 0.16.0 specifically). + igniter + |> Igniter.Project.Deps.add_dep( + {:zigler, github: "GenericJam/zigler", branch: "zig-016-port"} + ) + |> Igniter.add_task("zig.get") + end + + defp maybe_add_zigler_dep(igniter, _other), do: igniter + + # ── Rustler: dep + Cargo project skeleton ───────────────────────────────── + + defp maybe_add_rustler(igniter, name, "rustler", demo?) do + module = resolve_module(igniter, name, igniter.args.options[:module]) + + igniter + |> Igniter.Project.Deps.add_dep({:rustler, "~> 0.32"}) + |> create_file_if_missing("native/#{name}/Cargo.toml", cargo_toml(name)) + |> create_file_if_missing("native/#{name}/src/lib.rs", rust_lib_rs(name, module, demo?)) + |> create_file_if_missing("native/#{name}/.gitignore", "/target\n") + |> create_file_if_missing("native/#{name}/.cargo/config.toml", cargo_config_toml()) + end + + defp maybe_add_rustler(igniter, _name, _other, _demo?), do: igniter + + # macOS host link requires `-undefined dynamic_lookup` because `cdylib` + # crates reference `enif_*` symbols that aren't resolved until the BEAM + # `dlopen`s the produced library. Linux's `ld.bfd`/`ld.lld` defers + # undefined symbols by default; macOS `ld64` errors on them. Without + # this file, first `mix compile` on a Mac scaffolded with `--type + # rustler` fails with `Undefined symbols: _enif_raise_exception, + # _enif_schedule_nif` and the user has to know to search "Rustler + # macOS undefined symbols" to find the fix. + defp cargo_config_toml do + """ + # Generated by `mix mob.add_nif --type rustler`. + # + # macOS-only: rustler's default cdylib crate references enif_* symbols + # that are only resolved at BEAM dlopen time. macOS ld64 errors on + # them without `-undefined dynamic_lookup`. Harmless on Linux (those + # linkers defer by default and ignore the rustflags scope here since + # both target triples below are Apple-only). + + [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"] + """ + end + + defp create_file_if_missing(igniter, path, content) do + if File.exists?(path) do + # Re-run idempotency: don't overwrite hand-edited Cargo manifests + # or Rust sources. + igniter + else + Igniter.create_new_file(igniter, path, content) + end + end + + defp cargo_toml(name) do + """ + [package] + name = "#{name}" + version = "0.1.0" + edition = "2021" + + [lib] + name = "#{name}" + # `staticlib` is required for Mob's iOS/Android device builds + # (the .a gets linked into the main binary). `cdylib` keeps the + # host-dev `mix compile` path working — produces priv/native/<name>.so + # for the BEAM to dlopen on Mac/Linux dev. + crate-type = ["staticlib", "cdylib"] + + [dependencies] + # 0.37+ derives the static-NIF init symbol from CARGO_CRATE_NAME + # (`<crate>_nif_init`), which matches what Mob's driver_tab + # declares. Older versions (≤0.36) hardcode `nif_init` and + # require manual symbol-renaming to use with Mob's static link. + rustler = "0.37" + + # ─── DROP WHEN UPSTREAM RUSTLER MERGES THE ANDROID DLSYM FIX ─────────────── + # Rustler 0.37's nif_filler uses `dlopen(NULL)` to find `enif_*` symbols. + # On Android (Bionic), that handle doesn't see the app's RTLD_GLOBAL-promoted + # .so, so every NIF init panics with `undefined symbol: enif_priv_data`. + # The GenericJam fork patches the Android branch to do `dladdr` + `dlopen(self, + # RTLD_NOLOAD)` for an explicit self-handle. Other platforms are unchanged. + # + # Once upstream merges (or a release containing the fix lands on crates.io), + # bump the `rustler = "0.37"` line above to that version and DELETE this + # whole [patch.crates-io] block. Tracker: https://github.com/GenericJam/mob/issues/7 + [patch.crates-io] + rustler = { git = "https://github.com/GenericJam/rustler.git", branch = "genericjam-android-rtld-default" } + """ + end + + defp rust_lib_rs(name, module, demo?) do + """ + // native/#{name}/src/lib.rs — Rustler-backed NIF for `:#{name}`. + // + // Each #[rustler::nif] function becomes a NIF callable from the + // matching Elixir stub function. The `init!` macro at the bottom + // registers them with the BEAM at module load time. + // + // See https://hexdocs.pm/rustler for the full type-mapping table + // and codegen details. + + #{rust_example_fn(demo?)} + + rustler::init!("#{module}", [#{rust_init_list(demo?)}]); + """ + end + + defp rust_example_fn(true) do + """ + #[rustler::nif] + fn greet() -> String { + "Hello from Rust!".to_string() + } + """ + |> String.trim_trailing() + end + + defp rust_example_fn(false) do + """ + #[rustler::nif] + fn add_one(input: i64) -> i64 { + input + 1 + } + """ + |> String.trim_trailing() + end + + defp rust_init_list(true), do: "greet" + defp rust_init_list(false), do: "add_one" + + defp c_skeleton(name, module, demo?) do + # First arg to ERL_NIF_INIT is the BEAM module name — what + # load_nif matches against. For Elixir modules that's + # `Elixir.<DotPath>` (atomized by the BEAM as the full name). + nif_module_name = "Elixir." <> (module |> Module.split() |> Enum.join(".")) + + """ + /* + * c_src/#{name}.c — statically-linked NIF for `:#{name}`. + * + * This file is linked into the app's main binary alongside libbeam.a + * (NOT loaded via dlopen — see MobDev.StaticNifs for why). The + * generated driver_tab_{ios,android}.c registers `#{name}_nif_init` + * so `load_nif/2` from the Elixir stub binds these functions in. + * + * Wire this file into your platform builds (TODO: scaffolded + * auto-wire — see mob/issues.md #18): + * - Android: add to android/app/src/main/jni/CMakeLists.txt as + * a target_sources entry on the main library target. + * - iOS: add a `b.addCSourceFile`-style addCObject block in + * ios/build.zig and ios/build_device.zig with these flags + * (after enif_keepalive, before the link step): + * + * installAndCollect(b, objects_step, &objs, addCObject(b, .{ + * .name = "#{name}", + * .source = "absolute/path/to/c_src/#{name}.c", + * .target = target, + * .optimize = optimize, + * .c_flags = c_flags_base ++ &[_][]const u8{ + * "-DSTATIC_ERLANG_NIF", + * "-DSTATIC_ERLANG_NIF_LIBNAME=#{name}", + * }, + * // ... mob_dir/otp_root/erts_vsn/sdkroot ... + * }), "#{name}.o"); + * + * The two -D flags are mandatory: + * -DSTATIC_ERLANG_NIF selects the static-link + * dispatch path in erl_nif.h. + * -DSTATIC_ERLANG_NIF_LIBNAME=#{name} overrides the init symbol + * name to `#{name}_nif_init` + * (matches driver_tab's + * declaration). Without it, + * the symbol would mangle to + * `Elixir.<...>_nif_init` + * which is invalid C and + * won't compile. + */ + + #include <erl_nif.h> + + #{c_example_fn(demo?)} + + static ErlNifFunc nif_funcs[] = { + #{c_funcs_table_entry(demo?)} + }; + + /* First arg is the BEAM module name — what `:erlang.load_nif/2` + * matches against the static-NIF table. For Elixir modules this is + * the fully-qualified `Elixir.<DotPath>` form. The init function's + * SYMBOL name is decoupled and comes from -DSTATIC_ERLANG_NIF_LIBNAME + * on the compile line. */ + ERL_NIF_INIT(#{nif_module_name}, nif_funcs, NULL, NULL, NULL, NULL) + """ + end + + defp c_example_fn(true) do + """ + static ERL_NIF_TERM greet(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + return enif_make_string(env, "Hello from C!", ERL_NIF_LATIN1); + } + """ + |> String.trim_trailing() + end + + defp c_example_fn(false) do + """ + static ERL_NIF_TERM hello(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + return enif_make_atom(env, "hello_from_native"); + } + """ + |> String.trim_trailing() + end + + defp c_funcs_table_entry(true), do: ~s|{"greet", 0, greet, 0}| + defp c_funcs_table_entry(false), do: ~s|{"hello", 1, hello, 0}| + + # ── Demo screen + post-scaffold notice ──────────────────────────────────── + + defp maybe_add_demo_screen(igniter, module, name, type, true) when type != "elixir-only" do + screen_module = Module.concat([module, "Screen"]) + {exists?, igniter} = Igniter.Project.Module.module_exists(igniter, screen_module) + + if exists? do + # User may have edited the screen; don't clobber on re-run. + igniter + else + Igniter.Project.Module.create_module( + igniter, + screen_module, + demo_screen_body(module, name, type) + ) + end + end + + defp maybe_add_demo_screen(igniter, _module, _name, _type, _demo?), do: igniter + + defp demo_screen_body(module, name, type) do + """ + @moduledoc \"\"\" + Demo screen for the `:#{name}` NIF (#{type}-backed). + + Tap "Run NIF" to call `#{inspect(module)}.greet/0` and render the + returned string. Each call also goes through `Logger.info/1`, so if + you're connected to the device via `mix mob.connect`, the call shows + up in your Mac-side IEx as well. + + Generated by `mix mob.add_nif #{name} --type #{type} --demo`. + Safe to delete — net-additive, no other module references this. + \"\"\" + use Mob.Screen + + require Logger + + alias #{inspect(module)}, as: Nif + + def mount(_params, _session, socket) do + {:ok, Mob.Socket.assign(socket, result: nil, calls: 0, error: nil)} + end + + def render(assigns) do + ~MOB\"\"\" + <Scroll background={:background}> + <Column background={:background} padding={:space_lg}> + <Text text="#{Module.split(module) |> List.last()} NIF" text_size={:xl} text_color={:on_surface} padding={:space_sm} /> + <Spacer size={8} /> + <Text text="#{type}-backed" text_size={:xs} text_color={:muted} padding={4} /> + <Spacer size={24} /> + <Button + text="Run NIF" + background={:primary} + text_color={:on_primary} + text_size={:lg} + padding={:space_md} + fill_width={true} + on_tap={{self(), :run}} + /> + <Spacer size={16} /> + <Text text={status_label(assigns)} text_size={:md} text_color={status_color(assigns)} padding={:space_sm} /> + </Column> + </Scroll> + \"\"\" + end + + def handle_info({:tap, :run}, socket) do + calls = socket.assigns.calls + 1 + + try do + result = Nif.greet() + Logger.info("[#{name}-nif] call \#{calls} returned: \#{inspect(result)}") + {:noreply, Mob.Socket.assign(socket, result: result, calls: calls, error: nil)} + rescue + e -> + Logger.error("[#{name}-nif] call \#{calls} crashed: \#{Exception.message(e)}") + {:noreply, + Mob.Socket.assign(socket, + error: Exception.message(e), + calls: calls, + result: nil + )} + end + end + + def handle_info(_msg, socket), do: {:noreply, socket} + + defp status_label(%{result: nil, error: nil}), do: "Tap Run NIF above to call the NIF." + + defp status_label(%{result: r, calls: n, error: nil}) do + "Result (call \#{n}): " <> to_safe_string(r) + end + + defp status_label(%{error: msg, calls: n}) do + "Call \#{n} failed: " <> msg + end + + defp status_color(%{error: nil, result: nil}), do: :muted + defp status_color(%{error: nil}), do: :primary + defp status_color(_), do: :on_surface + + # The NIF can return a charlist (C), a Zig []const u8 (binary), or + # a Rust String (binary). Render all of them as a binary for the + # on-screen Text node. + defp to_safe_string(result) when is_binary(result), do: result + defp to_safe_string(result) when is_list(result), do: List.to_string(result) + defp to_safe_string(result), do: inspect(result) + """ + |> indent_for_module() + |> wrap_module(Module.concat([module, "Screen"])) + end + + # Print the wiring instructions AFTER Igniter applies its file changes + # so the message comes last in stdout, right where the user is looking. + # We piggyback on Igniter's notices system rather than emitting raw + # `Mix.shell().info/1` so the message survives `--dry-run` and shows + # up in the same place as other generator output. + defp maybe_add_demo_notice(igniter, module, name, true) do + screen = Module.concat([module, "Screen"]) + + Igniter.add_notice(igniter, demo_notice_text(module, screen, name)) + end + + defp maybe_add_demo_notice(igniter, _module, _name, _demo?), do: igniter + + defp demo_notice_text(module, screen, name) do + """ + Demo screen created: #{inspect(screen)} + + The screen calls `#{inspect(module)}.greet/0` on tap. Three ways to + see it: + + 1. Quick test from IEx (no app changes — easiest): + + mix mob.connect # connects to your running app + # In iex: + node = hd(Node.list()) # or use the printed node name + :rpc.call(node, Mob.Test, :navigate, [#{inspect(screen)}]) + + 2. Wire into your existing home screen — add to its render: + + {nav_button("#{name} demo", :open_#{name}_demo)} + + and a `handle_info({:tap, :open_#{name}_demo}, ...)` clause: + + def handle_info({:tap, :open_#{name}_demo}, socket) do + {:noreply, Mob.Socket.push_screen(socket, #{inspect(screen)})} + end + + 3. Make it the root screen (replaces your home) in your App module's + navigation/1 callback: + + stack(:main, root: #{inspect(screen)}) + + Each call also logs via `Logger.info` so the result shows up in your + Mac-side IEx if you're connected via `mix mob.connect`. + """ + end +end diff --git a/lib/mix/tasks/mob.audit_otp.ex b/lib/mix/tasks/mob.audit_otp.ex new file mode 100644 index 0000000..89b9739 --- /dev/null +++ b/lib/mix/tasks/mob.audit_otp.ex @@ -0,0 +1,291 @@ +defmodule Mix.Tasks.Mob.AuditOtp do + @shortdoc "Reports which OTP libs your bundled app actually uses" + + @moduledoc """ + Walks an OTP runtime tree and tells you which libraries are dead weight. + + Reads each `.beam` file's `imports` chunk to build a call graph, seeds + reachability from your app's modules + the OTP runtime essentials + (kernel/stdlib/elixir/logger/sasl), and reports: + + * Libraries with zero reachable modules → safe to strip entirely + * Duplicate library versions → keep only the newest + * Foreign apps from another project's release tree → cache cruft + * Per-lib breakdown of reachable vs total module count + KB + + ## Usage + + mix mob.audit_otp # audit the most recent release tree + mix mob.audit_otp --root path/to/otp # audit a specific tree + mix mob.audit_otp --json # machine-readable output + mix mob.audit_otp --trace-json path/to/trace.json # cross-reference against trace data + + # Multiple traces are UNIONED — a lib is trace-strippable only if + # NONE of the captures observed any of its modules. Much safer + # than a single window for production stripping decisions. + mix mob.audit_otp \ + --trace-json /tmp/boot.json \ + --trace-json /tmp/ui.json \ + --trace-json /tmp/auth.json + + ## Where the audit reads from + + Looks for an OTP root in this order: + 1. `--root` arg if given + 2. `_build/mob_release/<App>.app/otp` (latest release build) + 3. The iOS device cache at `~/.mob/cache/otp-ios-device-*` + + ## What this is NOT (yet) + + Read-only. Does not modify the bundle. The companion `mix mob.release + --slim` will use the same audit to drive auto-stripping; this task is + the dry-run that lets you see what would happen. + """ + + use Mix.Task + + alias MobDev.OtpAudit + + @impl Mix.Task + def run(args) do + {opts, _, _} = + OptionParser.parse(args, + strict: [ + root: :string, + json: :boolean, + app: :string, + trace_json: [:string, :keep] + ] + ) + + root = resolve_root(opts[:root]) + app_name = opts[:app] || infer_app_name() + project_deps = infer_project_deps() + trace_paths = Keyword.get_values(opts, :trace_json) + trace_input = union_trace_jsons(trace_paths) + + Mix.shell().info("Auditing OTP tree: #{root}") + if app_name, do: Mix.shell().info("App entry point: #{app_name}") + + if project_deps, + do: Mix.shell().info("Project deps (allow-listed): #{length(project_deps)} apps"), + else: :ok + + if trace_input, + do: + Mix.shell().info( + "Trace input: #{MapSet.size(trace_input)} unique modules observed across " <> + "#{length(trace_paths)} trace#{if length(trace_paths) == 1, do: "", else: "s"}" + ), + else: :ok + + Mix.shell().info("") + + report = + OtpAudit.audit(root, + app_name: app_name && String.to_atom(to_string(app_name)), + project_deps: project_deps, + trace_input: trace_input + ) + + if opts[:json] do + report + |> Jason.encode!(pretty: true) + |> IO.puts() + else + print_report(report) + end + end + + defp resolve_root(nil) do + candidates = + Path.wildcard("_build/mob_release/*.app/otp") ++ + Path.wildcard(Path.expand("~/.mob/cache/otp-ios-device-*")) + + case Enum.find(candidates, &File.dir?/1) do + nil -> + Mix.raise(""" + No OTP tree found. + + Run `mix mob.release --ios` first, or pass `--root path/to/otp`. + + Searched: + #{Enum.map_join(candidates, "\n", &" #{&1}")} + """) + + path -> + path + end + end + + defp resolve_root(path), do: path + + defp infer_app_name do + case Mix.Project.get() do + nil -> nil + _ -> Mix.Project.config()[:app] + end + end + + # Returns the project's runtime-dep apps, or nil when not in a Mix + # project context. `_build/dev/lib/` is Mix's view of every app the + # project needs (top-level deps + transitive closure + the app itself); + # using that as the source means we get exactly what Mix would have + # installed, no extra closure walking needed. + defp infer_project_deps do + case Mix.Project.get() do + nil -> + nil + + _ -> + case File.ls("_build/dev/lib") do + {:ok, libs} -> Enum.map(libs, &String.to_atom/1) + _ -> nil + end + end + end + + # Reads one or more JSON trace files via OtpAudit.union_trace_jsons/2. + # In the CLI a failed read should be a hard error (typo in path, + # missing file the user explicitly asked for) — `Mix.raise` on the + # first failure, don't silently degrade. + defp union_trace_jsons(paths) do + OtpAudit.union_trace_jsons(paths, fn path, reason -> + Mix.raise("Could not read --trace-json #{path}: #{inspect(reason)}") + end) + end + + defp print_report(r) do + h1 = IO.ANSI.bright() + dim = IO.ANSI.faint() + yellow = IO.ANSI.yellow() + red = IO.ANSI.red() + reset = IO.ANSI.reset() + + Mix.shell().info("#{h1}=== Per-library breakdown ==={reset}") + Mix.shell().info("(libs sorted by total size; ✗ = nothing reachable, ◐ = partly used)") + Mix.shell().info("") + + for lib <- r.libs do + icon = + cond do + lib.modules_reachable == 0 -> "#{red}✗#{reset}" + lib.modules_reachable < lib.modules_total -> "#{yellow}◐#{reset}" + true -> "✓" + end + + version = if lib.version, do: "-#{lib.version}", else: "" + + Mix.shell().info( + " #{icon} #{String.pad_trailing("#{lib.name}#{version}", 30)}" <> + " #{format_kb(lib.kb_total)}" <> + " #{lib.modules_reachable}/#{lib.modules_total} modules" + ) + end + + Mix.shell().info("") + Mix.shell().info("#{h1}=== Strippable (no reachable modules) ==={reset}") + + if r.strippable_libs == [] do + Mix.shell().info(" #{dim}none — every shipped lib has at least one used module#{reset}") + else + for name <- r.strippable_libs do + lib = Enum.find(r.libs, &(&1.name == name)) + Mix.shell().info(" #{red}#{name}#{reset} #{format_kb(lib.kb_total)}") + end + end + + if r.trace_strippable_libs do + Mix.shell().info("\n#{h1}=== Trace-strippable (no traced modules) ==={reset}") + Mix.shell().info("(libs whose modules never appeared in the trace — strong strip signal)") + + static = MapSet.new(r.strippable_libs) + trace_set = MapSet.new(r.trace_strippable_libs) + + both = MapSet.intersection(static, trace_set) |> Enum.sort() + trace_only = MapSet.difference(trace_set, static) |> Enum.sort() + + cond do + r.trace_strippable_libs == [] -> + Mix.shell().info(" #{dim}none — every lib had at least one module called#{reset}") + + true -> + if both != [] do + Mix.shell().info("\n #{dim}both static + trace (high confidence):#{reset}") + + for name <- both do + lib = Enum.find(r.libs, &(&1.name == name)) + Mix.shell().info(" #{red}#{name}#{reset} #{format_kb(lib.kb_total)}") + end + end + + if trace_only != [] do + Mix.shell().info( + "\n #{dim}trace-only (static graph reaches them; trace says never called):#{reset}" + ) + + for name <- trace_only do + lib = Enum.find(r.libs, &(&1.name == name)) + + Mix.shell().info( + " #{yellow}#{name}#{reset} #{format_kb(lib.kb_total)} " <> + "(#{lib.modules_reachable}/#{lib.modules_total} statically reachable)" + ) + end + end + end + end + + if map_size(r.duplicates) > 0 do + Mix.shell().info("\n#{h1}=== Duplicate library versions ==={reset}") + Mix.shell().info("(only the highest version is reachable; older ones are cache cruft)") + + for {name, dupe_paths} <- r.duplicates, dupe_path <- dupe_paths do + Mix.shell().info(" #{yellow}#{name}#{reset} obsolete: #{Path.basename(dupe_path)}") + end + end + + if r.foreign_apps != [] do + Mix.shell().info("\n#{h1}=== Foreign apps in lib/ ==={reset}") + Mix.shell().info("(other projects' code in your release — clean your OTP cache)") + + for path <- r.foreign_apps do + Mix.shell().info(" #{red}#{Path.basename(path)}#{reset}") + end + end + + duplicate_kb = duplicate_kb(r) + foreign_kb = foreign_kb(r) + estimated_savings = r.strippable_kb + duplicate_kb + foreign_kb + + Mix.shell().info("\n#{h1}=== Summary ==={reset}") + Mix.shell().info(" Total shipped: #{format_kb(r.total_kb)}") + Mix.shell().info(" Reachable: #{format_kb(r.reachable_kb)}") + Mix.shell().info(" Strippable libs: #{format_kb(r.strippable_kb)}") + Mix.shell().info(" Duplicate versions: #{format_kb(duplicate_kb)}") + Mix.shell().info(" Foreign apps: #{format_kb(foreign_kb)}") + + Mix.shell().info(" #{h1}Total potential savings: #{format_kb(estimated_savings)}#{reset}\n") + end + + defp duplicate_kb(r) do + r.duplicates + |> Enum.flat_map(fn {_name, paths} -> paths end) + |> Enum.map(&dir_size_kb/1) + |> Enum.sum() + end + + defp foreign_kb(r) do + r.foreign_apps |> Enum.map(&dir_size_kb/1) |> Enum.sum() + end + + defp dir_size_kb(path) do + case System.cmd("du", ["-sk", path], stderr_to_stdout: true) do + {out, 0} -> out |> String.split() |> List.first() |> String.to_integer() + _ -> 0 + end + end + + defp format_kb(kb) when kb >= 1024, do: "#{Float.round(kb / 1024, 1)} MB" + defp format_kb(kb), do: "#{kb} KB" +end diff --git a/lib/mix/tasks/mob.audit_plugins.ex b/lib/mix/tasks/mob.audit_plugins.ex new file mode 100644 index 0000000..1228744 --- /dev/null +++ b/lib/mix/tasks/mob.audit_plugins.ex @@ -0,0 +1,102 @@ +defmodule Mix.Tasks.Mob.AuditPlugins do + use Mix.Task + + @shortdoc "Static-analysis audit of activated Mob plugins" + + @moduledoc """ + Scans every activated Mob plugin's Elixir + C source for risky patterns + (see `MOB_PLUGIN_SECURITY.md`'s default ruleset). + + mix mob.audit_plugins + mix mob.audit_plugins --plugin mob_demo_haptic_extras + mix mob.audit_plugins --accept-medium + + Rules implemented (in `MobDev.Plugin.Audit`): + + * `Code.eval_string/1,2,3` and `Code.compile_string/1,2` (high) + * `:erlang.binary_to_term/1` (the unbounded arity-1 form) (high) + * `String.to_atom/1` with a non-literal argument (medium) + * `Application.put_env(:mob, ...)` (medium) + * `File.write/cp/rm_rf`, `:os.cmd`, `System.cmd`, `Path.expand("~")` (medium) + * `system(3)`, `popen(3)`, `execve(2)`, `socket(2)` in NIF C (high/medium) + + Kotlin and Swift sources are reported as "not yet audited" — proper + parsers land in a follow-up commit. + + ## Options + + * `--plugin <name>` — scope the audit to one activated plugin. + * `--accept-medium` — exit 0 when only mediums are found (highs still + produce exit 2). Use after you've reviewed and decided to live with + the medium findings. + + ## Exit code + + * `0` — no findings, or only `:low` findings, or `:medium` findings with + `--accept-medium`. + * `1` — at least one `:medium` finding (without `--accept-medium`). + * `2` — at least one `:high` finding. + """ + + alias MobDev.Plugin + alias MobDev.Plugin.{Audit, Manifest, Report} + + @switches [plugin: :string, accept_medium: :boolean] + + @impl Mix.Task + def run(args) do + Mix.Task.run("loadpaths") + + {opts, _, _} = OptionParser.parse(args, strict: @switches) + accept_medium? = Keyword.get(opts, :accept_medium, false) + only = parse_only(opts[:plugin]) + + reports = audit_all(only) + output = Report.render_audit(reports) + + IO.puts("\n" <> output <> "\n") + + case Audit.exit_code(reports, accept_medium?) do + 0 -> :ok + code -> exit({:shutdown, code}) + end + end + + @doc """ + Audits every activated plugin, optionally filtered to one name. Public for + testing — the matching CLI invocation is `mix mob.audit_plugins`. + """ + @spec audit_all(atom() | nil) :: [Audit.report()] + def audit_all(only \\ nil) do + plugins = activated_plugins() + + plugins + |> Enum.filter(fn {name, _dir, _manifest} -> only == nil or name == only end) + |> Enum.map(fn {name, dir, manifest} -> + manifest = manifest || %{name: name} + Audit.audit_plugin(dir, manifest) + end) + end + + @doc """ + Returns `{name, dir, manifest}` for every activated plugin, resolving names + through `Mix.Project.deps_paths/0`. Public for testing. + """ + @spec activated_plugins() :: [{atom(), Path.t(), map() | nil}] + def activated_plugins do + deps = Mix.Project.deps_paths() + + for name <- Plugin.activated_names(), dir = deps[name], not is_nil(dir) do + manifest = + case Manifest.load(dir) do + {:ok, m} -> m + {:error, _} -> nil + end + + {name, dir, manifest} + end + end + + defp parse_only(nil), do: nil + defp parse_only(name) when is_binary(name), do: String.to_atom(name) +end diff --git a/lib/mix/tasks/mob.battery_bench_android.ex b/lib/mix/tasks/mob.battery_bench_android.ex index dafb7e2..4660010 100644 --- a/lib/mix/tasks/mob.battery_bench_android.ex +++ b/lib/mix/tasks/mob.battery_bench_android.ex @@ -1,6 +1,8 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do use Mix.Task + alias MobDev.Bench.{DeviceObserver, Logger, Preflight, Probe, Reconnector, Summary} + @shortdoc "Run a battery benchmark on an Android device" @moduledoc """ @@ -23,7 +25,21 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do adb connect PHONE_IP:5555 # then unplug and pass PHONE_IP:5555 as --device - ## Usage + ## Recommended workflow + + Same two-step pattern as iOS — push BEAM flags via `mix mob.deploy`, then + bench with `--no-build`. Lets you change tuning without a Gradle rebuild. + + # 1. Push BEAM flags via mob.deploy (no APK rebuild — ~10 sec). + mix mob.deploy --beam-flags "" --android # tuned (Nerves) + mix mob.deploy --beam-flags "-S 4:4 -A 8" --android # untuned variant + + # 2. Run the bench with --no-build. + mix mob.battery_bench_android --no-build --device 192.168.1.42:5555 + + See `README.md` for the full rationale and recovery procedure. + + ## Usage (with built-in Gradle build path) mix mob.battery_bench_android mix mob.battery_bench_android --no-beam @@ -34,12 +50,16 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do ## Options - * `--duration N` — benchmark duration in seconds (default: 1800) + * `--duration N` — benchmark duration in **seconds** (default: 1800 = 30 min) * `--device SERIAL` — adb device serial or IP:port (auto-detected if omitted) * `--no-beam` — baseline: build without starting the BEAM at all - * `--preset NAME` — named BEAM flag preset: `untuned`, `sbwt`, or `nerves` - * `--flags "..."` — arbitrary BEAM VM flags (space-separated, e.g. "-sbwt none") + * `--no-keep-alive` — skip the foreground-service background keep-alive call + * `--preset NAME` — named BEAM flag preset (Gradle-build path only) + * `--flags "..."` — arbitrary BEAM VM flags (Gradle-build path only) * `--no-build` — skip APK build and install; run benchmark on current install + * `--log-path PATH` — override CSV log location (default: `_build/bench/run_android_<ts>.csv`) + * `--no-csv` — skip CSV logging + * `--skip-preflight` — bypass the preflight checks (adb/app/BEAM/RPC/NIF/keep-alive) ## What the presets do @@ -87,12 +107,16 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do @switches [ duration: :integer, - device: :string, - no_beam: :boolean, - preset: :string, - flags: :string, + device: :string, + no_beam: :boolean, + no_keep_alive: :boolean, + preset: :string, + flags: :string, no_build: :boolean, - dry_run: :boolean + dry_run: :boolean, + log_path: :string, + no_csv: :boolean, + skip_preflight: :boolean ] @android_activity ".MainActivity" @@ -109,18 +133,21 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do duration = opts[:duration] || 1800 no_build = opts[:no_build] || false - device = case opts[:device] || auto_detect_device() do - nil -> - Mix.raise(""" - No Android device found. Options: - mix mob.battery_bench_android --device 192.168.1.42:5555 - adb connect PHONE_IP:5555 then re-run - """) - d -> d - end + device = + case opts[:device] || auto_detect_device() do + nil -> + Mix.raise(""" + No Android device found. Options: + mix mob.battery_bench_android --device 192.168.1.42:5555 + adb connect PHONE_IP:5555 then re-run + """) + + d -> + d + end - pkg = MobDev.Config.bundle_id() - app = app_name() + pkg = MobDev.Config.bundle_id() + app = app_name() IO.puts("") IO.puts("=== Mob Battery Benchmark ===") @@ -165,24 +192,41 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do if battery_pct < 80 do IO.puts("WARNING: Battery below 80%. Charge to >90% for comparable results.") IO.puts("Continue? (y/N)") - case IO.gets("") |> String.trim() do + + case prompt_yn("") do "y" -> :ok - _ -> Mix.raise("Aborted.") + _ -> Mix.raise("Aborted.") end end + # ── Promote USB → WiFi ADB so the connection survives unplug ── + # If the user passed a USB serial, auto-enable WiFi adb and switch the + # bench's `device` to <ip>:5555. If it's already an IP:port (WiFi adb + # already active), pass through unchanged. Saves the user from the + # tcpip/connect dance manually. + device = ensure_wifi_adb!(device) + IO.puts("") IO.puts("==========================================") IO.puts(" Unplug the USB cable now if connected.") IO.puts(" Press Enter when ready to start the run.") IO.puts("==========================================") - IO.gets("") + wait_for_enter() unless adb_ok?(device) do Mix.raise(""" - Lost connection after unplug. Is WiFi ADB active? - adb -s SERIAL tcpip 5555 - adb connect PHONE_IP:5555 + Lost connection after unplug. + + The bench tried to switch to WiFi ADB automatically; that's failing + now. Common causes: + - Device not on WiFi + - WiFi network blocking ADB port (5555) + - Device's WiFi went to sleep when screen locked + + You can do it manually before re-running: + adb -s <USB-SERIAL> tcpip 5555 + adb connect <PHONE-WIFI-IP>:5555 + mix mob.battery_bench_android --no-build --device <PHONE-WIFI-IP>:5555 """) end @@ -196,11 +240,76 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do start_mah = read_charge_counter_mah(device) IO.puts("Start charge: #{start_mah} mAh") + # ── Set up adb tunnels BEFORE launching the app ───────────────────── + # The BEAM tries to register with Mac's EPMD via 127.0.0.1:4369 during + # startup. That works only if the adb reverse tunnel is already up + # before mob_start_beam runs. If we set up tunnels after launch, the + # BEAM has already tried and failed to register, and verify_app_running! + # will (correctly) report "BEAM never registered". + ensure_tunnels(device) + IO.puts("") IO.puts("=== Launching app ===") adb!(device, ~w[shell am start -n #{pkg}/#{@android_activity}]) :timer.sleep(3000) + # ── Verify the app actually started ───────────────────────────────── + # If the BEAM crashes on launch (missing native libs, bad flags, etc.) + # the app process disappears within seconds. Catching it here saves a + # 30-minute meaningless run. + verify_app_running!(device, pkg) + + # Try the per-device suffixed name first (post-2026-04 deploys), then the + # bare name (back-compat). try_connect_with_retry returns the first node + # that succeeds, or nil if both fail. + suffix = MobDev.Discovery.Android.device_node_suffix(device) + suffixed_node = :"#{app}_android_#{suffix}@127.0.0.1" + bare_node = :"#{app}_android@127.0.0.1" + + # Poll Node.connect for up to 10 s. The BEAM's `Mob.Dist` waits ~3 s + # after app launch and only then registers — and the EPMD-name->port + # path can be briefly stale if a previous run held the slot. A single- + # shot connect here would race with all of that and `active_node = nil` + # for the rest of the run, leaving every probe stuck on `:unreachable` + # even when the BEAM is healthy and Erlang dist works fine seconds later. + active_node = + try_connect_with_retry(suffixed_node, 10_000) || + try_connect_with_retry(bare_node, 2_000) + + if active_node && opts[:no_keep_alive] != true do + IO.puts(" Starting background keep-alive...") + :rpc.call(active_node, :mob_nif, :background_keep_alive, [], 5000) + end + + # ── Preflight ────────────────────────────────────────────────────────── + unless opts[:skip_preflight] do + IO.puts("") + IO.puts("=== Preflight checks ===") + + preflight_results = + Preflight.run( + platform: :android, + node: active_node, + host: "127.0.0.1", + cookie: :mob_secret, + bundle_id: pkg, + adb_serial: device, + require_keep_alive: opts[:no_keep_alive] != true + ) + + IO.puts(Preflight.pretty(preflight_results)) + + unless Preflight.all_ok?(preflight_results) do + IO.puts("") + IO.puts(">>> Preflight reported issues. Continue anyway? (y/N)") + + case IO.gets("") |> String.trim() do + "y" -> :ok + _ -> Mix.raise("Aborted at preflight.") + end + end + end + screen_off(device) IO.puts("") @@ -210,23 +319,58 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do total_min = div(duration, 60) start_time = System.monotonic_time(:second) - Enum.each(1..duration, fn i -> - :timer.sleep(1000) - if rem(i, 10) == 0 do - elapsed_sec = System.monotonic_time(:second) - start_time - current_mah = read_charge_counter_mah(device) - drain_so_far = start_mah - current_mah - elapsed_min = Float.round(elapsed_sec / 60, 1) - ts = time_string() - rate_str = if elapsed_sec > 30 do - rate = Float.round(drain_so_far * 3600 / elapsed_sec, 1) - " @ #{rate} mAh/hr" + # ── Open CSV log unless --no-csv ─────────────────────────────────────── + log = + if opts[:no_csv] do + nil + else + log_path = + opts[:log_path] || + Path.join([ + File.cwd!(), + "_build", + "bench", + "run_android_#{System.os_time(:second)}.csv" + ]) + + IO.puts(" Logging samples to #{log_path}") + Logger.open(log_path, start_ts_ms: System.monotonic_time(:millisecond)) + end + + reconnector = Reconnector.new(active_node || :unset@unset, :mob_secret) + + observer = + DeviceObserver.subscribe(active_node, categories: [:app, :display, :memory]) + + if observer.subscribed? do + IO.puts(" Subscribed to Mob.Device events on #{inspect(active_node)}") + end + + {final_log, _final_reconnector, _final_observer} = + Enum.reduce(1..duration, {log, reconnector, observer}, fn i, + {log_acc, recon_acc, obs_acc} -> + :timer.sleep(1000) + + if rem(i, 10) == 0 do + poll_tick( + log_acc, + recon_acc, + obs_acc, + node: active_node, + host: "127.0.0.1", + adb_serial: device, + bundle_id: pkg, + expected_screen: :off, + start_time: start_time, + start_mah: start_mah, + total_min: total_min + ) else - "" + {log_acc, recon_acc, DeviceObserver.consume_messages(obs_acc)} end - IO.puts(" [#{ts}] #{elapsed_min}/#{total_min} min — #{current_mah} mAh (−#{drain_so_far} mAh#{rate_str})") - end - end) + end) + + log = final_log # ── Results ──────────────────────────────────────────────────────────────── @@ -235,13 +379,15 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do adb!(device, ~w[shell am force-stop #{pkg}]) :timer.sleep(1000) - end_mah = read_charge_counter_mah(device) - end_pct = read_battery_pct(device) - drain_mah = start_mah - end_mah + end_mah = read_charge_counter_mah(device) + end_pct = read_battery_pct(device) + drain_mah = start_mah - end_mah elapsed_actual = System.monotonic_time(:second) - start_time - rate = if elapsed_actual > 0, - do: Float.round(drain_mah * 3600 / elapsed_actual, 1), - else: 0.0 + + rate = + if elapsed_actual > 0, + do: Float.round(drain_mah * 3600 / elapsed_actual, 1), + else: 0.0 IO.puts("") IO.puts("=== Summary: #{describe_mode(opts)} ===") @@ -254,12 +400,92 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do IO.puts("") IO.puts("Lower mAh/hr = better. No-BEAM baseline is ~200 mAh/hr on Moto G.") IO.puts("") + + # ── CSV-based summary ─────────────────────────────────────────────── + if log do + log_path = log.path + Logger.close(log) + + IO.puts("=== Probe-based summary ===") + IO.puts("") + + try do + metrics = Summary.from_csv(log_path) + IO.puts(Summary.pretty(metrics)) + IO.puts("") + IO.puts("Full log: #{log_path}") + rescue + e -> IO.puts(" (could not parse #{log_path}: #{Exception.message(e)})") + end + + IO.puts("") + end + end + + # ── Probe-driven poll tick ──────────────────────────────────────────────── + + defp poll_tick(log, reconnector, observer, opts) do + elapsed_sec = System.monotonic_time(:second) - opts[:start_time] + elapsed_min = Float.round(elapsed_sec / 60, 1) + ts = time_string() + + observer = DeviceObserver.consume_messages(observer) + + probe = + Probe.snapshot( + platform: :android, + node: opts[:node], + host: opts[:host], + adb_serial: opts[:adb_serial], + bundle_id: opts[:bundle_id], + expected_screen: opts[:expected_screen] + ) + + probe = DeviceObserver.apply_to_probe(observer, probe) + log = if log, do: Logger.append(log, probe), else: log + + fragment = Probe.format(probe) + + line = + case probe.battery_pct do + nil -> + " [#{ts}] #{elapsed_min}/#{opts[:total_min]} min — #{fragment}" + + pct -> + # Note: Android USB probe returns battery percentage. We separately + # track mAh via dumpsys for the Android-specific drain calculation + # below, but the live trace uses % to align with iOS bench output. + " [#{ts}] #{elapsed_min}/#{opts[:total_min]} min — #{fragment} (#{pct}%)" + end + + IO.puts(line) + + now_ms = System.monotonic_time(:millisecond) + + reconnector = + case Reconnector.tick(reconnector, probe, now_ms) do + {:no_action, r} -> + r + + {:attempt, r} -> + if opts[:node] && Node.connect(opts[:node]) do + IO.puts( + " ↻ reconnected to #{opts[:node]} (attempt #{r.attempts}, total #{r.total_reconnects + 1})" + ) + + Reconnector.record_success(r) + else + r + end + end + + {log, reconnector, observer} end # ── Dry run ─────────────────────────────────────────────────────────────────── defp dry_run!(opts) do - pkg = MobDev.Config.bundle_id() + pkg = MobDev.Config.bundle_id() duration = opts[:duration] || 1800 # Validate preset / flags (raises on bad preset name) @@ -270,7 +496,7 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do IO.puts("=== Mob Battery Benchmark (Android) — Dry Run ===") IO.puts("") IO.puts(" Device: #{opts[:device] || "(auto-detect at run time)"}") - IO.puts(" Package: #{pkg || "(NOT SET)"}") + IO.puts(" Package: #{pkg}") IO.puts(" Duration: #{duration}s (#{div(duration, 60)} min)") IO.puts(" Mode: #{describe_mode(opts)}") IO.puts(" Flags: #{if cflags == "", do: "(default Nerves tuning)", else: cflags}") @@ -285,6 +511,7 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do # Returns {extra_cpp_flags_string, header_temp_dir_or_nil} @doc false + @spec resolve_build_flags(keyword()) :: {String.t(), String.t() | nil} def resolve_build_flags(opts) do cond do opts[:no_beam] -> @@ -293,20 +520,25 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do opts[:flags] -> header_dir = Path.join(System.tmp_dir!(), "mob_bench_flags_#{System.os_time(:second)}") File.mkdir_p!(header_dir) - flags_list = String.split(opts[:flags], ~r/\s+/, trim: true) - c_literals = Enum.map_join(flags_list, ", ", &~s("#{&1}")) - header = "/* generated by mix mob.battery_bench_android -- do not edit */\n" <> - "#define BEAM_EXTRA_FLAGS #{c_literals},\n" + flags_list = String.split(opts[:flags], Regex.compile!("\\s+"), trim: true) + c_literals = Enum.map_join(flags_list, ", ", &~s("#{&1}")) + + header = + "/* generated by mix mob.battery_bench_android -- do not edit */\n" <> + "#define BEAM_EXTRA_FLAGS #{c_literals},\n" + File.write!(Path.join(header_dir, "mob_beam_flags.h"), header) {"-DBEAM_USE_CUSTOM_FLAGS -I#{header_dir}", header_dir} opts[:preset] -> - flag = case opts[:preset] do - "untuned" -> "-DBEAM_UNTUNED" - "sbwt" -> "-DBEAM_SBWT_ONLY" - "nerves" -> "-DBEAM_FULL_NERVES" - other -> Mix.raise("Unknown preset #{inspect(other)}. Choose: untuned, sbwt, nerves") - end + flag = + case opts[:preset] do + "untuned" -> "-DBEAM_UNTUNED" + "sbwt" -> "-DBEAM_SBWT_ONLY" + "nerves" -> "-DBEAM_FULL_NERVES" + other -> Mix.raise("Unknown preset #{inspect(other)}. Choose: untuned, sbwt, nerves") + end + {flag, nil} true -> @@ -316,12 +548,13 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do end @doc false + @spec describe_mode(keyword()) :: String.t() def describe_mode(opts) do cond do - opts[:no_beam] -> "no-beam (baseline)" - opts[:flags] -> "custom flags: #{opts[:flags]}" - opts[:preset] -> "preset: #{opts[:preset]}" - true -> "default (Nerves tuning)" + opts[:no_beam] -> "no-beam (baseline)" + opts[:flags] -> "custom flags: #{opts[:flags]}" + opts[:preset] -> "preset: #{opts[:preset]}" + true -> "default (Nerves tuning)" end end @@ -329,13 +562,15 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do defp build_apk(extra_cpp_flags, _header_dir) do android_dir = Path.join(File.cwd!(), "android") - gradlew = Path.join(android_dir, "gradlew") + gradlew = Path.join(android_dir, "gradlew") unless File.exists?(gradlew), do: Mix.raise("gradlew not found at #{gradlew}") IO.puts(" Running Gradle assembleDebug...") - args = ["assembleDebug", "-q"] ++ - if extra_cpp_flags != "", do: ["-PextraCppFlags=#{extra_cpp_flags}"], else: [] + + args = + ["assembleDebug", "-q"] ++ + if extra_cpp_flags != "", do: ["-PextraCppFlags=#{extra_cpp_flags}"], else: [] case System.cmd(gradlew, args, cd: android_dir, stderr_to_stdout: true, into: IO.stream()) do {_, 0} -> :ok @@ -346,11 +581,56 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do defp install_apk(device, apk, pkg) do IO.puts(" Stopping app...") adb(device, ~w[shell am force-stop #{pkg}]) - adb(device, ~w[uninstall #{pkg}]) IO.puts(" Installing #{apk}...") + + # `adb install -r` replaces the APK in-place. It re-extracts native libs + # to /data/app/<pkg>/lib/<abi>/ but preserves /data/data/<pkg>/, which is + # critical: that directory holds files/otp/erts-*/bin/ — pushed by + # `mix mob.deploy --native` during initial provisioning. A previous + # version of this code did `adb uninstall && adb install`, which nuked + # /data/data/ and left the device with no ERTS, so mob_start_beam would + # crash on every subsequent launch with "symlink erl_child_setup failed". + # + # Falls back to uninstall+install on signature mismatch + # (INSTALL_FAILED_UPDATE_INCOMPATIBLE) — that path will rebuild the OTP + # runtime via the next `mix mob.deploy --native`, but the user has been + # warned. + case adb(device, ~w[install -r #{apk}]) do + {:ok, out} -> + if String.contains?(out, "INSTALL_FAILED") do + handle_install_failure(device, apk, pkg, out) + else + :ok + end + + {:error, reason} -> + if String.contains?(reason, "INSTALL_FAILED_UPDATE_INCOMPATIBLE") do + handle_install_failure(device, apk, pkg, reason) + else + Mix.raise("APK install failed: #{reason}") + end + end + end + + # When `install -r` fails because the new APK has a different signing + # certificate from the installed one, fall back to uninstall + install. This + # destroys /data/data/<pkg>/ and any OTP runtime there, so warn the user + # they'll need to rerun `mix mob.deploy --native` to restore ERTS before + # launching the app again. + defp handle_install_failure(device, apk, pkg, reason) do + IO.puts( + " #{IO.ANSI.yellow()}⚠ install -r failed: #{String.slice(reason, 0, 200)}#{IO.ANSI.reset()}" + ) + + IO.puts(" Falling back to full uninstall+install. This will erase the") + IO.puts(" OTP runtime in /data/data/#{pkg}/files/. After the bench finishes,") + IO.puts(" re-run `mix mob.deploy --native --device #{device}` to restore ERTS.") + + adb(device, ~w[uninstall #{pkg}]) + case adb(device, ~w[install #{apk}]) do - {:ok, _} -> :ok - {:error, reason} -> Mix.raise("APK install failed: #{reason}") + {:ok, _} -> :ok + {:error, why} -> Mix.raise("APK install failed: #{why}") end end @@ -361,14 +641,16 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do beams_dir = "/data/data/#{pkg}/files/otp/#{app}" # Check if we can root - rooted? = case adb(device, ["root"]) do - {:ok, out} -> out =~ "restarting" or out =~ "already running as root" - _ -> false - end + rooted? = + case adb(device, ["root"]) do + {:ok, out} -> out =~ "restarting" or out =~ "already running as root" + _ -> false + end if rooted? do :timer.sleep(600) adb!(device, ~w[shell mkdir -p #{beams_dir}]) + Enum.each(beam_dirs, fn dir -> adb!(device, ["push", "#{dir}/.", "#{beams_dir}/"]) end) @@ -378,7 +660,7 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do end defp push_beams_runas(device, pkg, beams_dir, beam_dirs) do - stage_local = Path.join(System.tmp_dir!(), "mob_bench_beams.tar") + stage_local = Path.join(System.tmp_dir!(), "mob_bench_beams.tar") stage_device = "/data/local/tmp/mob_bench_beams.tar" tmp = Path.join(System.tmp_dir!(), "mob_bench_stage") @@ -404,7 +686,9 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do libs |> Enum.map(&"_build/dev/lib/#{&1}/ebin") |> Enum.filter(&File.dir?/1) - {:error, _} -> [] + + {:error, _} -> + [] end end @@ -417,9 +701,12 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do adb!(device, ~w[shell input keyevent 26]) :timer.sleep(1000) screen = adb_out(device, ~w[shell dumpsys display]) - if screen =~ ~r/mScreenState.*ON/i or screen =~ ~r/mState.*ON/i do + + if screen =~ Regex.compile!("mScreenState.*ON", "i") or + screen =~ Regex.compile!("mState.*ON", "i") do adb!(device, ~w[shell input keyevent 26]) end + IO.puts(" Screen off.") end @@ -429,8 +716,11 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do # Falls back to percentage-based estimate if charge counter is unavailable. defp read_charge_counter_mah(device) do out = adb_out(device, ~w[shell dumpsys battery]) - case Regex.run(~r/Charge counter:\s*(\d+)/, out) do - [_, uah] -> div(String.to_integer(uah), 1000) + + case Regex.run(Regex.compile!("Charge counter:\\s*(\\d+)"), out) do + [_, uah] -> + div(String.to_integer(uah), 1000) + nil -> # Fallback: no charge counter on this device read_battery_pct(device) @@ -439,9 +729,10 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do defp read_battery_pct(device) do out = adb_out(device, ~w[shell dumpsys battery]) - case Regex.run(~r/level:\s*(\d+)/, out) do + + case Regex.run(Regex.compile!("level:\\s*(\\d+)"), out) do [_, pct] -> String.to_integer(pct) - nil -> 0 + nil -> 0 end end @@ -456,15 +747,16 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do |> Enum.filter(&String.contains?(&1, "\tdevice")) |> Enum.map(&(&1 |> String.split("\t") |> hd() |> String.trim())) |> List.first() - _ -> nil + + _ -> + nil end end defp adb_ok?(device) do - case System.cmd("adb", ["-s", device, "shell", "echo", "ok"], - stderr_to_stdout: true) do + case System.cmd("adb", ["-s", device, "shell", "echo", "ok"], stderr_to_stdout: true) do {_, 0} -> true - _ -> false + _ -> false end end @@ -477,7 +769,9 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do defp adb!(device, args) do case adb(device, args) do - {:ok, out} -> out + {:ok, out} -> + out + {:error, reason} -> IO.puts(" adb warning: #{reason}") "" @@ -494,6 +788,516 @@ defmodule Mix.Tasks.Mob.BatteryBenchAndroid do defp app_name, do: Mix.Project.config()[:app] |> to_string() + # IO.gets returns :eof in non-interactive contexts (piped stdin, certain + # CI runners). Treat EOF as "no answer" rather than crashing in + # String.trim/1. + defp prompt_yn(prompt) do + case IO.gets(prompt) do + :eof -> "n" + {:error, _} -> "n" + str when is_binary(str) -> str |> String.trim() |> String.downcase() + end + end + + defp wait_for_enter do + case IO.gets("") do + :eof -> + IO.puts(" (stdin not interactive — proceeding without confirmation)") + :ok + + {:error, _} -> + :ok + + _ -> + :ok + end + end + + # Verify both that (a) the Android process is up and (b) the BEAM has + # finished booting and registered its node in EPMD with a *live* listener. + # Catches four failure modes: + # 1. App crashes immediately → pidof returns empty + # 2. App shell up, BEAM crashed → pidof returns pid, EPMD never has node + # 3. Stale EPMD entry from prior → EPMD has node but TCP-probe fails + # run (different device, etc.) (the listener at the registered + # port isn't actually accepting) + # 4. Healthy startup → pidof + live EPMD entry both succeed + # + # The stale-entry case is especially nasty: another device/run can leave a + # name registered in Mac's EPMD that points to a port nothing is listening + # on. Without the TCP probe, the bench thinks the BEAM is up and lets a + # 30-minute run proceed where every RPC will fail. + defp verify_app_running!(device, pkg) do + app = app_name() + base_name = "#{app}_android" + suffix = MobDev.Discovery.Android.device_node_suffix(device) + suffixed_name = "#{base_name}_#{suffix}" + + # Try the suffixed name first (post-2026-04 deploys). Fall back to the + # bare name for back-compat with apps deployed before per-device suffixes. + candidates = [suffixed_name, base_name] + + deadline_ms = System.monotonic_time(:millisecond) + 10_000 + + result = + verify_loop(device, pkg, candidates, deadline_ms, + last_pid: nil, + last_epmd_entries: nil, + matched_name: nil + ) + + case result do + {:ok, pid, port, matched} -> + IO.puts(" ✓ App running on device (pid #{pid})") + IO.puts(" ✓ BEAM registered in EPMD as #{matched} (port #{port})") + + {:error, :no_process, _state} -> + Mix.raise(crash_diagnosis_no_process(device, pkg)) + + {:error, :process_no_beam, state} -> + Mix.raise(crash_diagnosis_no_beam(device, pkg, state[:last_pid])) + + # Stale EPMD entry isn't fatal — the bench can still run with USB-only + # battery readings. Warn loudly so the user knows BEAM-driven probes + # (RPC, NIF version checks) won't work, then fall through. + {:error, :stale_epmd, state} -> + IO.puts(" ✓ App running on device (pid #{state[:last_pid]})") + + IO.puts( + " #{IO.ANSI.yellow()}⚠ EPMD has #{state[:matched_name]} at port #{state[:stale_port]} but Node.connect fails#{IO.ANSI.reset()}" + ) + + IO.puts(stale_epmd_recovery_hint(device, pkg)) + end + end + + defp verify_loop(device, pkg, candidates, deadline_ms, state) do + :timer.sleep(500) + + pid = pid_of(device, pkg) + epmd_entries = epmd_names_local() + + matched = + Enum.find_value(candidates, fn name -> + case Map.get(epmd_entries, name) do + nil -> nil + port -> {name, port} + end + end) + + cond do + pid && matched && beam_reachable?(:"#{elem(matched, 0)}@127.0.0.1") -> + {name, port} = matched + {:ok, pid, port, name} + + System.monotonic_time(:millisecond) >= deadline_ms -> + cond do + is_nil(pid) -> + {:error, :no_process, [last_pid: state[:last_pid], last_epmd_entries: epmd_entries]} + + # EPMD has an entry but Node.connect can't actually reach the BEAM + # — typically a stale entry from a prior run, or another device + # squatting on the same name (only possible with bare base name). + matched -> + {name, port} = matched + + {:error, :stale_epmd, + [ + last_pid: pid, + last_epmd_entries: epmd_entries, + stale_port: port, + matched_name: name + ]} + + true -> + {:error, :process_no_beam, [last_pid: pid, last_epmd_entries: epmd_entries]} + end + + true -> + verify_loop(device, pkg, candidates, deadline_ms, + last_pid: pid || state[:last_pid], + last_epmd_entries: epmd_entries, + matched_name: state[:matched_name] + ) + end + end + + defp pid_of(device, pkg) do + case System.cmd("adb", ["-s", device, "shell", "pidof", pkg], stderr_to_stdout: true) do + {out, 0} -> + case String.trim(out) do + "" -> nil + s -> s + end + + _ -> + nil + end + end + + # Returns a map of %{node_name => port} for everything Mac's EPMD knows. + defp epmd_names_local do + case :gen_tcp.connect(~c"127.0.0.1", 4369, [:binary, active: false], 500) do + {:ok, sock} -> + :gen_tcp.send(sock, <<0, 1, ?n>>) + + entries = + case :gen_tcp.recv(sock, 0, 500) do + {:ok, <<_::32, body::binary>>} -> + body + |> String.split("\n", trim: true) + |> Enum.flat_map(fn line -> + case Regex.run(Regex.compile!("^name (\\S+) at port (\\d+)$"), line) do + [_, name, port] -> [{name, String.to_integer(port)}] + _ -> [] + end + end) + |> Map.new() + + _ -> + %{} + end + + :gen_tcp.close(sock) + entries + + _ -> + %{} + end + end + + # Confirm the registered BEAM is actually reachable over Erlang + # distribution — the only check that distinguishes a live BEAM from a + # stale EPMD entry. A plain TCP-connect on the registered port is + # unreliable here because `adb forward` accepts host-side connections + # eagerly and only later finds out the device-side socket is dead, so a + # raw `gen_tcp:connect/3` returns `:ok` even when nothing is listening + # inside the app. + defp beam_reachable?(node) do + Node.set_cookie(node, :mob_secret) + Node.connect(node) == true + rescue + _ -> false + end + + # Repeatedly try Node.connect until success or timeout. Used right after + # `verify_app_running!` to handle the timing window where the device-side + # `Mob.Dist` is still bringing up its listener — a single Node.connect + # would fail and leave `active_node = nil` for the entire run, sending + # every probe to `:unreachable` even when the BEAM is healthy. + # + # Returns the connected node atom on success, nil on timeout. The bench + # tries the per-device suffixed name first then falls back to the bare + # name; returning the actual node lets the caller pick whichever worked. + defp try_connect_with_retry(node, timeout_ms) do + deadline = System.monotonic_time(:millisecond) + timeout_ms + do_try_connect(node, deadline, _attempts = 0) + end + + defp do_try_connect(node, deadline, attempts) do + if beam_reachable?(node) do + IO.puts(" BEAM connected: #{node}") + node + else + if System.monotonic_time(:millisecond) < deadline do + :timer.sleep(500) + do_try_connect(node, deadline, attempts + 1) + else + IO.puts(" (BEAM not reachable after #{attempts + 1} attempts — USB-only readings)") + + nil + end + end + end + + defp crash_diagnosis_no_process(device, pkg) do + """ + + ✗ App #{pkg} is not running ~10 seconds after launch. + + The Android process is gone — BEAM crashed before the iOS shell could + keep it alive. Common causes: + + - Missing ERTS helper libs in the APK (check lib/<abi>/ contains + liberl_child_setup.so, libinet_gethost.so, libepmd.so — for + 32-bit ARM devices they need to be in lib/arm, not just + lib/arm64). + - Bad BEAM flags in mob.exs (try `mix mob.deploy --beam-flags ""`) + - App crashed for an unrelated reason — check logcat: + + adb -s #{device} logcat -d | grep -iE "MobBeam|MobNIF|FATAL|tombstone" + + Re-run the bench after the app launches cleanly. + """ + end + + defp crash_diagnosis_no_beam(device, pkg, pid) do + """ + + ✗ App #{pkg} is running (pid #{pid}) but the BEAM never registered. + + The Android process is alive but the embedded BEAM either crashed + during startup or isn't reachable via Erlang distribution. Common + causes: + + - BEAM crashed in mob_start_beam — check logcat for SIGABRT in + beam-main: + + adb -s #{device} logcat -d | grep -iE "MobBeam|FATAL|SIGABRT|beam-main" + + - OTP runtime never deployed to this device. The app is installed + but /data/data/<pkg>/files/otp/erts-*/bin/ is missing. Common when + the device wasn't connected during a previous `mix mob.deploy + --native`. Provision it now: + + mix mob.deploy --native --device #{device} + + - BEAMs stale on device. If OTP is present, push fresh BEAMs: + + mix mob.deploy --android --device #{device} + + - Bad BEAM flags in mob.exs (try `mix mob.deploy --beam-flags ""`) + - adb tunnels not set up (the bench tries automatically; if your + Mac's EPMD is occupied by another node, things may collide) + + The Android process may be the foreground service / notification + process keeping the package alive even though the BEAM died. Don't + take a green `pidof` as proof the BEAM is up — EPMD registration is + the authoritative signal. + """ + end + + defp stale_epmd_recovery_hint(device, pkg) do + others = other_devices_running(device, pkg) + + collision_block = + case others do + [] -> + """ + No other adb-connected device appears to be running #{pkg}, so + the EPMD entry is most likely stale (left by a previous run). + """ + + _ -> + formatted = + Enum.map_join(others, "\n", fn {serial, pid} -> + " adb -s #{serial} shell am force-stop #{pkg} # pid #{pid}" + end) + + """ + Other adb-connected device(s) are also running #{pkg} — they're + holding the EPMD `<app>_android` slot. Force-stop them so this + bench's BEAM can register, OR disconnect those devices: + + #{formatted} + + (Each Android device hardcodes the same node name, so only one + can register in Mac's EPMD via adb-reverse at a time. The + structural fix is per-device unique node names, like iOS sims + do with their UDID suffix — not yet implemented.) + """ + end + + """ + Bench will fall back to USB-only readings (no per-second RPC probes). + + #{collision_block} + Other recovery options: + + # Force EPMD to forget every node (kills any other Mob iEx sessions): + pkill -9 epmd && epmd -daemon + adb -s #{device} reverse --remove-all && \\ + adb -s #{device} reverse tcp:4369 tcp:4369 + + Logcat tells you whether the BEAM tried distribution this run: + adb -s #{device} logcat -d | grep -iE "Mob.Dist|step [0-9]" + """ + end + + # Walk every adb-connected device, check whether it has `pkg` running, and + # return the [{serial, pid}] list excluding the bench's own target. Used to + # tell the user which other phone is squatting on the EPMD slot. + defp other_devices_running(this_device, pkg) do + case System.cmd("adb", ["devices"], stderr_to_stdout: true) do + {output, 0} -> + output + |> String.split("\n") + |> Enum.drop(1) + |> Enum.filter(&String.contains?(&1, "\tdevice")) + |> Enum.map(&hd(String.split(&1, "\t"))) + |> Enum.reject(&same_device?(&1, this_device)) + |> Enum.flat_map(fn serial -> + case System.cmd("adb", ["-s", serial, "shell", "pidof", pkg], stderr_to_stdout: true) do + {out, 0} -> + case String.trim(out) do + "" -> [] + pid -> [{serial, pid}] + end + + _ -> + [] + end + end) + + _ -> + [] + end + end + + # Two adb identifiers refer to the same physical device when one is the + # USB serial and the other is `<ip>:5555` for the same phone. We can't + # always tell that from the strings alone, so be lenient: equal-string match + # plus IP-port form for the bench's own device. + defp same_device?(serial, this_device) do + serial == this_device or + serial == strip_port(this_device) or + "#{serial}:5555" == this_device + end + + defp strip_port(s) do + case String.split(s, ":", parts: 2) do + [host, _port] -> host + _ -> s + end + end + + # If the user passed a USB serial (no IP:port), auto-enable WiFi ADB so + # the bench's `device` argument keeps working after the user unplugs the + # USB cable. Returns the (possibly-promoted) device identifier. + # + # Steps: + # 1. Detect device is USB-connected (serial doesn't match IP:port format) + # 2. Find its WiFi IP via `adb shell ip route get 1.1.1.1` + # 3. `adb -s SERIAL tcpip 5555` to enable WiFi adb + # 4. Sleep briefly for the device to switch + # 5. `adb connect IP:5555` + # 6. Verify the IP:5555 connection works + # 7. Return "IP:5555" — caller uses this for all subsequent adb commands + # + # If anything fails along the way, raise with a clear hint to do it + # manually rather than surprising the user later when unplug fails. + defp ensure_wifi_adb!(device) do + if String.contains?(device, ":") do + # Already IP:port — assume user has WiFi adb working. + device + else + promote_usb_to_wifi!(device) + end + end + + defp promote_usb_to_wifi!(serial) do + IO.puts("") + IO.puts("=== Switching to WiFi ADB ===") + IO.puts(" Finding device WiFi IP...") + ip = wifi_ip_for_serial!(serial) + IO.puts(" Device IP: #{ip}") + + IO.puts(" Enabling WiFi ADB on port 5555...") + + case System.cmd("adb", ["-s", serial, "tcpip", "5555"], stderr_to_stdout: true) do + {_, 0} -> + :ok + + {out, _} -> + Mix.raise(""" + Failed to enable WiFi ADB: + #{String.trim(out)} + + Try manually: + adb -s #{serial} tcpip 5555 + adb connect <PHONE-IP>:5555 + """) + end + + # Device needs a moment to restart adbd in TCP mode. + :timer.sleep(2_000) + + new_device = "#{ip}:5555" + IO.puts(" Connecting to #{new_device}...") + + case System.cmd("adb", ["connect", new_device], stderr_to_stdout: true) do + {out, 0} -> + if String.contains?(out, "connected") or String.contains?(out, "already connected") do + # Verify it actually works. + if adb_ok?(new_device) do + IO.puts(" ✓ WiFi ADB connected as #{new_device}") + new_device + else + Mix.raise(""" + adb connect reported success but the device isn't responding. + Check WiFi network and re-run with the WiFi-ADB serial: + mix mob.battery_bench_android --no-build --device #{new_device} + """) + end + else + Mix.raise(""" + adb connect failed: + #{String.trim(out)} + """) + end + + {out, _} -> + Mix.raise(""" + adb connect failed: + #{String.trim(out)} + + Try manually: + adb -s #{serial} tcpip 5555 + adb connect #{new_device} + """) + end + end + + # Find the device's WiFi IPv4 by running `ip route get 1.1.1.1` on it + # and parsing the `src` field from the output. + defp wifi_ip_for_serial!(serial) do + case System.cmd("adb", ["-s", serial, "shell", "ip", "route", "get", "1.1.1.1"], + stderr_to_stdout: true + ) do + {out, 0} -> + case Regex.run( + Regex.compile!("\\bsrc\\s+(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})"), + out + ) do + [_, ip] -> + ip + + nil -> + Mix.raise(""" + Couldn't determine the device's WiFi IP from: + #{String.trim(out)} + + Is the device connected to WiFi? Settings → Network & internet → Internet. + """) + end + + {out, _} -> + Mix.raise(""" + adb shell ip route failed: + #{String.trim(out)} + """) + end + end + + # Set up the adb tunnels needed for Erlang dist: + # adb reverse tcp:4369 tcp:4369 — Android BEAM registers in Mac's EPMD + # adb forward tcp:9100 tcp:9100 — Mac reaches device's dist port + # No-op on failure — the bench will detect the missing connection during + # preflight and the user can investigate. + defp ensure_tunnels(serial) when is_binary(serial) do + System.cmd("adb", ["-s", serial, "reverse", "tcp:4369", "tcp:4369"], stderr_to_stdout: true) + + System.cmd("adb", ["-s", serial, "forward", "tcp:9100", "tcp:9100"], stderr_to_stdout: true) + + # Local Erlang dist must be alive for Node.connect/1 to work. + unless Node.alive?() do + Node.start(:"mob_bench_android@127.0.0.1", :longnames) + Node.set_cookie(:mob_secret) + end + + :ok + end + defp time_string do {{_y, _mo, _d}, {h, m, s}} = :calendar.local_time() :io_lib.format("~2..0B:~2..0B:~2..0B", [h, m, s]) |> IO.iodata_to_binary() diff --git a/lib/mix/tasks/mob.battery_bench_ios.ex b/lib/mix/tasks/mob.battery_bench_ios.ex index e03cc65..dec409a 100644 --- a/lib/mix/tasks/mob.battery_bench_ios.ex +++ b/lib/mix/tasks/mob.battery_bench_ios.ex @@ -1,6 +1,8 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do use Mix.Task + alias MobDev.Bench.{DeviceObserver, Logger, Preflight, Probe, Reconnector, Summary} + @shortdoc "Run a battery benchmark on a physical iOS device" @moduledoc """ @@ -35,7 +37,26 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do skewed because the cable can trickle-charge. To minimise this, use a USB-only data cable (no charging), or note the baseline with and without cable. - ## Usage + ## Recommended workflow (Mob projects) + + Mob projects use `ios/build.zig` (driven by Mix's NativeBuild pipeline) + rather than a full Xcode project, which means the bench task's + `xcodebuild` path doesn't apply. Use this two-step pattern instead: + + # 1. Push BEAM flags via mob.deploy (no native rebuild — ~5 sec). + mix mob.deploy --beam-flags "" --ios # tuned (Nerves) + mix mob.deploy --beam-flags "-S 6:6 -A 8" --ios # untuned variant + + # 2. Run the bench with --no-build, specifying the phone's WiFi IP. + mix mob.battery_bench_ios --no-build --wifi-ip 10.0.0.120 + + Find the phone's WiFi IP in Settings → Wi-Fi → (i) → IP Address. + + See `README.md` for the full rationale and recovery procedure if a flag + combination crashes the BEAM (which can happen if you request more + threads than iOS allows per process). + + ## Usage (with built-in Xcode build path) mix mob.battery_bench_ios mix mob.battery_bench_ios --no-beam @@ -46,13 +67,18 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do ## Options - * `--duration N` — benchmark duration in seconds (default: 1800) + * `--duration N` — benchmark duration in **seconds** (default: 1800 = 30 min) * `--device UDID` — device UDID (auto-detected if one device connected) + * `--wifi-ip IP` — phone's WiFi IPv4 (recommended; bypasses auto-discovery) * `--no-beam` — baseline: build without starting the BEAM at all - * `--preset NAME` — named BEAM flag preset: `untuned`, `sbwt`, or `nerves` - * `--flags "..."` — arbitrary BEAM VM flags (space-separated) + * `--no-keep-alive` — skip the silent-audio background keep-alive call + * `--preset NAME` — named BEAM flag preset (Xcode-build path only) + * `--flags "..."` — arbitrary BEAM VM flags (Xcode-build path only) * `--no-build` — skip Xcode build and install; benchmark current install * `--scheme NAME` — Xcode scheme name (default: camelized app name) + * `--log-path PATH` — override CSV log location (default: `_build/bench/run_<ts>.csv`) + * `--no-csv` — skip CSV logging + * `--skip-preflight` — bypass the preflight checks (USB/app/BEAM/RPC/NIF/keep-alive) ## What the presets do @@ -80,14 +106,14 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do # Install and launch xcrun devicectl device install app --device UDID /path/to/App.app - xcrun devicectl device launch app --terminate-existing --device UDID \\ + xcrun devicectl device process launch --terminate-existing --device UDID \\ com.example.myapp # → captures PID # Lock screen idevicediagnostics -u UDID sleep # Poll battery every 10s - ideviceinfo -u UDID -q com.apple.mobile.battery -k CurrentCapacity + ideviceinfo -u UDID -q com.apple.mobile.battery -k BatteryCurrentCapacity ideviceinfo -u UDID -q com.apple.mobile.battery -k BatteryMaxCapacity # Stop app @@ -99,13 +125,18 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do @switches [ duration: :integer, - device: :string, - no_beam: :boolean, - preset: :string, - flags: :string, + device: :string, + wifi_ip: :string, + no_beam: :boolean, + no_keep_alive: :boolean, + preset: :string, + flags: :string, no_build: :boolean, - scheme: :string, - dry_run: :boolean + scheme: :string, + dry_run: :boolean, + log_path: :string, + no_csv: :boolean, + skip_preflight: :boolean ] @battery_domain "com.apple.mobile.battery" @@ -128,21 +159,59 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do duration = opts[:duration] || 1800 no_build = opts[:no_build] || false - udid = case opts[:device] || auto_detect_device() do - nil -> - Mix.raise(""" - No iOS device found. Options: - Connect an iPhone/iPad via USB and accept "Trust This Computer" - mix mob.battery_bench_ios --device UDID - List connected devices with: idevice_id -l - """) - d -> d - end + # hw_udid: hardware UDID for libimobiledevice tools (USB only, may be nil over WiFi). + # device_id: identifier for xcrun devicectl (works over WiFi for paired devices). + # --device accepts either; if given, use it for both. + {hw_udid, device_id} = + case opts[:device] do + given when is_binary(given) -> + # Hardware UDID has no hyphens in the first segment (e.g. 00008110-...) + # CoreDevice UUID has the standard 8-4-4-4-12 UUID format. + hw = + if String.match?( + given, + Regex.compile!("^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}") + ), + do: nil, + else: given + + {hw, given} + + nil -> + case {auto_detect_usb(), auto_detect_wifi()} do + {nil, nil} -> + Mix.raise(""" + No iOS device found. Options: + Connect an iPhone/iPad via USB and accept "Trust This Computer" + mix mob.battery_bench_ios --device UDID + List connected devices with: idevice_id -l + """) + + {usb, nil} -> + {usb, usb} + + {nil, wifi} -> + {nil, wifi} + + {usb, wifi} -> + {usb, wifi} + end + end + + # device_id is what we pass to xcrun devicectl (install, launch, terminate). + udid = device_id pkg = MobDev.Config.bundle_id() cfg = MobDev.Config.load_mob_config() - scheme = opts[:scheme] || cfg[:ios_scheme] || default_scheme() + # Workspace discovery is only needed when building. Skip it with --no-build. + {workspace_kind, workspace_path, scheme} = + if no_build do + {:none, nil, opts[:scheme] || cfg[:ios_scheme] || Macro.camelize(app_name())} + else + {wk, wp} = find_workspace!() + {wk, wp, opts[:scheme] || cfg[:ios_scheme] || detect_scheme!(wk, wp)} + end IO.puts("") IO.puts("=== Mob Battery Benchmark (iOS) ===") @@ -155,11 +224,7 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do IO.puts("") unless device_ok?(udid) do - Mix.raise(""" - Cannot reach device #{udid}. - Check: ideviceinfo -u #{udid} -k DeviceName - The device must be connected via USB and trusted on this Mac. - """) + Mix.raise("Cannot reach device #{udid} — check it is paired and on the same network.") end # ── Build ────────────────────────────────────────────────────────────────── @@ -170,7 +235,6 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do {other_cflags, header_dir} = resolve_build_flags(opts) IO.puts("=== Building iOS app ===") - {workspace_kind, workspace_path} = find_workspace!() app_path = build_app(workspace_kind, workspace_path, scheme, other_cflags, derived_data) IO.puts("=== Installing on device ===") @@ -179,11 +243,71 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do if header_dir, do: File.rm_rf!(header_dir) end + # ── Launch app first so the BEAM is reachable for all battery reads ────────── + + IO.puts("=== Launching app ===") + pid = launch_app!(udid, pkg) + :timer.sleep(3000) + # ── Pre-run checks ───────────────────────────────────────────────────────── - max_mah = read_max_capacity_mah(udid) - battery = read_battery(udid, max_mah) - unit = if max_mah, do: "mAh", else: "%" + # Connect to the phone's BEAM — used as fallback when ideviceinfo is + # unavailable (WiFi-only mode) and for battery reads when screen is locked. + # Best-effort: nil means RPC won't be available. + IO.puts(" Connecting to device BEAM...") + node = connect_beam_node(device_id, opts[:wifi_ip]) + + if node do + IO.puts(" BEAM connected: #{node}") + + # If the user *didn't* pass --wifi-ip but we found the device anyway + # (devicectl + ARP/EPMD scan, which can take 5–15 s on a cold network), + # surface the IP so the next run can short-circuit straight to it. + hint_wifi_ip(node, opts[:wifi_ip]) + + if opts[:no_keep_alive] do + IO.puts(" (skipping background keep-alive — iOS will suspend the app when locked)") + else + IO.puts(" Starting background keep-alive (silent audio session)...") + :rpc.call(node, :mob_nif, :background_keep_alive, [], 5000) + end + else + IO.puts(" (BEAM not reachable — will use ideviceinfo only, screen must stay on)") + hint_wifi_ip_on_failure(opts[:wifi_ip]) + end + + # ── Preflight ───────────────────────────────────────────────────────────── + + unless opts[:skip_preflight] do + IO.puts("") + IO.puts("=== Preflight checks ===") + + preflight_results = + Preflight.run( + node: node, + cookie: :mob_secret, + bundle_id: pkg, + device_id: device_id, + hw_udid: hw_udid, + require_keep_alive: opts[:no_keep_alive] != true + ) + + IO.puts(Preflight.pretty(preflight_results)) + + unless Preflight.all_ok?(preflight_results) do + IO.puts("") + IO.puts(">>> Preflight checks reported issues. Continue anyway? (y/N)") + + case IO.gets("") |> String.trim() do + "y" -> :ok + _ -> Mix.raise("Aborted at preflight.") + end + end + end + + max_mah = read_max_capacity_mah(hw_udid) + battery = read_battery_required(hw_udid, max_mah, node) + unit = if max_mah, do: "mAh", else: "%" IO.puts("") IO.puts("Battery: #{format_battery(battery, max_mah)}") @@ -191,68 +315,126 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do if battery.pct < 80 do IO.puts("WARNING: Battery below 80%. Charge to >90% for comparable results.") IO.puts("Continue? (y/N)") + case IO.gets("") |> String.trim() do "y" -> :ok - _ -> Mix.raise("Aborted.") + _ -> Mix.raise("Aborted.") end end IO.puts("") - IO.puts("=== Launching app ===") - pid = launch_app!(udid, pkg) - :timer.sleep(3000) - IO.puts("=== Locking screen ===") - lock_screen(udid) + total_min = div(duration, 60) + start_b = read_battery_required(hw_udid, max_mah, node) + start_val = battery_value(start_b, max_mah) + start_time = System.monotonic_time(:second) - IO.puts("") - IO.puts("Running for #{div(duration, 60)} min — do not touch the phone...") + IO.puts("Start: #{format_battery(start_b, max_mah)}") IO.puts("") - total_min = div(duration, 60) - start_b = read_battery(udid, max_mah) - start_val = battery_value(start_b, max_mah) - start_time = System.monotonic_time(:second) + screen_locked = + if node do + IO.puts(">>> Step 1 of 2 — Unplug the USB cable (if connected), then press Enter.") + IO.gets("") + IO.puts("") + IO.puts(">>> Step 2 of 2 — Locking the screen now...") + result = lock_screen_auto(hw_udid) + IO.puts("") + result + else + IO.puts(">>> Unplug the USB cable (if connected), keep the screen ON, then press Enter.") + IO.puts(" (BEAM not connected — battery reads require USB or an active screen.)") + IO.gets("") + false + end - IO.puts("Start: #{format_battery(start_b, max_mah)}") + IO.puts("Running for #{div(duration, 60)} min...") IO.puts("") + IO.puts("") + + # ── Open CSV log unless --no-csv ─────────────────────────────────────── + log = + if opts[:no_csv] do + nil + else + log_path = + opts[:log_path] || + Path.join([ + File.cwd!(), + "_build", + "bench", + "run_#{System.os_time(:second)}.csv" + ]) + + IO.puts(" Logging samples to #{log_path}") + Logger.open(log_path, start_ts_ms: System.monotonic_time(:millisecond)) + end - Enum.each(1..duration, fn i -> - :timer.sleep(1000) - if rem(i, 10) == 0 do - elapsed_sec = System.monotonic_time(:second) - start_time - current_b = read_battery(udid, max_mah) - current_val = battery_value(current_b, max_mah) - drain = start_val - current_val - elapsed_min = Float.round(elapsed_sec / 60, 1) - ts = time_string() - - rate_str = if elapsed_sec > 30 do - rate = Float.round(drain * 3600 / elapsed_sec, 1) - " @ #{rate} #{unit}/hr" + reconnector = Reconnector.new(node || :unset@unset, :mob_secret) + + expected_screen = if screen_locked, do: :off, else: :on + + # Subscribe to Mob.Device events on the device. If the app supports it, + # we'll get ground-truth screen + app-state events as they happen (via + # `:rpc.call(node, Mob.Device, :subscribe, ...)`). If not, the observer + # falls back to passing through `expected_screen`. + observer = DeviceObserver.subscribe(node, categories: [:app, :display, :memory]) + + if observer.subscribed? do + IO.puts(" Subscribed to Mob.Device events on #{inspect(node)}") + else + IO.puts(" (Mob.Device events not available — using expected screen state)") + end + + {final_log, final_reconnector, _final_observer} = + Enum.reduce(1..duration, {log, reconnector, observer}, fn i, + {log_acc, recon_acc, obs_acc} -> + :timer.sleep(1000) + + if rem(i, 10) == 0 do + poll_tick( + i, + log_acc, + recon_acc, + obs_acc, + node: node, + wifi_ip: opts[:wifi_ip], + hw_udid: hw_udid, + device_id: device_id, + app_pid: pid, + expected_screen: expected_screen, + start_time: start_time, + start_val: start_val, + unit: unit, + total_min: total_min + ) else - "" + # Even on non-poll iterations, drain device events into the observer. + {log_acc, recon_acc, DeviceObserver.consume_messages(obs_acc)} end + end) - IO.puts(" [#{ts}] #{elapsed_min}/#{total_min} min — #{format_battery(current_b, max_mah)} " <> - "(−#{Float.round(drain * 1.0, 1)} #{unit}#{rate_str})") - end - end) + log = final_log + _ = final_reconnector # ── Results ──────────────────────────────────────────────────────────────── IO.puts("") IO.puts("=== Collecting results ===") + if node, do: :rpc.call(node, :mob_nif, :background_stop, [], 5000) if pid, do: terminate_app(udid, pid) :timer.sleep(1000) - end_b = read_battery(udid, max_mah) - end_val = battery_value(end_b, max_mah) - drain = start_val - end_val + IO.puts(" Unlock the phone to read final battery level...") + end_b = read_battery_required(hw_udid, max_mah, node, 1, 60) + end_val = battery_value(end_b, max_mah) + drain = start_val - end_val elapsed_actual = System.monotonic_time(:second) - start_time - rate = if elapsed_actual > 0, - do: Float.round(drain * 3600 / elapsed_actual, 1), - else: 0.0 + + rate = + if elapsed_actual > 0, + do: Float.round(drain * 3600 / elapsed_actual, 1), + else: 0.0 IO.puts("") IO.puts("=== Summary: #{describe_mode(opts)} ===") @@ -262,39 +444,216 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do IO.puts(" End: #{format_battery(end_b, max_mah)}") IO.puts(" Drain: #{Float.round(drain * 1.0, 1)} #{unit}") IO.puts(" Rate: #{rate} #{unit}/hr") + if is_nil(max_mah) do IO.puts("") IO.puts("Note: BatteryMaxCapacity unavailable; showing percentage. 1% ≈ 40–60 mAh.") end + IO.puts("") + # ── Optional: precise USB read for 1% resolution ───────────────────── + # iOS UIDevice.batteryLevel is clamped to 5% increments at the OS level + # (privacy measure). ideviceinfo over USB exposes the raw 1% reading + # via the battery domain. After a screen-off bench where the phone was + # unplugged, plug it back in here for a more precise final number. + precise_final_read(hw_udid) + + # ── CSV-based summary (probes, reconnects, gap analysis) ───────────── + if log do + log_path = log.path + Logger.close(log) + + IO.puts("=== Probe-based summary ===") + IO.puts("") + + try do + metrics = Summary.from_csv(log_path) + IO.puts(Summary.pretty(metrics)) + IO.puts("") + IO.puts("Full log: #{log_path}") + rescue + e -> + IO.puts(" (could not parse #{log_path}: #{Exception.message(e)})") + end + + IO.puts("") + end + File.rm_rf!(derived_data) end - # ── Prerequisites ───────────────────────────────────────────────────────────── + # ── Probe-driven poll tick ──────────────────────────────────────────────── + + # One iteration of the polling loop (called every 10s). Takes a probe + # snapshot, prints a one-line trace, logs it, and runs the reconnector. + # Returns updated {log, reconnector, observer} for the next tick. + defp poll_tick(_iter, log, reconnector, observer, opts) do + elapsed_sec = System.monotonic_time(:second) - opts[:start_time] + elapsed_min = Float.round(elapsed_sec / 60, 1) + ts = time_string() + + # Drain any buffered Mob.Device events first so the probe reflects + # ground-truth screen/app state. + observer = DeviceObserver.consume_messages(observer) + + probe = + Probe.snapshot( + node: opts[:node], + host: opts[:wifi_ip] || derive_host_from_node(opts[:node]), + hw_udid: opts[:hw_udid], + device_id: opts[:device_id], + app_pid: opts[:app_pid], + expected_screen: opts[:expected_screen] + ) + + # Apply observer's authoritative screen/app state on top of the probe. + probe = DeviceObserver.apply_to_probe(observer, probe) + + log = if log, do: Logger.append(log, probe), else: log + + # Render the live line. + fragment = Probe.format(probe) + + line = + case probe.battery_pct do + nil -> + " [#{ts}] #{elapsed_min}/#{opts[:total_min]} min — #{fragment}" + + pct -> + drain = opts[:start_val] - pct + + rate_str = + if elapsed_sec > 30 do + rate = Float.round(drain * 3600 / elapsed_sec, 1) + " @ #{rate} #{opts[:unit]}/hr" + else + "" + end + + " [#{ts}] #{elapsed_min}/#{opts[:total_min]} min — #{fragment} (−#{Float.round(drain * 1.0, 1)} #{opts[:unit]}#{rate_str})" + end - defp check_prerequisites! do - unless System.find_executable("ideviceinfo") do - Mix.raise(""" - ideviceinfo not found. Install libimobiledevice: + IO.puts(line) - brew install libimobiledevice + # Reconnect logic — attempt Node.connect when in a recoverable state. + now_ms = System.monotonic_time(:millisecond) - This is required to read battery levels from the device. - """) + reconnector = + case Reconnector.tick(reconnector, probe, now_ms) do + {:no_action, r} -> + r + + {:attempt, r} -> + if opts[:node] && Node.connect(opts[:node]) do + IO.puts( + " ↻ reconnected to #{opts[:node]} (attempt #{r.attempts}, total #{r.total_reconnects + 1})" + ) + + Reconnector.record_success(r) + else + r + end + end + + {log, reconnector, observer} + end + + # Optional precise final-battery read via ideviceinfo over USB. iOS clamps + # UIDevice.batteryLevel to 5% increments — `99` rounds to `100`, hiding + # small drains that matter for benchmarking. ideviceinfo's battery domain + # exposes 1% precision when the device is connected via USB. + defp precise_final_read(nil), do: :ok + + defp precise_final_read(hw_udid) when is_binary(hw_udid) do + IO.puts("iOS reports battery in 5% increments. For 1% precision: plug in USB now.") + + IO.puts("Press Enter to read precise battery, or Ctrl-C to skip...") + + case IO.gets("") do + :eof -> + :ok + + {:error, _} -> + :ok + + _ -> + if System.find_executable("ideviceinfo") do + case System.cmd( + "ideviceinfo", + ["-u", hw_udid, "-q", "com.apple.mobile.battery"], + stderr_to_stdout: true + ) do + {out, 0} -> + IO.puts("") + IO.puts("=== Precise battery (via ideviceinfo) ===") + + fields = + out + |> String.split("\n", trim: true) + |> Enum.filter(fn line -> + String.starts_with?(line, [ + "BatteryCurrentCapacity:", + "BatteryMaxCapacity:", + "BatteryIsCharging:", + "ExternalConnected:", + "FullyCharged:" + ]) + end) + + Enum.each(fields, &IO.puts(" " <> &1)) + + {out, _} -> + IO.puts(" (ideviceinfo failed — is USB connected? trust this Mac?)") + IO.puts(" " <> String.trim(out)) + end + else + IO.puts(" (ideviceinfo not installed — `brew install libimobiledevice`)") + end + + IO.puts("") end + end + defp derive_host_from_node(nil), do: nil + + defp derive_host_from_node(node) when is_atom(node) do + case Atom.to_string(node) |> String.split("@", parts: 2) do + [_, host] -> host + _ -> nil + end + end + + # ── Prerequisites ───────────────────────────────────────────────────────────── + + defp check_prerequisites! do unless System.find_executable("xcrun") do Mix.raise("xcrun not found. Install Xcode command-line tools: xcode-select --install") end + + # ideviceinfo is needed for USB battery reads but not strictly required — + # WiFi mode falls back to Erlang distribution RPC. Warn but don't abort. + unless System.find_executable("ideviceinfo") do + IO.puts(""" + Note: ideviceinfo not found (brew install libimobiledevice). + Battery readings will use Erlang distribution over WiFi instead. + """) + end end # ── Dry run ─────────────────────────────────────────────────────────────────── defp dry_run!(opts) do - cfg = MobDev.Config.load_mob_config() - pkg = MobDev.Config.bundle_id() - scheme = opts[:scheme] || cfg[:ios_scheme] || default_scheme() + cfg = MobDev.Config.load_mob_config() + pkg = MobDev.Config.bundle_id() + + scheme = + opts[:scheme] || cfg[:ios_scheme] || + case find_workspace() do + {:ok, {kind, path}} -> detect_scheme!(kind, path) + :error -> Macro.camelize(app_name()) + end + duration = opts[:duration] || 1800 # Validate preset / flags (raises on bad preset name) @@ -305,7 +664,7 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do IO.puts("=== Mob Battery Benchmark (iOS) — Dry Run ===") IO.puts("") IO.puts(" Device: #{opts[:device] || "(auto-detect at run time)"}") - IO.puts(" Bundle: #{pkg || "(NOT SET)"}") + IO.puts(" Bundle: #{pkg}") IO.puts(" Scheme: #{scheme}") IO.puts(" Duration: #{duration}s (#{div(duration, 60)} min)") IO.puts(" Mode: #{describe_mode(opts)}") @@ -320,6 +679,7 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do # ── Build flags ────────────────────────────────────────────────────────────── @doc false + @spec resolve_build_flags(keyword()) :: {String.t(), String.t() | nil} def resolve_build_flags(opts) do cond do opts[:no_beam] -> @@ -328,20 +688,25 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do opts[:flags] -> header_dir = Path.join(System.tmp_dir!(), "mob_bench_flags_#{System.os_time(:second)}") File.mkdir_p!(header_dir) - flags_list = String.split(opts[:flags], ~r/\s+/, trim: true) - c_literals = Enum.map_join(flags_list, ", ", &~s("#{&1}")) - header = "/* generated by mix mob.battery_bench_ios -- do not edit */\n" <> - "#define BEAM_EXTRA_FLAGS #{c_literals},\n" + flags_list = String.split(opts[:flags], Regex.compile!("\\s+"), trim: true) + c_literals = Enum.map_join(flags_list, ", ", &~s("#{&1}")) + + header = + "/* generated by mix mob.battery_bench_ios -- do not edit */\n" <> + "#define BEAM_EXTRA_FLAGS #{c_literals},\n" + File.write!(Path.join(header_dir, "mob_beam_flags.h"), header) {"-DBEAM_USE_CUSTOM_FLAGS -I#{header_dir}", header_dir} opts[:preset] -> - flag = case opts[:preset] do - "untuned" -> "-DBEAM_UNTUNED" - "sbwt" -> "-DBEAM_SBWT_ONLY" - "nerves" -> "-DBEAM_FULL_NERVES" - other -> Mix.raise("Unknown preset #{inspect(other)}. Choose: untuned, sbwt, nerves") - end + flag = + case opts[:preset] do + "untuned" -> "-DBEAM_UNTUNED" + "sbwt" -> "-DBEAM_SBWT_ONLY" + "nerves" -> "-DBEAM_FULL_NERVES" + other -> Mix.raise("Unknown preset #{inspect(other)}. Choose: untuned, sbwt, nerves") + end + {flag, nil} true -> @@ -350,12 +715,13 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do end @doc false + @spec describe_mode(keyword()) :: String.t() def describe_mode(opts) do cond do - opts[:no_beam] -> "no-beam (baseline)" - opts[:flags] -> "custom flags: #{opts[:flags]}" - opts[:preset] -> "preset: #{opts[:preset]}" - true -> "default (Nerves tuning)" + opts[:no_beam] -> "no-beam (baseline)" + opts[:flags] -> "custom flags: #{opts[:flags]}" + opts[:preset] -> "preset: #{opts[:preset]}" + true -> "default (Nerves tuning)" end end @@ -365,21 +731,33 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do ios_dir = Path.join(File.cwd!(), "ios") unless File.dir?(ios_dir), do: Mix.raise("ios/ directory not found in #{File.cwd!()}") - # Prefer .xcworkspace (CocoaPods/SPM), fall back to .xcodeproj + # Prefer .xcworkspace (CocoaPods/SPM), fall back to .xcodeproj. + # Exclude Provision.xcodeproj — that is a mob.provision stub with no BEAM. workspaces = Path.wildcard(Path.join(ios_dir, "*.xcworkspace")) - projects = Path.wildcard(Path.join(ios_dir, "*.xcodeproj")) + + projects = + ios_dir + |> Path.join("*.xcodeproj") + |> Path.wildcard() + |> Enum.reject(&(Path.basename(&1) == "Provision.xcodeproj")) case {workspaces, projects} do - {[ws | _], _} -> {:workspace, ws} - {[], [proj | _]} -> {:project, proj} + {[ws | _], _} -> + {:workspace, ws} + + {[], [proj | _]} -> + {:project, proj} + _ -> - # Mob projects use ios/build.sh (simulator only) rather than an Xcode project. - # Physical device builds require xcodebuild, which needs a .xcodeproj or .xcworkspace. - build_sh = Path.join(ios_dir, "build.sh") - if File.exists?(build_sh) do + # Mob projects use ios/build.zig (mob's Mix-driven build) rather than an + # Xcode project. Physical device battery benchmarks require xcodebuild, + # which needs a .xcodeproj or .xcworkspace. + build_zig = Path.join(ios_dir, "build.zig") + + if File.exists?(build_zig) do Mix.raise(""" - This project uses ios/build.sh (mob's build system), which targets the \ - iOS simulator only. Physical device builds require an Xcode project file. + This project uses ios/build.zig (mob's build system), driven by Mix. \ + Physical device battery benchmarks require an Xcode project file. To run the battery benchmark: 1. Open the project in Xcode, select a physical device, and run once. @@ -396,35 +774,46 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do end defp build_app(kind, path, scheme, other_cflags, derived_data) do - type_flag = case kind do - :workspace -> ["-workspace", path] - :project -> ["-project", path] - end + type_flag = + case kind do + :workspace -> ["-workspace", path] + :project -> ["-project", path] + end # Build settings are passed as positional KEY=VALUE args to xcodebuild. # $(inherited) is processed by xcodebuild itself, not the shell. - cflags_arg = if other_cflags != "", - do: ["OTHER_CFLAGS=$(inherited) #{other_cflags}"], - else: [] - - args = type_flag ++ [ - "-scheme", scheme, - "-configuration", "Debug", - "-sdk", "iphoneos", - "-derivedDataPath", derived_data - ] ++ cflags_arg ++ ["build"] + cflags_arg = + if other_cflags != "", + do: ["OTHER_CFLAGS=$(inherited) #{other_cflags}"], + else: [] + + args = + type_flag ++ + [ + "-scheme", + scheme, + "-configuration", + "Debug", + "-sdk", + "iphoneos", + "-derivedDataPath", + derived_data + ] ++ cflags_arg ++ ["build"] IO.puts(" Running xcodebuild (this may take a while)...") + case System.cmd("xcodebuild", args, stderr_to_stdout: true, into: IO.stream()) do {_, 0} -> :ok {_, _} -> Mix.raise("xcodebuild failed — check output above") end products_dir = Path.join(derived_data, "Build/Products/Debug-iphoneos") + case Path.wildcard(Path.join(products_dir, "*.app")) do [app | _] -> IO.puts(" Built: #{Path.basename(app)}") app + [] -> Mix.raise("Built app not found in #{products_dir}. xcodebuild may have failed silently.") end @@ -432,10 +821,15 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do defp install_app!(udid, app_path) do IO.puts(" Installing #{Path.basename(app_path)}...") - case System.cmd("xcrun", ["devicectl", "device", "install", "app", - "--device", udid, app_path], - stderr_to_stdout: true) do - {_, 0} -> :ok + + case System.cmd( + "xcrun", + ["devicectl", "device", "install", "app", "--device", udid, app_path], + stderr_to_stdout: true + ) do + {_, 0} -> + :ok + {out, _} -> Mix.raise("App install failed: #{String.trim(out)}") end @@ -445,28 +839,55 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do # Returns the process PID (integer) or nil if it couldn't be parsed. defp launch_app!(udid, bundle_id) do - case System.cmd("xcrun", ["devicectl", "device", "launch", "app", - "--terminate-existing", "--device", udid, bundle_id], - stderr_to_stdout: true) do + case System.cmd( + "xcrun", + [ + "devicectl", + "device", + "process", + "launch", + "--terminate-existing", + "--device", + udid, + bundle_id + ], + stderr_to_stdout: true + ) do {out, 0} -> - case Regex.run(~r/\bprocess identifier\s+(\d+)/i, out) || - Regex.run(~r/\bpid[:\s]+(\d+)/i, out) || - Regex.run(~r/\b(\d{4,6})\b/, out) do - [_, pid_str] -> String.to_integer(pid_str) + case Regex.run(Regex.compile!("\\bprocess identifier\\s+(\\d+)", "i"), out) || + Regex.run(Regex.compile!("\\bpid[:\\s]+(\\d+)", "i"), out) || + Regex.run(Regex.compile!("\\b(\\d{4,6})\\b"), out) do + [_, pid_str] -> + String.to_integer(pid_str) + nil -> IO.puts(" App launched (could not parse PID from: #{String.trim(out)})") nil end + {out, _} -> Mix.raise("Failed to launch #{bundle_id}: #{String.trim(out)}") end end defp terminate_app(udid, pid) when is_integer(pid) do - case System.cmd("xcrun", ["devicectl", "device", "process", "terminate", - "--device", udid, "--pid", to_string(pid)], - stderr_to_stdout: true) do - {_, 0} -> :ok + case System.cmd( + "xcrun", + [ + "devicectl", + "device", + "process", + "terminate", + "--device", + udid, + "--pid", + to_string(pid) + ], + stderr_to_stdout: true + ) do + {_, 0} -> + :ok + {out, _} -> # Non-fatal — app may have already exited IO.puts(" terminate warning: #{String.trim(out)}") @@ -475,23 +896,263 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do # ── Screen lock ────────────────────────────────────────────────────────────── - defp lock_screen(udid) do + # Returns true when the screen is locked (either automatically or by user). + defp lock_screen_auto(nil) do + IO.puts(" No hardware UDID — please lock the phone now.") + IO.puts(" Press Enter once the screen is locked.") + IO.gets("") + true + end + + defp lock_screen_auto(udid) do case System.cmd("idevicediagnostics", ["-u", udid, "sleep"], stderr_to_stdout: true) do {_, 0} -> IO.puts(" Screen locked.") :timer.sleep(1000) + true + _ -> - IO.puts(""" - Could not lock screen automatically (idevicediagnostics sleep). - Please lock the phone manually now, then press Enter. - """) + IO.puts(" Auto-lock failed — please lock the phone manually.") + IO.puts(" Press Enter once the screen is locked.") IO.gets("") + true end end # ── Battery readings ───────────────────────────────────────────────────────── + # Establish Erlang distribution to the running app on the device. + # Returns the node atom if connected, nil otherwise. + # + # Retries up to 5 times, sleeping ~2 s between attempts so the device-side + # BEAM has time to start, register in EPMD, and accept connections after + # the app launch. Each attempt logs its outcome (no device / connect false / + # connect ignored) so failures are diagnosable without re-running the bench. + # + # Once a device is discovered, subsequent attempts skip the discovery + # cascade and just retry `Node.connect` against the same node — the + # discovery itself can take 3–5 s on iOS (devicectl + ARP + EPMD scan) + # which would chew through the retry budget if repeated each iteration. + # + # device_id: CoreDevice UUID — used to resolve the phone's IP via + # `xcrun devicectl` so we can query EPMD directly without relying on + # the ARP cache (which may not have the WiFi IP when all prior + # communication was over USB). + @max_connect_attempts 5 + @connect_retry_sleep_ms 2_000 + + # If we connected to a non-loopback iOS device (i.e. a physical iPhone over + # WiFi) without the user passing --wifi-ip, suggest passing it next run. + # Discovery via devicectl + ARP/EPMD scan takes 5–15 s on a cold network; + # `--wifi-ip` skips it. Loopback nodes (simulators, sub-second discovery) + # don't get the hint — there's nothing to skip. + defp hint_wifi_ip(_node, wifi_ip) when is_binary(wifi_ip), do: :ok + + defp hint_wifi_ip(node, _wifi_ip) when is_atom(node) do + case node |> Atom.to_string() |> String.split("@", parts: 2) do + [_, host] when host != "127.0.0.1" and host != "localhost" -> + IO.puts( + " #{IO.ANSI.faint()}tip: pass `--wifi-ip #{host}` next time to skip discovery#{IO.ANSI.reset()}" + ) + + _ -> + :ok + end + end + + # When discovery fails completely and the user didn't pass --wifi-ip, tell + # them the option exists. `mix mob.devices` shows the IP after a successful + # deploy, so the user has a way to learn it; we just need to point at the + # flag. + defp hint_wifi_ip_on_failure(wifi_ip) when is_binary(wifi_ip), do: :ok + + defp hint_wifi_ip_on_failure(_) do + IO.puts( + " #{IO.ANSI.faint()}tip: if you know the iPhone's WiFi IP, pass `--wifi-ip <ip>` to#{IO.ANSI.reset()}" + ) + + IO.puts( + " #{IO.ANSI.faint()} skip discovery entirely. `mix mob.devices` prints it after a deploy.#{IO.ANSI.reset()}" + ) + end + + defp connect_beam_node(device_id, explicit_wifi_ip) do + case Node.start(:"mob_bench@127.0.0.1", :longnames) do + {:ok, _} -> Node.set_cookie(:mob_secret) + {:error, {:already_started, _}} -> :ok + _ -> :ok + end + + # If the user passed --wifi-ip, warm the ARP cache with a quick ping so + # the subsequent EPMD probe doesn't fail on a cold ARP lookup. + if is_binary(explicit_wifi_ip) do + System.cmd("ping", ["-c", "1", "-W", "1000", explicit_wifi_ip], stderr_to_stdout: true) + end + + expected_node_prefix = "#{app_name()}_ios" + + do_connect_attempts(device_id, explicit_wifi_ip, expected_node_prefix, _cache = nil, 1) + end + + defp do_connect_attempts(_device_id, _wifi_ip, _expected_prefix, _cache, attempt) + when attempt > @max_connect_attempts, + do: nil + + defp do_connect_attempts(device_id, wifi_ip, expected_prefix, cache, attempt) do + device = cache || discover_ios_device(device_id, wifi_ip, expected_prefix) + + cond do + is_nil(device) -> + IO.puts( + " attempt #{attempt}/#{@max_connect_attempts}: no device discovered " <> + "(devicectl + ARP + EPMD scan all empty)" + ) + + sleep_unless_last(attempt) + do_connect_attempts(device_id, wifi_ip, expected_prefix, nil, attempt + 1) + + true -> + Node.set_cookie(device.node, :mob_secret) + + case Node.connect(device.node) do + true -> + device.node + + false -> + IO.puts( + " attempt #{attempt}/#{@max_connect_attempts}: found #{device.serial} at " <> + "#{device.host_ip || "?"} (#{device.node}) but Node.connect returned false " <> + "(BEAM not yet ready or cookie mismatch)" + ) + + sleep_unless_last(attempt) + # Keep the device cached — likely the BEAM just isn't up yet. + do_connect_attempts(device_id, wifi_ip, expected_prefix, device, attempt + 1) + + :ignored -> + # Local node isn't alive (couldn't start it) — retrying won't help. + IO.puts( + " attempt #{attempt}/#{@max_connect_attempts}: Node.connect returned :ignored — " <> + "local node never started, aborting retries" + ) + + nil + end + end + end + + defp sleep_unless_last(attempt) do + if attempt < @max_connect_attempts, do: :timer.sleep(@connect_retry_sleep_ms) + end + + # Three-stage discovery cascade. Returns the first %Device{} matching the + # current project's expected node-name prefix (`<app>_ios`) or nil. Stages + # run only as far as needed; ARP/EPMD scan is the slowest so it's last. + # + # The prefix filter matters because `MobDev.Discovery.IOS.list_physical/0` + # scans EPMD and ARP and can return false positives — e.g. an Android phone + # at 10.0.0.17 happens to have a stale `mob_qa_ios_*` EPMD entry tunneled + # via adb-reverse, which the iOS-name regex matches. Without filtering, the + # bench would happily try to connect to that and fail every retry. With the + # filter, we'll only accept nodes whose name actually corresponds to the + # app being benched. + defp discover_ios_device(device_id, explicit_wifi_ip, expected_prefix) do + explicit_match = + if is_binary(explicit_wifi_ip), + do: MobDev.Discovery.IOS.find_physical_at(explicit_wifi_ip) + + devicectl_match = + explicit_match || + with ip when is_binary(ip) <- device_ip_from_devicectl(device_id) do + MobDev.Discovery.IOS.find_physical_at(ip) + end + + devicectl_match || + MobDev.Discovery.IOS.list_physical() + |> Enum.find(fn d -> + d.host_ip && node_matches_prefix?(d.node, expected_prefix) + end) + end + + # Match `<expected_prefix>@<host>` (with optional `_<udid>` segment for + # simulators that disambiguate by booted UDID, e.g. `mob_qa_ios_78354490`). + # `test_nif_ios` therefore accepts `test_nif_ios@10.0.0.120` *and* + # `test_nif_ios_<udid>@127.0.0.1` (sim) but rejects `mob_qa_ios_*@anything`. + @doc false + @spec node_matches_prefix?(node() | nil, String.t()) :: boolean() + def node_matches_prefix?(nil, _prefix), do: false + + def node_matches_prefix?(node, prefix) when is_atom(node) and is_binary(prefix) do + name = Atom.to_string(node) |> String.split("@", parts: 2) |> hd() + name == prefix or String.starts_with?(name, prefix <> "_") + end + + # Returns the device's IP by extracting its mDNS hostname from xcrun devicectl + # output and resolving it. Falls back to nil if devicectl is unavailable or + # the device isn't found. + defp device_ip_from_devicectl(nil), do: nil + + defp device_ip_from_devicectl(device_id) do + tmp = Path.join(System.tmp_dir!(), "mob_bench_devlist_#{System.os_time(:millisecond)}.json") + + try do + case System.cmd("xcrun", ["devicectl", "list", "devices", "--json-output", tmp], + stderr_to_stdout: true + ) do + {_, 0} -> + conn = + tmp + |> File.read!() + |> Jason.decode!() + |> get_in(["result", "devices"]) + |> List.wrap() + |> Enum.find_value(fn dev -> + if dev["identifier"] == device_id, do: dev["connectionProperties"] + end) + + tunnel_ip = conn && conn["tunnelIPAddress"] + # CoreDevice sometimes reports an IPv6 tunnel address (fd7f::/16 range). + # Erlang EPMD doesn't listen on IPv6, so skip those and fall through to + # hostname resolution which gives us the IPv4 WiFi address. + ipv4_tunnel = + if is_binary(tunnel_ip) && not String.contains?(tunnel_ip, ":"), + do: tunnel_ip, + else: nil + + cond do + is_nil(conn) -> + nil + + is_binary(ipv4_tunnel) -> + ipv4_tunnel + + true -> + hostname = + conn["localHostnames"] + |> List.wrap() + |> List.first() + + case hostname && :inet.gethostbyname(String.to_charlist(hostname)) do + {:ok, {:hostent, _, _, :inet, 4, [addr | _]}} -> + addr |> Tuple.to_list() |> Enum.join(".") + + _ -> + nil + end + end + + _ -> + nil + end + rescue + _ -> nil + after + File.rm(tmp) + end + end + # Returns the max design capacity in mAh, or nil if unavailable. + # Only readable via USB (ideviceinfo); WiFi RPC returns percentage only. defp read_max_capacity_mah(udid) do case ideviceinfo(udid, "BatteryMaxCapacity") do {:ok, val} -> @@ -499,44 +1160,88 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do {mah, _} when mah > 0 -> mah _ -> nil end - _ -> nil + + _ -> + nil end end - # Returns %{pct: integer, mah: integer | nil} - defp read_battery(udid, max_mah) do - pct = case ideviceinfo(udid, "CurrentCapacity") do - {:ok, val} -> - case Integer.parse(val) do - {n, _} -> n - :error -> read_battery_pct_fallback(udid) + # Returns integer % or nil. + # Tries USB (ideviceinfo) first; falls back to Erlang RPC (mob_nif:battery_level/0) + # when USB is unavailable — e.g. screen locked with iOS USB restriction, or + # cable unplugged entirely. + defp read_battery_pct(udid, node) do + usb_result = + if System.find_executable("ideviceinfo") do + case ideviceinfo(udid, "BatteryCurrentCapacity") do + {:ok, val} -> + case Integer.parse(val) do + {n, _} when n >= 0 -> n + _ -> nil + end + + {:error, _} -> + nil end - _ -> read_battery_pct_fallback(udid) - end + end - mah = if max_mah, do: round(max_mah * pct / 100), else: nil - %{pct: pct, mah: mah} + usb_result || rpc_battery_level(node) end - # Fallback: try the key without domain qualifier - defp read_battery_pct_fallback(udid) do - case System.cmd("ideviceinfo", ["-u", udid, "-k", "BatteryCurrentCapacity"], - stderr_to_stdout: true) do - {out, 0} -> - case Integer.parse(String.trim(out)) do - {n, _} -> n - :error -> Mix.raise("Could not read battery from device #{udid}. Check device is unlocked and trusted.") - end - {out, _} -> - Mix.raise("ideviceinfo failed for #{udid}: #{String.trim(out)}") + defp rpc_battery_level(nil), do: nil + + defp rpc_battery_level(node) do + # Reconnect if the dist connection dropped (WiFi flap while screen locked). + unless node in Node.list(), do: Node.connect(node) + + case :rpc.call(node, :mob_nif, :battery_level, [], 5000) do + n when is_integer(n) and n >= 0 -> n + _ -> nil + end + end + + # Like read_battery but retries up to max_attempts times (2s apart) before raising. + defp read_battery_required(udid, max_mah, node), + do: read_battery_required(udid, max_mah, node, 1, 5) + + defp read_battery_required(udid, max_mah, node, attempt, max_attempts) do + case read_battery_pct(udid, node) do + nil when attempt < max_attempts -> + :timer.sleep(2000) + read_battery_required(udid, max_mah, node, attempt + 1, max_attempts) + + nil -> + device_str = if udid, do: "device #{udid}", else: "device" + + Mix.raise( + "Could not read battery from #{device_str} after #{attempt} attempts.\n" <> + " USB: no hardware UDID available (idevice_id -l returned nothing).\n" <> + " WiFi: BEAM not reachable — ensure the app is running and on the same network.\n" <> + " Run `mix mob.connect --no-iex` to verify the node is visible." + ) + + pct -> + mah = if max_mah, do: round(max_mah * pct / 100), else: nil + %{pct: pct, mah: mah} end end + defp ideviceinfo(nil, _key), do: {:error, :no_hardware_udid} + defp ideviceinfo(udid, key) do - case System.cmd("ideviceinfo", ["-u", udid, "-q", @battery_domain, "-k", key], - stderr_to_stdout: true) do - {out, 0} -> {:ok, String.trim(out)} - {out, _} -> {:error, String.trim(out)} + case System.find_executable("ideviceinfo") && + System.cmd("ideviceinfo", ["-u", udid, "-q", @battery_domain, "-k", key], + stderr_to_stdout: true + ) do + {out, 0} -> + val = String.trim(out) + if val == "", do: {:error, "empty"}, else: {:ok, val} + + {out, _} when is_binary(out) -> + {:error, String.trim(out)} + + _ -> + {:error, "ideviceinfo not available"} end end @@ -546,36 +1251,178 @@ defmodule Mix.Tasks.Mob.BatteryBenchIos do defp format_battery(%{mah: mah, pct: pct}, _max_mah) when is_integer(mah), do: "#{mah} mAh (#{pct}%)" + defp format_battery(%{pct: pct}, _max_mah), do: "#{pct}%" # ── Device detection ───────────────────────────────────────────────────────── - defp auto_detect_device do - case System.cmd("idevice_id", ["-l"], stderr_to_stdout: true) do + defp auto_detect_usb do + case System.find_executable("idevice_id") && + System.cmd("idevice_id", ["-l"], stderr_to_stdout: true) do {out, 0} -> out |> String.split("\n") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) |> List.first() + + _ -> + nil + end + end + + # Finds the CoreDevice UUID of a connected (including WiFi-paired) iOS device + # via `xcrun devicectl list devices --json-output`. + defp auto_detect_wifi do + tmp = Path.join(System.tmp_dir!(), "mob_devicectl_list_#{System.os_time(:millisecond)}.json") + + try do + case System.cmd("xcrun", ["devicectl", "list", "devices", "--json-output", tmp], + stderr_to_stdout: true + ) do + {_, 0} -> + tmp + |> File.read!() + |> Jason.decode!() + |> get_in(["result", "devices"]) + |> List.wrap() + |> Enum.find_value(fn dev -> + state = + get_in(dev, ["connectionProperties", "tunnelState"]) || + get_in(dev, ["connectionProperties", "transportType"]) + + if state not in [nil, "unavailable"] do + dev["identifier"] + end + end) + + _ -> + nil + end + rescue _ -> nil + after + File.rm(tmp) end end defp device_ok?(udid) do - case System.cmd("ideviceinfo", ["-u", udid, "-k", "DeviceName"], - stderr_to_stdout: true) do - {_, 0} -> true - _ -> false + # Try USB (ideviceinfo), then devicectl (works over WiFi for paired devices) + usb_ok = + System.find_executable("ideviceinfo") && + match?( + {_, 0}, + System.cmd("ideviceinfo", ["-u", udid, "-k", "DeviceName"], stderr_to_stdout: true) + ) + + usb_ok || devicectl_ok?(udid) + end + + defp devicectl_ok?(identifier) do + tmp = Path.join(System.tmp_dir!(), "mob_devicectl_check_#{System.os_time(:millisecond)}.json") + + try do + case System.cmd("xcrun", ["devicectl", "list", "devices", "--json-output", tmp], + stderr_to_stdout: true + ) do + {_, 0} -> + tmp + |> File.read!() + |> Jason.decode!() + |> get_in(["result", "devices"]) + |> List.wrap() + |> Enum.any?(fn dev -> + dev["identifier"] == identifier && + get_in(dev, ["connectionProperties", "tunnelState"]) not in [nil, "unavailable"] + end) + + _ -> + false + end + rescue + _ -> false + after + File.rm(tmp) end end # ── Misc ───────────────────────────────────────────────────────────────────── - defp default_scheme, do: app_name() |> Macro.camelize() - defp app_name, do: Mix.Project.config()[:app] |> to_string() + # Query xcodebuild -list to find the scheme name instead of guessing from the + # Elixir app name — the Xcode project/scheme name often differs from the Mix + # app atom (e.g. project "Provision" scheme "MobProvision" vs app :smoke_test). + defp detect_scheme!(workspace_kind, workspace_path) do + type_flag = + case workspace_kind do + :workspace -> ["-workspace", workspace_path] + :project -> ["-project", workspace_path] + end + case System.cmd("xcodebuild", type_flag ++ ["-list"], stderr_to_stdout: true) do + {output, 0} -> + schemes = + output + |> String.split("\n") + |> Enum.drop_while(&(not String.contains?(&1, "Schemes:"))) + |> Enum.drop(1) + |> Enum.take_while(&String.match?(&1, Regex.compile!("^\\s+\\S"))) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + camelized = Macro.camelize(app_name()) + + case schemes do + [] -> + Mix.raise( + "No schemes found in #{Path.basename(workspace_path)}. " <> + "Use --scheme NAME or set ios_scheme in mob.exs." + ) + + [single] -> + single + + multiple -> + if camelized in multiple do + camelized + else + Mix.raise(""" + Multiple schemes found in #{Path.basename(workspace_path)}: + #{Enum.join(multiple, "\n ")} + + Use --scheme NAME to select one, or add to mob.exs: + config :mob_dev, ios_scheme: "YourSchemeName" + """) + end + end + + {output, _} -> + fallback = Macro.camelize(app_name()) + IO.puts(" (warning: xcodebuild -list failed, assuming scheme \"#{fallback}\")") + IO.puts(" #{String.trim(output)}") + fallback + end + end + + # Non-raising variant used by dry_run! where ios/ may not exist. + defp find_workspace do + ios_dir = Path.join(File.cwd!(), "ios") + + if File.dir?(ios_dir) do + workspaces = Path.wildcard(Path.join(ios_dir, "*.xcworkspace")) + projects = Path.wildcard(Path.join(ios_dir, "*.xcodeproj")) + + case {workspaces, projects} do + {[ws | _], _} -> {:ok, {:workspace, ws}} + {[], [proj | _]} -> {:ok, {:project, proj}} + _ -> :error + end + else + :error + end + end + + defp app_name, do: Mix.Project.config()[:app] |> to_string() defp time_string do {{_y, _mo, _d}, {h, m, s}} = :calendar.local_time() diff --git a/lib/mix/tasks/mob.cache.ex b/lib/mix/tasks/mob.cache.ex new file mode 100644 index 0000000..0d719eb --- /dev/null +++ b/lib/mix/tasks/mob.cache.ex @@ -0,0 +1,295 @@ +defmodule Mix.Tasks.Mob.Cache do + use Mix.Task + + @shortdoc "Show or clear the machine-wide caches Mob writes to" + + @moduledoc """ + Inspects every cache `mix mob.*` writes to outside the project tree, and + (with `--clear`) deletes them. Distinct from `mix clean` (build artifacts + in `_build/`) and `mix deps.clean` (deps in `deps/`) — this targets caches + in your home directory that survive across projects. + + By default the command is read-only — it prints a summary of what's on + disk and exits. Pass `--clear` to wipe Mob's own cache, and add + `--include-transitive` to also wipe caches owned by transitive deps + (currently `elixir_make`, used by `exqlite` for its prebuilt NIF tarball). + + Caches we *do not* touch — even with `--include-transitive` — because + they're shared with the rest of your Elixir/Android/iOS work: + + * `~/.hex/`, `~/.mix/` — Hex/Mix global state + * `~/.gradle/` — Gradle wrapper + caches + * `~/Library/Developer/Xcode/` — Xcode DerivedData, Index + * `~/Library/Caches/com.apple.dt.*` — Xcode-related caches + + Clean those manually if you want a true scorched-earth reset. + + ## Usage + + mix mob.cache # show what's on disk (default) + mix mob.cache --include-transitive # also list elixir_make cache + mix mob.cache --clear # delete Mob's own cache + mix mob.cache --clear --include-transitive # delete Mob's + elixir_make's + mix mob.cache --clear --yes # skip the confirmation prompt + mix mob.cache --dry-run # explicit "list only" (alias for default) + + ## Where the caches live + + **Mob's own cache** — pre-built OTP runtimes (iOS sim, iOS device, Android + arm64, Android arm32). One per platform/ABI; ~200–400 MB each. Reused + across every Mob project on this machine. + + $MOB_CACHE_DIR (if set) + ~/.mob/cache/ (default) + + Override with `MOB_CACHE_DIR` in your shell or `mob.exs` if you want it + somewhere project-local or sandbox-friendly (Nix users: this is the + switch you want). + + **`elixir_make` cache** — pre-built NIF tarballs that `exqlite` and other + NIF-using deps download instead of recompiling from source. The same + tarball is reused across every Elixir project on this machine. + + ~/Library/Caches/elixir_make/ (macOS) + ~/.cache/elixir_make/ (Linux) + + This belongs to `elixir_make`, not Mob — but `mix mob.deploy` is what + populated it, so we offer to clear it here. + """ + + @switches [ + clear: :boolean, + include_transitive: :boolean, + dry_run: :boolean, + yes: :boolean + ] + + @impl Mix.Task + def run(args) do + {opts, _, _} = OptionParser.parse(args, switches: @switches) + + targets = resolve_targets(opts) + + print_header() + print_targets(targets) + + cond do + opts[:clear] != true -> + IO.puts("") + IO.puts("(read-only — pass --clear to delete; add --include-transitive to widen)") + + opts[:dry_run] == true -> + IO.puts("") + IO.puts("(--dry-run: nothing was deleted)") + + opts[:yes] == true or confirm_delete?(targets) -> + delete_targets(targets) + + true -> + IO.puts("") + IO.puts("Aborted.") + end + end + + # ── Target resolution ────────────────────────────────────────────────────── + + defp resolve_targets(opts) do + list = [our_cache()] ++ sim_runtime_targets() + list = if opts[:include_transitive], do: list ++ [elixir_make_cache()], else: list + list + end + + @doc false + @spec our_cache() :: %{name: String.t(), path: String.t(), kind: :ours} + def our_cache do + base = + System.get_env("MOB_CACHE_DIR") || + Path.join([System.user_home!(), ".mob", "cache"]) + + %{ + name: "Mob OTP runtime cache", + path: base, + kind: :ours, + hint: "set MOB_CACHE_DIR to relocate; otherwise lives at ~/.mob/cache" + } + end + + # Return both the new (~/.mob/runtime/ios-sim) and legacy (/tmp/otp-ios-sim) + # iOS simulator runtime locations, plus any MOB_SIM_RUNTIME_DIR override — + # deduplicated. Users can have stale data in either place if they've used + # multiple projects, so list both unconditionally regardless of which one + # the current project would resolve to. + @doc false + @spec sim_runtime_targets() :: [map()] + def sim_runtime_targets do + new_default = MobDev.Paths.default_runtime_dir() + legacy = MobDev.Paths.legacy_tmp_path() + override = System.get_env("MOB_SIM_RUNTIME_DIR") + + [new_default, legacy, override] + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> Enum.map(&sim_runtime_entry/1) + end + + defp sim_runtime_entry(path) do + %{ + name: "iOS simulator runtime (#{label_for_runtime(path)})", + path: path, + kind: :ours, + hint: hint_for_runtime(path) + } + end + + defp label_for_runtime(path) do + cond do + path == MobDev.Paths.default_runtime_dir() -> "current default" + path == MobDev.Paths.legacy_tmp_path() -> "legacy /tmp location" + true -> "MOB_SIM_RUNTIME_DIR override" + end + end + + defp hint_for_runtime(path) do + cond do + path == MobDev.Paths.default_runtime_dir() -> + "writable OTP root for new projects; mob_new ≥ 0.1.20" + + path == MobDev.Paths.legacy_tmp_path() -> + "used by projects whose ios/build.sh predates MOB_SIM_RUNTIME_DIR" + + true -> + "set MOB_SIM_RUNTIME_DIR to override the default" + end + end + + @doc false + @spec elixir_make_cache() :: %{name: String.t(), path: String.t(), kind: :transitive} + def elixir_make_cache do + %{ + name: "elixir_make precompiled-NIF cache", + path: elixir_make_cache_path(), + kind: :transitive, + hint: "owned by elixir_make (used by exqlite); reused across all Elixir projects" + } + end + + @doc false + @spec elixir_make_cache_path() :: String.t() + def elixir_make_cache_path do + case :os.type() do + {:unix, :darwin} -> + Path.join([System.user_home!(), "Library", "Caches", "elixir_make"]) + + _ -> + Path.join([System.user_home!(), ".cache", "elixir_make"]) + end + end + + # ── Reporting ────────────────────────────────────────────────────────────── + + defp print_header do + IO.puts("") + IO.puts("Mob caches on this machine:") + IO.puts("") + end + + defp print_targets(targets) do + Enum.each(targets, fn t -> + {exists?, size_str} = path_status(t.path) + + status_icon = + case {exists?, t.kind} do + {true, :ours} -> IO.ANSI.cyan() <> "●" <> IO.ANSI.reset() + {true, :transitive} -> IO.ANSI.yellow() <> "●" <> IO.ANSI.reset() + {false, _} -> IO.ANSI.faint() <> "○" <> IO.ANSI.reset() + end + + IO.puts(" #{status_icon} #{t.name}") + IO.puts(" path: #{t.path}") + IO.puts(" size: #{size_str}") + IO.puts(" note: #{t.hint}") + IO.puts("") + end) + end + + @doc false + @spec path_status(String.t()) :: {boolean(), String.t()} + def path_status(path) do + cond do + not File.exists?(path) -> + {false, "(not present)"} + + File.dir?(path) -> + {true, format_size(dir_size(path))} + + true -> + case File.stat(path) do + {:ok, %File.Stat{size: s}} -> {true, format_size(s)} + _ -> {true, "(unknown)"} + end + end + end + + defp dir_size(dir) do + Path.wildcard(Path.join(dir, "**/*"), match_dot: true) + |> Enum.reduce(0, fn p, acc -> + case File.stat(p) do + {:ok, %File.Stat{type: :regular, size: s}} -> acc + s + _ -> acc + end + end) + end + + @doc false + @spec format_size(non_neg_integer()) :: String.t() + def format_size(bytes) when bytes < 1024, do: "#{bytes} B" + + def format_size(bytes) when bytes < 1024 * 1024 do + :io_lib.format("~.1f KB", [bytes / 1024]) |> IO.iodata_to_binary() + end + + def format_size(bytes) when bytes < 1024 * 1024 * 1024 do + :io_lib.format("~.1f MB", [bytes / (1024 * 1024)]) |> IO.iodata_to_binary() + end + + def format_size(bytes) do + :io_lib.format("~.2f GB", [bytes / (1024 * 1024 * 1024)]) |> IO.iodata_to_binary() + end + + # ── Deletion ─────────────────────────────────────────────────────────────── + + defp confirm_delete?(targets) do + paths_to_delete = Enum.filter(targets, &File.exists?(&1.path)) + + if paths_to_delete == [] do + IO.puts("Nothing to delete — all listed caches are already absent.") + false + else + IO.puts("") + IO.write("Delete the paths above? [y/N] ") + + case IO.gets("") do + :eof -> false + input -> String.trim(input) |> String.downcase() == "y" + end + end + end + + defp delete_targets(targets) do + Enum.each(targets, fn t -> + if File.exists?(t.path) do + case File.rm_rf(t.path) do + {:ok, _} -> + IO.puts(" #{IO.ANSI.green()}✓ deleted#{IO.ANSI.reset()} #{t.path}") + + {:error, reason, file} -> + IO.puts( + " #{IO.ANSI.red()}✗ failed#{IO.ANSI.reset()} #{t.path} — #{inspect(reason)} (#{file})" + ) + end + end + end) + + IO.puts("") + end +end diff --git a/lib/mix/tasks/mob.connect.ex b/lib/mix/tasks/mob.connect.ex index 478245d..49a6106 100644 --- a/lib/mix/tasks/mob.connect.ex +++ b/lib/mix/tasks/mob.connect.ex @@ -15,6 +15,19 @@ defmodule Mix.Tasks.Mob.Connect do * `--no-iex` — set up connections but don't start IEx (print node names instead) * `--name` — local node name for this session (default: `mob_dev@127.0.0.1`) * `--cookie` — Erlang cookie (default: `mob_secret`) + * `--ios-only` / `--android-only` — restrict discovery to one platform. iOS-only + development on a Mac with no Android platform-tools installed works without + this flag (adb's absence is handled gracefully), but `--ios-only` skips the + Android scan entirely — useful when a phone for another project is plugged in. + To make it the default for a project, set it once in `mob.exs`: + + config :mob_dev, platforms: [:ios] + * `--only` / `--device` (`-d`) — restrict to devices whose serial/udid contains + the given substring. Repeatable. Without it, connect attaches to *every* + running device, so a single slow or locked device (e.g. a plugged-in + physical iPhone) can stall the whole run. Target one phone with: + + mix mob.connect --only ZY22CRLMWK ## Multiple simultaneous sessions @@ -43,6 +56,46 @@ defmodule Mix.Tasks.Mob.Connect do This is the recommended setup when working alongside an agent — the agent uses Tidewave to execute `Mob.Test.*` calls in the same running session. + ## iOS physical device connectivity + + Physical iPhones support three connection modes. The BEAM picks the right one + automatically at startup based on which network interfaces are present: + + | Priority | Connection | Node name | When | + |----------|------------|-----------|------| + | 1 | WiFi / LAN | `<app>_ios@10.0.0.x` | On the same network as the Mac | + | 1 | Tailscale | `<app>_ios@100.x.x.x` | Any network — see below | + | 2 | USB only | `<app>_ios@169.254.x.x` | Cable plugged in, no WiFi | + | 3 | None | `<app>_ios@127.0.0.1` | No network | + + WiFi is preferred over USB so the node IP stays stable across cable plug/unplug. + Plugging or unplugging the USB cable does not change the node name as long as + WiFi is available. The node only falls back to the USB link-local address when + there is no WiFi at all. + + **The node name is still fixed at app launch.** If distribution isn't working, + force-quit the app on the iPhone and relaunch it so it picks up the current + network state. + + **USB** is the default and works with no setup. Plug in the cable and run + `mix mob.connect`. + + **WiFi** works automatically when the Mac and iPhone are on the same network. If + it doesn't connect, check: was the app last launched with USB plugged in? If so, + force-quit and relaunch the app (without USB), then run `mix mob.connect` again. + Public WiFi and corporate networks often block device-to-device traffic (client + isolation) — use Tailscale in those environments. + + **Tailscale** lets you connect over any network including cellular. It is a free + mesh VPN (free for personal use at tailscale.com). Install it on both the Mac + and iPhone, sign in to the same account, and `mix mob.connect` works the same + way regardless of what network either device is on. Tailscale must be active on + the iPhone before the app launches — the node name is fixed at BEAM startup. + + **Personal Hotspot** (iPhone sharing its cellular connection as WiFi) also works + automatically — the Mac connects to the hotspot and the LAN detection picks up + the `172.20.10.x` address. + ## Under the hood `mix mob.connect` is a convenience wrapper around standard Erlang distribution setup: @@ -53,6 +106,9 @@ defmodule Mix.Tasks.Mob.Connect do # iOS simulator shares the Mac's network stack — no tunnelling needed + # iOS physical: BEAM registers its own in-process EPMD on the device; + # Mac connects directly to the device IP (USB link-local, WiFi, or Tailscale) + # Then, in Elixir: Node.start(:"mob_dev@127.0.0.1", :longnames) Node.set_cookie(:mob_secret) @@ -66,18 +122,41 @@ defmodule Mix.Tasks.Mob.Connect do @impl Mix.Task def run(args) do - {opts, _, _} = OptionParser.parse(args, - switches: [iex: :boolean, cookie: :string, name: :string], - aliases: [c: :cookie, n: :name] - ) + {opts, _, _} = + OptionParser.parse(args, + switches: [ + iex: :boolean, + cookie: :string, + name: :string, + only: :keep, + device: :keep, + ios_only: :boolean, + android_only: :boolean + ], + aliases: [c: :cookie, n: :name, d: :device] + ) no_iex = Keyword.get(opts, :iex, true) == false cookie = opts |> Keyword.get(:cookie, "mob_secret") |> String.to_atom() local_name = opts |> Keyword.get(:name, "mob_dev@127.0.0.1") |> String.to_atom() + # --only / --device (repeatable) restrict to matching serials/udids. Without + # it, connect attaches to every running device — handy for a cluster, but a + # slow or locked device (e.g. a physical iPhone) can stall the whole run. + only = Keyword.get_values(opts, :only) ++ Keyword.get_values(opts, :device) Mix.Task.run("app.config") - {connected, _failed} = MobDev.Connector.connect_all(cookie: cookie) + # Platform filter: --ios-only / --android-only override the mob.exs default + # (`config :mob_dev, platforms: [...]`). An iOS-only Mac with no adb skips + # Android discovery entirely rather than crashing on the missing binary. + platforms = + case resolve_platforms(opts, MobDev.Config.platforms()) do + {:ok, platforms} -> platforms + {:error, message} -> Mix.raise(message) + end + + {connected, _failed} = + MobDev.Connector.connect_all(cookie: cookie, only: only, platforms: platforms) if connected == [] do IO.puts("\n#{IO.ANSI.yellow()}No nodes connected. Nothing to do.#{IO.ANSI.reset()}\n") @@ -92,8 +171,41 @@ defmodule Mix.Tasks.Mob.Connect do end end + @doc """ + Resolves which platforms to discover from the parsed options and the + `mob.exs` default. + + `--ios-only` / `--android-only` win over the default; passing both is a + contradiction and returns `{:error, _}`. With neither flag, the `mob.exs` + default (`config :mob_dev, platforms: [...]`, both platforms when unset) is + used. Pure — exposed for testing. + """ + @spec resolve_platforms(keyword(), [:android | :ios]) :: + {:ok, [:android | :ios]} | {:error, String.t()} + def resolve_platforms(opts, default) do + ios_only = Keyword.get(opts, :ios_only, false) + android_only = Keyword.get(opts, :android_only, false) + + cond do + ios_only and android_only -> + {:error, "Cannot combine --ios-only and --android-only."} + + ios_only -> + {:ok, [:ios]} + + android_only -> + {:ok, [:android]} + + true -> + {:ok, default} + end + end + defp start_iex(connected, cookie, local_name) do - IO.puts("\n#{IO.ANSI.cyan()}Starting IEx (connected to #{length(connected)} device(s))...#{IO.ANSI.reset()}") + IO.puts( + "\n#{IO.ANSI.cyan()}Starting IEx (connected to #{length(connected)} device(s))...#{IO.ANSI.reset()}" + ) + IO.puts(" Node.list() — see connected nodes") IO.puts(" nl(MyModule) — hot-push code to all nodes") IO.puts("") @@ -110,6 +222,21 @@ defmodule Mix.Tasks.Mob.Connect do end) # Hand off to IEx in this process — tunnels stay alive via adb daemon. - IEx.start() + ensure_iex_started() + # IEx.Server.run/1 is the 1.20 programmatic-start surface (IEx.start/0 + # was removed in 1.20-rc.4); ensure_iex_started/0 above is the actual fix. + IEx.Server.run([]) + end + + @doc false + @spec ensure_iex_started() :: :ok + def ensure_iex_started do + case Application.ensure_all_started(:iex) do + {:ok, _apps} -> + :ok + + {:error, {app, reason}} -> + Mix.raise("Failed to start #{inspect(app)} before launching IEx: #{inspect(reason)}") + end end end diff --git a/lib/mix/tasks/mob.deploy.ex b/lib/mix/tasks/mob.deploy.ex index 15460c6..d43d007 100644 --- a/lib/mix/tasks/mob.deploy.ex +++ b/lib/mix/tasks/mob.deploy.ex @@ -1,7 +1,12 @@ defmodule Mix.Tasks.Mob.Deploy do use Mix.Task + alias MobDev.{AndroidDeployRecoveryProof, Device} + @shortdoc "Build and deploy to all connected mob devices" + @native_android_success_statuses [:discovered, :connected, :tunneled] + @zigler_staging_env "ZIGLER_STAGING_ROOT" + @zigler_staging_dir "zigler-staging" @moduledoc """ Compiles the project then pushes BEAM files to all connected @@ -14,15 +19,87 @@ defmodule Mix.Tasks.Mob.Deploy do mix mob.deploy - **Full deploy** — build native binary + install APK/app + push BEAMs. - Use this the first time, or after changes to native C/Java/Swift code. + **Full deploy** — build native binary + update APK/app + push BEAMs. + Use this after changes to native C/Java/Swift code. Android native updates + are update-only: every target must already have the exact configured package + installed. The task resolves a non-empty connected-device set, uses only + serial-scoped `adb install -r`, and never uninstalls or clears the existing + app, so a signing mismatch or downgrade fails while preserving app data. The + validated serial snapshot also scopes the final BEAM push, even if device + discovery changes mid-deploy. mix mob.deploy --native ## Options - * `--native` — build native binaries before pushing BEAMs - * `--no-restart` — push BEAMs but don't restart the app + * `--native` — build native binaries before pushing BEAMs + * `--resume-native-ready` — recover one stale, fully proven Android native-ready + lease and continue its exact payload to final commit + * `--no-restart` — push BEAMs but don't restart the app (fast deploy + only; native Android requires a checked restart) + * `--device <id>` — target a specific device; use `mix mob.devices` to find IDs + * `--dist-port <N>` — pin the BEAM dist listen port (default: auto-allocated per + device, `9100 + index`). Use to resolve EPMD collisions when + multiple sims/emulators are running the same app concurrently + and the auto-allocated ports aren't what you want. + * `--node-suffix <S>` — append `_<S>` to the BEAM node name (default: auto-derived + from device serial on Android, SIMULATOR_UDID on iOS sim). Use + for scripted scenarios where you need a specific naming scheme. + * `--schedulers <N>` — set BEAM scheduler count (saved to mob.exs) + * `--beam-flags "<flags>"` — arbitrary BEAM flags string (saved to mob.exs) + * `--slim` — strip OTP source/debug for size measurement on + a real device. OFF by default for dev iteration + (the strip pass adds ~5-10s per build); use this + to verify a slim build runs before + `mix mob.republish` round-trips through TestFlight. + The strip set is controlled by `MobDev.OtpAudit.Slim`; + per-app overrides live in `mob.exs`: + + config :mob_dev, + slim: [ + drop_libs: ["my_unused_dep"], + keep_libs: ["mnesia"], + audit: true, # opt in + # Single capture (a starting point): + trace_json: "priv/mob_trace.json", + # OR multiple captures unioned — + # much safer for production + # stripping. A lib is trace- + # strippable only if NONE of the + # captures observed any of its + # modules. + trace_jsons: [ + "priv/boot.json", + "priv/ui.json", + "priv/auth.json" + ] + ] + + With `audit: true`, the slim pass runs + `MobDev.OtpAudit` against the bundle and + expands the strip set with foreign apps + + (when a trace is supplied) the + trace-augmented strip set. Trace JSON + comes from `mix mob.trace_otp --json`. + + ## BEAM scheduler tuning + + The default native build uses `1:1` (single scheduler) for battery efficiency. + Override for the current deploy and all future deploys until changed: + + # Pin to 2 schedulers + mix mob.deploy --schedulers 2 + + # Let BEAM auto-detect — one scheduler per logical core + mix mob.deploy --schedulers 0 + + # Arbitrary flags (replaces --schedulers) + mix mob.deploy --beam-flags "-S 4:4 -A 4" + + The chosen value is written to `mob.exs` under `beam_flags:` and reused on + subsequent `mix mob.deploy` runs that don't pass either flag. The flags are + written alongside the BEAMs as a `mob_beam_flags` file that the native launcher + reads at startup — no APK/app rebuild required. ## Under the hood @@ -55,79 +132,1669 @@ defmodule Mix.Tasks.Mob.Deploy do xcrun simctl install booted <app>.app """ - @switches [native: :boolean, restart: :boolean, android: :boolean, ios: :boolean] + @switches [ + native: :boolean, + restart: :boolean, + android: :boolean, + ios: :boolean, + device: :string, + schedulers: :integer, + beam_flags: :string, + # Manual overrides for the BEAM-distribution surface — useful when + # the auto-allocated per-device dist port (`Tunnel.dist_port(idx)`) + # or auto-derived node-name suffix (`Discovery.Android.device_node_suffix` + # / SIMULATOR_UDID-derived) collides with another locally-running + # device, or when scripting a specific naming scheme. + # + # When set, ALL targeted devices share the same value (so use with + # `--device` to be explicit about which one you mean). Auto-allocation + # only kicks in when neither flag is set. + dist_port: :integer, + node_suffix: :string, + # Slim build (drops src/include + .beam debug chunks + Apple-policy strips). + # On by default for both dev and release. Pass `--no-slim` to keep the + # full OTP runtime in the bundle — useful if you need debug info on + # device, or to isolate a strip-induced regression during diagnosis. + slim: :boolean, + resume_native_ready: :boolean + ] @impl Mix.Task - def run(args) do + def run(args), do: run(args, []) + + @doc false + @spec run([String.t()], keyword()) :: term() + def run(args, callbacks) do + validate_literal_recovery_request!(args) {opts, _, _} = OptionParser.parse(args, switches: @switches) + opts = normalize_negative_switches(opts, args) - restart = Keyword.get(opts, :restart, true) - native = Keyword.get(opts, :native, false) + device_id = opts[:device] platforms = resolve_platforms(opts) + validate_recovery_request!(opts, platforms, device_id) + + # Narrow once at the task level so build_all and deploy_all both see the + # same platform list. Without this, the deployer iterates over the + # irrelevant platform and `filter_by_device_id` emits a misleading + # "No device matched" warning even when the targeted platform succeeded. + android_lister = + Keyword.get(callbacks, :android_lister, &MobDev.Discovery.Android.list_devices/0) + + ios_lister = Keyword.get(callbacks, :ios_lister, &MobDev.Discovery.IOS.list_devices/0) + + platforms = + resolve_target_platforms!( + platforms, + device_id, + android_lister, + ios_lister + ) + + orchestrator = Keyword.get(callbacks, :orchestrator, &orchestrate_deploy/3) + orchestrator.(opts, platforms, device_id) + end + + # Elixir 1.19's permissive `switches:` parser does not consistently retain + # boolean negations. Recovery safety cannot depend on the host toolchain's + # OptionParser minor-version behavior. + defp normalize_negative_switches(opts, args) do + if "--no-restart" in args, do: Keyword.put(opts, :restart, false), else: opts + end + + defp validate_literal_recovery_request!(args) do + if "--resume-native-ready" in args and + ("--native" not in args or "--android" not in args or "--ios" in args or + "--no-restart" in args or not literal_device_selector?(args)) do + Mix.raise( + "--resume-native-ready requires --native --android --device <exact-id> and restart" + ) + end + end + + defp literal_device_selector?(["--device", value | _rest]), + do: is_binary(value) and value != "" and not String.starts_with?(value, "-") + + defp literal_device_selector?(["--device=" <> value | _rest]), do: value != "" + defp literal_device_selector?([_arg | rest]), do: literal_device_selector?(rest) + defp literal_device_selector?([]), do: false + + defp orchestrate_deploy(opts, platforms, device_id) do + restart = Keyword.get(opts, :restart, true) + native = Keyword.get(opts, :native, false) + resume_native_ready = Keyword.get(opts, :resume_native_ready, false) + beam_flags = resolve_beam_flags(opts) + + if native and not restart and :android in platforms do + Mix.raise("Native Android deploy requires an authoritative restart; remove --no-restart") + end + + # When no --device is given and we're doing a native iOS build, auto-detect + # a connected physical device now so both the native build and the BEAM push + # target the same device (not all simulators + the phone). + effective_device_id = + device_id || + if native and :ios in platforms, + do: MobDev.NativeBuild.detect_physical_ios() + + # Validate every targeted device against the project's enabled + # features (Pythonx, etc.) BEFORE we waste time on a multi-minute + # native build that the device couldn't have run anyway. See + # `MobDev.SupportMatrix` for the per-feature requirements and why + # silent failures here are particularly costly for users on older + # / cheaper hardware. + # + # `MOB_FORCE_DEPLOY=1` bypasses for the trust-but-verify case + # ("I know my device is below the floor; show me what actually + # breaks"). The Moto e empirical run that uncovered the corrected + # `:base` armv7 floor used this — the SupportMatrix message is + # only as good as the data it's based on, and an escape hatch is + # how we keep that data honest. + if System.get_env("MOB_FORCE_DEPLOY") in [nil, ""] do + validate_device_compatibility!(platforms, effective_device_id) + else + IO.puts( + " #{IO.ANSI.yellow()}MOB_FORCE_DEPLOY set — skipping device compatibility check#{IO.ANSI.reset()}" + ) + end IO.puts("") if native do - IO.puts("Fetching dependencies...") - mix = System.find_executable("mix") - System.cmd(mix, ["deps.get"], into: IO.stream()) + fetch_native_dependencies!() end - Mix.Task.run("compile") - IO.puts("\n#{IO.ANSI.cyan()}Deploying to devices...#{IO.ANSI.reset()}\n") + operation = fn -> + with_zigler_staging(native, fn -> + IO.puts("\n#{IO.ANSI.cyan()}Deploying to devices...#{IO.ANSI.reset()}\n") - native_ok = if native do - MobDev.NativeBuild.build_all(platforms: platforms) + # Default OFF for dev iteration: slim adds the strip pass + erl spawn + # for beam_lib:strip_release + xcrun strip, which costs seconds. Dev + # cycle wants those seconds back. Opt in with `--slim` when you want + # to size-test before mix mob.republish round-trips through TestFlight + # (and the inevitable extra TestFlight build that confuses testers). + slim = Keyword.get(opts, :slim, false) + + deploy_opts = + [ + restart: restart, + platforms: platforms, + force_fs: native, + device: device_id, + ios_device: effective_device_id, + beam_flags: beam_flags, + # nil → auto-allocation (per-device port + auto-derived suffix). + # Set → all targeted devices use these values verbatim. + dist_port: opts[:dist_port], + node_suffix: opts[:node_suffix] + ] + + deploy_result = + if native do + native_opts = [ + slim: slim, + resume_native_ready: resume_native_ready, + android_preinstall: fn native_context -> + MobDev.Deployer.prepare_android_payload(native_context, + restart: restart, + beam_flags: beam_flags, + dist_port: opts[:dist_port], + node_suffix: opts[:node_suffix] + ) + end, + android_preinstall_cleanup: &MobDev.Deployer.cleanup_android_payload/1 + ] + + execute_native_deploy!( + platforms, + device_id, + effective_device_id, + native_opts, + deploy_opts + ) + else + deploy_after_native_build!(false, nil, deploy_opts) + end + + report_deploy_result!(deploy_result, restart: restart) + end) end - {deployed, failed} = MobDev.Deployer.deploy_all(restart: restart, platforms: platforms, force_fs: native) + with_android_native_host_lock(native, platforms, operation) + end + + @doc false + @spec with_android_native_host_lock(boolean(), [:android | :ios], (-> term())) :: term() + def with_android_native_host_lock(native, platforms, operation) + when is_boolean(native) and is_list(platforms) and is_function(operation, 0) do + if native and :android in platforms do + case AndroidDeployRecoveryProof.with_host_lock(MobDev.Config.bundle_id(), operation) do + {:error, :recovery_host_lock_unavailable} -> + Mix.raise("Android native deploy host lock is unavailable") - if deployed == [] and failed == [] do - IO.puts("#{IO.ANSI.yellow()}No devices found.#{IO.ANSI.reset()}") - IO.puts("Try: mix mob.devices to diagnose connection issues") + result -> + result + end else - if deployed != [] do - IO.puts("\n#{IO.ANSI.green()}Deployed to #{length(deployed)} device(s)#{IO.ANSI.reset()}") - if restart do - IO.puts("Apps restarted. Run #{IO.ANSI.cyan()}mix mob.connect#{IO.ANSI.reset()} to open IEx.") + operation.() + end + end + + @doc false + @spec validate_recovery_request!(keyword(), [:android | :ios], String.t() | nil) :: :ok + def validate_recovery_request!(opts, platforms, device_id) do + if Keyword.get(opts, :resume_native_ready, false) and + (Keyword.get(opts, :native, false) != true or platforms != [:android] or + not is_binary(device_id) or Keyword.get(opts, :restart, true) != true) do + Mix.raise( + "--resume-native-ready requires --native --android --device <exact-id> and restart" + ) + end + + :ok + end + + @doc false + @spec resolve_target_platforms!( + [:android | :ios], + String.t() | nil, + (-> [Device.t()]), + (-> [Device.t()]) + ) :: [:android | :ios] + def resolve_target_platforms!(platforms, nil, _android_lister, _ios_lister), do: platforms + + def resolve_target_platforms!(platforms, device_id, android_lister, ios_lister) + when is_binary(device_id) and is_function(android_lister, 0) and + is_function(ios_lister, 0) do + android_devices = android_lister.() + ios_devices = ios_lister.() + + matches = + matching_inventory_devices(android_devices, :android, device_id) ++ + matching_inventory_devices(ios_devices, :ios, device_id) + + case matches do + [%Device{platform: matched_platform}] -> + if matched_platform in platforms do + [matched_platform] else - IO.puts("BEAMs pushed. In IEx: #{IO.ANSI.cyan()}nl(MyModule)#{IO.ANSI.reset()} to hot-load.") + raise_no_device_match!(device_id) end + + _unmatched_or_ambiguous -> + raise_no_device_match!(device_id) + end + end + + defp matching_inventory_devices(devices, platform, device_id) when is_list(devices) do + Enum.filter(devices, fn + %Device{platform: ^platform, serial: serial} = device when is_binary(serial) -> + device_matches_selector?(device, device_id) + + _invalid_or_other_platform -> + false + end) + end + + defp matching_inventory_devices(_invalid_inventory, _platform, _device_id) do + [] + end + + defp device_matches_selector?(%Device{platform: :android, serial: serial} = device, device_id) do + Device.match_id?(device, device_id) or + serial == "#{device_id}:5555" or + android_serial_host(serial) == device_id + end + + defp device_matches_selector?(%Device{} = device, device_id) do + Device.match_id?(device, device_id) + end + + defp android_serial_host(serial) do + case String.split(serial, ":", parts: 2) do + [host, _port] -> host + _serial_without_port -> serial + end + end + + defp raise_no_device_match!(device_id) do + Mix.raise( + ~s(No device matched "#{device_id}". Run `mix mob.devices` to see available device IDs.) + ) + end + + @doc false + @spec with_zigler_staging(boolean(), (-> term()), keyword()) :: term() + def with_zigler_staging(native?, operation, opts \\ []) + + def with_zigler_staging(false, operation, opts) when is_function(operation, 0) do + compiler = Keyword.get(opts, :compiler, &Mix.Task.run/2) + compiler.("compile", []) + operation.() + end + + def with_zigler_staging(true, operation, opts) when is_function(operation, 0) do + compiler = Keyword.get(opts, :compiler, &Mix.Task.run/2) + previous_staging_root = System.fetch_env(@zigler_staging_env) + staging_root = zigler_staging_root(previous_staging_root, opts) + + File.mkdir_p!(staging_root) + System.put_env(@zigler_staging_env, staging_root) + + try do + compiler.("compile", ["--force"]) + operation.() + after + restore_zigler_staging_root(previous_staging_root) + end + end + + defp zigler_staging_root({:ok, staging_root}, _opts) when staging_root != "", + do: staging_root + + defp zigler_staging_root(_previous_staging_root, opts) do + build_path = Keyword.get_lazy(opts, :build_path, &Mix.Project.build_path/0) + Path.join(build_path, @zigler_staging_dir) + end + + defp restore_zigler_staging_root({:ok, staging_root}), + do: System.put_env(@zigler_staging_env, staging_root) + + defp restore_zigler_staging_root(:error), do: System.delete_env(@zigler_staging_env) + + @doc false + @spec execute_native_deploy!( + [:android | :ios], + String.t() | nil, + String.t() | nil, + keyword(), + keyword(), + keyword() + ) :: {[Device.t()], [Device.t()], [Device.t()]} + def execute_native_deploy!( + platforms, + android_device_id, + ios_device_id, + native_opts, + deploy_opts, + callbacks \\ [] + ) do + builder = Keyword.get(callbacks, :builder, &MobDev.NativeBuild.build_all_with_outcome/1) + deployer = Keyword.get(callbacks, :deployer, &MobDev.Deployer.deploy_all_with_lease/1) + + finalizer = + Keyword.get(callbacks, :finalizer, &MobDev.NativeBuild.release_android_deploy_lock/1) + + cleanup = Keyword.get(callbacks, :cleanup, &MobDev.Deployer.cleanup_android_payload/1) + + valid? = + valid_native_platforms?(platforms) and is_list(native_opts) and + Keyword.keyword?(native_opts) and is_list(deploy_opts) and Keyword.keyword?(deploy_opts) and + valid_optional_device_id?(android_device_id) and valid_optional_device_id?(ios_device_id) and + is_function(builder, 1) and is_function(deployer, 1) and is_function(finalizer, 1) and + is_function(cleanup, 1) + + if valid? do + execute_native_platforms!( + platforms, + android_device_id, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) + else + raise_native_build_failed!() + end + end + + defp execute_native_platforms!( + platforms, + android_device_id, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) do + cond do + :android in platforms and :ios in platforms -> + execute_mixed_native_platforms!( + android_device_id, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) + + :android in platforms -> + run_native_platform!( + :android, + android_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) + + true -> + run_native_platform!( + :ios, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) + end + end + + defp execute_mixed_native_platforms!( + android_device_id, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) do + android_build_opts = native_platform_build_opts(native_opts, :android, android_device_id) + android_outcome = builder.(android_build_opts) + + case android_outcome do + %{ + ok?: false, + android_device_disposition: :not_attempted, + android_serials: [], + android_deploy_lock: nil, + android_payload_plan: nil + } = not_attempted + when map_size(not_attempted) == 5 -> + run_native_platform!( + :ios, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) + + _attempted_or_malformed -> + android_result = + deploy_native_platform_outcome!( + :android, + android_device_id, + android_outcome, + deploy_opts, + deployer, + finalizer, + cleanup + ) + + case android_result do + {_deployed, [], _skipped} -> + ios_result = + run_native_platform!( + :ios, + ios_device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) + + merge_deploy_results([android_result, ios_result]) + + _android_failed -> + android_result + end + end + end + + defp run_native_platform!( + :ios, + device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) do + ios_opts = + deploy_opts + |> Keyword.put(:platforms, [:ios]) + |> Keyword.put(:ios_device, device_id) + + case freeze_remaining_ios_target(ios_opts, [:ios]) do + {:ok, frozen_deploy_opts, selected} -> + outcome = builder.(native_platform_build_opts(native_opts, :ios, selected.serial)) + + deploy_native_platform_outcome!( + :ios, + selected.serial, + outcome, + frozen_deploy_opts, + deployer, + finalizer, + cleanup + ) + + {:error, _reason} -> + raise_native_build_failed!() + end + end + + defp run_native_platform!( + platform, + device_id, + native_opts, + deploy_opts, + builder, + deployer, + finalizer, + cleanup + ) do + outcome = builder.(native_platform_build_opts(native_opts, platform, device_id)) + + deploy_native_platform_outcome!( + platform, + device_id, + outcome, + deploy_opts, + deployer, + finalizer, + cleanup + ) + end + + defp native_platform_build_opts(native_opts, platform, device_id) do + native_opts + |> Keyword.put(:platforms, [platform]) + |> Keyword.put(:device, device_id) + |> Keyword.put(:android_device_phase, platform == :android) + |> maybe_drop_android_callbacks(platform) + end + + defp deploy_native_platform_outcome!( + platform, + device_id, + outcome, + deploy_opts, + deployer, + finalizer, + cleanup + ) do + platform_deploy_opts = + deploy_opts + |> Keyword.put(:platforms, [platform]) + |> Keyword.delete(:canonical_android_serials) + |> Keyword.delete(:android_deploy_lock) + |> Keyword.delete(:android_payload_plan) + |> platform_device_opts(platform, device_id) + + deploy_after_native_build!( + true, + outcome, + platform_deploy_opts, + deployer, + finalizer, + cleanup + ) + end + + defp maybe_drop_android_callbacks(opts, :android), do: opts + + defp maybe_drop_android_callbacks(opts, :ios) do + opts + |> Keyword.delete(:android_preinstall) + |> Keyword.delete(:android_preinstall_cleanup) + end + + defp platform_device_opts(opts, :android, device_id) do + opts + |> Keyword.put(:device, device_id) + |> Keyword.delete(:ios_device) + end + + defp platform_device_opts(opts, :ios, device_id) do + opts + |> Keyword.put(:device, nil) + |> Keyword.put(:ios_device, device_id) + end + + defp valid_optional_device_id?(nil), do: true + + defp valid_optional_device_id?(device_id) when is_binary(device_id), + do: byte_size(device_id) in 1..256 and String.valid?(device_id) + + defp valid_optional_device_id?(_device_id), do: false + + @doc false + @spec deploy_after_native_build!( + boolean(), + MobDev.NativeBuild.build_outcome() | nil, + keyword() + ) :: + {[Device.t()], [Device.t()], [Device.t()]} + def deploy_after_native_build!(true, native_outcome, deploy_opts) do + deploy_after_native_build!( + true, + native_outcome, + deploy_opts, + &MobDev.Deployer.deploy_all_with_lease/1, + &MobDev.NativeBuild.release_android_deploy_lock/1, + &MobDev.Deployer.cleanup_android_payload/1 + ) + end + + def deploy_after_native_build!(false, native_outcome, deploy_opts) do + deploy_after_native_build!( + false, + native_outcome, + deploy_opts, + &MobDev.Deployer.deploy_all/1, + &MobDev.NativeBuild.release_android_deploy_lock/1, + &MobDev.Deployer.cleanup_android_payload/1 + ) + end + + @doc false + @spec deploy_after_native_build!( + boolean(), + MobDev.NativeBuild.build_outcome() | nil, + keyword(), + (keyword() -> {[Device.t()], [Device.t()], [Device.t()]}) + ) :: {[Device.t()], [Device.t()], [Device.t()]} + def deploy_after_native_build!(native, native_outcome, deploy_opts, deployer) do + deploy_after_native_build!( + native, + native_outcome, + deploy_opts, + deployer, + &MobDev.NativeBuild.release_android_deploy_lock/1, + &MobDev.Deployer.cleanup_android_payload/1 + ) + end + + @doc false + @spec deploy_after_native_build!( + boolean(), + MobDev.NativeBuild.build_outcome() | nil, + keyword(), + (keyword() -> {[Device.t()], [Device.t()], [Device.t()]}), + (map() -> :ok | {:error, String.t()} | {:error, String.t(), map()}) + ) :: {[Device.t()], [Device.t()], [Device.t()]} + def deploy_after_native_build!( + native, + native_outcome, + deploy_opts, + deployer, + lock_finalizer + ) do + deploy_after_native_build!( + native, + native_outcome, + deploy_opts, + deployer, + lock_finalizer, + &MobDev.Deployer.cleanup_android_payload/1 + ) + end + + @doc false + @spec deploy_after_native_build!( + boolean(), + MobDev.NativeBuild.build_outcome() | nil, + keyword(), + (keyword() -> term()), + (map() -> :ok | {:error, term()}), + (map() -> term()) + ) :: {[Device.t()], [Device.t()], [Device.t()]} + def deploy_after_native_build!( + true, + %{ + ok?: true, + android_device_disposition: android_device_disposition, + android_serials: android_serials, + android_deploy_lock: android_deploy_lock, + android_payload_plan: android_payload_plan + }, + deploy_opts, + deployer, + lock_finalizer, + payload_cleanup + ) + when is_list(android_serials) and is_function(deployer, 1) and + is_function(lock_finalizer, 1) and is_function(payload_cleanup, 1) do + case validate_native_deploy_inputs( + deploy_opts, + android_device_disposition, + android_serials, + android_deploy_lock, + android_payload_plan + ) do + :ok -> + deploy_native_targets( + deploy_opts, + android_serials, + android_deploy_lock, + android_payload_plan, + deployer, + lock_finalizer, + payload_cleanup + ) + + {:error, _invalid_or_noncommittable} -> + _cleanup_result = cleanup_native_android_payload(android_payload_plan, payload_cleanup) + raise_native_build_failed!() + end + end + + def deploy_after_native_build!( + true, + %{ + ok?: false, + android_device_disposition: :partial_update, + android_serials: android_serials, + android_deploy_lock: android_deploy_lock, + android_payload_plan: nil + } = native_outcome, + deploy_opts, + _deployer, + _finalizer, + _payload_cleanup + ) + when map_size(native_outcome) == 5 and is_list(android_serials) and + is_map(android_deploy_lock) do + if valid_partial_android_update?(android_serials, android_deploy_lock, deploy_opts) do + raise_native_partial_update!() + else + raise_native_build_failed!() + end + end + + def deploy_after_native_build!( + true, + native_outcome, + _deploy_opts, + _deployer, + _finalizer, + payload_cleanup + ) + when is_function(payload_cleanup, 1) do + payload_plan = if is_map(native_outcome), do: Map.get(native_outcome, :android_payload_plan) + + try do + raise_native_build_failed!() + after + cleanup_native_android_payload(payload_plan, payload_cleanup) + end + end + + def deploy_after_native_build!( + false, + _native_outcome, + deploy_opts, + deployer, + _finalizer, + _payload_cleanup + ) do + deployer.(deploy_opts) + end + + defp raise_native_build_failed! do + IO.puts("\n#{IO.ANSI.red()}Native build had failures — see errors above.#{IO.ANSI.reset()}") + + IO.puts( + "#{IO.ANSI.yellow()}Run `mix mob.doctor` to check your environment, or `mix mob.deploy` (without --native) once the issue is fixed.#{IO.ANSI.reset()}" + ) + + Mix.raise("Native build failed") + end + + defp raise_native_partial_update! do + IO.puts( + "\n#{IO.ANSI.red()}Android native deploy partially applied: APK update completed before runtime delivery failed.#{IO.ANSI.reset()}" + ) + + IO.puts( + "#{IO.ANSI.yellow()}The exact deploy lease remains retained. Inspect it with `mix mob.deploy_lock --device <exact-serial>` and reconcile the reviewed APK/runtime pair before another native deploy. Do not retry blindly, uninstall, or clear app data.#{IO.ANSI.reset()}" + ) + + Mix.raise("Android native deploy partially applied") + end + + defp valid_partial_android_update?( + android_serials, + %{phase: :acquired, state: state, serials: lock_serials} = lock, + deploy_opts + ) + when state in [:retained_failure, :retained_ambiguous] and is_list(lock_serials) do + valid_opts? = proper_list?(deploy_opts) and Keyword.keyword?(deploy_opts) + platforms = if valid_opts?, do: Keyword.get(deploy_opts, :platforms, [:android, :ios]) + + valid_opts? and proper_list?(platforms) and valid_native_platforms?(platforms) and + :android in platforms and android_serials != [] and + android_serials == Enum.sort(android_serials) and + Enum.uniq(android_serials) == android_serials and lock_serials == android_serials and + lock.bundle_id == MobDev.Config.bundle_id() and + MobDev.AndroidDeployLock.valid?(%{lock | state: :held_success}, :acquired) + end + + defp valid_partial_android_update?(_android_serials, _lock, _deploy_opts), do: false + + defp fetch_native_dependencies! do + IO.puts("Fetching dependencies...") + + with mix when is_binary(mix) <- System.find_executable("mix"), + {_output, 0} <- System.cmd(mix, ["deps.get"], into: IO.stream()) do + :ok + else + nil -> Mix.raise("Could not find mix while preparing the native deploy") + {_output, _status} -> Mix.raise("Could not fetch dependencies for the native deploy") + end + end + + defp validate_native_deploy_inputs( + deploy_opts, + android_device_disposition, + android_serials, + android_deploy_lock, + android_payload_plan + ) do + try do + valid_opts? = is_list(deploy_opts) and Keyword.keyword?(deploy_opts) + platforms = if valid_opts?, do: Keyword.get(deploy_opts, :platforms, [:android, :ios]) + restart = if valid_opts?, do: Keyword.get(deploy_opts, :restart, true) + + consistent_platform? = + proper_list?(android_serials) and proper_list?(platforms) and + ((android_device_disposition == :not_attempted and android_serials == [] and + is_nil(android_deploy_lock) and + is_nil(android_payload_plan)) or + (android_device_disposition == :held and android_serials != [] and + :android in platforms and is_map(android_deploy_lock) and + is_map(android_payload_plan))) + + with true <- valid_opts?, + true <- valid_native_platforms?(platforms), + true <- consistent_platform?, + :ok <- validate_native_android_lock(android_deploy_lock, android_serials), + true <- valid_native_restart?(restart, android_serials) do + :ok + else + _invalid_or_noncommittable -> {:error, :invalid_native_deploy_inputs} end + catch + _kind, _reason -> {:error, :invalid_native_deploy_inputs} + end + end - if failed != [] do - IO.puts("\n#{IO.ANSI.red()}Failed on #{length(failed)} device(s)#{IO.ANSI.reset()}") - Enum.each(failed, fn d -> - IO.puts(" ✗ #{d.name || d.serial}: #{d.error}") - end) + defp validate_native_android_lock(nil, []), do: :ok + + defp validate_native_android_lock(lock, canonical_serials) + when is_map(lock) and is_list(canonical_serials) do + if canonical_serials != [] and canonical_serials == Enum.sort(canonical_serials) and + Enum.uniq(canonical_serials) == canonical_serials and + MobDev.AndroidDeployLock.valid?(lock, :native_ready) and + lock.bundle_id == MobDev.Config.bundle_id() and lock.serials == canonical_serials do + :ok + else + {:error, :invalid_native_android_lock} + end + end + + defp validate_native_android_lock(_lock, _serials), + do: {:error, :invalid_native_android_lock} + + defp valid_native_platforms?(platforms) when is_list(platforms) do + proper_list?(platforms) and platforms != [] and Enum.uniq(platforms) == platforms and + Enum.all?(platforms, &(&1 in [:android, :ios])) + end + + defp valid_native_platforms?(_platforms), do: false + + defp proper_list?([]), do: true + defp proper_list?([_head | tail]), do: proper_list?(tail) + defp proper_list?(_improper_tail), do: false + + defp valid_native_restart?(restart, []), do: restart in [true, false] + defp valid_native_restart?(true, [_serial | _]), do: true + defp valid_native_restart?(_restart, _serials), do: false + + defp deploy_native_targets( + deploy_opts, + android_serials, + android_deploy_lock, + android_payload_plan, + deployer, + lock_finalizer, + payload_cleanup + ) do + platforms = Keyword.get(deploy_opts, :platforms, [:android, :ios]) + remaining_platforms = platforms -- [:android] + + if android_serials == [] and remaining_platforms == [] do + raise_native_build_failed!() + end + + android_results = + if :android in platforms and android_serials != [] do + try do + {raw_android_result, committed_lock} = + deploy_opts + |> Keyword.put(:platforms, [:android]) + |> Keyword.put(:canonical_android_serials, android_serials) + |> Keyword.put(:android_deploy_lock, android_deploy_lock) + |> Keyword.put(:android_payload_plan, android_payload_plan) + |> Keyword.delete(:device) + |> deployer.() + |> normalize_native_deployer_result() + + android_result = enforce_native_android_targets(raw_android_result, android_serials) + + [ + finalize_native_android_lock( + android_result, + android_deploy_lock, + committed_lock, + lock_finalizer + ) + ] + catch + kind, reason -> + _cleanup_result = + cleanup_native_android_payload(android_payload_plan, payload_cleanup) + + :erlang.raise(kind, reason, __STACKTRACE__) + end + else + [] + end + + android_results = + finalize_native_android_payload( + android_results, + android_serials, + android_payload_plan, + payload_cleanup + ) + + case android_results do + [{_deployed, [_failure | _], _skipped}] -> + merge_deploy_results(android_results) + + _android_committed_or_absent -> + remaining_results = + if remaining_platforms == [] do + [] + else + remaining_opts = + deploy_opts + |> Keyword.put(:platforms, remaining_platforms) + |> Keyword.delete(:canonical_android_serials) + |> Keyword.delete(:android_deploy_lock) + |> Keyword.delete(:android_payload_plan) + + case freeze_remaining_ios_target(remaining_opts, remaining_platforms) do + {:ok, frozen_opts, selected} -> + [ + frozen_opts + |> deployer.() + |> normalize_remaining_deployer_result( + remaining_platforms, + selected.serial + ) + ] + + {:error, _reason} -> + raise_native_build_failed!() + end + end + + merge_deploy_results(android_results ++ remaining_results) + end + end + + defp finalize_native_android_payload( + [], + _android_serials, + _android_payload_plan, + _payload_cleanup + ), + do: [] + + defp finalize_native_android_payload( + [{deployed, [], []}] = successful_results, + android_serials, + android_payload_plan, + payload_cleanup + ) do + case cleanup_native_android_payload(android_payload_plan, payload_cleanup) do + :ok -> + successful_results + + {:error, _cleanup_reason} -> + failed = + case deployed do + [] -> + Enum.map(android_serials, fn serial -> + native_target_failure( + %Device{platform: :android, serial: serial}, + "Native Android payload cleanup failed" + ) + end) + + devices -> + Enum.map(devices, fn device -> + native_target_failure(device, "Native Android payload cleanup failed") + end) + end + + [{[], failed, []}] + end + end + + defp finalize_native_android_payload( + failed_results, + _android_serials, + android_payload_plan, + payload_cleanup + ) do + _cleanup_result = cleanup_native_android_payload(android_payload_plan, payload_cleanup) + failed_results + end + + defp finalize_native_android_lock( + {deployed, [], []} = result, + native_lock, + committed_lock, + finalizer + ) + when is_map(native_lock) do + with :ok <- validate_committed_android_lock(committed_lock, native_lock), + :ok <- finalizer.(committed_lock) do + result + else + _invalid_failure_or_ambiguity -> + {[], + Enum.map(deployed, fn device -> + native_target_failure(device, "Native Android deploy-lock release failed") + end), []} + end + end + + defp finalize_native_android_lock( + {deployed, failed, skipped}, + native_lock, + _committed_lock, + _finalizer + ) + when is_map(native_lock) do + uncommitted = + Enum.map(deployed ++ skipped, fn device -> + native_target_failure( + device, + "Native Android target set did not reach an authoritative commit" + ) + end) + + {[], uncommitted ++ failed, []} + end + + defp finalize_native_android_lock(result, _native_lock, _committed_lock, _finalizer), + do: result + + defp validate_committed_android_lock( + %{phase: :final_committed, state: :held_success} = committed, + %{phase: :native_ready, state: :held_success} = native + ) do + identity_fields = [:bundle_id, :owner, :serials, :target_digest] + + if MobDev.AndroidDeployLock.valid?(committed, :final_committed) and + MobDev.AndroidDeployLock.valid?(native, :native_ready) and + Map.take(committed, identity_fields) == Map.take(native, identity_fields), + do: :ok, + else: {:error, :committed_lock_identity_mismatch} + end + + defp validate_committed_android_lock(_committed, _native), + do: {:error, :invalid_committed_lock} + + defp normalize_native_deployer_result({{deployed, failed, skipped}, lease}) + when is_list(deployed) and is_list(failed) and is_list(skipped) do + if valid_device_buckets?([deployed, failed, skipped]), + do: {{deployed, failed, skipped}, lease}, + else: {{[], [], []}, lease} + end + + defp normalize_native_deployer_result({deployed, failed, skipped}) + when is_list(deployed) and is_list(failed) and is_list(skipped) do + if valid_device_buckets?([deployed, failed, skipped]), + do: {{deployed, failed, skipped}, nil}, + else: {{[], [], []}, nil} + end + + defp normalize_native_deployer_result(_invalid), do: {{[], [], []}, nil} + + defp normalize_remaining_deployer_result( + {{deployed, failed, skipped}, _lease}, + platforms, + ios_device_id + ) + when is_list(deployed) and is_list(failed) and is_list(skipped) do + normalize_remaining_device_buckets( + deployed, + failed, + skipped, + platforms, + ios_device_id + ) + end + + defp normalize_remaining_deployer_result( + {deployed, failed, skipped}, + platforms, + ios_device_id + ) + when is_list(deployed) and is_list(failed) and is_list(skipped) do + normalize_remaining_device_buckets( + deployed, + failed, + skipped, + platforms, + ios_device_id + ) + end + + defp normalize_remaining_deployer_result(_invalid, _platforms, _ios_device_id), + do: raise_native_build_failed!() + + defp freeze_remaining_ios_target(opts, [:ios]) when is_list(opts) do + lister = Keyword.get(opts, :ios_lister, &MobDev.Discovery.IOS.list_devices/0) + requested_id = Keyword.get(opts, :ios_device) + + if is_function(lister, 0) do + try do + devices = lister.() + + with true <- proper_list?(devices), + true <- Enum.all?(devices, &authoritative_ios_discovery_device?/1), + {:ok, selected} <- select_unique_ios_target(devices, requested_id) do + frozen_opts = + opts + |> Keyword.put(:ios_device, selected.serial) + |> Keyword.put(:ios_lister, fn -> [selected] end) + + {:ok, frozen_opts, selected} + else + _invalid_or_ambiguous -> {:error, :invalid_ios_target_selection} + end + rescue + _error -> {:error, :ios_target_discovery_failed} + catch + _kind, _reason -> {:error, :ios_target_discovery_failed} + end + else + {:error, :invalid_ios_lister} + end + end + + defp freeze_remaining_ios_target(_opts, _platforms), + do: {:error, :invalid_ios_target_platforms} + + defp select_unique_ios_target([device], nil), do: {:ok, device} + + defp select_unique_ios_target(devices, requested_id) when is_binary(requested_id) do + case Enum.filter(devices, &Device.match_id?(&1, requested_id)) do + [device] -> {:ok, device} + _none_or_ambiguous -> {:error, :ios_target_not_unique} + end + end + + defp select_unique_ios_target(_devices, _requested_id), + do: {:error, :invalid_ios_target_id} + + defp authoritative_ios_discovery_device?(%Device{serial: serial} = device) + when is_binary(serial) do + byte_size(serial) in 1..256 and String.valid?(serial) and + authoritative_native_ios_success?(device) + end + + defp authoritative_ios_discovery_device?(_device), do: false + + defp normalize_remaining_device_buckets( + deployed, + failed, + skipped, + platforms, + ios_device_id + ) do + result = {deployed, failed, skipped} + + if valid_device_buckets?([deployed, failed, skipped]) and + authoritative_remaining_result?(result, platforms, ios_device_id) do + result + else + raise_native_build_failed!() + end + end + + defp authoritative_remaining_result?({_deployed, [_failure | _], _skipped}, [:ios], _id), + do: true + + defp authoritative_remaining_result?({[device], [], []}, [:ios], ios_device_id) + when is_binary(ios_device_id) do + authoritative_native_ios_success?(device) and Device.match_id?(device, ios_device_id) + end + + defp authoritative_remaining_result?({[device], [], []}, [:ios], nil), + do: authoritative_native_ios_success?(device) + + defp authoritative_remaining_result?(_result, _platforms, _ios_device_id), do: false + + defp valid_device_buckets?([deployed, failed, skipped]) do + proper_list?(deployed) and proper_list?(failed) and proper_list?(skipped) and + valid_device_bucket?(deployed, :deployed) and + valid_device_bucket?(failed, :failed) and + valid_device_bucket?(skipped, :skipped) + end + + defp valid_device_buckets?(_invalid), do: false + + defp valid_device_bucket?(bucket, expected_bucket) do + Enum.all?(bucket, fn + %Device{platform: platform, serial: serial} = device + when platform in [:android, :ios] and is_binary(serial) -> + byte_size(serial) in 1..256 and String.valid?(serial) and + valid_bucket_device?(expected_bucket, device) + + _invalid -> + false + end) + end + + defp valid_bucket_device?(:deployed, %Device{platform: :ios} = device), + do: authoritative_native_ios_success?(device) + + defp valid_bucket_device?(expected_bucket, %Device{status: status}), + do: valid_bucket_status?(expected_bucket, status) + + defp authoritative_native_ios_success?(%Device{ + platform: :ios, + type: :physical, + status: :discovered, + error: nil + }), + do: true + + defp authoritative_native_ios_success?(%Device{ + platform: :ios, + type: :simulator, + status: :booted, + error: nil + }), + do: true + + defp authoritative_native_ios_success?(_device), do: false + + defp valid_bucket_status?(:deployed, status), do: status not in [:error, :skipped] + defp valid_bucket_status?(:failed, :error), do: true + defp valid_bucket_status?(:skipped, :skipped), do: true + defp valid_bucket_status?(_bucket, _status), do: false + + defp cleanup_native_android_payload(nil, _cleanup), do: :ok + + defp cleanup_native_android_payload(payload_plan, cleanup) when is_map(payload_plan) do + try do + case cleanup.(payload_plan) do + :ok -> :ok + _failed_or_invalid -> android_payload_cleanup_error() end + catch + _kind, _reason -> android_payload_cleanup_error() end + end + + defp cleanup_native_android_payload(_untrusted_payload_plan, _cleanup), + do: {:error, :invalid_android_payload_plan} + + defp android_payload_cleanup_error do + IO.puts( + "#{IO.ANSI.yellow()}Could not clean local Android deploy staging; no device cleanup was attempted.#{IO.ANSI.reset()}" + ) + + {:error, :android_payload_cleanup_failed} + end + + defp enforce_native_android_targets({deployed, failed, skipped}, serials) do + canonical = MapSet.new(serials) + + tagged = + Enum.map(deployed, &{:deployed, &1}) ++ + Enum.map(failed, &{:failed, &1}) ++ Enum.map(skipped, &{:skipped, &1}) + + grouped = + Enum.group_by(tagged, fn {_bucket, device} -> {device.platform, device.serial} end) + + canonical_results = + Enum.map(serials, fn serial -> + case Map.get(grouped, {:android, serial}, []) do + [{:deployed, device}] -> + if authoritative_native_android_success?(device, serial) do + {:deployed, device} + else + {:failed, + native_target_failure( + device, + "Native Android target did not report authoritative deployment success" + )} + end + + [{:failed, device}] -> + {:failed, device} + + [{:skipped, device}] -> + {:failed, + native_target_failure( + device, + "Native Android target became unavailable after install" + )} + + [] -> + {:failed, + %Device{ + platform: :android, + serial: serial, + status: :error, + error: "Native Android target was not accounted for after install" + }} + + _duplicate_or_conflicting -> + {:failed, + %Device{ + platform: :android, + serial: serial, + status: :error, + error: "Native Android target produced duplicate or conflicting results" + }} + end + end) + + invalid_results = + tagged + |> Enum.reject(fn {_bucket, device} -> + device.platform == :android and MapSet.member?(canonical, device.serial) + end) + |> Enum.map(fn {_bucket, device} -> + {:failed, + native_target_failure(device, "Native Android pass reported a non-canonical target")} + end) + + results = canonical_results ++ invalid_results + + { + for({:deployed, device} <- results, do: device), + for({:failed, device} <- results, do: device), + [] + } + end + + defp authoritative_native_android_success?( + %Device{ + platform: :android, + serial: serial, + status: status, + error: nil + }, + serial + ) + when status in @native_android_success_statuses, + do: true + + defp authoritative_native_android_success?(_device, _serial), do: false + + defp native_target_failure(%Device{} = device, reason) do + %{device | status: :error, error: reason} + end - if native and native_ok == false do - IO.puts("\n#{IO.ANSI.red()}Native build had failures — see errors above.#{IO.ANSI.reset()}") - IO.puts("#{IO.ANSI.yellow()}Run `mix mob.doctor` to check your environment, or `mix mob.deploy` (without --native) once the issue is fixed.#{IO.ANSI.reset()}") - Mix.raise("Native build failed") + defp merge_deploy_results(results) do + { + Enum.flat_map(results, fn {deployed, _failed, _skipped} -> deployed end), + Enum.flat_map(results, fn {_deployed, failed, _skipped} -> failed end), + Enum.flat_map(results, fn {_deployed, _failed, skipped} -> skipped end) + } + end + + @doc false + @spec ensure_deploy_succeeded!({[Device.t()], [Device.t()], [Device.t()]}) :: :ok + def ensure_deploy_succeeded!({_deployed, [], _skipped}), do: :ok + + def ensure_deploy_succeeded!({_deployed, failed, _skipped}) when is_list(failed) do + Mix.raise("Deploy failed on #{length(failed)} device(s)") + end + + @doc false + @spec report_deploy_result!( + {[Device.t()], [Device.t()], [Device.t()]}, + keyword() + ) :: :ok + def report_deploy_result!({deployed, failed, skipped} = result, opts \\ []) do + Enum.each(format_summary(deployed, failed, skipped, opts), &IO.puts/1) + ensure_deploy_succeeded!(result) + end + + @doc """ + Build the per-deploy summary lines from the three device buckets. + + Returns an iolist of strings (one per line) that the task prints + verbatim. Public so the report shape can be pinned against fixture + device lists — keeps "Failed on N" from regressing back into + counting skipped-because-not-installed devices. + + Opts: + * `:restart` — boolean; controls the post-deploy IEx hint line + """ + @spec format_summary([Device.t()], [Device.t()], [Device.t()], keyword()) :: [String.t()] + def format_summary(deployed, failed, skipped, opts \\ []) do + restart? = Keyword.get(opts, :restart, true) + + cond do + deployed == [] and failed == [] and skipped == [] -> + [ + "#{IO.ANSI.yellow()}No devices found.#{IO.ANSI.reset()}", + "Try: mix mob.devices to diagnose connection issues" + ] + + true -> + [] + |> append_deployed_block(deployed, restart?) + |> append_skipped_block(skipped) + |> append_failed_block(failed) end end + defp append_deployed_block(acc, [], _restart?), do: acc + + defp append_deployed_block(acc, deployed, restart?) do + follow_up = + if restart? do + "Apps restarted. Run #{IO.ANSI.cyan()}mix mob.connect#{IO.ANSI.reset()} to open IEx." + else + "BEAMs pushed. In IEx: #{IO.ANSI.cyan()}nl(MyModule)#{IO.ANSI.reset()} to hot-load." + end + + acc ++ + [ + "\n#{IO.ANSI.green()}Deployed to #{length(deployed)} device(s)#{IO.ANSI.reset()}", + follow_up + ] + end + + defp append_skipped_block(acc, []), do: acc + + defp append_skipped_block(acc, skipped) do + header = + "\n#{IO.ANSI.yellow()}Skipped on #{length(skipped)} device(s) — app not installed " <> + "(build for that platform with --android / --ios if intended)#{IO.ANSI.reset()}" + + rows = + Enum.map(skipped, fn d -> + " #{IO.ANSI.faint()}— #{d.name || d.serial}: #{d.error}#{IO.ANSI.reset()}" + end) + + acc ++ [header | rows] + end + + defp append_failed_block(acc, []), do: acc + + defp append_failed_block(acc, failed) do + header = "\n#{IO.ANSI.red()}Failed on #{length(failed)} device(s)#{IO.ANSI.reset()}" + rows = Enum.map(failed, fn d -> " ✗ #{d.name || d.serial}: #{d.error}" end) + acc ++ [header | rows] + end + defp resolve_platforms(opts) do android = opts[:android] - ios = opts[:ios] + ios = opts[:ios] cond do - android && ios -> [:android, :ios] - android -> [:android] - ios -> + android && ios -> + [:android, :ios] + + android -> + [:android] + + ios -> if macos?() do [:ios] else - IO.puts("#{IO.ANSI.yellow()}Warning: --ios is only supported on macOS. Skipping iOS.#{IO.ANSI.reset()}") + IO.puts( + "#{IO.ANSI.yellow()}Warning: --ios is only supported on macOS. Skipping iOS.#{IO.ANSI.reset()}" + ) + [] end - macos?() -> [:android, :ios] - true -> [:android] + + macos?() -> + [:android, :ios] + + true -> + [:android] end end defp macos?, do: match?({:unix, :darwin}, :os.type()) + + # ── Pre-build device compatibility check ──────────────────────────────────── + # + # The instinct in mobile build pipelines is "let it fail at install / runtime + # and tell the user something went wrong." That instinct is hostile to users + # with older or cheaper hardware — they buy a phone, deploy, get a cryptic + # error, and walk away assuming the framework is broken. + # + # We instead query each candidate device's properties up front, cross- + # reference them against the project's enabled features (Pythonx, etc.), and + # refuse to proceed with a clear, named-feature, named-reason error when + # there's a mismatch. The user finds out which device(s) won't work and why + # before any build runs. + # + # We deliberately don't filter — if any one of the targeted devices fails, + # we halt and surface every device that fails. Skipping unsupported devices + # silently would just regrow the silent-failure problem at a different layer. + defp validate_device_compatibility!(platforms, device_id) do + project_dir = File.cwd!() + features = MobDev.SupportMatrix.enabled_features(project_dir) + + if features == [] do + :ok + else + devices = candidate_devices(platforms, device_id) + + issues = + devices + |> Enum.flat_map(fn device -> + case MobDev.SupportMatrix.check_device(device, features) do + :ok -> [] + {:error, items} -> items + end + end) + + case issues do + [] -> + :ok + + _ -> + IO.puts("") + IO.puts("#{IO.ANSI.red()}Device compatibility check failed.#{IO.ANSI.reset()}") + IO.puts(MobDev.SupportMatrix.format_error(issues)) + IO.puts("") + + IO.puts( + " See guides/support_matrix.md for the per-feature device floor, " <> + "or pick a different device with #{IO.ANSI.cyan()}--device <id>#{IO.ANSI.reset()}." + ) + + Mix.raise("Device compatibility check failed") + end + end + end + + # Returns the connected devices that mob.deploy would actually target. + # Mirrors what the deployer / build pipeline does internally — narrow by + # platform and (if given) by --device id. + defp candidate_devices(platforms, device_id) do + devices = + [] + |> maybe_concat(:android in platforms, fn -> + try do + MobDev.Discovery.Android.list_devices() + rescue + _ -> [] + end + end) + |> maybe_concat(:ios in platforms, fn -> + try do + MobDev.Discovery.IOS.list_simulators() + rescue + _ -> [] + end + end) + + case device_id do + nil -> devices + id -> Enum.filter(devices, &MobDev.Device.match_id?(&1, id)) + end + end + + defp maybe_concat(list, true, fun), do: list ++ fun.() + defp maybe_concat(list, false, _fun), do: list + + # Resolve --schedulers / --beam-flags into a combined flags string, save to + # mob.exs, and return it (or the previously saved value if no flags given). + defp resolve_beam_flags(opts) do + new_flags = combine_beam_flags(opts[:schedulers], opts[:beam_flags]) + + if new_flags do + save_beam_flags(new_flags) + IO.puts("#{IO.ANSI.cyan()}* beam flags: #{new_flags} (saved to mob.exs)#{IO.ANSI.reset()}") + new_flags + else + MobDev.Config.load_mob_config()[:beam_flags] + end + end + + @doc false + @spec combine_beam_flags(pos_integer() | nil, String.t() | nil) :: String.t() | nil + def combine_beam_flags(schedulers, flags_string) do + case {schedulers, flags_string} do + {nil, nil} -> nil + {n, nil} -> "-S #{n}:#{n}" + {nil, flags} -> String.trim(flags) + {n, flags} -> "-S #{n}:#{n} #{String.trim(flags)}" + end + end + + # Write or update the beam_flags key in mob.exs. + defp save_beam_flags(flags) do + path = Path.join(File.cwd!(), "mob.exs") + unless File.exists?(path), do: Mix.raise("mob.exs not found in current directory") + + content = File.read!(path) + updated = update_beam_flags_in_config(content, flags) + File.write!(path, updated) + end + + @doc false + @spec update_beam_flags_in_config(String.t(), String.t() | nil) :: String.t() + def update_beam_flags_in_config(content, flags) do + value = inspect(flags) + + if content =~ Regex.compile!("^\\s+beam_flags:", "m") do + Regex.replace( + Regex.compile!("^(\\s+beam_flags:).*$", "m"), + content, + " beam_flags: #{value}" + ) + else + String.trim_trailing(content) <> "\nconfig :mob_dev, beam_flags: #{value}\n" + end + end end diff --git a/lib/mix/tasks/mob.deploy_lock.ex b/lib/mix/tasks/mob.deploy_lock.ex new file mode 100644 index 0000000..6317ecc --- /dev/null +++ b/lib/mix/tasks/mob.deploy_lock.ex @@ -0,0 +1,147 @@ +defmodule Mix.Tasks.Mob.DeployLock do + use Mix.Task + + alias MobDev.{AndroidDeployLock, Config} + + @shortdoc "Inspect or clean a verified Android deploy-lock tombstone" + + @moduledoc """ + Inspects the app-private Android native-deploy lease for one exact device. + + The default operation is read-only. Recovery is deliberately narrow: + `--cleanup-committed` removes only a single, structurally valid release + tombstone whose record is already in a committed phase. It refuses active, + malformed, missing, or topologically ambiguous leases. It never removes the + app, clears app data, or deletes an active deploy lock. + + mix mob.deploy_lock --device <exact-adb-serial> + mix mob.deploy_lock --device <exact-adb-serial> --cleanup-committed + + A retained active or ambiguous lease means the interrupted operation needs + diagnosis. Do not retry a native deploy until its exact state is understood. + """ + + @switches [device: :string, cleanup_committed: :boolean] + + @impl Mix.Task + def run(args) when is_list(args) do + require_exactly_one_device_switch!(args) + {opts, positional, invalid} = OptionParser.parse(args, strict: @switches) + + if positional != [] or invalid != [] do + Mix.raise("Usage: mix mob.deploy_lock --device <exact-adb-serial> [--cleanup-committed]") + end + + device = exact_device!(opts) + + case inspect_or_cleanup( + Config.bundle_id(), + device, + opts[:cleanup_committed] == true, + &run_adb/1 + ) do + {:ok, :clear} -> + IO.puts("Android deploy lock: clear") + + {:ok, :held} -> + IO.puts("Android deploy lock: active; manual diagnosis required") + + {:ok, :released_tombstone} -> + IO.puts("Android deploy lock: release tombstone present; phase unverified") + + {:ok, :ambiguous} -> + IO.puts("Android deploy lock: ambiguous; manual diagnosis required") + + {:ok, :cleaned} -> + IO.puts("Android deploy lock: verified committed tombstone removed") + + {:error, {:cleanup_refused, state}} -> + Mix.raise("Committed tombstone cleanup refused (#{status_label(state)})") + + {:error, reason} when reason in [:cleanup_ambiguous, :post_cleanup_ambiguous] -> + Mix.raise( + "Committed tombstone cleanup became ambiguous after its single attempt; do not retry" + ) + + {:error, _reason} -> + Mix.raise("Android deploy-lock status is ambiguous; no cleanup was attempted") + end + end + + def run(_args), + do: Mix.raise("Usage: mix mob.deploy_lock --device <exact-adb-serial> [--cleanup-committed]") + + @doc false + @spec inspect_or_cleanup(String.t(), String.t(), boolean(), ([String.t()] -> term())) :: + {:ok, :clear | :held | :released_tombstone | :ambiguous | :cleaned} + | {:error, atom() | {:cleanup_refused, atom()}} + def inspect_or_cleanup(bundle_id, serial, false, runner) + when is_binary(bundle_id) and is_binary(serial) and is_function(runner, 1) do + AndroidDeployLock.status(bundle_id, serial, runner) + end + + def inspect_or_cleanup(bundle_id, serial, true, runner) + when is_binary(bundle_id) and is_binary(serial) and is_function(runner, 1) do + case AndroidDeployLock.status(bundle_id, serial, runner) do + {:ok, :released_tombstone} -> + clean_committed_tombstone(bundle_id, serial, runner) + + {:ok, state} when state in [:clear, :held, :ambiguous] -> + {:error, {:cleanup_refused, state}} + + {:error, reason} -> + {:error, reason} + end + end + + def inspect_or_cleanup(_bundle_id, _serial, _cleanup?, _runner), + do: {:error, :invalid_request} + + defp clean_committed_tombstone(bundle_id, serial, runner) do + with :ok <- AndroidDeployLock.cleanup_committed_tombstone(bundle_id, serial, runner) do + case AndroidDeployLock.status(bundle_id, serial, runner) do + {:ok, :clear} -> {:ok, :cleaned} + _changed_or_invalid -> {:error, :post_cleanup_ambiguous} + end + end + end + + defp run_adb(args) do + case System.find_executable("adb") do + nil -> {"", 127} + adb -> System.cmd(adb, args, stderr_to_stdout: true) + end + end + + defp exact_device!(opts) do + case Keyword.get_values(opts, :device) do + [device] when is_binary(device) and device != "" -> + device + + [] -> + Mix.raise("An exact Android device serial is required; pass --device <serial>") + + [_device | _duplicates] -> + Mix.raise("Exactly one Android device serial is required; pass --device once") + end + end + + defp require_exactly_one_device_switch!(args) do + case Enum.count(args, &device_switch?/1) do + 1 -> + :ok + + 0 -> + Mix.raise("An exact Android device serial is required; pass --device <serial>") + + _duplicates -> + Mix.raise("Exactly one Android device serial is required; pass --device once") + end + end + + defp device_switch?("--device"), do: true + defp device_switch?("--device=" <> _value), do: true + defp device_switch?(_arg), do: false + + defp status_label(state), do: Atom.to_string(state) +end diff --git a/lib/mix/tasks/mob.devices.ex b/lib/mix/tasks/mob.devices.ex index 89b5a62..552c703 100644 --- a/lib/mix/tasks/mob.devices.ex +++ b/lib/mix/tasks/mob.devices.ex @@ -4,12 +4,17 @@ defmodule Mix.Tasks.Mob.Devices do @shortdoc "List all connected Android and iOS devices" @moduledoc """ - Scans for connected Android devices (via adb) and iOS simulators - (via xcrun simctl) and prints their status. + Scans for connected Android devices (via adb) and iOS simulators/physical + devices (via xcrun simctl / ideviceinfo) and prints their status. mix mob.devices - Useful for diagnosing connection issues before running mix mob.connect. + Each device is shown with a short **ID** you can pass to `--device`: + + mix mob.deploy --device emulator-5554 + mix mob.deploy --native --device 78354490 + + Gracefully skips platforms whose tools are not installed (adb / xcrun). ## Under the hood @@ -18,11 +23,8 @@ defmodule Mix.Tasks.Mob.Devices do # → parses serial numbers, device/emulator state, and manufacturer/model # iOS (macOS only) - xcrun simctl list devices --json - # → filters for Booted simulators with device name and UDID - - You can run either command directly to get the raw output. `mix mob.devices` - adds status hints (e.g. "enable Developer Mode", "check USB debugging prompt"). + xcrun simctl list devices booted --json + ideviceinfo -k UniqueDeviceID (if libimobiledevice is installed) """ alias MobDev.Discovery.{Android, IOS} @@ -32,44 +34,162 @@ defmodule Mix.Tasks.Mob.Devices do def run(_args) do Mix.Task.run("app.config") - android = Android.list_devices() - ios = IOS.list_devices() + android = list_android() + ios = list_ios() - IO.puts("\n#{IO.ANSI.cyan()}Android#{IO.ANSI.reset()}") + IO.puts("") + print_section("Android", android) + IO.puts("") + print_section("iOS", ios) - if android == [] do - IO.puts(" (none — is adb installed? Any devices connected?)") - else - Enum.each(android, fn d -> - IO.puts(" " <> Device.summary(d)) - print_android_hints(d) - end) + all = device_list(android) ++ device_list(ios) + + if all != [] do + IO.puts("") + IO.puts("Pass the ID to --device to target a specific device:") + IO.puts(" mix mob.deploy --device #{Device.display_id(hd(all))}") + + print_bench_hints(all) end - IO.puts("\n#{IO.ANSI.cyan()}iOS#{IO.ANSI.reset()}") + IO.puts("") + end - if ios == [] do - IO.puts(" (none — is a simulator running?)") - else - Enum.each(ios, fn d -> - IO.puts(" " <> Device.summary(d)) + defp print_bench_hints(all) do + physical_ios_with_ip = + Enum.find(all, fn d -> + d.platform == :ios and d.type == :physical and d.host_ip end) + + physical_android = + Enum.find(all, fn d -> d.platform == :android and d.type == :physical end) + + if physical_ios_with_ip do + IO.puts("") + IO.puts("For an iOS battery bench, use --wifi-ip with the device IP:") + + IO.puts(" mix mob.battery_bench_ios --no-build --wifi-ip #{physical_ios_with_ip.host_ip}") end - IO.puts("") + if physical_android do + IO.puts("") + IO.puts("For an Android battery bench, target the device by serial:") + + IO.puts(" mix mob.battery_bench_android --no-build --device #{physical_android.serial}") + end + end + + # ── Device discovery (returns tagged list or a reason atom) ────────────────── + + defp list_android do + case System.find_executable("adb") do + nil -> {:unavailable, "adb not found — install Android platform-tools"} + _ -> {:ok, Android.list_devices()} + end + end + + defp list_ios do + cond do + not macos?() -> + {:unavailable, "iOS deployment requires macOS"} + + System.find_executable("xcrun") == nil -> + {:unavailable, "xcrun not found — install Xcode command-line tools"} + + true -> + {:ok, IOS.list_devices()} + end + end + + # ── Output ─────────────────────────────────────────────────────────────────── + + defp print_section(title, result) do + IO.puts("#{IO.ANSI.cyan()}#{title}#{IO.ANSI.reset()}") + + case result do + {:unavailable, reason} -> + IO.puts(" (#{reason})") + + {:ok, []} -> + IO.puts(" (none)") + + {:ok, devices} -> + print_table(devices) + Enum.each(devices, &print_hints/1) + end end - defp print_android_hints(%{status: :unauthorized}) do - IO.puts(" #{IO.ANSI.yellow()}→ Check device for 'Allow USB debugging?' prompt#{IO.ANSI.reset()}") + defp print_table(devices) do + max_name = devices |> Enum.map(&name_len/1) |> Enum.max() + max_ver = devices |> Enum.map(&ver_len/1) |> Enum.max() + max_type = devices |> Enum.map(&type_len/1) |> Enum.max() + max_id = devices |> Enum.map(&id_len/1) |> Enum.max() + any_ip = Enum.any?(devices, & &1.host_ip) + + Enum.each(devices, fn d -> + icon = status_icon(d) + name = pad(d.name || d.serial, max_name) + ver = pad(d.version || "", max_ver) + type = pad(type_label(d), max_type) + + id_str = Device.display_id(d) + id_padded = pad(id_str, max_id) + id = IO.ANSI.bright() <> id_padded <> IO.ANSI.reset() + + ip_part = + cond do + d.host_ip -> " " <> IO.ANSI.faint() <> d.host_ip <> IO.ANSI.reset() + any_ip -> " " <> IO.ANSI.faint() <> "(no IP)" <> IO.ANSI.reset() + true -> "" + end + + IO.puts(" #{icon} #{name} #{ver} #{type} #{id}#{ip_part}") + end) end - defp print_android_hints(%{platform: :android, serial: serial}) do + defp name_len(d), do: String.length(d.name || d.serial) + defp ver_len(d), do: String.length(d.version || "") + defp type_len(d), do: String.length(type_label(d)) + defp id_len(d), do: String.length(Device.display_id(d)) + + defp type_label(%{type: :emulator}), do: "emulator" + defp type_label(%{type: :simulator}), do: "simulator" + defp type_label(%{type: :physical}), do: "physical" + defp type_label(_), do: "device" + + defp status_icon(%{status: :connected}), do: "✓" + defp status_icon(%{status: :booted}), do: "·" + defp status_icon(%{status: :discovered}), do: "·" + defp status_icon(%{status: :unauthorized}), do: "✗" + defp status_icon(%{status: :error}), do: "!" + defp status_icon(_), do: "·" + + defp pad(str, width), do: String.pad_trailing(str, width) + + # ── Hints ──────────────────────────────────────────────────────────────────── + + defp print_hints(%{status: :unauthorized}) do + IO.puts( + " #{IO.ANSI.yellow()}→ Check device for 'Allow USB debugging?' prompt#{IO.ANSI.reset()}" + ) + end + + defp print_hints(%{platform: :android, serial: serial}) do case Android.developer_mode(serial) do :disabled -> - IO.puts(" #{IO.ANSI.yellow()}→ Enable Developer Mode: Settings → About → tap Build Number 7×#{IO.ANSI.reset()}") - _ -> :ok + IO.puts( + " #{IO.ANSI.yellow()}→ Enable Developer Mode: Settings → About → tap Build Number 7×#{IO.ANSI.reset()}" + ) + + _ -> + :ok end end - defp print_android_hints(_), do: :ok + defp print_hints(_), do: :ok + + defp device_list({:ok, devices}), do: devices + defp device_list({:unavailable, _}), do: [] + + defp macos?, do: match?({:unix, :darwin}, :os.type()) end diff --git a/lib/mix/tasks/mob.doctor.ex b/lib/mix/tasks/mob.doctor.ex index 55090cf..4085623 100644 --- a/lib/mix/tasks/mob.doctor.ex +++ b/lib/mix/tasks/mob.doctor.ex @@ -1,6 +1,8 @@ defmodule Mix.Tasks.Mob.Doctor do use Mix.Task + alias MobDev.NdkVersion + @shortdoc "Check your environment for common Mob setup issues" @moduledoc """ @@ -85,6 +87,7 @@ defmodule Mix.Tasks.Mob.Doctor do check_epmd(), check_adb(), check_xcrun(), + check_zig(), if(has_android_project?(), do: check_android_build_tools(), else: []), if(has_ios_project?() and macos?(), do: check_ios_build_tools(), else: []), check_optional( @@ -213,13 +216,35 @@ defmodule Mix.Tasks.Mob.Doctor do {:fail, "adb", "required to deploy to Android devices and emulators", "Install Android SDK Platform Tools:\n" <> " https://developer.android.com/tools/releases/platform-tools\n" <> - " or: brew install --cask android-platform-tools"} + if(macos?(), + do: " or: brew install --cask android-platform-tools", + else: " or: sudo apt install adb" + )} path -> {:ok, "adb", path, nil} end end + defp check_zig do + case System.find_executable("zig") do + nil -> + {:warn, "zig", + "not on PATH — required as the C cross-compile driver from Phase 1 of the build-system migration", + "Install zig 0.15.x:\n macOS: brew install zig\n Linux/asdf: asdf plugin add zig && asdf install zig 0.15.2\n manual: https://ziglang.org/download/"} + + _ -> + case System.cmd("zig", ["version"], stderr_to_stdout: true) do + {out, 0} -> + version = String.trim(out) + {:ok, "zig", version, nil} + + _ -> + {:warn, "zig", "found but `zig version` failed", nil} + end + end + end + defp check_xcrun do if macos?() do case System.find_executable("xcrun") do @@ -233,7 +258,7 @@ defmodule Mix.Tasks.Mob.Doctor do version_line = out |> String.split("\n") |> List.first() |> String.trim() major = - Regex.run(~r/Xcode (\d+)/, version_line) + Regex.run(Regex.compile!("Xcode (\\d+)"), version_line) |> case do [_, v] -> String.to_integer(v) nil -> 99 @@ -255,22 +280,156 @@ defmodule Mix.Tasks.Mob.Doctor do end end + @min_jdk 17 + defp check_android_build_tools do [ case System.find_executable("java") do nil -> {:fail, "java", "required by Gradle to build the Android APK", - "Install a JDK:\n brew install --cask temurin\n or install Android Studio which bundles a JDK"} + "Install a JDK:\n macOS: brew install --cask temurin\n Ubuntu/Debian: sudo apt install openjdk-21-jdk\n Arch: sudo pacman -S jdk21-openjdk\n Fedora: sudo dnf install java-21-openjdk\n or install Android Studio which bundles a JDK"} path -> case System.cmd(path, ["-version"], stderr_to_stdout: true) do {out, _} -> - version = out |> String.split("\n") |> List.first() |> String.trim() - {:ok, "java", version, nil} + version_line = out |> String.split("\n") |> List.first() |> String.trim() + + major = + case Regex.run(Regex.compile!("version \"(\\d+)"), version_line, + capture: :all_but_first + ) do + [v] -> String.to_integer(v) + _ -> 0 + end + + cond do + major > 0 and major < @min_jdk -> + {:fail, "java", + "JDK #{major} found — JDK #{@min_jdk}+ required by Android Gradle Plugin 8.x", + "Install a supported JDK:\n #{java_install_hint()}"} + + major > 21 -> + {:warn, "java", "#{version_line} — JDK #{major} detected", + "AGP 8.2.0 is tested through JDK 21. JDK #{major} may cause Kotlin compilation errors.\n Switch to JDK 17 or 21:\n macOS: brew install --cask temurin@21 && export JAVA_HOME=$(/usr/libexec/java_home -v 21)\n Ubuntu/Debian: sudo apt install openjdk-21-jdk && sudo update-alternatives --config java\n Arch: sudo pacman -S jdk21-openjdk && sudo archlinux-java set java-21-openjdk\n Fedora: sudo dnf install java-21-openjdk && sudo alternatives --config java"} + + true -> + {:ok, "java", version_line, nil} + end end end, - check_android_sdk() - ] + check_android_sdk(), + check_android_ndk() + ] ++ maybe_check_rust_android_targets() + end + + # If the project has any Rust NIFs (native/*/Cargo.toml), make sure both + # Android rustup targets are installed. Without these, `cargo build + # --target=aarch64-linux-android` (or `armv7-linux-androideabi`) fails + # with "error: toolchain '<x>' is not installed". + defp maybe_check_rust_android_targets do + if has_rust_nif?() and System.find_executable("rustup") do + [check_rust_android_targets()] + else + [] + end + end + + defp has_rust_nif?, do: Path.wildcard("native/*/Cargo.toml") != [] + + defp check_rust_android_targets do + case System.cmd("rustup", ["target", "list", "--installed"], stderr_to_stdout: true) do + {out, 0} -> + installed = String.split(out, "\n", trim: true) + + wanted = ["aarch64-linux-android", "armv7-linux-androideabi"] + missing = wanted -- installed + + case missing do + [] -> + {:ok, "rust android targets", "aarch64 + armv7 ✓", nil} + + _ -> + {:fail, "rust android targets", "missing: #{Enum.join(missing, ", ")}", + "Install:\n rustup target add #{Enum.join(missing, " ")}"} + end + + {_, _} -> + {:warn, "rust android targets", "rustup target list failed", + "Verify rustup is functional: rustup --version"} + end + end + + # Validate the Android NDK install matches what mob's bundled OTP runtime + # was cross-compiled against. The libbeam.a in the OTP tarballs embeds + # libc++ ABI symbols using a specific inline namespace (NDK 27 = ne180000; + # NDK 25 = ne140000). An app's libpigeon.so must link against the same + # namespace or the C++ exception ABI symbols (__cxa_*) come up undefined. + # + # Three states surface here, matching the matrix in the side-quest doc: + # + # ✓ recommended NDK installed and used (or override matches it) + # ⚠ override active for a non-recommended version + # ✗ recommended NDK not installed AND no override + defp check_android_ndk do + recommended = NdkVersion.recommended() + effective = NdkVersion.effective() + override = NdkVersion.override() + + cond do + override == :none and effective == recommended and NdkVersion.installed?(recommended) -> + {:ok, "Android NDK", "#{recommended} (recommended) ✓", nil} + + override == :none and not NdkVersion.installed?(recommended) -> + installed_hint = + case NdkVersion.installed_versions() do + [] -> "No NDKs installed." + versions -> "Installed: #{Enum.join(versions, ", ")}." + end + + {:fail, "Android NDK", + "#{recommended} not installed (Mob's OTP runtime is built against this NDK).\n #{installed_hint}", + "Install with:\n #{NdkVersion.install_command()}\n Or via Android Studio → SDK Manager → SDK Tools → NDK (Side by side)\n → check 27.2.12479018 (or whatever the recommended is, see\n ~/code/mob_dev/lib/mob_dev/ndk_version.ex `@recommended`)."} + + override != :none -> + {source, version} = override + + source_label = + if source == :env, do: "MOB_ANDROID_NDK_VERSION", else: "mob.exs :android_ndk_version" + + installed_label = + if NdkVersion.installed?(version) do + "installed" + else + "NOT installed" + end + + # The override case is always a warning (never a fail). The user + # opted out of the happy path; we just remind them what they took + # on. They get to debug the link errors themselves. + msg = + "override active: building with #{version} via #{source_label} (#{installed_label}). " <> + "Recommended is #{recommended}. You've opted out of the bundled-OTP libc++ ABI " <> + "guarantee — mismatched libc++ inline namespaces between your NDK and Mob's libbeam.a " <> + "surface as `undefined symbol: __cxa_allocate_exception` (or similar) at link time." + + hint = + "To return to the happy path:\n" <> + " - drop `:android_ndk_version` from mob.exs's :mob_dev config\n" <> + " - or unset MOB_ANDROID_NDK_VERSION\n" <> + "Then run mob.doctor again. See ~/code/mob/common_fixes.md for ABI details." + + {:warn, "Android NDK", msg, hint} + end + end + + defp java_install_hint do + if macos?() do + "brew install --cask temurin\n or install Android Studio which bundles a JDK" + else + "sudo apt install openjdk-21-jdk\n" <> + " then: export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64\n" <> + " or install Android Studio which bundles a JDK" + end end defp check_android_sdk do @@ -288,10 +447,15 @@ defmodule Mix.Tasks.Mob.Doctor do "Install Android Studio or set ANDROID_HOME to a valid SDK path"} true -> + default_path = + if macos?(), + do: "$HOME/Library/Android/sdk", + else: "$HOME/Android/Sdk" + {:warn, "Android SDK", "ANDROID_HOME not set and sdk.dir not found in android/local.properties", "Set ANDROID_HOME in your shell profile:\n" <> - " export ANDROID_HOME=$HOME/Library/Android/sdk # macOS default\n" <> + " export ANDROID_HOME=#{default_path}\n" <> " or open the android/ folder in Android Studio (it writes local.properties)"} end end @@ -300,7 +464,7 @@ defmodule Mix.Tasks.Mob.Doctor do path = Path.join(File.cwd!(), "android/local.properties") with {:ok, content} <- File.read(path), - [_, sdk] <- Regex.run(~r/^sdk\.dir=(.+)$/m, content) do + [_, sdk] <- Regex.run(Regex.compile!("^sdk\\.dir=(.+)$", "m"), content) do String.trim(sdk) else _ -> nil @@ -312,13 +476,13 @@ defmodule Mix.Tasks.Mob.Doctor do check_required( "python3", :fail, - "required by ios/build.sh to detect the booted simulator", + "required by NativeBuild's iOS pipeline (EPMD source patching for in-process startup)", "python3 is included with macOS Xcode command-line tools:\n xcode-select --install" ), check_required( "rsync", :fail, - "required by ios/build.sh to sync the OTP runtime to /tmp/otp-ios-sim", + "required by NativeBuild's iOS pipeline to sync the OTP runtime to ~/.mob/runtime/ios-sim", "rsync is included with macOS — if missing:\n brew install rsync" ) ] @@ -407,13 +571,96 @@ defmodule Mix.Tasks.Mob.Doctor do if File.exists?("mix.exs") do List.flatten([ check_deps_fetched(), - check_compiled() + check_compiled(), + check_driver_tab(), + check_plugin_build_options() ]) else [] end end + # ── Plugin build options ────────────────────────────────────────────────────── + # + # When activated plugins contribute native code, the native build passes + # -Dplugin_* flags to the project's build.zig files. An app scaffolded before + # the plugin system declares no b.option for them, and Zig hard-rejects an + # unknown -D flag — half a build in, with nothing pointing at the real cause. + # Surface the mismatch up front. + + @plugin_build_options %{ + "android/app/src/main/jni/build.zig" => ~w(plugin_c_nifs plugin_zig_nifs plugin_jni_sources), + "ios/build.zig" => ~w(plugin_c_nifs plugin_swift_files plugin_frameworks), + "ios/build_device.zig" => ~w(plugin_c_nifs plugin_swift_files plugin_frameworks) + } + + defp check_plugin_build_options do + case MobDev.Plugin.activated() do + [] -> + [] + + _activated -> + for {path, required} <- @plugin_build_options, + {:ok, content} <- [File.read(path)], + missing = __missing_plugin_options__(content, required), + missing != [] do + {:warn, "plugin options (#{path})", + "doesn't declare #{Enum.join(missing, ", ")} — the native build emits " <> + "these -D flags for activated plugins' native code, and Zig rejects " <> + "unknown options", + "Port the plugin option block into #{path} from a freshly generated " <> + "app (mix mob.new) or mob_new's templates"} + end + end + end + + @doc false + # Pure kernel: which plugin -D option names the build file doesn't declare. + # Detection is the quoted option name (how every template's b.option call + # spells it). Public for tests. + @spec __missing_plugin_options__(String.t(), [String.t()]) :: [String.t()] + def __missing_plugin_options__(content, required) do + Enum.reject(required, &String.contains?(content, "\"#{&1}\"")) + end + + # ── Driver_tab manifest drift ──────────────────────────────────────────────── + # + # If the project uses the per-app generated driver_tab (Phase 0 of the build + # system migration), check that on-disk priv/generated/driver_tab_*.c match + # what the current :static_nifs declaration would produce. Drift means + # someone changed the manifest but didn't run `mix mob.regen_driver_tab`. + + defp check_driver_tab do + paths = Mix.Tasks.Mob.RegenDriverTab.target_paths() + + case Enum.filter([paths.ios, paths.android], &File.exists?/1) do + [] -> + [] + + _present -> + nifs = Mix.Tasks.Mob.RegenDriverTab.resolved_nifs() + ios_expected = MobDev.StaticNifs.generate(:ios, nifs) |> IO.iodata_to_binary() + android_expected = MobDev.StaticNifs.generate(:android, nifs) |> IO.iodata_to_binary() + + drifted = + [{paths.ios, ios_expected}, {paths.android, android_expected}] + |> Enum.filter(fn {path, expected} -> + File.exists?(path) and File.read!(path) != expected + end) + |> Enum.map(fn {path, _} -> path end) + + case drifted do + [] -> + {:ok, "driver_tab", "in sync with :static_nifs", nil} + + paths -> + {:warn, "driver_tab", + "drift detected — these files don't match :static_nifs:\n - " <> + Enum.join(paths, "\n - "), "Run: mix mob.regen_driver_tab"} + end + end + end + defp check_deps_fetched do lock_exists = File.exists?("mix.lock") deps_dir = File.exists?("deps") and File.ls!("deps") != [] @@ -446,7 +693,9 @@ defmodule Mix.Tasks.Mob.Doctor do case File.read("mix.lock") do {:ok, content} -> # Each dep appears as a quoted key on its own line: "dep_name": { - content |> String.split("\n") |> Enum.count(&Regex.match?(~r/^\s+"[^"]+":/, &1)) + content + |> String.split("\n") + |> Enum.count(&Regex.match?(Regex.compile!("^\\s+\"[^\"]+\":"), &1)) _ -> 0 @@ -549,7 +798,7 @@ defmodule Mix.Tasks.Mob.Doctor do {out, 0} -> lines = out |> String.split("\n") |> Enum.drop(1) |> Enum.reject(&(&1 == "")) - authorized = Enum.filter(lines, &(&1 =~ ~r/[\t,\s]device\s/)) + authorized = Enum.filter(lines, &(&1 =~ Regex.compile!("[\\t,\\s]device\\s"))) unauthorized = Enum.filter(lines, &(&1 =~ "\tunauthorized")) offline = Enum.filter(lines, &(&1 =~ "\toffline")) @@ -569,7 +818,7 @@ defmodule Mix.Tasks.Mob.Doctor do serial = line |> String.split() |> hd() model = - case Regex.run(~r/model:(\S+)/, line) do + case Regex.run(Regex.compile!("model:(\\S+)"), line) do [_, m] -> String.replace(m, "_", " ") nil -> serial end @@ -685,7 +934,7 @@ defmodule Mix.Tasks.Mob.Doctor do # ── Helpers ────────────────────────────────────────────────────────────────── defp has_android_project?, do: File.dir?("android") - defp has_ios_project?, do: File.exists?("ios/build.sh") + defp has_ios_project?, do: File.exists?("ios/build.zig") defp macos?, do: match?({:unix, :darwin}, :os.type()) defp ansi(:cyan), do: IO.ANSI.cyan() diff --git a/lib/mix/tasks/mob.emulators.ex b/lib/mix/tasks/mob.emulators.ex new file mode 100644 index 0000000..d8690f0 --- /dev/null +++ b/lib/mix/tasks/mob.emulators.ex @@ -0,0 +1,327 @@ +defmodule Mix.Tasks.Mob.Emulators do + use Mix.Task + + @shortdoc "List, start, and stop Android emulators / iOS simulators" + + @moduledoc """ + Manage virtual devices: Android emulators (AVDs) and iOS simulators. + + ## Examples + + mix mob.emulators # list all (default) + mix mob.emulators --list # same as above + mix mob.emulators --list --android # Android only + mix mob.emulators --list --ios # iOS only + + mix mob.emulators --start --id Pixel_8_API_34 + mix mob.emulators --start --id 78354490 + + mix mob.emulators --stop --id emulator-5554 + mix mob.emulators --stop --id 78354490 + mix mob.emulators --stop --all # everything booted + + `--id` accepts the same display IDs `mix mob.devices` shows, plus AVD + names. For Android the running serial (`emulator-5554`) also works. + + Out of scope: creating new AVDs or installing simulator runtimes — those + involve license acceptance and multi-GB downloads. Use Android Studio / + Xcode for that. + """ + + alias MobDev.{Device, Emulators} + + @switches [ + list: :boolean, + start: :boolean, + stop: :boolean, + android: :boolean, + ios: :boolean, + id: :string, + all: :boolean + ] + + @impl Mix.Task + def run(args) do + {opts, _, _} = OptionParser.parse(args, switches: @switches) + + cond do + opts[:start] -> do_start(opts) + opts[:stop] -> do_stop(opts) + true -> do_list(opts) + end + end + + # ── List ────────────────────────────────────────────────────────────────── + + defp do_list(opts) do + # Both shown if neither flag specified, otherwise only the requested one(s). + android? = Keyword.get(opts, :android, false) + ios? = Keyword.get(opts, :ios, false) + # `or` short-circuits, so the right operand only evaluates when the + # left is false — at which point `not android?` / `not ios?` is + # redundant and Elixir 1.20's type checker flags it. Simplified: + # show the unrequested one only when the requested one isn't asked + # for at all (i.e. no flags → show both). + show_android = android? or not ios? + show_ios = ios? or not android? + + IO.puts("") + + if show_android do + print_android_section() + IO.puts("") + end + + if show_ios do + print_ios_section() + IO.puts("") + end + end + + defp print_android_section do + IO.puts("#{cyan()}Android emulators (AVDs)#{reset()}") + + case Emulators.list_android() do + {:ok, []} -> + IO.puts(" (no AVDs configured — create one in Android Studio)") + + {:ok, avds} -> + Enum.each(avds, &print_avd/1) + + {:error, reason} -> + IO.puts(" #{yellow()}#{reason}#{reset()}") + end + end + + defp print_ios_section do + IO.puts("#{cyan()}iOS simulators#{reset()}") + + case Emulators.list_ios() do + {:ok, sims} -> + # Group by runtime and sort booted-first within each group. + sims + |> Enum.sort_by(&{&1.runtime, not &1.running, &1.name}) + |> Enum.each(&print_sim/1) + + {:error, reason} -> + IO.puts(" #{yellow()}#{reason}#{reset()}") + end + end + + defp print_avd(%Emulators{platform: :android} = a) do + dot = if a.running, do: "#{green()}●#{reset()}", else: "○" + suffix = if a.running, do: " #{dim()}(running, #{a.serial})#{reset()}", else: "" + IO.puts(" #{dot} #{bold()}#{a.name}#{reset()}#{suffix}") + end + + defp print_sim(%Emulators{platform: :ios} = s) do + dot = if s.running, do: "#{green()}●#{reset()}", else: "○" + state = if s.running, do: "booted, ", else: "" + short_id = String.replace(s.id, "-", "") |> String.slice(0, 8) |> String.downcase() + + IO.puts( + " #{dot} #{bold()}#{pad(s.name, 28)}#{reset()} #{s.runtime} #{dim()}(#{state}#{short_id})#{reset()}" + ) + end + + # ── Start ───────────────────────────────────────────────────────────────── + + defp do_start(opts) do + id = opts[:id] + + if is_nil(id) do + Mix.raise("--start requires --id <id>. See `mix mob.emulators --list` for IDs.") + end + + case resolve(id) do + {:android, %Emulators{name: avd_name, running: false}} -> + IO.puts("Starting Android emulator: #{avd_name}") + + case Emulators.start_android(avd_name) do + :ok -> + IO.puts( + "#{green()}Started.#{reset()} (boots in background — `adb wait-for-device` to block)" + ) + + {:error, reason} -> + Mix.raise(reason) + end + + {:android, %Emulators{name: avd_name, running: true, serial: serial}} -> + IO.puts("Already running: #{avd_name} (#{serial})") + + {:ios, %Emulators{name: name, id: udid, running: false}} -> + IO.puts("Booting iOS simulator: #{name}") + + case Emulators.start_ios(udid) do + :ok -> IO.puts("#{green()}Booted.#{reset()}") + {:error, reason} -> Mix.raise(reason) + end + + {:ios, %Emulators{name: name, running: true}} -> + IO.puts("Already booted: #{name}") + + :not_found -> + Mix.raise("No emulator/simulator matched #{inspect(id)}. Run `mix mob.emulators --list`.") + end + end + + # ── Stop ────────────────────────────────────────────────────────────────── + + defp do_stop(opts) do + cond do + opts[:all] -> + do_stop_all(opts) + + opts[:id] -> + do_stop_one(opts[:id]) + + true -> + Mix.raise( + "--stop needs either --id <id> or --all. " <> + "Use --all to stop every running emulator/simulator." + ) + end + end + + defp do_stop_one(id) do + case resolve(id) do + {:android, %Emulators{running: true, serial: serial, name: name}} -> + IO.puts("Stopping Android emulator: #{name} (#{serial})") + + case Emulators.stop_android(serial) do + :ok -> IO.puts("#{green()}Stopped.#{reset()}") + {:error, reason} -> Mix.raise(reason) + end + + {:android, %Emulators{running: false, name: name}} -> + IO.puts("Not running: #{name}") + + {:ios, %Emulators{running: true, id: udid, name: name}} -> + IO.puts("Shutting down iOS simulator: #{name}") + + case Emulators.stop_ios(udid) do + :ok -> IO.puts("#{green()}Stopped.#{reset()}") + {:error, reason} -> Mix.raise(reason) + end + + {:ios, %Emulators{running: false, name: name}} -> + IO.puts("Not booted: #{name}") + + :not_found -> + Mix.raise("No emulator/simulator matched #{inspect(id)}. Run `mix mob.emulators --list`.") + end + end + + defp do_stop_all(opts) do + # Both shown if neither flag specified, otherwise only the requested one(s). + android? = Keyword.get(opts, :android, false) + ios? = Keyword.get(opts, :ios, false) + # `or` short-circuits, so the right operand only evaluates when the + # left is false — at which point `not android?` / `not ios?` is + # redundant and Elixir 1.20's type checker flags it. Simplified: + # show the unrequested one only when the requested one isn't asked + # for at all (i.e. no flags → show both). + show_android = android? or not ios? + show_ios = ios? or not android? + + running = + [] + |> then(fn acc -> + if show_android do + case Emulators.list_android() do + {:ok, avds} -> acc ++ Enum.filter(avds, & &1.running) + _ -> acc + end + else + acc + end + end) + |> then(fn acc -> + if show_ios do + case Emulators.list_ios() do + {:ok, sims} -> acc ++ Enum.filter(sims, & &1.running) + _ -> acc + end + else + acc + end + end) + + if running == [] do + IO.puts("No running emulators or simulators.") + else + names = Enum.map_join(running, ", ", & &1.name) + IO.puts("Stopping #{length(running)} running: #{names}") + + Enum.each(running, fn + %Emulators{platform: :android, serial: serial, name: name} -> + case Emulators.stop_android(serial) do + :ok -> IO.puts(" #{green()}✓#{reset()} #{name}") + {:error, reason} -> IO.puts(" #{red()}✗#{reset()} #{name}: #{reason}") + end + + %Emulators{platform: :ios, id: udid, name: name} -> + case Emulators.stop_ios(udid) do + :ok -> IO.puts(" #{green()}✓#{reset()} #{name}") + {:error, reason} -> IO.puts(" #{red()}✗#{reset()} #{name}: #{reason}") + end + end) + end + end + + # ── Resolution ──────────────────────────────────────────────────────────── + + # Try Android first then iOS — they don't share id formats so collisions + # are vanishingly rare. Match against the AVD name (Android), the running + # adb serial (Android), the UDID (iOS), or the 8-char display id (iOS). + defp resolve(id) do + android_match = + case Emulators.list_android() do + {:ok, avds} -> Enum.find(avds, &android_id_match?(&1, id)) + _ -> nil + end + + if android_match do + {:android, android_match} + else + case Emulators.list_ios() do + {:ok, sims} -> + case Enum.find(sims, &ios_id_match?(&1, id)) do + nil -> :not_found + sim -> {:ios, sim} + end + + _ -> + :not_found + end + end + end + + defp android_id_match?(%Emulators{name: name, serial: serial}, id) do + String.downcase(name) == String.downcase(id) or + (serial != nil and String.downcase(serial) == String.downcase(id)) + end + + defp ios_id_match?(%Emulators{id: udid}, id) do + # Build a fake Device just to reuse Device.match_id?/2's "display_id or serial" + # logic. Simulator display_id = first 8 hex chars of UDID with dashes removed. + fake = %Device{platform: :ios, type: :simulator, serial: udid} + Device.match_id?(fake, id) + end + + # ── ANSI helpers ────────────────────────────────────────────────────────── + + defp cyan, do: IO.ANSI.cyan() + defp green, do: IO.ANSI.green() + defp yellow, do: IO.ANSI.yellow() + defp red, do: IO.ANSI.red() + defp bold, do: IO.ANSI.bright() + defp dim, do: IO.ANSI.faint() + defp reset, do: IO.ANSI.reset() + + defp pad(s, n) do + pad_len = max(n - String.length(s), 0) + s <> String.duplicate(" ", pad_len) + end +end diff --git a/lib/mix/tasks/mob.enable.ex b/lib/mix/tasks/mob.enable.ex new file mode 100644 index 0000000..2319f47 --- /dev/null +++ b/lib/mix/tasks/mob.enable.ex @@ -0,0 +1,370 @@ +defmodule Mix.Tasks.Mob.Enable do + use Igniter.Mix.Task + + @shortdoc "Enable optional Mob features in this project" + + @moduledoc """ + Enables one or more optional Mob features by patching `mix.exs`, manifest + files, and generating any required source files. + + ## Usage + + mix mob.enable FEATURE [FEATURE ...] + + Multiple features can be enabled in a single command: + + mix mob.enable camera photo_library + mix mob.enable camera photo_library file_sharing liveview + + ## Features + + ### `liveview` + + Enables LiveView mode — the Mob app runs a local Phoenix endpoint and displays + it in a native WebView. Web developers can ship a mobile app with zero native + UI code. + + What it does: + + - Generates `lib/<app>/mob_screen.ex` — a `Mob.Screen` that opens a WebView + at `http://127.0.0.1:PORT/` + - Injects the `MobHook` LiveView hook into `assets/js/app.js` + - Injects a hidden `<div id="mob-bridge" phx-hook="MobHook">` into + `root.html.heex` — **this is required for the hook to mount** + - Updates `mob.exs` with `liveview_port` so `Mob.LiveView.local_url/1` works + + ### Why the hidden div is required + + Phoenix LiveView hooks only execute when a DOM element carrying + `phx-hook="MobHook"` exists in the rendered page. Registering `MobHook` in + `app.js` is necessary but not sufficient — without a matching DOM element the + hook never mounts and `window.mob` is never replaced with the LiveView-backed + version. Messages would silently route through the native NIF bridge instead + of the LiveView WebSocket, so `handle_event/3` would never fire. + + See `MobDev.Enable` module doc and `guides/liveview.md` for the full + two-bridge architecture explanation. + + After running: + + 1. Add `MyApp.MobScreen` to your supervision tree (or call + `Mob.Screen.start_root(MyApp.MobScreen)` from your `Mob.App.on_start/0`) + 2. Ensure Phoenix is running on the port set in `mob.exs` (default: 4000) + + ### `camera` + + Adds camera permission declarations to platform manifests. + + - iOS: adds `NSCameraUsageDescription` to `ios/*/Info.plist` + - Android: adds `<uses-permission android:name="android.permission.CAMERA"/>` + to `android/app/src/main/AndroidManifest.xml` + + ### `photo_library` + + - iOS: adds `NSPhotoLibraryAddUsageDescription` to Info.plist + - Android: no manifest change needed (API 29+) + + ### `file_sharing` + + - iOS: adds `UIFileSharingEnabled` and `LSSupportsOpeningDocumentsInPlace` + to Info.plist + - Android: adds `<provider android:name="FileProvider">` with paths config + + ### `location` + + - iOS: adds `NSLocationWhenInUseUsageDescription` to Info.plist + - Android: adds `ACCESS_FINE_LOCATION` permission + + ### `notifications` + + - iOS: creates `ios/<app>.entitlements` with `aps-environment: development`. + After running, execute `mix mob.provision` so Xcode downloads a push-capable + provisioning profile. Then call `Mob.Permissions.request(socket, :notifications)` + and `Mob.Notify.register_push(socket)` at runtime to obtain a device token. + - Android: runtime only — `POST_NOTIFICATIONS` is requested at runtime, no + manifest key needed. + + ### `pythonx` + + Enables embedded CPython via [Pythonx](https://hex.pm/packages/pythonx) + on iOS **and** Android. + + - **iOS:** BeeWare's [`Python-Apple-support`](https://github.com/beeware/Python-Apple-support) + `Python.xcframework` is bundled by `mix mob.deploy --native`. + - **Android:** [Chaquopy](https://chaquo.com/chaquopy/)'s prebuilt CPython + is unpacked at first launch. `libpythonx.so` (the Pythonx NIF) is + cross-compiled with the Android NDK against a stub `libpython3.13.so` + so the BEAM dynamic loader is satisfied; the real lib resolves at + runtime via SONAME match. + - **Bare CPython only.** Bundles ship the interpreter, stdlib, and + standard C extensions (`_ssl`, `_ctypes`, `_hashlib`, …). Third-party + wheels (`cryptography`, `numpy`, `RNS`, …) are out of scope — + produce your own (BeeWare's [`mobile-forge`](https://github.com/beeware/mobile-forge) + on iOS, Chaquopy's wheel pipeline on Android) and drop them into + your project. + + What it does: + + - Adds `{:pythonx, "~> 0.4"}` to `mix.exs` deps. + - Generates `lib/<app>/python_paths.ex` — pure detection module that + locates the bundled framework at runtime (`:desktop` / + `{:ios, paths}` / `{:android, paths}` / `{:partial, missing}`). + - **No `:uv_init` config patch.** Pythonx ships an Application + that auto-runs uv at boot if `:uv_init` is in compile-time config, + and uv doesn't exist on device. Instead the on_start template + inlines `pyproject_toml` and calls `Pythonx.Uv.fetch/2 + + Pythonx.Uv.init/2` only on the `:desktop` branch. Same code path + `iex -S mix` would use, just opt-in. + + Bundle size impact: ~70 MB on iOS, ~30 MB on Android (interpreter + + stdlib + arch-specific C extensions). Apply this only when you actually + want to call Python from BEAM — non-Python apps stay vanilla. + + After running, your `Mob.App.on_start/0` should: + + - call `Application.ensure_all_started(:pythonx)` (starts the + `Pythonx.Janitor`, required for `Pythonx.eval/3`); + - case-match `<App>.PythonPaths.detect/1` and call `Pythonx.Uv.fetch + + init` on `:desktop` (provisioning a uv-managed CPython on first + run) or `Pythonx.init/4` on `{:ios, _}` / `{:android, _}`. + + See `guides/python_embedding.md` for the full template. + + ### `mlx` + + Enables Apple's [MLX](https://github.com/ml-explore/mlx) library + the + [EMLX](https://hex.pm/packages/emlx) Nx backend on iOS. Gives the app + fast on-device tensor math (matmul, FFT, linalg, etc.) backed by + Apple's Accelerate framework (vectorized BLAS/LAPACK). + + - **iOS device + simulator:** `libmlx.a` + `libemlx.a` are + cross-compiled and statically linked into the app binary. The + pre-built bundle (~5 MB compressed, ~30 MB on disk per arch) is + downloaded once and cached at `~/.mob/cache/libmlx-<ver>-ios-<slice>/` + by `MobDev.MLXDownloader`. `MOB_STATIC_EMLX_NIF` flips on + automatically — the EMLX NIF is registered in the static-NIF table + so `load_nif/2` resolves it without dlopen. + - **Android:** not supported in v1. No Metal on Android — a CPU-only + NDK build via OpenBLAS is the path forward but isn't shipped yet. + - **CPU-only for v1.** Metal-on-iOS needs the iOS-Metal CMakeLists + patch and Xcode 16's optional Metal Toolchain — deferred to a v2 + tarball variant. + + What it does: + + - Adds `{:nx, "~> 0.10"}` and `{:emlx, "~> 0.2"}` to `mix.exs` deps. + - Generates `lib/<app>/ml_init.ex` — a one-call helper that sets + `EMLX.Backend` as Nx's global default, with a clean + `Nx.BinaryBackend` fallback if the NIF can't load. + + After running, your `Mob.App.on_start/0` should call + `<App>.MLInit.configure()` once `Mob.Screen.start_root/1` and + `Mob.Dist.ensure_started/1` have run. + """ + + @valid_features ~w(liveview camera photo_library file_sharing location notifications pythonx mlx nxeigen tflite) + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :mob, + schema: [], + # mob.enable takes a variable list of feature names; we parse those + # from `igniter.args.argv` ourselves rather than declaring a single + # positional binding. + positional: [] + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + features = parse_features(igniter.args.argv) + unknown = features -- @valid_features + + cond do + features == [] -> + Igniter.add_issue( + igniter, + "Usage: mix mob.enable FEATURE [FEATURE ...]\nValid features: #{Enum.join(@valid_features, ", ")}" + ) + + unknown != [] -> + Igniter.add_issue( + igniter, + "Unknown feature(s): #{Enum.join(unknown, ", ")}. Valid: #{Enum.join(@valid_features, ", ")}" + ) + + not File.exists?("mix.exs") -> + Igniter.add_issue(igniter, "No mix.exs found. Run mix mob.enable from your project root.") + + true -> + # Read app name from Igniter's view of mix.exs (which respects + # `test_project(files: %{"mix.exs" => ...})` virtualization). + # Falls back to the on-disk read when the igniter doesn't have a + # mix.exs source yet (real invocation outside a test harness). + app_name = + case Igniter.Project.Application.app_name(igniter) do + nil -> read_app_name(File.cwd!()) + atom -> Atom.to_string(atom) + end + + Enum.reduce(features, igniter, fn feature, acc -> + dispatch(acc, feature, app_name) + end) + end + end + + # ── Feature dispatch (Phase 4 iter 1: all features routed through Igniter) ── + + alias MobDev.Enable.Igniter, as: EI + + defp dispatch(igniter, "camera", app_name), do: EI.enable_camera(igniter, app_name) + + defp dispatch(igniter, "photo_library", app_name), + do: EI.enable_photo_library(igniter, app_name) + + defp dispatch(igniter, "location", app_name), do: EI.enable_location(igniter, app_name) + defp dispatch(igniter, "file_sharing", app_name), do: EI.enable_file_sharing(igniter, app_name) + + defp dispatch(igniter, "notifications", app_name), + do: EI.enable_notifications(igniter, app_name) + + defp dispatch(igniter, "liveview", app_name), do: EI.enable_liveview(igniter, app_name) + + defp dispatch(igniter, "pythonx", app_name) do + igniter + |> EI.enable_python(app_name) + |> Igniter.add_notice(pythonx_next_steps(app_name)) + end + + defp dispatch(igniter, "mlx", app_name) do + igniter + |> EI.enable_mlx(app_name) + |> Igniter.add_notice(mlx_next_steps(app_name)) + end + + defp dispatch(igniter, "nxeigen", app_name) do + igniter + |> EI.enable_nxeigen(app_name) + |> Igniter.add_notice(nxeigen_next_steps(app_name)) + end + + defp dispatch(igniter, "tflite", app_name) do + igniter + |> EI.enable_tflite(app_name) + |> Igniter.add_notice(tflite_next_steps(app_name)) + end + + defp parse_features(argv) do + {_opts, features, _} = OptionParser.parse(argv, strict: [yes: :boolean]) + features + end + + defp mlx_next_steps(app_name) do + module = Macro.camelize(app_name) + + """ + Next steps for mlx: + 1. Run `mix deps.get` to fetch :nx + :emlx. + 2. In your `Mob.App.on_start/0`, call: + #{module}.MLInit.configure() + after `Mob.Screen.start_root/1` and `Mob.Dist.ensure_started/1`. + It picks EMLX as Nx's global backend (CPU + Apple Accelerate on + iOS) and falls back to Nx.BinaryBackend if EMLX can't load. + 3. `mix mob.deploy --native --device <udid>` to cross-compile and + install the app. The first build downloads ~5 MB of pre-built + MLX (libmlx.a + libemlx.a) into ~/.mob/cache/. + + Bundle size impact: ~5 MB compressed (~30 MB on disk per arch). + Apply this only when you actually want fast on-device tensor math. + + iOS-only for v1. Android MLX support is a separate cross-compile + (no Metal — CPU + NDK BLAS); not shipped yet. + """ + end + + defp nxeigen_next_steps(app_name) do + module = Macro.camelize(app_name) + + """ + Next steps for nxeigen: + 1. Run `mix deps.get` to fetch :nx + :nx_eigen. + 2. Run `mix deps.compile nx_eigen` once on host — this triggers + the auto-download of the Eigen 3.4.0 header tarball into + deps/nx_eigen/eigen-3.4.0/, which mob_dev's cross-compile + then references. + 3. In your `Mob.App.on_start/0`, call: + #{module}.NxEigenInit.configure() + after `Mob.Screen.start_root/1` and `Mob.Dist.ensure_started/1`. + It picks NxEigen as Nx's global backend (Eigen CPU, header-only, + NEON-vectorised on ARM) and falls back to Nx.BinaryBackend if + the NIF can't load. + 4. `mix mob.deploy --native --device <udid>` to cross-compile and + install the app. The first build cross-compiles libnx_eigen.a + (two .cpp files: NxEigen's main NIF + our Eigen-FFT bridge) + per target arch into `_build/<env>/nxeigen/`. + + Works on BOTH iOS (device + sim) and Android (arm64 + arm32) — + Eigen is header-only C++. FFT support uses Eigen's built-in + kissfft (header-only); swap to a FFTW variant later if needed. + """ + end + + defp tflite_next_steps(app_name) do + module = Macro.camelize(app_name) + + """ + Next steps for tflite: + 1. Run `mix deps.get` to fetch :nx + :nx_tflite_mob. + 2. Drop a `.tflite` model into `priv/` (e.g. exported from Ultralytics: + yolo export model=yolov8n.pt format=tflite int8=True + produces `yolov8n_full_integer_quant.tflite`). + 3. Use in app code: + tflite = File.read!(Path.join(:code.priv_dir(:#{app_name}), "yolov8n_full_integer_quant.tflite")) + {:ok, m} = NxTfliteMob.load_module(tflite, #{module}.TfliteInit.default_opts()) + {:ok, [out]} = NxTfliteMob.call(m, [input_bytes]) + 4. `mix mob.deploy --native --device <udid>` to cross-compile and install. + First build downloads: + - Android: tensorflow-lite-2.16.1.aar (~6 MB) into ~/.mob/cache/ + - iOS: TensorFlowLiteC-2.17.0.tar.gz (~77 MB) into ~/.mob/cache/ + + Bundle size impact: ~3-4 MB compressed (Android `libtensorflowlite_jni.so`); + ~20-30 MB on iOS (TensorFlowLiteC + CoreML/Metal frameworks). Apply this + only when you actually want to run TFLite models. + + Cross-platform: same `.tflite` model + same `NxTfliteMob.call/2` Elixir + code on iOS and Android. The per-platform delegate is picked by + `#{module}.TfliteInit.default_opts/0` automatically. + """ + end + + defp pythonx_next_steps(app_name) do + module = Macro.camelize(app_name) + + """ + Next steps for pythonx: + 1. Run `mix deps.get` to fetch :pythonx + 2. In your `Mob.App.on_start/0`: + {:ok, _} = Application.ensure_all_started(:pythonx) + case #{module}.PythonPaths.detect(to_string(:code.root_dir())) do + :desktop -> Pythonx.Uv.fetch(toml, false); Pythonx.Uv.init(toml, false) + {:ios, %{dl_path: dl, home_path: home}} -> Pythonx.init(dl, home, dl, sys_paths: []) + {:android, %{dl_path: dl, home_path: home}} -> Pythonx.init(dl, home, dl, sys_paths: [Path.join([home, "lib", "python3.13"])]) + {:partial, missing} -> # log + bail out + end + pyproject_toml stays inline so Pythonx.Application doesn't auto-run uv + on device (where uv doesn't exist). + 3. `mix mob.deploy --native --device <udid>` to bundle CPython + + install on device. iOS downloads the BeeWare framework on first + run; Android pulls Chaquopy's prebuilt distribution. + """ + end + + # ── Helpers ─────────────────────────────────────────────────────────────── + + defp read_app_name(project_dir) do + MobDev.Enable.read_app_name_from(Path.join(project_dir, "mix.exs")) + rescue + e -> Mix.raise(Exception.message(e)) + end +end diff --git a/lib/mix/tasks/mob.gen.live_screen.ex b/lib/mix/tasks/mob.gen.live_screen.ex new file mode 100644 index 0000000..2102c84 --- /dev/null +++ b/lib/mix/tasks/mob.gen.live_screen.ex @@ -0,0 +1,217 @@ +defmodule Mix.Tasks.Mob.Gen.LiveScreen do + use Mix.Task + + @shortdoc "Generate a LiveView + Mob.Screen pair" + + @moduledoc """ + Generates a paired `Mob.Screen` and Phoenix `LiveView` for LiveView mode apps. + + ## Usage + + mix mob.gen.live_screen NAME [PATH] + + `NAME` is the LiveView module name (PascalCase). `PATH` is the URL path + (defaults to `/name` derived from `NAME`). + + ## Examples + + mix mob.gen.live_screen Dashboard + # → lib/<app>_web/live/dashboard_live.ex (LiveView) + # → lib/<app>/screens/dashboard_screen.ex (Mob.Screen) + + mix mob.gen.live_screen Settings /preferences + # → path override: /preferences + + ## What gets generated + + ### LiveView (`lib/<app>_web/live/<name>_live.ex`) + + defmodule MyAppWeb.DashboardLive do + use MyAppWeb, :live_view + use Mob.LiveView + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def render(assigns) do + ~H\""" + <div> + <h1>Dashboard</h1> + </div> + \""" + end + + # Receive messages from the native layer: + # window.mob.send({ type: "back" }) + def handle_event("mob_message", _data, socket) do + {:noreply, socket} + end + + # Push messages to the native layer JS: + # push_event(socket, "mob_push", %{type: "haptic"}) + end + + ### Mob.Screen (`lib/<app>/screens/<name>_screen.ex`) + + defmodule MyApp.DashboardScreen do + use Mob.Screen + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def render(_assigns) do + Mob.UI.webview( + url: Mob.LiveView.local_url("/dashboard"), + show_url: false + ) + end + end + + ## Router note + + Add the LiveView to your Phoenix router: + + live "/dashboard", DashboardLive + + Then navigate to the screen from Elixir: + + Mob.Socket.navigate(socket, {:push, MyApp.DashboardScreen}) + """ + + @impl Mix.Task + def run(argv) do + case argv do + [] -> + Mix.raise("Usage: mix mob.gen.live_screen NAME [PATH]") + + [name | rest] -> + path = List.first(rest) + project_dir = File.cwd!() + + unless File.exists?(Path.join(project_dir, "mix.exs")) do + Mix.raise("No mix.exs found. Run from your project root.") + end + + app_name = read_app_name(project_dir) + generate(project_dir, app_name, name, path) + end + end + + # ── Generation ──────────────────────────────────────────────────────────── + + defp generate(project_dir, app_name, name, path_override) do + module_name = Macro.camelize(app_name) + snake_name = Macro.underscore(name) + url_path = path_override || "/#{snake_name}" + + live_module = "#{module_name}Web.#{name}Live" + screen_module = "#{module_name}.#{name}Screen" + web_module = "#{module_name}Web" + + live_path = + Path.join([project_dir, "lib", "#{app_name}_web", "live", "#{snake_name}_live.ex"]) + + screen_path = Path.join([project_dir, "lib", app_name, "screens", "#{snake_name}_screen.ex"]) + + write_file(live_path, live_view_template(live_module, web_module, name, snake_name)) + write_file(screen_path, screen_template(screen_module, url_path)) + + Mix.shell().info(""" + + Generated: + #{live_path} + #{screen_path} + + Next steps: + + 1. Add the route to your Phoenix router (lib/#{app_name}_web/router.ex): + + live "#{url_path}", #{name}Live + + 2. Navigate to the screen from Elixir: + + Mob.Socket.navigate(socket, {:push, #{screen_module}}) + + 3. Send events from JS to Elixir: + + window.mob.send({ type: "action", payload: "hello" }) + + Handle them in #{live_module}: + + def handle_event("mob_message", %{"type" => "action"} = data, socket) do + {:noreply, socket} + end + """) + end + + defp write_file(path, content) do + if File.exists?(path) do + Mix.shell().info(" * skip #{path} (already exists)") + else + File.mkdir_p!(Path.dirname(path)) + File.write!(path, content) + Mix.shell().info([:green, " * create ", :reset, path]) + end + end + + # ── Templates ───────────────────────────────────────────────────────────── + + defp live_view_template(live_module, web_module, name, snake_name) do + display = name |> String.replace(Regex.compile!("([A-Z])"), " \\1") |> String.trim() + + """ + defmodule #{live_module} do + use #{web_module}, :live_view + use Mob.LiveView + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def render(assigns) do + ~H\"\"\" + <div class="mob-screen" id="#{snake_name}"> + <h1>#{display}</h1> + </div> + \"\"\" + end + + # Receive messages from the native layer via window.mob.send(data). + def handle_event("mob_message", _data, socket) do + {:noreply, socket} + end + + # Push to native layer: push_event(socket, "mob_push", %{...}) + end + """ + end + + defp screen_template(screen_module, url_path) do + """ + defmodule #{screen_module} do + use Mob.Screen + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def render(_assigns) do + Mob.UI.webview( + url: Mob.LiveView.local_url("#{url_path}"), + show_url: false + ) + end + end + """ + end + + # ── Helpers ─────────────────────────────────────────────────────────────── + + defp read_app_name(project_dir) do + MobDev.Enable.read_app_name_from(Path.join(project_dir, "mix.exs")) + rescue + e -> Mix.raise(Exception.message(e)) + end +end diff --git a/lib/mix/tasks/mob.icon.ex b/lib/mix/tasks/mob.icon.ex index 3d4aa04..1d45b21 100644 --- a/lib/mix/tasks/mob.icon.ex +++ b/lib/mix/tasks/mob.icon.ex @@ -8,35 +8,42 @@ defmodule Mix.Tasks.Mob.Icon do Must be run from the project root (the directory containing `mix.exs`). - mix mob.icon # random robot avatar - mix mob.icon --source PATH # resize an existing image + mix mob.icon # random robot avatar + mix mob.icon --source PATH # resize an existing image + mix mob.icon --source PATH --adaptive # also emit adaptive Android icons + mix mob.icon --source PATH --adaptive --adaptive-bg "#E8B53C" ## Output Writes icons into the current project directory: - - `android/app/src/main/res/mipmap-*/ic_launcher.png` + - `android/app/src/main/res/mipmap-*/ic_launcher.png` (legacy) - `ios/Assets.xcassets/AppIcon.appiconset/icon_*.png` - `ios/Assets.xcassets/AppIcon.appiconset/Contents.json` - `icon_source.png` (1024×1024 master, only when generating) - ## Under the hood + With `--adaptive`, also writes: + + - `android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml` + - `android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml` + - `android/app/src/main/res/mipmap-*/ic_launcher_foreground.png` + - `android/app/src/main/res/values/ic_launcher_background.xml` - `mix mob.icon` uses the `image` Elixir library (backed by `libvips`) to resize a - 1024×1024 source PNG into every required platform size: + Adaptive icons are what modern Android launchers (Pixel, Samsung, Moto…) + expect: a foreground layer + a background colour, masked by the launcher + to whatever shape it prefers (circle, squircle, teardrop). Without them, + legacy icons get shrunk inside a launcher-supplied white circle. - # Android (mipmap-mdpi through mipmap-xxxhdpi: 48px → 192px) - Image.thumbnail(source, size) |> Image.write(dest) + ## Under the hood - # iOS (20px → 1024px, all required AppIcon sizes) - Image.thumbnail(source, size) |> Image.write(dest) - # also writes Contents.json for Xcode + `mix mob.icon` uses the `image` Elixir library (backed by `libvips`) to + resize a 1024×1024 source PNG into every required platform size. No external tools (ImageMagick, Xcode, etc.) are required — `libvips` is bundled as a precompiled NIF via the `image` dependency. """ - @switches [source: :string] + @switches [source: :string, adaptive: :boolean, adaptive_bg: :string] @impl Mix.Task def run(argv) do @@ -48,21 +55,42 @@ defmodule Mix.Tasks.Mob.Icon do Mix.raise("No mix.exs found. Run mix mob.icon from your project root.") end - case opts[:source] do - nil -> - Mix.shell().info("Generating random robot icon...") - MobDev.IconGenerator.generate_random(project_dir) - - source -> - unless File.exists?(source) do - Mix.raise("Source file not found: #{source}") - end - Mix.shell().info("Resizing icon from #{source}...") - MobDev.IconGenerator.generate_from_source(source, project_dir) + # `--adaptive-bg` (if given) sets the flatten background for the opaque iOS + # icons too, so both platforms share one background colour; otherwise it's + # sampled from the source per platform. + icon_opts = if opts[:adaptive_bg], do: [background_color: opts[:adaptive_bg]], else: [] + + source = + case opts[:source] do + nil -> + Mix.shell().info("Generating random robot icon...") + MobDev.IconGenerator.generate_random(project_dir, icon_opts) + Path.join(project_dir, "icon_source.png") + + source -> + unless File.exists?(source) do + Mix.raise("Source file not found: #{source}") + end + + Mix.shell().info("Resizing icon from #{source}...") + MobDev.IconGenerator.generate_from_source(source, project_dir, icon_opts) + source + end + + if opts[:adaptive] do + Mix.shell().info("Generating adaptive Android icons...") + MobDev.IconGenerator.generate_adaptive(source, project_dir, icon_opts) end Mix.shell().info("Icons written to #{project_dir}") Mix.shell().info(" Android: android/app/src/main/res/mipmap-*/ic_launcher.png") + + if opts[:adaptive] do + Mix.shell().info( + " Android (adaptive): mipmap-anydpi-v26/, ic_launcher_foreground.png, values/ic_launcher_background.xml" + ) + end + Mix.shell().info(" iOS: ios/Assets.xcassets/AppIcon.appiconset/") end end diff --git a/lib/mix/tasks/mob.install.ex b/lib/mix/tasks/mob.install.ex index 49b543b..43b580d 100644 --- a/lib/mix/tasks/mob.install.ex +++ b/lib/mix/tasks/mob.install.ex @@ -1,6 +1,8 @@ defmodule Mix.Tasks.Mob.Install do use Mix.Task + alias MobDev.NdkVersion + @shortdoc "First-run setup for a new Mob project" @moduledoc """ @@ -8,30 +10,28 @@ defmodule Mix.Tasks.Mob.Install do Must be run from inside the project directory (the one containing `mix.exs`). - mix mob.install [--no-icon] [--icon PATH] + mix mob.install [--icon PATH] ## What it does 1. Prompts for machine-specific paths (`mob_dir`, `elixir_lib`) and writes them to `mob.exs` (gitignored) and `android/local.properties` 2. Downloads and caches the pre-built OTP runtime tarballs for Android and iOS - 3. Generates app icons (random robot avatar, or a provided source image) + 3. Writes the Mob logo as a placeholder app icon (if no icon exists yet) ## Options - * `--no-icon` — skip icon generation - * `--icon PATH` — use an existing image instead of generating a random robot + * `--icon PATH` — use a custom image instead of the Mob logo placeholder ## Icon output - `android/app/src/main/res/mipmap-*/ic_launcher.png` - `ios/Assets.xcassets/AppIcon.appiconset/icon_*.png` + `Contents.json` - - `icon_source.png` (1024×1024 master, when generating) - Run at any time to regenerate icons: + The Mob logo is written as a placeholder. Replace it any time: - mix mob.install --no-icon # skip icons, re-run other setup steps - mix mob.icon # icon only, any time after install + mix mob.icon # generate a custom icon + mix mob.icon --source my_logo.png ## Under the hood @@ -50,10 +50,11 @@ defmodule Mix.Tasks.Mob.Install do curl -L https://github.com/genericjam/mob/releases/download/<tag>/otp-android-arm64.tar.gz \ -o ~/.mob_dev/otp-android-arm64.tar.gz - **3. Icon generation** — calls `mix mob.icon` internally (see that task for details). + **3. Placeholder icon** — writes the Mob logo to all platform icon sizes (pre-built PNGs, + no system tools required). Run `mix mob.icon` afterwards to replace it with a custom icon. """ - @switches [no_icon: :boolean, icon: :string] + @switches [icon: :string] @impl Mix.Task def run(argv) do @@ -67,10 +68,8 @@ defmodule Mix.Tasks.Mob.Install do configure_paths(project_dir) download_otp() - - unless opts[:no_icon] do - generate_icons(project_dir, opts[:icon]) - end + check_android_ndk() + setup_icon(project_dir, opts[:icon]) Mix.shell().info(""" @@ -80,27 +79,119 @@ defmodule Mix.Tasks.Mob.Install do mix mob.deploy # fast push + restart (day-to-day) mix mob.watch # auto-push changes while developing mix mob.connect # connect IEx to running device nodes + + Run `mix mob.icon` to replace the placeholder icon with a custom one. """) end + # ── Android NDK validation ─────────────────────────────────────────────────── + + # Mirrors `mob.doctor`'s NDK check at install time so users see the + # required-NDK message during onboarding rather than as a cryptic link + # error on first deploy. Doesn't fail the install — overrides are + # legitimate, the recommended NDK might not be installed yet on a + # fresh dev box, etc. Just warns loud. + defp check_android_ndk do + if has_android_project?() do + recommended = NdkVersion.recommended() + override = NdkVersion.override() + + cond do + override == :none and NdkVersion.installed?(recommended) -> + Mix.shell().info([:green, "* Android NDK #{recommended}: installed ✓", :reset]) + + override == :none -> + Mix.shell().error(""" + + Warning: Android NDK #{recommended} is not installed locally. + Mob's bundled OTP runtime is cross-compiled against this NDK; using a + different NDK at build time produces a libc++ ABI mismatch that surfaces + as `undefined symbol: __cxa_allocate_exception` (or similar) at link. + + Install with: + #{NdkVersion.install_command()} + + Or via Android Studio → SDK Manager → SDK Tools → NDK (Side by side) + → check #{recommended}. + + If you genuinely need a different NDK, set + config :mob_dev, android_ndk_version: "<your-version>" + in mob.exs (or export MOB_ANDROID_NDK_VERSION=<your-version>) and you'll + own the resulting ABI compatibility yourself. Run `mix mob.doctor` for + status. + """) + + true -> + {source, version} = override + source_label = if source == :env, do: "MOB_ANDROID_NDK_VERSION", else: "mob.exs" + + Mix.shell().error([ + :yellow, + "* Android NDK override active: #{version} via #{source_label} " <> + "(recommended is #{recommended}). You're off the happy path; " <> + "ABI mismatches against Mob's libbeam.a are now your problem to debug.", + :reset + ]) + end + end + end + # ── OTP download ───────────────────────────────────────────────────────────── defp download_otp do Mix.shell().info("Ensuring OTP releases are cached...") - case MobDev.OtpDownloader.ensure_android() do - {:ok, path} -> Mix.shell().info([:green, "* Android OTP: #{path}", :reset]) - {:error, reason} -> Mix.shell().error("Warning: Android OTP download failed: #{reason}") + if has_android_project?() do + case MobDev.OtpDownloader.ensure_android("arm64-v8a") do + {:ok, path} -> + Mix.shell().info([:green, "* Android arm64 OTP: #{path}", :reset]) + + {:error, reason} -> + Mix.shell().error("Warning: Android arm64 OTP download failed: #{reason}") + end + + case MobDev.OtpDownloader.ensure_android("armeabi-v7a") do + {:ok, path} -> + Mix.shell().info([:green, "* Android arm32 OTP: #{path}", :reset]) + + {:error, reason} -> + Mix.shell().error("Warning: Android arm32 OTP download failed: #{reason}") + end + + case MobDev.OtpDownloader.ensure_android("x86_64") do + {:ok, path} -> + Mix.shell().info([:green, "* Android x86_64 OTP: #{path}", :reset]) + + {:error, reason} -> + Mix.shell().error("Warning: Android x86_64 OTP download failed: #{reason}") + end + else + Mix.shell().info([:yellow, "* Android OTP skipped — no android/ in project", :reset]) end - if match?({:unix, :darwin}, :os.type()) do + if has_ios_project?() and match?({:unix, :darwin}, :os.type()) do case MobDev.OtpDownloader.ensure_ios_sim() do - {:ok, path} -> Mix.shell().info([:green, "* iOS OTP: #{path}", :reset]) + {:ok, path} -> Mix.shell().info([:green, "* iOS OTP: #{path}", :reset]) {:error, reason} -> Mix.shell().error("Warning: iOS OTP download failed: #{reason}") end + else + cond do + not has_ios_project?() -> + Mix.shell().info([:yellow, "* iOS OTP skipped — no ios/ in project", :reset]) + + not match?({:unix, :darwin}, :os.type()) -> + Mix.shell().info([:yellow, "* iOS OTP skipped — non-macOS host", :reset]) + + true -> + :ok + end end end + # Project layout detection — same predicates the doctor uses. + defp has_android_project?, do: File.dir?(Path.join(File.cwd!(), "android")) + defp has_ios_project?, do: File.exists?(Path.join(File.cwd!(), "ios/build.zig")) + # ── Path configuration ─────────────────────────────────────────────────────── # elixir_lib is no longer prompted — it's always auto-detected from the running BEAM. @@ -116,10 +207,11 @@ defmodule Mix.Tasks.Mob.Install do [] end - missing = Enum.filter(@required_keys, fn key -> - val = cfg[key] - is_nil(val) or (is_binary(val) and String.contains?(val, "/path/to/")) - end) + missing = + Enum.filter(@required_keys, fn key -> + val = cfg[key] + is_nil(val) or (is_binary(val) and String.contains?(val, "/path/to/")) + end) if missing != [] do defaults = detect_defaults(project_dir) @@ -151,7 +243,7 @@ defmodule Mix.Tasks.Mob.Install do defp detect_defaults(project_dir) do %{ elixir_lib: detect_elixir_lib(), - mob_dir: detect_mob_dir(project_dir) + mob_dir: detect_mob_dir(project_dir) } end @@ -164,8 +256,9 @@ defmodule Mix.Tasks.Mob.Install do # Falls back to deps/mob for Hex installs. defp detect_mob_dir(project_dir) do mix_exs = Path.join(project_dir, "mix.exs") + with {:ok, content} <- File.read(mix_exs), - [_, rel] <- Regex.run(~r/\{:mob,\s+path:\s+"([^"]+)"/, content) do + [_, rel] <- Regex.run(Regex.compile!("\\{:mob,\\s+path:\\s+\"([^\"]+)\""), content) do Path.expand(rel, project_dir) else _ -> @@ -176,6 +269,7 @@ defmodule Mix.Tasks.Mob.Install do defp prompt_path(key, detected) do label = prompt_label(key) + if detected do input = Mix.shell().prompt(" #{label} [#{detected}]:") |> String.trim() if input == "", do: detected, else: input @@ -184,7 +278,7 @@ defmodule Mix.Tasks.Mob.Install do end end - defp prompt_label(:mob_dir), do: "mob library path" + defp prompt_label(:mob_dir), do: "mob library path" defp prompt_label(:elixir_lib), do: "Elixir lib path" defp write_mob_exs(path, cfg) do @@ -198,19 +292,29 @@ defmodule Mix.Tasks.Mob.Install do File.write!(path, content) end - defp write_local_properties(project_dir, cfg) do + @doc false + @spec write_local_properties(String.t(), keyword()) :: :ok | nil + def write_local_properties(project_dir, cfg) do props = Path.join(project_dir, "android/local.properties") if File.exists?(props) do content = File.read!(props) - if String.contains?(content, "/path/to/") do - otp_dir = MobDev.OtpDownloader.android_otp_dir() + needs_mob_paths? = String.contains?(content, "/path/to/") + needs_sdk_dir? = not has_active_sdk_dir?(content) + + if needs_mob_paths? or needs_sdk_dir? do + otp_dir = MobDev.OtpDownloader.android_otp_dir("arm64-v8a") + otp_dir_arm32 = MobDev.OtpDownloader.android_otp_dir("armeabi-v7a") + otp_dir_x86_64 = MobDev.OtpDownloader.android_otp_dir("x86_64") new_content = content |> replace_prop("mob.otp_release", otp_dir) - |> replace_prop("mob.mob_dir", cfg[:mob_dir]) + |> replace_prop("mob.otp_release_arm32", otp_dir_arm32) + |> replace_prop("mob.otp_release_x86_64", otp_dir_x86_64) + |> replace_prop("mob.mob_dir", cfg[:mob_dir]) + |> ensure_sdk_dir(detect_android_sdk()) File.write!(props, new_content) Mix.shell().info([:green, "* android/local.properties configured", :reset]) @@ -218,23 +322,137 @@ defmodule Mix.Tasks.Mob.Install do end end - defp replace_prop(content, key, value) when not is_nil(value) do - String.replace(content, ~r/^#{Regex.escape(key)}=.*$/m, "#{key}=#{value}") + # Returns true iff local.properties has an *uncommented* `sdk.dir=...` line. + # The mob_new template ships with a commented `# sdk.dir=...` placeholder; we + # treat that as "not set" so the auto-detection writes a real value over it. + @doc false + @spec has_active_sdk_dir?(String.t()) :: boolean() + def has_active_sdk_dir?(content) do + Regex.match?(Regex.compile!("^\\s*sdk\\.dir\\s*=", "m"), content) end - defp replace_prop(content, _key, nil), do: content - # ── Icon generation ─────────────────────────────────────────────────────────── + # Inserts or updates `sdk.dir=...` in local.properties when a real SDK path + # is detected. No-op when nothing was found — better to leave the comment + # placeholder than write a bogus value. + @doc false + @spec ensure_sdk_dir(String.t(), String.t() | nil) :: String.t() + def ensure_sdk_dir(content, nil), do: content + + def ensure_sdk_dir(content, sdk_path) when is_binary(sdk_path) do + cond do + Regex.match?(Regex.compile!("^\\s*sdk\\.dir\\s*=", "m"), content) -> + # Active line already present — replace its value + Regex.replace( + Regex.compile!("^\\s*sdk\\.dir\\s*=.*$", "m"), + content, + "sdk.dir=#{sdk_path}" + ) + + Regex.match?(Regex.compile!("^\\s*#\\s*sdk\\.dir\\s*=", "m"), content) -> + # Commented placeholder — replace it with the active line + Regex.replace( + Regex.compile!("^\\s*#\\s*sdk\\.dir\\s*=.*$", "m"), + content, + "sdk.dir=#{sdk_path}" + ) + + true -> + # Neither — prepend the line so Gradle finds it without scanning the + # comment block at the top of the file + "sdk.dir=#{sdk_path}\n" <> content + end + end - defp generate_icons(project_dir, nil) do - Mix.shell().info("Generating app icon (random robot)...") - MobDev.IconGenerator.generate_random(project_dir) - Mix.shell().info([:green, "* icons written", :reset]) + # Locate the Android SDK by checking the standard env vars first, then the + # platform-default install paths Android Studio uses. Returns `nil` if + # nothing was found — `mix mob.deploy --native` will fall back to its own + # toolchain warning so the user knows what to install. + @doc false + @spec detect_android_sdk() :: String.t() | nil + def detect_android_sdk do + candidates = + [ + System.get_env("ANDROID_HOME"), + System.get_env("ANDROID_SDK_ROOT") + ] ++ default_sdk_locations() + + Enum.find(candidates, fn + nil -> false + "" -> false + path -> File.dir?(path) + end) end - defp generate_icons(project_dir, source) do + defp default_sdk_locations do + home = System.user_home!() + + case :os.type() do + {:unix, :darwin} -> + [Path.join([home, "Library", "Android", "sdk"])] + + {:unix, _} -> + # Linux. Android Studio's default is ~/Android/Sdk; package managers + # often install to /opt/android-sdk or /opt/android-sdk-linux. + [ + Path.join([home, "Android", "Sdk"]), + "/opt/android-sdk", + "/opt/android-sdk-linux" + ] + + {:win32, _} -> + [Path.join([home, "AppData", "Local", "Android", "Sdk"])] + + _ -> + [] + end + end + + @doc false + @spec replace_prop(String.t(), String.t(), String.t() | nil) :: String.t() + def replace_prop(content, key, value) when not is_nil(value) do + String.replace(content, Regex.compile!("^#{Regex.escape(key)}=.*$", "m"), "#{key}=#{value}") + end + + def replace_prop(content, _key, nil), do: content + + # ── Icon setup ──────────────────────────────────────────────────────────────── + + defp setup_icon(project_dir, nil) do + placeholder = + Path.join([ + project_dir, + "android", + "app", + "src", + "main", + "res", + "mipmap-mdpi", + "ic_launcher.png" + ]) + + if File.exists?(placeholder) do + Mix.shell().info([ + :cyan, + "* icons already present — skipping (run `mix mob.icon` to replace)", + :reset + ]) + else + Mix.shell().info("Writing Mob logo as placeholder icon...") + MobDev.IconGenerator.use_mob_logo(project_dir) + + Mix.shell().info([ + :green, + "* placeholder icons written (run `mix mob.icon` to customise)", + :reset + ]) + end + end + + defp setup_icon(project_dir, source) do unless File.exists?(source) do Mix.raise("Source file not found: #{source}") end + Mix.shell().info("Generating app icon from #{source}...") MobDev.IconGenerator.generate_from_source(source, project_dir) Mix.shell().info([:green, "* icons written", :reset]) diff --git a/lib/mix/tasks/mob.new_plugin.ex b/lib/mix/tasks/mob.new_plugin.ex new file mode 100644 index 0000000..bbf99f1 --- /dev/null +++ b/lib/mix/tasks/mob.new_plugin.ex @@ -0,0 +1,162 @@ +defmodule Mix.Tasks.Mob.NewPlugin do + use Mix.Task + + @shortdoc "Scaffold a new mob plugin (tier 0–4)" + + @moduledoc """ + Generates the skeleton for a new mob plugin under `plugins/<name>/`. + + mix mob.new_plugin <name> [--tier <0|1|2|3|4>] [--dest <DIR>] + + Tiers (per `MOB_PLUGINS.md`): + + - `0` (default) — pure-Elixir helpers. No manifest, no native code. + - `1` — native NIF + Elixir wrapper. Manifest with `:nifs`; ships an Erlang + NIF stub and the matching C source. Wired into the build by + `MobDev.Plugin.Merge.nifs/1` + the build.zig `-Dplugin_c_nifs` arg. + - `2` — native UI component via `Mob.UI.native_view` + `Mob.Component`. + Manifest with `:ui_components`; ships an Elixir `Mob.Component` module, + the matching Kotlin Composable, and a Swift View placeholder. + - `3` — multi-screen plugin. Manifest with `:screens` + `:migrations` (and + optionally `:assets`); ships two `Mob.Screen` modules and an Ecto migration + the host applies on device. + - `4` — embedded sub-app. Manifest with `:lifecycle` + `:settings` + + `:notifications`; ships a lifecycle module, a supervised worker, a + notification handler, and a settings editor screen. + + ## Options + + * `--tier <0|1|2|3|4>` — plugin tier; defaults to `0`. + * `--dest <DIR>` — destination directory; defaults to `plugins/<name>` + relative to the current working directory. + + ## Activating + + After scaffolding: + + # mix.exs + defp deps, do: [{:<name>, path: "plugins/<name>"} | …] + + # mob.exs + config :mob, :plugins, [:<name>] + """ + + alias MobDev.Plugin.Scaffold + + @switches [tier: :integer, dest: :string] + + @impl Mix.Task + def run(args) do + {opts, positional, invalid} = OptionParser.parse(args, strict: @switches) + + refuse_invalid!(invalid) + name = parse_name!(positional) + tier = Keyword.get(opts, :tier, 0) + + with :ok <- Scaffold.validate_name(name), + :ok <- Scaffold.validate_tier(tier) do + dest = opts[:dest] || Path.join([File.cwd!(), "plugins", name]) + refuse_if_exists!(dest) + write_files!(dest, Scaffold.files_for(tier, name, Scaffold.detect_mob_requirement())) + print_next_steps(name, tier) + else + {:error, reason} -> Mix.raise(reason) + end + end + + defp parse_name!([name | _]) when is_binary(name) and name != "", do: name + defp parse_name!(_), do: Mix.raise("usage: mix mob.new_plugin <name> [--tier 0|1|2]") + + # OptionParser's third element holds switches it could not parse: an unknown + # flag, or a bad value for a typed switch (e.g. `--tier abc` for an :integer). + # Without this guard those are silently dropped, so `mix mob.new_plugin foo + # --tier two` would scaffold a tier-0 plugin instead of erroring — the user's + # requested tier vanishes with no signal. + defp refuse_invalid!(invalid) do + case invalid_option_message(invalid) do + :ok -> :ok + {:error, msg} -> Mix.raise(msg) + end + end + + @doc false + # Pure decision kernel for the `invalid` element of OptionParser.parse/2. + # Returns `:ok` for an empty list, or `{:error, message}` describing the + # unrecognized/badly-typed switches. `@doc false` — extracted for testing. + @spec invalid_option_message([{String.t(), String.t() | nil}]) :: :ok | {:error, String.t()} + def invalid_option_message([]), do: :ok + + def invalid_option_message(invalid) do + flags = + Enum.map_join(invalid, ", ", fn + {flag, nil} -> flag + {flag, val} -> "#{flag} #{val}" + end) + + {:error, + "unrecognized or invalid option(s): #{flags}\n" <> + "usage: mix mob.new_plugin <name> [--tier 0|1|2|3|4] [--dest DIR]"} + end + + defp refuse_if_exists!(dest) do + if File.exists?(dest) do + Mix.raise("refusing to overwrite existing directory: #{dest}") + end + end + + defp write_files!(dest, files) do + Enum.each(files, fn {rel, content} -> + path = Path.join(dest, rel) + path |> Path.dirname() |> File.mkdir_p!() + File.write!(path, content) + Mix.shell().info([:green, " create ", :reset, Path.relative_to_cwd(path)]) + end) + end + + defp print_next_steps(name, tier) do + Mix.shell().info([ + :cyan, + "\nNext steps:\n", + :reset, + " 1. Add the plugin to your host's deps in `mix.exs`:\n", + " {:#{name}, path: \"plugins/#{name}\"}\n", + " 2. Activate it in `mob.exs`:\n", + " config :mob, :plugins, [:#{name}]\n", + " 3. Run `mix deps.get && mix mob.plugins` to verify.\n", + tier_specific_hint(tier, name) + ]) + end + + defp tier_specific_hint(1, name) do + nif = "#{name}_nif" + + " 4. Tier 1: the C NIF in priv/native/jni/#{nif}.c compiles + links via\n" <> + " mob_dev's plugin merge engine. Run `mix mob.deploy --native` to\n" <> + " pick it up; verify with `mix mob.validate_plugin` from the plugin dir.\n" + end + + defp tier_specific_hint(2, name) do + mod = MobDev.Plugin.Scaffold.module_name(name) + + " 4. Tier 2: copy priv/native/android/#{mod}.kt into the host's MobBridge.kt\n" <> + " (paste the @Composable + the #{mod}Plugin object), and call\n" <> + " #{mod}Plugin.register() from MobNativeViewRegistry's init {} block.\n" <> + " (Mix automation of this step is a future merge-engine slice.)\n" + end + + defp tier_specific_hint(3, _name) do + " 4. Tier 3: implement the two Mob.Screen modules + the Ecto migration.\n" <> + " The host registers your screens (by default_route) and applies the\n" <> + " migration on `mix mob.deploy --native`. Rename the migration file\n" <> + " with a real timestamp; add fonts/images under :assets when ready.\n" + end + + defp tier_specific_hint(4, name) do + " 4. Tier 4: flesh out lib/#{name}.ex (lifecycle), the supervised Worker,\n" <> + " the Notifications handler, and the settings schema. on_start +\n" <> + " supervised children run at boot under the host's plugin supervisor;\n" <> + " settings round-trip via Mob.Plugins get_setting/3 + put_setting/4.\n" + end + + defp tier_specific_hint(_, _), do: "" +end diff --git a/lib/mix/tasks/mob.plugin.keygen.ex b/lib/mix/tasks/mob.plugin.keygen.ex new file mode 100644 index 0000000..60acd64 --- /dev/null +++ b/lib/mix/tasks/mob.plugin.keygen.ex @@ -0,0 +1,96 @@ +defmodule Mix.Tasks.Mob.Plugin.Keygen do + use Mix.Task + + @shortdoc "Generate an Ed25519 keypair for signing a mob plugin" + + @moduledoc """ + Generates a per-plugin Ed25519 keypair and writes: + + - `~/.mob/keys/<plugin_name>.priv` — base64-encoded raw 32-byte private + key, mode 0600. + - `priv/mob_plugin.pub` in the plugin directory — base64-encoded raw + 32-byte public key. + + The public key file ships with the plugin (committed to source + control); the private key never leaves the author's machine. + + mix mob.plugin.keygen [--plugin <dir>] [--force] + + ## Options + + * `--plugin <dir>` — plugin directory; defaults to the current + working directory. + * `--force` — overwrite an existing `~/.mob/keys/<name>.priv`. + Refused by default to prevent an accidental key rotation. + """ + + alias MobDev.Plugin.{Crypto, Manifest, PrivateKeyStore} + + @switches [plugin: :string, force: :boolean] + + @impl Mix.Task + def run(args) do + {opts, _, _} = OptionParser.parse(args, strict: @switches) + plugin_dir = opts[:plugin] || File.cwd!() + force? = Keyword.get(opts, :force, false) + + name = plugin_name!(plugin_dir) + refuse_if_exists!(name, force?) + + {priv, pub} = Crypto.generate_keypair() + + :ok = PrivateKeyStore.write_key(name, priv) + pub_path = write_pubkey!(plugin_dir, pub) + + Mix.shell().info([ + :green, + " generated ", + :reset, + "ed25519 keypair for #{name}\n", + " private key: #{PrivateKeyStore.key_path(name)} (mode 0600)\n", + " public key: #{pub_path}\n", + " fingerprint: ", + :cyan, + Crypto.fingerprint(pub), + :reset, + "\n\nNext: run `mix mob.plugin.sign` to produce priv/mob_plugin.sig.\n" + ]) + end + + defp plugin_name!(plugin_dir) do + case Manifest.load(plugin_dir) do + {:ok, %{name: name}} when is_atom(name) and not is_nil(name) -> + name + + {:ok, _} -> + Mix.raise( + "#{plugin_dir}/priv/mob_plugin.exs is missing a :name field — " <> + "a plugin needs a manifest before it can be signed" + ) + + {:error, reason} -> + Mix.raise("could not load plugin manifest at #{plugin_dir}: #{reason}") + end + end + + defp refuse_if_exists!(name, false) do + path = PrivateKeyStore.key_path(name) + + if File.exists?(path) do + Mix.raise( + "private key already exists at #{path} — pass --force to overwrite " <> + "(this is a key rotation; existing hosts will need to re-run " <> + "mix mob.plugin.trust)" + ) + end + end + + defp refuse_if_exists!(_name, true), do: :ok + + defp write_pubkey!(plugin_dir, pub) do + path = Path.join(plugin_dir, "priv/mob_plugin.pub") + File.mkdir_p!(Path.dirname(path)) + File.write!(path, Base.encode64(pub) <> "\n") + path + end +end diff --git a/lib/mix/tasks/mob.plugin.sign.ex b/lib/mix/tasks/mob.plugin.sign.ex new file mode 100644 index 0000000..af90b37 --- /dev/null +++ b/lib/mix/tasks/mob.plugin.sign.ex @@ -0,0 +1,103 @@ +defmodule Mix.Tasks.Mob.Plugin.Sign do + use Mix.Task + + @shortdoc "Sign a mob plugin's manifest + source files" + + @moduledoc """ + Signs the plugin in `<dir>` (default: cwd) and writes + `priv/mob_plugin.sig`. The signature covers the loaded manifest + plus SHA-256 hashes of every file the manifest references. + + mix mob.plugin.sign [--plugin <dir>] + + Reads the private key for the plugin's `:name` from + `~/.mob/keys/<name>.priv`. Run `mix mob.plugin.keygen` first if you + haven't already. + + After signing, the printed fingerprint can be shared with host + operators so they can run `mix mob.plugin.trust <name>` to record + trust in their `mob.exs`. + """ + + alias MobDev.Plugin.{Crypto, Manifest, PrivateKeyStore, Sign, Verify} + + @switches [plugin: :string] + + @impl Mix.Task + def run(args) do + {opts, _, _} = OptionParser.parse(args, strict: @switches) + plugin_dir = opts[:plugin] || File.cwd!() + + {name, manifest} = load_manifest!(plugin_dir) + priv = read_priv_key!(name) + + case Sign.sign_plugin(plugin_dir, priv) do + :ok -> + sig_path = Sign.signature_path(plugin_dir) + print_success(plugin_dir, name, manifest, sig_path) + + {:error, reason} -> + Mix.raise("signing failed: #{inspect(reason)}") + end + end + + defp load_manifest!(plugin_dir) do + case Manifest.load(plugin_dir) do + {:ok, %{name: name} = manifest} when is_atom(name) and not is_nil(name) -> + {name, manifest} + + {:ok, nil} -> + Mix.raise( + "no priv/mob_plugin.exs in #{plugin_dir} — a plugin needs a manifest before it can be signed" + ) + + {:ok, _} -> + Mix.raise("#{plugin_dir}/priv/mob_plugin.exs is missing a :name field") + + {:error, reason} -> + Mix.raise("could not load plugin manifest at #{plugin_dir}: #{reason}") + end + end + + defp read_priv_key!(name) do + case PrivateKeyStore.read_key(name) do + {:ok, priv} -> + priv + + {:error, :missing} -> + Mix.raise( + "no private key for #{name} at #{PrivateKeyStore.key_path(name)} — " <> + "run `mix mob.plugin.keygen` first" + ) + + {:error, :malformed} -> + Mix.raise( + "private key at #{PrivateKeyStore.key_path(name)} is malformed " <> + "(expected base64 of raw 32 bytes)" + ) + end + end + + defp print_success(plugin_dir, name, _manifest, sig_path) do + fingerprint = + case Verify.load_pubkey(plugin_dir) do + {:ok, pub} -> Crypto.fingerprint(pub) + _ -> "(no priv/mob_plugin.pub; run mix mob.plugin.keygen)" + end + + Mix.shell().info([ + :green, + " signed ", + :reset, + "#{name}\n", + " signature: #{sig_path}\n", + " fingerprint: ", + :cyan, + fingerprint, + :reset, + "\n\nShare the fingerprint with host operators so they can run\n", + " mix mob.plugin.trust #{name}\n", + "in their host project to record trust in mob.exs.\n" + ]) + end +end diff --git a/lib/mix/tasks/mob.plugin.trust.ex b/lib/mix/tasks/mob.plugin.trust.ex new file mode 100644 index 0000000..45f44ee --- /dev/null +++ b/lib/mix/tasks/mob.plugin.trust.ex @@ -0,0 +1,180 @@ +defmodule Mix.Tasks.Mob.Plugin.Trust do + use Mix.Task + + @shortdoc "Trust a signed mob plugin by fingerprint" + + @moduledoc """ + Records trust in a signed mob plugin by writing + `config :mob, :trusted_plugins, %{...}` to `mob.exs`. + + mix mob.plugin.trust <plugin_name> + + Resolves the plugin via `Mix.Project.deps_paths/0`, reads its + `priv/mob_plugin.pub` + manifest, displays the declared capabilities + (frameworks, permissions, gradle deps, plist keys) plus the + fingerprint, and prompts `y/N` before recording the entry. + + Key rotation: if a different fingerprint is already trusted for this + plugin name, this task warns and prompts for explicit confirmation + before replacing the entry. + """ + + alias MobDev.Plugin.{Crypto, Manifest, TrustStore, Verify} + + @impl Mix.Task + def run(args) do + Mix.Task.run("loadpaths") + run_with_deps(args, Mix.Project.deps_paths(), File.cwd!()) + end + + @doc false + # Public for tests: lets the test inject a deps_paths map and project + # dir without having to fake out Mix.Project. + @spec run_with_deps([String.t()], %{atom() => Path.t()}, Path.t()) :: :ok + def run_with_deps(args, deps_paths, project_dir) do + name = parse_name!(args) + plugin_dir = resolve_dep!(name, deps_paths) + pub = load_pubkey!(plugin_dir, name) + manifest = load_manifest!(plugin_dir, name) + fingerprint = Crypto.fingerprint(pub) + + existing = Map.get(TrustStore.load_trusted_plugins(project_dir), name) + print_summary(name, manifest, fingerprint, existing) + + prompt = trust_prompt(existing, fingerprint) + + if Mix.shell().yes?(prompt) do + :ok = TrustStore.add_trust(name, pub, project_dir) + + Mix.shell().info([ + :green, + " trusted ", + :reset, + "#{name} (", + :cyan, + fingerprint, + :reset, + ") — recorded in mob.exs\n" + ]) + + :ok + else + Mix.shell().info("aborted — mob.exs unchanged") + :ok + end + end + + defp parse_name!([name | _]) when is_binary(name) and name != "", + do: String.to_atom(name) + + defp parse_name!(_), do: Mix.raise("usage: mix mob.plugin.trust <plugin_name>") + + defp resolve_dep!(name, deps_paths) do + case Map.get(deps_paths, name) do + nil -> + Mix.raise( + "no dependency named #{inspect(name)} — add it to mix.exs deps " <> + "and run `mix deps.get` first" + ) + + dir -> + dir + end + end + + defp load_pubkey!(plugin_dir, name) do + case Verify.load_pubkey(plugin_dir) do + {:ok, pub} -> + pub + + {:error, :missing} -> + Mix.raise( + "plugin #{inspect(name)} ships no priv/mob_plugin.pub — " <> + "ask the author to run `mix mob.plugin.keygen` and re-publish" + ) + + {:error, :malformed} -> + Mix.raise( + "plugin #{inspect(name)} has a malformed priv/mob_plugin.pub " <> + "(expected base64 of raw 32 bytes)" + ) + end + end + + defp load_manifest!(plugin_dir, name) do + case Manifest.load(plugin_dir) do + {:ok, manifest} when is_map(manifest) -> + manifest + + {:ok, nil} -> + Mix.raise("plugin #{inspect(name)} has no priv/mob_plugin.exs manifest") + + {:error, reason} -> + Mix.raise("could not load manifest for #{inspect(name)}: #{reason}") + end + end + + defp print_summary(name, manifest, fingerprint, existing) do + Mix.shell().info([ + "\nReview ", + :cyan, + Atom.to_string(name), + :reset, + ":\n", + " version: #{manifest[:version] || "(unset)"}\n", + " fingerprint: ", + :cyan, + fingerprint, + :reset, + "\n", + existing_line(existing, fingerprint), + capability_lines(manifest), + "\n" + ]) + end + + defp existing_line(nil, _new), do: "" + + defp existing_line(existing, new) when existing == new do + " trust state: already trusted (no change)\n" + end + + defp existing_line(existing, _new) do + [ + " ", + IO.ANSI.yellow(), + "trust state: KEY ROTATION — currently trusted: #{existing}", + IO.ANSI.reset(), + "\n" + ] + end + + defp capability_lines(manifest) do + [ + list_line("ios frameworks", get_in(manifest, [:ios, :frameworks])), + list_line("android permissions", get_in(manifest, [:android, :permissions])), + list_line("android gradle_deps", get_in(manifest, [:android, :gradle_deps])), + plist_line(get_in(manifest, [:ios, :plist_keys])) + ] + end + + defp list_line(_label, nil), do: "" + defp list_line(_label, []), do: "" + + defp list_line(label, list) when is_list(list) do + " #{label}: #{inspect(list)}\n" + end + + defp plist_line(map) when is_map(map) and map_size(map) > 0 do + " ios plist_keys: #{inspect(Map.keys(map))}\n" + end + + defp plist_line(_), do: "" + + defp trust_prompt(nil, _new), do: "Trust this plugin? [y/N]" + + defp trust_prompt(existing, new) when existing == new, do: "Re-confirm trust? [y/N]" + + defp trust_prompt(_existing, _new), + do: "This replaces a previously trusted key (key rotation). Trust the new key? [y/N]" +end diff --git a/lib/mix/tasks/mob.plugin.untrust.ex b/lib/mix/tasks/mob.plugin.untrust.ex new file mode 100644 index 0000000..71bd924 --- /dev/null +++ b/lib/mix/tasks/mob.plugin.untrust.ex @@ -0,0 +1,47 @@ +defmodule Mix.Tasks.Mob.Plugin.Untrust do + use Mix.Task + + @shortdoc "Remove a plugin's trust entry from mob.exs" + + @moduledoc """ + Removes the `config :mob, :trusted_plugins` entry for `plugin_name` + from `mob.exs`. No-op if the plugin wasn't trusted. + + mix mob.plugin.untrust <plugin_name> + """ + + alias MobDev.Plugin.TrustStore + + @impl Mix.Task + def run(args) do + run_in(args, File.cwd!()) + end + + @doc false + # Public for tests: lets a test inject the project dir. + @spec run_in([String.t()], Path.t()) :: :ok + def run_in(args, project_dir) do + name = parse_name!(args) + + if Map.has_key?(TrustStore.load_trusted_plugins(project_dir), name) do + :ok = TrustStore.remove_trust(name, project_dir) + + Mix.shell().info([ + :green, + " untrusted ", + :reset, + "#{name} — removed from mob.exs\n" + ]) + + :ok + else + Mix.shell().info("plugin #{inspect(name)} was not trusted — nothing to do") + :ok + end + end + + defp parse_name!([name | _]) when is_binary(name) and name != "", + do: String.to_atom(name) + + defp parse_name!(_), do: Mix.raise("usage: mix mob.plugin.untrust <plugin_name>") +end diff --git a/lib/mix/tasks/mob.plugins.ex b/lib/mix/tasks/mob.plugins.ex new file mode 100644 index 0000000..5d3abe0 --- /dev/null +++ b/lib/mix/tasks/mob.plugins.ex @@ -0,0 +1,102 @@ +defmodule Mix.Tasks.Mob.Plugins do + use Mix.Task + + @shortdoc "List installed Mob plugins and their activation status" + + @moduledoc """ + Lists the Mob plugins this project depends on, with tier, hot-push status, + and whether each is activated. + + mix mob.plugins + + A dependency is shown as a plugin if it ships a `priv/mob_plugin.exs` + manifest, or if it is named in `config :mob, :plugins` in `mob.exs`. + Tier-0 plugins (pure Elixir, no manifest) are indistinguishable from + ordinary libraries until activated, so they appear only once listed in + `config :mob, :plugins`. + + Activation is two-step by design (see `MOB_PLUGINS.md`): adding a plugin to + `deps` makes it *installed*; adding it to `config :mob, :plugins` makes it + *activated* — only then are its contributions merged into the build. + """ + + alias MobDev.Plugin.{Manifest, Report, Validator} + + @impl Mix.Task + def run(_args) do + Mix.Task.run("loadpaths") + + deps = load_all_manifests() + activated = activated_plugins() + dep_dirs = Mix.Project.deps_paths() + + deps + |> Report.rows(activated) + |> Report.with_vetting(dep_dirs) + |> Report.render() + |> then(&IO.puts("\n" <> &1 <> "\n")) + + report_conflicts(deps, activated) + end + + defp report_conflicts(deps, activated) do + activated_manifests = for {name, manifest} <- deps, name in activated, do: {name, manifest} + + case Validator.cross_validate(activated_manifests) do + %{errors: []} -> + :ok + + %{errors: errors} -> + Mix.shell().error("Plugin activation conflicts:") + Enum.each(errors, &Mix.shell().error(" ✗ #{&1}")) + IO.puts("") + end + end + + defp load_all_manifests do + Mix.Project.deps_paths() + |> Enum.map(fn {app, path} -> + case Manifest.load(path) do + {:ok, manifest} -> + {app, manifest} + + {:error, reason} -> + Mix.shell().info([:yellow, "[mob.plugins] skipping #{app}: #{reason}", :reset]) + {app, nil} + end + end) + end + + # Reads `config :mob, :plugins` from mob.exs (the build config). Falls back + # to the loaded Application env, then an empty list. + defp activated_plugins do + config_file = Path.join(File.cwd!(), "mob.exs") + + raw = + if File.exists?(config_file) do + config_file + |> Config.Reader.read!() + |> Keyword.get(:mob, []) + |> Keyword.get(:plugins, []) + else + Application.get_env(:mob, :plugins, []) + end + + normalize_activated(raw) + rescue + _ -> normalize_activated(Application.get_env(:mob, :plugins, [])) + end + + @doc false + # Pure kernel: coerces a `config :mob, :plugins` value into a clean list of + # atom plugin names. A misconfigured value (a non-list, or a list carrying + # non-atom entries like a stray string typo `"mob_haptic"`) must not crash + # `mix mob.plugins` — `name in activated` raises Protocol.UndefinedError on a + # non-list, and downstream `name in activated` silently mismatches string + # entries. Non-list → `[]`; lists are filtered down to their atom entries. + @spec normalize_activated(term()) :: [atom()] + def normalize_activated(plugins) when is_list(plugins), + do: Enum.filter(plugins, &is_atom/1) + + def normalize_activated(_other), do: [] +end diff --git a/lib/mix/tasks/mob.provision.ex b/lib/mix/tasks/mob.provision.ex new file mode 100644 index 0000000..73f7b6f --- /dev/null +++ b/lib/mix/tasks/mob.provision.ex @@ -0,0 +1,1056 @@ +defmodule Mix.Tasks.Mob.Provision do + use Mix.Task + + @shortdoc "Register your app ID and download an iOS provisioning profile" + + @moduledoc """ + Registers your app's bundle ID with Apple and downloads an iOS + provisioning profile. + + Two modes: + + mix mob.provision # development profile (default) + mix mob.provision --distribution # App Store distribution profile + + Run development provisioning once before your first `mix mob.deploy --native`. + Run distribution provisioning once before your first `mix mob.release`. + + ## What you need first + + 1. **Apple ID** — free at https://appleid.apple.com + 2. **Xcode signed in** with that Apple ID: + open Xcode → Settings → Accounts → [+] → Apple ID + 3. **Apple Developer Program** — optional for personal device development, + required for App Store distribution ($99/year). + Free accounts can deploy to their own devices; profiles expire every + 7 days. Paid accounts get 1-year profiles and App Store access. + Enroll at https://developer.apple.com/programs/enroll/ + + Distribution mode requires a paid Developer Program membership. + + ## Headless / unattended provisioning (App Store Connect API key) + + Step 2 (an interactive Xcode Apple ID account) is impossible for an unattended + user — a CI runner or a headless agent account with no GUI login. Instead, + authenticate `-allowProvisioningUpdates` with an **App Store Connect API key** + by setting three env vars; when they are present the task passes them to + `xcodebuild` and no signed-in Xcode account is needed: + + * `APP_STORE_CONNECT_KEY_ID` — the API key's Key ID + * `APP_STORE_CONNECT_ISSUER_ID` — your team's Issuer ID + * `APP_STORE_CONNECT_API_KEY_PATH` — path to the downloaded `AuthKey_<id>.p8` + + Create the key at App Store Connect → Users and Access → Integrations → App + Store Connect API, with a role that can manage certificates/profiles/devices + (Admin or App Manager). The `.p8` downloads once — store it read-only. Set all + three or none (a partial set raises); with none set, the signed-in Xcode + account is used as before. Signing still needs the certificate + private key in + an unlocked keychain — the API key only authorizes the profile/device calls. + + ## What it does (development) + + 1. Reads your signing team from the macOS keychain or existing profiles + 2. Generates `ios/Provision.xcodeproj` — a minimal Xcode project used + only for provisioning (safe to commit) + 3. Generates `ios/MobProvision.swift` — a two-line SwiftUI stub + 4. Runs `xcodebuild -allowProvisioningUpdates build` which contacts + Apple to: + - Register your bundle ID in your developer account (if not registered) + - Create a development provisioning profile + - Download it to ~/Library/Developer/Xcode/.../Provisioning Profiles/ + 5. Verifies the profile is present + + ## What it does (distribution) + + Same as above, but runs `xcodebuild archive -allowProvisioningUpdates` + with `CODE_SIGN_STYLE=Automatic` against the Release configuration. + Apple creates an App Store provisioning profile (and an Apple + Distribution certificate, if missing) and downloads them to your + keychain + provisioning profile directory. + """ + + @switches [distribution: :boolean] + + @impl Mix.Task + def run(argv) do + {opts, _, _} = OptionParser.parse(argv, strict: @switches) + mode = if opts[:distribution], do: :distribution, else: :development + + unless macos?() do + Mix.raise("mix mob.provision is only supported on macOS.") + end + + unless File.dir?("ios") do + Mix.raise("No ios/ directory found. Run from the root of a mob iOS project.") + end + + IO.puts("") + label = if mode == :distribution, do: "Distribution", else: "Development" + IO.puts("#{cyan()}=== iOS Provisioning (#{label}) ===#{reset()}") + IO.puts("") + IO.puts("#{bright()}What you need before this step:#{reset()}") + IO.puts("") + IO.puts(" 1. Apple ID — free at #{cyan()}https://appleid.apple.com#{reset()}") + IO.puts(" 2. Xcode signed in with that Apple ID") + IO.puts(" Open Xcode → Settings → Accounts → [+] → Apple ID") + IO.puts(" 3. (App Store only) Apple Developer Program — $99/year") + IO.puts(" Free accounts work for deploying to your own devices.") + IO.puts("") + IO.puts("#{bright()}Checking...#{reset()}") + IO.puts("") + + check_signing_identity!(mode) + team_id = resolve_team_id() + bundle_id = check_bundle_id!() + + IO.puts("") + IO.puts(" Bundle ID : #{cyan()}#{bundle_id}#{reset()}") + IO.puts(" Team ID : #{cyan()}#{team_id}#{reset()}") + IO.puts("") + + # For distribution mode: if the user already has an App Store profile + # for this bundle ID locally, use its actual UUID rather than guessing + # the name. xcodebuild's PROVISIONING_PROFILE_SPECIFIER accepts both + # names and UUIDs; UUIDs are unambiguous and survive any naming + # convention the user picked when they downloaded the profile from + # developer.apple.com or via Xcode UI. + # + # For dev mode the Release config isn't used (build action defaults + # to Debug), but the pbxproj template always emits both configs, so + # we pass the predicted name as a harmless placeholder. + profile_specifier = + case mode do + :distribution -> + discover_dist_profile(bundle_id, team_id) || default_profile_name(bundle_id) + + _ -> + default_profile_name(bundle_id) + end + + generate_xcodeproj(bundle_id, team_id, profile_specifier) + generate_swift_stub() + + IO.puts("") + IO.puts("#{bright()}Contacting Apple to register App ID and download profile...#{reset()}") + IO.puts("(requires internet — may take 10–30 seconds)") + IO.puts("") + + run_xcodebuild!(mode) + verify_profile!(bundle_id, mode) + + IO.puts("") + IO.puts("#{green()}✓ Provisioning complete!#{reset()}") + IO.puts("") + + case mode do + :distribution -> + IO.puts("Next step: #{cyan()}mix mob.release#{reset()}") + + _ -> + IO.puts("Next step: #{cyan()}mix mob.deploy --native#{reset()}") + end + + IO.puts("") + + IO.puts( + "#{faint()}Free Apple ID profiles expire every 7 days — re-run mix mob.provision when that happens." + ) + + IO.puts("Paid Developer Program profiles last 1 year.#{reset()}") + end + + # ── Prerequisite checks ─────────────────────────────────────────────────────── + + defp check_signing_identity!(mode) do + cert_kind = + case mode do + :distribution -> "Apple Distribution" + _ -> "Apple Development" + end + + case System.cmd("security", ["find-identity", "-v", "-p", "codesigning"], + stderr_to_stdout: true + ) do + {output, 0} -> + identities = + Regex.scan(Regex.compile!("\\d+\\) [0-9A-F]+ \"([^\"]+)\""), output) + |> Enum.map(fn [_, id] -> id end) + |> Enum.filter(&String.contains?(&1, cert_kind)) + |> Enum.uniq() + + case identities do + [] -> + # For distribution, xcodebuild -allowProvisioningUpdates with the + # archive action can create the cert if missing — so this isn't + # fatal in distribution mode. Just warn and let xcodebuild try. + IO.puts(" #{yellow()}?#{reset()} #{cert_kind} certificate — not yet in keychain") + + if mode == :distribution do + IO.puts( + " #{faint()}xcodebuild will attempt to create one when contacting Apple.#{reset()}" + ) + else + Mix.raise(""" + + No #{cert_kind} signing certificate found. + + One-time setup: + 1. Open Xcode + 2. Xcode → Settings → Accounts → [+] → add your Apple ID + 3. Select your team → click "Manage Certificates" → "+" + 4. Re-run: mix mob.provision + """) + end + + [id] -> + IO.puts(" #{green()}✓#{reset()} Signing certificate — #{faint()}#{id}#{reset()}") + + many -> + IO.puts( + " #{green()}✓#{reset()} Signing certificate — #{faint()}#{hd(many)}#{reset()} (#{length(many)} found, using first)" + ) + end + + _ -> + Mix.raise("Could not query keychain — is this macOS?") + end + end + + defp resolve_team_id do + cfg = MobDev.Config.load_mob_config() + + cond do + team = cfg[:ios_team_id] -> + IO.puts(" #{green()}✓#{reset()} Team ID — #{team} #{faint()}(from mob.exs)#{reset()}") + team + + team = team_from_any_profile() -> + IO.puts( + " #{green()}✓#{reset()} Team ID — #{team} #{faint()}(auto-detected from existing profile)#{reset()}" + ) + + team + + true -> + IO.puts(" #{yellow()}?#{reset()} Team ID — could not auto-detect") + + IO.puts(" Paid Apple Developer Program ($99/yr):") + + IO.puts( + " #{cyan()}https://developer.apple.com/account#{reset()} → Membership → Team ID" + ) + + IO.puts(" Free tier (Personal Team, no $99):") + IO.puts(" Xcode → Settings → Accounts → [your Apple ID] → Team column") + + team = Mix.shell().prompt(" Enter Team ID:") |> String.trim() + + unless Regex.match?(Regex.compile!("^[A-Z0-9]{10}$"), team) do + Mix.raise( + "Invalid Team ID '#{team}' — expected 10 uppercase alphanumeric characters (e.g. Q89CW299G8)" + ) + end + + team + end + end + + defp team_from_any_profile do + profile_dirs = [ + Path.expand("~/Library/Developer/Xcode/UserData/Provisioning Profiles"), + Path.expand("~/Library/MobileDevice/Provisioning Profiles") + ] + + Enum.flat_map(profile_dirs, &Path.wildcard(Path.join(&1, "*.mobileprovision"))) + |> Enum.find_value(fn path -> + with {:ok, data} <- File.read(path), + {s, _} <- :binary.match(data, "<?xml"), + {e, len} <- :binary.match(data, "</plist>") do + xml = binary_part(data, s, e - s + len) + + case Regex.run( + Regex.compile!("<key>TeamIdentifier</key>\\s*<array>\\s*<string>([^<]+)</string>"), + xml + ) do + [_, team] -> String.trim(team) + _ -> nil + end + else + _ -> nil + end + end) + end + + defp check_bundle_id! do + bundle_id = MobDev.Config.bundle_id() + IO.puts(" #{green()}✓#{reset()} Bundle ID — #{bundle_id}") + bundle_id + end + + # ── File generation ─────────────────────────────────────────────────────────── + + defp generate_xcodeproj(bundle_id, team_id, profile_specifier) do + proj_dir = "ios/Provision.xcodeproj" + proj_file = Path.join(proj_dir, "project.pbxproj") + entitlements = detect_push_entitlements() + if entitlements, do: IO.puts(" Push entitlements detected: #{entitlements}") + expected = project_pbxproj(bundle_id, team_id, profile_specifier, entitlements) + + needs_write = + case File.read(proj_file) do + {:ok, ^expected} -> false + # Any drift — wrong bundle/team, different profile specifier + # (user downloaded a new profile), missing settings added in + # newer mob_dev versions, hand-edits — gets rewritten. Cheap to + # do (one local file write) and catches the common "old project + # generated by an older mob_dev" trap on upgrade. + _ -> true + end + + if needs_write do + IO.puts(" Writing ios/Provision.xcodeproj...") + File.mkdir_p!(proj_dir) + File.write!(proj_file, expected) + else + IO.puts(" #{green()}✓#{reset()} ios/Provision.xcodeproj — up to date") + end + end + + # Look for ios/*.entitlements files that declare aps-environment, indicating + # the app wants push notifications. Returns the filename (not full path) of + # the first matching file, or nil if none found. + defp detect_push_entitlements do + "ios/*.entitlements" + |> Path.wildcard() + |> Enum.find(fn path -> + case File.read(path) do + {:ok, content} -> String.contains?(content, "aps-environment") + _ -> false + end + end) + |> case do + nil -> nil + path -> Path.basename(path) + end + end + + # Discover an existing App Store profile for the bundle ID by parsing + # the profiles in ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. + # Returns the profile UUID (preferred over name — UUIDs don't change + # if the user renames their profile), or nil if none found. + # + # Reuses MobDev.Release.parse_mobileprovision/1 which already filters + # for App Store profiles (no provisioned-devices, no provisions-all-devices). + defp discover_dist_profile(bundle_id, team_id) do + profile_dirs = [ + Path.expand("~/Library/Developer/Xcode/UserData/Provisioning Profiles"), + Path.expand("~/Library/MobileDevice/Provisioning Profiles") + ] + + matches = + profile_dirs + |> Enum.flat_map(&Path.wildcard(Path.join(&1, "*.mobileprovision"))) + |> Enum.flat_map(&MobDev.Release.parse_mobileprovision/1) + |> Enum.filter(fn p -> + # App Store profile (not dev / ad-hoc / enterprise) for our bundle + team. + not p.provisioned_devices? and not p.provisions_all_devices? and + p.team_id == team_id and + (String.ends_with?(p.app_id, ".#{bundle_id}") or + String.ends_with?(p.app_id, ".*")) + end) + + case matches do + [%{uuid: uuid}] -> + IO.puts(" #{green()}✓#{reset()} App Store profile — found locally (UUID #{uuid})") + uuid + + [] -> + nil + + many -> + # Prefer exact bundle ID over wildcard. + exact = Enum.filter(many, &String.ends_with?(&1.app_id, ".#{bundle_id}")) + + case exact do + [%{uuid: uuid} | _] -> + IO.puts(" #{green()}✓#{reset()} App Store profile — found locally (UUID #{uuid})") + uuid + + [] -> + %{uuid: uuid} = hd(many) + + IO.puts( + " #{yellow()}?#{reset()} Multiple wildcard profiles match — using first (UUID #{uuid})" + ) + + uuid + end + end + end + + # Fallback profile-specifier name pattern (used only when no profile is + # locally cached yet — first --distribution run before the user has + # downloaded anything). Won't match a user-renamed profile, but xcodebuild + # will produce a clear "no profile matching..." error in that case which + # our diagnose_xcodebuild_failure picks up. + defp default_profile_name(bundle_id), + do: "iOS Team Store Provisioning Profile: #{bundle_id}" + + defp generate_swift_stub do + path = "ios/MobProvision.swift" + + if File.exists?(path) do + IO.puts(" #{green()}✓#{reset()} ios/MobProvision.swift — already exists") + else + IO.puts(" Writing ios/MobProvision.swift...") + + File.write!(path, """ + import SwiftUI + + @main + struct MobProvision: App { + var body: some Scene { WindowGroup { EmptyView() } } + } + """) + end + end + + # ── xcodebuild ──────────────────────────────────────────────────────────────── + + @asc_key_id "APP_STORE_CONNECT_KEY_ID" + @asc_issuer_id "APP_STORE_CONNECT_ISSUER_ID" + @asc_key_path "APP_STORE_CONNECT_API_KEY_PATH" + + @doc false + # App Store Connect API-key auth flags for xcodebuild, from env. Lets an + # unattended user (a headless agent / CI) provision without an interactive + # Xcode Apple ID account: with the key set, `-allowProvisioningUpdates` + # authenticates against App Store Connect directly. Returns `[]` when none of + # the vars are set (falls back to the signed-in Xcode account, the default). + # Raises on partial config — a half-set key is a mistake worth surfacing, not + # a silent fall-back to a different auth path. + # + # * `APP_STORE_CONNECT_KEY_ID` — the API key's Key ID + # * `APP_STORE_CONNECT_ISSUER_ID` — your team's Issuer ID + # * `APP_STORE_CONNECT_API_KEY_PATH` — path to the downloaded `AuthKey_<id>.p8` + @spec asc_auth_args(map()) :: [String.t()] + def asc_auth_args(env) do + id = present(env, @asc_key_id) + issuer = present(env, @asc_issuer_id) + path = present(env, @asc_key_path) + + cond do + is_nil(id) and is_nil(issuer) and is_nil(path) -> + [] + + is_binary(id) and is_binary(issuer) and is_binary(path) -> + [ + "-authenticationKeyID", + id, + "-authenticationKeyIssuerID", + issuer, + "-authenticationKeyPath", + path + ] + + true -> + set = + for {v, k} <- [{id, @asc_key_id}, {issuer, @asc_issuer_id}, {path, @asc_key_path}], + v, + do: k + + missing = Enum.reject([@asc_key_id, @asc_issuer_id, @asc_key_path], &(&1 in set)) + + Mix.raise(""" + Incomplete App Store Connect API key config. + + Set #{Enum.join(set, ", ")} but missing #{Enum.join(missing, ", ")}. + Provide all three (#{@asc_key_id}, #{@asc_issuer_id}, #{@asc_key_path}) to + provision via an API key, or none to use the signed-in Xcode account. + """) + end + end + + # nil for an unset OR empty env var, so `APP_STORE_CONNECT_KEY_ID=` counts as absent. + defp present(env, key) do + case Map.get(env, key) do + v when is_binary(v) and v != "" -> v + _ -> nil + end + end + + # Fail early with a clear message rather than an opaque xcodebuild error when + # the key file is missing — the common headless-setup slip. + defp verify_asc_key_file!(path) when is_binary(path) and path != "" do + unless File.exists?(path) do + Mix.raise("#{@asc_key_path} points to a file that does not exist: #{path}") + end + end + + defp verify_asc_key_file!(_), do: :ok + + defp run_xcodebuild!(mode) do + # `-scheme MobProvision` rather than `-target MobProvision`: Xcode 16+ + # rejects `-archivePath` paired with `-target` ("The flag -scheme is + # required when specifying -archivePath but not -exportArchive"). + # Both forms work for the build action, so we use scheme for both + # to keep the invocation consistent. + base = + [ + "-project", + "ios/Provision.xcodeproj", + "-scheme", + "MobProvision", + "-destination", + "generic/platform=iOS", + "-allowProvisioningUpdates", + "-allowProvisioningDeviceRegistration" + ] ++ asc_auth_args(System.get_env()) + + verify_asc_key_file!(System.get_env(@asc_key_path)) + + args = + case mode do + :distribution -> + # `archive` + Release config triggers Apple to create or refresh + # the App Store provisioning profile (and Distribution cert if + # missing) under automatic signing. + # + # Don't override SYMROOT/OBJROOT for archive — Xcode 26's + # archive action expects its own DerivedData layout (creates + # ArchiveIntermediates/.../BuildProductsPath/SwiftSupport + # internally), and pointing OBJROOT to /tmp leaves + # BuildProductsPath unwritten and the archive packaging step + # fails with "BuildProductsPath couldn't be opened". + base ++ + [ + "-configuration", + "Release", + "-archivePath", + "/tmp/mob_provision_build/Provision.xcarchive", + "archive" + ] + + _ -> + # Build action is fine with the /tmp scratch dir — only the + # archive action has the BuildProductsPath layout requirement. + base ++ + [ + "SYMROOT=/tmp/mob_provision_build", + "OBJROOT=/tmp/mob_provision_build", + "build" + ] + end + + {output, rc} = System.cmd("xcodebuild", args, stderr_to_stdout: true) + + if rc != 0 do + # Show the full xcodebuild output first — keeps Apple's exact error + # text visible for google searches and for users comparing notes with + # online answers. The targeted hint below it is additive. + IO.puts(output) + + hint = diagnose_xcodebuild_failure(output) + Mix.raise(format_xcodebuild_error(rc, hint, args)) + end + + # Print only the summary line on success + output + |> String.split("\n") + |> Enum.filter(&(&1 =~ Regex.compile!("^\\*\\* BUILD (SUCCEEDED|FAILED)"))) + |> Enum.each(&IO.puts/1) + + :ok + end + + # ── xcodebuild error diagnosis ──────────────────────────────────────────── + # + # Pattern-match against known Apple error strings and return a {label, + # snippet, hint} describing the targeted fix. Returns nil for unmatched + # errors — the caller falls back to a generic "common causes" message. + # + # The Apple/xcodebuild text is preserved verbatim in `:snippet` so users + # can paste it into a search engine and find existing community + # answers — our hint is additive, not a replacement. + # + # ## URL stability + # + # Each hint includes a link to Apple's official docs (`developer.apple.com/help/account/...`), + # which is Apple's account-management knowledge base — more stable than + # blog posts or Developer Forum threads. Apple does occasionally + # reorganise these; if a link 404s, search "site:developer.apple.com" + # for the section title to find its new home, then update the + # `@apple_url_*` module attributes below. The pattern matchers are the + # long-term backstop: they catch the error even when the URL goes stale. + + @apple_url_app_id "https://developer.apple.com/help/account/identifiers/register-an-app-id" + @apple_url_signing_cert "https://developer.apple.com/help/account/create-certificates/create-signing-certificates" + @apple_url_team_id "https://developer.apple.com/help/account/manage-your-team/locate-your-team-id" + + @doc false + @spec diagnose_xcodebuild_failure(String.t()) :: + {label :: String.t(), snippet :: String.t(), hint :: String.t()} | nil + def diagnose_xcodebuild_failure(output) do + cond do + snippet = match_no_store_profile(output) -> + {"Distribution profile can't be auto-created for an unregistered App ID", snippet, + """ + xcodebuild can manage existing profiles via -allowProvisioningUpdates, + but it won't register a brand-new bundle ID *and* create the App + Store profile in one shot — Apple's distribution flow needs the + App ID to exist first. + + Register the App ID once (1 minute), then re-run mix mob.provision: + + 1. https://developer.apple.com/account/resources/identifiers/list + 2. Click + → App IDs → Continue → App → Continue + 3. Description: <your app name> + Bundle ID: select Explicit, paste your bundle id + Capabilities: leave defaults + 4. Continue → Register + + Then: mix mob.provision --distribution + """} + + snippet = match_invalid_app_id_name(output) -> + {"Apple rejected the auto-generated App ID display name", snippet, + """ + Apple derives the App ID display name from your bundle ID by + prepending "XC " and replacing dots with spaces. The result has + to fit Apple's portal validation (~30-char limit, no characters + their validator rejects — underscores have been flagged in some + years). + + Fix: shorten the bundle ID's last segment in mob.exs: + + config :mob_dev, bundle_id: "com.example.<short_name>" + + Or regenerate with a shorter app name: + + mix mob.new <short_name> + + Apple's App ID rules: #{@apple_url_app_id} + """} + + snippet = match_no_signing_cert(output) -> + {"No Apple Development signing certificate", snippet, + """ + Open Xcode → Settings → Accounts: + 1. [+] → add your Apple ID (free at https://appleid.apple.com) + 2. select your team → "Manage Certificates" → "+" → Apple Development + + Then re-run `mix mob.provision`. + + Apple's signing certificate guide: #{@apple_url_signing_cert} + """} + + snippet = match_no_team(output) -> + {"No team available for signing", snippet, + """ + Set your Team ID in mob.exs: + + config :mob_dev, ios_team_id: "ABC123XYZ4" + + Find yours at: + Paid ($99/yr): https://developer.apple.com/account → Membership + Free (Personal Team): Xcode → Settings → Accounts → + [your Apple ID] → Team column + + Apple's "locate your Team ID" guide: #{@apple_url_team_id} + """} + + snippet = match_app_id_quota(output) -> + {"Free-tier App ID limit (3 per 7 days) hit", snippet, + """ + Apple caps Personal Team accounts at 3 distinct bundle IDs + registered per rolling 7-day window. Either wait it out, or + reuse a bundle ID Xcode already provisioned for you by setting + it explicitly in mob.exs: + + config :mob_dev, bundle_id: "com.example.<previously_registered>" + + Apple's App ID registration page (mentions registration limits): + #{@apple_url_app_id} + """} + + snippet = match_bundle_id_taken(output) -> + {"Bundle ID belongs to a different team", snippet, + """ + Apple won't let two teams own the same App ID. Pick a unique + bundle ID — for personal projects, append your initials or a + random suffix: + + config :mob_dev, bundle_id: "com.example.<app>.<your_suffix>" + + Or change MOB_BUNDLE_PREFIX away from the conflicting reverse-DNS. + + Apple's App ID rules (uniqueness across teams): + #{@apple_url_app_id} + """} + + true -> + nil + end + end + + # Each match_* helper returns the verbatim snippet from xcodebuild output + # if the pattern is present, else nil. Keeping the snippet in the user's + # output (rather than rephrasing) keeps it google-searchable. + + defp match_no_store_profile(output) do + grep_first(output, "iOS Team Store Provisioning Profile") && + grep_first(output, "No profile for team") + end + + defp match_invalid_app_id_name(output) do + grep_first(output, "The attribute 'name' is invalid") + end + + defp match_no_signing_cert(output) do + grep_first(output, "No signing certificate") || + grep_first(output, "no Apple Development cert") || + grep_first(output, "requires a development team") + end + + defp match_no_team(output) do + grep_first(output, "no eligible accounts") || + grep_first(output, "doesn't include any iOS App Development") || + grep_first(output, "No development team") + end + + defp match_app_id_quota(output) do + grep_first(output, "There are too many App IDs") || + grep_first(output, "maximum allowed number of App IDs") || + grep_first(output, "Maximum App IDs Reached") + end + + defp match_bundle_id_taken(output) do + grep_first(output, "Failed to register bundle identifier") || + grep_first(output, "An App ID with Identifier") || + grep_first(output, "is not available. Please enter a different string") + end + + # First line of `output` containing `needle`, or nil. + defp grep_first(output, needle) do + output + |> String.split("\n") + |> Enum.find(&String.contains?(&1, needle)) + |> case do + nil -> nil + line -> String.trim(line) + end + end + + defp format_xcodebuild_error(rc, nil, args) do + """ + + xcodebuild provisioning failed (exit #{rc}). + + Common causes: + - Xcode not signed in: open Xcode → Settings → Accounts → add Apple ID + - Bundle ID registered to a different team + - No internet connection (provisioning contacts Apple's servers) + - Free-tier Apple ID hit the 3-App-IDs-per-7-days limit + + The full xcodebuild output is above; search any error line you don't + recognise — Apple's text is fairly distinctive and there's almost always + a Stack Overflow / forum hit for it. + + To debug, run manually from #{File.cwd!()}: + xcodebuild #{Enum.join(args, " ")} + """ + end + + defp format_xcodebuild_error(rc, {label, snippet, hint}, _args) do + """ + + xcodebuild provisioning failed (exit #{rc}). + + #{IO.ANSI.bright()}#{label}#{IO.ANSI.reset()} + + Apple's exact error (paste this into a search engine for community answers): + + #{snippet} + + #{hint} + """ + end + + defp verify_profile!(bundle_id, mode) do + profile_dirs = [ + Path.expand("~/Library/Developer/Xcode/UserData/Provisioning Profiles"), + Path.expand("~/Library/MobileDevice/Provisioning Profiles") + ] + + matching = + Enum.flat_map(profile_dirs, &Path.wildcard(Path.join(&1, "*.mobileprovision"))) + |> Enum.filter(fn path -> + case File.read(path) do + {:ok, data} -> + bundle_match = + String.contains?(data, bundle_id) or + Regex.match?( + Regex.compile!( + "<key>application-identifier</key>\\s*<string>[^<]+\\.\\*</string>" + ), + data + ) + + mode_match = + case mode do + :distribution -> + # App Store profiles have no ProvisionedDevices array + not String.contains?(data, "<key>ProvisionedDevices</key>") and + not String.contains?(data, "<key>ProvisionsAllDevices</key>") + + _ -> + # Development profiles list ProvisionedDevices + String.contains?(data, "<key>ProvisionedDevices</key>") + end + + bundle_match and mode_match + + _ -> + false + end + end) + + if matching != [] do + label = if mode == :distribution, do: "App Store", else: "development" + IO.puts(" #{green()}✓#{reset()} #{label} provisioning profile ready") + else + IO.puts( + " #{yellow()}⚠#{reset()} Profile not found — re-run `mix mob.provision#{if mode == :distribution, do: " --distribution", else: ""}` if needed" + ) + end + end + + # ── project.pbxproj template ────────────────────────────────────────────────── + # + # Release config note: Manual signing + explicit Apple Distribution + # identity is the Apple-blessed pattern when the team already has a + # wildcard Apple Development profile (most do). Under automatic + # signing, Xcode greedily picks that wildcard profile (it satisfies + # the bundle ID), never enters distribution mode, and then refuses + # any manual identity override with "conflicting provisioning + # settings". Manual + `-allowProvisioningUpdates` tells xcodebuild + # "fetch (and create at Apple if needed) the App Store profile for + # this bundle ID" — which is exactly what we want for the one-shot + # provision flow. + # + # A minimal Xcode project with a single Swift target. The UUIDs are fixed (they + # only need to be unique within this file). MobProvision.swift is referenced + # relative to the ios/ directory (the directory containing Provision.xcodeproj). + + defp project_pbxproj(bundle_id, team_id, profile_specifier, entitlements_file) + when is_binary(profile_specifier) do + entitlements_ref = + if entitlements_file do + "\t\tAA00000F /* #{entitlements_file} */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = #{entitlements_file}; sourceTree = \"<group>\"; };\n" + else + "" + end + + entitlements_group_entry = + if entitlements_file do + "\t\t\t\tAA00000F /* #{entitlements_file} */,\n" + else + "" + end + + target_attributes = + if entitlements_file do + "\t\t\t\tTargetAttributes = {\n\t\t\t\t\tAA000006 = {\n\t\t\t\t\t\tSystemCapabilities = {\n\t\t\t\t\t\t\t\"com.apple.Push\" = {\n\t\t\t\t\t\t\t\tenabled = 1;\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t};\n\t\t\t\t\t};\n\t\t\t\t};\n" + else + "" + end + + entitlements_setting = + if entitlements_file do + "\t\t\t\tCODE_SIGN_ENTITLEMENTS = #{entitlements_file};\n" + else + "" + end + + """ + // !$*UTF8*$! + { + \tarchiveVersion = 1; + \tclasses = { + \t}; + \tobjectVersion = 77; + \tobjects = { + + /* Begin PBXBuildFile section */ + \t\tAA000001 /* MobProvision.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002 /* MobProvision.swift */; }; + /* End PBXBuildFile section */ + + /* Begin PBXFileReference section */ + \t\tAA000002 /* MobProvision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobProvision.swift; sourceTree = "<group>"; }; + \t\tAA000003 /* MobProvision.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobProvision.app; sourceTree = BUILT_PRODUCTS_DIR; }; + #{entitlements_ref}/* End PBXFileReference section */ + + /* Begin PBXGroup section */ + \t\tAA000004 = { + \t\t\tisa = PBXGroup; + \t\t\tchildren = ( + \t\t\t\tAA000002 /* MobProvision.swift */, + #{entitlements_group_entry}\t\t\t\tAA000005 /* Products */, + \t\t\t); + \t\t\tsourceTree = "<group>"; + \t\t}; + \t\tAA000005 /* Products */ = { + \t\t\tisa = PBXGroup; + \t\t\tchildren = ( + \t\t\t\tAA000003 /* MobProvision.app */, + \t\t\t); + \t\t\tname = Products; + \t\t\tsourceTree = "<group>"; + \t\t}; + /* End PBXGroup section */ + + /* Begin PBXNativeTarget section */ + \t\tAA000006 /* MobProvision */ = { + \t\t\tisa = PBXNativeTarget; + \t\t\tbuildConfigurationList = AA000007 /* Build configuration list for PBXNativeTarget "MobProvision" */; + \t\t\tbuildPhases = ( + \t\t\t\tAA000008 /* Sources */, + \t\t\t); + \t\t\tbuildRules = ( + \t\t\t); + \t\t\tdependencies = ( + \t\t\t); + \t\t\tname = MobProvision; + \t\t\tproductName = MobProvision; + \t\t\tproductReference = AA000003 /* MobProvision.app */; + \t\t\tproductType = "com.apple.product-type.application"; + \t\t}; + /* End PBXNativeTarget section */ + + /* Begin PBXProject section */ + \t\tAA000009 /* Project object */ = { + \t\t\tisa = PBXProject; + \t\t\tattributes = { + \t\t\t\tBuildIndependentTargetsInParallel = YES; + \t\t\t\tLastUpgradeCheck = 1600; + #{target_attributes}\t\t\t}; + \t\t\tbuildConfigurationList = AA00000A /* Build configuration list for PBXProject "Provision" */; + \t\t\tdevelopmentRegion = en; + \t\t\thasScannedForEncodings = 0; + \t\t\tknownRegions = ( + \t\t\t\tBase, + \t\t\t\ten, + \t\t\t); + \t\t\tmainGroup = AA000004; + \t\t\tproductRefGroup = AA000005 /* Products */; + \t\t\tprojectDirPath = ""; + \t\t\tprojectRoot = ""; + \t\t\ttargets = ( + \t\t\t\tAA000006 /* MobProvision */, + \t\t\t); + \t\t}; + /* End PBXProject section */ + + /* Begin PBXSourcesBuildPhase section */ + \t\tAA000008 /* Sources */ = { + \t\t\tisa = PBXSourcesBuildPhase; + \t\t\tbuildActionMask = 2147483647; + \t\t\tfiles = ( + \t\t\t\tAA000001 /* MobProvision.swift in Sources */, + \t\t\t); + \t\t\trunOnlyForDeploymentPostprocessing = 0; + \t\t}; + /* End PBXSourcesBuildPhase section */ + + /* Begin XCBuildConfiguration section */ + \t\tAA00000B /* Debug */ = { + \t\t\tisa = XCBuildConfiguration; + \t\t\tbuildSettings = { + \t\t\t\tCODE_SIGN_STYLE = Automatic; + #{entitlements_setting}\t\t\t\tDEVELOPMENT_TEAM = #{team_id}; + \t\t\t\tGENERATE_INFOPLIST_FILE = YES; + \t\t\t\tINFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + \t\t\t\tINFOPLIST_KEY_UILaunchScreen_Generation = YES; + \t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0; + \t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = #{bundle_id}; + \t\t\t\tPRODUCT_NAME = MobProvision; + \t\t\t\tSDKROOT = iphoneos; + \t\t\t\tSWIFT_VERSION = 5.9; + \t\t\t\tTARGETED_DEVICE_FAMILY = "1,2"; + \t\t\t}; + \t\t\tname = Debug; + \t\t}; + \t\tAA00000C /* Release */ = { + \t\t\tisa = XCBuildConfiguration; + \t\t\tbuildSettings = { + \t\t\t\tCODE_SIGN_STYLE = Manual; + \t\t\t\tCODE_SIGN_IDENTITY = "Apple Distribution"; + \t\t\t\t"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution"; + #{entitlements_setting}\t\t\t\tDEVELOPMENT_TEAM = #{team_id}; + \t\t\t\tPROVISIONING_PROFILE_SPECIFIER = "#{profile_specifier}"; + \t\t\t\tGENERATE_INFOPLIST_FILE = YES; + \t\t\t\tINFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + \t\t\t\tINFOPLIST_KEY_UILaunchScreen_Generation = YES; + \t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0; + \t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = #{bundle_id}; + \t\t\t\tPRODUCT_NAME = MobProvision; + \t\t\t\tSDKROOT = iphoneos; + \t\t\t\tSWIFT_VERSION = 5.9; + \t\t\t\tTARGETED_DEVICE_FAMILY = "1,2"; + \t\t\t}; + \t\t\tname = Release; + \t\t}; + \t\tAA00000D /* Debug */ = { + \t\t\tisa = XCBuildConfiguration; + \t\t\tbuildSettings = { + \t\t\t\tALWAYS_SEARCH_USER_PATHS = NO; + \t\t\t\tSDKROOT = iphoneos; + \t\t\t}; + \t\t\tname = Debug; + \t\t}; + \t\tAA00000E /* Release */ = { + \t\t\tisa = XCBuildConfiguration; + \t\t\tbuildSettings = { + \t\t\t\tALWAYS_SEARCH_USER_PATHS = NO; + \t\t\t\tSDKROOT = iphoneos; + \t\t\t}; + \t\t\tname = Release; + \t\t}; + /* End XCBuildConfiguration section */ + + /* Begin XCConfigurationList section */ + \t\tAA000007 /* Build configuration list for PBXNativeTarget "MobProvision" */ = { + \t\t\tisa = XCConfigurationList; + \t\t\tbuildConfigurations = ( + \t\t\t\tAA00000B /* Debug */, + \t\t\t\tAA00000C /* Release */, + \t\t\t); + \t\t\tdefaultConfigurationIsVisible = 0; + \t\t\tdefaultConfigurationName = Debug; + \t\t}; + \t\tAA00000A /* Build configuration list for PBXProject "Provision" */ = { + \t\t\tisa = XCConfigurationList; + \t\t\tbuildConfigurations = ( + \t\t\t\tAA00000D /* Debug */, + \t\t\t\tAA00000E /* Release */, + \t\t\t); + \t\t\tdefaultConfigurationIsVisible = 0; + \t\t\tdefaultConfigurationName = Debug; + \t\t}; + /* End XCConfigurationList section */ + \t}; + \trootObject = AA000009 /* Project object */; + } + """ + end + + # ── ANSI helpers ────────────────────────────────────────────────────────────── + + defp macos?, do: match?({:unix, :darwin}, :os.type()) + defp green, do: IO.ANSI.green() + defp yellow, do: IO.ANSI.yellow() + defp cyan, do: IO.ANSI.cyan() + defp bright, do: IO.ANSI.bright() + defp faint, do: IO.ANSI.faint() + defp reset, do: IO.ANSI.reset() +end diff --git a/lib/mix/tasks/mob.publish.ex b/lib/mix/tasks/mob.publish.ex new file mode 100644 index 0000000..3ac75a9 --- /dev/null +++ b/lib/mix/tasks/mob.publish.ex @@ -0,0 +1,357 @@ +defmodule Mix.Tasks.Mob.Publish do + use Mix.Task + + @shortdoc "Upload a release artifact to a platform store (--ios | --android)" + + @moduledoc """ + Uploads a release-signed artifact to the platform's app store. + + mix mob.publish --ios # uploads _build/mob_release/<App>.ipa + mix mob.publish --ios path/to/Foo.ipa # uploads a specific .ipa + mix mob.publish --android # uploads android/app/build/outputs/bundle/release/app-release.aab + mix mob.publish --android path/to/app.aab # uploads a specific .aab + mix mob.publish --android --track production # override track from mob.exs + + Platform flag is **required** — Mob is intentionally platform-agnostic + and refuses to default to either side. Pick `--ios` or `--android` + explicitly so it's obvious from the command which store you're hitting. + + ## --ios prerequisites + + 1. App Store Connect API key (.p8 file). Create one at + https://appstoreconnect.apple.com/access/api with App Manager role. + 2. The app record exists in App Store Connect (the bundle ID is + registered there as an app, not just as an App ID in the developer + portal). + 3. `mob.exs` configured: + + config :mob_dev, + app_store_connect: [ + key_id: "ABC123XYZ4", + issuer_id: "69a6de76-aaaa-bbbb-cccc-1234567890ab", + key_path: "~/.appstoreconnect/AuthKey_ABC123XYZ4.p8" + ] + + ## What --ios does + + Runs `xcrun altool --upload-app` with API-key auth. altool validates the + IPA, uploads it, and returns when Apple has accepted the build for + processing. Apple then takes 5-15 minutes to process the build before it + appears in TestFlight. + """ + + @switches [verbose: :boolean, ios: :boolean, android: :boolean, track: :string] + + @impl Mix.Task + def run(argv) do + {opts, args, _} = OptionParser.parse(argv, strict: @switches) + + case pick_platform(opts) do + :ios -> publish_ios(opts, args) + :android -> publish_android(opts, args) + end + end + + # Platform selection — explicit-only. `mix mob.publish` with no flag + # is always wrong; `mix mob.publish --ios --android` is contradictory. + defp pick_platform(opts) do + case {opts[:ios], opts[:android]} do + {true, true} -> + Mix.raise( + "Pass exactly one of --ios or --android, not both. Each store has " <> + "a separate validator and credential set; one publish at a time." + ) + + {true, _} -> + :ios + + {_, true} -> + :android + + _ -> + Mix.raise(""" + mix mob.publish requires --ios or --android. + + Mob is platform-agnostic by design — neither side is the default. + Pick the store you want to publish to: + + mix mob.publish --ios + mix mob.publish --android + + Or use `mix mob.republish --ios` to bump the build number, + rebuild, and upload in one shot. + """) + end + end + + defp publish_android(opts, args) do + aab_path = resolve_aab_path(args) + gp = load_google_play_config!() + + track = opts[:track] || gp[:track] || "internal" + + Mix.shell().info("") + Mix.shell().info("#{cyan()}=== Uploading to Google Play ===#{reset()}") + Mix.shell().info(" AAB: #{aab_path}") + Mix.shell().info(" Package: #{gp[:package_name]}") + Mix.shell().info(" Track: #{track}") + Mix.shell().info(" Service acct: #{gp[:service_account_json]}") + Mix.shell().info("") + + case MobDev.GooglePlay.upload(aab_path, Keyword.put(gp, :track, track)) do + {:ok, version_code} -> + Mix.shell().info("") + Mix.shell().info("#{green()}✓ Upload accepted by Google Play#{reset()}") + Mix.shell().info(" versionCode #{version_code} is on the #{track} track.") + Mix.shell().info("") + Mix.shell().info("View it at:") + Mix.shell().info(" #{cyan()}https://play.google.com/console#{reset()}") + + {:error, reason} -> + Mix.raise(reason) + end + end + + # ── --ios path (existing behavior) ────────────────────────────────────────── + + defp publish_ios(opts, args) do + case :os.type() do + {:unix, :darwin} -> + :ok + + _ -> + Mix.raise("mix mob.publish --ios is only supported on macOS (xcrun altool is required).") + end + + unless System.find_executable("xcrun") do + Mix.raise("xcrun not found — install Xcode and run `xcode-select --install`.") + end + + ipa_path = resolve_ipa_path(args) + asc = load_asc_config!() + + Mix.shell().info("") + Mix.shell().info("#{cyan()}=== Uploading to App Store Connect ===#{reset()}") + Mix.shell().info(" IPA: #{ipa_path}") + Mix.shell().info(" Key ID: #{asc[:key_id]}") + Mix.shell().info(" Issuer ID: #{asc[:issuer_id]}") + Mix.shell().info(" Key path: #{asc[:key_path]}") + Mix.shell().info("") + Mix.shell().info("(altool may take a few minutes — IPA is uploaded then validated by Apple.)") + Mix.shell().info("") + + install_p8_for_altool!(asc[:key_path], asc[:key_id]) + + altool_args = [ + "altool", + "--upload-app", + "--type", + "ios", + "--file", + ipa_path, + "--apiKey", + asc[:key_id], + "--apiIssuer", + asc[:issuer_id] + ] + + altool_args = if opts[:verbose], do: altool_args ++ ["--verbose"], else: altool_args + + case System.cmd("xcrun", altool_args, stderr_to_stdout: true, into: IO.stream()) do + {_, 0} -> + Mix.shell().info("") + Mix.shell().info("#{green()}✓ Upload accepted by App Store Connect#{reset()}") + Mix.shell().info("") + Mix.shell().info("Apple is processing the build now (~5-15 minutes).") + Mix.shell().info("Once processed, the build appears in TestFlight at:") + Mix.shell().info(" #{cyan()}https://appstoreconnect.apple.com/apps#{reset()}") + + {_, rc} -> + Mix.raise("altool exited #{rc} — see output above.") + end + end + + # ── IPA resolution ────────────────────────────────────────────────────────── + + defp resolve_ipa_path([path]) when is_binary(path) do + abs = Path.expand(path) + + unless File.exists?(abs) do + Mix.raise("IPA not found at #{abs}") + end + + abs + end + + defp resolve_ipa_path([]) do + output_dir = Path.expand("_build/mob_release") + + case Path.wildcard(Path.join(output_dir, "*.ipa")) do + [] -> + Mix.raise(""" + No .ipa found in #{output_dir}. + + Run `mix mob.release` first, or pass an explicit path: + + mix mob.publish path/to/App.ipa + """) + + [single] -> + single + + many -> + Mix.raise(""" + Multiple .ipas found in #{output_dir}; pass one explicitly: + + #{Enum.map_join(many, "\n", &" #{&1}")} + + mix mob.publish #{List.first(many)} + """) + end + end + + defp resolve_ipa_path(_) do + Mix.raise("Usage: mix mob.publish [path/to/App.ipa]") + end + + # ── App Store Connect config ──────────────────────────────────────────────── + + defp load_asc_config! do + config_file = Path.join(File.cwd!(), "mob.exs") + + unless File.exists?(config_file) do + Mix.raise("mob.exs not found in #{File.cwd!()} — run from the project root.") + end + + cfg = Config.Reader.read!(config_file) |> Keyword.get(:mob_dev, []) + asc = cfg[:app_store_connect] + + unless is_list(asc) do + Mix.raise(""" + Missing :app_store_connect in mob.exs. Add: + + config :mob_dev, + app_store_connect: [ + key_id: "ABC123XYZ4", + issuer_id: "69a6de76-aaaa-bbbb-cccc-1234567890ab", + key_path: "~/.appstoreconnect/AuthKey_ABC123XYZ4.p8" + ] + + Get an API key at https://appstoreconnect.apple.com/access/api + """) + end + + Enum.each([:key_id, :issuer_id, :key_path], fn key -> + unless is_binary(asc[key]) do + Mix.raise("app_store_connect[:#{key}] missing or not a string in mob.exs") + end + end) + + Keyword.update!(asc, :key_path, &Path.expand/1) + end + + # altool's --apiKey flag looks up the .p8 file in fixed locations: + # ./private_keys/AuthKey_<KEY_ID>.p8 + # ~/private_keys/AuthKey_<KEY_ID>.p8 + # ~/.private_keys/AuthKey_<KEY_ID>.p8 + # ~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8 + # Copy/symlink the user's key into the last of those so altool finds it + # without us needing to clutter their home dir's ~/private_keys. + defp install_p8_for_altool!(key_path, key_id) do + unless File.exists?(key_path) do + Mix.raise("App Store Connect API key not found at #{key_path}") + end + + # Apple's altool reads .p8 keys from this fixed home-dir location; + # the "priv" substring is literally "private_keys", not Mob's own + # priv/ directory. Application.app_dir/2 doesn't apply here. + # credo:disable-for-next-line ExSlop.Check.Warning.PathExpandPriv + target_dir = Path.expand("~/.appstoreconnect/private_keys") + File.mkdir_p!(target_dir) + target = Path.join(target_dir, "AuthKey_#{key_id}.p8") + + if not File.exists?(target) or File.read!(target) != File.read!(key_path) do + File.cp!(key_path, target) + end + + :ok + end + + # ── AAB resolution ─────────────────────────────────────────────────────────── + + defp resolve_aab_path([path]) when is_binary(path) do + abs = Path.expand(path) + unless File.exists?(abs), do: Mix.raise("AAB not found at #{abs}") + abs + end + + defp resolve_aab_path([]) do + output_dir = Path.expand("android/app/build/outputs/bundle/release") + + case Path.wildcard(Path.join(output_dir, "*.aab")) do + [] -> + Mix.raise(""" + No .aab found in #{output_dir}. + + Run `cd android && ./gradlew bundleRelease` first, or pass an explicit path: + + mix mob.publish --android path/to/app-release.aab + """) + + [single] -> + single + + many -> + Mix.raise(""" + Multiple .aab files found; pass one explicitly: + + #{Enum.map_join(many, "\n", &" #{&1}")} + """) + end + end + + defp resolve_aab_path(_), do: Mix.raise("Usage: mix mob.publish --android [path/to/app.aab]") + + # ── Google Play config ─────────────────────────────────────────────────────── + + defp load_google_play_config! do + config_file = Path.join(File.cwd!(), "mob.exs") + + unless File.exists?(config_file) do + Mix.raise("mob.exs not found in #{File.cwd!()} — run from the project root.") + end + + cfg = Config.Reader.read!(config_file) |> Keyword.get(:mob_dev, []) + gp = cfg[:google_play] + + unless is_list(gp) do + Mix.raise(""" + Missing :google_play in mob.exs. Add: + + config :mob_dev, + google_play: [ + package_name: "com.example.myapp", + service_account_json: "~/.google_play/my-service-account.json", + track: "internal" + ] + + Service account setup: + 1. Play Console → Setup → API access → link a Google Cloud project + 2. Google Cloud → IAM → Service Accounts → create account → download JSON key + 3. Play Console → Setup → API access → grant the account "Release manager" + """) + end + + Enum.each([:package_name, :service_account_json], fn key -> + unless is_binary(gp[key]) do + Mix.raise("google_play[:#{key}] missing or not a string in mob.exs") + end + end) + + Keyword.update!(gp, :service_account_json, &Path.expand/1) + end + + defp green, do: IO.ANSI.green() + defp cyan, do: IO.ANSI.cyan() + defp reset, do: IO.ANSI.reset() +end diff --git a/lib/mix/tasks/mob.push.ex b/lib/mix/tasks/mob.push.ex index de21553..9904d77 100644 --- a/lib/mix/tasks/mob.push.ex +++ b/lib/mix/tasks/mob.push.ex @@ -37,13 +37,14 @@ defmodule Mix.Tasks.Mob.Push do @impl Mix.Task def run(args) do - {opts, _, _} = OptionParser.parse(args, - switches: [all: :boolean, cookie: :string], - aliases: [c: :cookie] - ) + {opts, _, _} = + OptionParser.parse(args, + switches: [all: :boolean, cookie: :string], + aliases: [c: :cookie] + ) - push_all = Keyword.get(opts, :all, false) - cookie = opts |> Keyword.get(:cookie, "mob_secret") |> String.to_atom() + push_all = Keyword.get(opts, :all, false) + cookie = opts |> Keyword.get(:cookie, "mob_secret") |> String.to_atom() IO.puts("") @@ -71,6 +72,7 @@ defmodule Mix.Tasks.Mob.Push do if pushed > 0 do IO.puts(" #{IO.ANSI.green()}✓ #{pushed} module(s) pushed#{IO.ANSI.reset()}") end + Enum.each(failed, fn {mod, reason} -> IO.puts(" #{IO.ANSI.red()}✗ #{mod}: #{inspect(reason)}#{IO.ANSI.reset()}") end) diff --git a/lib/mix/tasks/mob.regen_driver_tab.ex b/lib/mix/tasks/mob.regen_driver_tab.ex new file mode 100644 index 0000000..946ab42 --- /dev/null +++ b/lib/mix/tasks/mob.regen_driver_tab.ex @@ -0,0 +1,248 @@ +defmodule Mix.Tasks.Mob.RegenDriverTab do + use Mix.Task + + alias MobDev.StaticNifs + + @shortdoc "Regenerate priv/generated/driver_tab_*.zig from :static_nifs" + + @moduledoc """ + Regenerates the per-app static-NIF table source files in + `priv/generated/driver_tab_ios.zig` and `priv/generated/driver_tab_android.zig`. + + These files are linked **before** `libbeam.a` so they override BEAM's empty + built-in `erts_static_nif_tab[]`. Without them, `load_nif/2` falls back to + `dlopen`, which is broken on iOS (App Store rejects bundled `.dylibs`) and + on Android (RTLD_LOCAL hides parent's `enif_*` symbols from children). + + mix mob.regen_driver_tab # regenerate both platforms (default: Zig) + mix mob.regen_driver_tab --format c # emit driver_tab_*.c instead (hand-editable C) + mix mob.regen_driver_tab --check # verify on-disk matches manifest, exit non-zero on drift + + ## Format + + Zig is the default as of Phase 6a iter 4 — it uses comptime gates + instead of `#ifdef` for guarded NIFs (e.g. iOS `sqlite3_nif` + device-only), and Zig's `export` keyword produces the same C-ABI + symbols libbeam.a expects. `--format c` is still supported for + anyone who wants a hand-editable C dispatch table. Both formats + produce equivalent behavior at link time. + + C NIF authors are unaffected by the default: their `c_src/<name>.c` + files compile via the usual path, and the Zig dispatch table calls + their `<name>_nif_init()` function through standard C ABI (`extern + fn <name>_nif_init() callconv(.c)`). + + ## Where the NIF list comes from + + `MobDev.StaticNifs.default_nifs/0` baked-in defaults are merged with any + `:static_nifs` set in `mob.exs`: + + config :mob_dev, + static_nifs: [ + %{module: :my_native, archs: [:all]} + ] + + See `MobDev.StaticNifs` for the entry schema and arch values. + + ## Why a Mix task and not a build-time generator + + The output is committed to the app's repo so reviewers can see what's in + the static-link surface. It also lets non-Mob build tools (e.g. xcodebuild + invoked outside `mix mob.deploy`) pick up the file as plain source. The + task is fast (deterministic file generation) so re-running it on every + `mix compile` is cheap. + + ## Drift detection + + `mob.doctor` runs `--check` mode and reports any drift between the + manifest and the on-disk files. CI can do the same. + """ + + @ios_c_path "priv/generated/driver_tab_ios.c" + @android_c_path "priv/generated/driver_tab_android.c" + @ios_zig_path "priv/generated/driver_tab_ios.zig" + @android_zig_path "priv/generated/driver_tab_android.zig" + + @impl Mix.Task + def run(args) do + {opts, _, _} = OptionParser.parse(args, strict: [check: :boolean, format: :string]) + format = parse_format(opts[:format]) + + # Per-platform: the iOS build has no zig plugin-NIF compile path yet, so a + # zig plugin NIF (e.g. Android-only mob_bluetooth) must not land in + # driver_tab_ios or the link fails on an unresolved <module>_nif_init. + ios_nifs = resolved_nifs(:ios) + android_nifs = resolved_nifs(:android) + + case validate_all(android_nifs) do + :ok -> :ok + {:error, msg} -> Mix.raise(":static_nifs invalid — #{msg}") + end + + ios_src = StaticNifs.generate(:ios, ios_nifs, format: format) |> IO.iodata_to_binary() + + android_src = + StaticNifs.generate(:android, android_nifs, format: format) |> IO.iodata_to_binary() + + paths = target_paths(format) + + if opts[:check] do + check_mode(ios_src, android_src, paths) + else + write_mode(ios_src, android_src, paths) + end + end + + # Default is :zig as of Phase 6a iter 4 — for projects whose + # `ios/build.zig` was generated with the addZigObject helper (mob_new + # post-iter-2). Older projects without that helper fall back to :c + # because their build.zig's addCObject → addCSourceFile path can't + # compile a .zig source (Zig 0.17-dev rejects the conflicting + # -Mroot=./ + positional .zig combination). Pass `--format c`/`--format zig` + # explicitly to override the auto-detect. + defp parse_format(nil), do: detect_default_format() + defp parse_format("zig"), do: :zig + defp parse_format("c"), do: :c + + defp parse_format(other) do + Mix.raise("Unknown --format #{inspect(other)}. Supported: zig, c.") + end + + defp detect_default_format do + case File.read("ios/build.zig") do + {:ok, content} -> + if String.contains?(content, "addZigObject") do + :zig + else + :c + end + + _ -> + # No project-side build.zig (rare — e.g. running from a fresh + # template directory). Default to Zig; if there's no build + # pipeline yet the file is harmless until one's added. + :zig + end + end + + @doc false + @spec resolved_nifs() :: [StaticNifs.nif_entry()] + def resolved_nifs, do: resolved_nifs(:all) + + @doc false + # `platform` filters the *plugin* NIFs to those the platform's build actually + # compiles, so the generated table never references a symbol the link can't + # find. The iOS build compiles only C plugin NIFs (`-Dplugin_c_nifs`); it has + # no zig plugin-NIF path yet, so zig plugin NIFs (e.g. Android-only + # mob_bluetooth, whose zig is full of JNI symbols and can't build on iOS) are + # excluded from driver_tab_ios. Android compiles both, and `:all` (the + # default, used by mob.doctor / project-NIF classification) keeps everything. + @spec resolved_nifs(:ios | :android | :all) :: [StaticNifs.nif_entry()] + def resolved_nifs(platform) do + # mob.exs isn't auto-imported by Mix.Config — every other task that + # reads from it goes through Config.Reader.read! directly. Match that + # pattern here. Application.get_env stays as a secondary source so + # MIX_CONFIG=... or programmatic Application.put_env still wins for + # tests that want to bypass the file. + user = + MobDev.Config.load_mob_config() + |> Keyword.get(:static_nifs, Application.get_env(:mob_dev, :static_nifs, [])) + + # Activated plugins contribute their NIFs to the static-NIF table. The + # plugin's native source still has to be compiled into the binary by the + # build (see android_sources/swift_files); this only adds the table entry. + plugin_nifs = + MobDev.Plugin.Merge.nifs(MobDev.Plugin.activated()) + |> reject_uncompiled_plugin_nifs(platform) + + StaticNifs.resolve(user ++ plugin_nifs) + end + + @doc false + # Drops plugin NIFs the platform's build won't compile, so the generated table + # never references an uncompiled <module>_nif_init: + # - iOS has no zig plugin-NIF path yet, so `lang: :zig` entries are dropped. + # - Android has no Objective-C runtime, so `lang: :objc` entries are dropped + # (objc is implicitly Apple-only even without an explicit `platform: :ios`). + # - A NIF tagged `platform: :ios | :android` is only compiled on that + # platform (a cross-platform plugin ships a separate iOS + Android source + # for the same module); the other platform drops it. No `:platform` = + # compiled everywhere. `:all` keeps everything. + # Public for tests. + @spec reject_uncompiled_plugin_nifs([map()], :ios | :android | :all) :: [map()] + def reject_uncompiled_plugin_nifs(nifs, :ios) do + nifs + |> Enum.reject(&(&1[:lang] == :zig)) + |> Enum.reject(&(&1[:platform] == :android)) + end + + def reject_uncompiled_plugin_nifs(nifs, :android) do + nifs + |> Enum.reject(&(&1[:lang] == :objc)) + |> Enum.reject(&(&1[:platform] == :ios)) + end + + def reject_uncompiled_plugin_nifs(nifs, _), do: nifs + + @doc false + @spec target_paths() :: %{ios: String.t(), android: String.t()} + def target_paths, do: target_paths(:c) + + @doc false + @spec target_paths(:c | :zig) :: %{ios: String.t(), android: String.t()} + def target_paths(:c), do: %{ios: @ios_c_path, android: @android_c_path} + def target_paths(:zig), do: %{ios: @ios_zig_path, android: @android_zig_path} + + defp validate_all(nifs) do + Enum.reduce_while(nifs, :ok, fn entry, :ok -> + case StaticNifs.validate_entry(entry) do + :ok -> {:cont, :ok} + {:error, msg} -> {:halt, {:error, "#{inspect(entry)}: #{msg}"}} + end + end) + end + + defp write_mode(ios_src, android_src, %{ios: ios_path, android: android_path}) do + File.mkdir_p!(Path.dirname(ios_path)) + File.mkdir_p!(Path.dirname(android_path)) + + write_if_changed(ios_path, ios_src) + write_if_changed(android_path, android_src) + end + + defp write_if_changed(path, new_content) do + case File.read(path) do + {:ok, ^new_content} -> + Mix.shell().info(" ✓ #{path} (unchanged)") + + _ -> + File.write!(path, new_content) + Mix.shell().info(" ✓ #{path} (regenerated)") + end + end + + defp check_mode(ios_src, android_src, %{ios: ios_path, android: android_path}) do + drifts = + [{ios_path, ios_src}, {android_path, android_src}] + |> Enum.filter(fn {path, expected} -> + File.read(path) != {:ok, expected} + end) + + case drifts do + [] -> + Mix.shell().info("✓ driver_tab files match :static_nifs") + :ok + + paths -> + msg = + paths + |> Enum.map(fn {path, _} -> " - #{path}" end) + |> Enum.join("\n") + + Mix.raise( + "driver_tab drift detected — these files don't match :static_nifs:\n#{msg}\n\n" <> + "Run `mix mob.regen_driver_tab` to fix." + ) + end + end +end diff --git a/lib/mix/tasks/mob.regen_plugin_manifest.ex b/lib/mix/tasks/mob.regen_plugin_manifest.ex new file mode 100644 index 0000000..fd8f891 --- /dev/null +++ b/lib/mix/tasks/mob.regen_plugin_manifest.ex @@ -0,0 +1,65 @@ +defmodule Mix.Tasks.Mob.RegenPluginManifest do + use Mix.Task + + alias MobDev.Plugin.RuntimeManifest + + @shortdoc "Regenerate priv/generated/mob_plugins.exs from the activated plugins" + + @moduledoc """ + Regenerates `priv/generated/mob_plugins.exs` — the host's **runtime plugin + manifest** — from the activated plugins' tier-3/4 manifest sections. + + mix mob.regen_plugin_manifest # regenerate the file + mix mob.regen_plugin_manifest --check # verify on-disk matches, non-zero on drift + + Tiers 3 (multi-screen) and 4 (sub-app) are pure-Elixir and runtime-wired: the + on-device `Mob.Plugins` module reads this file at boot to learn which screens, + lifecycle hooks, settings, and notification handlers the activated plugins + declared (`MobDev.Plugin.activated/0` is compile-time only). Spec-v2 + `:screens_generator`s run here, under the host-config audit. + + Like `mix mob.regen_driver_tab`, the output is committed so reviewers see the + runtime surface, and the file is regenerated whenever `config :mob, :plugins` + changes. `mob.doctor` runs `--check` to report drift. + """ + + @rel_path "priv/generated/mob_plugins.exs" + + @impl Mix.Task + def run(args) do + # Spec-v2 generators may call HOST modules (mob_ash introspects the host's + # Ash domains), not just read config values — make sure the host app is + # compiled, its config loaded, and its ebin on the code path. (Surfaced by + # mob_ash: gen_screens only ever read config, masking this.) + Mix.Task.run("app.config") + + {opts, _, _} = OptionParser.parse(args, strict: [check: :boolean]) + + manifest = RuntimeManifest.build(MobDev.Plugin.activated()) + rendered = RuntimeManifest.render(manifest) + path = Path.join(File.cwd!(), @rel_path) + + if opts[:check] do + check_mode(path, rendered) + else + File.mkdir_p!(Path.dirname(path)) + File.write!(path, rendered) + Mix.shell().info(" ✓ #{@rel_path} (#{summary(manifest)})") + end + end + + defp check_mode(path, rendered) do + case File.read(path) do + {:ok, ^rendered} -> + Mix.shell().info(" ✓ #{@rel_path} up to date") + + _ -> + Mix.raise("#{@rel_path} is out of date — run `mix mob.regen_plugin_manifest`") + end + end + + defp summary(m) do + "#{length(m.screens)} screens, #{length(m.lifecycle)} lifecycle, " <> + "#{length(m.settings)} settings, #{length(m.notification_handlers)} handlers" + end +end diff --git a/lib/mix/tasks/mob.release.ex b/lib/mix/tasks/mob.release.ex new file mode 100644 index 0000000..606e813 --- /dev/null +++ b/lib/mix/tasks/mob.release.ex @@ -0,0 +1,211 @@ +defmodule Mix.Tasks.Mob.Release do + use Mix.Task + + @shortdoc "Build a signed release artifact (.ipa or .aab) for the app store" + + @moduledoc """ + Builds a release-signed artifact ready to upload to the app store. + + mix mob.release # iOS .ipa (default) + mix mob.release --ios # iOS .ipa (explicit) + mix mob.release --android # Android .aab + mix mob.release --security-gate # run mix mob.security_scan first; + # abort the release on any + # critical/high/medium finding + + ## --security-gate + + Runs the full security scan against the project (every layer: + Hex/Gradle/Swift dep CVEs, bundled-runtime drift, C/Kotlin/Swift + static analysis) **before** building or signing. If the scan + surfaces any critical/high/medium finding, the release aborts + with a non-zero exit code — nothing is built, nothing is signed. + Combine with the rest of your release flags as needed: + + mix mob.release --android --security-gate + mix mob.release --ios --security-gate + + Equivalent to running `mix mob.security_scan --strict` and only + proceeding to `mix mob.release` if the scan exits 0; the gate + flag just bundles the two into one command so a wrong-order + invocation can't slip through. + + ## --ios output + + `_build/mob_release/<App>.ipa` + + ## --android output + + `android/app/build/outputs/bundle/release/app-release.aab` + + ## --ios prerequisites + + 1. Apple Developer Program membership (paid, $99/yr) + 2. An "Apple Distribution" certificate in your keychain + (Xcode → Settings → Accounts → Manage Certificates → +) + 3. An App Store provisioning profile for your bundle ID, downloaded + to `~/Library/Developer/Xcode/UserData/Provisioning Profiles/`. + `mix mob.provision --distribution` automates the profile download. + + ## --android prerequisites + + 1. `android/keystore.properties` filled in with your upload keystore + credentials. `android/upload_jks.keystore` must exist. See + `android/keystore.properties.example`. + + ## What --android does + + 1. Ensures the Android OTP runtime is cached (`~/.mob/cache/otp-android-*`). + 2. Stages a temp tree: OTP runtime + app BEAMs + exqlite BEAMs. + 3. Runs `MobDev.OtpAssetBundle.build/2` to produce + `android/app/src/release/assets/otp.zip` — stripped and compressed. + `MobBridge.extractOtpIfNeeded()` extracts this on first launch. The + release-variant asset dir keeps this out of debug builds. + 4. Runs `./gradlew bundleRelease` to produce the signed AAB. + + Use `mix mob.publish --android` to upload to Google Play. + """ + + @impl Mix.Task + def run(args) do + {opts, _, _} = + OptionParser.parse(args, + switches: [ + ios: :boolean, + android: :boolean, + slim: :boolean, + security_gate: :boolean + ] + ) + + if opts[:security_gate], do: run_security_gate() + + if opts[:android] do + run_android(opts) + else + run_ios(opts) + end + end + + # Runs the full security scan before the build kicks off. Aborts + # the release on any critical/high/medium finding so a vulnerable + # build never reaches signing. Tip: mention `--security-gate` in + # the success printouts so users discover it next time. + defp run_security_gate do + Mix.shell().info("→ #{cyan()}--security-gate#{reset()}: running mix mob.security_scan first") + + report = MobDev.SecurityScan.run([]) + counts = MobDev.SecurityScan.Report.severity_counts(report) + blocking = counts.critical + counts.high + counts.medium + + if blocking > 0 do + Mix.shell().error("") + IO.write(MobDev.SecurityScan.Formatter.terminal(report)) + + Mix.raise( + "--security-gate: #{blocking} blocking finding(s) — release aborted before build. " <> + "Run `mix mob.security_scan` for the full breakdown, fix or `--skip` the offending layer, " <> + "and rerun." + ) + end + + Mix.shell().info( + "→ #{green()}✓ security scan clean#{reset()} (#{counts.low} low, #{counts.unknown} unknown — non-blocking)\n" + ) + end + + defp run_android(opts) do + unless File.dir?("android") do + Mix.raise("No android/ directory found. Run from the root of a Mob Android project.") + end + + Mix.Task.run("compile") + + case MobDev.ReleaseAndroid.build_aab(slim: Keyword.get(opts, :slim, true)) do + {:ok, path} -> + Mix.shell().info("") + Mix.shell().info("#{green()}✓ Release build complete#{reset()}") + Mix.shell().info(" AAB: #{cyan()}#{path}#{reset()}") + Mix.shell().info(" Size: #{file_size_human(path)}") + Mix.shell().info("") + + Mix.shell().info( + "Next: #{cyan()}mix mob.publish --android#{reset()} to upload to Google Play." + ) + + maybe_security_gate_tip(opts) + + {:error, reason} -> + Mix.raise(reason) + end + end + + defp run_ios(opts) do + case :os.type() do + {:unix, :darwin} -> :ok + _ -> Mix.raise("mix mob.release --ios is only supported on macOS.") + end + + unless File.dir?("ios") do + Mix.raise("No ios/ directory found. Run from the root of a mob iOS project.") + end + + slim = Keyword.get(opts, :slim, true) + + Mix.Task.run("compile") + + case MobDev.Release.build_ipa(slim: slim) do + {:ok, path} -> + Mix.shell().info("") + Mix.shell().info("#{green()}✓ Release build complete#{reset()}") + Mix.shell().info(" IPA: #{cyan()}#{path}#{reset()}") + Mix.shell().info(" Size: #{file_size_human(path)}") + Mix.shell().info("") + + Mix.shell().info( + "Next: #{cyan()}mix mob.publish --ios#{reset()} to upload to TestFlight." + ) + + maybe_security_gate_tip(opts) + + {:error, reason} -> + Mix.raise(reason) + end + end + + # Surface --security-gate in the post-build "next steps" block when + # it wasn't used. Discovery via the same terminal printout that + # already lists `mix mob.publish` keeps the option visible. + defp maybe_security_gate_tip(opts) do + unless opts[:security_gate] do + Mix.shell().info( + "Tip: #{cyan()}mix mob.release --security-gate#{reset()} " <> + "to run mix mob.security_scan first and abort on critical/high/medium findings." + ) + end + end + + defp file_size_human(path) do + case File.stat(path) do + {:ok, %{size: bytes}} -> + cond do + bytes >= 1024 * 1024 -> + :io_lib.format("~.1fM", [bytes / (1024 * 1024)]) |> List.flatten() + + bytes >= 1024 -> + :io_lib.format("~.1fK", [bytes / 1024]) |> List.flatten() + + true -> + "#{bytes}B" + end + |> to_string() + + _ -> + "?" + end + end + + defp green, do: IO.ANSI.green() + defp cyan, do: IO.ANSI.cyan() + defp reset, do: IO.ANSI.reset() +end diff --git a/lib/mix/tasks/mob.release.openssl.ex b/lib/mix/tasks/mob.release.openssl.ex new file mode 100644 index 0000000..271eb15 --- /dev/null +++ b/lib/mix/tasks/mob.release.openssl.ex @@ -0,0 +1,135 @@ +defmodule Mix.Tasks.Mob.Release.Openssl do + @shortdoc "Build OpenSSL + crypto NIF static archives for one Mob release target" + + @moduledoc """ + Drives the OpenSSL cross-compile + the OTP crypto NIF static archive + for one target — replaces running `scripts/release/openssl/<plat>.sh` + and `scripts/release/openssl/build_crypto_static_<plat>.sh` in + sequence. + + mix mob.release.openssl android_arm64 # one target + mix mob.release.openssl android_arm32 + mix mob.release.openssl ios_sim + mix mob.release.openssl ios_device + mix mob.release.openssl all # all four, sequentially + + ## What runs + + For each target: + 1. `MobDev.Release.OpenSSL.build/2` — cross-compile OpenSSL 3.x, + produces `<prefix>/lib/libcrypto.a` + headers. + 2. `MobDev.Release.OpenSSL.CryptoNif.build/2` — compile OTP's + crypto NIF C sources with `-DSTATIC_ERLANG_NIF`, archive as + `<otp_src>/lib/crypto/priv/lib/<arch>/crypto.a`, verify the + `crypto_nif_init` symbol is exported. + + Both steps are required for the release tarball (handled in iter 4 + by `mix mob.release.tarball`); this task just produces the two + artefacts and stops. + + ## Options + + * `--openssl-src PATH` — OpenSSL source checkout. Default: + `$OPENSSL_SRC` env or `~/code/openssl`. + * `--otp-src PATH` — OTP source checkout. Default: `$OTP_SRC` env + or `~/code/otp`. + * `--prefix PATH` — OpenSSL install prefix. Default per-target: + `/tmp/openssl-<target>`. + * `--ndk-root PATH` — Android NDK root (Android targets only). + Default: `~/Library/Android/sdk/ndk/<recommended-version>`. + + ## Errors + + Failure produces a tagged `MobDev.Release.Errors` tuple formatted + via `Mix.raise/1`. Common failure modes: + + * `precondition failed — OPENSSL_SRC missing` — clone openssl/openssl + * `precondition failed — Android NDK not at <path>` — check NDK install + * `precondition failed — iOS iphonesimulator SDK not available` — + run `xcode-select --install` + * `command failed (exit N)` — actual tool error, output in the + raised message + """ + use Mix.Task + + alias MobDev.Release.{Errors, OpenSSL} + alias OpenSSL.CryptoNif + + @valid_targets [ + "android_arm64", + "android_arm32", + "ios_sim", + "ios_device", + "all" + ] + + @switches [ + openssl_src: :string, + otp_src: :string, + prefix: :string, + ndk_root: :string + ] + + @impl Mix.Task + def run(args) do + {opts, positional, _} = OptionParser.parse(args, strict: @switches) + + case positional do + [target_str] when target_str in @valid_targets -> + targets = + if target_str == "all" do + OpenSSL.targets() + else + [String.to_atom(target_str)] + end + + Enum.each(targets, &run_one(&1, opts)) + + [bad] -> + Mix.raise("unknown target: #{bad}\nvalid: #{Enum.join(@valid_targets, ", ")}") + + [] -> + Mix.raise("missing target argument\nusage: mix mob.release.openssl <target>") + + _ -> + Mix.raise("too many arguments — pass exactly one target") + end + end + + defp run_one(target_id, opts) do + Mix.shell().info("==> OpenSSL #{target_id}") + + case OpenSSL.build(target_id, build_opts(opts, target_id)) do + {:ok, info} -> + Mix.shell().info(" ✓ libcrypto.a → #{info.libcrypto}") + run_crypto_nif(target_id, info, opts) + + {:error, _} = err -> + Mix.raise(Errors.format(err)) + end + end + + defp run_crypto_nif(target_id, openssl_info, opts) do + Mix.shell().info("==> crypto NIF #{target_id}") + + crypto_opts = + opts + |> build_opts(target_id) + |> Keyword.put(:openssl_prefix, openssl_info.prefix) + |> Keyword.drop([:openssl_src, :prefix]) + + case CryptoNif.build(target_id, crypto_opts) do + {:ok, info} -> + Mix.shell().info(" ✓ crypto.a → #{info.archive}") + + {:error, _} = err -> + Mix.raise(Errors.format(err)) + end + end + + defp build_opts(opts, _target_id) do + opts + |> Keyword.take([:openssl_src, :otp_src, :prefix, :ndk_root]) + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + end +end diff --git a/lib/mix/tasks/mob.release.otp.ex b/lib/mix/tasks/mob.release.otp.ex new file mode 100644 index 0000000..060ecda --- /dev/null +++ b/lib/mix/tasks/mob.release.otp.ex @@ -0,0 +1,106 @@ +defmodule Mix.Tasks.Mob.Release.Otp do + @shortdoc "Cross-compile the OTP runtime for one Mob release target" + + @moduledoc """ + Drives `MobDev.Release.OTP.build/2` from the CLI. Replaces the + three `scripts/release/xcompile_*.sh` scripts plus the misplaced + `scripts/release/openssl/_build_otp_android_arm64.sh`. + + mix mob.release.otp android_arm64 # one target + mix mob.release.otp android_arm32 + mix mob.release.otp ios_sim + mix mob.release.otp ios_device + mix mob.release.otp all # all four, sequentially + + ## What runs + + For each target: + 1. `make distclean` (tolerated — first-time builds have nothing) + 2. `./otp_build configure --xcomp-conf=<conf> <ssl-flags>` + 3. `./otp_build boot` (the long step — 5–10 minutes per target) + 4. `rm -rf <release_root>` then install: + - Android: `./otp_build release -a <release_root>` + - iOS: `make release RELEASE_ROOT=<release_root>` + 5. Verify per-target sanity (erts-<vsn> dir exists, Android also + checks `lib/{crypto,public_key,ssl}-*` apps were produced). + + ## Options + + * `--otp-src PATH` — OTP source checkout. Default: `$OTP_SRC` env + or `~/code/otp`. + * `--openssl-prefix PATH` — pre-built OpenSSL install. Required + for Android targets; ignored for iOS. + * `--release-root PATH` — install destination. Default per-target. + * `--ndk-root PATH` — Android NDK root override. + + ## Errors + + All failures format via `MobDev.Release.Errors.format/1` and raise + through `Mix.raise/1`. Common cases: + + * `precondition failed — OTP_SRC missing` — clone erlang/otp + * `precondition failed — openssl_prefix required` — run + `mix mob.release.openssl <target>` first + * `precondition failed — Android NDK not at …` — install NDK + * `precondition failed — crypto / public_key / ssl apps missing` — + this is the verify step catching a broken `--with-ssl` wiring; + see the OTP source's `xcomp/erl-xcomp-<arch>-android.conf` + """ + use Mix.Task + + alias MobDev.Release.{Errors, OTP} + + @valid_targets [ + "android_arm64", + "android_arm32", + "android_x86_64", + "ios_sim", + "ios_device", + "all" + ] + + @switches [ + otp_src: :string, + openssl_prefix: :string, + release_root: :string, + ndk_root: :string + ] + + @impl Mix.Task + def run(args) do + {opts, positional, _} = OptionParser.parse(args, strict: @switches) + + case positional do + [target_str] when target_str in @valid_targets -> + targets = + if target_str == "all", do: OTP.targets(), else: [String.to_atom(target_str)] + + Enum.each(targets, &run_one(&1, opts)) + + [bad] -> + Mix.raise("unknown target: #{bad}\nvalid: #{Enum.join(@valid_targets, ", ")}") + + [] -> + Mix.raise("missing target argument\nusage: mix mob.release.otp <target>") + + _ -> + Mix.raise("too many arguments — pass exactly one target") + end + end + + defp run_one(target_id, opts) do + Mix.shell().info("==> OTP cross-compile #{target_id}") + Mix.shell().info(" (this takes 5–10 minutes — `./otp_build boot` dominates)") + + build_opts = Enum.reject(opts, fn {_k, v} -> is_nil(v) end) + + case OTP.build(target_id, build_opts) do + {:ok, info} -> + Mix.shell().info(" ✓ release tree at #{info.release_root}") + Mix.shell().info(" ✓ erts-#{info.erts_vsn}") + + {:error, _} = err -> + Mix.raise(Errors.format(err)) + end + end +end diff --git a/lib/mix/tasks/mob.release.publish.ex b/lib/mix/tasks/mob.release.publish.ex new file mode 100644 index 0000000..f32fcf5 --- /dev/null +++ b/lib/mix/tasks/mob.release.publish.ex @@ -0,0 +1,87 @@ +defmodule Mix.Tasks.Mob.Release.Publish do + @shortdoc "Upload built OTP tarballs to the GitHub release" + + @moduledoc """ + Drives `MobDev.Release.Publish.publish/1` from the CLI. Replaces + `scripts/release/publish.sh`. + + mix mob.release.publish # all 4 default tarballs + mix mob.release.publish --repo myfork/mob + mix mob.release.publish --assets otp-android,otp-ios-sim + mix mob.release.publish --hash abc12345 + + ## Options + + * `--repo OWNER/NAME` — GitHub repo. Default: `GenericJam/mob`. + * `--hash STR` — release tag hash. Default: detected from OTP source + git, or `$HASH` env. + * `--otp-src PATH` — OTP source checkout used for hash detection. + * `--out-dir PATH` — directory containing the built tarballs. + Default: `$OUT_DIR` or `/tmp`. + * `--assets BASES` — comma-separated tarball basenames to upload + (e.g. `otp-android,otp-ios-sim`). Default: auto-discover any of + the four canonical names that exist in `--out-dir`. + + ## Errors + + Failures format via `MobDev.Release.Errors.format/1` and raise through + `Mix.raise/1`. The categories of interest here are + `:auth_required` and `:infra_unreachable` — the publish step is the + one place in the release pipeline where the failure is most often + not the user's fault, so the message tells them whether it's a `gh + auth login` problem, a GitHub outage, or something else. + """ + use Mix.Task + + alias MobDev.Release.{Errors, Publish} + + @switches [ + repo: :string, + hash: :string, + otp_src: :string, + out_dir: :string, + assets: :string + ] + + @impl Mix.Task + def run(args) do + {opts, positional, _} = OptionParser.parse(args, strict: @switches) + + case positional do + [] -> + do_publish(opts) + + _ -> + Mix.raise("unexpected positional arguments: #{Enum.join(positional, " ")}") + end + end + + defp do_publish(opts) do + Mix.shell().info("==> publish to GitHub release") + + publish_opts = + opts + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> normalize_assets() + + case Publish.publish(publish_opts) do + {:ok, info} -> + Mix.shell().info(" ✓ release #{info.tag} on #{info.repo}") + Enum.each(info.assets, &Mix.shell().info(" • #{&1}")) + + {:error, _} = err -> + Mix.raise(Errors.format(err)) + end + end + + defp normalize_assets(opts) do + case Keyword.fetch(opts, :assets) do + {:ok, csv} -> + bases = csv |> String.split(",", trim: true) |> Enum.map(&String.trim/1) + Keyword.put(opts, :assets, bases) + + :error -> + opts + end + end +end diff --git a/lib/mix/tasks/mob.release.tarball.ex b/lib/mix/tasks/mob.release.tarball.ex new file mode 100644 index 0000000..172bc0e --- /dev/null +++ b/lib/mix/tasks/mob.release.tarball.ex @@ -0,0 +1,84 @@ +defmodule Mix.Tasks.Mob.Release.Tarball do + @shortdoc "Stage and tar the per-target OTP runtime tarball" + + @moduledoc """ + Drives `MobDev.Release.Tarball.build/2` from the CLI. Replaces the + four `scripts/release/tarball_*.sh` scripts. + + mix mob.release.tarball android_arm64 --exqlite-build /path/to/_build/dev/lib/exqlite + mix mob.release.tarball ios_sim + mix mob.release.tarball ios_device + mix mob.release.tarball all --exqlite-build /path/to/exqlite + + Android targets require `--exqlite-build` (no default — projects vary). + iOS targets don't ship exqlite BEAMs so the flag is ignored. + + ## Options + + * `--otp-src PATH` — OTP source checkout. Default: `$OTP_SRC` env or + `~/code/otp`. + * `--otp-release PATH` — install tree from `mix mob.release.otp`. + Default per-target. + * `--openssl-prefix PATH` — OpenSSL install. Default per-target. + * `--exqlite-build PATH` — `_build/dev/lib/exqlite` in any project + that has run `mix deps.get && mix compile`. Required for Android. + * `--android-otp-release PATH` — used by iOS targets to borrow + `crypto`/`public_key`/`ssl` apps. Default: `/tmp/otp-android`. + * `--asn1rt-nif-arm32 PATH` — pre-built arm32 asn1rt_nif.a. + Default: `/tmp/asn1rt_nif_arm32.a`. + * `--out-dir PATH` — tarball destination. Default: `/tmp`. + * `--hash STR` — release tag hash. Default: detected from OTP git. + """ + use Mix.Task + + alias MobDev.Release.{Errors, Tarball} + + @valid_targets ["android_arm64", "android_arm32", "ios_sim", "ios_device", "all"] + + @switches [ + otp_src: :string, + otp_release: :string, + openssl_prefix: :string, + exqlite_build: :string, + android_otp_release: :string, + asn1rt_nif_arm32: :string, + out_dir: :string, + hash: :string + ] + + @impl Mix.Task + def run(args) do + {opts, positional, _} = OptionParser.parse(args, strict: @switches) + + case positional do + [target_str] when target_str in @valid_targets -> + targets = + if target_str == "all", do: Tarball.targets(), else: [String.to_atom(target_str)] + + Enum.each(targets, &run_one(&1, opts)) + + [bad] -> + Mix.raise("unknown target: #{bad}\nvalid: #{Enum.join(@valid_targets, ", ")}") + + [] -> + Mix.raise("missing target argument\nusage: mix mob.release.tarball <target>") + + _ -> + Mix.raise("too many arguments — pass exactly one target") + end + end + + defp run_one(target_id, opts) do + Mix.shell().info("==> tarball #{target_id}") + + build_opts = Enum.reject(opts, fn {_k, v} -> is_nil(v) end) + + case Tarball.build(target_id, build_opts) do + {:ok, info} -> + Mix.shell().info(" ✓ #{info.tarball}") + + {:error, _} = err -> + Mix.raise(Errors.format(err)) + end + end +end diff --git a/lib/mix/tasks/mob.republish.ex b/lib/mix/tasks/mob.republish.ex new file mode 100644 index 0000000..4cb82de --- /dev/null +++ b/lib/mix/tasks/mob.republish.ex @@ -0,0 +1,236 @@ +defmodule Mix.Tasks.Mob.Republish do + use Mix.Task + + @shortdoc "Bump build, rebuild, and upload — one shot (--ios | --android)" + + @moduledoc """ + Convenience wrapper around the per-release flow. Bumps the platform's + build number, rebuilds the release artifact, and uploads to the store. + + mix mob.republish --ios # bump CFBundleVersion, mob.release, mob.publish --ios + mix mob.republish --ios --no-bump # skip the bump (Apple will reject same build #; mostly for testing) + mix mob.republish --android # bump versionCode, gradlew bundleRelease, mob.publish --android + mix mob.republish --android --track production # publish to a specific track + + Platform flag is **required** — Mob is intentionally platform-agnostic + and refuses to default to either side. + + ## What --ios does (under the hood) + + Three steps. Each is a standalone command if you'd rather run them + separately or need to troubleshoot one in isolation: + + 1. Bump `CFBundleVersion` in `ios/Info.plist` by 1: + + CURRENT=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" ios/Info.plist) + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $((CURRENT + 1))" ios/Info.plist + + Apple rejects re-uploads with the same `CFBundleVersion`. The bump + only touches `CFBundleVersion` (the integer build number); your + `CFBundleShortVersionString` (the public semver) stays put. + + 2. `mix mob.release` — builds `_build/mob_release/<App>.ipa`. See + `Mix.Tasks.Mob.Release` for what this produces. Bump-then-release + order matters: the build number is baked into the binary, not + inferred at upload time. + + 3. `mix mob.publish --ios` — uploads via `xcrun altool` with App + Store Connect API key auth. See `Mix.Tasks.Mob.Publish`. + + ## Failure handling + + If the bump itself fails (e.g. `CFBundleVersion` isn't an integer — + someone wrote `"1.0"` which actually belongs in `CFBundleShortVersionString`), + you get a clear error before anything else runs. + + If `mix mob.release` fails, `mix mob.publish` is not invoked. The + build-number bump is NOT rolled back — you'll see a gap in your + uploaded versions, which Apple is fine with (gaps are allowed; going + backward isn't). Re-run after fixing whatever broke the build. + + If `mix mob.publish` itself fails (network, Apple API error, etc.), + you may need to bump the version *again* before retrying — Apple + considers the build number "consumed" once they see it, even on + upload failure. Use `--no-bump` is rarely the right call; usually + the right move is just `mix mob.republish --ios` again. + """ + + @switches [ + ios: :boolean, + android: :boolean, + no_bump: :boolean, + verbose: :boolean, + track: :string + ] + + @impl Mix.Task + def run(argv) do + {opts, _, _} = OptionParser.parse(argv, strict: @switches) + + case pick_platform(opts) do + :ios -> republish_ios(opts) + :android -> republish_android(opts) + end + end + + defp pick_platform(opts) do + case {opts[:ios], opts[:android]} do + {true, true} -> + Mix.raise( + "Pass exactly one of --ios or --android, not both. " <> + "Republish targets one store at a time." + ) + + {true, _} -> + :ios + + {_, true} -> + :android + + _ -> + Mix.raise(""" + mix mob.republish requires --ios or --android. + + Mob is platform-agnostic by design — neither side is the default. + + mix mob.republish --ios + mix mob.republish --android + """) + end + end + + defp republish_android(opts) do + gradle = Path.expand("android/app/build.gradle") + + unless File.exists?(gradle) do + Mix.raise( + "android/app/build.gradle not found — run from the project root of a Mob Android app." + ) + end + + unless opts[:no_bump] do + {old, new} = bump_android_version_code!(gradle) + + Mix.shell().info( + "#{cyan()}Bumped versionCode: #{old} → #{new}#{reset()} " <> + "(Play rejects re-uploads of the same versionCode)" + ) + end + + Mix.Task.run("mob.release", ["--android"]) + Mix.Task.reenable("mob.release") + + publish_argv = ["--android"] ++ if(opts[:track], do: ["--track", opts[:track]], else: []) + Mix.Task.run("mob.publish", publish_argv) + Mix.Task.reenable("mob.publish") + end + + defp republish_ios(opts) do + plist = "ios/Info.plist" + + unless File.exists?(plist) do + Mix.raise("ios/Info.plist not found — run from the project root of a Mob iOS app.") + end + + unless opts[:no_bump] do + {old, new} = bump_ios_build_number!(plist) + + Mix.shell().info( + "#{cyan()}Bumped CFBundleVersion: #{old} → #{new}#{reset()} " <> + "(Apple rejects re-uploads of the same build number)" + ) + end + + Mix.Task.run("mob.release") + Mix.Task.reenable("mob.release") + + publish_argv = ["--ios"] ++ if(opts[:verbose], do: ["--verbose"], else: []) + Mix.Task.run("mob.publish", publish_argv) + Mix.Task.reenable("mob.publish") + end + + @doc """ + Read `versionCode` from the given `build.gradle`, integer-bump it, and + write back. Returns `{old, new}` strings. + + Raises with a clear message if no `versionCode` line is found. + """ + @spec bump_android_version_code!(Path.t()) :: {String.t(), String.t()} + def bump_android_version_code!(gradle_path) do + content = File.read!(gradle_path) + + # Regex.compile! used deliberately — ~r// bakes compiled patterns into the .beam, + # which breaks on OTP 28.0 (re.import/1 removed; fixed in 28.1). + find_re = Regex.compile!("\\bversionCode\\s+(\\d+)") + replace_re = Regex.compile!("\\bversionCode\\s+\\d+") + + case Regex.run(find_re, content, capture: :all_but_first) do + [current_str] -> + current = String.to_integer(current_str) + new_str = Integer.to_string(current + 1) + + updated = + String.replace(content, replace_re, "versionCode #{new_str}", global: false) + + File.write!(gradle_path, updated) + {current_str, new_str} + + nil -> + Mix.raise(""" + No versionCode found in #{gradle_path}. + + Expected a line like: + versionCode 1 + + inside the defaultConfig block. + """) + end + end + + @doc """ + Read `CFBundleVersion` from the given Info.plist, integer-bump it, and + write back. Returns `{old, new}` strings. + + Raises with a clear message if the current value isn't a clean integer + — typically that means someone put a semver-style version + (`"1.0.0"`) where Apple expects an integer build counter; the semver + belongs in `CFBundleShortVersionString` instead. + """ + @spec bump_ios_build_number!(String.t()) :: {String.t(), String.t()} + def bump_ios_build_number!(plist) do + {raw, 0} = + System.cmd("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleVersion", plist]) + + current = String.trim(raw) + + case Integer.parse(current) do + {n, ""} -> + new = Integer.to_string(n + 1) + + {_, 0} = + System.cmd("/usr/libexec/PlistBuddy", [ + "-c", + "Set :CFBundleVersion #{new}", + plist + ]) + + {current, new} + + _ -> + Mix.raise(""" + CFBundleVersion in #{plist} is "#{current}" — expected a bare integer. + + CFBundleVersion is the *build number* (an integer counter Apple + uses to distinguish uploads). The public semver "1.0.0" belongs + in CFBundleShortVersionString. Fix manually: + + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion 1" #{plist} + + Then re-run `mix mob.republish --ios`. + """) + end + end + + defp cyan, do: IO.ANSI.cyan() + defp reset, do: IO.ANSI.reset() +end diff --git a/lib/mix/tasks/mob.routes.ex b/lib/mix/tasks/mob.routes.ex index 72d81fa..ae069be 100644 --- a/lib/mix/tasks/mob.routes.ex +++ b/lib/mix/tasks/mob.routes.ex @@ -52,9 +52,9 @@ defmodule Mix.Tasks.Mob.Routes do Mix.Task.run("compile") - refs = collect_nav_refs() + refs = collect_nav_refs() {ok, bad} = Enum.split_with(refs, fn r -> r.valid end) - skipped = Enum.filter(refs, fn r -> r.skipped end) + skipped = Enum.filter(refs, fn r -> r.skipped end) total = length(refs) - length(skipped) IO.puts("") @@ -66,20 +66,31 @@ defmodule Mix.Tasks.Mob.Routes do end if bad == [] do - IO.puts("#{IO.ANSI.green()}✓ #{total} navigation reference(s) valid" <> - skipped_note(skipped) <> "#{IO.ANSI.reset()}") + IO.puts( + "#{IO.ANSI.green()}✓ #{total} navigation reference(s) valid" <> + skipped_note(skipped) <> "#{IO.ANSI.reset()}" + ) else - IO.puts("#{IO.ANSI.red()}✗ #{length(bad)} unresolvable navigation destination(s):#{IO.ANSI.reset()}\n") + IO.puts( + "#{IO.ANSI.red()}✗ #{length(bad)} unresolvable navigation destination(s):#{IO.ANSI.reset()}\n" + ) + Enum.each(bad, fn %{file: file, line: line, fn_name: fn_name, dest: dest} -> - IO.puts(" #{IO.ANSI.yellow()}#{file}:#{line}#{IO.ANSI.reset()} " <> - "#{fn_name}(socket, #{inspect(dest)})") + IO.puts( + " #{IO.ANSI.yellow()}#{file}:#{line}#{IO.ANSI.reset()} " <> + "#{fn_name}(socket, #{inspect(dest)})" + ) + IO.puts(" Module #{inspect(dest)} could not be loaded.") end) if skipped != [] do IO.puts("") - IO.puts(" #{IO.ANSI.cyan()}#{length(skipped)} dynamic/named destination(s) skipped " <> - "(cannot verify at compile time)#{IO.ANSI.reset()}") + + IO.puts( + " #{IO.ANSI.cyan()}#{length(skipped)} dynamic/named destination(s) skipped " <> + "(cannot verify at compile time)#{IO.ANSI.reset()}" + ) end end @@ -96,40 +107,47 @@ defmodule Mix.Tasks.Mob.Routes do case File.read(file) do {:ok, source} -> case Code.string_to_quoted(source, file: file, columns: false) do - {:ok, ast} -> extract_nav_calls(ast, file) + {:ok, ast} -> extract_nav_calls(ast, file) {:error, _} -> [] end - {:error, _} -> [] + + {:error, _} -> + [] end end) end defp extract_nav_calls(ast, file) do - {_, refs} = Macro.prewalk(ast, [], fn node, acc -> - case nav_call(node) do - {fn_name, meta, dest_ast} -> - dest = resolve_dest(dest_ast) - {valid, skipped} = classify(dest) - ref = %{ - file: file, - line: Keyword.get(meta, :line, 0), - fn_name: fn_name, - dest: dest, - valid: valid, - skipped: skipped - } - {node, [ref | acc]} - - nil -> - {node, acc} - end - end) + {_, refs} = + Macro.prewalk(ast, [], fn node, acc -> + case nav_call(node) do + {fn_name, meta, dest_ast} -> + dest = resolve_dest(dest_ast) + {valid, skipped} = classify(dest) + + ref = %{ + file: file, + line: Keyword.get(meta, :line, 0), + fn_name: fn_name, + dest: dest, + valid: valid, + skipped: skipped + } + + {node, [ref | acc]} + + nil -> + {node, acc} + end + end) refs end # Mob.Socket.push_screen / reset_to / pop_to with at least 2 args - defp nav_call({{:., meta, [{:__aliases__, _, [:Mob, :Socket]}, fn_name]}, _, [_socket, dest | _]}) + defp nav_call( + {{:., meta, [{:__aliases__, _, [:Mob, :Socket]}, fn_name]}, _, [_socket, dest | _]} + ) when fn_name in @nav_fns, do: {fn_name, meta, dest} @@ -143,27 +161,31 @@ defmodule Mix.Tasks.Mob.Routes do # ── Destination resolution ─────────────────────────────────────────────────── defp resolve_dest({:__aliases__, _, parts}), do: Module.concat(parts) - defp resolve_dest(atom) when is_atom(atom), do: atom - defp resolve_dest(_), do: :dynamic + defp resolve_dest(atom) when is_atom(atom), do: atom + defp resolve_dest(_), do: :dynamic + + # skip — dynamic value + defp classify(:dynamic), do: {true, true} - defp classify(:dynamic), do: {true, true} # skip — dynamic value defp classify(dest) when is_atom(dest) do # Plain lowercase atoms (e.g. :main) are registered names — runtime only. name = Atom.to_string(dest) - if String.match?(name, ~r/^[a-z]/) do - {true, true} # registered name atom — skip + + if String.match?(name, Regex.compile!("^[a-z]")) do + # registered name atom — skip + {true, true} else # Uppercase module atom — check if loadable case Code.ensure_loaded(dest) do {:module, _} -> {true, false} - _ -> {false, false} + _ -> {false, false} end end end # ── Helpers ────────────────────────────────────────────────────────────────── - defp skipped_note([]), do: "" + defp skipped_note([]), do: "" defp skipped_note(skipped), do: " (#{length(skipped)} dynamic/named skipped) " defp return(has_errors, strict) do diff --git a/lib/mix/tasks/mob.security_scan.ex b/lib/mix/tasks/mob.security_scan.ex new file mode 100644 index 0000000..df718bb --- /dev/null +++ b/lib/mix/tasks/mob.security_scan.ex @@ -0,0 +1,107 @@ +defmodule Mix.Tasks.Mob.SecurityScan do + @shortdoc "Comprehensive security scan of a Mob app's dependencies, bundled runtime, and source" + + @moduledoc """ + Audits the project for known vulnerabilities and unsafe code across + every surface a Mob app actually ships: + + * Hex dependency CVEs (`mix_audit`, `osv-scanner` over `mix.lock`) + * Android Gradle dependency CVEs (`osv-scanner`) + * iOS Swift Package dependency CVEs (`osv-scanner`) + * Bundled-runtime CVEs — OpenSSL/SQLite/OTP/Elixir baked into + Mob's pre-built OTP tarballs (manifest + fingerprint verification + + OpenSSL/SQLite/Erlef advisory feeds) + * C source static analysis (semgrep, flawfinder) + * Kotlin static analysis (detekt) + * Swift static analysis (`xcodebuild analyze`) + + Layers run sequentially. A missing external scanner is a soft warning, + not a failure — the layer reports `tool missing` and the rest of the + scan continues. + + ## Usage + + mix mob.security_scan # full scan, pretty terminal output + mix mob.security_scan --json # machine-readable JSON to stdout + mix mob.security_scan --skip hex,gradle # skip named layers + mix mob.security_scan --strict # exit 1 if any high+ finding + mix mob.security_scan --write-report PATH # also write a markdown report + + ## External tools + + Recommended one-time install on macOS: + + brew install osv-scanner semgrep flawfinder detekt + + Each layer prints which tool produced its findings so the report + is fully sourced. + + ## Why "security_scan" not "audit" + + `mix mob.audit_otp` already exists and does something else — it + reports which OTP libs your bundled app doesn't use so they can be + stripped to shrink the binary. That's a *binary-size* audit. This + task is the *security* counterpart, deliberately named differently. + """ + + use Mix.Task + + alias MobDev.SecurityScan + alias MobDev.SecurityScan.{Formatter, Report} + + @switches [ + json: :boolean, + strict: :boolean, + skip: :string, + write_report: :string, + project_root: :string + ] + + @impl Mix.Task + def run(args) do + {opts, _, _} = OptionParser.parse(args, strict: @switches) + + skip = parse_skip(opts[:skip]) + project_root = opts[:project_root] || File.cwd!() + + run_opts = [ + project_root: project_root, + skip: skip, + on_layer_start: &on_layer_start(&1, opts), + on_layer_done: &on_layer_done(&1, opts) + ] + + report = SecurityScan.run(run_opts) + + cond do + opts[:json] -> + IO.puts(Formatter.json(report)) + + true -> + IO.write(Formatter.terminal(report)) + end + + if path = opts[:write_report] do + File.write!(path, Formatter.markdown(report)) + Mix.shell().info("wrote markdown report to #{path}") + end + + Report.maybe_exit_strict(report, opts[:strict]) + end + + defp parse_skip(nil), do: [] + + defp parse_skip(value) do + value + |> String.split(",", trim: true) + |> Enum.map(&(&1 |> String.trim() |> String.to_atom())) + end + + defp on_layer_start(_name, opts) do + if opts[:json], do: :ok, else: :ok + end + + defp on_layer_done(_layer, opts) do + if opts[:json], do: :ok, else: :ok + end +end diff --git a/lib/mix/tasks/mob.security_scan.log.ex b/lib/mix/tasks/mob.security_scan.log.ex new file mode 100644 index 0000000..3635944 --- /dev/null +++ b/lib/mix/tasks/mob.security_scan.log.ex @@ -0,0 +1,116 @@ +defmodule Mix.Tasks.Mob.SecurityScan.Log do + @shortdoc "Run mix mob.security_scan and update SECURITY_SCAN.md / SECURITY_HISTORY.md" + + @moduledoc """ + Scheduled-run helper for the security scan. Designed to be invoked + by cron, GitHub Actions, or any other recurring trigger. + + Each run does four things: + + 1. Runs the full `mix mob.security_scan` against the project. + 2. **Overwrites** `SECURITY_SCAN.md` — a current-state snapshot + you can point at to answer "what's the situation right now?". + 3. **Prepends** a changelog entry to `SECURITY_HISTORY.md` — + newest at the top — describing what's New / Resolved / Still + present since the last logged run. + 4. Updates the JSON state sidecar at `.security_scan/state.json` + so the next run can compute its diff. + + Commit all three files. The state file is what makes the changelog + meaningful across machines and CI runs — without it every scheduled + run reports every finding as "new" and the history loses signal. + + ## Usage + + mix mob.security_scan.log # default paths + mix mob.security_scan.log --scan SECURITY.md \\ + --history HISTORY.md \\ + --state .scan/state.json + mix mob.security_scan.log --strict # exit 1 if any high+ finding + + ## Suggested cron entry + + # daily at 06:00 local + 0 6 * * * cd /path/to/project && mix mob.security_scan.log >> /tmp/security_scan.log 2>&1 + + ## Suggested GitHub Actions workflow + + name: security-scan + on: + schedule: [{cron: "0 6 * * *"}] + workflow_dispatch: + jobs: + scan: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: {elixir-version: "1.19", otp-version: "28"} + - run: brew install osv-scanner semgrep flawfinder detekt swiftlint + - run: mix deps.get + - run: mix mob.security_scan.log + - uses: peter-evans/create-pull-request@v6 + with: + title: "security: weekly scan update" + commit-message: "security: weekly scan update" + branch: security-scan-update + add-paths: SECURITY_SCAN.md SECURITY_HISTORY.md .security_scan/state.json + """ + + use Mix.Task + + alias MobDev.SecurityScan + alias MobDev.SecurityScan.{Diff, Formatter, HistoryFormatter, Report, StateFile} + + @switches [ + scan: :string, + history: :string, + state: :string, + project_root: :string, + strict: :boolean, + skip: :string + ] + + @impl Mix.Task + def run(args) do + {opts, _, _} = OptionParser.parse(args, strict: @switches) + + project_root = opts[:project_root] || File.cwd!() + scan_path = opts[:scan] || Path.join(project_root, "SECURITY_SCAN.md") + history_path = opts[:history] || Path.join(project_root, "SECURITY_HISTORY.md") + state_path = opts[:state] || Path.join(project_root, ".security_scan/state.json") + + skip = parse_skip(opts[:skip]) + now = DateTime.utc_now() + + Mix.shell().info("→ scanning #{project_root}") + report = SecurityScan.run(project_root: project_root, skip: skip) + + prev_state = StateFile.load(state_path) + diff = Diff.compute(prev_state, report, now) + + Mix.shell().info( + "→ diff: +#{length(diff.new)} new / -#{length(diff.resolved)} resolved / =#{length(diff.still_present)} still present" + ) + + File.write!(scan_path, Formatter.markdown(report)) + Mix.shell().info("→ wrote #{scan_path}") + + HistoryFormatter.prepend_to_file(history_path, HistoryFormatter.entry(report, diff, now)) + Mix.shell().info("→ updated #{history_path}") + + next_state = StateFile.from_report(report, diff, now) + StateFile.save(state_path, next_state) + Mix.shell().info("→ saved #{state_path}") + + Report.maybe_exit_strict(report, opts[:strict]) + end + + defp parse_skip(nil), do: [] + + defp parse_skip(value) do + value + |> String.split(",", trim: true) + |> Enum.map(&(&1 |> String.trim() |> String.to_atom())) + end +end diff --git a/lib/mix/tasks/mob.server.ex b/lib/mix/tasks/mob.server.ex index c193cb1..7224afa 100644 --- a/lib/mix/tasks/mob.server.ex +++ b/lib/mix/tasks/mob.server.ex @@ -57,24 +57,25 @@ defmodule Mix.Tasks.Mob.Server do {:ok, _} = Application.ensure_all_started(:phoenix_live_view) children = [ - {Phoenix.PubSub, name: MobDev.PubSub}, + {Phoenix.PubSub, name: MobDev.PubSub}, MobDev.Server.LogBuffer, MobDev.Server.ElixirLogBuffer, MobDev.Server.Endpoint, MobDev.Server.DevicePoller, MobDev.Server.LogStreamerSupervisor, MobDev.Server.WatchWorker, - {Task.Supervisor, name: MobDev.Server.TaskSupervisor} + {Task.Supervisor, name: MobDev.Server.TaskSupervisor} ] - {:ok, sup} = Supervisor.start_link(children, strategy: :one_for_one, name: MobDev.Server.Supervisor) + {:ok, sup} = + Supervisor.start_link(children, strategy: :one_for_one, name: MobDev.Server.Supervisor) # Attach the Elixir logger handler now that PubSub and the buffer are up MobDev.Server.ElixirLogger.attach() local_url = "http://localhost:#{port}" IO.puts("") - IO.puts("#{IO.ANSI.cyan()}=== Mob Dev Server ===#{ IO.ANSI.reset()}") + IO.puts("#{IO.ANSI.cyan()}=== Mob Dev Server ===#{IO.ANSI.reset()}") IO.puts(" #{IO.ANSI.green()}#{local_url}#{IO.ANSI.reset()}") if lan_ip do @@ -91,10 +92,17 @@ defmodule Mix.Tasks.Mob.Server do # Unlink the supervisor from this task process so it survives after run/1 returns. # Without this the supervisor exits when the Mix task process exits. Process.unlink(sup) - IO.puts(" #{IO.ANSI.green()}IEx ready.#{IO.ANSI.reset()} Elixir log output appears in the dashboard → Elixir panel.") + + IO.puts( + " #{IO.ANSI.green()}IEx ready.#{IO.ANSI.reset()} Elixir log output appears in the dashboard → Elixir panel." + ) + IO.puts("") else - IO.puts(" Tip: run #{IO.ANSI.cyan()}iex -S mix mob.server#{IO.ANSI.reset()} for an interactive terminal.") + IO.puts( + " Tip: run #{IO.ANSI.cyan()}iex -S mix mob.server#{IO.ANSI.reset()} for an interactive terminal." + ) + IO.puts(" Press Ctrl+C to stop.") IO.puts("") Process.sleep(:infinity) @@ -106,9 +114,9 @@ defmodule Mix.Tasks.Mob.Server do Application.put_env(:mob_dev, :dashboard_lan_url, lan_url) Application.put_env(:mob_dev, MobDev.Server.Endpoint, - adapter: Bandit.PhoenixAdapter, + adapter: Bandit.PhoenixAdapter, http: [ip: {0, 0, 0, 0}, port: port], - url: [host: "localhost", port: port], + url: [host: "localhost", port: port], server: true, live_view: [signing_salt: "mob_dev_server_salt"], secret_key_base: String.duplicate("mob_dev_secret_key_base_not_for_production_", 2) @@ -116,14 +124,16 @@ defmodule Mix.Tasks.Mob.Server do end defp open_browser(url) do - cmd = case :os.type() do - {:unix, :darwin} -> "open" - {:unix, _} -> "xdg-open" - {:win32, _} -> "start" - end + cmd = + case :os.type() do + {:unix, :darwin} -> "open" + {:unix, _} -> "xdg-open" + {:win32, _} -> "start" + end Task.start(fn -> - :timer.sleep(500) # brief pause so the server is up before the browser hits it + # brief pause so the server is up before the browser hits it + :timer.sleep(500) System.cmd(cmd, [url], stderr_to_stdout: true) end) end diff --git a/lib/mix/tasks/mob.setup.google_play.ex b/lib/mix/tasks/mob.setup.google_play.ex new file mode 100644 index 0000000..d016c76 --- /dev/null +++ b/lib/mix/tasks/mob.setup.google_play.ex @@ -0,0 +1,83 @@ +defmodule Mix.Tasks.Mob.Setup.GooglePlay do + use Mix.Task + + @shortdoc "Automate the Google Cloud setup steps for Play Store publishing" + + @moduledoc """ + Interactive wizard that automates the Google Cloud steps required before + `mix mob.publish --android` can work. + + ## Usage + + mix mob.setup.google_play + mix mob.setup.google_play --package com.example.myapp + + ## What it does + + Opens your browser for a one-time Google sign-in, then automatically: + + 1. Lists your Google Cloud projects (prompts you to pick one) + 2. Enables the Android Publisher API in that project + 3. Creates a `play-publisher` service account + 4. Generates a JSON key and saves it to `~/.google_play/` + 5. Attempts to grant Release Manager access via the Play Developer API + 6. Generates `android/upload_jks.keystore` if not present + 7. Prints the `mob.exs` config block to add + + The wizard also walks you through the four steps that genuinely cannot be + automated (account creation, identity verification, app record creation, + and linking the Cloud project to Play Console). + + ## Manual fallback + + If you prefer to complete any step manually, the wizard degrades gracefully + and prints exact instructions. See `guides/publishing_to_google_play.md` + for the full browser-based walkthrough. + + ## Options + + * `--package` — Android applicationId (e.g. `com.example.myapp`). If omitted, + the wizard will prompt for it. + * `--key-name` — Base filename for the saved JSON key (without `.json`). + Defaults to the last segment of the package name + `-service-account`. + * `--dry-run` — Print every step the wizard would take without making any + API calls, writing any files, or opening a browser. Use this to preview + the wizard before running it for real. + + ## OAuth client + + The wizard signs in using an OAuth2 Desktop App client bundled with mob_dev. + No external CLI tools (gcloud, etc.) are required. The sign-in is a standard + Google OAuth browser flow — you will see Google's consent screen. + + To use your own OAuth client instead, set: + + export GOOGLE_OAUTH_CLIENT_ID=your_client_id.apps.googleusercontent.com + export GOOGLE_OAUTH_CLIENT_SECRET=your_client_secret + """ + + @switches [package: :string, key_name: :string, dry_run: :boolean] + + @impl Mix.Task + def run(argv) do + {opts, _, _} = OptionParser.parse(argv, strict: @switches) + + wizard_opts = + [] + |> maybe_put(:package_name, opts[:package]) + |> maybe_put(:key_filename, opts[:key_name]) + |> maybe_put(:dry_run, opts[:dry_run]) + + case MobDev.GooglePlay.SetupWizard.run(wizard_opts) do + :ok -> + :ok + + {:error, reason} -> + Mix.shell().error(reason) + exit({:shutdown, 1}) + end + end + + defp maybe_put(kw, _key, nil), do: kw + defp maybe_put(kw, key, value), do: Keyword.put(kw, key, value) +end diff --git a/lib/mix/tasks/mob.snapshot_loaded.ex b/lib/mix/tasks/mob.snapshot_loaded.ex new file mode 100644 index 0000000..769ebba --- /dev/null +++ b/lib/mix/tasks/mob.snapshot_loaded.ex @@ -0,0 +1,176 @@ +defmodule Mix.Tasks.Mob.SnapshotLoaded do + @shortdoc "Snapshot what's loaded on a connected device — empirical strip candidates" + + @moduledoc """ + Asks a connected device's BEAM what modules it has loaded right now. + In interactive mode (Mob's default), a module is loaded only when + something calls into it — so the loaded set after a real user session + is the empirical "actually used" set. + + Anything **shipped but never loaded** is a strong strip candidate. + Combine with `mix mob.audit_otp` (static reachability) for the + highest-confidence strip set: + + shipped ∩ statically-reachable ∩ NEVER-loaded = safe to strip + + ## Workflow + + mix mob.deploy # deploy the app + # Use the app — every flow you care about + mix mob.connect --no-iex # set up the dist tunnel + mix mob.snapshot_loaded # snapshot + mix mob.snapshot_loaded --json out.json # machine-readable + + ## What the report shows + + * Total .beam files shipped + * Modules currently loaded + * Modules in the bundle that have never been loaded + * Per-OTP-lib breakdown (which libs have any module loaded vs not) + + ## What this is NOT + + Not a trace — it doesn't tell you *which* function was called or how + often. Just whether the module was touched. For per-function or + per-call-frequency data, use `mix mob.trace_otp` (host-side harness) + or extend `Mob.Diag` with `:recon_trace`-based on-device tracing. + """ + + use Mix.Task + + @impl Mix.Task + def run(args) do + {opts, _, _} = OptionParser.parse(args, strict: [node: :string, json: :string]) + + Mix.Task.run("loadpaths") + Mix.Task.run("compile") + + node = resolve_node(opts[:node]) + + case :rpc.call(node, Mob.Diag, :loaded_snapshot, []) do + {:badrpc, reason} -> + Mix.shell().error("RPC failed: #{inspect(reason)}") + + Mix.shell().error(""" + + Is the device's mob library new enough to have Mob.Diag.loaded_snapshot/0? + Push the latest BEAMs: mix mob.push + """) + + exit({:shutdown, 1}) + + snapshot -> + if opts[:json] do + File.write!(opts[:json], encode_json(snapshot)) + Mix.shell().info("Wrote snapshot to #{opts[:json]}") + else + print_summary(snapshot) + end + end + end + + defp resolve_node(nil) do + case Node.list() do + [] -> + Mix.raise(""" + No connected nodes found. Run `mix mob.connect --no-iex` first + in another terminal so the device's BEAM is reachable. + + Or pass --node <name@host> explicitly. + """) + + [single] -> + single + + multiple -> + Mix.raise(""" + Multiple nodes connected — specify with --node: + + #{Enum.map_join(multiple, "\n", &" #{&1}")} + """) + end + end + + defp resolve_node(node_str), do: String.to_atom(node_str) + + defp encode_json(snapshot) do + %{ + otp_root: snapshot.otp_root, + captured_at: DateTime.to_iso8601(snapshot.captured_at), + loaded_count: snapshot.loaded_count, + shipped_count: snapshot.shipped_count, + loaded: Enum.map(snapshot.loaded, &to_string/1), + unloaded_in_bundle: Enum.map(snapshot.unloaded_in_bundle, &to_string/1) + } + |> Jason.encode!(pretty: true) + end + + defp print_summary(s) do + h1 = IO.ANSI.bright() + yellow = IO.ANSI.yellow() + green = IO.ANSI.green() + reset = IO.ANSI.reset() + + Mix.shell().info("#{h1}=== Loaded-modules snapshot ==={reset}") + Mix.shell().info(" OTP root: #{s.otp_root || "(not detected)"}") + Mix.shell().info(" Captured at: #{DateTime.to_iso8601(s.captured_at)}") + Mix.shell().info(" Shipped: #{s.shipped_count} .beam files") + Mix.shell().info(" Loaded: #{s.loaded_count} modules") + + Mix.shell().info( + " Strip candidates (shipped, never loaded): #{length(s.unloaded_in_bundle)}\n" + ) + + by_lib = group_by_lib(s.unloaded_in_bundle) + + if by_lib == %{} do + Mix.shell().info( + " #{green}Every shipped module has been loaded — no strip candidates from this snapshot.#{reset}" + ) + else + Mix.shell().info("#{h1}Strip candidates by lib (top 20):#{reset}") + + by_lib + |> Enum.sort_by(fn {_lib, mods} -> -length(mods) end) + |> Enum.take(20) + |> Enum.each(fn {lib, mods} -> + Mix.shell().info( + " #{yellow}#{String.pad_trailing(to_string(lib), 25)}#{reset} " <> + "#{length(mods)} unused module(s)" + ) + end) + end + + Mix.shell().info(""" + + Cross-reference with `mix mob.audit_otp` for the highest-confidence + strip set: shipped + statically-reachable + NEVER loaded. + """) + end + + # Heuristic lib grouping from module name patterns. Not perfect, but + # good enough for a summary view. + defp group_by_lib(modules) do + Enum.group_by(modules, fn mod -> + mod + |> to_string() + |> guess_lib() + end) + end + + defp guess_lib(name) do + cond do + String.starts_with?(name, "Elixir.") -> + # "Elixir.Logger.Foo" → "elixir/logger" + rest = String.replace_prefix(name, "Elixir.", "") + rest |> String.split(".") |> List.first() |> Macro.underscore() + + true -> + # "logger_h_common" → "logger" by stripping after first underscore + case String.split(name, "_", parts: 2) do + [head | _] -> head + _ -> name + end + end + end +end diff --git a/lib/mix/tasks/mob.styles.ex b/lib/mix/tasks/mob.styles.ex new file mode 100644 index 0000000..7160c12 --- /dev/null +++ b/lib/mix/tasks/mob.styles.ex @@ -0,0 +1,55 @@ +defmodule Mix.Tasks.Mob.Styles do + @shortdoc "List the project's style packages and the active default" + + @moduledoc """ + Lists the activated style packages (MOB_STYLES.md, tokens-only tier) with + their themes and which one `config :mob, :default_style` selects. + + mix mob.styles + """ + + use Mix.Task + + @impl Mix.Task + def run(_args) do + Mix.Task.run("app.config") + + styles = MobDev.Style.activated() + default = MobDev.Style.default_style() + + if styles == [] do + Mix.shell().info(""" + No style packages activated. The app renders mob's neutral baseline + (or its own `use Mob.App, theme:`). Activate one in mob.exs: + + config :mob, :styles, [:mob_themes] + config :mob, :default_style, :mob_themes + """) + else + Mix.shell().info("\n STYLE THEME DEFAULT") + Mix.shell().info(" " <> String.duplicate("─", 60)) + + for {dir, manifest} <- styles do + case MobDev.Style.validate(manifest) do + {:ok, m} -> + marker = if m.name == default, do: " ← default", else: "" + + Mix.shell().info( + " #{String.pad_trailing(to_string(m.name), 18)}#{String.pad_trailing(inspect(m.theme), 28)}#{marker}" + ) + + {:error, errs} -> + Mix.shell().error(" #{Path.basename(dir)}: INVALID — #{Enum.join(errs, "; ")}") + end + end + + if default != nil and not Enum.any?(styles, fn {_d, m} -> m[:name] == default end) do + Mix.shell().error( + "\n ✗ :default_style #{inspect(default)} is not among the activated styles (build will fail)" + ) + end + + Mix.shell().info("") + end + end +end diff --git a/lib/mix/tasks/mob.trace_otp.ex b/lib/mix/tasks/mob.trace_otp.ex new file mode 100644 index 0000000..9627855 --- /dev/null +++ b/lib/mix/tasks/mob.trace_otp.ex @@ -0,0 +1,251 @@ +defmodule Mix.Tasks.Mob.TraceOtp do + @shortdoc "Run the Elixir characterization harness under trace, dump touched MFAs" + + @moduledoc """ + Runs `MobDev.OtpTrace.Harness` under full call tracing and reports + the runtime modules + MFAs actually exercised. + + This is the empirical complement to `mix mob.audit_otp` (which uses + static reachability). Combined, they give a high-confidence answer + to "what does any Elixir runtime actually need." + + ## Usage + + mix mob.trace_otp # Run all phases, summary report + mix mob.trace_otp --phase otp # Just one phase + mix mob.trace_otp --json out.json # Machine-readable dump + + # Trace a real running app on a connected device for 30s. + # First run `mix mob.connect` in another terminal. + mix mob.trace_otp --remote pigeon_ios_defd4bdc@127.0.0.1 --duration 30000 + + Local mode (default) runs a synthetic Elixir/OTP characterization + harness inside the host BEAM. Remote mode wraps `:erlang.trace_pattern` + on a connected device node and captures the MFAs hit during a real + user session — drive the app interactively while the trace runs. + + ## Phases + + language — pattern match, comprehensions, structs, protocols + collections — Enum, List, Map, MapSet, Stream, Range + strings — String, Binary, charlist, codepoints + processes — spawn, send/receive, monitor, link, Task + otp — GenServer, Supervisor, Application, Logger + data — ETS, persistent_term, Date/Time, System + errors — raise/rescue, throw/catch, exit, exception structs + all — every phase (default) + + ## Output + + Plain mode prints a summary + sorted module list. `--json` writes a + JSON file with the full MFA set, suitable for cross-referencing + against the static audit (`mix mob.audit_otp`) to find modules + shipped-but-never-called. + """ + + use Mix.Task + + alias MobDev.OtpTrace + alias MobDev.OtpTrace.Harness + + @phases ~w(language collections strings processes otp data errors all)a + + @impl Mix.Task + def run(args) do + {opts, _, _} = + OptionParser.parse(args, + strict: [phase: :string, json: :string, remote: :string, duration: :integer] + ) + + Mix.Task.run("loadpaths") + Mix.Task.run("compile") + + if opts[:remote] do + run_remote(opts) + else + run_local(opts) + end + end + + # ── Local synthetic-harness trace ──────────────────────────────────────────── + + defp run_local(opts) do + phase = parse_phase(opts[:phase] || "all") + + Code.ensure_all_loaded([ + Harness, + Harness.HarnessStruct, + Harness.HarnessProto, + Harness.HarnessGS, + Harness.HarnessApp + ]) + + Mix.shell().info("Running Elixir characterization harness under trace…") + Mix.shell().info(" Phase: #{phase}\n") + + result = OtpTrace.capture(fn -> apply(Harness, phase, []) end) + + if opts[:json] do + write_json(result, opts[:json]) + Mix.shell().info("Wrote #{opts[:json]}") + else + print_summary(result, phase) + end + end + + # ── Remote (real-app) trace ────────────────────────────────────────────────── + + defp run_remote(opts) do + node_str = opts[:remote] + duration = opts[:duration] || 30_000 + node = String.to_atom(node_str) + + ensure_distribution_started!() + + Mix.shell().info("Tracing #{node_str} for #{duration / 1000}s…") + Mix.shell().info(" Drive the app on the device while the window is open.\n") + + case :rpc.call(node, Mob.Diag, :mfa_trace, [duration]) do + {:badrpc, reason} -> + Mix.shell().error("RPC failed: #{inspect(reason)}") + + Mix.shell().error(""" + + Is the device connected? Try `mix mob.connect` in another + terminal first, then re-run this command. + """) + + exit({:shutdown, 1}) + + result -> + if opts[:json] do + write_remote_json(result, opts[:json]) + Mix.shell().info("Wrote #{opts[:json]}") + else + print_remote_summary(result, node) + end + end + end + + defp print_remote_summary(result, node) do + h1 = IO.ANSI.bright() + dim = IO.ANSI.faint() + reset = IO.ANSI.reset() + + Mix.shell().info("#{h1}=== Remote trace summary ==={reset}") + Mix.shell().info(" Node: #{node}") + Mix.shell().info(" Duration: #{result.duration_ms / 1000}s") + Mix.shell().info(" Modules touched: #{result.module_count}") + Mix.shell().info(" Unique MFAs: #{result.mfa_count}") + + Mix.shell().info("\n#{h1}=== Modules touched (sorted) ==={reset}\n") + + {elixir, erlang} = + result.modules + |> Enum.split_with(&String.starts_with?(to_string(&1), "Elixir.")) + + Mix.shell().info(" #{dim}Elixir modules (#{length(elixir)}):#{reset}") + for m <- elixir, do: Mix.shell().info(" #{inspect(m)}") + Mix.shell().info("\n #{dim}Erlang modules (#{length(erlang)}):#{reset}") + for m <- erlang, do: Mix.shell().info(" #{inspect(m)}") + Mix.shell().info("") + end + + # mix doesn't start distribution by default. Without it, :rpc.call + # returns {:badrpc, :nodedown} even though the target node is alive + # and registered in EPMD. Self-name into a unique node so multiple + # invocations don't collide. + defp ensure_distribution_started! do + if Node.alive?() do + :ok + else + name = :"mob_trace_otp_#{System.unique_integer([:positive])}@127.0.0.1" + + case Node.start(name, :longnames) do + {:ok, _} -> + :ok + + {:error, reason} -> + Mix.raise("Failed to start distribution: #{inspect(reason)}") + end + end + + Node.set_cookie(:mob_secret) + end + + defp write_remote_json(result, path) do + payload = %{ + mfas: + result.mfas + |> Enum.map(fn {m, f, a} -> [to_string(m), to_string(f), a] end), + modules: Enum.map(result.modules, &to_string/1), + mfa_count: result.mfa_count, + module_count: result.module_count, + duration_ms: result.duration_ms, + captured_at: DateTime.to_iso8601(result.captured_at) + } + + File.write!(path, Jason.encode!(payload, pretty: true)) + end + + defp parse_phase(name) do + atom = String.to_existing_atom(name) + + if atom in @phases do + atom + else + Mix.raise("Unknown phase: #{name}. Valid: #{Enum.join(@phases, ", ")}") + end + rescue + ArgumentError -> + Mix.raise("Unknown phase: #{name}. Valid: #{Enum.join(@phases, ", ")}") + end + + defp print_summary(result, phase) do + h1 = IO.ANSI.bright() + dim = IO.ANSI.faint() + reset = IO.ANSI.reset() + + Mix.shell().info("#{h1}=== Trace summary ==={reset}") + Mix.shell().info(" Phase exercised: #{phase}") + Mix.shell().info(" Modules touched: #{MapSet.size(result.modules)}") + Mix.shell().info(" Unique MFAs: #{MapSet.size(result.mfas)}") + Mix.shell().info(" Wall time: #{MobDev.Duration.format_us(result.elapsed_us)}") + + Mix.shell().info("\n#{h1}=== Modules touched (sorted) ==={reset}") + + {elixir, erlang} = + result.modules + |> Enum.sort() + |> Enum.split_with(fn m -> String.starts_with?(to_string(m), "Elixir.") end) + + Mix.shell().info("\n #{dim}Elixir modules (#{length(elixir)}):#{reset}") + + for m <- elixir do + Mix.shell().info(" #{inspect(m)}") + end + + Mix.shell().info("\n #{dim}Erlang modules (#{length(erlang)}):#{reset}") + + for m <- erlang do + Mix.shell().info(" #{inspect(m)}") + end + + Mix.shell().info("") + end + + defp write_json(result, path) do + payload = %{ + modules: result.modules |> Enum.sort() |> Enum.map(&to_string/1), + mfas: + result.mfas + |> Enum.sort() + |> Enum.map(fn {m, f, a} -> [to_string(m), to_string(f), a] end), + elapsed_us: result.elapsed_us, + module_count: MapSet.size(result.modules), + mfa_count: MapSet.size(result.mfas) + } + + File.write!(path, Jason.encode!(payload, pretty: true)) + end +end diff --git a/lib/mix/tasks/mob.uninstall.ex b/lib/mix/tasks/mob.uninstall.ex new file mode 100644 index 0000000..d2839e7 --- /dev/null +++ b/lib/mix/tasks/mob.uninstall.ex @@ -0,0 +1,276 @@ +defmodule Mix.Tasks.Mob.Uninstall do + @shortdoc "Uninstall a Mob app (or every Mob app) from connected devices" + + @moduledoc """ + Uninstall a Mob app from one or more connected devices. + + By default, uninstalls the **current project's** app from the + auto-detected device (when exactly one device is connected). For + other scopes, pass the relevant flag. + + ## Usage + + mix mob.uninstall # this app, one device + mix mob.uninstall --all-devices # this app, all emulators/sims (NOT phones) + mix mob.uninstall --all-physical # this app, every physical device + mix mob.uninstall --all-devices --all-physical # literally everything + mix mob.uninstall --device emulator-5554 # this app, one named device + mix mob.uninstall --device foo --device bar # this app, several + mix mob.uninstall --all-apps # one device, every mob app + mix mob.uninstall --all-devices --all-apps # every mob app on every emulator/sim + mix mob.uninstall --bundle-id com.x.y # override the project's bundle_id + mix mob.uninstall --bundle-prefix com.acme # override the prefix for --all-apps + mix mob.uninstall --yes # skip the confirmation prompt + mix mob.uninstall --help # this help + mix mob.uninstall -h # this help + + ## Options + + * `--device <id>` — Target a specific device by serial or name. + Repeat for multiple devices: `--device a --device b`. Works + for any device type — `--device` is the explicit override + that bypasses the emulator/physical filter. + * `--all-devices` — Target every connected **emulator or + simulator**. Physical devices are NEVER swept by this flag — + that requires `--all-physical` or `--device <id>` explicitly. + Rationale: emulators are disposable; physical devices are + someone's personal phone, with potential to do real damage. + * `--all-physical` — Target every connected physical device + (iPhones over USB/Wi-Fi, Android devices via adb). Opt-in. + Composes with `--all-devices` to mean "literally everything." + * `--bundle-id <id>` — Uninstall this specific bundle id instead of + auto-detecting the project's. + * `--all-apps` — Match every installed package whose id starts with + `bundle_prefix` (defaults to `MOB_BUNDLE_PREFIX` env or + `com.example`). Useful for clearing stale test apps in one shot. + * `--bundle-prefix <prefix>` — Override the prefix used by + `--all-apps`. Defaults to `MobDev.Config.bundle_prefix/0`. + * `--yes` — Skip the y/N confirmation prompt. The prompt is skipped + automatically when targeting exactly one app on exactly one + device (low blast radius). + * `--help` / `-h` — Print this help text. + + ## Default scope behaviour + + When you pass no flags: + + * 0 devices connected → exit with "no devices found" + * 1 emulator/sim connected → auto-target it (physical devices + are never the auto-target — they require explicit selection) + * >1 devices or only physical devices → exit with an + instruction to pass `--device`, `--all-devices`, or + `--all-physical`. Never silently fans out without consent. + + ## Per-platform mechanics + + * **Android** — `adb -s <serial> uninstall <pkg>`. "Unknown + package" responses bucket as skipped (the app wasn't installed), + everything else non-zero is a failure. + * **iOS simulator** — `xcrun simctl uninstall <udid> <bundle>`, + followed by a probe through `simctl listapps` to distinguish + actual uninstall from "wasn't installed in the first place". + * **iOS physical device** — `xcrun devicectl device uninstall app + --device <udid> <bundle>`. `ContainerLookupErrorDomain` in + output → not installed (skipped). Requires the device to be + paired and trusted via Xcode. + """ + use Mix.Task + + alias MobDev.{TaskHelp, Uninstaller} + + @switches [ + device: [:string, :keep], + all_devices: :boolean, + all_physical: :boolean, + bundle_id: :string, + all_apps: :boolean, + bundle_prefix: :string, + yes: :boolean, + help: :boolean + ] + + @aliases [h: :help] + + @impl Mix.Task + def run(args) do + if TaskHelp.help_requested?(args) do + TaskHelp.print_module_help(__MODULE__) + else + do_run(args) + end + end + + defp do_run(args) do + {opts, _positional, _} = OptionParser.parse(args, strict: @switches, aliases: @aliases) + device_ids = Keyword.get_values(opts, :device) + + project_bundle_id = + case Mix.Project.get() do + nil -> nil + _ -> MobDev.Config.bundle_id() + end + + uninstaller_opts = [ + device_ids: device_ids, + all_devices: Keyword.get(opts, :all_devices, false), + all_physical: Keyword.get(opts, :all_physical, false), + bundle_id: opts[:bundle_id], + all_apps: Keyword.get(opts, :all_apps, false), + bundle_prefix: opts[:bundle_prefix], + project_bundle_id: project_bundle_id + ] + + case Uninstaller.plan(uninstaller_opts) do + {:ok, plan} -> + confirm_and_run(plan, opts) + + {:error, :no_devices, _} -> + Mix.shell().error( + "No devices found. Run `mix mob.devices` to diagnose, " <> + "or connect a device / boot a simulator." + ) + + exit({:shutdown, 1}) + + {:error, :ambiguous_devices, ctx} -> + Mix.shell().error( + "Multiple devices connected (#{ctx.detected} total, " <> + "#{ctx.non_physical} emulator/sim, #{ctx.physical} physical) — " <> + "pass --device <id> (repeatable), --all-devices to sweep " <> + "emulators/sims, or --all-physical to sweep phones." + ) + + exit({:shutdown, 1}) + + {:error, :no_matching_devices, %{requested: ids}} -> + Mix.shell().error( + "No matching devices for --device #{inspect(ids)}. " <> + "Run `mix mob.devices` to see available IDs." + ) + + exit({:shutdown, 1}) + + {:error, :no_dev_devices, %{hint: hint}} -> + # --all-devices wanted emulators/sims; user only has physical + # devices connected. Print the rich hint from Uninstaller so + # the user knows the safe-by-default behaviour. + Mix.shell().error(hint) + exit({:shutdown, 1}) + + {:error, :no_physical_devices, _} -> + Mix.shell().error( + "--all-physical was passed, but no physical devices are connected. " <> + "Plug in / pair an iPhone or Android device, or pass --all-devices " <> + "to target emulators/sims instead." + ) + + exit({:shutdown, 1}) + end + end + + defp confirm_and_run(plan, opts) do + Enum.each(Uninstaller.preview_lines(plan), fn line -> Mix.shell().info(line) end) + bundle_count = bundle_count(plan) + + if bundle_count == 0 do + Mix.shell().info("(no apps to uninstall — nothing to do)") + else + if should_skip_prompt?(plan, opts) or confirm_yn() do + {uninstalled, failed, skipped} = Uninstaller.execute_plan(plan) + + Enum.each(format_summary(uninstalled, failed, skipped), fn line -> + Mix.shell().info(line) + end) + + if failed != [], do: exit({:shutdown, 1}) + else + Mix.shell().info("Aborted.") + end + end + end + + @doc """ + Decides whether to skip the y/N confirmation prompt before executing + a `plan`. Returns `true` when: + + * the user passed `--yes` (any truthy value at `opts[:yes]`), OR + * the plan targets exactly one device with exactly one bundle id + (low blast radius — destructive intent is unambiguous and the + preview already showed exactly what's about to happen). + + Pure for testability — the original inline `opts[:yes] or + single_target?` form crashed on `BadBooleanError` because + `opts[:yes]` is `nil` when the flag isn't passed and Elixir 1.20's + type checker enforces boolean operands on `or`. Pinning this as a + helper means the boolean-coercion mistake can't happen again. + """ + @spec should_skip_prompt?(Uninstaller.plan(), keyword()) :: boolean() + def should_skip_prompt?(plan, opts) do + bundle_count = bundle_count(plan) + device_count = length(plan) + single_target? = device_count == 1 and bundle_count == 1 + yes? = opts[:yes] == true + + yes? or single_target? + end + + defp bundle_count(plan), do: plan |> Enum.flat_map(fn {_d, bs} -> bs end) |> length() + + defp confirm_yn do + case Mix.shell().prompt("Proceed? [y/N]") |> String.trim() |> String.downcase() do + "y" -> true + "yes" -> true + _ -> false + end + end + + @doc """ + Build the summary lines for an uninstall run. Pure — pinned by tests + in `test/mix/tasks/mob_uninstall_test.exs`. Public so the rendering + invariants (skipped never gets counted as failed, etc.) can be + asserted independent of connected hardware. + """ + @spec format_summary([Uninstaller.result()], [Uninstaller.result()], [Uninstaller.result()]) :: + [String.t()] + def format_summary(uninstalled, failed, skipped) do + cond do + uninstalled == [] and failed == [] and skipped == [] -> + ["No-op — nothing to uninstall."] + + true -> + [] + |> append_block(uninstalled, "Uninstalled", :green) + |> append_block(skipped, "Skipped (not installed)", :yellow) + |> append_block(failed, "Failed", :red) + end + end + + defp append_block(acc, [], _label, _color), do: acc + + defp append_block(acc, results, label, color) do + ansi_color = + case color do + :green -> IO.ANSI.green() + :yellow -> IO.ANSI.yellow() + :red -> IO.ANSI.red() + end + + marker = + case color do + :green -> "✓" + :yellow -> "—" + :red -> "✗" + end + + header = "\n#{ansi_color}#{label}: #{length(results)}#{IO.ANSI.reset()}" + + rows = + Enum.map(results, fn r -> + device_label = r.device.name || r.device.serial + suffix = if r.reason, do: " (#{r.reason})", else: "" + " #{marker} #{device_label}: #{r.bundle_id}#{suffix}" + end) + + acc ++ [header | rows] + end +end diff --git a/lib/mix/tasks/mob.validate_plugin.ex b/lib/mix/tasks/mob.validate_plugin.ex new file mode 100644 index 0000000..5bf5226 --- /dev/null +++ b/lib/mix/tasks/mob.validate_plugin.ex @@ -0,0 +1,66 @@ +defmodule Mix.Tasks.Mob.ValidatePlugin do + use Mix.Task + + @shortdoc "Validate this project's mob plugin manifest (priv/mob_plugin.exs)" + + @moduledoc """ + Validates the `priv/mob_plugin.exs` manifest of the plugin project in the + current directory — a plugin author's pre-publish check. + + mix mob.validate_plugin + + Checks (see `MOB_PLUGINS.md`): required top-level fields, every declared file + path exists, and the installed `:mob` satisfies the manifest's `mob_version`. + Advisory warnings cover single-platform components and declared + permissions/plist keys. Exits non-zero if any error is found — never silent. + + A project with no `priv/mob_plugin.exs` is a tier-0 plugin (nothing to + validate) and the task says so. + """ + + alias MobDev.Plugin.{Manifest, Validator} + + @impl Mix.Task + def run(_args) do + Mix.Task.run("loadpaths") + dir = File.cwd!() + + case Manifest.load(dir) do + {:ok, nil} -> + Mix.shell().info( + "No priv/mob_plugin.exs — this is a tier-0 plugin (no manifest to validate)." + ) + + {:ok, manifest} -> + manifest + |> Validator.validate_plugin(dir, installed_mob_version()) + |> report() + + {:error, reason} -> + Mix.raise("could not read priv/mob_plugin.exs: #{reason}") + end + end + + defp installed_mob_version do + _ = Application.load(:mob) + + case Application.spec(:mob, :vsn) do + nil -> nil + vsn -> to_string(vsn) + end + end + + defp report(%{errors: errors, warnings: warnings}) do + Enum.each(warnings, &Mix.shell().info([:yellow, "⚠ ", &1, :reset])) + + case errors do + [] -> + suffix = if warnings == [], do: "", else: " (#{length(warnings)} warning(s))" + Mix.shell().info([:green, "✓ manifest valid#{suffix}", :reset]) + + _ -> + Enum.each(errors, &Mix.shell().error("✗ #{&1}")) + Mix.raise("plugin manifest validation failed with #{length(errors)} error(s)") + end + end +end diff --git a/lib/mix/tasks/mob.verify_strip.ex b/lib/mix/tasks/mob.verify_strip.ex new file mode 100644 index 0000000..f7d9a71 --- /dev/null +++ b/lib/mix/tasks/mob.verify_strip.ex @@ -0,0 +1,135 @@ +defmodule Mix.Tasks.Mob.VerifyStrip do + @shortdoc "Eager-load every shipped .beam on a connected device, report failures" + + @moduledoc """ + Boot-safety verification for stripped Mob app builds. + + After deploying a slim build (where `mix mob.release` has dropped OTP + libs to shrink the IPA), this task connects to the running app's BEAM + and asks it to force-load every `.beam` shipped in the bundle. Any + module that fails to load — typically because a stripped lib was + needed by a transitive dep — shows up here. + + ## Usage + + # First connect to the device (sets up tunnels + verifies node): + mix mob.connect --no-iex + + # Then run the verifier: + mix mob.verify_strip + + Or pass `--node` explicitly to skip auto-discovery. + + ## What it checks + + 1. **Eager load sweep** — `Code.ensure_loaded/1` on every `.beam` + under `<files_dir>/otp/`. Catches "module X depends on stripped + module Y" at load time. + + 2. **Harness exercise** — runs `MobDev.OtpTrace.Harness.all/0` on + the device and reports any exception. Catches issues with the + common Elixir surface even if individual modules loaded fine. + + ## What it does NOT check + + App-specific code paths. If your app calls `:public_key.something` + when the user opens a particular screen, this verifier won't find it + unless the screen is opened. Run your own integration tests for that. + + ## Output + + Plain mode prints a summary + per-failure detail. Exit code is 0 if + everything loaded + harness passed, 1 otherwise. + """ + + use Mix.Task + + @impl Mix.Task + def run(args) do + {opts, _, _} = OptionParser.parse(args, strict: [node: :string]) + + Mix.Task.run("loadpaths") + Mix.Task.run("compile") + + node = resolve_node(opts[:node]) + + Mix.shell().info("Verifying strip safety on #{node}…\n") + + case :rpc.call(node, Mob.Diag, :verify_loaded_modules, []) do + {:badrpc, reason} -> + Mix.shell().error("RPC failed: #{inspect(reason)}") + + Mix.shell().error(""" + + Is `Mob.Diag` deployed to the device? It ships with the `mob` + runtime library — push the latest BEAMs first: + + mix mob.push + + And confirm the device's mob version is recent enough. + """) + + exit({:shutdown, 1}) + + report -> + print_load_report(report) + if report.failed != [], do: exit({:shutdown, 1}) + end + end + + defp resolve_node(nil) do + case Node.list() do + [] -> + Mix.raise(""" + No connected nodes found. Run `mix mob.connect --no-iex` first + in a separate terminal so the device's BEAM is reachable. + + Or pass --node <name@host> explicitly. + """) + + [single] -> + single + + multiple -> + Mix.raise(""" + Multiple nodes connected — specify with --node: + + #{Enum.map_join(multiple, "\n", &" #{&1}")} + """) + end + end + + defp resolve_node(node_str), do: String.to_atom(node_str) + + defp print_load_report(report) do + h1 = IO.ANSI.bright() + green = IO.ANSI.green() + red = IO.ANSI.red() + reset = IO.ANSI.reset() + + Mix.shell().info("#{h1}=== Eager-load sweep ==={reset}") + Mix.shell().info(" OTP root: #{report.otp_root || "(not detected)"}") + Mix.shell().info(" Total .beam files: #{report.total}") + Mix.shell().info(" #{green}Loaded: #{report.loaded}#{reset}") + + case report.failed do + [] -> + Mix.shell().info( + " #{green}✓ all modules loaded — strip is safe at the load-time level#{reset}" + ) + + failures -> + Mix.shell().error(" #{red}✗ #{length(failures)} module(s) failed to load:#{reset}") + + for %{module: mod, reason: reason} <- Enum.take(failures, 20) do + Mix.shell().error(" #{inspect(mod)} — #{inspect(reason)}") + end + + if length(failures) > 20 do + Mix.shell().error(" … and #{length(failures) - 20} more") + end + end + + Mix.shell().info(" Elapsed: #{MobDev.Duration.format_us(report.elapsed_us)}") + end +end diff --git a/lib/mix/tasks/mob.watch.ex b/lib/mix/tasks/mob.watch.ex index a22686c..255d018 100644 --- a/lib/mix/tasks/mob.watch.ex +++ b/lib/mix/tasks/mob.watch.ex @@ -52,32 +52,37 @@ defmodule Mix.Tasks.Mob.Watch do @impl Mix.Task def run(args) do - {opts, _, _} = OptionParser.parse(args, - switches: [cookie: :string, debounce: :integer, interval: :integer], - aliases: [c: :cookie] - ) + {opts, _, _} = + OptionParser.parse(args, + switches: [cookie: :string, debounce: :integer, interval: :integer], + aliases: [c: :cookie] + ) - cookie = opts |> Keyword.get(:cookie, "mob_secret") |> String.to_atom() - debounce = Keyword.get(opts, :debounce, 300) - interval = Keyword.get(opts, :interval, 500) + cookie = opts |> Keyword.get(:cookie, "mob_secret") |> String.to_atom() + debounce = Keyword.get(opts, :debounce, 300) + interval = Keyword.get(opts, :interval, 500) File.mkdir_p!("_build") File.write!(@pid_file, to_string(:os.getpid())) IO.puts("") - IO.puts("#{IO.ANSI.cyan()}mob.watch#{IO.ANSI.reset()} — watching lib/ for changes (Ctrl-C to stop)\n") + + IO.puts( + "#{IO.ANSI.cyan()}mob.watch#{IO.ANSI.reset()} — watching lib/ for changes (Ctrl-C to stop)\n" + ) nodes = connect_with_retry(cookie) # Initial compile + push everything so device is in sync. recompile() {pushed, _} = MobDev.HotPush.push_all(nodes) + if pushed > 0 do IO.puts(" #{IO.ANSI.green()}✓ initial push: #{pushed} module(s)#{IO.ANSI.reset()}") end # Snapshot source mtimes. - sources = snapshot_sources() + sources = MobDev.SourceWatch.snapshot() IO.puts(" Watching #{map_size(sources)} source file(s)...\n") watch_loop(sources, nodes, cookie, debounce, interval) @@ -88,20 +93,21 @@ defmodule Mix.Tasks.Mob.Watch do defp watch_loop(sources, nodes, cookie, debounce, interval) do :timer.sleep(interval) - current = snapshot_sources() - changed_files = changed_sources(sources, current) + current = MobDev.SourceWatch.snapshot() + changed_files = MobDev.SourceWatch.diff(sources, current) if changed_files == [] do watch_loop(current, nodes, cookie, debounce, interval) else IO.puts("#{IO.ANSI.cyan()}◉ #{length(changed_files)} file(s) changed#{IO.ANSI.reset()}") + Enum.each(changed_files, fn f -> IO.puts(" #{Path.relative_to_cwd(f)}") end) # Debounce — wait in case more saves are incoming (e.g. format-on-save). :timer.sleep(debounce) - current2 = snapshot_sources() + current2 = MobDev.SourceWatch.snapshot() # Re-connect if any nodes dropped (device rebooted, app restarted, etc.) live_nodes = reconnect_if_needed(nodes, cookie) @@ -114,12 +120,16 @@ defmodule Mix.Tasks.Mob.Watch do pushed > 0 -> node_str = Enum.map_join(live_nodes, " ", &short_node/1) IO.puts(" #{IO.ANSI.green()}✓ #{pushed} module(s) → #{node_str}#{IO.ANSI.reset()}") + failed != [] -> Enum.each(failed, fn {mod, reason} -> IO.puts(" #{IO.ANSI.red()}✗ #{mod}: #{inspect(reason)}#{IO.ANSI.reset()}") end) + true -> - IO.puts(" #{IO.ANSI.yellow()}(compile ran but no new BEAMs — syntax error?)#{IO.ANSI.reset()}") + IO.puts( + " #{IO.ANSI.yellow()}(compile ran but no new BEAMs — syntax error?)#{IO.ANSI.reset()}" + ) end IO.puts("") @@ -132,6 +142,7 @@ defmodule Mix.Tasks.Mob.Watch do defp connect_with_retry(cookie) do IO.write("Connecting to devices...") nodes = MobDev.HotPush.connect(cookie: cookie) + if nodes == [] do IO.puts(" #{IO.ANSI.yellow()}none found#{IO.ANSI.reset()}") IO.puts(" Start apps first: mix mob.connect") @@ -141,6 +152,7 @@ defmodule Mix.Tasks.Mob.Watch do Enum.each(nodes, fn n -> IO.puts(" #{IO.ANSI.green()}✓#{IO.ANSI.reset()} #{n}") end) IO.puts("") end + nodes end @@ -152,37 +164,24 @@ defmodule Mix.Tasks.Mob.Watch do end # Lines from mix compile subprocess we don't want to echo. - @noise_prefixes ["warning! Erlang/OTP", "Regexes will be re-compiled", - "This can be fixed by using"] + @noise_prefixes [ + "warning! Erlang/OTP", + "Regexes will be re-compiled", + "This can be fixed by using" + ] defp recompile do # Run in a subprocess — Mix task caches are process-local and can't be # fully cleared with reenable/1 when inside another running mix task. mix = System.find_executable("mix") || "mix" {output, _} = System.cmd(mix, ["compile"], cd: File.cwd!(), stderr_to_stdout: true) + output |> String.split("\n", trim: true) |> Enum.reject(fn line -> Enum.any?(@noise_prefixes, &String.starts_with?(line, &1)) end) |> Enum.each(&IO.puts/1) end - defp snapshot_sources do - Path.wildcard("lib/**/*.ex") - |> Map.new(fn path -> - mtime = case File.stat(path, time: :posix) do - {:ok, %{mtime: t}} -> t - _ -> 0 - end - {path, mtime} - end) - end - - defp changed_sources(old, current) do - Enum.flat_map(current, fn {path, mtime} -> - if Map.get(old, path) != mtime, do: [path], else: [] - end) - end - defp short_node(node) do node |> to_string() |> String.split("@") |> hd() end diff --git a/lib/mix/tasks/mob.watch_stop.ex b/lib/mix/tasks/mob.watch_stop.ex index 0a0ca79..476c095 100644 --- a/lib/mix/tasks/mob.watch_stop.ex +++ b/lib/mix/tasks/mob.watch_stop.ex @@ -26,17 +26,24 @@ defmodule Mix.Tasks.Mob.WatchStop do case File.read(pid_file) do {:ok, contents} -> pid = String.trim(contents) + case System.cmd("kill", [pid], stderr_to_stdout: true) do {_, 0} -> File.rm(pid_file) IO.puts("#{IO.ANSI.green()}mob.watch stopped (pid #{pid})#{IO.ANSI.reset()}") + {out, _} -> File.rm(pid_file) - IO.puts("#{IO.ANSI.yellow()}kill failed (process may have already exited): #{String.trim(out)}#{IO.ANSI.reset()}") + + IO.puts( + "#{IO.ANSI.yellow()}kill failed (process may have already exited): #{String.trim(out)}#{IO.ANSI.reset()}" + ) end {:error, _} -> - IO.puts("#{IO.ANSI.yellow()}mob.watch is not running (no PID file found)#{IO.ANSI.reset()}") + IO.puts( + "#{IO.ANSI.yellow()}mob.watch is not running (no PID file found)#{IO.ANSI.reset()}" + ) end end end diff --git a/lib/mix/tasks/mob/adopt.ex b/lib/mix/tasks/mob/adopt.ex new file mode 100644 index 0000000..560e264 --- /dev/null +++ b/lib/mix/tasks/mob/adopt.ex @@ -0,0 +1,200 @@ +defmodule Mix.Tasks.Mob.Adopt do + @shortdoc "Installs Mob into an existing Phoenix project" + + @moduledoc """ + Adds Mob (mobile framework) to an existing Phoenix-based Elixir project. + + ## ⚠ Experimental (pre-1.0) + + `mix mob.adopt` is experimental. On anything outside the supported + shapes the task refuses with a clear message rather than risk + breaking your app. The supported surface will widen as we stabilise. + + ### Supported (default — LV bridge) + + - Single (non-umbrella) Phoenix project. + - Stock `assets/js/app.js` (contains `new LiveSocket(...)`). + - Stock root layout (`lib/<app>_web/components/layouts/root.html.heex` + or the legacy `templates/layout/root.html.heex`) with a `<body>` + tag. + - **Ecto Repo uses the SQLite adapter** (`:ecto_sqlite3` in deps). + The generated `mob_app.ex` migrates `<App>.Repo` on-device; the + SQLite assumption is hard-coded. A `mix phx.new --database sqlite3` + project matches. + + ### Supported (`--no-live-view` — thin-client) + + Same Phoenix-shape requirements but **no Ecto/Repo constraint** — + the phone opens a deployed Phoenix server via WebView and runs no DB + on-device. Works against Postgres / MySQL / `--no-ecto` hosts. + + ### Refused (loud, with guidance) + + - Umbrella applications. + - Non-Phoenix projects (no `:phoenix` dep). + - Heavily customised `app.js` (no recognisable `new LiveSocket(`). + - Heavily customised root layout (no recognisable `<body>` tag, or + no layout file at all). + - **LV mode** + host Repo uses Postgres / MySQL / MSSQL (or no Repo + at all). Use `--no-live-view` instead, or wait for the future + `--with-local-repo` mode that handles non-SQLite hosts via a + separate on-device LocalRepo. + + Composable, [Igniter](https://hex.pm/packages/igniter)-based — mirrors + the architecture of [team-alembic/phx_install](https://github.com/team-alembic/phx_install). + This is the install-into-existing counterpart to `mix mob.new`, which + generates a project from scratch. `mix mob.new` is unaffected by this task. + + ## Usage + + mix mob.adopt [OPTIONS] + + Run from inside an existing Mix project. The target project must + declare `{:igniter, "~> 0.7", only: [:dev, :test]}` in its mix.exs + (most modern Phoenix-ecosystem projects already do). + + The native trees (`--android` / `--ios`, on by default) render from + mob_new's templates, so they also require the **mob_new archive + installed**: + + mix archive.install hex mob_new + + mob_new stays the single source of native templates (no duplication + across repos). The Elixir-side adoption (deps, LiveView bridge, + `mob.exs`, `MobScreen`) needs no archive — only `--android`/`--ios` do. + + ## Options + + - `--no-ios` — skip the iOS native tree + - `--no-android` — skip the Android native tree + - `--local` — `path:` deps for `:mob`/`:mob_dev`; pre-fill `mob.exs` + paths from `MOB_DIR` / `MOB_DEV_DIR`. For Mob framework contributors. + - `--python` — iOS-only: pre-configure embedded CPython via Pythonx + - `--host-url URL` — write `config :mob, host_url: URL` so the + generated `MobScreen` opens `URL` instead of the default + `http://127.0.0.1:4000/`. Use for thin-client deployments where + the WebView points at a deployed Phoenix server (fly.io etc.). + - `--no-live-view` — skip the LiveView bridge patches + (`assets/js/app.js` MobHook, `root.html.heex` bridge div) AND + generate a thin-client `mob_app.ex` that does NOT boot Phoenix + on-device. For Hologram-only or non-Phoenix hosts where the + BEAM-on-device is just the native interop layer. + + Both platforms emit by default. Passing both `--no-ios` and + `--no-android` raises. + + ## What gets installed + + - `:mob` + `:mob_dev` deps in `mix.exs` + - `lib/<app>/mob_screen.ex` — `Mob.Screen` opening a WebView at + `Application.get_env(:mob, :host_url)` (default localhost) + - `mob.exs` — build-environment config + - `.gitignore` updated to ignore `mob.exs` + - `android/` and/or `ios/` native trees (gated by platform flags) + - `lib/<app>/mob_app.ex` + `src/<app>.erl` for on-device BEAM entry + - `erlc_paths`/`erlc_options` added to `mix.exs` + + Default (no `--no-live-view`): + - `MobHook` injected into `assets/js/app.js` + - bridge `<div>` injected into `root.html.heex` + - `mob_app.ex` boots the host Phoenix endpoint on-device + + With `--no-live-view`: + - LiveView bridge patches skipped + - `mob_app.ex` is the thin-client variant (`use Mob.App` shell, + no `Application.ensure_all_started`) + + ## Composability + + Every sub-installer is invokable independently: + + mix mob.adopt.deps # just bump mix.exs + mix mob.adopt.bridge # just patch app.js + root.html.heex + mix mob.adopt.screen # just generate mob_screen.ex + mix mob.adopt.mob_app # just generate mob_app.ex + .erl bootstrap + mix mob.adopt.mob_exs # just write mob.exs + .gitignore + mix mob.adopt.native # both native trees + mix mob.adopt.native.android + mix mob.adopt.native.ios + mix mob.adopt.finalize # post-install notice (no file changes) + + Each accepts the same flags as `mob.adopt` and respects them + individually. Run `mix help mob.adopt.<sub>` for sub-task docs. + + On-device runtime services (`Mob.ComponentRegistry`, + `Mob.NativeLogger`, etc.) start imperatively inside + `<App>.MobApp.start/0` — `Mob.App` is the *behaviour* the device + entry uses (via `use Mob.App`), never a supervision-tree child. + + The native trees come from mob_new's `priv/templates/mob.new/`; the + Elixir-source content (`mob_screen.ex`, `mob_app.ex`, the LV bridge + patches) from `MobDev.Adopt.Patcher` / `MobDev.Adopt.Generator`, both + duplicated from mob_new pending the Phase-5 Igniter reunification. + """ + use Igniter.Mix.Task + + alias Mix.Tasks.Mob.Adopt.{Bridge, Deps, Finalize, MobApp, MobExs, Native, Screen} + alias MobDev.AdoptGuard + + @schema [ + ios: :boolean, + android: :boolean, + local: :boolean, + python: :boolean, + host_url: :string, + live_view: :boolean + ] + + @defaults [ios: true, android: true, live_view: true] + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :mob, + example: "mix mob.adopt --host-url https://my-app.fly.dev/", + schema: @schema, + defaults: @defaults, + composes: [ + "mob.adopt.deps", + "mob.adopt.bridge", + "mob.adopt.screen", + "mob.adopt.mob_app", + "mob.adopt.mob_exs", + "mob.adopt.native", + "mob.adopt.finalize" + ] + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + validate_platforms!(igniter.args.options) + + igniter = AdoptGuard.check(igniter, AdoptGuard.mode_from(igniter.args.options)) + + if igniter.issues == [] do + compose_pipeline(igniter) + else + igniter + end + end + + defp compose_pipeline(igniter) do + argv = igniter.args.argv || [] + + igniter + |> Igniter.compose_task(Deps, argv) + |> Igniter.compose_task(Bridge, argv) + |> Igniter.compose_task(Screen, argv) + |> Igniter.compose_task(MobApp, argv) + |> Igniter.compose_task(MobExs, argv) + |> Igniter.compose_task(Native, argv) + |> Igniter.compose_task(Finalize, argv) + end + + defp validate_platforms!(opts) do + if Keyword.get(opts, :ios, true) == false and Keyword.get(opts, :android, true) == false do + Mix.raise("Cannot pass both --no-ios and --no-android; at least one platform must remain.") + end + end +end diff --git a/lib/mix/tasks/mob/adopt/bridge.ex b/lib/mix/tasks/mob/adopt/bridge.ex new file mode 100644 index 0000000..f7b22bb --- /dev/null +++ b/lib/mix/tasks/mob/adopt/bridge.ex @@ -0,0 +1,122 @@ +defmodule Mix.Tasks.Mob.Adopt.Bridge do + @shortdoc "Installs the Mob LiveView bridge (MobHook + bridge div)" + + @moduledoc """ + Patches `assets/js/app.js` and `lib/<web>/components/layouts/root.html.heex` + to wire `window.mob` through a LiveView `phx-hook`. Output matches + `mix mob.new --liveview`. + + Mob's native shell injects `window.mob` into every WebView for direct + JS↔native interop (camera, audio, sensors). The LV bridge patches + here *replace* that injection on mount with a LiveView-routed shim, + so `window.mob.send` goes through `pushEvent`/`handle_event` instead + of straight to native code. Useful when you want server-side BEAM + visibility into JS messages. Skip this if your project isn't using + LiveView (e.g. Hologram, vanilla controllers) — the native injection + alone is what you want. + + ## Options + + - `--no-live-view` — skip the patches entirely with a notice. For + Hologram-only or non-Phoenix hosts. + + Other orchestrator flags (`--no-ios`, `--no-android`, `--local`, + `--python`, `--host-url`) are accepted but inert here — declared in + the schema only so `mix mob.adopt` can forward its full argv + without Igniter rejecting unknown options. + + ## Refusal (LV mode) + + Refuses (via `Igniter.add_issue/2`) when `assets/js/app.js` is missing + or doesn't contain a `new LiveSocket(...)` call, or when the + `root.html.heex` layout is missing or has no `<body>` tag. The pre-1.0 + contract is "blessed shape only" — `--no-live-view` is the escape + hatch for everything else. + + ## Idempotency + + Both `MobDev.Adopt.Patcher.inject_mob_hook/1` and + `inject_mob_bridge_element/1` short-circuit when their markers + (`MobHook` / `mob-bridge`) are already present. + + Typically called by `mix mob.adopt`, not directly. + """ + use Igniter.Mix.Task + + alias Igniter.Project.Application, as: ProjectApplication + alias MobDev.Adopt.Patcher + alias MobDev.AdoptGuard + + @common_schema [ + ios: :boolean, + android: :boolean, + local: :boolean, + python: :boolean, + host_url: :string, + live_view: :boolean + ] + @common_defaults [ios: true, android: true, live_view: true] + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :mob, + example: "mix mob.adopt.bridge", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + mode = AdoptGuard.mode_from(igniter.args.options) + + # Guard call is idempotent — orchestrator runs the same checks but + # `prepare_for_write` dedupes issues. Defends direct invocation. + igniter = AdoptGuard.check(igniter, mode) + + cond do + igniter.issues != [] -> + igniter + + mode == :thin -> + Igniter.add_notice(igniter, """ + `mob.adopt.bridge` skipped (--no-live-view). The native shell + will inject `window.mob` directly; no LiveView hook needed. + """) + + true -> + igniter + |> patch_app_js() + |> patch_root_html() + end + end + + defp patch_app_js(igniter) do + Igniter.update_file(igniter, "assets/js/app.js", &update_app_js/1) + end + + defp update_app_js(source) do + content = Rewrite.Source.get(source, :content) + Rewrite.Source.update(source, :content, Patcher.inject_mob_hook(content)) + end + + defp patch_root_html(igniter) do + web = "#{ProjectApplication.app_name(igniter)}_web" + + candidates = [ + "lib/#{web}/components/layouts/root.html.heex", + "lib/#{web}/templates/layout/root.html.heex" + ] + + case Enum.find(candidates, &Igniter.exists?(igniter, &1)) do + nil -> igniter + path -> Igniter.update_file(igniter, path, &update_root_html/1) + end + end + + defp update_root_html(source) do + content = Rewrite.Source.get(source, :content) + Rewrite.Source.update(source, :content, Patcher.inject_mob_bridge_element(content)) + end +end diff --git a/lib/mix/tasks/mob/adopt/deps.ex b/lib/mix/tasks/mob/adopt/deps.ex new file mode 100644 index 0000000..a82ed6a --- /dev/null +++ b/lib/mix/tasks/mob/adopt/deps.ex @@ -0,0 +1,91 @@ +defmodule Mix.Tasks.Mob.Adopt.Deps do + @shortdoc "Adds :mob and :mob_dev to the project's mix.exs" + + @moduledoc """ + Adds Mob's two deps to the host project's `mix.exs`: + + - `{:mob, "~> 0.7"}` — the framework, used at runtime. + - `{:mob_dev, "~> 0.6", only: :dev, runtime: false}` — build/deploy + Mix tasks. Dev-only. + + ## Options + + - `--local` — write `path:` deps instead of Hex version constraints, + resolved from `MOB_DIR` / `MOB_DEV_DIR` env vars (falling back to + `./mob` / `../mob`). For Mob framework contributors. + + Other orchestrator flags accepted but inert. + + ## Idempotency + + Patching is done via `MobDev.Adopt.Patcher.inject_deps/3` (the + same stdlib-AST walk `mix mob.new --liveview` uses), which + short-circuits if `:mob` is already declared. + + Typically called by `mix mob.adopt`, not directly. + """ + use Igniter.Mix.Task + + alias MobDev.Adopt.{Generator, Patcher} + alias MobDev.AdoptGuard + + @common_schema [ + ios: :boolean, + android: :boolean, + local: :boolean, + python: :boolean, + host_url: :string, + live_view: :boolean + ] + @common_defaults [ios: true, android: true, live_view: true] + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :mob, + example: "mix mob.adopt.deps", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + opts = igniter.args.options + + # Guard call is idempotent — orchestrator runs the same checks but + # `prepare_for_write` dedupes issues. Defends direct invocation. + igniter = AdoptGuard.check(igniter, AdoptGuard.mode_from(opts)) + + if igniter.issues != [] do + igniter + else + inject(igniter, opts) + end + end + + defp inject(igniter, opts) do + {mob_dep_str, mob_dev_dep_str, _, _} = Generator.resolve_deps(opts) + live_view? = Keyword.get(opts, :live_view, true) + + # Not `Igniter.Project.Deps.add_dep/3`: 0.8.1 renders 3-tuples with + # trailing keyword opts as `:k => v` map syntax — invalid Elixir. + Igniter.update_file(igniter, "mix.exs", fn source -> + content = Rewrite.Source.get(source, :content) + + patched = + content + |> Patcher.inject_deps(mob_dep_str, mob_dev_dep_str) + |> maybe_inject_ecto_sqlite3(live_view?) + + Rewrite.Source.update(source, :content, patched) + end) + end + + # LV mode emits a `mob_app.ex` that calls + # `Application.ensure_all_started(:ecto_sqlite3)` and runs migrations + # on-device, so the dep is required. Thin-client mode (`--no-live-view`) + # doesn't run on-device DB, so we skip. + defp maybe_inject_ecto_sqlite3(content, true), do: Patcher.inject_ecto_sqlite3(content) + defp maybe_inject_ecto_sqlite3(content, false), do: content +end diff --git a/lib/mix/tasks/mob/adopt/finalize.ex b/lib/mix/tasks/mob/adopt/finalize.ex new file mode 100644 index 0000000..eeeda34 --- /dev/null +++ b/lib/mix/tasks/mob/adopt/finalize.ex @@ -0,0 +1,78 @@ +defmodule Mix.Tasks.Mob.Adopt.Finalize do + @shortdoc "Prints next-steps after mob.adopt" + + @moduledoc """ + Emits a post-install notice with the next steps for the user. + Performs no file changes — purely informational. + + All orchestrator flags accepted but inert. (`--no-live-view` causes a + slight variation in the notice, mentioning the thin-client setup + instead of the standard LV bridge flow.) + """ + use Igniter.Mix.Task + + @common_schema [ + ios: :boolean, + android: :boolean, + local: :boolean, + python: :boolean, + host_url: :string, + live_view: :boolean + ] + @common_defaults [ios: true, android: true, live_view: true] + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :mob, + example: "mix mob.adopt.finalize", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + live_view? = Keyword.get(igniter.args.options, :live_view, true) + host_url = igniter.args.options[:host_url] + Igniter.add_notice(igniter, notice(live_view?, host_url)) + end + + defp notice(live_view?, host_url) do + host_line = + if is_binary(host_url) and host_url != "" do + " - WebView URL set to `#{host_url}` via `config :mob, host_url:`.\n" + else + " - WebView URL defaults to `http://127.0.0.1:4000/`. Override with\n" <> + " `config :mob, host_url: \"https://your-app.example.com/\"`.\n" + end + + flavour_line = + if live_view?, + do: " - `mob_app.ex` boots the host Phoenix on-device (LiveView bridge).\n", + else: + " - `mob_app.ex` is the thin-client variant (no on-device Phoenix).\n" <> + " Deploy your Phoenix server separately; WebView opens its URL.\n" + + """ + + Mob installed. + + #{flavour_line}#{host_line} + 1. Edit mob.exs with your local paths (mob_dir, elixir_lib). + 2. Edit android/local.properties with your Android SDK path. + 3. First-time setup (icon generation, OTP runtime, signing): + + mix mob.install # mob_dev's first-run task — different from the + # `mix mob.adopt` you just ran. Runs once per device. + + 4. iOS only — if targeting a physical iPhone: + + mix mob.provision # register bundle ID + provisioning profile + + 5. Deploy to device (first time builds the native APK/iOS app): + + mix mob.deploy --native + """ + end +end diff --git a/lib/mix/tasks/mob/adopt/mob_app.ex b/lib/mix/tasks/mob/adopt/mob_app.ex new file mode 100644 index 0000000..7fefcd8 --- /dev/null +++ b/lib/mix/tasks/mob/adopt/mob_app.ex @@ -0,0 +1,144 @@ +defmodule Mix.Tasks.Mob.Adopt.MobApp do + @shortdoc "Generates lib/<app>/mob_app.ex + src/<app>.erl (on-device BEAM entry)" + + @moduledoc """ + Generates the on-device BEAM entry point invoked by Mob's native + shell at app launch: + + - `lib/<app>/mob_app.ex` — the entry module + - `src/<app>.erl` — Erlang bootstrap that calls + `<App>.MobApp.start/0` + - `mix.exs` patches: `erlc_paths: ["src"]` + `erlc_options: [:debug_info]` + so the Erlang bootstrap gets compiled + + Two flavours of `mob_app.ex`: + + - **LiveView** (default) — calls + `Application.ensure_all_started(:<app>)` which boots the host + Phoenix endpoint, runs Ecto migrations, sets up the on-device + runtime config. `secret_key_base` is read from + `config/dev.exs` if available (so it matches the host dev + server) or freshly generated. + - **Thin client** (with `--no-live-view`) — uses `use Mob.App` with + `navigation/1` + `on_start/0` callbacks. Does NOT boot Phoenix + on-device; the WebView points at a deployed Phoenix server (set + `config :mob, host_url: ...`). The device's BEAM is just the + native interop layer. + + ## Options + + - `--no-live-view` — generate the thin-client `mob_app.ex` instead + of the LiveView-flavoured one. Pairs with the `bridge` sub-task + being skipped under the same flag. + + Other orchestrator flags accepted but inert. + + ## Idempotency + + - Files are created with `on_exists: :skip`. Re-running won't + overwrite — delete first if you want to switch between LV and + thin flavours. + - `erlc_paths` / `erlc_options` injection checks string presence in + `mix.exs` before patching. + + Typically called by `mix mob.adopt`, not directly. + """ + use Igniter.Mix.Task + + alias Igniter.Project.Application, as: ProjectApplication + alias MobDev.Adopt.{Generator, Patcher} + alias MobDev.AdoptGuard + + @common_schema [ + ios: :boolean, + android: :boolean, + local: :boolean, + python: :boolean, + host_url: :string, + live_view: :boolean + ] + @common_defaults [ios: true, android: true, live_view: true] + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :mob, + example: "mix mob.adopt.mob_app", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + # Guard call is idempotent — orchestrator runs the same checks but + # `prepare_for_write` dedupes issues. Defends direct invocation. + igniter = AdoptGuard.check(igniter, AdoptGuard.mode_from(igniter.args.options)) + + if igniter.issues != [] do + igniter + else + generate(igniter) + end + end + + defp generate(igniter) do + app_name = ProjectApplication.app_name(igniter) |> to_string() + module_name = Macro.camelize(app_name) + live_view? = Keyword.get(igniter.args.options, :live_view, true) + + mob_app_content = build_mob_app_content(live_view?, module_name, app_name) + erl_content = Patcher.erlang_entry_content(module_name, app_name) + + igniter + |> Igniter.create_new_file("lib/#{app_name}/mob_app.ex", mob_app_content, on_exists: :skip) + |> Igniter.create_new_file("src/#{app_name}.erl", erl_content, on_exists: :skip) + |> patch_erlc_paths() + end + + defp build_mob_app_content(true = _live_view, module_name, app_name) do + secret_key_base = + Generator.extract_secret_key_base(File.cwd!()) || + Generator.generate_secret_key_base() + + signing_salt = Generator.generate_signing_salt() + + Patcher.mob_live_app_content(module_name, app_name, secret_key_base, signing_salt) + end + + defp build_mob_app_content(false = _live_view, module_name, app_name) do + Patcher.mob_app_content_thin(module_name, app_name) + end + + # Adds erlc_paths: ["src"] and erlc_options: [:debug_info] to the host + # mix.exs def project. Text-based for resilience — keyword-list AST + # manipulation inside `def project do [...]` is fragile across Phoenix + # versions. Idempotent via String.contains? checks. + defp patch_erlc_paths(igniter) do + Igniter.update_file(igniter, "mix.exs", fn source -> + content = Rewrite.Source.get(source, :content) + Rewrite.Source.update(source, :content, inject_erlc(content)) + end) + end + + @doc false + @spec inject_erlc(String.t()) :: String.t() + def inject_erlc(content) do + content + |> maybe_inject_key("erlc_paths", ~s(erlc_paths: ["src"],)) + |> maybe_inject_key("erlc_options", ~s(erlc_options: [:debug_info],)) + end + + defp maybe_inject_key(content, key, snippet) do + if String.contains?(content, key) do + content + else + Regex.replace( + Regex.compile!("(def project do\\s*\\[)"), + content, + "\\1\n #{snippet}", + global: false + ) + end + end +end diff --git a/lib/mix/tasks/mob/adopt/mob_exs.ex b/lib/mix/tasks/mob/adopt/mob_exs.ex new file mode 100644 index 0000000..bc609a6 --- /dev/null +++ b/lib/mix/tasks/mob/adopt/mob_exs.ex @@ -0,0 +1,99 @@ +defmodule Mix.Tasks.Mob.Adopt.MobExs do + @shortdoc "Generates mob.exs and adds it to .gitignore" + + @moduledoc """ + Writes `mob.exs` (build-environment config: `mob_dir`, `elixir_lib`) + and ensures `.gitignore` ignores it. + + ## Options + + - `--local` — pre-fill `mob_dir` and `elixir_lib` from `MOB_DIR` / + `MOB_DEV_DIR` env vars (or sibling-directory fallbacks). Without + `--local` the file uses `Path.join(File.cwd!(), "deps/mob")` and + reads `MOB_ELIXIR_LIB` / `:code.lib_dir(:elixir)` at runtime. + + Other orchestrator flags accepted but inert. + + ## Idempotency + + - `mob.exs` is created with `on_exists: :skip` — re-running won't + overwrite an edited mob.exs. + - `.gitignore` patch checks for `mob.exs` before appending. + + Typically called by `mix mob.adopt`, not directly. + """ + use Igniter.Mix.Task + + alias MobDev.Adopt.{Generator, Patcher} + alias MobDev.AdoptGuard + + @common_schema [ + ios: :boolean, + android: :boolean, + local: :boolean, + python: :boolean, + host_url: :string, + live_view: :boolean + ] + @common_defaults [ios: true, android: true, live_view: true] + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :mob, + example: "mix mob.adopt.mob_exs", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + opts = igniter.args.options + + # Guard call is idempotent — orchestrator runs the same checks but + # `prepare_for_write` dedupes issues. Defends direct invocation. + igniter = AdoptGuard.check(igniter, AdoptGuard.mode_from(opts)) + + if igniter.issues != [] do + igniter + else + generate(igniter, opts) + end + end + + defp generate(igniter, opts) do + {_mob_dep, _mob_dev_dep, mob_dir_expr, elixir_lib_expr} = + Generator.resolve_deps(local: opts[:local] || false) + + mob_exs_content = Patcher.mob_exs_content(mob_dir_expr, elixir_lib_expr) + + igniter + |> Igniter.create_new_file("mob.exs", mob_exs_content, on_exists: :skip) + |> patch_gitignore() + end + + defp patch_gitignore(igniter) do + if Igniter.exists?(igniter, ".gitignore") do + Igniter.update_file(igniter, ".gitignore", &append_mob_exs/1) + else + Igniter.create_new_file(igniter, ".gitignore", "# Mob local config\nmob.exs\n", + on_exists: :skip + ) + end + end + + defp append_mob_exs(source) do + content = Rewrite.Source.get(source, :content) + + if mob_exs_ignored?(content) do + source + else + Rewrite.Source.update(source, :content, content <> "\n# Mob local config\nmob.exs\n") + end + end + + defp mob_exs_ignored?(content) do + String.contains?(content, "\nmob.exs") or String.starts_with?(content, "mob.exs") + end +end diff --git a/lib/mix/tasks/mob/adopt/native.ex b/lib/mix/tasks/mob/adopt/native.ex new file mode 100644 index 0000000..3272176 --- /dev/null +++ b/lib/mix/tasks/mob/adopt/native.ex @@ -0,0 +1,60 @@ +defmodule Mix.Tasks.Mob.Adopt.Native do + @shortdoc "Installs the native (Android + iOS) build trees" + + @moduledoc """ + Dispatcher — composes `mob.adopt.native.android` and + `mob.adopt.native.ios`. + + ## Options + + - `--no-android` — skip the Android tree. + - `--no-ios` — skip the iOS tree. + - `--local` — forwarded to the platform sub-installers for path-dep + resolution. + - `--python` — iOS-only: pre-configure embedded CPython via Pythonx + (forwarded to `mob.adopt.native.ios`). + + Other orchestrator flags accepted but inert. + + Both platforms emit by default. Useful as a standalone task for + "refresh the native trees after a template fix" workflows. + """ + use Igniter.Mix.Task + + alias Mix.Tasks.Mob.Adopt.Native.{Android, Ios} + + @common_schema [ + ios: :boolean, + android: :boolean, + local: :boolean, + python: :boolean, + host_url: :string, + live_view: :boolean + ] + @common_defaults [ios: true, android: true, live_view: true] + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :mob, + example: "mix mob.adopt.native", + schema: @common_schema, + defaults: @common_defaults, + composes: ["mob.adopt.native.android", "mob.adopt.native.ios"] + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + opts = igniter.args.options + + igniter + |> maybe_compose(Android, Keyword.get(opts, :android, true)) + |> maybe_compose(Ios, Keyword.get(opts, :ios, true)) + end + + defp maybe_compose(igniter, _module, false), do: igniter + + defp maybe_compose(igniter, module, true), + do: Igniter.compose_task(igniter, module, igniter.args.argv) +end diff --git a/lib/mix/tasks/mob/adopt/native/android.ex b/lib/mix/tasks/mob/adopt/native/android.ex new file mode 100644 index 0000000..8d8eb1d --- /dev/null +++ b/lib/mix/tasks/mob/adopt/native/android.ex @@ -0,0 +1,93 @@ +defmodule Mix.Tasks.Mob.Adopt.Native.Android do + @shortdoc "Generates the android/ native tree from mob_new templates" + + @moduledoc """ + Walks `priv/templates/mob.new/android/**/*.eex` (resolved from mob_new + — see `MobDev.Adopt.Generator`), renders each with the project's + assigns, and writes them via `Igniter.create_new_file/4` with + `on_exists: :skip`. Then copies the binary static tree + (`priv/static/mob.new/android/**`) via direct `File.copy!/2` since + Igniter's `Rewrite` engine assumes UTF-8 text and would corrupt the + Gradle wrapper jar and PNG icons. + + Idempotent — `on_exists: :skip` for EEx-rendered files; `File.exists?` + pre-check for binaries. + + The `gradlew` script is `chmod 0o755` after copy so it's executable. + """ + use Igniter.Mix.Task + + alias Igniter.Project.Application, as: ProjectApplication + alias MobDev.Adopt.Generator + + @common_schema [ + ios: :boolean, + android: :boolean, + local: :boolean, + python: :boolean, + host_url: :string, + live_view: :boolean + ] + @common_defaults [ios: true, android: true, live_view: true] + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :mob, + example: "mix mob.adopt.native.android", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + opts = igniter.args.options + app_name = ProjectApplication.app_name(igniter) |> to_string() + assigns = Generator.assigns(app_name, opts) + + igniter + |> emit_templates(assigns, opts, "android") + |> copy_static_binaries(opts, "android") + end + + @doc false + @spec emit_templates(Igniter.t(), map(), keyword(), String.t()) :: Igniter.t() + def emit_templates(igniter, assigns, opts, platform) do + t_root = Generator.templates_root(opts) + + t_root + |> Path.join("#{platform}/**/*.eex") + |> Path.wildcard(match_dot: true) + |> Enum.reduce(igniter, fn template_path, ig -> + rel = Path.relative_to(template_path, t_root) + dest_rel = Generator.expand_path(rel, assigns) + content = EEx.eval_file(template_path, Map.to_list(assigns)) + Igniter.create_new_file(ig, dest_rel, content, on_exists: :skip) + end) + end + + @doc false + @spec copy_static_binaries(Igniter.t(), keyword(), String.t()) :: Igniter.t() + def copy_static_binaries(igniter, opts, platform) do + s_root = Generator.static_root(opts) + + s_root + |> Path.join("#{platform}/**/*") + |> Path.wildcard(match_dot: true) + |> Enum.reject(&File.dir?/1) + |> Enum.reduce(igniter, ©_one_binary(&1, &2, s_root)) + end + + defp copy_one_binary(src, igniter, s_root) do + rel = Path.relative_to(src, s_root) + if File.exists?(rel), do: igniter, else: do_copy_binary(src, rel, igniter) + end + + defp do_copy_binary(src, rel, igniter) do + File.mkdir_p!(Path.dirname(rel)) + File.copy!(src, rel) + if rel == "android/gradlew", do: File.chmod!(rel, 0o755) + Igniter.add_notice(igniter, "* copied binary: #{rel}") + end +end diff --git a/lib/mix/tasks/mob/adopt/native/ios.ex b/lib/mix/tasks/mob/adopt/native/ios.ex new file mode 100644 index 0000000..c96d07d --- /dev/null +++ b/lib/mix/tasks/mob/adopt/native/ios.ex @@ -0,0 +1,66 @@ +defmodule Mix.Tasks.Mob.Adopt.Native.Ios do + @shortdoc "Generates the ios/ native tree from mob_new templates" + + @moduledoc """ + Walks `priv/templates/mob.new/ios/**/*.eex` (resolved from mob_new — + see `MobDev.Adopt.Generator`), renders each, and writes via + `Igniter.create_new_file/4` with `on_exists: :skip`. Then copies binary + static iOS assets via direct `File.copy!/2`. + + Idempotent — `on_exists: :skip` for EEx-rendered files; `File.exists?` + pre-check for binaries. + + With `--python`, also applies the Pythonx wiring (`{:pythonx, ...}` dep + in `mix.exs`, generates `lib/<app>/python_paths.ex`). iOS-only — Android + Python is intentionally out of scope. Mirrors `mix mob.enable pythonx`. + """ + use Igniter.Mix.Task + + alias Igniter.Project.Application, as: ProjectApplication + alias Mix.Tasks.Mob.Adopt.Native.Android, as: AndroidInstaller + alias MobDev.Adopt.Generator + + @common_schema [ + ios: :boolean, + android: :boolean, + local: :boolean, + python: :boolean, + host_url: :string, + live_view: :boolean + ] + @common_defaults [ios: true, android: true, live_view: true] + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :mob, + example: "mix mob.adopt.native.ios --python", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + opts = igniter.args.options + app_name = ProjectApplication.app_name(igniter) |> to_string() + assigns = Generator.assigns(app_name, opts) + + igniter + |> AndroidInstaller.emit_templates(assigns, opts, "ios") + |> AndroidInstaller.copy_static_binaries(opts, "ios") + |> maybe_apply_python(opts, app_name) + end + + defp maybe_apply_python(igniter, opts, app_name) do + if opts[:python] == true do + # apply_python_patches operates on the filesystem directly (it + # predates the Igniter install path). Wrap as a side-effect noticed + # by Igniter so users see it in the output. + Generator.apply_python_patches(File.cwd!(), app_name) + Igniter.add_notice(igniter, "* applied Pythonx wiring (mix.exs + python_paths.ex)") + else + igniter + end + end +end diff --git a/lib/mix/tasks/mob/adopt/screen.ex b/lib/mix/tasks/mob/adopt/screen.ex new file mode 100644 index 0000000..3c1d92d --- /dev/null +++ b/lib/mix/tasks/mob/adopt/screen.ex @@ -0,0 +1,104 @@ +defmodule Mix.Tasks.Mob.Adopt.Screen do + @shortdoc "Generates the MobScreen (WebView wrapper) module" + + @moduledoc """ + Generates `lib/<app>/mob_screen.ex` — the `Mob.Screen` that opens the + WebView pointed at the host Phoenix endpoint. + + The generated module reads the URL from application config: + + config :mob, host_url: "https://your-app.example.com/" + + Default if unset is `http://127.0.0.1:4000/`, suitable for on-device + BEAM hitting a local Phoenix endpoint. The screen module never has + the URL hardcoded. + + ## Options + + - `--host-url URL` — write `config :mob, host_url: URL` to + `config/config.exs`. Equivalent to editing config by hand after + install; provided as a flag so the install pipeline can be fully + declarative. No-op when not given. + + Other orchestrator flags (`--no-ios`, `--no-android`, `--local`, + `--python`, `--no-live-view`) are accepted but inert here — declared + in the schema only so `mix mob.adopt` can forward its full argv + to this sub-installer without Igniter rejecting unknown options. + + ## Idempotency + + - `lib/<app>/mob_screen.ex` is created with `on_exists: :skip` — if + it already exists, contents are left alone. To regenerate, delete + the file first. + - `--host-url`'s config write goes through `Igniter.Project.Config`, + which is idempotent: the key is set to the new value, or left as + is if the same value is already present. + + Typically called by `mix mob.adopt`, not directly. + """ + use Igniter.Mix.Task + + alias Igniter.Project.Application, as: ProjectApplication + alias Igniter.Project.Config, as: ProjectConfig + alias MobDev.Adopt.Patcher + alias MobDev.AdoptGuard + + # Common schema — every install sub-task accepts the full orchestrator + # flag set so `mix mob.adopt` can forward its argv unchanged. + # Sub-tasks ignore options that don't apply to them. + @common_schema [ + ios: :boolean, + android: :boolean, + local: :boolean, + python: :boolean, + host_url: :string, + live_view: :boolean + ] + @common_defaults [ios: true, android: true, live_view: true] + + @impl Igniter.Mix.Task + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + group: :mob, + example: "mix mob.adopt.screen --host-url https://my-app.fly.dev/", + schema: @common_schema, + defaults: @common_defaults + } + end + + @impl Igniter.Mix.Task + def igniter(igniter) do + # Guard call is idempotent — orchestrator runs the same checks but + # `prepare_for_write` dedupes issues. Defends direct invocation. + igniter = AdoptGuard.check(igniter, AdoptGuard.mode_from(igniter.args.options)) + + if igniter.issues != [] do + igniter + else + generate(igniter) + end + end + + defp generate(igniter) do + app_name = ProjectApplication.app_name(igniter) |> to_string() + module_name = Macro.camelize(app_name) + + igniter + |> Igniter.create_new_file( + "lib/#{app_name}/mob_screen.ex", + Patcher.mob_screen_content_install(module_name), + on_exists: :skip + ) + |> maybe_configure_host_url() + end + + defp maybe_configure_host_url(igniter) do + case igniter.args.options[:host_url] do + url when is_binary(url) and url != "" -> + ProjectConfig.configure(igniter, "config.exs", :mob, [:host_url], url) + + _ -> + igniter + end + end +end diff --git a/lib/mob_dev/adopt/generator.ex b/lib/mob_dev/adopt/generator.ex new file mode 100644 index 0000000..7c27f19 --- /dev/null +++ b/lib/mob_dev/adopt/generator.ex @@ -0,0 +1,431 @@ +defmodule MobDev.Adopt.Generator do + @moduledoc """ + EEx-template assigns, native-tree template/static roots, dep + resolution, and Pythonx wiring for `mix mob.adopt`. + + Duplicated from `MobNew.ProjectGenerator` (mob_new, the project + generator archive). Only the transitive closure `mix mob.adopt` + exercises was copied — the full `mix mob.new` generation pipeline + (`generate/3`, `liveview_generate/3`, the `mix phx.new` shell-out, + the config/router patchers) stays in mob_new. Phase 5 of + `build_system_migration.md` reunifies the two copies behind a single + Igniter-based path; until then both repos carry their own copy + (mob_new can't depend on mob_dev — it's a self-contained Mix archive; + see `ArchiveSelfContainedTest`). + + ## Native templates come from the installed mob_new archive + + The Android/iOS native trees `mob.adopt` emits are rendered from + mob_new's `priv/templates/mob.new/` and `priv/static/mob.new/`. Those + template files belong to the generator and are deliberately NOT + duplicated here. By design, `mix mob.adopt --android/--ios` requires the + mob_new archive installed (`mix archive.install hex mob_new`) in addition + to mob_dev as a project dep — mob_new stays the single source of native + templates, so the two can't drift. Mix puts an installed archive on the + code path, so `:code.priv_dir(:mob_new)` resolves its bundled priv at + runtime — the same way mob_new's own `mix mob.new` loads them. + `templates_root/1` / `static_root/1` prefer that, then fall back to a + local checkout via `$MOB_NEW_DIR` / `~/code/mob_new` for development. See + `decisions/2026-06-19-mob-adopt-lives-in-mob_dev.md`. + + ## Compile-time regex + + Compiles regexes at runtime via `Regex.compile!/1`, never `~r//` + literals (OTP 28.0 dropped `:re.import/1`). See mob `AGENTS.md` rule #9. + """ + + alias MobDev.NdkVersion + + @doc false + @spec templates_root(keyword()) :: String.t() + def templates_root(opts), do: priv_root(opts) |> Path.join("templates/mob.new") + + @doc false + @spec static_root(keyword()) :: String.t() + def static_root(opts), do: priv_root(opts) |> Path.join("static/mob.new") + + # The native templates ship in mob_new, not mob_dev. Resolve mob_new's + # priv dir: prefer the installed mob_new archive (on Mix's code path, so + # `:code.priv_dir(:mob_new)` finds its bundled priv), then a local + # checkout via `$MOB_NEW_DIR` / `~/code/mob_new` for development. + defp priv_root(_opts) do + case mob_new_priv_from_code() do + nil -> mob_new_priv_from_checkout() || raise_no_templates() + dir -> dir + end + end + + defp mob_new_priv_from_code do + case :code.priv_dir(:mob_new) do + {:error, _} -> nil + path -> if templates_present?(to_string(path)), do: to_string(path) + end + end + + defp mob_new_priv_from_checkout do + [System.get_env("MOB_NEW_DIR"), Path.expand("~/code/mob_new")] + |> Enum.reject(&is_nil/1) + |> Enum.find_value(&priv_if_templates_exist/1) + end + + defp priv_if_templates_exist(dir) do + priv = Path.join(dir, "priv") + if templates_present?(priv), do: priv + end + + defp templates_present?(priv), do: File.dir?(Path.join(priv, "templates/mob.new")) + + defp raise_no_templates do + Mix.raise(""" + mob.adopt could not find mob_new's native templates. + + The Android/iOS native trees are rendered from mob_new's bundled + priv/templates/mob.new/. Install the mob_new archive so it is on + Mix's code path: + + mix archive.install hex mob_new + + (For local mob_new development instead, set MOB_NEW_DIR to your + checkout, or clone mob_new to ~/code/mob_new.) + """) + end + + # Reverse-DNS prefix for the generated bundle id. Honors MOB_BUNDLE_PREFIX + # (typical value: "com.acme" or "net.you"); defaults to "com.example", the + # universal "must change before shipping" placeholder. Never defaults to + # "com.mob" — Apple and Google enforce reverse-DNS ownership at App Store + # / Play Store submission. + @spec bundle_prefix() :: String.t() + def bundle_prefix do + case System.get_env("MOB_BUNDLE_PREFIX") do + nil -> "com.example" + "" -> "com.example" + raw -> String.trim(raw) + end + end + + @doc """ + Returns the EEx template assigns map for `app_name`. + + Options: + - `:local` — when `true`, generates `path:` deps pointing to local mob/mob_dev + repos instead of hex version constraints. Paths are resolved from the + `MOB_DIR` and `MOB_DEV_DIR` environment variables, falling back to + `../mob` and `../mob_dev` relative to the generated project location. + """ + @spec assigns(String.t(), keyword()) :: map() + def assigns(app_name, opts \\ []) do + module_name = Macro.camelize(app_name) + display_name = module_name + bundle_prefix = bundle_prefix() + bundle_id = "#{bundle_prefix}.#{app_name}" + java_package = bundle_id + lib_name = String.replace(app_name, "_", "") + java_path = String.replace(bundle_id, ".", "/") + + # JNI method name segment: dots→underscores, then underscores→_1 + # e.g. "com.mob.test_app" → "com_mob_test_1app" + jni_package = + java_package + |> String.replace("_", "_1") + |> String.replace(".", "_") + + {mob_dep, mob_dev_dep, mob_exs_mob_dir, mob_exs_elixir_lib} = resolve_deps(opts) + + %{ + app_name: app_name, + module_name: module_name, + display_name: display_name, + bundle_id: bundle_id, + java_package: java_package, + jni_package: jni_package, + lib_name: lib_name, + java_path: java_path, + mob_dep: mob_dep, + mob_dev_dep: mob_dev_dep, + mob_exs_mob_dir: mob_exs_mob_dir, + mob_exs_elixir_lib: mob_exs_elixir_lib, + ndk_version: NdkVersion.recommended(), + python: Keyword.get(opts, :python, false), + blank: Keyword.get(opts, :blank, false) + } + end + + @doc """ + Patches a generated project to enable Pythonx (embedded CPython, + iOS + Android). + + Two patches: + * `mix.exs` — adds `{:pythonx, "~> 0.4"}` to deps. + * `lib/<app>/python_paths.ex` — pure detection module that reads + `:code.root_dir/0` for iOS and `MOB_PYTHON_HOME` / `MOB_PYTHON_DL` + env vars (set by Android's `MainActivity`) for Android. + + Mirrors `mix mob.enable pythonx`. Idempotent — safe to run twice. + """ + @spec apply_python_patches(String.t(), String.t()) :: :ok + def apply_python_patches(project_dir, app_name) do + add_pythonx_dep(project_dir) + write_python_paths_module(project_dir, app_name) + :ok + end + + defp add_pythonx_dep(project_dir) do + path = Path.join(project_dir, "mix.exs") + + case File.read(path) do + {:ok, content} -> + cond do + String.contains?(content, ":pythonx") -> + :ok + + Regex.match?(Regex.compile!(~S{defp\s+deps\s+do\s*\[}), content) -> + patched = + Regex.replace( + Regex.compile!(~S{(defp\s+deps\s+do\s*\[)}), + content, + ~s(\\1\n {:pythonx, "~> 0.4"},), + global: false + ) + + File.write!(path, patched) + + true -> + :ok + end + + _ -> + :ok + end + end + + defp write_python_paths_module(project_dir, app_name) do + module_name = Macro.camelize(app_name) + dir = Path.join([project_dir, "lib", app_name]) + File.mkdir_p!(dir) + path = Path.join(dir, "python_paths.ex") + + unless File.exists?(path) do + File.write!(path, python_paths_module_source(module_name)) + end + end + + defp python_paths_module_source(module_name) do + """ + defmodule #{module_name}.PythonPaths do + @moduledoc \"\"\" + Detects bundled CPython at runtime and reports the paths needed + for `Pythonx.init/4` (dl_path, home_path, stdlib_path). + + Pure detection logic — see your app's `App` module for how the + result is fed into `Pythonx.init/4` at boot. + + ## Per-platform layout + + * **iOS**: `mix mob.deploy --native` bundles `Python.framework`, + stdlib, and lib-dynload at `<App>.app/otp/python/`. Detection + reads `:code.root_dir/0` and inspects that subtree. + + * **Android**: `mix mob.deploy --native` bundles libpython.so + into the APK's `jniLibs/<abi>/` (auto-extracted by the + installer to `applicationInfo.nativeLibraryDir`) and stdlib + + lib-dynload into `assets/python/` (extracted to + `filesDir/python/` by `MainActivity.onCreate` on first + launch). MainActivity exports the resolved paths via + `MOB_PYTHON_DL` and `MOB_PYTHON_HOME` env vars before + starting the BEAM. + + ## Returns + + * `:desktop` — no platform bundle found; the caller should + drive `Pythonx.Uv.fetch + init` manually. + * `{:ios, paths}` / `{:android, paths}` — bundle present; pass + into `Pythonx.init/4`. + * `{:partial, missing}` — bundle is incomplete; surface to + the user. + \"\"\" + + @type python_paths :: %{ + dl_path: String.t(), + home_path: String.t(), + stdlib_path: String.t() + } + + @type detection :: + :desktop + | {:ios, python_paths()} + | {:android, python_paths()} + | {:partial, [atom()]} + + @python_version "python3.13" + + @spec detect(String.t()) :: detection() + def detect(otp_root) when is_binary(otp_root) do + cond do + android_paths() != nil -> + paths = android_paths() + + case missing(paths) do + [] -> {:android, paths} + missing -> {:partial, missing} + end + + File.dir?(Path.join(otp_root, "python")) -> + paths = build_ios_paths(otp_root) + + case missing(paths) do + [] -> {:ios, paths} + missing -> {:partial, missing} + end + + true -> + :desktop + end + end + + @spec build_ios_paths(String.t()) :: python_paths() + def build_ios_paths(otp_root) when is_binary(otp_root) do + python_dir = Path.join(otp_root, "python") + + %{ + dl_path: Path.join([python_dir, "Python.framework", "Python"]), + home_path: python_dir, + stdlib_path: Path.join([python_dir, "lib", @python_version]) + } + end + + @spec build_android_paths() :: python_paths() | nil + def build_android_paths do + case {System.get_env("MOB_PYTHON_DL"), System.get_env("MOB_PYTHON_HOME")} do + {dl, home} when is_binary(dl) and is_binary(home) -> + %{ + dl_path: dl, + home_path: home, + stdlib_path: Path.join([home, "lib", @python_version]) + } + + _ -> + nil + end + end + + defp android_paths, do: build_android_paths() + + @spec missing(python_paths()) :: [atom()] + def missing(%{dl_path: dl, home_path: home, stdlib_path: stdlib}) do + [ + {:dl_path, File.exists?(dl)}, + {:home_path, File.dir?(home)}, + {:stdlib_path, File.dir?(stdlib)} + ] + |> Enum.reject(fn {_, present?} -> present? end) + |> Enum.map(&elem(&1, 0)) + end + end + """ + end + + @doc false + @spec extract_secret_key_base(String.t()) :: String.t() | nil + def extract_secret_key_base(project_dir) do + dev_exs = Path.join([project_dir, "config", "dev.exs"]) + + if File.exists?(dev_exs) do + content = File.read!(dev_exs) + + case Regex.run(Regex.compile!("secret_key_base:\\s*\"([^\"]{40,})\""), content) do + [_, key] -> key + _ -> nil + end + end + end + + @doc false + @spec generate_secret_key_base() :: String.t() + def generate_secret_key_base do + :crypto.strong_rand_bytes(48) |> Base.encode64(padding: false) + end + + @doc false + @spec generate_signing_salt() :: String.t() + def generate_signing_salt do + :crypto.strong_rand_bytes(8) |> Base.encode64(padding: false) + end + + # ── Dep resolution ──────────────────────────────────────────────────────────── + + @doc false + @spec resolve_deps(keyword()) :: {String.t(), String.t(), String.t(), String.t()} + def resolve_deps(opts) do + if opts[:local] do + mob_dir = resolve_local_path("MOB_DIR", "mob") + mob_dev_dir = resolve_local_path("MOB_DEV_DIR", "mob_dev") + elixir_lib = :code.lib_dir(:elixir) |> to_string() |> Path.dirname() |> Path.expand() + + # override: true so the local checkout satisfies the `mob ~> 0.7` + # requirement that the Hex showcase plugins (mob_camera, mob_themes, …) + # declare — Mix won't otherwise use a path dep to resolve a Hex + # sub-dependency requirement. + mob_dep = ~s({:mob, path: "#{mob_dir}", override: true}) + mob_dev_dep = ~s({:mob_dev, path: "#{mob_dev_dir}", only: :dev, runtime: false}) + mob_exs_mob_dir = inspect(mob_dir) + mob_exs_elixir_lib = inspect(elixir_lib) + + {mob_dep, mob_dev_dep, mob_exs_mob_dir, mob_exs_elixir_lib} + else + mob_dep = ~s({:mob, "~> 0.7"}) + mob_dev_dep = ~s({:mob_dev, "~> 0.6", only: :dev, runtime: false}) + mob_exs_mob_dir = "Path.join(File.cwd!(), \"deps/mob\")" + + # Default to the running Elixir's actual lib dir — `:code.lib_dir(:elixir)` + # returns ".../lib/elixir", so `Path.dirname/1` yields the parent that + # holds elixir/, logger/, eex/, etc. that build.sh's stdlib copy needs. + mob_exs_elixir_lib = + "System.get_env(\"MOB_ELIXIR_LIB\", :code.lib_dir(:elixir) |> to_string() |> Path.dirname())" + + {mob_dep, mob_dev_dep, mob_exs_mob_dir, mob_exs_elixir_lib} + end + end + + @doc false + @spec resolve_local_path(String.t(), String.t()) :: String.t() + def resolve_local_path(env_var, sibling_name) do + cond do + path = System.get_env(env_var) -> + Path.expand(path) + + File.dir?(sibling = Path.expand("./#{sibling_name}")) -> + sibling + + File.dir?(sibling = Path.expand("../#{sibling_name}")) -> + sibling + + true -> + Mix.raise(""" + Could not find local #{sibling_name} directory. + Set #{env_var} env var or ensure #{sibling_name} exists alongside your project: + export #{env_var}=/path/to/#{sibling_name} + """) + end + end + + @doc false + # Replace `app_name` placeholder in directory segments and strip .eex extension. + @spec expand_path(String.t(), map()) :: String.t() + def expand_path(rel, assigns) do + rel + # Dotfile templates can't ship in the archive (mix archive.build's + # wildcard drops dotfiles), so they live under non-dot names and are + # renamed here — the escape hatch the @dotfiles comment documents. + |> String.replace("dot_credo.exs", ".credo.exs") + |> String.replace("app_name", assigns.app_name) + |> String.replace("java/", "java/#{assigns.java_path}/") + |> strip_eex() + end + + defp strip_eex(path) do + if String.ends_with?(path, ".eex"), + do: String.slice(path, 0..-5//1), + else: path + end +end diff --git a/lib/mob_dev/adopt/patcher.ex b/lib/mob_dev/adopt/patcher.ex new file mode 100644 index 0000000..1a24196 --- /dev/null +++ b/lib/mob_dev/adopt/patcher.ex @@ -0,0 +1,610 @@ +defmodule MobDev.Adopt.Patcher do + @moduledoc """ + Pure helpers for the `mix mob.adopt` Elixir-source patches and + content generators: the LiveView bridge patches (`assets/js/app.js` + MobHook + `root.html.heex` bridge div), the `mix.exs` dep injection, + and the generated `mob_screen.ex` / `mob_app.ex` / `mob.exs` / + `src/<app>.erl` contents. + + Duplicated from `MobNew.LiveViewPatcher` (mob_new, the project + generator archive). Only the transitive closure `mix mob.adopt` + actually exercises was copied — `mob.new`'s notes-app generators + (`repo_content`, `note_content`, the LiveView starter screens, …) + stay in mob_new. Phase 5 of `build_system_migration.md` reunifies the + two copies behind a single Igniter-based path; until then both repos + carry their own copy (mob_new can't depend on mob_dev — it's a + self-contained Mix archive; see `ArchiveSelfContainedTest`). + + ## Compile-time regex + + Like the mob_new original, this module compiles regexes at runtime via + `Regex.compile!/1` rather than `~r//` literals — the `~r//` form bakes + a bytecode pattern that calls `:re.import/1`, removed in OTP 28.0 (the + version Mob's bundled iOS/Android tarballs ship). See mob `AGENTS.md` + rule #9. + """ + + @mob_hook_js ~S""" + // MobHook — Mob LiveView bridge. Added by `mix mob.new --liveview`. + // + // WHY THIS EXISTS: The native WebView injects window.mob pointing at the NIF + // bridge (postMessage on iOS, JavascriptInterface on Android). In LiveView + // mode we want window.mob to route through the LiveView WebSocket instead so + // handle_event/3 in your LiveView receives JS messages and push_event/3 + // delivers server messages back to JS. + // + // This hook replaces window.mob on mount. It requires a DOM element with + // phx-hook="MobHook" — see root.html.heex. Without that element this hook + // never runs and messages silently use the native bridge instead. + const MobHook = { + mounted() { + window.mob = { + // JS → LiveView: arrives as handle_event("mob_message", data, socket) + send: (data) => this.pushEvent("mob_message", data), + // LiveView → JS: push_event(socket, "mob_push", data) calls all handlers + onMessage: (handler) => this.handleEvent("mob_push", handler), + // No-op in LiveView mode. The native bridge calls this to deliver + // webview_post_message results, but in LiveView mode server messages + // arrive via handleEvent("mob_push") instead. + _dispatch: () => {} + } + } + } + """ + + @mob_bridge_element ~s(<div id="mob-bridge" phx-hook="MobHook" style="display:none"></div>) + + # ── Public API ──────────────────────────────────────────────────────────────── + + @doc "Returns the hidden bridge element string (for test assertions)." + @spec mob_bridge_element() :: String.t() + def mob_bridge_element, do: @mob_bridge_element + + @doc "Returns the MobHook JS string (for tests and warning messages)." + @spec mob_hook_js() :: String.t() + def mob_hook_js, do: @mob_hook_js + + @doc """ + Injects the MobHook definition and registration into the given `app.js` content. + + Idempotent: returns unchanged content if MobHook is already present. + """ + @spec inject_mob_hook(String.t()) :: String.t() + def inject_mob_hook(content) do + if String.contains?(content, "MobHook") do + content + else + content + |> insert_hook_definition() + |> register_hook_in_live_socket() + end + end + + @doc """ + Injects the hidden bridge `<div>` immediately after the opening `<body>` tag. + + Idempotent: returns unchanged content if mob-bridge is already present. + """ + @spec inject_mob_bridge_element(String.t()) :: String.t() + def inject_mob_bridge_element(content) do + if String.contains?(content, "mob-bridge") do + content + else + Regex.replace( + Regex.compile!("<body([^>]*)>"), + content, + "<body\\1>\n #{@mob_bridge_element}", + global: false + ) + end + end + + @doc """ + Injects mob / mob_dev dependencies into the `deps/0` function in `mix.exs` content. + + `mob_dep` and `mob_dev_dep` are dependency tuple strings (already formatted — + e.g. `~s({:mob, "~> 0.5"})` or `~s({:mob, path: "/path"})`). They are parsed + back to AST and inserted at the end of the user's deps list. + + Idempotent: no-op if `:mob` is already declared in the user's deps list, + regardless of indentation or trailing-comma shape. + + ## Implementation note + + Deps are injected by an AST walk (robust against Phoenix-version / formatter + variation), then serialized with **stdlib only** (`Macro.to_string` + + `Code.format_string!`). The mob_new original is reachable from `mix mob.new` + running as a Mix *archive*, and archives don't bundle runtime deps — so it + must not call any non-stdlib module (Sourceror). Preserved here verbatim. + """ + @spec inject_deps(String.t(), String.t(), String.t()) :: String.t() + def inject_deps(content, mob_dep, mob_dev_dep) do + case inject_deps_via_ast(content, mob_dep, mob_dev_dep) do + {:ok, patched} -> patched + :unchanged -> content + end + end + + defp inject_deps_via_ast(content, mob_dep, mob_dev_dep) do + with {:ok, ast} <- Code.string_to_quoted(content), + false <- mob_already_present?(ast), + {:ok, mob_quoted} <- parse_dep_tuple(mob_dep), + {:ok, mob_dev_quoted} <- parse_dep_tuple(mob_dev_dep), + {:ok, patched_ast} <- append_to_deps(ast, [mob_quoted, mob_dev_quoted]) do + {:ok, quoted_to_source(patched_ast)} + else + # mob already declared — no-op for idempotency + true -> + :unchanged + + # No deps/0 function, a parse failure, or anything unexpected — bail out + # without mangling the file. Callers see `content` unchanged. + _ -> + :unchanged + end + end + + defp parse_dep_tuple(tuple_str), do: Code.string_to_quoted(tuple_str) + + # Serialize the patched AST back to source with **stdlib only** — NOT Sourceror. + # Trade-off: Macro.to_string reformats and drops comments — acceptable for a + # freshly generated mix.exs (no user comments to preserve yet); format_string! + # normalizes the rest. + defp quoted_to_source(ast) do + ast + |> Macro.to_string() + |> Code.format_string!() + |> IO.iodata_to_binary() + |> Kernel.<>("\n") + end + + defp mob_already_present?(ast) do + {_, found?} = + Macro.prewalk(ast, false, fn + # A dep tuple whose first element is :mob — both `{:mob, "~> 0.5"}` and + # `{:mob, path: "…"}` parse to a 2-tuple `{:mob, _}` in standard quoted form. + {:mob, _} = node, _acc -> + {node, true} + + node, acc -> + {node, acc} + end) + + found? + end + + defp append_to_deps(ast, new_dep_asts) do + {patched, found?} = + Macro.prewalk(ast, false, fn + # Find `def(p) deps do <body> end` (the `, do:` shorthand desugars to the + # same `[do: body]`) and append the new deps to the list in <body>. + {defp_or_def, meta, [{:deps, _, args} = head, [{:do, body}]]}, found? + when defp_or_def in [:def, :defp] and (is_nil(args) or args == []) -> + new_body = append_to_list_node(body, new_dep_asts) + {{defp_or_def, meta, [head, [{:do, new_body}]]}, found? or new_body != body} + + node, acc -> + {node, acc} + end) + + if found?, do: {:ok, patched}, else: {:error, :no_deps_function} + end + + defp append_to_list_node(list, new_items) when is_list(list), do: list ++ new_items + defp append_to_list_node(other, _new_items), do: other + + @doc """ + Adds `{:ecto_sqlite3, "~> 0.18"}` to the deps list in `mix.exs` content + if not already present. The generated `mob_app.ex` (LiveView flavour) + calls `Application.ensure_all_started(:ecto_sqlite3)` and runs + `Ecto.Migrator` on-device, so the dep is required whenever that + template is emitted. + + Idempotent — no-op when `ecto_sqlite3` is already in the deps string. + """ + @spec inject_ecto_sqlite3(String.t()) :: String.t() + def inject_ecto_sqlite3(content) do + if String.contains?(content, "ecto_sqlite3") do + content + else + Regex.replace( + Regex.compile!("(defp deps do\\s*\\[)"), + content, + ~s[\\1\n {:ecto_sqlite3, "~> 0.18"},], + global: false + ) + end + end + + @doc """ + Generates `MobScreen` content for `mix mob.adopt`. + + The generated module reads the WebView URL from application config: + + config :mob, host_url: "https://your-app.example.com/" + + Default if unset is `http://127.0.0.1:4000/`, suitable for on-device + BEAM hitting a local Phoenix endpoint. `mix mob.adopt --host-url + <URL>` writes the config entry so the user doesn't need to edit + `config/config.exs` by hand. + """ + @spec mob_screen_content_install(String.t()) :: String.t() + def mob_screen_content_install(module_name) do + """ + defmodule #{module_name}.MobScreen do + @moduledoc \"\"\" + Mob.Screen that wraps the host Phoenix app in a native WebView. + + Reads the URL from `config :mob, :host_url` (default + `http://127.0.0.1:4000/`) so the same module works for the + on-device BEAM (localhost) or a remote deployment (set + `config :mob, host_url: "https://your-app.example.com/"`). + \"\"\" + use Mob.Screen + + @default_host_url "http://127.0.0.1:4000/" + + def host_url do + Application.get_env(:mob, :host_url, @default_host_url) + end + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def render(_assigns) do + Mob.UI.webview( + url: host_url(), + show_url: false + ) + end + end + """ + end + + @doc """ + Generates a thin-client `<App>.MobApp` for projects where the BEAM on + device does NOT host Phoenix/Hologram/game state — instead the WebView + points at a deployed Phoenix server and the device's BEAM is just the + native interop layer. + + Produced when `mix mob.adopt --no-live-view` is invoked. The thin + variant uses `use Mob.App` with `navigation/1` + `on_start/0` + callbacks (the same shape `mix mob.new` generates for native mode), + rather than the LV-flavored `def start do ... end` that boots the + host Phoenix endpoint on-device. + """ + @spec mob_app_content_thin(String.t(), String.t()) :: String.t() + def mob_app_content_thin(module_name, app_name) do + """ + defmodule #{module_name}.MobApp do + @moduledoc \"\"\" + Thin-client on-device BEAM entry. The native shell launches the + BEAM, this module configures DNS, opens `MobScreen` (which loads + a WebView at `config :mob, :host_url`), and starts Erlang + distribution so `mix mob.connect` can attach. + + Does NOT call `Application.ensure_all_started(:#{app_name})` — the + host's `#{module_name}.Application` belongs on the deployed server, + not on the phone. If you later decide you DO want the host app + running on-device (full on-device Phoenix), swap this for the + LiveView-flavoured `mob_app.ex` template generated by + `mix mob.adopt` without `--no-live-view`. + \"\"\" + + use Mob.App + + @impl Mob.App + def navigation(_platform) do + stack(:main, root: #{module_name}.MobScreen) + end + + @impl Mob.App + def on_start do + # Pure-BEAM DNS — iOS's `inet_gethost` port program is broken; + # this flips Erlang's lookup chain to `[:file, :dns]` with + # Google + Cloudflare as fallback resolvers. See + # `Mob.DNS.configure_pure_beam/1` for tuning. + Mob.DNS.configure_pure_beam() + + # Open the WebView pointed at the configured host URL. + Mob.Screen.start_root(#{module_name}.MobScreen) + + # Distribution for `mix mob.connect`. Optional; remove if you + # don't need on-device IEx. + Mob.Dist.ensure_started( + node: :"#{app_name}_android@127.0.0.1", + cookie: :mob_secret + ) + end + end + """ + end + + @doc """ + Generates mob.exs config content for a LiveView project. + """ + @spec mob_exs_content(String.t(), String.t()) :: String.t() + def mob_exs_content(mob_exs_mob_dir, mob_exs_elixir_lib) do + """ + # mob.exs — Mob build environment configuration. + # Set these paths for your machine. Not committed to version control. + # (Add mob.exs to .gitignore if you share this project.) + # + # OTP runtimes for Android and iOS are downloaded automatically by `mix mob.install`. + + import Config + + config :mob_dev, + # Path to the mob library repo (native source files for iOS/Android builds). + mob_dir: #{mob_exs_mob_dir}, + + # Path to your Elixir lib dir (e.g. ~/.local/share/mise/installs/elixir/1.18.4-otp-28/lib). + elixir_lib: #{mob_exs_elixir_lib} + + # The on-device LiveView endpoint port. Defaults to a deterministic + # value derived from the app name (4200..4999) so multiple Mob LV apps + # installed on the same device don't collide on a single hardcoded + # port. Uncomment + set this only if you need a fixed value (e.g. + # because your test harness pins one). + # config :mob, liveview_port: 4200 + """ + end + + @doc """ + Generates the `mob_app.ex` entry point for a LiveView project. + + This module is called from the Erlang bootstrap (`src/app_name.erl`) instead + of a native `Mob.App` module. It starts the Phoenix OTP application (which + boots the endpoint) and then starts `MobScreen` to open the WebView. + + Unlike native Mob apps, this does NOT `use Mob.App` — Phoenix owns the + supervision tree. Mob is wired in at the BEAM entry level only. + + `secret_key_base` and `signing_salt` are embedded directly because Mix config + files (`config/*.exs`) are not loaded on-device — `Application.put_env/3` is + the only way to configure the endpoint before `ensure_all_started/1` runs. + The on-device port defaults to a per-app hash (4200..4999) — see + `default_liveview_port/0` for the collision rationale. + """ + @spec mob_live_app_content(String.t(), String.t(), String.t(), String.t()) :: String.t() + def mob_live_app_content(module_name, app_name, secret_key_base, signing_salt) do + """ + defmodule #{module_name}.MobApp do + @moduledoc \"\"\" + BEAM entry point for the LiveView Mob app. + + Called from `src/#{app_name}.erl` by the iOS/Android native launcher. + Starts the Phoenix OTP application (which boots the endpoint and all + supervision trees), then opens the MobScreen WebView pointing at + http://127.0.0.1:<liveview_port>/ (port set in mob.exs). + + This module is the LiveView equivalent of `Mob.App`. It does not use + `use Mob.App` because Phoenix owns the supervision tree. Mob is added + only as a WebView wrapper around the running Phoenix endpoint. + \"\"\" + + def start do + Mob.NativeLogger.install() + + # On-device, Mix config files are not loaded — set Phoenix endpoint + # config explicitly before starting applications so the endpoint knows + # its port, adapter, and secret key base. Watchers and code reload + # are omitted (no dev tools on-device). + # + # Port default is hashed from the app name into 4200..4999 so two + # Mob LV apps installed on the same device don't fight over a + # single hardcoded port (Bandit returns :eaddrinuse, the endpoint + # supervisor crashes, BEAM dies). With 800 candidate ports and + # `phash2`'s good distribution, collision odds are p<0.5% even + # at five installed apps. Override in mob.exs by setting + # `config :mob, liveview_port: <port>` if you need a specific value. + liveview_port = Application.get_env(:mob, :liveview_port, default_liveview_port()) + Application.put_env(:mob, :liveview_port, liveview_port) + Application.put_env(:#{app_name}, #{module_name}Web.Endpoint, + adapter: Bandit.PhoenixAdapter, + http: [ip: {127, 0, 0, 1}, port: liveview_port], + check_origin: false, + debug_errors: true, + server: true, + secret_key_base: "#{secret_key_base}", + pubsub_server: #{module_name}.PubSub, + live_view: [signing_salt: "#{signing_salt}"], + code_reloader: false, + watchers: [], + live_reload: [patterns: []] + ) + + # esbuild + tailwind are dev-time asset compilers. They get pulled in + # as runtime apps but don't have access to their host config (which + # lives in `config/dev.exs`, not bundled). Set their versions here so + # the on-device boot log stays clean — they never actually run. + # Versions match Phoenix 1.7's defaults; bump alongside `mix phx.new`. + Application.put_env(:esbuild, :version, "0.25.0") + Application.put_env(:tailwind, :version, "3.4.6") + + # ecto_sqlite3 must be started before #{app_name} so its NIF is loaded + # before the Repo supervisor tries to open the database. + {:ok, _} = Application.ensure_all_started(:ecto_sqlite3) + + # Start the Phoenix application and all its children. + # This boots the endpoint, repo, pubsub, telemetry, etc. + {:ok, _} = Application.ensure_all_started(:#{app_name}) + + # Run any pending Ecto migrations. MOB_BEAMS_DIR is set by the native + # launcher to the flat deploy directory; migrations are copied there at + # build time. Falls back to Application.app_dir when running in dev. + Ecto.Migrator.with_repo(#{module_name}.Repo, fn _repo -> + Ecto.Migrator.run(#{module_name}.Repo, migrations_dir(), :up, all: true) + end) + + # ComponentRegistry is normally started by Mob.App but we bypass that. + # Start it standalone so Mob.Screen.start_root can render components. + {:ok, _} = Mob.ComponentRegistry.start_link() + + # Start the MobScreen WebView pointing at the local Phoenix endpoint. + # The WebView loads http://127.0.0.1:<liveview_port>/ (see mob.exs). + Mob.Screen.start_root(#{module_name}.MobScreen) + + # Start Erlang distribution so `mix mob.connect` can attach. + Mob.Dist.ensure_started(node: :"#{app_name}_android@127.0.0.1", cookie: :mob_secret) + end + + defp migrations_dir do + case System.get_env("MOB_BEAMS_DIR") do + nil -> Application.app_dir(:#{app_name}, "priv/repo/migrations") + beams_dir -> Path.join([beams_dir, "priv", "repo", "migrations"]) + end + end + + # 4200..4999 inclusive — small enough to leave room above the standard + # dev range, large enough that birthday-paradox collisions are rare for + # any reasonable number of installed Mob LV apps. Deterministic, so the + # WebView URL stays stable across restarts. + defp default_liveview_port do + 4200 + :erlang.phash2(:#{app_name}, 800) + end + end + """ + end + + @doc """ + Generates the Erlang bootstrap for a LiveView project. + + Calls `ModuleName.MobApp.start()` instead of `ModuleName.App.start()`. + """ + @spec erlang_entry_content(String.t(), String.t()) :: String.t() + def erlang_entry_content(module_name, app_name) do + """ + %% #{app_name}.erl — BEAM bootstrap for #{module_name} (LiveView mode). + %% Called by the iOS/Android native launcher via -eval '#{app_name}:start().'. + %% Starts the OTP ecosystem, then starts Phoenix + MobScreen via MobApp. + -module(#{app_name}). + -export([start/0]). + + start() -> + step(1, fun() -> application:start(compiler) end), + step(2, fun() -> application:start(elixir) end), + step(3, fun() -> application:start(logger) end), + step(4, fun() -> mob_nif:platform() end), + step(5, fun() -> 'Elixir.#{module_name}.MobApp':start() end), + timer:sleep(infinity). + + step(N, Fun) -> + mob_nif:log("step " ++ integer_to_list(N) ++ " starting"), + Result = (catch Fun()), + mob_nif:log("step " ++ integer_to_list(N) ++ " => " ++ + lists:flatten(io_lib:format("~p", [Result]))). + """ + end + + # ── Private ─────────────────────────────────────────────────────────────────── + + # Insert `hooks: {MobHook}` before the closing `})` of the LiveSocket call. + # Works by tracking brace depth line by line — avoids regex fights with nested braces. + defp insert_hooks_before_closing(content) do + lines = String.split(content, "\n") + + {result_lines, _} = + Enum.reduce(lines, {[], :before}, fn line, {acc, state} -> + reduce_line(line, acc, state) + end) + + Enum.join(result_lines, "\n") + end + + defp reduce_line(line, acc, :before) do + if String.contains?(line, "new LiveSocket(") do + depth = count_brace_depth(line) + + if depth <= 0 do + patched = + Regex.replace(Regex.compile!("\\)\\s*$"), line, ", {hooks: {MobHook}})", global: false) + + {acc ++ [patched], :done} + else + {acc ++ [line], {:in_call, depth}} + end + else + {acc ++ [line], :before} + end + end + + defp reduce_line(line, acc, {:in_call, depth}) do + new_depth = depth + count_brace_depth(line) + trimmed = String.trim(line) + + if new_depth <= 0 and (trimmed == "})" or String.starts_with?(trimmed, "})")) do + {insert_hooks_line(acc, line), :done} + else + {acc ++ [line], {:in_call, new_depth}} + end + end + + defp reduce_line(line, acc, :done), do: {acc ++ [line], :done} + + defp insert_hooks_line(acc, closing_line) do + last_acc = List.last(acc) + last_trimmed = if last_acc, do: String.trim_trailing(last_acc), else: "" + + acc_with_comma = + if String.ends_with?(last_trimmed, ",") do + acc + else + List.update_at(acc, -1, fn l -> String.trim_trailing(l) <> "," end) + end + + acc_with_comma ++ [" hooks: {MobHook}", closing_line] + end + + # Returns the net brace depth change for a line (opens minus closes). + defp count_brace_depth(line) do + opens = line |> :binary.matches("{") |> length() + closes = line |> :binary.matches("}") |> length() + opens - closes + end + + defp insert_hook_definition(content) do + lines = String.split(content, "\n") + + last_import_idx = + lines + |> Enum.with_index() + |> Enum.filter(fn {line, _} -> String.starts_with?(String.trim(line), "import ") end) + |> Enum.map(fn {_, idx} -> idx end) + |> List.last() + + insert_at = (last_import_idx || -1) + 1 + hook_lines = String.split(@mob_hook_js, "\n") + + (Enum.take(lines, insert_at) ++ [""] ++ hook_lines ++ Enum.drop(lines, insert_at)) + |> Enum.join("\n") + end + + defp register_hook_in_live_socket(content) do + cond do + String.contains?(content, "hooks: {}") -> + String.replace(content, "hooks: {}", "hooks: {MobHook}") + + Regex.match?(Regex.compile!("hooks:\\s*\\{"), content) -> + # hooks key already exists — prepend MobHook to it + Regex.replace( + Regex.compile!("(hooks:\\s*\\{)"), + content, + "\\1MobHook, ", + global: false + ) + + true -> + # No hooks key. Insert `hooks: {MobHook}` into the LiveSocket options. + # + # Strategy: process line by line. Once we see `new LiveSocket(`, track + # nesting depth. When we find the line that closes the options object + # (depth goes to 0 with `})`), insert `hooks: {MobHook}` before it. + # + # This handles both single-line and multiline LiveSocket calls correctly + # without fighting nested-brace regex limitations. + insert_hooks_before_closing(content) + end + end +end diff --git a/lib/mob_dev/adopt_guard.ex b/lib/mob_dev/adopt_guard.ex new file mode 100644 index 0000000..66ad21e --- /dev/null +++ b/lib/mob_dev/adopt_guard.ex @@ -0,0 +1,203 @@ +defmodule MobDev.AdoptGuard do + @moduledoc false + # Pre-1.0 detect-and-refuse for `mix mob.adopt`. Adds `Igniter.add_issue/2` + # entries when the target project doesn't match the blessed shape. The + # caller is expected to skip the rest of its work when `igniter.issues` + # is non-empty after `check/2` runs. + + alias Igniter.Project.Application, as: ProjectApplication + alias Igniter.Project.Deps, as: ProjectDeps + + @doc """ + Returns `:live_view` when LV bridge mode is in effect (default) or + `:thin` when `--no-live-view` was passed. + """ + @spec mode_from(keyword()) :: :live_view | :thin + def mode_from(opts) do + if Keyword.get(opts, :live_view, true), do: :live_view, else: :thin + end + + @doc """ + Runs the blessed-shape checks for `mode`. Returns the igniter with + `add_issue/2` entries appended for any check that fails. Caller gates + on `igniter.issues == []`. + """ + @spec check(Igniter.t(), :live_view | :thin) :: Igniter.t() + def check(igniter, mode) do + igniter + |> refuse_if_umbrella() + |> require_phoenix_dep() + |> maybe_check_live_view_shape(mode) + end + + defp refuse_if_umbrella(igniter) do + if umbrella?(igniter) do + Igniter.add_issue(igniter, """ + mob.adopt does not support umbrella applications. + Run it from inside one of the sub-app folders instead. + """) + else + igniter + end + end + + # Stubbable via `Igniter.assign(:umbrella?, true|false)` for tests. + defp umbrella?(igniter) do + case igniter.assigns[:umbrella?] do + nil -> Mix.Project.umbrella?() + bool -> bool + end + end + + defp require_phoenix_dep(igniter) do + if ProjectDeps.has_dep?(igniter, :phoenix) do + igniter + else + Igniter.add_issue(igniter, """ + mob.adopt requires a Phoenix project (`:phoenix` in your mix.exs deps). + For non-Phoenix Elixir apps, follow the manual install path documented + at `mix help mob.adopt`. + """) + end + end + + defp maybe_check_live_view_shape(igniter, :thin), do: igniter + + defp maybe_check_live_view_shape(igniter, :live_view) do + igniter + |> check_app_js() + |> check_root_html() + |> check_repo_shape() + end + + # The LV-flavoured `mob_app.ex` calls `Application.ensure_all_started(:ecto_sqlite3)` + # and runs `Ecto.Migrator.run(<App>.Repo, ...)` on-device. That assumes + # the host has an Ecto Repo using the SQLite adapter (the `mix mob.new` + # shape). Refuse loudly when the host doesn't match — silently emitting + # a mob_app.ex that tries to migrate Postgres on a phone would crash at + # boot. + defp check_repo_shape(igniter) do + cond do + not has_any_ecto_repo?(igniter) -> + Igniter.add_issue(igniter, """ + mob.adopt (LiveView mode) generates a `mob_app.ex` that boots Ecto + and runs migrations on-device. Your project has no Ecto Repo + (no `:ecto_sql` in deps). + + Options: + - Add an Ecto Repo before adopting (e.g. start from a phx.new + project with `--database sqlite3`). + - Or use `--no-live-view` for the thin-client path — the phone + opens a deployed Phoenix server; no on-device DB needed. + """) + + has_non_sqlite_adapter?(igniter) and not has_sqlite_adapter?(igniter) -> + Igniter.add_issue(igniter, """ + mob.adopt (LiveView mode) generates a `mob_app.ex` that migrates the + host's `<App>.Repo` on-device — assumes SQLite. Your project looks + like it uses Postgres / MySQL / MSSQL, which won't run on a phone. + + Options: + - Use `--no-live-view` for the thin-client path (server hosts + Phoenix + your existing DB; phone is just a WebView shell). + - Switch the host Repo to SQLite (matches `mix mob.new --liveview`). + - Wait for the upcoming `--with-local-repo` mode that generates a + separate SQLite LocalRepo + target-aware Repo selection. + """) + + true -> + igniter + end + end + + defp has_any_ecto_repo?(igniter), do: ProjectDeps.has_dep?(igniter, :ecto_sql) + defp has_sqlite_adapter?(igniter), do: ProjectDeps.has_dep?(igniter, :ecto_sqlite3) + + defp has_non_sqlite_adapter?(igniter) do + Enum.any?([:postgrex, :myxql, :tds], &ProjectDeps.has_dep?(igniter, &1)) + end + + defp check_app_js(igniter) do + path = "assets/js/app.js" + + cond do + not Igniter.exists?(igniter, path) -> + Igniter.add_issue(igniter, """ + mob.adopt (LiveView mode) requires #{path}. Not found. + Use `--no-live-view` for thin-client mode (WebView opens a remote URL; + no app.js patches needed). + """) + + not stock_live_socket?(igniter, path) -> + Igniter.add_issue(igniter, """ + mob.adopt (LiveView mode) requires a stock `new LiveSocket(...)` in #{path}. + The current app.js shape is too customised for safe automated patching. + Either restore the standard Phoenix shape or use `--no-live-view`. + """) + + true -> + igniter + end + end + + defp check_root_html(igniter) do + web = "#{ProjectApplication.app_name(igniter)}_web" + + candidates = [ + "lib/#{web}/components/layouts/root.html.heex", + "lib/#{web}/templates/layout/root.html.heex" + ] + + case Enum.find(candidates, &Igniter.exists?(igniter, &1)) do + nil -> + Igniter.add_issue(igniter, """ + mob.adopt (LiveView mode) requires a root layout at one of: + - lib/#{web}/components/layouts/root.html.heex + - lib/#{web}/templates/layout/root.html.heex + Neither was found. Use `--no-live-view` for thin-client mode. + """) + + path -> + if has_body_tag?(igniter, path) do + igniter + else + Igniter.add_issue(igniter, """ + mob.adopt (LiveView mode) requires a `<body>` tag in #{path} for the + bridge `<div>` injection. The current layout shape is too customised + for safe automated patching. + Either restore the standard layout or use `--no-live-view`. + """) + end + end + end + + defp stock_live_socket?(igniter, path) do + case read_content(igniter, path) do + {:ok, content} -> String.contains?(content, "new LiveSocket(") + _ -> false + end + end + + defp has_body_tag?(igniter, path) do + case read_content(igniter, path) do + {:ok, content} -> Regex.match?(Regex.compile!("<body[^>]*>"), content) + _ -> false + end + end + + defp read_content(igniter, path) do + cond do + igniter.assigns[:test_mode?] -> + case igniter.assigns[:test_files][path] do + nil -> {:error, :not_found} + content -> {:ok, content} + end + + File.regular?(path) -> + File.read(path) + + true -> + {:error, :not_found} + end + end +end diff --git a/lib/mob_dev/android_deploy_lock.ex b/lib/mob_dev/android_deploy_lock.ex new file mode 100644 index 0000000..eded05a --- /dev/null +++ b/lib/mob_dev/android_deploy_lock.ex @@ -0,0 +1,704 @@ +defmodule MobDev.AndroidDeployLock do + @moduledoc false + + @max_targets 32 + @max_serial_bytes 128 + @max_record_bytes 128 + @max_command_output_bytes 256 + @owner_pattern "\\A[A-Za-z0-9_-]{16}\\z" + @digest_pattern "\\A[0-9a-f]{64}\\z" + @bundle_pattern "\\A[A-Za-z][A-Za-z0-9_]*(?:\\.[A-Za-z0-9_]+)+\\z" + @phases [:acquired, :native_ready, :final_committed, :fast_committed] + @committed_phases [:final_committed, :fast_committed] + + @type runner :: ([String.t()] -> {String.t(), integer()}) + @type phase :: :acquired | :native_ready | :final_committed | :fast_committed + @type lease_state :: + :not_acquired | :held_success | :retained_failure | :retained_ambiguous + @type lease :: %{ + required(:bundle_id) => String.t(), + required(:owner) => String.t(), + required(:serials) => [String.t()], + required(:target_digest) => String.t(), + required(:phase) => phase(), + required(:state) => lease_state() + } + @type failure :: %{ + required(:reason) => atom(), + required(:phase) => atom(), + required(:serial) => String.t() | nil, + required(:lease) => lease(), + optional(:affected_serials) => [String.t()], + optional(:transitioned_serials) => [String.t()], + optional(:renamed_serials) => [String.t()], + optional(:released_serials) => [String.t()], + optional(:transition) => {phase(), phase()} + } + + @doc false + @spec valid?(term(), phase() | nil) :: boolean() + def valid?(lease, expected_phase \\ nil) do + validate_lease(lease) == :ok and lease.state == :held_success and + (is_nil(expected_phase) or lease.phase == expected_phase) + end + + @doc false + @spec acquire(String.t(), [String.t()], runner(), keyword()) :: + {:ok, lease()} | {:error, failure()} + def acquire(bundle_id, serials, runner, opts \\ []) + + def acquire(bundle_id, serials, runner, opts) when is_function(runner, 1) do + owner = Keyword.get_lazy(opts, :owner, &new_owner/0) + + with :ok <- validate_bundle_id(bundle_id), + :ok <- validate_owner(owner), + {:ok, ordered_serials} <- validate_serials(serials) do + lease = %{ + bundle_id: bundle_id, + owner: owner, + serials: ordered_serials, + target_digest: target_digest(ordered_serials), + phase: :acquired, + state: :not_acquired + } + + with :ok <- preflight_available(lease, runner) do + acquire_ordered(lease, runner) + end + else + {:error, reason} -> + {:error, + %{ + reason: reason, + phase: :validate, + serial: nil, + lease: invalid_lease(bundle_id, owner, serials) + }} + end + end + + def acquire(bundle_id, serials, _runner, opts) do + owner = Keyword.get(opts, :owner, "<invalid>") + + {:error, + %{ + reason: :invalid_runner, + phase: :validate, + serial: nil, + lease: invalid_lease(bundle_id, owner, serials) + }} + end + + @doc false + @spec verify_owner(lease(), String.t(), runner()) :: :ok | {:error, failure()} + def verify_owner(lease, serial, runner) when is_function(runner, 1) do + with :ok <- validate_lease(lease), + :ok <- require_held(lease), + :ok <- require_target(lease, serial), + :ok <- validate_serial(serial) do + expected = record(lease, lease.phase) + + case invoke(runner, serial, record_proof_command(lease.bundle_id)) do + {^expected, 0} -> + :ok + + _missing_mismatched_or_ambiguous -> + {:error, failure(lease, :record_mismatch, :verify_owner, serial)} + end + else + {:error, reason} -> {:error, failure(normalize_lease(lease), reason, :validate, serial)} + end + end + + def verify_owner(lease, serial, _runner), + do: {:error, failure(normalize_lease(lease), :invalid_runner, :validate, serial)} + + @doc false + @spec transition(lease(), phase(), phase(), runner()) :: + {:ok, lease()} | {:error, failure()} + def transition(lease, expected_phase, next_phase, runner) when is_function(runner, 1) do + with :ok <- validate_lease(lease), + true <- lease.state == :held_success, + true <- lease.phase == expected_phase, + :ok <- validate_transition(expected_phase, next_phase), + :ok <- preflight_records(lease, runner) do + transition_ordered(lease, expected_phase, next_phase, runner) + else + false -> + {:error, failure(normalize_lease(lease), :phase_mismatch, :transition_validate, nil)} + + {:error, %{lease: _lease} = failure} -> + {:error, failure} + + {:error, reason} -> + {:error, failure(normalize_lease(lease), reason, :transition_validate, nil)} + end + end + + def transition(lease, _expected_phase, _next_phase, _runner), + do: {:error, failure(normalize_lease(lease), :invalid_runner, :transition_validate, nil)} + + @doc false + @spec release(lease(), runner()) :: :ok | {:error, failure()} + def release(lease, runner) when is_function(runner, 1) do + with :ok <- validate_lease(lease), + true <- lease.state == :held_success, + true <- lease.phase in @committed_phases, + :ok <- preflight_records(lease, runner) do + release_ordered(lease, runner) + else + false -> {:error, failure(normalize_lease(lease), :lease_not_releasable, :validate, nil)} + {:error, %{lease: _lease} = failure} -> {:error, failure} + {:error, reason} -> {:error, failure(normalize_lease(lease), reason, :validate, nil)} + end + end + + def release(lease, _runner), + do: {:error, failure(normalize_lease(lease), :invalid_runner, :validate, nil)} + + @doc false + @spec status(String.t(), String.t(), runner()) :: + {:ok, :clear | :held | :released_tombstone | :ambiguous} | {:error, atom()} + def status(bundle_id, serial, runner) when is_function(runner, 1) do + with :ok <- validate_bundle_id(bundle_id), + :ok <- validate_serial(serial) do + case invoke(runner, serial, status_command(bundle_id)) do + {"clear", 0} -> {:ok, :clear} + {"held", 0} -> {:ok, :held} + {"released_tombstone", 0} -> {:ok, :released_tombstone} + {"ambiguous", 0} -> {:ok, :ambiguous} + _invalid_or_failed -> {:error, :status_ambiguous} + end + end + end + + def status(_bundle_id, _serial, _runner), do: {:error, :invalid_runner} + + @doc false + @spec cleanup_committed_tombstone(String.t(), String.t(), runner()) :: + :ok | {:error, atom()} + def cleanup_committed_tombstone(bundle_id, serial, runner) when is_function(runner, 1) do + with :ok <- validate_bundle_id(bundle_id), + :ok <- validate_serial(serial), + {:ok, owner, record} <- probe_committed_tombstone(bundle_id, serial, runner), + {"", 0} <- + invoke(runner, serial, cleanup_tombstone_command(bundle_id, owner, record)) do + :ok + else + {:error, reason} -> {:error, reason} + _failure_or_ambiguity -> {:error, :cleanup_ambiguous} + end + end + + def cleanup_committed_tombstone(_bundle_id, _serial, _runner), + do: {:error, :invalid_runner} + + @doc false + @spec message(failure()) :: String.t() + def message(%{phase: phase, reason: reason}) do + "Android deploy lease #{phase_label(phase)} failed (#{reason_label(reason)}); manual recovery required" + end + + def message(_failure), do: "Android deploy lease failed; manual recovery required" + + defp preflight_available(lease, runner) do + Enum.reduce_while(lease.serials, :ok, fn serial, :ok -> + case invoke(runner, serial, available_command(lease.bundle_id)) do + {"", 0} -> + {:cont, :ok} + + _blocked_or_ambiguous -> + {:halt, + {:error, + failure( + %{lease | state: :not_acquired}, + :lease_present_or_ambiguous, + :preflight, + serial + )}} + end + end) + end + + defp preflight_records(lease, runner) do + Enum.reduce_while(lease.serials, :ok, fn serial, :ok -> + case verify_owner(lease, serial, runner) do + :ok -> + {:cont, :ok} + + {:error, failure} -> + retained = %{lease | state: :retained_ambiguous} + {:halt, {:error, %{failure | lease: retained}}} + end + end) + end + + defp acquire_ordered(lease, runner) do + lease.serials + |> Enum.reduce_while({:ok, []}, fn serial, {:ok, acquired} -> + case invoke(runner, serial, acquire_command(lease)) do + {"", 0} -> + {:cont, {:ok, [serial | acquired]}} + + _failure_or_ambiguity -> + affected = Enum.sort([serial | acquired]) + retained_lease = %{lease | state: :retained_ambiguous} + + {:halt, + {:error, + failure(retained_lease, :acquire_ambiguous, :acquire, serial, + affected_serials: affected + )}} + end + end) + |> case do + {:ok, _acquired} -> {:ok, %{lease | state: :held_success}} + {:error, _failure} = error -> error + end + end + + defp transition_ordered(lease, expected_phase, next_phase, runner) do + old_record = record(lease, expected_phase) + next_record = record(lease, next_phase) + + lease.serials + |> Enum.reduce_while({:ok, []}, fn serial, {:ok, transitioned} -> + case invoke( + runner, + serial, + transition_command(lease.bundle_id, lease.owner, old_record, next_record) + ) do + {"", 0} -> + {:cont, {:ok, [serial | transitioned]}} + + _failure_or_ambiguity -> + retained = %{lease | state: :retained_ambiguous} + + {:halt, + {:error, + failure(retained, :transition_ambiguous, :transition, serial, + transition: {expected_phase, next_phase}, + affected_serials: Enum.sort([serial | transitioned]), + transitioned_serials: Enum.sort(transitioned) + )}} + end + end) + |> case do + {:ok, _transitioned} -> {:ok, %{lease | phase: next_phase}} + {:error, _failure} = error -> error + end + end + + defp release_ordered(lease, runner) do + ordered = Enum.sort(lease.serials, :desc) + + with {:ok, renamed} <- rename_all(lease, ordered, runner), + :ok <- verify_all_tombstones(lease, ordered, renamed, runner), + {:ok, _released} <- delete_all_tombstones(lease, ordered, runner) do + :ok + else + {:error, _failure} = error -> error + end + end + + defp rename_all(lease, ordered, runner) do + Enum.reduce_while(ordered, {:ok, []}, fn serial, {:ok, renamed} -> + case release_fixed_lock(lease, serial, runner) do + :ok -> + {:cont, {:ok, [serial | renamed]}} + + {:error, %{phase: phase, reason: reason}} -> + retained = %{lease | state: :retained_ambiguous} + + {:halt, + {:error, + failure(retained, reason, phase, serial, + affected_serials: Enum.sort([serial | renamed]), + renamed_serials: Enum.sort(renamed) + )}} + end + end) + end + + defp verify_all_tombstones(lease, ordered, renamed, runner) do + Enum.reduce_while(ordered, :ok, fn serial, :ok -> + case verify_tombstone_record(lease, serial, runner) do + :ok -> + {:cont, :ok} + + {:error, %{phase: phase, reason: reason}} -> + retained = %{lease | state: :retained_ambiguous} + + {:halt, + {:error, failure(retained, reason, phase, serial, renamed_serials: Enum.sort(renamed))}} + end + end) + end + + defp delete_all_tombstones(lease, ordered, runner) do + Enum.reduce_while(ordered, {:ok, []}, fn serial, {:ok, released} -> + case delete_tombstone(lease, serial, runner) do + :ok -> + {:cont, {:ok, [serial | released]}} + + {:error, %{phase: phase, reason: reason}} -> + retained = %{lease | state: :retained_ambiguous} + + {:halt, + {:error, + failure(retained, reason, phase, serial, released_serials: Enum.sort(released))}} + end + end) + end + + defp release_fixed_lock(lease, serial, runner) do + case invoke(runner, serial, rename_command(lease)) do + {"", 0} -> + :ok + + _failure_or_ambiguity -> + {:error, failure(lease, :rename_ambiguous, :release_rename, serial)} + end + end + + defp verify_tombstone_record(lease, serial, runner) do + expected = record(lease, lease.phase) + + case invoke(runner, serial, tombstone_record_proof_command(lease.bundle_id, lease.owner)) do + {^expected, 0} -> + :ok + + _failure_or_ambiguity -> + {:error, failure(lease, :tombstone_record_ambiguous, :release_verify, serial)} + end + end + + defp delete_tombstone(lease, serial, runner) do + case invoke(runner, serial, delete_command(lease)) do + {"", 0} -> + :ok + + _failure_or_ambiguity -> + {:error, failure(lease, :delete_ambiguous, :release_delete, serial)} + end + end + + defp available_command(bundle_id) do + {files, fixed, tombstones} = lock_paths(bundle_id) + + "run-as #{bundle_id} sh -c 'set -e; test ! -e #{fixed}; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "test -d #{files}'" + end + + defp acquire_command(lease) do + {_files, fixed, tombstones} = lock_paths(lease.bundle_id) + value = record(lease, :acquired) + + "run-as #{lease.bundle_id} sh -c 'set -e; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "mkdir #{fixed}; printf %s \"#{value}\" > #{fixed}/record'" + end + + defp record_proof_command(bundle_id) do + {_files, fixed, tombstones} = lock_paths(bundle_id) + + "run-as #{bundle_id} sh -c 'set -e; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "size=$(wc -c < #{fixed}/record); test \"$size\" -le #{@max_record_bytes}; " <> + "cat #{fixed}/record'" + end + + defp transition_command(bundle_id, owner, old_record, next_record) do + {_files, fixed, tombstones} = lock_paths(bundle_id) + next_file = "#{fixed}/record_next_#{owner}" + old_size = byte_size(old_record) + + "run-as #{bundle_id} sh -c 'set -e; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "size=$(wc -c < #{fixed}/record); test \"$size\" -eq #{old_size}; " <> + "value=$(cat #{fixed}/record); test \"$value\" = \"#{old_record}\"; " <> + "test ! -e #{next_file}; printf %s \"#{next_record}\" > #{next_file}; " <> + "mv #{next_file} #{fixed}/record'" + end + + defp rename_command(lease) do + {_files, fixed, tombstones} = lock_paths(lease.bundle_id) + tombstone = tombstone_path(lease.bundle_id, lease.owner) + expected = record(lease, lease.phase) + expected_size = byte_size(expected) + + "run-as #{lease.bundle_id} sh -c 'set -e; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "size=$(wc -c < #{fixed}/record); test \"$size\" -eq #{expected_size}; " <> + "value=$(cat #{fixed}/record); test \"$value\" = \"#{expected}\"; " <> + "mv #{fixed} #{tombstone}'" + end + + defp tombstone_record_proof_command(bundle_id, owner) do + {_files, fixed, tombstones} = lock_paths(bundle_id) + tombstone = tombstone_path(bundle_id, owner) + + "run-as #{bundle_id} sh -c 'set -e; test ! -e #{fixed}; " <> + "set -- #{tombstones}; test \"$#\" -eq 1; test \"$1\" = \"#{tombstone}\"; " <> + "entries=$(find #{tombstone} -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f #{tombstone}/record; " <> + "size=$(wc -c < #{tombstone}/record); test \"$size\" -le #{@max_record_bytes}; " <> + "cat #{tombstone}/record'" + end + + defp delete_command(lease) do + {_files, fixed, tombstones} = lock_paths(lease.bundle_id) + tombstone = tombstone_path(lease.bundle_id, lease.owner) + expected = record(lease, lease.phase) + expected_size = byte_size(expected) + + "run-as #{lease.bundle_id} sh -c 'set -e; test ! -e #{fixed}; " <> + "set -- #{tombstones}; test \"$#\" -eq 1; test \"$1\" = \"#{tombstone}\"; " <> + "entries=$(find #{tombstone} -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f #{tombstone}/record; " <> + "size=$(wc -c < #{tombstone}/record); test \"$size\" -eq #{expected_size}; " <> + "value=$(cat #{tombstone}/record); test \"$value\" = \"#{expected}\"; " <> + "rm #{tombstone}/record; rmdir #{tombstone}'" + end + + defp status_command(bundle_id) do + {_files, fixed, tombstones} = lock_paths(bundle_id) + + "run-as #{bundle_id} sh -c 'fixed=0; tombstones=0; " <> + "if [ -e #{fixed} ]; then fixed=1; fi; " <> + "for path in #{tombstones}; do if [ -e \"$path\" ]; then tombstones=$((tombstones + 1)); fi; done; " <> + "if [ \"$fixed\" -eq 0 ] && [ \"$tombstones\" -eq 0 ]; then printf clear; " <> + "elif [ \"$fixed\" -eq 1 ] && [ \"$tombstones\" -eq 0 ]; then printf held; " <> + "elif [ \"$fixed\" -eq 0 ] && [ \"$tombstones\" -eq 1 ]; then printf released_tombstone; " <> + "else printf ambiguous; fi'" + end + + defp probe_committed_tombstone(bundle_id, serial, runner) do + case invoke(runner, serial, committed_tombstone_probe_command(bundle_id)) do + {output, 0} -> parse_committed_tombstone(output) + _missing_malformed_or_ambiguous -> {:error, :tombstone_ambiguous} + end + end + + defp committed_tombstone_probe_command(bundle_id) do + {_files, fixed, tombstones} = lock_paths(bundle_id) + + "run-as #{bundle_id} sh -c 'set -e; test ! -e #{fixed}; " <> + "set -- #{tombstones}; test \"$#\" -eq 1; test \"$1\" != \"#{tombstones}\"; " <> + "test -d \"$1\"; base=${1##*/}; " <> + "entries=$(find \"$1\" -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f \"$1/record\"; " <> + "size=$(wc -c < \"$1/record\"); test \"$size\" -le #{@max_record_bytes}; " <> + "printf \"%s\\n\" \"$base\"; cat \"$1/record\"'" + end + + defp parse_committed_tombstone(output) when is_binary(output) do + with [basename, record] <- String.split(output, "\n", parts: 2), + ["1", owner, digest, phase] <- String.split(record, "|", parts: 4), + true <- basename == ".mob_native_deploy_releasing_#{owner}", + :ok <- validate_owner(owner), + true <- valid_digest?(digest), + true <- phase in Enum.map(@committed_phases, &Atom.to_string/1), + true <- byte_size(record) <= @max_record_bytes do + {:ok, owner, record} + else + _malformed_or_uncommitted -> {:error, :tombstone_not_committed} + end + end + + defp cleanup_tombstone_command(bundle_id, owner, record) do + {_files, fixed, tombstones} = lock_paths(bundle_id) + tombstone = tombstone_path(bundle_id, owner) + expected_size = byte_size(record) + + "run-as #{bundle_id} sh -c 'set -e; test ! -e #{fixed}; " <> + "set -- #{tombstones}; test \"$#\" -eq 1; test \"$1\" = \"#{tombstone}\"; " <> + "entries=$(find #{tombstone} -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f #{tombstone}/record; " <> + "size=$(wc -c < #{tombstone}/record); test \"$size\" -eq #{expected_size}; " <> + "value=$(cat #{tombstone}/record); test \"$value\" = \"#{record}\"; " <> + "rm #{tombstone}/record; rmdir #{tombstone}'" + end + + defp lock_paths(bundle_id) do + files = "/data/data/#{bundle_id}/files" + {files, "#{files}/.mob_native_deploy_lock", "#{files}/.mob_native_deploy_releasing_*"} + end + + defp tombstone_path(bundle_id, owner), + do: "/data/data/#{bundle_id}/files/.mob_native_deploy_releasing_#{owner}" + + defp record(lease, phase), + do: "1|#{lease.owner}|#{lease.target_digest}|#{phase}" + + defp target_digest(serials) do + serials + |> Enum.join(<<0>>) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end + + defp invoke(runner, serial, command) do + try do + case runner.(["-s", serial, "shell", command]) do + {output, status} + when is_binary(output) and is_integer(status) and + byte_size(output) <= @max_command_output_bytes -> + {output, status} + + _invalid -> + {:invalid, :invalid} + end + rescue + _error -> {:invalid, :invalid} + catch + _kind, _reason -> {:invalid, :invalid} + end + end + + defp validate_lease(%{ + bundle_id: bundle_id, + owner: owner, + serials: serials, + target_digest: digest, + phase: phase, + state: state + }) + when phase in @phases and + state in [:not_acquired, :held_success, :retained_failure, :retained_ambiguous] do + with :ok <- validate_bundle_id(bundle_id), + :ok <- validate_owner(owner), + {:ok, ordered} <- validate_serials(serials), + true <- ordered == serials, + true <- valid_digest?(digest), + true <- digest == target_digest(ordered), + true <- + byte_size(record(%{owner: owner, target_digest: digest}, phase)) <= @max_record_bytes do + :ok + else + false -> {:error, :invalid_lease_identity} + {:error, reason} -> {:error, reason} + end + end + + defp validate_lease(_lease), do: {:error, :invalid_lease} + + defp validate_transition(:acquired, :native_ready), do: :ok + defp validate_transition(:native_ready, :final_committed), do: :ok + defp validate_transition(:acquired, :fast_committed), do: :ok + defp validate_transition(_from, _to), do: {:error, :invalid_transition} + + defp require_held(%{state: :held_success}), do: :ok + defp require_held(_lease), do: {:error, :lease_not_held} + + defp require_target(%{serials: serials}, serial) do + if serial in serials, do: :ok, else: {:error, :target_not_in_lease} + end + + defp validate_bundle_id(bundle_id) when is_binary(bundle_id) do + if byte_size(bundle_id) <= 255 and String.valid?(bundle_id) and + Regex.match?(Regex.compile!(@bundle_pattern), bundle_id), + do: :ok, + else: {:error, :invalid_bundle_id} + end + + defp validate_bundle_id(_bundle_id), do: {:error, :invalid_bundle_id} + + defp validate_owner(owner) when is_binary(owner) do + if String.valid?(owner) and Regex.match?(Regex.compile!(@owner_pattern), owner), + do: :ok, + else: {:error, :invalid_owner} + end + + defp validate_owner(_owner), do: {:error, :invalid_owner} + + defp valid_digest?(digest) when is_binary(digest), + do: String.valid?(digest) and Regex.match?(Regex.compile!(@digest_pattern), digest) + + defp valid_digest?(_digest), do: false + + defp validate_serials(serials) when is_list(serials) do + cond do + serials == [] -> + {:error, :empty_targets} + + length(serials) > @max_targets -> + {:error, :too_many_targets} + + Enum.any?(serials, &(validate_serial(&1) != :ok)) -> + {:error, :invalid_target} + + Enum.uniq(serials) != serials -> + {:error, :duplicate_target} + + serials |> Enum.map(&String.downcase/1) |> Enum.uniq() |> length() != length(serials) -> + {:error, :ambiguous_target} + + true -> + {:ok, Enum.sort(serials)} + end + end + + defp validate_serials(_serials), do: {:error, :invalid_targets} + + defp validate_serial(serial) when is_binary(serial) do + valid? = + byte_size(serial) in 1..@max_serial_bytes and String.valid?(serial) and + not String.starts_with?(serial, "-") and + Enum.all?(:binary.bin_to_list(serial), fn byte -> + byte in ?0..?9 or byte in ?A..?Z or byte in ?a..?z or byte in ~c".:-_" + end) + + if valid?, do: :ok, else: {:error, :invalid_target} + end + + defp validate_serial(_serial), do: {:error, :invalid_target} + + defp new_owner, do: :crypto.strong_rand_bytes(12) |> Base.url_encode64(padding: false) + + defp invalid_lease(bundle_id, owner, serials) do + safe_serials = + if is_list(serials) do + serials + |> Enum.take(@max_targets) + |> Enum.filter(&(validate_serial(&1) == :ok)) + |> Enum.uniq() + |> Enum.sort() + else + [] + end + + %{ + bundle_id: if(validate_bundle_id(bundle_id) == :ok, do: bundle_id, else: "<invalid>"), + owner: if(validate_owner(owner) == :ok, do: owner, else: "<invalid>"), + serials: safe_serials, + target_digest: target_digest(Enum.sort(safe_serials)), + phase: :acquired, + state: :not_acquired + } + end + + defp normalize_lease( + %{ + bundle_id: _, + owner: _, + serials: _, + target_digest: _, + phase: _, + state: _ + } = lease + ), + do: lease + + defp normalize_lease(_lease), do: invalid_lease("<invalid>", "<invalid>", []) + + defp failure(lease, reason, phase, serial, extra \\ []) do + Map.merge(%{reason: reason, phase: phase, serial: serial, lease: lease}, Map.new(extra)) + end + + defp phase_label(phase) when is_atom(phase), do: Atom.to_string(phase) + defp phase_label(_phase), do: "unknown" + defp reason_label(reason) when is_atom(reason), do: Atom.to_string(reason) + defp reason_label(_reason), do: "unknown" +end diff --git a/lib/mob_dev/android_deploy_recovery.ex b/lib/mob_dev/android_deploy_recovery.ex new file mode 100644 index 0000000..2c4ddef --- /dev/null +++ b/lib/mob_dev/android_deploy_recovery.ex @@ -0,0 +1,197 @@ +defmodule MobDev.AndroidDeployRecovery do + @moduledoc false + + @owner_pattern "\\A[A-Za-z0-9_-]{16}\\z" + @bundle_pattern "\\A[A-Za-z][A-Za-z0-9_]*(?:\\.[A-Za-z0-9_]+)+\\z" + @digest_pattern "\\A[0-9a-f]{64}\\z" + @default_minimum_age_seconds 900 + + @type runner :: ([String.t()] -> {String.t(), integer()}) + @type lease :: %{ + required(:bundle_id) => String.t(), + required(:owner) => String.t(), + required(:serials) => [String.t()], + required(:target_digest) => String.t(), + required(:phase) => :native_ready, + required(:state) => :held_success | :retained_ambiguous + } + + @doc false + @spec resume(map(), runner()) :: + {:ok, lease()} + | {:error, :recovery_proof_refused} + | {:error, :recovery_cas_ambiguous, lease()} + @spec resume(map(), runner(), keyword()) :: + {:ok, lease()} + | {:error, :recovery_proof_refused} + | {:error, :recovery_cas_ambiguous, lease()} + def resume(proof, runner, opts \\ []) + + def resume(proof, runner, opts) when is_map(proof) and is_function(runner, 1) do + owner = Keyword.get_lazy(opts, :owner, &new_owner/0) + minimum_age = Keyword.get(opts, :minimum_age_seconds, @default_minimum_age_seconds) + + with {:ok, old_owner} <- validate_proof(proof, owner, minimum_age), + next_record = "1|#{owner}|#{proof.target_digest}|native_ready", + lease = recovered_lease(proof, owner), + {"", 0} <- + invoke(runner, proof.serial, cas_command(proof, old_owner, owner, next_record)), + {^next_record, 0} <- invoke(runner, proof.serial, proof_command(proof.bundle_id)) do + {:ok, lease} + else + {:error, :recovery_proof_refused} = error -> + error + + _changed_or_ambiguous -> + {:error, :recovery_cas_ambiguous, recovered_lease(proof, owner, :retained_ambiguous)} + end + end + + def resume(_proof, _runner, _opts), do: {:error, :recovery_proof_refused} + + defp validate_proof(proof, owner, minimum_age) do + with true <- valid_owner?(owner), + true <- is_integer(minimum_age) and minimum_age >= @default_minimum_age_seconds, + true <- exact_keys?(proof), + true <- proof.version == 1, + true <- valid_bundle?(proof.bundle_id), + true <- valid_serial?(proof.serial), + true <- valid_digest?(proof.target_digest), + true <- proof.target_digest == target_digest(proof.serial), + true <- proof.phase == :native_ready, + true <- is_integer(proof.lease_age_seconds), + true <- proof.lease_age_seconds >= minimum_age, + true <- proof.transport == :usb, + true <- required_proofs?(proof), + {:ok, old_owner} <- parse_record(proof.record, proof.target_digest), + true <- owner != old_owner do + {:ok, old_owner} + else + _invalid -> {:error, :recovery_proof_refused} + end + end + + defp recovered_lease(proof, owner, state \\ :held_success) do + %{ + bundle_id: proof.bundle_id, + owner: owner, + serials: [proof.serial], + target_digest: proof.target_digest, + phase: :native_ready, + state: state + } + end + + defp exact_keys?(proof) do + MapSet.new(Map.keys(proof)) == + MapSet.new([ + :version, + :bundle_id, + :serial, + :target_digest, + :phase, + :record, + :lease_age_seconds, + :transport, + :adb_tcp_disabled?, + :host_deployer_absent?, + :exact_topology?, + :package_identity_matches?, + :apk_signature_verified?, + :apk_digest_matches?, + :runtime_provenance_matches?, + :payload_valid?, + :staging_clear? + ]) + end + + defp required_proofs?(proof) do + Enum.all?( + [ + proof.adb_tcp_disabled?, + proof.host_deployer_absent?, + proof.exact_topology?, + proof.package_identity_matches?, + proof.apk_signature_verified?, + proof.apk_digest_matches?, + proof.runtime_provenance_matches?, + proof.payload_valid?, + proof.staging_clear? + ], + &(&1 == true) + ) + end + + defp parse_record(record, digest) when is_binary(record) do + case String.split(record, "|", parts: 4) do + ["1", owner, ^digest, "native_ready"] -> + if valid_owner?(owner), do: {:ok, owner}, else: :error + + _invalid -> + :error + end + end + + defp parse_record(_record, _digest), do: :error + + defp cas_command(proof, old_owner, new_owner, next_record) do + fixed = "/data/data/#{proof.bundle_id}/files/.mob_native_deploy_lock" + tombstones = "/data/data/#{proof.bundle_id}/files/.mob_native_deploy_releasing_*" + next_file = "#{fixed}/record_next_#{new_owner}" + size = byte_size(proof.record) + + "run-as #{proof.bundle_id} sh -c 'set -e; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "entries=$(find #{fixed} -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f #{fixed}/record; " <> + "size=$(wc -c < #{fixed}/record); test \"$size\" -eq #{size}; " <> + "value=$(cat #{fixed}/record); test \"$value\" = \"#{proof.record}\"; " <> + "test \"${value#1|#{old_owner}|}\" != \"$value\"; " <> + "test ! -e #{next_file}; printf %s \"#{next_record}\" > #{next_file}; " <> + "mv #{next_file} #{fixed}/record'" + end + + defp proof_command(bundle_id) do + fixed = "/data/data/#{bundle_id}/files/.mob_native_deploy_lock" + tombstones = "/data/data/#{bundle_id}/files/.mob_native_deploy_releasing_*" + + "run-as #{bundle_id} sh -c 'set -e; " <> + "for path in #{tombstones}; do test ! -e \"$path\" || exit 1; done; " <> + "entries=$(find #{fixed} -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f #{fixed}/record; " <> + "size=$(wc -c < #{fixed}/record); test \"$size\" -le 128; cat #{fixed}/record'" + end + + defp invoke(runner, serial, command) do + try do + runner.(["-s", serial, "shell", command]) + rescue + _error -> {:invalid, 1} + catch + _kind, _reason -> {:invalid, 1} + end + end + + defp target_digest(serial), + do: :crypto.hash(:sha256, serial) |> Base.encode16(case: :lower) + + defp valid_owner?(owner), do: matches?(owner, @owner_pattern) + defp valid_digest?(digest), do: matches?(digest, @digest_pattern) + defp valid_bundle?(bundle), do: matches?(bundle, @bundle_pattern) + + defp valid_serial?(serial) when is_binary(serial) do + byte_size(serial) in 1..128 and String.valid?(serial) and + Enum.all?(:binary.bin_to_list(serial), fn byte -> + byte in ?0..?9 or byte in ?A..?Z or byte in ?a..?z or byte in ~c".:-_" + end) + end + + defp valid_serial?(_serial), do: false + + defp matches?(value, pattern) when is_binary(value), + do: String.valid?(value) and Regex.match?(Regex.compile!(pattern), value) + + defp matches?(_value, _pattern), do: false + + defp new_owner, do: :crypto.strong_rand_bytes(12) |> Base.url_encode64(padding: false) +end diff --git a/lib/mob_dev/android_deploy_recovery_proof.ex b/lib/mob_dev/android_deploy_recovery_proof.ex new file mode 100644 index 0000000..59220b7 --- /dev/null +++ b/lib/mob_dev/android_deploy_recovery_proof.ex @@ -0,0 +1,615 @@ +defmodule MobDev.AndroidDeployRecoveryProof do + @moduledoc false + + alias MobDev.AndroidDeployRecovery + + @max_output_bytes 8_192 + @record_pattern ~r/\A1\|[A-Za-z0-9_-]{16}\|[0-9a-f]{64}\|native_ready\z/ + @lock_owner_file "owner.term" + @lock_version 1 + @refusal_codes [ + :payload_identity_invalid, + :payload_invalid, + :host_lock_unavailable, + :transport_identity_mismatch, + :lease_record_invalid, + :apk_signature_invalid, + :apk_identity_mismatch, + :runtime_provenance_mismatch, + :staging_not_clear, + :recovery_transition_refused + ] + + @type refusal_code :: + :payload_identity_invalid + | :payload_invalid + | :host_lock_unavailable + | :transport_identity_mismatch + | :lease_record_invalid + | :apk_signature_invalid + | :apk_identity_mismatch + | :runtime_provenance_mismatch + | :staging_not_clear + | :recovery_transition_refused + + @doc false + @spec resume(map(), ([String.t()] -> {binary(), integer()}), keyword()) :: + {:ok, map()} + | {:error, {:recovery_proof_refused, refusal_code()}} + | {:error, :recovery_cas_ambiguous, map()} + def resume(payload_plan, runner, opts \\ []) + + def resume(payload_plan, runner, opts) when is_map(payload_plan) and is_function(runner, 1) do + validator = Keyword.get(opts, :payload_validator, &default_payload_validator/1) + host_lock? = Keyword.get(opts, :host_lock_held?, &host_lock_held?/0) + signature? = Keyword.get(opts, :apk_signature_verified?, &apk_signature_verified?/1) + minimum_age = Keyword.get(opts, :minimum_age_seconds, 900) + runtime_provenance = Keyword.get(opts, :runtime_provenance) + + with {:ok, identity} <- tagged(payload_identity(payload_plan), :payload_identity_invalid), + :ok <- callback_ok(validator, payload_plan, :payload_invalid), + :ok <- callback_true0(host_lock?, :host_lock_unavailable), + {:ok, transport} <- + tagged(exact_usb_transport(identity.serial, runner), :transport_identity_mismatch), + {:ok, record, age} <- tagged(lease_record(identity, runner), :lease_record_invalid), + :ok <- callback_true(signature?, identity.apk_path, :apk_signature_invalid), + :ok <- tagged(installed_apk_matches(identity, runner), :apk_identity_mismatch), + {:ok, runtime_provenance_proven?} <- + tagged( + runtime_provenance_matches(identity, runtime_provenance, runner), + :runtime_provenance_mismatch + ), + :ok <- proven(staging_clear?(identity, runner), :staging_not_clear) do + proof = %{ + version: 1, + bundle_id: identity.bundle_id, + serial: identity.serial, + target_digest: target_digest(identity.serial), + phase: :native_ready, + record: record, + lease_age_seconds: age, + transport: transport, + adb_tcp_disabled?: true, + host_deployer_absent?: true, + exact_topology?: true, + package_identity_matches?: true, + apk_signature_verified?: true, + apk_digest_matches?: true, + runtime_provenance_matches?: runtime_provenance_proven?, + payload_valid?: true, + staging_clear?: true + } + + recovery_opts = + [minimum_age_seconds: minimum_age] + |> maybe_put_owner(opts) + + case AndroidDeployRecovery.resume(proof, runner, recovery_opts) do + {:error, :recovery_proof_refused} -> refusal(:recovery_transition_refused) + result -> result + end + else + {:error, {:recovery_proof_refused, code}} when code in @refusal_codes -> refusal(code) + end + end + + def resume(_payload_plan, _runner, _opts), do: refusal(:payload_identity_invalid) + + defp tagged({:ok, _value} = result, _code), do: result + defp tagged({:ok, _first, _second} = result, _code), do: result + defp tagged(:ok, _code), do: :ok + defp tagged(_unproven, code), do: refusal(code) + + defp proven(true, _code), do: :ok + defp proven(_unproven, code), do: refusal(code) + + defp callback_ok(callback, value, code) do + try do + proven(callback.(value) == :ok, code) + rescue + _error -> refusal(code) + catch + _kind, _reason -> refusal(code) + end + end + + defp callback_true(callback, value, code) do + try do + proven(callback.(value) == true, code) + rescue + _error -> refusal(code) + catch + _kind, _reason -> refusal(code) + end + end + + defp callback_true0(callback, code) do + try do + proven(callback.() == true, code) + rescue + _error -> refusal(code) + catch + _kind, _reason -> refusal(code) + end + end + + defp refusal(code) when code in @refusal_codes, + do: {:error, {:recovery_proof_refused, code}} + + @doc false + @spec with_host_lock(binary(), (-> term())) :: + term() | {:error, :recovery_host_lock_unavailable} + def with_host_lock(bundle_id, operation) + when is_binary(bundle_id) and is_function(operation, 0) do + with true <- Regex.match?(~r/\A[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\z/, bundle_id), + {:ok, lock} <- acquire_host_lock(bundle_id) do + Process.put(:mob_dev_android_recovery_host_lock, lock) + + try do + operation.() + after + Process.delete(:mob_dev_android_recovery_host_lock) + release_host_lock(lock) + end + else + _unavailable_or_held -> {:error, :recovery_host_lock_unavailable} + end + end + + def with_host_lock(_bundle_id, _operation), do: {:error, :recovery_host_lock_unavailable} + + @doc false + @spec __test_only__(:lock_path, binary()) :: binary() + def __test_only__(:lock_path, bundle_id), do: host_lock_path(bundle_id) + + @doc false + @spec __test_only__(:set_release_hook, (-> term())) :: :ok + def __test_only__(:set_release_hook, hook) when is_function(hook, 0) do + Process.put(:mob_dev_android_recovery_release_hook, hook) + :ok + end + + defp payload_identity(%{ + version: 1, + package: bundle_id, + serials: [serial], + apk: %{path: apk_path, sha256: apk_sha256} + }) + when is_binary(bundle_id) and is_binary(serial) and is_binary(apk_path) and + is_binary(apk_sha256) do + if Regex.match?(~r/\A[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\z/, bundle_id) and + byte_size(serial) in 1..128 and Regex.match?(~r/\A[A-Za-z0-9._:-]+\z/, serial) and + File.regular?(apk_path) and Regex.match?(~r/\A[0-9a-f]{64}\z/, apk_sha256) do + {:ok, %{bundle_id: bundle_id, serial: serial, apk_path: apk_path, apk_sha256: apk_sha256}} + else + :error + end + end + + defp payload_identity(_payload_plan), do: :error + + defp exact_usb_transport(serial, runner) do + with {:ok, output} <- invoke(runner, ["devices", "-l"]), + [line] <- + output + |> String.split("\n", trim: true) + |> Enum.reject(&String.starts_with?(&1, "List of devices")), + [^serial, "device" | fields] <- String.split(line), + true <- Enum.any?(fields, &String.starts_with?(&1, "usb:")), + {:ok, tcp_state} <- + invoke(runner, [ + "-s", + serial, + "shell", + "printf '%s|%s' \"$(getprop service.adb.tcp.port)\" \"$(getprop persist.adb.tcp.port)\"" + ]), + true <- tcp_state in ["|", "-1|-1", "0|0", "-1|", "|-1"] do + {:ok, :usb} + else + _invalid_or_network_transport -> :error + end + end + + defp lease_record(identity, runner) do + fixed = "/data/data/#{identity.bundle_id}/files/.mob_native_deploy_lock" + + command = + "run-as #{identity.bundle_id} sh -c 'set -e; " <> + "for path in /data/data/#{identity.bundle_id}/files/.mob_native_deploy_releasing_*; " <> + "do test ! -e \"$path\" || exit 1; done; " <> + "test -d #{fixed}; entries=$(find #{fixed} -mindepth 1 -maxdepth 1 -print | wc -l); " <> + "test \"$entries\" -eq 1; test -f #{fixed}/record; " <> + "cat #{fixed}/record; printf \"\\n%s\\n%s\\n\" \"$(stat -c %Y #{fixed}/record)\" \"$(date +%s)\"'" + + with {:ok, output} <- invoke(runner, ["-s", identity.serial, "shell", command]), + [record, modified, current] <- String.split(output, "\n", trim: true), + true <- Regex.match?(@record_pattern, record), + {modified_at, ""} <- Integer.parse(modified), + {current_at, ""} <- Integer.parse(current), + age when age >= 0 <- current_at - modified_at do + {:ok, record, age} + else + _invalid_or_ambiguous -> :error + end + end + + defp installed_apk_matches(identity, runner) do + with {:ok, path_output} <- + invoke(runner, ["-s", identity.serial, "shell", "pm path #{identity.bundle_id}"]), + ["package:" <> installed_path] <- String.split(path_output, "\n", trim: true), + true <- + Regex.match?(~r{\A/data/app/[A-Za-z0-9._~+/=-]+/base\.apk\z}, installed_path), + {:ok, digest_output} <- + invoke(runner, ["-s", identity.serial, "shell", "sha256sum #{installed_path}"]), + [digest, ^installed_path] <- String.split(digest_output), + true <- digest == identity.apk_sha256 do + :ok + else + _mismatch_or_ambiguity -> :error + end + end + + defp staging_clear?(identity, runner) do + root = "/data/data/#{identity.bundle_id}/files" + + command = + "run-as #{identity.bundle_id} sh -c 'set -e; " <> + "for path in #{root}/.mob_otp_stage_* #{root}/.mob_beams_stage_* " <> + "#{root}/.mob_beams_backup_* #{root}/.mob_beams_activation_lock; " <> + "do test ! -e \"$path\" || exit 1; done'" + + match?({:ok, ""}, invoke(runner, ["-s", identity.serial, "shell", command])) + end + + defp runtime_provenance_matches(identity, provenance, runner) + when is_list(provenance) and provenance != [] and length(provenance) <= 32 do + app_data = "/data/data/#{identity.bundle_id}/files" + + with true <- valid_runtime_provenance?(provenance), + paths <- Enum.map(provenance, &Path.join(app_data, &1.path)), + command <- + "run-as #{identity.bundle_id} sh -c 'set -e; sha256sum #{Enum.join(paths, " ")}'", + {:ok, output} <- invoke(runner, ["-s", identity.serial, "shell", command]), + {:ok, observed} <- parse_runtime_digests(output, paths), + expected <- Map.new(Enum.zip(paths, Enum.map(provenance, & &1.sha256))), + true <- observed == expected do + {:ok, true} + else + _missing_or_changed -> :error + end + end + + defp runtime_provenance_matches(_identity, _provenance, _runner), do: :error + + defp valid_runtime_provenance?(provenance) do + paths = Enum.map(provenance, &Map.get(&1, :path)) + + Enum.uniq(paths) == paths and + Enum.all?(provenance, fn entry -> + is_map(entry) and MapSet.new(Map.keys(entry)) == MapSet.new([:path, :sha256]) and + is_binary(entry.path) and byte_size(entry.path) in 1..1_024 and + Regex.match?(~r{\Aotp/[A-Za-z0-9_./-]+\z}, entry.path) and + not Enum.member?(Path.split(entry.path), "..") and is_binary(entry.sha256) and + Regex.match?(~r/\A[0-9a-f]{64}\z/, entry.sha256) + end) + end + + defp parse_runtime_digests(output, expected_paths) do + parsed = + output + |> String.split("\n", trim: true) + |> Enum.map(fn line -> String.split(line) end) + + with true <- length(parsed) == length(expected_paths), + true <- Enum.all?(parsed, &(length(&1) == 2)), + observed <- Map.new(parsed, fn [digest, path] -> {path, digest} end), + true <- map_size(observed) == length(expected_paths), + true <- Map.keys(observed) |> Enum.sort() == Enum.sort(expected_paths), + true <- + Enum.all?(observed, fn {_path, digest} -> + Regex.match?(~r/\A[0-9a-f]{64}\z/, digest) + end) do + {:ok, observed} + else + _invalid_or_ambiguous -> :error + end + end + + defp invoke(runner, args) do + try do + case runner.(args) do + {output, 0} when is_binary(output) and byte_size(output) <= @max_output_bytes -> + {:ok, String.trim(output)} + + _failure_or_oversize -> + :error + end + rescue + _error -> :error + catch + _kind, _reason -> :error + end + end + + defp default_payload_validator(payload_plan) do + MobDev.NativeBuild.validate_android_recovery_payload(payload_plan) + end + + defp apk_signature_verified?(apk_path) do + with executable when is_binary(executable) <- System.find_executable("apksigner"), + {_output, 0} <- System.cmd(executable, ["verify", "--print-certs", apk_path]) do + true + else + _unavailable_or_invalid -> false + end + end + + defp acquire_host_lock(bundle_id) do + path = host_lock_path(bundle_id) + + with {:ok, owner} <- current_lock_owner() do + publish_or_recover_lock(path, owner, 0) + end + end + + defp host_lock_held? do + case Process.get(:mob_dev_android_recovery_host_lock) do + %{owner_path: owner_path, owner: owner} -> read_lock_owner(owner_path) == {:ok, owner} + _missing -> false + end + end + + defp release_host_lock(lock) do + with {:ok, owner} <- read_lock_owner(lock.owner_path), + true <- owner == lock.owner, + release_path <- + "#{lock.path}.released.#{Base.url_encode64(:crypto.strong_rand_bytes(18), padding: false)}", + :ok <- File.rename(lock.path, release_path) do + run_release_hook() + _ = File.rm(Path.join(release_path, @lock_owner_file)) + _ = File.rmdir(release_path) + :ok + else + _changed_or_missing -> :ok + end + end + + defp run_release_hook do + case Process.get(:mob_dev_android_recovery_release_hook) do + hook when is_function(hook, 0) -> hook.() + _missing -> :ok + end + end + + defp publish_or_recover_lock(_path, _owner, attempts) when attempts > 8, + do: {:error, :ambiguous} + + defp publish_or_recover_lock(path, owner, attempts) do + candidate = + "#{path}.candidate.#{Base.url_encode64(:crypto.strong_rand_bytes(18), padding: false)}" + + owner_path = Path.join(candidate, @lock_owner_file) + + result = + with :ok <- File.mkdir(candidate), + :ok <- + File.write(owner_path, :erlang.term_to_binary(owner), [:write, :exclusive, :binary]) do + case File.rename(candidate, path) do + :ok -> + {:ok, %{path: path, owner_path: Path.join(path, @lock_owner_file), owner: owner}} + + {:error, reason} when reason in [:eexist, :enotempty] -> + case existing_lock_state(path, owner) do + :held -> {:error, :held} + :ambiguous -> {:error, :ambiguous} + :stale -> quarantine_stale_lock(path, owner, attempts) + end + + _failure -> + {:error, :ambiguous} + end + else + _failure -> {:error, :ambiguous} + end + + _ = File.rm(owner_path) + _ = File.rmdir(candidate) + result + end + + defp quarantine_stale_lock(path, owner, attempts) do + quarantine = + "#{path}.stale.#{Base.url_encode64(:crypto.strong_rand_bytes(18), padding: false)}" + + case File.rename(path, quarantine) do + :ok -> + _ = File.rm(Path.join(quarantine, @lock_owner_file)) + _ = File.rmdir(quarantine) + publish_or_recover_lock(path, owner, attempts + 1) + + {:error, :enoent} -> + publish_or_recover_lock(path, owner, attempts + 1) + + _failure -> + {:error, :ambiguous} + end + end + + defp existing_lock_state(path, current_owner) do + case read_lock_owner(Path.join(path, @lock_owner_file)) do + {:ok, owner} -> owner_liveness(owner, current_owner) + {:error, :enoent} -> :ambiguous + {:error, _reason} -> :ambiguous + end + end + + defp owner_liveness(owner, current) do + cond do + owner.boot_id != current.boot_id -> + :stale + + owner.vm_id == current.vm_id -> + local_owner_liveness(owner) + + true -> + os_owner_liveness(owner) + end + end + + defp local_owner_liveness(owner) do + with {:ok, pid} <- local_pid(owner.beam_pid) do + if Process.alive?(pid), do: :held, else: :stale + else + _invalid -> :ambiguous + end + end + + defp os_owner_liveness(owner) do + case os_process_start(owner.os_pid) do + {:ok, start} when start == owner.os_start -> :held + {:ok, _reused_pid} -> :stale + {:error, :not_found} -> :stale + {:error, _reason} -> :ambiguous + end + end + + defp current_lock_owner do + with {:ok, boot_id} <- machine_boot_id(), + {os_pid, ""} <- Integer.parse(System.pid()), + {:ok, os_start} <- os_process_start(os_pid) do + {:ok, + %{ + version: @lock_version, + boot_id: boot_id, + os_pid: os_pid, + os_start: os_start, + vm_id: vm_id(boot_id, os_pid, os_start), + beam_pid: List.to_string(:erlang.pid_to_list(self())) + }} + else + _unavailable -> {:error, :ambiguous} + end + end + + defp read_lock_owner(path) do + try do + with {:ok, bytes} <- File.read(path), + true <- byte_size(bytes) <= 4_096, + owner when is_map(owner) <- :erlang.binary_to_term(bytes, [:safe]), + true <- valid_lock_owner?(owner) do + {:ok, owner} + else + {:error, reason} -> {:error, reason} + _invalid -> {:error, :invalid} + end + catch + _kind, _reason -> {:error, :invalid} + end + end + + defp valid_lock_owner?(owner) do + Map.keys(owner) |> Enum.sort() == + Enum.sort([:version, :boot_id, :os_pid, :os_start, :vm_id, :beam_pid]) and + owner.version == @lock_version and is_binary(owner.boot_id) and + byte_size(owner.boot_id) == 64 and is_integer(owner.os_pid) and owner.os_pid > 0 and + is_binary(owner.os_start) and byte_size(owner.os_start) in 1..256 and + is_binary(owner.vm_id) and byte_size(owner.vm_id) == 64 and + is_binary(owner.beam_pid) and byte_size(owner.beam_pid) in 3..64 + end + + defp local_pid(encoded) do + {:ok, :erlang.list_to_pid(String.to_charlist(encoded))} + catch + _kind, _reason -> {:error, :invalid} + end + + defp machine_boot_id do + case File.read("/proc/sys/kernel/random/boot_id") do + {:ok, value} -> {:ok, fingerprint(value)} + {:error, _reason} -> command_fingerprint("sysctl", ["-n", "kern.boottime"]) + end + end + + defp os_process_start(pid) do + case File.read("/proc/#{pid}/stat") do + {:ok, stat} -> linux_process_start(stat) + {:error, :enoent} -> ps_process_start(pid) + {:error, _reason} -> ps_process_start(pid) + end + end + + defp linux_process_start(stat) do + with close when is_integer(close) <- last_paren_index(stat), + fields <- binary_part(stat, close + 1, byte_size(stat) - close - 1) |> String.split(), + value when is_binary(value) <- Enum.at(fields, 19), + true <- Regex.match?(~r/\A\d+\z/, value) do + {:ok, value} + else + _invalid -> {:error, :ambiguous} + end + end + + defp last_paren_index(stat) do + case :binary.matches(stat, ")") do + [] -> nil + matches -> matches |> List.last() |> elem(0) + end + end + + defp ps_process_start(pid) do + case System.find_executable("ps") do + nil -> + {:error, :ambiguous} + + executable -> + case System.cmd(executable, ["-o", "lstart=", "-p", Integer.to_string(pid)], + stderr_to_stdout: true + ) do + {output, 0} -> + case String.trim(output) do + "" -> {:error, :not_found} + value -> {:ok, fingerprint(value)} + end + + {_output, 1} -> + {:error, :not_found} + + _failure -> + {:error, :ambiguous} + end + end + end + + defp command_fingerprint(command, args) do + case System.find_executable(command) do + nil -> + {:error, :ambiguous} + + executable -> + case System.cmd(executable, args, stderr_to_stdout: true) do + {output, 0} when output != "" -> {:ok, fingerprint(output)} + _failure -> {:error, :ambiguous} + end + end + end + + defp vm_id(boot_id, os_pid, os_start), + do: fingerprint("#{boot_id}\0#{os_pid}\0#{os_start}") + + defp fingerprint(value), do: :crypto.hash(:sha256, value) |> Base.encode16(case: :lower) + + defp host_lock_path(bundle_id) do + digest = :crypto.hash(:sha256, bundle_id) |> Base.url_encode64(padding: false) + Path.join(System.tmp_dir!(), "mob_native_recovery_#{digest}.lock") + end + + defp maybe_put_owner(recovery_opts, opts) do + case Keyword.fetch(opts, :owner) do + {:ok, owner} -> Keyword.put(recovery_opts, :owner, owner) + :error -> recovery_opts + end + end + + defp target_digest(serial), + do: :crypto.hash(:sha256, serial) |> Base.encode16(case: :lower) +end diff --git a/lib/mob_dev/app_file.ex b/lib/mob_dev/app_file.ex new file mode 100644 index 0000000..f7fa339 --- /dev/null +++ b/lib/mob_dev/app_file.ex @@ -0,0 +1,68 @@ +defmodule MobDev.AppFile do + @moduledoc false + + # Helpers for parsing OTP .app files (the Erlang term file every + # compiled app ships in `<app>-<vsn>/ebin/<app>.app`). + + @doc """ + Extract the `vsn` value from an .app file's contents. + + iex> MobDev.AppFile.vsn_from_content(~s|{application,foo,[{vsn,"1.2.3"}]}|) + "1.2.3" + + iex> MobDev.AppFile.vsn_from_content("not an app file") + nil + """ + @spec vsn_from_content(String.t()) :: String.t() | nil + def vsn_from_content(content) when is_binary(content) do + case Regex.run(Regex.compile!("\\{vsn,\"([^\"]+)\"\\}"), content) do + [_, vsn] -> vsn + _ -> nil + end + end + + @doc """ + Read an .app file from disk and return its vsn, or nil if the file + doesn't exist or doesn't parse. + """ + @spec vsn_from_path(Path.t()) :: String.t() | nil + def vsn_from_path(path) do + case File.read(path) do + {:ok, content} -> vsn_from_content(content) + _ -> nil + end + end + + @doc """ + Resolve the installed version of a hex dep. Prefers `mix.lock` (most + reliable, survives across envs); falls back to the compiled .app file + under `_build/dev/lib/<dep>/ebin/<dep>.app`. Returns nil if neither + source resolves. + """ + @spec dep_version(String.t() | atom()) :: String.t() | nil + def dep_version(dep) when is_atom(dep), do: dep_version(Atom.to_string(dep)) + + def dep_version(dep) when is_binary(dep) do + case lock_version(dep) do + nil -> wildcard_app_version(dep) + vsn -> vsn + end + end + + defp lock_version(dep) do + with {:ok, lock} <- File.read("mix.lock"), + pattern <- Regex.compile!("\"#{Regex.escape(dep)}\"[^\"]*\"(\\d+\\.\\d+\\.\\d+)\""), + [_, vsn] <- Regex.run(pattern, lock) do + vsn + else + _ -> nil + end + end + + defp wildcard_app_version(dep) do + case Path.wildcard("_build/dev/lib/#{dep}/ebin/#{dep}.app") do + [app_file | _] -> vsn_from_path(app_file) + _ -> nil + end + end +end diff --git a/lib/mob_dev/bench/device_observer.ex b/lib/mob_dev/bench/device_observer.ex new file mode 100644 index 0000000..91d6658 --- /dev/null +++ b/lib/mob_dev/bench/device_observer.ex @@ -0,0 +1,174 @@ +defmodule MobDev.Bench.DeviceObserver do + @moduledoc """ + Subscribes to `Mob.Device` events on the running app over Erlang + distribution and tracks ground-truth screen/app state for the bench. + + Without this, the bench only knows what *it* asked the device to do + ("we just ran lock_screen, so the screen *should* be off"). With this, + the bench learns from the device what's actually happening + (`{:mob_device, :did_enter_background}`, `{:mob_device, :screen_off}`), + and the probe snapshots reflect reality. + + ## Lifecycle + + observer = DeviceObserver.subscribe(node, categories: [:app, :display]) + ... + observer = DeviceObserver.consume_messages(observer) # call each tick + observer.screen # => :on | :off | :unknown + observer.app # => :running | :background | :suspended | :unknown + observer.events # => list of recent events (most recent first) + + Subscription is best-effort — if the device's BEAM doesn't have + `Mob.Device.subscribe/1` exported (older app build), `subscribe/2` + returns an observer that just passes through the caller's expected + state. + """ + + defstruct [ + :node, + :subscribed?, + :screen, + :app, + :last_event_ts_ms, + :events + ] + + @type screen_state :: :on | :off | :unknown + @type app_state :: :running | :background | :suspended | :unknown + + @type t :: %__MODULE__{ + node: atom() | nil, + subscribed?: boolean(), + screen: screen_state(), + app: app_state(), + last_event_ts_ms: integer() | nil, + events: [{integer(), atom(), term()}] + } + + @max_events_kept 100 + + @doc """ + Try to subscribe the calling process to `Mob.Device` events on `node`. + Returns an observer struct, possibly with `subscribed?: false` if the + device's app doesn't support it (older build). + """ + @spec subscribe(atom() | nil, keyword()) :: t() + def subscribe(nil, _opts) do + %__MODULE__{ + node: nil, + subscribed?: false, + screen: :unknown, + app: :unknown, + last_event_ts_ms: nil, + events: [] + } + end + + def subscribe(node, opts) when is_atom(node) do + categories = Keyword.get(opts, :categories, [:app, :display]) + pid = self() + + subscribed? = + try do + case :rpc.call(node, Mob.Device, :subscribe, [categories], 3_000) do + :ok -> true + {:badrpc, _} -> false + _ -> false + end + rescue + _ -> false + catch + _, _ -> false + end + + # Touching pid intentionally so dialyzer doesn't whine (it's where the + # device sends events). + _ = pid + + %__MODULE__{ + node: node, + subscribed?: subscribed?, + screen: :unknown, + app: :unknown, + last_event_ts_ms: nil, + events: [] + } + end + + @doc """ + Drain the calling process's mailbox of pending Mob.Device messages and + update the observer's tracked state. Returns the updated observer. + + Call this at the top of each poll cycle. Non-blocking — uses `receive` + with `after 0`. + """ + @spec consume_messages(t()) :: t() + def consume_messages(%__MODULE__{} = obs) do + do_consume(obs) + end + + defp do_consume(obs) do + receive do + {:mob_device, event} when is_atom(event) -> + obs + |> apply_event(event, nil) + |> do_consume() + + {:mob_device, event, payload} when is_atom(event) -> + obs + |> apply_event(event, payload) + |> do_consume() + after + 0 -> + obs + end + end + + @doc false + @spec apply_event(t(), atom(), term()) :: t() + def apply_event(obs, event, payload) do + now = System.monotonic_time(:millisecond) + + obs = %{ + obs + | last_event_ts_ms: now, + events: [{now, event, payload} | obs.events] |> Enum.take(@max_events_kept) + } + + case event do + :screen_off -> %{obs | screen: :off} + :screen_on -> %{obs | screen: :on} + :did_enter_background -> %{obs | app: :background} + :will_resign_active -> obs + :will_enter_foreground -> obs + :did_become_active -> %{obs | app: :running} + :will_terminate -> %{obs | app: :suspended} + :memory_warning -> obs + _ -> obs + end + end + + @doc """ + Merge the observer's ground-truth state into a Probe snapshot. If the + observer has authoritative state, prefer it over what the probe inferred; + fall back to the probe's view otherwise. + """ + @spec apply_to_probe(t(), MobDev.Bench.Probe.t()) :: MobDev.Bench.Probe.t() + def apply_to_probe(%__MODULE__{} = obs, %MobDev.Bench.Probe{} = probe) do + screen = + case obs.screen do + :unknown -> probe.screen + observed -> observed + end + + app_process = + case obs.app do + :running -> :app_running + :background -> :app_running + :suspended -> :app_suspended + :unknown -> probe.app_process + end + + %{probe | screen: screen, app_process: app_process} + end +end diff --git a/lib/mob_dev/bench/logger.ex b/lib/mob_dev/bench/logger.ex new file mode 100644 index 0000000..a01a485 --- /dev/null +++ b/lib/mob_dev/bench/logger.ex @@ -0,0 +1,183 @@ +defmodule MobDev.Bench.Logger do + @moduledoc """ + Append-only CSV log of bench probe snapshots. + + Format: + + ts_ms,elapsed_sec,reachability,app_process,usb,screen,battery_pct,reason + + Reading: + - `ts_ms` is monotonic — safe to subtract for intervals + - `elapsed_sec` is seconds since the run started (set on `open/2`) + - `reachability`, `app_process`, `usb`, `screen` are atoms (string-encoded) + - `battery_pct` is integer or empty + - `reason` is a string (CSV-escaped) + + Use `summary/1` after a run to compute % success, gap distribution, + reconnect count, etc. + """ + + alias MobDev.Bench.Probe + + defstruct [:path, :file, :start_ts_ms, :rows] + + @type t :: %__MODULE__{ + path: Path.t(), + file: File.io_device() | nil, + start_ts_ms: integer(), + rows: non_neg_integer() + } + + @header "ts_ms,elapsed_sec,reachability,app_process,usb,screen,battery_pct,reason\n" + + @doc """ + Open a log file for writing. Creates parent dirs as needed. + + Returns a struct that's passed to subsequent `append/2` and `close/1` calls. + """ + @spec open(Path.t(), keyword()) :: t() + def open(path, opts \\ []) do + File.mkdir_p!(Path.dirname(path)) + file = File.open!(path, [:write, :utf8]) + IO.write(file, @header) + + %__MODULE__{ + path: path, + file: file, + start_ts_ms: Keyword.get(opts, :start_ts_ms, System.monotonic_time(:millisecond)), + rows: 0 + } + end + + @doc """ + Append a probe snapshot. Returns the updated logger struct. + """ + @spec append(t(), Probe.t()) :: t() + def append(%__MODULE__{file: file} = log, %Probe{} = probe) when is_pid(file) do + elapsed_sec = + Float.round((probe.ts_ms - log.start_ts_ms) / 1000.0, 2) + + line = + [ + Integer.to_string(probe.ts_ms), + :erlang.float_to_binary(elapsed_sec, decimals: 2), + Atom.to_string(probe.reachability), + Atom.to_string(probe.app_process), + Atom.to_string(probe.usb), + Atom.to_string(probe.screen), + if(probe.battery_pct, do: Integer.to_string(probe.battery_pct), else: ""), + csv_escape(probe.reason) + ] + |> Enum.join(",") + + IO.write(file, line <> "\n") + + %{log | rows: log.rows + 1} + end + + @doc """ + Close the log file. Idempotent. + """ + @spec close(t()) :: t() + def close(%__MODULE__{file: nil} = log), do: log + + def close(%__MODULE__{file: file} = log) when is_pid(file) do + File.close(file) + %{log | file: nil} + end + + @doc """ + Read a CSV file and return a list of probe-like maps. Useful for tests + and for `summary/1`. + + Each row is `%{ts_ms, elapsed_sec, reachability, app_process, usb, + screen, battery_pct, reason}` with atoms restored. + """ + @spec read(Path.t()) :: [map()] + def read(path) do + path + |> File.read!() + |> String.split("\n", trim: true) + |> Enum.drop(1) + |> Enum.map(&parse_row/1) + end + + defp parse_row(line) do + [ts_ms, elapsed_sec, reach, app, usb, screen, battery, reason] = + split_csv(line, 8) + + %{ + ts_ms: String.to_integer(ts_ms), + elapsed_sec: String.to_float(elapsed_sec), + reachability: String.to_atom(reach), + app_process: String.to_atom(app), + usb: String.to_atom(usb), + screen: String.to_atom(screen), + battery_pct: if(battery == "", do: nil, else: String.to_integer(battery)), + reason: csv_unescape(reason) + } + end + + # ── CSV helpers ────────────────────────────────────────────────────────── + # + # Strategy: sanitize reasons on write (replace newlines / commas / + # double-quotes with escape sequences) so each row is exactly one line + # with comma-separated fields. Avoids the complexity of a full CSV + # state-machine parser for a log format that's only consumed by us. + # + # Encoding for `reason`: + # newline → \n (literal two chars) + # tab → \t + # comma → \, + # backslash → \\ + # Other fields are atoms / integers — never need escaping. + + defp csv_escape(nil), do: "" + + defp csv_escape(str) when is_binary(str) do + str + |> String.replace("\\", "\\\\") + |> String.replace(",", "\\,") + |> String.replace("\n", "\\n") + |> String.replace("\r", "\\r") + |> String.replace("\t", "\\t") + end + + defp csv_unescape(""), do: nil + + defp csv_unescape(str) when is_binary(str) do + unescape(str, []) + end + + # Walks the string character by character, recognising backslash-escapes. + defp unescape("", acc), do: acc |> Enum.reverse() |> List.to_string() + defp unescape("\\\\" <> rest, acc), do: unescape(rest, [?\\ | acc]) + defp unescape("\\n" <> rest, acc), do: unescape(rest, [?\n | acc]) + defp unescape("\\r" <> rest, acc), do: unescape(rest, [?\r | acc]) + defp unescape("\\t" <> rest, acc), do: unescape(rest, [?\t | acc]) + defp unescape("\\," <> rest, acc), do: unescape(rest, [?, | acc]) + defp unescape(<<c, rest::binary>>, acc), do: unescape(rest, [c | acc]) + + defp split_csv(line, fields) when is_binary(line) and is_integer(fields) do + do_split(line, fields, [], []) + end + + defp do_split("", _fields, current, acc) do + final = current |> Enum.reverse() |> List.to_string() + Enum.reverse([final | acc]) + end + + defp do_split("\\" <> <<c, rest::binary>>, fields, current, acc) do + # Preserve escape — the unescape pass restores it later. + do_split(rest, fields, [c, ?\\ | current], acc) + end + + defp do_split("," <> rest, fields, current, acc) do + field = current |> Enum.reverse() |> List.to_string() + do_split(rest, fields, [], [field | acc]) + end + + defp do_split(<<c, rest::binary>>, fields, current, acc) do + do_split(rest, fields, [c | current], acc) + end +end diff --git a/lib/mob_dev/bench/preflight.ex b/lib/mob_dev/bench/preflight.ex new file mode 100644 index 0000000..2abb17e --- /dev/null +++ b/lib/mob_dev/bench/preflight.ex @@ -0,0 +1,386 @@ +defmodule MobDev.Bench.Preflight do + @moduledoc """ + Pre-run checklist for the iOS battery bench. + + Walks through the things that have to be right before locking the screen + and running for 30 minutes. Each check returns `:ok | {:error, message}`. + + Goals: + + - Catch the misconfigurations that would invalidate a run *before* the run + starts (saves ~30 min of wasted bench time). + - Tell the user exactly what's wrong and how to fix it. + - Be testable — each check is a pure function returning a tagged result. + + ## Checks performed + + 1. **USB / hardware UDID** — at least one of: USB-connected device + reachable via `idevice_id -l`, or a configured `:hw_udid`. + 2. **App installed** — bundle id appears in `xcrun devicectl device info apps`. + 3. **BEAM reachable** — Node.connect succeeds. + 4. **RPC responsive** — `rpc.call(node, :erlang, :node, [])` returns within + 2 seconds. Distinguishes "BEAM up but suspended" from "fully alive". + 5. **NIF version** — `mob_nif:battery_level/0` is exported. (Indicates the + installed app build is recent enough.) + 6. **Background NIF** — `mob_nif:background_keep_alive/0` is exported. + Required for screen-off bench mode. + + Each check is independent — failure in (3) doesn't skip (5); we run them + all and report a complete picture. + """ + + alias MobDev.Bench.Probe + + @typedoc "Result for a single check." + @type check_result :: {:ok, String.t()} | {:error, String.t()} + + @doc """ + Run all preflight checks and return a list of `{name, result}` tuples in + the order they were run. + + Common options: + - `:platform` — `:ios` (default) or `:android` — selects platform-specific + `hardware` and `app_installed` checks + - `:node` — node atom (required for BEAM checks) + - `:cookie` — cookie atom + - `:bundle_id` — app bundle id + - `:host` — IP/host for EPMD (default: derive from node) + - `:require_keep_alive` — boolean, default true (set false for screen-on bench) + + iOS-specific: + - `:device_id` — devicectl identifier (CoreDevice UUID) + - `:hw_udid` — hardware UDID for USB checks + + Android-specific: + - `:adb_serial` — ADB serial / IP:port for `adb` checks + """ + @spec run(keyword()) :: [{atom(), check_result()}] + def run(opts) do + platform = Keyword.get(opts, :platform, :ios) + + [ + {:hardware, check_hardware(platform, opts)}, + {:app_installed, check_app_installed(platform, opts)}, + {:beam_reachable, check_beam_reachable(opts)}, + {:rpc_responsive, check_rpc_responsive(opts)}, + {:nif_version, check_nif_version(opts)}, + {:keep_alive_nif, + if(opts[:require_keep_alive] != false, + do: check_keep_alive_nif(opts), + else: {:ok, "skipped"} + )} + ] + end + + @doc """ + Returns true if every result is `{:ok, _}`. Stricter overall check than + examining individual results — useful for deciding whether to abort. + """ + @spec all_ok?([{atom(), check_result()}]) :: boolean() + def all_ok?(results) do + Enum.all?(results, fn + {_name, {:ok, _}} -> true + _ -> false + end) + end + + @doc """ + Format the results as a multi-line string with ✓/✗ markers. + """ + @spec pretty([{atom(), check_result()}]) :: String.t() + def pretty(results) do + Enum.map_join(results, "\n", fn + {name, {:ok, msg}} -> " ✓ #{format_name(name)} — #{msg}" + {name, {:error, msg}} -> " ✗ #{format_name(name)} — #{msg}" + end) + end + + defp format_name(name) do + name |> Atom.to_string() |> String.replace("_", " ") + end + + # ── Individual checks ──────────────────────────────────────────────────── + + # ── Hardware check (platform-dispatched) ────────────────────────────── + + @doc false + @spec check_hardware(:ios | :android, keyword()) :: {:ok, String.t()} | {:error, String.t()} + def check_hardware(platform, opts) do + case platform do + :ios -> check_hardware_ios(opts) + :android -> check_hardware_android(opts) + _ -> {:error, "unknown platform: #{inspect(platform)}"} + end + end + + # Backward-compat — old single-arg version defaults to iOS. + @doc false + @spec check_hardware(keyword()) :: {:ok, String.t()} | {:error, String.t()} + def check_hardware(opts), do: check_hardware(:ios, opts) + + defp check_hardware_ios(opts) do + cond do + is_binary(opts[:hw_udid]) -> + {:ok, "hardware UDID provided: #{opts[:hw_udid]}"} + + System.find_executable("idevice_id") -> + case System.cmd("idevice_id", ["-l"], stderr_to_stdout: true) do + {out, 0} -> + udids = + out + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + case udids do + [] -> {:error, "no USB device detected (idevice_id -l returned empty)"} + [_one] -> {:ok, "USB device connected"} + many -> {:ok, "#{length(many)} USB devices connected"} + end + + _ -> + {:error, "idevice_id failed — is the device trusted?"} + end + + true -> + {:error, + "no hw_udid given and idevice_id not installed " <> + "(brew install libimobiledevice)"} + end + end + + defp check_hardware_android(opts) do + serial = opts[:adb_serial] + + cond do + is_nil(System.find_executable("adb")) -> + {:error, "adb not found (install Android platform-tools)"} + + not is_binary(serial) -> + # Try `adb devices` to see if any device is reachable. + case System.cmd("adb", ["devices"], stderr_to_stdout: true) do + {out, 0} -> + devices = + out + |> String.split("\n") + |> Enum.drop(1) + |> Enum.flat_map(fn line -> + case String.split(line) do + [s, "device" | _] -> [s] + _ -> [] + end + end) + + case devices do + [] -> {:error, "no Android device detected (adb devices returned empty)"} + [single] -> {:ok, "device connected: #{single}"} + many -> {:ok, "#{length(many)} devices connected"} + end + + _ -> + {:error, "adb devices failed"} + end + + true -> + case System.cmd("adb", ["-s", serial, "get-state"], stderr_to_stdout: true) do + {out, 0} -> + state = String.trim(out) + + if state == "device", + do: {:ok, "adb device #{serial} (#{state})"}, + else: {:error, "adb device #{serial} state: #{state}"} + + {out, _} -> + {:error, "adb get-state failed: #{String.trim(out)}"} + end + end + end + + # ── App-installed check (platform-dispatched) ───────────────────────── + + @doc false + @spec check_app_installed(:ios | :android, keyword()) :: + {:ok, String.t()} | {:error, String.t()} + def check_app_installed(platform, opts) do + case platform do + :ios -> check_app_installed_ios(opts) + :android -> check_app_installed_android(opts) + _ -> {:error, "unknown platform: #{inspect(platform)}"} + end + end + + @doc false + @spec check_app_installed(keyword()) :: {:ok, String.t()} | {:error, String.t()} + def check_app_installed(opts), do: check_app_installed(:ios, opts) + + defp check_app_installed_ios(opts) do + bundle = opts[:bundle_id] + device = opts[:device_id] + + cond do + not is_binary(bundle) -> + {:error, "bundle_id not configured"} + + not is_binary(device) -> + # Without a device id, we can't query devicectl. Treat as informational. + {:ok, "skipped (no device_id provided to verify)"} + + is_nil(System.find_executable("xcrun")) -> + {:ok, "skipped (xcrun unavailable)"} + + true -> + # devicectl prints a noisy "No provider was found" provisioning + # warning to stderr and exits non-zero even when the query + # succeeded. Don't trust the exit code — search the combined + # output for the bundle id and treat that as authoritative. + {out, _exit} = + System.cmd( + "xcrun", + [ + "devicectl", + "device", + "info", + "apps", + "--device", + device, + "--bundle-identifier", + bundle + ], + stderr_to_stdout: true + ) + + cond do + String.contains?(out, bundle) -> + {:ok, "#{bundle} found on device"} + + String.contains?(out, "ContainerLookupError") or + String.contains?(out, "not installed") -> + {:error, "#{bundle} not installed — run `mix mob.deploy --native`"} + + true -> + # Couldn't verify via devicectl — but the other checks (BEAM + # reachability, NIF exports) will catch real "app not installed" + # cases anyway. Don't fail the run on devicectl noise. + {:ok, "couldn't verify via devicectl (BEAM reachability is authoritative)"} + end + end + end + + defp check_app_installed_android(opts) do + bundle = opts[:bundle_id] + serial = opts[:adb_serial] + + cond do + not is_binary(bundle) -> + {:error, "bundle_id not configured"} + + is_nil(System.find_executable("adb")) -> + {:ok, "skipped (adb unavailable)"} + + not is_binary(serial) -> + {:ok, "skipped (no adb_serial provided to verify)"} + + true -> + case System.cmd("adb", ["-s", serial, "shell", "pm", "list", "packages", bundle], + stderr_to_stdout: true + ) do + {out, 0} -> + # `pm list packages com.example.foo` prints "package:com.example.foo" + # if installed; empty if not. + if String.contains?(out, "package:#{bundle}") do + {:ok, "#{bundle} installed on device"} + else + {:error, "#{bundle} not installed — run `mix mob.deploy --native`"} + end + + {out, _} -> + {:error, "adb pm list failed: #{String.trim(out)}"} + end + end + end + + @doc false + @spec check_beam_reachable(keyword()) :: {:ok, String.t()} | {:error, String.t()} + def check_beam_reachable(opts) do + node = opts[:node] + host = opts[:host] || derive_host(node) + + cond do + not is_atom(node) or node == nil -> + {:error, "no node provided"} + + not is_binary(host) -> + {:error, "could not derive host from node #{inspect(node)}"} + + Probe.tcp_open?(host, 4369, 1500) -> + {:ok, "EPMD reachable at #{host}:4369"} + + true -> + {:error, "EPMD not reachable at #{host}:4369 — phone offline or BEAM dead"} + end + end + + @doc false + @spec check_rpc_responsive(keyword()) :: {:ok, String.t()} | {:error, String.t()} + def check_rpc_responsive(opts) do + node = opts[:node] + cookie = opts[:cookie] + + cond do + not is_atom(node) or node == nil -> + {:error, "no node provided"} + + true -> + if is_atom(cookie), do: Node.set_cookie(node, cookie) + + case Node.connect(node) do + true -> + if Probe.rpc_responsive?(node, 2_000) do + {:ok, "RPC ping returned in <2 s"} + else + {:error, "RPC ping timed out (BEAM may be suspended)"} + end + + false -> + {:error, "Node.connect/1 returned false — wrong cookie or dist down"} + + :ignored -> + {:error, "Node.connect/1 returned :ignored — local node not started"} + end + end + end + + @doc false + @spec check_nif_version(keyword()) :: {:ok, String.t()} | {:error, String.t()} + def check_nif_version(opts) do + check_nif_export(opts, :battery_level, "battery_level/0") + end + + @doc false + @spec check_keep_alive_nif(keyword()) :: {:ok, String.t()} | {:error, String.t()} + def check_keep_alive_nif(opts) do + check_nif_export(opts, :background_keep_alive, "background_keep_alive/0") + end + + defp check_nif_export(opts, fun_name, label) do + node = opts[:node] + + case :rpc.call(node, :mob_nif, :module_info, [:exports], 2_000) do + list when is_list(list) -> + if Enum.any?(list, fn {f, _arity} -> f == fun_name end) do + {:ok, "#{label} exported"} + else + {:error, + "#{label} not exported on device — installed app is older than mob_dev expects"} + end + + {:badrpc, reason} -> + {:error, "could not query exports: #{inspect(reason)}"} + + other -> + {:error, "unexpected exports result: #{inspect(other)}"} + end + end + + defp derive_host(node), do: MobDev.NodeUtil.host_from_node(node) +end diff --git a/lib/mob_dev/bench/probe.ex b/lib/mob_dev/bench/probe.ex new file mode 100644 index 0000000..c798013 --- /dev/null +++ b/lib/mob_dev/bench/probe.ex @@ -0,0 +1,427 @@ +defmodule MobDev.Bench.Probe do + @moduledoc """ + Multi-source state probe for the battery bench. + + When a battery read fails, we want to know *why*: is the BEAM dead, just + unreachable, suspended in the background? The probe walks a short pipeline + of network checks and returns a typed state. The bench uses this to + produce informative trace lines and decide whether to attempt reconnection. + + ## State derivation + + epmd_reachable? Node.connect rpc_ping → state + ────────────── ───────────── ────────────── ───── + false — — :unreachable + true false — :alive_epmd_only + true true timeout :alive_dist_only + true true ok :alive_rpc + + When a `hw_udid` is provided and `ideviceinfo` is available, we additionally + probe USB battery readiness: + + ideviceinfo battery → :usb_ok | :usb_failed | :no_usb + + And app-process liveness via `xcrun devicectl device info processes`: + + app_pid_alive? → :app_running | :app_dead | :app_unknown + + All probes are independent and failure-tolerant — any single probe failing + doesn't crash the whole snapshot. + """ + + defstruct [ + :ts_ms, + :reachability, + :app_process, + :usb, + :screen, + :battery_pct, + :reason + ] + + @typedoc """ + - `:alive_rpc` — RPC just succeeded; BEAM is fully responsive + - `:alive_dist_only` — Node.connect works, RPC times out (suspended?) + - `:alive_epmd_only` — TCP to EPMD works, dist refused (BEAM up but no dist) + - `:unreachable` — Phone offline or BEAM dead + """ + @type reachability :: :alive_rpc | :alive_dist_only | :alive_epmd_only | :unreachable + + @typedoc "Foreground/background/dead, or unknown if we can't tell." + @type app_process :: :app_running | :app_suspended | :app_dead | :app_unknown + + @typedoc "USB battery readiness via ideviceinfo." + @type usb :: :usb_ok | :usb_failed | :no_usb + + @typedoc "Screen state, where derivable. `:unknown` is the honest default." + @type screen :: :on | :off | :unknown + + @type t :: %__MODULE__{ + ts_ms: integer(), + reachability: reachability(), + app_process: app_process(), + usb: usb(), + screen: screen(), + battery_pct: integer() | nil, + reason: String.t() | nil + } + + @doc """ + Run the full probe and return a populated state struct. + + Common options: + - `:platform` — `:ios` (default) or `:android` — selects which USB / app- + process probes to run + - `:node` — node atom to probe (required for dist/RPC checks) + - `:host` — IP/host for EPMD probe (defaults to host portion of `:node`) + - `:rpc_timeout_ms` — defaults to 2000 + - `:tcp_timeout_ms` — defaults to 1000 + - `:expected_screen` — `:on | :off | :unknown` — what we *believe* the + screen state to be (e.g. after `lock_screen`). Recorded with the snapshot. + + iOS-specific: + - `:hw_udid` — hardware UDID for `ideviceinfo` USB probe + - `:device_id` — CoreDevice UUID for `devicectl` process check + - `:app_pid` — pid launched at bench start; checked against `device_id` + + Android-specific: + - `:adb_serial` — ADB serial / IP:port for `adb shell` battery + process probes + - `:bundle_id` — app bundle identifier for the process-running check + """ + @spec snapshot(keyword()) :: t() + def snapshot(opts \\ []) do + ts = System.monotonic_time(:millisecond) + platform = Keyword.get(opts, :platform, :ios) + node = Keyword.get(opts, :node) + host = Keyword.get(opts, :host) || derive_host(node) + + rpc_timeout = Keyword.get(opts, :rpc_timeout_ms, 2_000) + tcp_timeout = Keyword.get(opts, :tcp_timeout_ms, 1_000) + + reachability = probe_reachability(node, host, rpc_timeout, tcp_timeout) + {rpc_pct, rpc_reason} = probe_rpc_battery(reachability, node, rpc_timeout) + {usb, usb_pct, usb_reason} = probe_usb(platform, opts) + app_process = probe_app_process(platform, opts, reachability) + + screen = derive_screen(opts[:expected_screen], reachability, usb) + + # Battery: prefer USB (more reliable), fall back to RPC, else nil. + # Only surface a reason when we couldn't get a battery reading at all — + # otherwise the CSV's reason column gets flooded with fallback noise + # (e.g. "ideviceinfo: device not found" when the user unplugged USB + # for the bench, even though RPC succeeded right after). + {battery, reason} = + cond do + is_integer(usb_pct) -> {usb_pct, nil} + is_integer(rpc_pct) -> {rpc_pct, nil} + rpc_reason -> {nil, rpc_reason} + usb_reason -> {nil, usb_reason} + true -> {nil, nil} + end + + %__MODULE__{ + ts_ms: ts, + reachability: reachability, + app_process: app_process, + usb: usb, + screen: screen, + battery_pct: battery, + reason: reason + } + end + + # ── Reachability pipeline ──────────────────────────────────────────────── + + @doc false + @spec probe_reachability(node() | nil, String.t() | nil, timeout(), timeout()) :: + reachability() + def probe_reachability(nil, _host, _rpc_timeout, _tcp_timeout), do: :unreachable + + def probe_reachability(_node, nil, _rpc_timeout, _tcp_timeout), do: :unreachable + + def probe_reachability(node, host, rpc_timeout, tcp_timeout) do + cond do + not tcp_open?(host, 4369, tcp_timeout) -> + :unreachable + + not dist_connected?(node) -> + :alive_epmd_only + + not rpc_responsive?(node, rpc_timeout) -> + :alive_dist_only + + true -> + :alive_rpc + end + end + + @doc false + @spec tcp_open?(String.t() | term(), :inet.port_number(), timeout()) :: boolean() + def tcp_open?(host, port, timeout_ms) when is_binary(host) do + case :gen_tcp.connect(String.to_charlist(host), port, [:binary, active: false], timeout_ms) do + {:ok, sock} -> + :gen_tcp.close(sock) + true + + {:error, _} -> + false + end + end + + def tcp_open?(_, _, _), do: false + + @doc false + @spec dist_connected?(node()) :: boolean() + def dist_connected?(node) when is_atom(node) do + case Node.list() do + list when is_list(list) -> node in list or node == Node.self() + _ -> false + end + end + + @doc false + @spec rpc_responsive?(node(), timeout()) :: boolean() + def rpc_responsive?(node, timeout_ms) when is_atom(node) do + case :rpc.call(node, :erlang, :node, [], timeout_ms) do + n when is_atom(n) and n != :nonode@nohost -> true + _ -> false + end + end + + # ── Battery via RPC ────────────────────────────────────────────────────── + + defp probe_rpc_battery(:alive_rpc, node, timeout_ms) do + case :rpc.call(node, :mob_nif, :battery_level, [], timeout_ms) do + n when is_integer(n) and n >= 0 and n <= 100 -> + {n, nil} + + n when is_integer(n) -> + {nil, "rpc battery: out-of-range #{n}"} + + {:badrpc, reason} -> + {nil, "rpc battery: badrpc #{inspect(reason)}"} + + other -> + {nil, "rpc battery: unexpected #{inspect(other)}"} + end + end + + defp probe_rpc_battery(_, _, _), do: {nil, nil} + + # ── USB probe ──────────────────────────────────────────────────────────── + + defp probe_usb(:ios, opts), do: probe_usb_ios(opts[:hw_udid]) + defp probe_usb(:android, opts), do: probe_usb_android(opts[:adb_serial]) + defp probe_usb(_, _), do: {:no_usb, nil, nil} + + defp probe_usb_ios(nil), do: {:no_usb, nil, nil} + + defp probe_usb_ios(hw_udid) when is_binary(hw_udid) do + case System.find_executable("ideviceinfo") do + nil -> + {:no_usb, nil, nil} + + _path -> + run_ideviceinfo(hw_udid) + end + end + + defp run_ideviceinfo(hw_udid) do + case System.cmd( + "ideviceinfo", + ["-u", hw_udid, "-q", "com.apple.mobile.battery", "-k", "BatteryCurrentCapacity"], + stderr_to_stdout: true + ) do + {out, 0} -> + case Integer.parse(String.trim(out)) do + {n, _} when n in 0..100 -> {:usb_ok, n, nil} + _ -> {:usb_failed, nil, "ideviceinfo: empty/unparsed output"} + end + + {out, _} -> + {:usb_failed, nil, "ideviceinfo: " <> String.trim(out)} + end + rescue + e -> {:usb_failed, nil, "ideviceinfo raised: #{Exception.message(e)}"} + end + + # Android: parse `adb shell dumpsys battery` for the level field. We use + # battery percentage (not the µAh charge counter) so the snapshot field + # is consistent across platforms. + defp probe_usb_android(nil), do: {:no_usb, nil, nil} + + defp probe_usb_android(serial) when is_binary(serial) do + case System.find_executable("adb") do + nil -> + {:no_usb, nil, nil} + + _ -> + run_adb_battery(serial) + end + end + + defp run_adb_battery(serial) do + case System.cmd("adb", ["-s", serial, "shell", "dumpsys", "battery"], stderr_to_stdout: true) do + {out, 0} -> + case Regex.run(Regex.compile!("^\\s*level:\\s*(\\d+)", "m"), out) do + [_, n_str] -> + case Integer.parse(n_str) do + {n, _} when n in 0..100 -> {:usb_ok, n, nil} + _ -> {:usb_failed, nil, "adb battery: bad level value #{n_str}"} + end + + nil -> + {:usb_failed, nil, "adb battery: no level field in dumpsys output"} + end + + {out, _} -> + {:usb_failed, nil, "adb battery: " <> String.trim(out)} + end + rescue + e -> {:usb_failed, nil, "adb raised: #{Exception.message(e)}"} + end + + # ── App process probe ──────────────────────────────────────────────────── + + # If RPC just succeeded, the app is definitely running (foreground or + # background — RPC works in both). If RPC failed but EPMD is open, the + # BEAM is alive but might be suspended. If both fail, the app might be + # dead OR the phone is offline; the platform-specific probe disambiguates. + + defp probe_app_process(_platform, _opts, :alive_rpc), do: :app_running + defp probe_app_process(_platform, _opts, :alive_dist_only), do: :app_suspended + + defp probe_app_process(:ios, opts, _reachability), + do: probe_app_process_ios(opts[:device_id], opts[:app_pid]) + + defp probe_app_process(:android, opts, _reachability), + do: probe_app_process_android(opts[:adb_serial], opts[:bundle_id]) + + defp probe_app_process(_, _, _), do: :app_unknown + + defp probe_app_process_ios(nil, _pid), do: :app_unknown + defp probe_app_process_ios(_device_id, nil), do: :app_unknown + + defp probe_app_process_ios(device_id, pid) when is_integer(pid) do + case System.find_executable("xcrun") do + nil -> + :app_unknown + + _ -> + case System.cmd( + "xcrun", + [ + "devicectl", + "device", + "info", + "processes", + "--device", + device_id, + "--pid", + to_string(pid) + ], + stderr_to_stdout: true + ) do + {out, 0} -> + if String.contains?(out, to_string(pid)), do: :app_running, else: :app_dead + + _ -> + :app_dead + end + end + rescue + _ -> :app_unknown + end + + # Android: `adb shell pidof <pkg>` returns the pid (or empty if not running). + # `pidof` is available on Android 6+ which is well below any device Mob + # currently targets. + defp probe_app_process_android(nil, _bundle), do: :app_unknown + defp probe_app_process_android(_serial, nil), do: :app_unknown + + defp probe_app_process_android(serial, bundle) when is_binary(serial) and is_binary(bundle) do + case System.find_executable("adb") do + nil -> + :app_unknown + + _ -> + case System.cmd("adb", ["-s", serial, "shell", "pidof", bundle], stderr_to_stdout: true) do + {out, 0} -> + case String.trim(out) do + "" -> + :app_dead + + pid_str -> + case Integer.parse(pid_str) do + {n, _} when n > 0 -> :app_running + _ -> :app_dead + end + end + + _ -> + :app_dead + end + end + rescue + _ -> :app_unknown + end + + # ── Screen state ───────────────────────────────────────────────────────── + + # We don't have a clean USB-side iOS API for "is the screen on?". Best + # signals available: + # - `:expected_screen` — what we believe based on our own lock action + # - If RPC works during a screen-off bench, the BEAM is awake even though + # the screen is locked — so screen state is independent of RPC state + # + # We honor the caller's expectation. Future: subscribe to Mob.Device's + # protected_data_will_become_unavailable / available events via RPC for + # ground-truth screen state. + + defp derive_screen(:on, _reachability, _usb), do: :on + defp derive_screen(:off, _reachability, _usb), do: :off + defp derive_screen(_, _, _), do: :unknown + + # ── Helpers ────────────────────────────────────────────────────────────── + + defp derive_host(node), do: MobDev.NodeUtil.host_from_node(node) + + @doc """ + Format the probe result as a one-line trace fragment. + + iex> probe = %MobDev.Bench.Probe{ + ...> ts_ms: 0, reachability: :alive_rpc, app_process: :app_running, + ...> usb: :no_usb, screen: :off, battery_pct: 87, reason: nil + ...> } + iex> MobDev.Bench.Probe.format(probe) + "screen:off app:running rpc:ok battery:87%" + """ + @spec format(t()) :: String.t() + def format(%__MODULE__{} = p) do + parts = [ + "screen:#{abbreviate_screen(p.screen)}", + "app:#{abbreviate_app(p.app_process)}", + reachability_short(p.reachability), + battery_short(p.battery_pct) + ] + + Enum.reject(parts, &(&1 == "")) |> Enum.join(" ") + end + + defp abbreviate_screen(:on), do: "on" + defp abbreviate_screen(:off), do: "off" + defp abbreviate_screen(_), do: "?" + + defp abbreviate_app(:app_running), do: "running" + defp abbreviate_app(:app_suspended), do: "suspended" + defp abbreviate_app(:app_dead), do: "dead" + defp abbreviate_app(_), do: "?" + + defp reachability_short(:alive_rpc), do: "rpc:ok" + defp reachability_short(:alive_dist_only), do: "rpc:timeout" + defp reachability_short(:alive_epmd_only), do: "rpc:no-dist" + defp reachability_short(:unreachable), do: "rpc:unreachable" + + defp battery_short(nil), do: "" + defp battery_short(pct), do: "battery:#{pct}%" +end diff --git a/lib/mob_dev/bench/reconnector.ex b/lib/mob_dev/bench/reconnector.ex new file mode 100644 index 0000000..6131dc3 --- /dev/null +++ b/lib/mob_dev/bench/reconnector.ex @@ -0,0 +1,123 @@ +defmodule MobDev.Bench.Reconnector do + @moduledoc """ + Auto-reconnect logic for the bench's BEAM dist connection. + + When a probe says the dist connection has dropped (`:alive_epmd_only` or + `:alive_dist_only` after an RPC timeout), we want to attempt to reconnect + automatically rather than leaving the bench in a stuck state for the rest + of the run. + + This module is *pure logic* — no GenServer, no timers. The bench polling + loop calls `tick/2` once per cycle, which decides whether to attempt a + reconnect based on the current reachability and elapsed time since the + last attempt. This keeps the reconnect logic testable and lets the + caller control the cadence. + + ## Backoff schedule (defaults) + + 1st attempt: immediate + 2nd attempt: 2 s after 1st + 3rd attempt: 4 s after 2nd + 4th attempt: 8 s after 3rd + Subsequent: 30 s cap + + Reset to immediate on a successful reconnect. + """ + + alias MobDev.Bench.Probe + + defstruct [ + :node, + :cookie, + :attempts, + :last_attempt_ms, + :total_reconnects, + :max_backoff_ms + ] + + @type t :: %__MODULE__{ + node: atom(), + cookie: atom(), + attempts: non_neg_integer(), + last_attempt_ms: integer() | nil, + total_reconnects: non_neg_integer(), + max_backoff_ms: pos_integer() + } + + @default_backoffs [0, 2_000, 4_000, 8_000, 16_000] + @default_max_backoff_ms 30_000 + + @doc """ + Initialise a reconnector for `node` with cookie. Optional `:max_backoff_ms`. + """ + @spec new(atom(), atom(), keyword()) :: t() + def new(node, cookie, opts \\ []) do + %__MODULE__{ + node: node, + cookie: cookie, + attempts: 0, + last_attempt_ms: nil, + total_reconnects: 0, + max_backoff_ms: Keyword.get(opts, :max_backoff_ms, @default_max_backoff_ms) + } + end + + @doc """ + Decide whether the caller should attempt a reconnect right now, given the + current probe state and current time. Returns `{action, updated_reconnector}`. + + Actions: + - `:no_action` — connection is healthy or it's not time yet + - `:attempt` — caller should try `Node.connect(reconnector.node)` now + + After a successful reconnect, call `record_success/1` to reset the backoff. + """ + @spec tick(t(), Probe.t() | atom(), integer()) :: {:no_action | :attempt, t()} + def tick(reconnector, %Probe{} = probe, now_ms) do + tick(reconnector, probe.reachability, now_ms) + end + + def tick(%__MODULE__{} = r, reachability, now_ms) when is_atom(reachability) do + cond do + # Connection is healthy — reset attempts. + reachability == :alive_rpc -> + {:no_action, %{r | attempts: 0}} + + # Not enough time has passed since the last attempt. + r.last_attempt_ms != nil and now_ms - r.last_attempt_ms < current_backoff_ms(r) -> + {:no_action, r} + + # Time to try. + true -> + {:attempt, %{r | attempts: r.attempts + 1, last_attempt_ms: now_ms}} + end + end + + @doc """ + Record that the most recent reconnect attempt succeeded — resets the + backoff counter and bumps the total_reconnects counter. + """ + @spec record_success(t()) :: t() + def record_success(%__MODULE__{} = r) do + %{r | attempts: 0, total_reconnects: r.total_reconnects + 1} + end + + @doc """ + Returns the backoff (in ms) that applies to the *next* attempt. + + iex> r = MobDev.Bench.Reconnector.new(:node@host, :secret) + iex> MobDev.Bench.Reconnector.current_backoff_ms(r) + 0 + + iex> r = %{MobDev.Bench.Reconnector.new(:node@host, :secret) | attempts: 3} + iex> MobDev.Bench.Reconnector.current_backoff_ms(r) + 8000 + """ + @spec current_backoff_ms(t()) :: non_neg_integer() + def current_backoff_ms(%__MODULE__{attempts: attempts, max_backoff_ms: max}) do + case Enum.at(@default_backoffs, attempts) do + nil -> max + ms -> min(ms, max) + end + end +end diff --git a/lib/mob_dev/bench/summary.ex b/lib/mob_dev/bench/summary.ex new file mode 100644 index 0000000..0233c26 --- /dev/null +++ b/lib/mob_dev/bench/summary.ex @@ -0,0 +1,256 @@ +defmodule MobDev.Bench.Summary do + @moduledoc """ + Post-run analysis of a bench CSV log. + + Reads a `MobDev.Bench.Logger` CSV and produces a summary map with + metrics that tell you whether the bench measurement is trustworthy: + + - `total_samples` — how many polls completed + - `successful_samples` — those that produced a battery reading + - `success_rate` — fraction (0.0..1.0) + - `reconnect_count` — number of times we transitioned :unreachable / :alive_*_only → :alive_rpc + - `longest_gap_sec` — longest interval between successful battery reads + - `state_durations` — total time (sec) spent in each reachability state + - `screen_off_duration_sec` — time the screen was off + - `screen_on_duration_sec` — time the screen was on + - `start_battery`, `end_battery`, `drain_pct` — first and last successful reads + - `effective_rate_pct_per_hour` — drain extrapolated to per-hour + """ + + alias MobDev.Bench.Logger + + @type metrics :: %{ + total_samples: non_neg_integer(), + successful_samples: non_neg_integer(), + success_rate: float(), + reconnect_count: non_neg_integer(), + longest_gap_sec: float(), + state_durations: %{atom() => float()}, + screen_off_duration_sec: float(), + screen_on_duration_sec: float(), + start_battery: integer() | nil, + end_battery: integer() | nil, + drain_pct: integer() | nil, + effective_rate_pct_per_hour: float() | nil, + taint_warnings: [String.t()] + } + + @doc """ + Compute summary metrics for a bench CSV. + """ + @spec from_csv(Path.t()) :: metrics() + def from_csv(path) do + rows = Logger.read(path) + from_rows(rows) + end + + @doc """ + Compute summary metrics from already-parsed rows. Useful for tests. + """ + @spec from_rows([map()]) :: metrics() + def from_rows([]), do: empty_metrics() + + def from_rows(rows) when is_list(rows) do + successful = Enum.filter(rows, &is_integer(&1.battery_pct)) + + %{ + total_samples: length(rows), + successful_samples: length(successful), + success_rate: length(successful) / length(rows), + reconnect_count: count_reconnects(rows), + longest_gap_sec: longest_gap_sec(successful), + state_durations: state_durations(rows), + screen_off_duration_sec: screen_duration(rows, :off), + screen_on_duration_sec: screen_duration(rows, :on), + start_battery: start_battery(successful), + end_battery: end_battery(successful), + drain_pct: drain(successful), + effective_rate_pct_per_hour: effective_rate(successful), + taint_warnings: taint_warnings(rows) + } + end + + defp empty_metrics do + %{ + total_samples: 0, + successful_samples: 0, + success_rate: 0.0, + reconnect_count: 0, + longest_gap_sec: 0.0, + state_durations: %{}, + screen_off_duration_sec: 0.0, + screen_on_duration_sec: 0.0, + start_battery: nil, + end_battery: nil, + drain_pct: nil, + effective_rate_pct_per_hour: nil, + taint_warnings: [] + } + end + + # ── Reconnect counting ─────────────────────────────────────────────────── + + defp count_reconnects(rows) do + rows + |> Enum.map(& &1.reachability) + |> Enum.chunk_every(2, 1, :discard) + |> Enum.count(fn + [prev, :alive_rpc] when prev != :alive_rpc -> true + _ -> false + end) + end + + # ── Gap analysis ───────────────────────────────────────────────────────── + + defp longest_gap_sec([]), do: 0.0 + defp longest_gap_sec([_]), do: 0.0 + + defp longest_gap_sec(rows) do + rows + |> Enum.map(& &1.elapsed_sec) + |> Enum.chunk_every(2, 1, :discard) + |> Enum.map(fn [a, b] -> b - a end) + |> Enum.max(fn -> 0.0 end) + end + + # ── State duration breakdown ───────────────────────────────────────────── + + # from_rows/1 short-circuits the empty-list case to empty_metrics/0, so + # state_durations is only called with non-empty input. Same for + # screen_duration below. The dedicated [] clauses were dead and Elixir + # 1.20's type checker started flagging them. + defp state_durations(rows) do + # For each row, the duration is the gap until the next row (or 0 for the + # last row). We attribute that duration to the row's reachability state. + rows + |> Enum.chunk_every(2, 1, :discard) + |> Enum.reduce(%{}, fn [a, b], acc -> + duration = b.elapsed_sec - a.elapsed_sec + Map.update(acc, a.reachability, duration, &(&1 + duration)) + end) + |> Map.new(fn {k, v} -> {k, Float.round(v, 2)} end) + end + + defp screen_duration(rows, target_state) do + rows + |> Enum.chunk_every(2, 1, :discard) + |> Enum.reduce(0.0, fn [a, b], acc -> + if a.screen == target_state do + acc + (b.elapsed_sec - a.elapsed_sec) + else + acc + end + end) + |> Float.round(2) + end + + # ── Battery extraction ─────────────────────────────────────────────────── + + defp start_battery([first | _]), do: first.battery_pct + defp start_battery([]), do: nil + + defp end_battery([]), do: nil + defp end_battery(rows), do: List.last(rows).battery_pct + + defp drain([]), do: nil + + defp drain([_]), do: 0 + + defp drain(rows) do + s = start_battery(rows) + e = end_battery(rows) + if s && e, do: s - e, else: nil + end + + defp effective_rate([]), do: nil + defp effective_rate([_]), do: nil + + defp effective_rate(rows) do + case {drain(rows), List.last(rows).elapsed_sec - List.first(rows).elapsed_sec} do + {drain, elapsed} when is_integer(drain) and is_float(elapsed) and elapsed > 0 -> + Float.round(drain * 3600.0 / elapsed, 2) + + _ -> + nil + end + end + + # ── Taint detection ────────────────────────────────────────────────────── + # + # Surface things that would make a measurement inconclusive: screen came + # back on mid-run, app died, reconnect-storms, etc. + + defp taint_warnings(rows) do + [] + |> add_warning(screen_on_during_off_run?(rows), "screen turned ON during off-screen run") + |> add_warning(app_died?(rows), "app process reported as dead at some point") + |> add_warning(unreachable_majority?(rows), "majority of polls were :unreachable") + |> add_warning(many_reconnects?(rows), "many reconnects (>=10) — flapping connection") + end + + defp add_warning(list, true, msg), do: list ++ [msg] + defp add_warning(list, false, _), do: list + + defp screen_on_during_off_run?(rows) do + states = rows |> Enum.map(& &1.screen) |> MapSet.new() + MapSet.member?(states, :off) and MapSet.member?(states, :on) + end + + defp app_died?(rows), do: Enum.any?(rows, &(&1.app_process == :app_dead)) + + defp unreachable_majority?([]), do: false + + defp unreachable_majority?(rows) do + unreachable = Enum.count(rows, &(&1.reachability == :unreachable)) + unreachable * 2 > length(rows) + end + + defp many_reconnects?(rows), do: count_reconnects(rows) >= 10 + + # ── Pretty-print ───────────────────────────────────────────────────────── + + @doc """ + Render a summary as a human-readable multi-line string. + """ + @spec pretty(metrics()) :: String.t() + def pretty(%{} = m) do + [ + "Total samples: #{m.total_samples}", + "Successful samples: #{m.successful_samples} (#{percent(m.success_rate)})", + "Reconnects: #{m.reconnect_count}", + "Longest gap: #{m.longest_gap_sec} sec", + "Time by state:", + m.state_durations + |> Enum.sort_by(fn {_k, v} -> -v end) + |> Enum.map(fn {state, dur} -> " #{state}: #{dur} sec" end) + |> Enum.join("\n"), + "Screen off: #{m.screen_off_duration_sec} sec", + "Screen on: #{m.screen_on_duration_sec} sec", + battery_line(m), + taint_lines(m.taint_warnings) + ] + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n") + end + + defp percent(rate) when is_float(rate), do: "#{Float.round(rate * 100, 1)}%" + + defp battery_line(%{start_battery: nil}), do: "Battery: no successful reads" + + defp battery_line(m) do + rate = + case m.effective_rate_pct_per_hour do + nil -> "" + r -> " (≈ #{r} %/hr)" + end + + "Battery: #{m.start_battery}% → #{m.end_battery}% (drain #{m.drain_pct}%)#{rate}" + end + + defp taint_lines([]), do: "" + + defp taint_lines(warnings) do + "WARNINGS (run may be inconclusive):\n" <> + Enum.map_join(warnings, "\n", &(" - " <> &1)) + end +end diff --git a/lib/mob_dev/config.ex b/lib/mob_dev/config.ex index f29eed9..d5864a3 100644 --- a/lib/mob_dev/config.ex +++ b/lib/mob_dev/config.ex @@ -8,24 +8,88 @@ defmodule MobDev.Config do Returns the app's bundle ID / Android package name. Resolution order: - 1. `mob.exs` — `config :mob_dev, bundle_id: "..."` + 1. `mob.exs` — `config :mob_dev, bundle_id: "..."` (opt-in override; + not required — projects work fine without it) 2. `ios/Info.plist` — `CFBundleIdentifier` 3. `android/app/build.gradle` — `applicationId` - 4. Generated default: `"com.mob.<app_name>"` + 4. Generated default: `"<MOB_BUNDLE_PREFIX or com.example>.<app_name>"` + + The four-level fallback exists so cross-platform tasks (e.g. `mix mob.deploy`) + always have a value to work with, regardless of which platform's manifest is + authoritative for the project. For most users, the value resolved at step 2 + or 3 is what `mix mob.new` wrote there at generation time; mob.exs is + reserved for explicit overrides. """ + @spec bundle_id() :: String.t() def bundle_id do load_mob_config()[:bundle_id] || detect_from_ios_plist() || detect_from_android_gradle() || - "com.mob.#{app_name()}" + "#{bundle_prefix()}.#{app_name()}" + end + + @doc """ + Default reverse-DNS prefix when no platform manifest is available. + Honors `MOB_BUNDLE_PREFIX` so users with a corporate prefix can set + it once. Mirrors `MobNew.ProjectGenerator.bundle_prefix/0` so the + generator's default and the runtime fallback agree. + + Also used by `mix mob.uninstall --all-apps` to compute the prefix + match (e.g. uninstall every `com.example.*` package on a device). + """ + @spec bundle_prefix() :: String.t() + def bundle_prefix do + case System.get_env("MOB_BUNDLE_PREFIX") do + nil -> "com.example" + "" -> "com.example" + raw -> String.trim(raw) + end end + # The platforms a project develops for. `:android` and `:ios` are the only + # valid entries; order is irrelevant. + @all_platforms [:android, :ios] + + @doc """ + Platforms this project targets, read from `mob.exs` + (`config :mob_dev, platforms: [:ios]`). + + Defaults to both platforms when unset. The chief use is letting a Mac-only + iOS developer opt out of Android discovery/tunnelling once, instead of + passing `--ios-only` on every command. A `--ios-only` / `--android-only` + flag overrides this at the call site. + """ + @spec platforms() :: [:android | :ios] + def platforms, do: parse_platforms(load_mob_config()[:platforms]) + + @doc """ + Normalises a raw `:platforms` config value to a valid platform list. + + `nil` (unset) yields both platforms. A list is filtered to the known + platforms (`:android`, `:ios`); unknown or malformed entries are dropped. + If nothing valid remains, falls back to both platforms rather than leaving + the caller with no devices to discover. Pure — exposed for testing. + """ + @spec parse_platforms(term()) :: [:android | :ios] + def parse_platforms(nil), do: @all_platforms + + def parse_platforms(value) when is_list(value) do + case Enum.filter(@all_platforms, &(&1 in value)) do + [] -> @all_platforms + valid -> valid + end + end + + def parse_platforms(_other), do: @all_platforms + @doc """ Reads the `mob_dev` section from `mob.exs` in the current directory. Returns an empty keyword list if the file does not exist. """ + @spec load_mob_config() :: keyword() def load_mob_config do config_file = Path.join(File.cwd!(), "mob.exs") + if File.exists?(config_file), do: Config.Reader.read!(config_file) |> Keyword.get(:mob_dev, []), else: [] @@ -35,9 +99,14 @@ defmodule MobDev.Config do defp detect_from_ios_plist do plist = Path.join([File.cwd!(), "ios", "Info.plist"]) + with true <- File.exists?(plist), {:ok, content} <- File.read(plist), - [_, id] <- Regex.run(~r/<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/, content) do + [_, id] <- + Regex.run( + Regex.compile!("<key>CFBundleIdentifier</key>\\s*<string>([^<]+)</string>"), + content + ) do id else _ -> nil @@ -46,11 +115,12 @@ defmodule MobDev.Config do defp detect_from_android_gradle do gradle = Path.join([File.cwd!(), "android", "app", "build.gradle"]) + with true <- File.exists?(gradle), {:ok, content} <- File.read(gradle), match when match != nil <- - Regex.run(~r/applicationId\s+["']([^"']+)["']/, content) || - Regex.run(~r/applicationId\s*=\s*["']([^"']+)["']/, content) do + Regex.run(Regex.compile!("applicationId\\s+[\"']([^\"']+)[\"']"), content) || + Regex.run(Regex.compile!("applicationId\\s*=\\s*[\"']([^\"']+)[\"']"), content) do Enum.at(match, 1) else _ -> nil diff --git a/lib/mob_dev/connector.ex b/lib/mob_dev/connector.ex index 0d5437c..a077120 100644 --- a/lib/mob_dev/connector.ex +++ b/lib/mob_dev/connector.ex @@ -8,11 +8,13 @@ defmodule MobDev.Connector do @android_activity ".MainActivity" - defp bundle_id, do: MobDev.Config.bundle_id() + defp bundle_id, do: MobDev.Config.bundle_id() defp android_package, do: bundle_id() - defp ios_bundle_id, do: bundle_id() - @connect_timeout 10_000 # ms to wait for node to appear - @connect_interval 500 # ms between polls + defp ios_bundle_id, do: bundle_id() + # ms to wait for node to appear + @connect_timeout 25_000 + # ms between polls + @connect_interval 500 @doc """ Discovers all connected devices, sets up tunnels, restarts apps, and waits @@ -24,14 +26,24 @@ defmodule MobDev.Connector do def connect_all(opts \\ []) do cookie = Keyword.get(opts, :cookie, :mob_secret) + only = opts |> Keyword.get(:only, []) |> List.wrap() + platforms = opts |> Keyword.get(:platforms, [:android, :ios]) |> List.wrap() + IO.puts("\n#{color(:cyan)}Scanning for devices...#{color(:reset)}\n") - devices = discover_all() + devices = platforms |> discover_all() |> filter_only(only) if devices == [] do - IO.puts(" #{color(:yellow)}No devices found.#{color(:reset)}") - IO.puts(" • Connect an Android device via USB and enable USB debugging") - IO.puts(" • Start an iOS simulator in Xcode or via xcrun simctl") + if only != [] do + IO.puts(" #{color(:yellow)}No devices matched #{Enum.join(only, ", ")}.#{color(:reset)}") + + IO.puts(" • Run `mix mob.connect` with no --only to list all discovered devices") + else + IO.puts(" #{color(:yellow)}No devices found.#{color(:reset)}") + IO.puts(" • Connect an Android device via USB and enable USB debugging") + IO.puts(" • Start an iOS simulator in Xcode or via xcrun simctl") + end + {[], []} else print_discovered(devices) @@ -39,25 +51,40 @@ defmodule MobDev.Connector do # Set up tunnels (assigns dist_port per device) {tunneled, failed_tunnel} = setup_tunnels(devices) + # Kill any stale simulator processes from previous sessions. A lingering + # BEAM holds its EPMD slot, blocking new instances from registering. + kill_stale_simulator_apps(tunneled) + # Restart apps so they pick up tunnels and use correct node names Enum.each(tunneled, &restart_app/1) # Start distribution on the Mac side ensure_local_dist(cookie) + # Activate accessibility on iOS simulators so ui_tree() returns elements. + # SwiftUI lazily populates its a11y tree; this one-time activation persists + # for the simulator session (survives app restarts). + tunneled + |> Enum.filter(&(&1.platform == :ios)) + |> Enum.each(fn d -> IOS.enable_accessibility(d.serial) end) + # Wait for nodes to come online IO.puts("\n Waiting for nodes...") {connected, failed_wait} = wait_for_nodes(tunneled, cookie) # Report failures all_failed = failed_tunnel ++ failed_wait + Enum.each(all_failed, fn d -> IO.puts(" #{color(:red)}✗ #{d.name || d.serial}: #{d.error}#{color(:reset)}") print_fix_hint(d) end) if connected != [] do - IO.puts("\n#{color(:green)}Connected cluster (#{length(connected)} node(s)):#{color(:reset)}") + IO.puts( + "\n#{color(:green)}Connected cluster (#{length(connected)} node(s)):#{color(:reset)}" + ) + Enum.each(connected, fn d -> IO.puts(" #{color(:green)}✓#{color(:reset)} #{d.node} [port #{d.dist_port}]") end) @@ -67,22 +94,45 @@ defmodule MobDev.Connector do end end - defp discover_all do - android = Android.list_devices() - ios = IOS.list_devices() + # Only scan the platforms the project targets. An iOS-only Mac (no Android + # platform-tools) skips Android discovery entirely — both so it never shells + # out to a missing `adb`, and so a plugged-in Android phone for some *other* + # project isn't swept into this session. + defp discover_all(platforms) do + android = if :android in platforms, do: Android.list_devices(), else: [] + ios = if :ios in platforms, do: IOS.list_devices(), else: [] android ++ ios end + # Restrict the discovered set to devices whose serial/udid contains any of the + # given substrings (case-insensitive). Empty list = no filter (connect to all). + @doc false + @spec filter_only([Device.t()], [String.t()]) :: [Device.t()] + def filter_only(devices, []), do: devices + + def filter_only(devices, patterns) do + pats = Enum.map(patterns, &String.downcase/1) + + Enum.filter(devices, fn d -> + serial = String.downcase(to_string(d.serial)) + Enum.any?(pats, &String.contains?(serial, &1)) + end) + end + defp setup_tunnels(devices) do - # Track index per platform to assign unique ports + # Ports are derived from each device's serial (Tunnel.assign_dist_port/2), + # not a per-run index — so they're stable and don't collide across projects. + # Sequential so each device's freshly-added forward is visible as "in use" + # to the next device's collision check. devices - |> Enum.with_index() - |> Enum.reduce({[], []}, fn {device, idx}, {ok, fail} -> + |> Enum.reduce({[], []}, fn device, {ok, fail} -> IO.write(" #{device.name || device.serial} → tunneling...") - case Tunnel.setup(device, idx) do + + case Tunnel.setup(device) do {:ok, d} -> IO.puts(" #{color(:green)}✓#{color(:reset)}") {ok ++ [d], fail} + {:error, reason} -> IO.puts(" #{color(:red)}✗#{color(:reset)}") {ok, fail ++ [%{device | status: :error, error: reason}]} @@ -90,17 +140,67 @@ defmodule MobDev.Connector do end) end - defp restart_app(%Device{platform: :android, serial: serial, dist_port: port}) do + # Kill any app processes running in simulators that are NOT in our current + # tunneled set. A stale BEAM from a previous session holds its EPMD slot, + # blocking new instances of the same node name from registering. + defp kill_stale_simulator_apps(tunneled) do + active = + tunneled + |> Enum.filter(&(&1.platform == :ios && &1.type == :simulator)) + |> Enum.map(& &1.serial) + |> MapSet.new() + + case System.cmd("pgrep", ["-fl", bundle_id()], stderr_to_stdout: true) do + {output, 0} -> + output + |> String.split("\n", trim: true) + |> Enum.each(fn line -> + with [pid_str | _] <- String.split(line, " ", parts: 2), + {pid, ""} <- Integer.parse(pid_str), + [_, udid] <- Regex.run(~r|/Devices/([0-9A-F-]{36})/|i, line), + false <- MapSet.member?(active, udid) do + System.cmd("kill", ["-9", to_string(pid)], stderr_to_stdout: true) + end + end) + + _ -> + :ok + end + + :timer.sleep(300) + end + + defp restart_app(%Device{ + platform: :android, + serial: serial, + dist_port: port, + node_suffix: suffix + }) do IO.write(" Restarting app on #{serial}...") - Android.restart_app(serial, android_package(), @android_activity, dist_port: port) + # node_suffix may be nil — Android.restart_app falls back to + # device_node_suffix(serial) in that case (auto-derive from serial). + Android.restart_app(serial, android_package(), @android_activity, + dist_port: port, + node_suffix: suffix + ) + + IO.puts(" done") + end + + defp restart_app(%Device{platform: :ios, type: :physical, serial: udid}) do + IO.write(" Restarting app on #{udid}...") + # mob_beam.m discovers the USB link-local IP via getifaddrs() — no env vars needed. + IOS.restart_app_physical(udid, ios_bundle_id()) IO.puts(" done") end - defp restart_app(%Device{platform: :ios, serial: udid, dist_port: port}) do + defp restart_app(%Device{platform: :ios, serial: udid, dist_port: port, node_suffix: suffix}) do IO.write(" Restarting app on #{udid}...") IOS.terminate_app(udid, ios_bundle_id()) :timer.sleep(500) - IOS.launch_app(udid, ios_bundle_id(), dist_port: port) + # node_suffix nil → IOS.launch_app omits SIMCTL_CHILD_MOB_NODE_SUFFIX + # → mob_beam.m auto-derives from SIMULATOR_UDID. + IOS.launch_app(udid, ios_bundle_id(), dist_port: port, node_suffix: suffix) IO.puts(" done") end @@ -117,15 +217,18 @@ defmodule MobDev.Connector do # epmd -daemon exits 0 immediately in that case. # Public for testing. @doc false + @spec start_epmd() :: {String.t(), non_neg_integer()} | :ok def start_epmd do System.cmd("epmd", ["-daemon"], stderr_to_stdout: true) rescue - _ -> :ok # epmd not in PATH — Node.start will surface a clear error + # epmd not in PATH — Node.start will surface a clear error + _ -> :ok end # Handle the return value of Node.start/2. # Public for testing. @doc false + @spec handle_dist_start({:ok, term()} | {:error, term()}, atom()) :: :ok def handle_dist_start({:ok, _}, cookie), do: Node.set_cookie(cookie) @@ -147,46 +250,177 @@ defmodule MobDev.Connector do end defp wait_for_nodes(devices, cookie) do - devices - |> Enum.reduce({[], []}, fn device, {ok, fail} -> + # Start all connection attempts in parallel so slow starters (simulators + # that need ~20s to boot their BEAM) don't consume the other devices' budget. + # Total wall time = max(individual connect times), not sum. + tasks = + Enum.map(devices, fn device -> + candidates = node_candidates(device) + + {device, Task.async(fn -> wait_for_any_node(candidates, cookie, @connect_timeout) end)} + end) + + Enum.reduce(tasks, {[], []}, fn {device, task}, {ok, fail} -> IO.write(" #{device.node} ...") - case wait_for_node(device.node, cookie, @connect_timeout) do - :ok -> - IO.puts(" #{color(:green)}✓#{color(:reset)}") - {ok ++ [%{device | status: :connected}], fail} + + case Task.await(task, @connect_timeout + 2_000) do + {:ok, connected_node} -> + if connected_node == device.node do + IO.puts(" #{color(:green)}✓#{color(:reset)}") + {ok ++ [%{device | status: :connected}], fail} + else + # Fallback name responded — surface it so the user knows what to + # use with `mix mob.connect --no-iex` and friends. + IO.puts(" #{color(:green)}✓#{color(:reset)} (registered as #{connected_node})") + {ok ++ [%{device | status: :connected, node: connected_node}], fail} + end + {:error, reason} -> IO.puts(" #{color(:red)}✗#{color(:reset)}") - {ok, fail ++ [%{device | status: :error, error: reason}]} + diagnosis = connect_diagnosis(device) + error = if diagnosis, do: "#{reason} — #{diagnosis}", else: reason + {ok, fail ++ [%{device | status: :error, error: error}]} end end) end - defp wait_for_node(node, _cookie, timeout) when timeout <= 0 do - {:error, "timed out waiting for #{node}"} + # iOS sim — issues.md #14: mob_beam.m derives the node name from + # SIMULATOR_UDID at startup, but in some launch contexts that env var isn't + # set and the BEAM falls back to the suffix-less form. Probe both names so + # `mix mob.connect` works regardless of which was actually registered. + # Other platforms (physical iOS over LAN, Android over adb tunnel) always + # use a single deterministic name; the candidate list is just `[device.node]`. + defp node_candidates(%Device{platform: :ios, type: :simulator, node: node} = device) do + fallback = ios_sim_fallback_node(device) + + if fallback && fallback != node do + [node, fallback] + else + [node] + end end - defp wait_for_node(node, cookie, timeout) do + defp node_candidates(%Device{node: node}), do: [node] + + # Turn a black-box "timed out" into an actionable reason by inspecting the + # actual EPMD / forward / app state. Android only (the path users hit); other + # platforms fall through to nil and keep the bare reason. + @spec connect_diagnosis(Device.t()) :: String.t() | nil + defp connect_diagnosis(%Device{platform: :android, serial: serial, node: node, dist_port: port}) do + name = node |> Atom.to_string() |> String.split("@") |> hd() + registered_port = epmd_port_for(name) + + cond do + not android_app_running?(serial) -> + "app not running on #{serial} (crashed, or Android App Standby killed its " <> + "network while backgrounded) — foreground it and retry" + + registered_port == nil -> + "node #{name} never registered in EPMD — distribution didn't start on the " <> + "device. Check `adb -s #{serial} logcat` for the dist boot step" + + registered_port != port -> + "node #{name} registered at port #{registered_port} but mob.connect uses " <> + "#{port} — re-run mob.connect to realign the forward" + + not android_forwarded?(serial, port) -> + "no adb forward localhost:#{port} → #{serial}:#{port} — re-run mob.connect" + + true -> + "registered + forwarded but Node.connect failed — likely a cookie mismatch " <> + "(both sides must use :mob_secret)" + end + end + + defp connect_diagnosis(_device), do: nil + + defp epmd_port_for(name) do + case System.cmd("epmd", ["-names"], stderr_to_stdout: true) do + {out, 0} -> + case Regex.run(~r/name #{Regex.escape(name)} at port (\d+)/, out) do + [_, p] -> String.to_integer(p) + _ -> nil + end + + _ -> + nil + end + end + + defp android_forwarded?(serial, port) do + case System.cmd("adb", ["forward", "--list"], stderr_to_stdout: true) do + {out, 0} -> String.contains?(out, "#{serial} tcp:#{port} ") + _ -> false + end + end + + defp android_app_running?(serial) do + case System.cmd("adb", ["-s", serial, "shell", "pidof", android_package()], + stderr_to_stdout: true + ) do + {out, 0} -> String.trim(out) != "" + _ -> false + end + end + + defp ios_sim_fallback_node(%Device{node: node}) do + case Atom.to_string(node) |> String.split("@", parts: 2) do + [name, host] -> + # Strip the `_<8-char>` suffix that Device.node_name appends for + # simulators — yields the suffix-less `<app>_ios@<host>` form. + case Regex.run(~r/^(.+_ios)_[0-9a-f]{1,8}$/, name) do + [_, base] -> :"#{base}@#{host}" + _ -> nil + end + + _ -> + nil + end + end + + defp wait_for_any_node(candidates, _cookie, timeout) when timeout <= 0 do + {:error, "timed out waiting for any of #{inspect(candidates)}"} + end + + defp wait_for_any_node(candidates, cookie, timeout) do + case try_connect_each(candidates, cookie) do + {:ok, _} = ok -> + ok + + :none -> + :timer.sleep(@connect_interval) + wait_for_any_node(candidates, cookie, timeout - @connect_interval) + + {:error, _} = err -> + err + end + end + + defp try_connect_each([], _cookie), do: :none + + defp try_connect_each([node | rest], cookie) do Node.set_cookie(node, cookie) + case Node.connect(node) do - true -> :ok - false -> - :timer.sleep(@connect_interval) - wait_for_node(node, cookie, timeout - @connect_interval) - :ignored -> - {:error, "local node not alive (distribution not started)"} + true -> {:ok, node} + false -> try_connect_each(rest, cookie) + :ignored -> {:error, "local node not alive (distribution not started)"} end end defp print_discovered(devices) do android = Enum.filter(devices, &(&1.platform == :android)) - ios = Enum.filter(devices, &(&1.platform == :ios)) + ios = Enum.filter(devices, &(&1.platform == :ios)) if android != [] do IO.puts(" #{color(:blue)}Android#{color(:reset)}") + Enum.each(android, fn d -> - status = if d.status == :unauthorized, - do: "#{color(:red)}unauthorized#{color(:reset)}", - else: "found" + status = + if d.status == :unauthorized, + do: "#{color(:red)}unauthorized#{color(:reset)}", + else: "found" + IO.puts(" ├── #{d.name || d.serial} #{d.serial} #{status}") if d.status == :unauthorized, do: IO.puts(" │ #{d.error}") end) @@ -194,10 +428,12 @@ defmodule MobDev.Connector do if ios != [] do IO.puts(" #{color(:blue)}iOS#{color(:reset)}") + Enum.each(ios, fn d -> IO.puts(" ├── #{d.name || d.serial} #{d.serial} found") end) end + IO.puts("") end @@ -216,10 +452,10 @@ defmodule MobDev.Connector do defp print_fix_hint(_), do: :ok - defp color(:red), do: IO.ANSI.red() - defp color(:green), do: IO.ANSI.green() + defp color(:red), do: IO.ANSI.red() + defp color(:green), do: IO.ANSI.green() defp color(:yellow), do: IO.ANSI.yellow() - defp color(:blue), do: IO.ANSI.cyan() - defp color(:cyan), do: IO.ANSI.cyan() - defp color(:reset), do: IO.ANSI.reset() + defp color(:blue), do: IO.ANSI.cyan() + defp color(:cyan), do: IO.ANSI.cyan() + defp color(:reset), do: IO.ANSI.reset() end diff --git a/lib/mob_dev/deployer.ex b/lib/mob_dev/deployer.ex index 2225a93..52ca244 100644 --- a/lib/mob_dev/deployer.ex +++ b/lib/mob_dev/deployer.ex @@ -24,222 +24,3438 @@ defmodule MobDev.Deployer do """ alias MobDev.Discovery.{Android, IOS} - alias MobDev.{Device, HotPush, Tunnel} + alias MobDev.{AndroidDeployLock, Device, HotPush, Tunnel} @cookie :mob_secret @android_activity ".MainActivity" + @max_android_launch_output_bytes 4_096 + @max_android_query_output_bytes 8_192 + # `elixir.app` is structured application metadata, not a general adb query. + # Current reviewed releases exceed 8 KiB; keep a separate bounded read so + # they do not weaken the smaller limit used by package/path probes. + @max_android_elixir_app_bytes 65_536 + @max_adb_serial_bytes 128 + @android_attempt_id_pattern "\\A[A-Za-z0-9_-]{16}\\z" + @max_android_payload_bytes 1_073_741_824 + @android_abis ["arm64-v8a", "armeabi-v7a", "x86_64"] + @payload_registry_key {__MODULE__, :android_payload_registry} - defp app_name, do: Mix.Project.config()[:app] |> to_string() - defp bundle_id, do: MobDev.Config.bundle_id() - defp android_package, do: bundle_id() - defp android_app_data, do: "/data/data/#{android_package()}/files" + defp app_name, do: Mix.Project.config()[:app] |> to_string() + defp bundle_id, do: MobDev.Config.bundle_id() + defp android_package, do: bundle_id() + defp android_app_data, do: "/data/data/#{android_package()}/files" defp android_beams_dir, do: "#{android_app_data()}/otp/#{app_name()}" - defp ios_bundle_id, do: bundle_id() + defp ios_bundle_id, do: bundle_id() + + @doc false + @spec collect_android_beam_dirs() :: [String.t()] + def collect_android_beam_dirs, do: collect_beam_dirs() + + @doc false + @spec prepare_android_payload(map(), keyword()) :: {:ok, map()} | {:error, String.t()} + def prepare_android_payload(context, opts \\ []) + + def prepare_android_payload(context, opts) when is_map(context) and is_list(opts) do + beam_dirs = Keyword.get_lazy(opts, :beam_dirs, &collect_android_beam_dirs/0) + priv_dir = Keyword.get_lazy(opts, :priv_dir, &default_priv_dir/0) + tmp_root = Keyword.get(opts, :tmp_root, System.tmp_dir!()) + + with {:ok, identity} <- validate_android_payload_context(context), + {:ok, attempt_id} <- android_attempt_id(opts), + :ok <- validate_payload_prepare_opts(opts, beam_dirs, priv_dir, tmp_root), + :ok <- File.mkdir_p(tmp_root) do + root = Path.join(tmp_root, "mob_android_payload_#{attempt_id}") + + case File.mkdir(root) do + :ok -> + prepare_android_payload_root(root, identity, attempt_id, beam_dirs, priv_dir, opts) + + {:error, _reason} -> + {:error, "Could not reserve immutable Android payload staging"} + end + else + {:error, reason} when is_binary(reason) -> {:error, reason} + {:error, _reason} -> {:error, "Could not prepare immutable Android payload"} + end + rescue + _error -> {:error, "Could not prepare immutable Android payload"} + catch + _kind, _reason -> {:error, "Could not prepare immutable Android payload"} + end + + def prepare_android_payload(_context, _opts), + do: {:error, "Android payload context is invalid"} + + defp prepare_fast_android_payload(devices, package, opts) do + beam_dirs = Keyword.get(opts, :beam_dirs, collect_android_beam_dirs()) + priv_dir = Keyword.get(opts, :priv_dir, default_priv_dir()) + tmp_root = Keyword.get(opts, :tmp_root, System.tmp_dir!()) + serials = Enum.map(devices, & &1.serial) + selected_by_serial = Map.new(devices, &{&1.serial, &1.abi}) + + identity = %{ + package: package, + serials: serials, + selected_abis_by_serial: selected_by_serial, + selected_abis: selected_by_serial |> Map.values() |> Enum.uniq() |> Enum.sort() + } + + with :ok <- validate_android_package(package), + :ok <- validate_payload_serials(serials), + :ok <- validate_selected_abis(identity.selected_abis), + true <- Enum.all?(selected_by_serial, fn {_serial, abi} -> abi in @android_abis end), + {:ok, attempt_id} <- android_attempt_id(opts), + :ok <- + validate_payload_prepare_opts( + Keyword.put(opts, :operation, :fast), + beam_dirs, + priv_dir, + tmp_root + ), + :ok <- File.mkdir_p(tmp_root) do + root = Path.join(tmp_root, "mob_android_fast_payload_#{attempt_id}") + + case File.mkdir(root) do + :ok -> + prepare_fast_android_payload_root(root, identity, attempt_id, beam_dirs, priv_dir, opts) + + {:error, _reason} -> + {:error, "Could not reserve immutable fast Android payload staging"} + end + else + {:error, reason} when is_binary(reason) -> {:error, reason} + _invalid -> {:error, "Fast Android payload identity is invalid"} + end + rescue + _error -> {:error, "Could not prepare immutable fast Android payload"} + catch + _kind, _reason -> {:error, "Could not prepare immutable fast Android payload"} + end + + defp prepare_fast_android_payload_root(root, identity, attempt_id, beam_dirs, priv_dir, opts) do + try do + with {:ok, beam, beam_checks} <- + prepare_payload_beam(root, identity, attempt_id, beam_dirs, priv_dir, opts), + {:ok, exqlite} <- prepare_payload_exqlite(root, identity, attempt_id, opts), + {:ok, restart_by_serial} <- prepare_restart_map(identity, opts) do + plan = %{ + version: 1, + operation: :fast, + package: identity.package, + attempt_id: attempt_id, + serials: identity.serials, + selected_abis: identity.selected_abis, + beam: beam, + exqlite: exqlite, + restart_by_serial: restart_by_serial + } + + if validate_fast_android_payload_shape(plan) == :ok and + valid_payload_artifact_identities?(plan) and valid_payload_checks?(beam_checks) do + case register_android_payload(plan, root, beam_checks) do + :ok -> + {:ok, plan} + + {:error, _reason} -> + cleanup_payload_root(root) + {:error, "Could not register fast Android payload"} + end + else + cleanup_payload_root(root) + {:error, "Prepared fast Android payload failed validation"} + end + else + {:error, reason} -> + cleanup_payload_root(root) + {:error, reason} + end + rescue + _error -> + cleanup_payload_root(root) + {:error, "Could not snapshot fast Android payload"} + catch + _kind, _reason -> + cleanup_payload_root(root) + {:error, "Could not snapshot fast Android payload"} + end + end + + @doc false + @spec valid_android_payload?(term(), %{ + required(:package) => String.t(), + required(:serials) => [String.t()] + }) :: + boolean() + def valid_android_payload?(plan, %{package: package, serials: serials}) do + validate_android_payload_shape(plan) == :ok and plan.package == package and + plan.serials == serials and registered_android_payload?(plan) and + valid_payload_artifact_identities?(plan) + end + + def valid_android_payload?(_plan, _identity), do: false + + defp valid_fast_android_payload?(plan, %{package: package, serials: serials}) do + validate_fast_android_payload_shape(plan) == :ok and plan.package == package and + plan.serials == serials and registered_android_payload?(plan) and + valid_payload_artifact_identities?(plan) + end + + defp valid_fast_android_payload?(_plan, _identity), do: false + + defp valid_deploy_payload?(%{operation: :fast} = plan, identity), + do: valid_fast_android_payload?(plan, identity) + + defp valid_deploy_payload?(plan, identity), do: valid_android_payload?(plan, identity) + + @doc false + @spec cleanup_android_payload(term()) :: :ok | {:error, String.t()} + def cleanup_android_payload(plan) do + with :ok <- validate_android_payload_shape(plan), + {:ok, entry} <- registered_android_payload(plan) do + cleanup_registered_android_payload(plan, entry) + else + _invalid -> {:error, "Android payload cleanup authority is invalid"} + end + rescue + _error -> {:error, "Could not clean Android payload staging"} + catch + _kind, _reason -> {:error, "Could not clean Android payload staging"} + end + + defp cleanup_deploy_payload(%{operation: :fast} = plan) do + with :ok <- validate_fast_android_payload_shape(plan), + {:ok, entry} <- registered_android_payload(plan) do + cleanup_registered_android_payload(plan, entry) + else + _invalid -> {:error, "Fast Android payload cleanup authority is invalid"} + end + end + + defp cleanup_deploy_payload(plan), do: cleanup_android_payload(plan) + + defp prepare_android_payload_root(root, identity, attempt_id, beam_dirs, priv_dir, opts) do + try do + with {:ok, apk} <- snapshot_payload_apk(root, identity), + {:ok, beam, beam_checks} <- + prepare_payload_beam(root, identity, attempt_id, beam_dirs, priv_dir, opts), + {:ok, exqlite} <- prepare_payload_exqlite(root, identity, attempt_id, opts), + {:ok, restart_by_serial} <- prepare_restart_map(identity, opts) do + plan = %{ + version: 1, + package: identity.package, + attempt_id: attempt_id, + serials: identity.serials, + selected_abis: identity.selected_abis, + selected_abis_by_serial: identity.selected_abis_by_serial, + apk: apk, + beam: beam, + exqlite: exqlite, + restart_by_serial: restart_by_serial + } + + if validate_android_payload_shape(plan) == :ok and + valid_payload_artifact_identities?(plan) and valid_payload_checks?(beam_checks) do + case register_android_payload(plan, root, beam_checks) do + :ok -> + {:ok, plan} + + {:error, _reason} -> + cleanup_payload_root(root) + {:error, "Could not register Android payload cleanup authority"} + end + else + cleanup_payload_root(root) + {:error, "Prepared Android payload failed structural validation"} + end + else + {:error, reason} -> + cleanup_payload_root(root) + {:error, reason} + end + rescue + _error -> + cleanup_payload_root(root) + {:error, "Could not snapshot immutable Android payload"} + catch + _kind, _reason -> + cleanup_payload_root(root) + {:error, "Could not snapshot immutable Android payload"} + end + end + + defp validate_android_payload_context( + %{ + apk: apk, + apk_sha256: apk_sha256, + apk_size: apk_size, + bundle_id: package, + serials: serials, + selected_abis: selected_abis, + selected_abis_by_serial: selected_by_serial + } = context + ) do + with true <- map_size(context) == 7, + :ok <- validate_android_package(package), + :ok <- validate_payload_serials(serials), + :ok <- validate_selected_abis(selected_abis), + true <- + is_map(selected_by_serial) and Map.keys(selected_by_serial) |> Enum.sort() == serials, + true <- Enum.all?(selected_by_serial, fn {_serial, abi} -> abi in selected_abis end), + true <- selected_abis == selected_by_serial |> Map.values() |> Enum.uniq() |> Enum.sort(), + true <- is_binary(apk) and File.regular?(apk), + true <- is_integer(apk_size) and apk_size in 1..@max_android_payload_bytes, + true <- valid_hex_sha256?(apk_sha256), + {:ok, %{size: ^apk_size}} <- File.stat(apk), + {:ok, ^apk_sha256} <- file_sha256_hex(apk) do + {:ok, + %{ + apk: Path.expand(apk), + apk_sha256: apk_sha256, + apk_size: apk_size, + package: package, + serials: serials, + selected_abis: selected_abis, + selected_abis_by_serial: selected_by_serial + }} + else + _invalid -> {:error, "Android payload context identity is invalid"} + end + end + + defp validate_android_payload_context(_context), + do: {:error, "Android payload context identity is invalid"} + + defp validate_payload_prepare_opts(opts, beam_dirs, priv_dir, tmp_root) do + restart = Keyword.get(opts, :restart, true) + operation = Keyword.get(opts, :operation, :native) + beam_flags = Keyword.get(opts, :beam_flags) + + cond do + not is_list(beam_dirs) or beam_dirs == [] or not Enum.all?(beam_dirs, &File.dir?/1) -> + {:error, "Android BEAM source set is invalid"} + + not (is_nil(priv_dir) or (is_binary(priv_dir) and File.dir?(priv_dir))) -> + {:error, "Android priv source is invalid"} + + not is_binary(tmp_root) or tmp_root == "" -> + {:error, "Android payload staging root is invalid"} + + operation not in [:native, :fast] -> + {:error, "Android payload operation is invalid"} + + operation == :native and restart != true -> + {:error, "Native Android payload requires checked restart"} + + operation == :fast and restart not in [true, false] -> + {:error, "Fast Android restart mode is invalid"} + + not (is_nil(beam_flags) or + (is_binary(beam_flags) and byte_size(beam_flags) <= 4_096 and + String.valid?(beam_flags))) -> + {:error, "Android BEAM flags are invalid"} + + true -> + :ok + end + end + + defp snapshot_payload_apk(root, identity) do + path = Path.join(root, "payload.apk") + + with :ok <- File.cp(identity.apk, path), + :ok <- File.chmod(path, 0o400), + {:ok, %{type: :regular, size: size}} <- File.stat(path), + true <- size == identity.apk_size, + {:ok, sha256} <- file_sha256_hex(path), + true <- sha256 == identity.apk_sha256 do + {:ok, %{path: path, size: size, sha256: sha256}} + else + _failure -> {:error, "Could not snapshot exact Android APK"} + end + end + + defp prepare_payload_beam(root, identity, attempt_id, beam_dirs, priv_dir, opts) do + stage = Path.join(root, "beam_stage") + archive_path = Path.join(root, "beams.tar") + local_runner = Keyword.get(opts, :local_runner, &run_local_command/3) + file_writer = Keyword.get(opts, :file_writer, &File.write/2) + beam_flags = Keyword.get(opts, :beam_flags) + app_root = "/data/data/#{identity.package}/files" + + try do + with :ok <- File.mkdir(stage), + {:ok, sentinel} <- beam_sentinel(beam_dirs), + :ok <- stage_android_beam_dirs(beam_dirs, stage, local_runner), + {:ok, flag_checks} <- stage_android_beam_flags(stage, beam_flags, file_writer), + {:ok, priv_checks} <- stage_android_priv(stage, priv_dir, local_runner), + :ok <- + checked_local_command(local_runner, "create immutable BEAM archive", "tar", [ + "cf", + archive_path, + "-C", + stage, + "." + ]), + {:ok, archive} <- payload_archive_identity(archive_path), + {:ok, dist_snapshot} <- payload_dist_snapshot(stage) do + {:ok, + %{ + archive: archive, + stage_device: "/data/local/tmp/mob_beams_#{attempt_id}.tar", + app_stage: "#{app_root}/.mob_beams_stage_#{attempt_id}", + app_backup: "#{app_root}/.mob_beams_backup_#{attempt_id}", + activation_lock: "#{app_root}/.mob_beams_activation_lock", + dist_snapshot: dist_snapshot, + runtime_version: System.version(), + beam_flags: beam_flags + }, [{:file, sentinel} | flag_checks ++ priv_checks]} + else + {:error, reason} -> {:error, reason} + _failure -> {:error, "Could not prepare immutable BEAM payload"} + end + after + File.rm_rf(stage) + end + end + + defp prepare_payload_exqlite(root, identity, attempt_id, opts) do + {vsn, ebin} = payload_exqlite_source(opts) + + case {vsn, ebin} do + {nil, nil} -> + {:ok, nil} + + {vsn, ebin} when is_binary(vsn) and is_binary(ebin) -> + prepare_payload_exqlite_present(root, identity, attempt_id, vsn, ebin, opts) + + _incomplete -> + {:error, "Configured exqlite state is incomplete"} + end + end + + defp prepare_payload_exqlite_present(root, identity, attempt_id, vsn, ebin, opts) do + stage = Path.join(root, "exqlite_stage") + archive_path = Path.join(root, "exqlite.tar") + local_runner = Keyword.get(opts, :local_runner, &run_local_command/3) + lib_root = "/data/data/#{identity.package}/files/otp/lib" + + try do + with :ok <- validate_exqlite_version(vsn), + {:ok, sentinel} <- validate_exqlite_source(ebin, vsn), + :ok <- File.mkdir(stage), + :ok <- prepare_exqlite_local_stage(stage), + :ok <- + checked_local_command(local_runner, "stage immutable exqlite ebin", "cp", [ + "-r", + "#{ebin}/.", + Path.join(stage, "ebin") + ]), + :ok <- + checked_local_command(local_runner, "create immutable exqlite archive", "tar", [ + "cf", + archive_path, + "-C", + stage, + "." + ]), + {:ok, archive} <- payload_archive_identity(archive_path) do + {:ok, + %{ + archive: archive, + stage_device: "/data/local/tmp/mob_exqlite_#{attempt_id}.tar", + app_stage: "#{lib_root}/.mob_exqlite_stage_#{attempt_id}", + app_backup: "#{lib_root}/.mob_exqlite_backup_#{attempt_id}", + activation_lock: "#{lib_root}/.mob_exqlite_activation_lock", + app_version: vsn, + beam_sentinel: sentinel, + nif: %{ + source: :installed_apk, + filename: "libsqlite3_nif.so", + selected_abis: identity.selected_abis, + required_apk_entries: + Map.new(identity.selected_abis, &{&1, "lib/#{&1}/libsqlite3_nif.so"}) + } + }} + else + {:error, reason} -> {:error, reason} + _failure -> {:error, "Could not prepare immutable exqlite payload"} + end + after + File.rm_rf(stage) + end + end + + defp payload_exqlite_source(opts) do + case Keyword.get(opts, :exqlite_source, :auto) do + :auto -> {exqlite_version(), Path.wildcard("_build/dev/lib/exqlite/ebin") |> List.first()} + nil -> {nil, nil} + {vsn, ebin} -> {vsn, ebin} + _invalid -> {:invalid, :invalid} + end + end + + defp prepare_restart_map(identity, opts) do + restart = Keyword.get(opts, :restart, true) + dist_override = Keyword.get(opts, :dist_port) + suffix_override = Keyword.get(opts, :node_suffix) + activity = Keyword.get(opts, :activity, @android_activity) + resolver = Keyword.get(opts, :node_suffix_resolver, &Android.device_node_suffix/1) + + with :ok <- validate_android_activity(activity), + true <- is_function(resolver, 1) do + identity.serials + |> Enum.reduce_while({:ok, %{}}, fn serial, {:ok, result} -> + dist_port = dist_override || Tunnel.serial_base_port(serial) + + suffix = + if is_binary(suffix_override), + do: suffix_override, + else: safe_suffix_call(resolver, serial) + + with :ok <- validate_android_dist_port(dist_port), + :ok <- validate_android_node_suffix(suffix) do + record = %{ + package: identity.package, + activity: activity, + restart?: restart, + mode: if(restart, do: :checked_restart, else: :no_restart), + dist_port: dist_port, + node_suffix: suffix + } + + {:cont, {:ok, Map.put(result, serial, record)}} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end) + else + _invalid -> {:error, "Android restart identity is invalid"} + end + end + + defp safe_suffix_call(resolver, serial) do + try do + resolver.(serial) + rescue + _error -> nil + catch + _kind, _reason -> nil + end + end + + defp payload_dist_snapshot(stage) do + stage + |> Path.join("*.beam") + |> Path.wildcard() + |> Enum.sort() + |> HotPush.prepare() + end + + defp payload_archive_identity(path) do + with :ok <- File.chmod(path, 0o400), + {:ok, %{type: :regular, size: size}} <- File.stat(path), + true <- size in 1..@max_android_payload_bytes, + {:ok, sha256} <- file_sha256_hex(path) do + {:ok, %{path: path, size: size, sha256: sha256}} + else + _failure -> {:error, "Immutable Android archive identity is invalid"} + end + end + + defp file_sha256_hex(path) do + case File.open(path, [:read, :binary], fn io -> hash_file(io, :crypto.hash_init(:sha256)) end) do + {:ok, digest} when is_binary(digest) -> {:ok, Base.encode16(digest, case: :lower)} + _failure -> {:error, :hash_failed} + end + end + + defp hash_file(io, context) do + case IO.binread(io, 1_048_576) do + :eof -> :crypto.hash_final(context) + bytes when is_binary(bytes) -> hash_file(io, :crypto.hash_update(context, bytes)) + {:error, _reason} -> {:error, :read_failed} + end + end + + defp default_priv_dir do + path = Path.join(File.cwd!(), "priv") + if File.dir?(path), do: path, else: nil + end + + defp validate_payload_serials(serials) when is_list(serials) and serials != [] do + valid = Enum.all?(serials, &(validate_adb_serial(&1) == :ok)) + folded = Enum.map(serials, &String.downcase/1) + + if valid and serials == Enum.sort(serials) and Enum.uniq(serials) == serials and + Enum.uniq(folded) == folded and length(serials) <= 32, + do: :ok, + else: {:error, "Android target identity is invalid"} + end + + defp validate_payload_serials(_serials), do: {:error, "Android target identity is invalid"} + + defp validate_selected_abis(abis) when is_list(abis) do + if abis != [] and abis == Enum.sort(abis) and Enum.uniq(abis) == abis and + Enum.all?(abis, &(&1 in @android_abis)), + do: :ok, + else: {:error, "Android ABI identity is invalid"} + end + + defp validate_selected_abis(_abis), do: {:error, "Android ABI identity is invalid"} + + defp valid_hex_sha256?(value) when is_binary(value) do + byte_size(value) == 64 and Regex.match?(Regex.compile!("\\A[0-9a-f]{64}\\z"), value) + end + + defp valid_hex_sha256?(_value), do: false + + defp validate_android_payload_shape( + %{ + version: 1, + package: package, + attempt_id: attempt_id, + serials: serials, + selected_abis: selected_abis, + selected_abis_by_serial: selected_by_serial, + apk: apk, + beam: beam, + exqlite: exqlite, + restart_by_serial: restart_by_serial + } = plan + ) do + with true <- map_size(plan) == 10, + :ok <- validate_android_package(package), + {:ok, ^attempt_id} <- android_attempt_id(attempt_id: attempt_id), + :ok <- validate_payload_serials(serials), + :ok <- validate_selected_abis(selected_abis), + true <- is_map(selected_by_serial) and Enum.sort(Map.keys(selected_by_serial)) == serials, + true <- Enum.all?(selected_by_serial, fn {_serial, abi} -> abi in selected_abis end), + true <- selected_abis == selected_by_serial |> Map.values() |> Enum.uniq() |> Enum.sort(), + {:ok, root} <- validate_payload_apk_shape(apk, attempt_id), + :ok <- validate_payload_beam_shape(beam, root, package, attempt_id), + :ok <- validate_payload_exqlite_shape(exqlite, root, package, attempt_id, selected_abis), + :ok <- validate_restart_map_shape(restart_by_serial, package, serials) do + :ok + else + _invalid -> {:error, :invalid_android_payload} + end + end + + defp validate_android_payload_shape(_plan), do: {:error, :invalid_android_payload} + + defp validate_fast_android_payload_shape( + %{ + version: 1, + operation: :fast, + package: package, + attempt_id: attempt_id, + serials: serials, + selected_abis: selected_abis, + beam: beam, + exqlite: exqlite, + restart_by_serial: restart_by_serial + } = plan + ) do + root = + case beam do + %{archive: %{path: path}} when is_binary(path) -> Path.dirname(path) + _invalid -> nil + end + + with true <- map_size(plan) == 9, + :ok <- validate_android_package(package), + {:ok, ^attempt_id} <- android_attempt_id(attempt_id: attempt_id), + :ok <- validate_payload_serials(serials), + :ok <- validate_selected_abis(selected_abis), + true <- is_binary(root), + true <- Path.basename(root) == "mob_android_fast_payload_#{attempt_id}", + :ok <- validate_payload_beam_shape(beam, root, package, attempt_id), + :ok <- validate_payload_exqlite_shape(exqlite, root, package, attempt_id, selected_abis), + :ok <- validate_restart_map_shape(restart_by_serial, package, serials) do + :ok + else + _invalid -> {:error, :invalid_fast_android_payload} + end + end + + defp validate_fast_android_payload_shape(_plan), + do: {:error, :invalid_fast_android_payload} + + defp validate_payload_apk_shape(%{path: path, size: size, sha256: sha256} = apk, attempt_id) do + root = if is_binary(path), do: Path.dirname(path), else: nil + + if map_size(apk) == 3 and is_binary(root) and + Path.basename(root) == "mob_android_payload_#{attempt_id}" and + path == Path.join(root, "payload.apk") and is_integer(size) and + size in 1..@max_android_payload_bytes and valid_hex_sha256?(sha256) do + {:ok, root} + else + {:error, :invalid_apk} + end + end + + defp validate_payload_apk_shape(_apk, _attempt_id), do: {:error, :invalid_apk} + + defp validate_payload_beam_shape(beam, root, package, attempt_id) when is_map(beam) do + app_root = "/data/data/#{package}/files" + + with %{ + archive: archive, + stage_device: "/data/local/tmp/mob_beams_" <> stage_tail, + app_stage: app_stage, + app_backup: app_backup, + activation_lock: activation_lock, + dist_snapshot: snapshot, + runtime_version: runtime_version, + beam_flags: beam_flags + } <- beam, + true <- map_size(beam) == 8, + true <- stage_tail == "#{attempt_id}.tar", + :ok <- validate_archive_shape(archive, Path.join(root, "beams.tar")), + true <- app_stage == "#{app_root}/.mob_beams_stage_#{attempt_id}", + true <- app_backup == "#{app_root}/.mob_beams_backup_#{attempt_id}", + true <- activation_lock == "#{app_root}/.mob_beams_activation_lock", + :ok <- HotPush.validate_prepared_snapshot(snapshot), + true <- runtime_version == System.version(), + true <- + is_nil(beam_flags) or + (is_binary(beam_flags) and byte_size(beam_flags) <= 4_096 and + String.valid?(beam_flags)) do + :ok + else + _invalid -> {:error, :invalid_beam_payload} + end + end + + defp validate_payload_beam_shape(_beam, _root, _package, _attempt_id), + do: {:error, :invalid_beam_payload} + + defp validate_payload_exqlite_shape(nil, _root, _package, _attempt_id, _abis), do: :ok + + defp validate_payload_exqlite_shape(exqlite, root, package, attempt_id, abis) + when is_map(exqlite) do + lib_root = "/data/data/#{package}/files/otp/lib" + + with %{ + archive: archive, + stage_device: "/data/local/tmp/mob_exqlite_" <> stage_tail, + app_stage: app_stage, + app_backup: app_backup, + activation_lock: activation_lock, + app_version: app_version, + beam_sentinel: sentinel, + nif: nif + } <- exqlite, + true <- map_size(exqlite) == 8, + true <- stage_tail == "#{attempt_id}.tar", + :ok <- validate_archive_shape(archive, Path.join(root, "exqlite.tar")), + :ok <- validate_exqlite_version(app_version), + true <- app_stage == "#{lib_root}/.mob_exqlite_stage_#{attempt_id}", + true <- app_backup == "#{lib_root}/.mob_exqlite_backup_#{attempt_id}", + true <- activation_lock == "#{lib_root}/.mob_exqlite_activation_lock", + {:ok, ^sentinel} <- validate_beam_sentinel(sentinel), + :ok <- validate_nif_plan_shape(nif, abis) do + :ok + else + _invalid -> {:error, :invalid_exqlite_payload} + end + end + + defp validate_payload_exqlite_shape(_value, _root, _package, _attempt_id, _abis), + do: {:error, :invalid_exqlite_payload} + + defp validate_nif_plan_shape( + %{ + source: :installed_apk, + filename: "libsqlite3_nif.so", + selected_abis: abis, + required_apk_entries: entries + } = nif, + abis + ) do + expected = Map.new(abis, &{&1, "lib/#{&1}/libsqlite3_nif.so"}) + + if map_size(nif) == 4 and entries == expected, + do: :ok, + else: {:error, :invalid_nif_plan} + end + + defp validate_nif_plan_shape(_nif, _abis), do: {:error, :invalid_nif_plan} + + defp validate_archive_shape(%{path: path, size: size, sha256: sha256} = archive, expected) do + if map_size(archive) == 3 and path == expected and is_integer(size) and + size in 1..@max_android_payload_bytes and valid_hex_sha256?(sha256), + do: :ok, + else: {:error, :invalid_archive} + end + + defp validate_archive_shape(_archive, _expected), do: {:error, :invalid_archive} + + defp valid_payload_checks?(checks) when is_list(checks) and checks != [] do + length(checks) <= 64 and Enum.uniq(checks) == checks and + Enum.all?(checks, fn + {kind, path} when kind in [:file, :dir] -> safe_android_relative_path?(path) + _invalid -> false + end) + end + + defp valid_payload_checks?(_checks), do: false + + defp validate_restart_map_shape(restart_by_serial, package, serials) + when is_map(restart_by_serial) do + if Enum.sort(Map.keys(restart_by_serial)) == serials and + Enum.all?(restart_by_serial, fn {_serial, record} -> + valid_restart_record?(record, package) + end), + do: :ok, + else: {:error, :invalid_restart_map} + end + + defp validate_restart_map_shape(_restart_by_serial, _package, _serials), + do: {:error, :invalid_restart_map} + + defp valid_restart_record?(record, package) when is_map(record) do + with %{ + package: ^package, + activity: activity, + restart?: restart, + mode: mode, + dist_port: port, + node_suffix: suffix + } <- record, + true <- map_size(record) == 6, + true <- restart in [true, false], + true <- mode == if(restart, do: :checked_restart, else: :no_restart), + :ok <- validate_android_activity(activity), + :ok <- validate_android_dist_port(port), + :ok <- validate_android_node_suffix(suffix) do + true + else + _invalid -> false + end + end + + defp valid_restart_record?(_record, _package), do: false + + defp register_android_payload(plan, root, beam_checks) do + paths = payload_artifact_paths(plan) + + if plan_root(plan) == root and Enum.all?(paths, &(Path.dirname(&1) == root)) and + valid_payload_checks?(beam_checks) do + registry = Process.get(@payload_registry_key, %{}) + + entry = %{ + root: root, + paths: paths, + beam_checks: beam_checks, + cleaned?: false + } + + Process.put(@payload_registry_key, Map.put(registry, payload_registry_id(plan), entry)) + :ok + else + {:error, :invalid_payload_registry_entry} + end + end + + defp registered_android_payload?(plan) do + match?({:ok, %{cleaned?: false}}, registered_android_payload(plan)) + end + + defp registered_android_payload(plan) do + case Process.get(@payload_registry_key, %{}) |> Map.fetch(payload_registry_id(plan)) do + {:ok, %{root: root, paths: paths} = entry} + when is_binary(root) and is_list(paths) -> + if root == plan_root(plan) and paths == payload_artifact_paths(plan) do + {:ok, entry} + else + {:error, :payload_registry_mismatch} + end + + _missing -> + {:error, :payload_not_registered} + end + end + + defp payload_registry_id(plan) do + :crypto.hash(:sha256, :erlang.term_to_binary(plan)) + end + + defp cleanup_registered_android_payload(_plan, %{cleaned?: true}), do: :ok + + defp cleanup_registered_android_payload(plan, %{root: root, paths: paths}) do + with :ok <- remove_registered_payload_files(paths), + :ok <- remove_registered_payload_root(root) do + registry = Process.get(@payload_registry_key, %{}) + id = payload_registry_id(plan) + Process.put(@payload_registry_key, put_in(registry, [id, :cleaned?], true)) + :ok + end + end + + defp remove_registered_payload_files(paths) do + Enum.reduce_while(paths, :ok, fn path, :ok -> + case File.rm(path) do + :ok -> {:cont, :ok} + {:error, :enoent} -> {:cont, :ok} + {:error, _reason} -> {:halt, {:error, "Could not remove Android payload artifact"}} + end + end) + end + + defp remove_registered_payload_root(root) do + case File.rmdir(root) do + :ok -> :ok + {:error, :enoent} -> :ok + {:error, _reason} -> {:error, "Could not remove empty Android payload staging"} + end + end + + defp valid_payload_artifact_identities?(plan) do + identities = + if(Map.has_key?(plan, :apk), do: [plan.apk], else: []) ++ + [plan.beam.archive] ++ if(plan.exqlite, do: [plan.exqlite.archive], else: []) + + paths = Enum.map(identities, & &1.path) + + Enum.uniq(paths) == paths and Enum.all?(identities, &valid_payload_artifact_identity?/1) + end + + defp valid_payload_artifact_identity?(%{path: path, size: size, sha256: sha256} = identity) + when map_size(identity) == 3 do + with true <- is_binary(path) and Path.type(path) == :absolute, + true <- is_integer(size) and size in 1..@max_android_payload_bytes, + true <- valid_hex_sha256?(sha256), + {:ok, %{type: :regular}} <- File.lstat(path), + {:ok, %{type: :regular, size: ^size, mode: mode}} <- File.stat(path), + true <- Bitwise.band(mode, 0o222) == 0, + {:ok, ^sha256} <- file_sha256_hex(path) do + true + else + _invalid -> false + end + end + + defp valid_payload_artifact_identity?(_identity), do: false + + defp payload_artifact_paths(plan) do + if(Map.has_key?(plan, :apk), do: [plan.apk.path], else: []) ++ + [plan.beam.archive.path] ++ + if(is_map(plan.exqlite), do: [plan.exqlite.archive.path], else: []) + end + + defp plan_root(plan) do + if Map.has_key?(plan, :apk), + do: Path.dirname(plan.apk.path), + else: Path.dirname(plan.beam.archive.path) + end + + defp cleanup_payload_root(root) do + for name <- ["payload.apk", "beams.tar", "exqlite.tar"] do + File.rm(Path.join(root, name)) + end + + File.rm_rf(root) + :ok + end + defp ios_beams_dir do - # mob_beam.m hardcodes /tmp/otp-ios-sim as OTP_ROOT. - # If that directory exists (manually set up or from a prior native deploy), - # deploy beams there so the running BEAM picks them up immediately. - # Fall back to the cache dir otherwise (e.g. fresh machine before first --native). - tmp_path = Path.join("/tmp/otp-ios-sim", app_name()) + # The simulator's OTP_ROOT is resolved by `MobDev.Paths.sim_runtime_dir/1`. + # New projects: ~/.mob/runtime/ios-sim. Legacy projects (build.sh predates + # MOB_SIM_RUNTIME_DIR support): /tmp/otp-ios-sim. Either way, if that + # directory exists deploy beams there so the running BEAM picks them up + # immediately. Fall back to the cache dir on a fresh machine that hasn't + # done its first --native build yet. + runtime_dir = MobDev.Paths.sim_runtime_dir() + runtime_path = Path.join(runtime_dir, app_name()) cache_path = Path.join(MobDev.OtpDownloader.ios_sim_otp_dir(), app_name()) - if File.dir?("/tmp/otp-ios-sim"), do: tmp_path, else: cache_path + if File.dir?(runtime_dir), do: runtime_path, else: cache_path + end + + @doc false + @spec deploy_all_with_lease(keyword()) :: + {{[Device.t()], [Device.t()], [Device.t()]}, map() | nil} + def deploy_all_with_lease(opts) when is_list(opts) do + case {Keyword.get(opts, :android_deploy_lock), Keyword.get(opts, :android_payload_plan)} do + {%{} = lease, %{} = plan} -> + deploy_native_android_with_lease(opts, lease, plan) + + {nil, nil} -> + deploy_fast_or_non_android_with_lease(opts) + + _incomplete_authority -> + devices = canonical_android_devices_for_failure(opts) + reason = "Android deploy authority is incomplete; refusing device mutation" + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, nil} + end + end + + def deploy_all_with_lease(_opts), do: {{[], [], []}, nil} + + defp deploy_fast_or_non_android_with_lease(opts) do + platforms = Keyword.get(opts, :platforms, [:android, :ios]) + + if :android in platforms do + deploy_fast_android_with_lease(opts, platforms) + else + {deploy_all_unleased(opts), nil} + end + end + + defp deploy_fast_android_with_lease(opts, platforms) do + android_lister = Keyword.get(opts, :android_lister, &Android.list_devices/0) + device_id = Keyword.get(opts, :device) + canonical_serials = Keyword.get(opts, :canonical_android_serials) + + devices = + android_lister.() + |> select_android_devices!(device_id, canonical_serials) + |> Enum.sort_by(& &1.serial) + + if devices == [] do + remaining = Keyword.put(opts, :platforms, platforms -- [:android]) + {deploy_all_unleased(remaining), nil} + else + package = bundle_id() + package_runner = Keyword.get(opts, :android_package_runner, &run_android_lock_command/1) + + case preflight_fast_android_targets(devices, package, package_runner) do + {:ok, [], skipped} -> + ios_result = + opts + |> Keyword.put(:platforms, platforms -- [:android]) + |> deploy_all_unleased() + + {merge_device_results({[], [], skipped}, ios_result), nil} + + {:ok, installed, skipped} -> + IO.puts(" Pushing authoritative BEAM payload to #{length(installed)} device(s)...") + + {result, lease} = run_fast_android_operation(installed, opts, platforms) + {merge_device_results(result, {[], [], skipped}), lease} + + {:error, reason} -> + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, nil} + end + end + end + + defp preflight_fast_android_targets(devices, package, runner) do + Enum.reduce_while(devices, {:ok, [], []}, fn device, {:ok, installed, skipped} -> + result = runner.(["-s", device.serial, "shell", "pm", "list", "packages", package]) + + case classify_android_package_probe(result, package) do + :installed -> + {:cont, {:ok, [device | installed], skipped}} + + :absent -> + reason = "#{package} is not installed; Android target was not mutated" + skipped_device = %{device | status: :skipped, error: reason} + {:cont, {:ok, installed, [skipped_device | skipped]}} + + {:error, _reason} -> + {:halt, {:error, "Android package preflight was ambiguous"}} + end + end) + |> case do + {:ok, installed, skipped} -> {:ok, Enum.reverse(installed), Enum.reverse(skipped)} + {:error, _reason} = error -> error + end + rescue + _error -> {:error, "Android package preflight was ambiguous"} + catch + _kind, _reason -> {:error, "Android package preflight was ambiguous"} + end + + defp run_fast_android_operation(devices, opts, platforms) do + package = bundle_id() + serials = Enum.map(devices, & &1.serial) + lock_runner = Keyword.get(opts, :android_lock_runner, &run_android_lock_command/1) + prepare = Keyword.get(opts, :fast_android_payload_preparer, &prepare_fast_android_payload/3) + + with {:ok, plan} <- + prepare.(devices, package, + operation: :fast, + restart: Keyword.get(opts, :restart, true), + beam_flags: Keyword.get(opts, :beam_flags), + dist_port: Keyword.get(opts, :dist_port), + node_suffix: Keyword.get(opts, :node_suffix), + beam_dirs: Keyword.get(opts, :beam_dirs, collect_android_beam_dirs()), + priv_dir: Keyword.get(opts, :priv_dir, default_priv_dir()), + exqlite_source: Keyword.get(opts, :exqlite_source, :auto), + tmp_root: Keyword.get(opts, :tmp_root, System.tmp_dir!()), + node_suffix_resolver: + Keyword.get(opts, :node_suffix_resolver, &Android.device_node_suffix/1) + ) do + try do + identity = %{package: package, serials: serials} + + with true <- valid_fast_android_payload?(plan, identity), + {:ok, lease} <- AndroidDeployLock.acquire(package, serials, lock_runner) do + result = deploy_fast_android_targets(devices, opts, lease, plan, identity, lock_runner) + + finalize_fast_android_operation(result, opts, platforms, lock_runner) + else + false -> + reason = "Fast Android payload is invalid or changed" + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, nil} + + {:error, %{lease: retained} = failure} -> + reason = AndroidDeployLock.message(failure) + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + end + after + cleanup_deploy_payload(plan) + end + else + {:error, reason} -> + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, nil} + + _invalid -> + reason = "Could not prepare authoritative fast Android payload" + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, nil} + end + end + + defp deploy_fast_android_targets(devices, opts, lease, plan, identity, lock_runner) do + case connected_fast_android_nodes(devices, opts) do + {:ok, nodes, hot_push_devices} -> + deploy_fast_android_via_dist( + devices, + nodes, + hot_push_devices, + opts, + lease, + plan, + identity, + lock_runner + ) + + :filesystem -> + deploy_fast_android_via_filesystem( + devices, + opts, + lease, + plan, + identity, + lock_runner + ) + end + end + + defp deploy_fast_android_via_filesystem(devices, opts, lease, plan, identity, lock_runner) do + try do + deploy_native_android_targets(devices, opts, lease, plan, identity, lock_runner) + rescue + _error -> failed_fast_filesystem_result(devices, lease, lock_runner) + catch + _kind, _reason -> failed_fast_filesystem_result(devices, lease, lock_runner) + end + end + + defp failed_fast_filesystem_result(devices, lease, lock_runner) do + reason = "Fast Android filesystem deploy failed before exact-set commit" + retained = retained_lease_after_failure(lease, lock_runner) + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + end + + defp connected_fast_android_nodes(devices, opts) do + if Keyword.get(opts, :force_fs, false) do + :filesystem + else + connected = Keyword.get(opts, :connected_nodes, [Node.self() | Node.list()]) + + if is_list(connected) and Enum.all?(connected, &is_atom/1) do + nodes = Enum.map(devices, &Device.node_name/1) + + if nodes != [] and Enum.uniq(nodes) == nodes and Enum.all?(nodes, &(&1 in connected)) do + hot_push_devices = + Enum.zip_with(devices, nodes, fn device, node -> %{device | node: node} end) + + {:ok, nodes, hot_push_devices} + else + :filesystem + end + else + :filesystem + end + end + end + + defp deploy_fast_android_via_dist( + devices, + nodes, + hot_push_devices, + opts, + lease, + plan, + identity, + lock_runner + ) do + rpc = Keyword.get(opts, :hot_push_rpc, &hot_push_load_rpc/4) + post_push = Keyword.get(opts, :hot_push_post_push, &hot_push_repaint/1) + + with :ok <- payload_deploy_barrier(plan, identity, lease, lock_runner), + {pushed, []} when pushed == length(plan.beam.dist_snapshot) <- + HotPush.push_prepared_fenced(nodes, plan.beam.dist_snapshot, + package: identity.package, + android_devices: hot_push_devices, + android_deploy_lock: lease, + expected_lock_phase: lease.phase, + lock_runner: lock_runner, + rpc: rpc, + post_push: post_push + ), + :ok <- payload_deploy_barrier(plan, identity, lease, lock_runner), + {:ok, committed} <- + AndroidDeployLock.transition(lease, :acquired, :fast_committed, lock_runner) do + {{devices, [], []}, committed} + else + {0, failures} when is_list(failures) -> + failed_fast_dist_result(devices, lease, lock_runner, "Android hot push failed closed") + + {partial_count, failures} when is_integer(partial_count) and is_list(failures) -> + failed_fast_dist_result( + devices, + lease, + lock_runner, + "Android hot push result was ambiguous" + ) + + {:error, %{lease: retained}} -> + reason = "Android hot push commit became ambiguous; deploy lease retained" + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + + {:error, _reason} -> + failed_fast_dist_result( + devices, + lease, + lock_runner, + "Android hot push authority changed" + ) + + _invalid -> + failed_fast_dist_result( + devices, + lease, + lock_runner, + "Android hot push returned an invalid result" + ) + end + end + + defp failed_fast_dist_result(devices, lease, lock_runner, reason) do + retained = retained_lease_after_failure(lease, lock_runner) + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + end + + defp hot_push_load_rpc(node, module, filename, binary) do + :rpc.call(node, :code, :load_binary, [module, filename, binary]) + end + + defp hot_push_repaint(node) do + case :rpc.call(node, :erlang, :send, [:mob_screen, :__mob_hot_reload__]) do + {:badrpc, _reason} -> {:error, :repaint_failed} + _sent_message -> :ok + end + rescue + _error -> {:error, :repaint_failed} + catch + _kind, _reason -> {:error, :repaint_failed} + end + + defp finalize_fast_android_operation( + {{deployed, [], []}, %{phase: :fast_committed} = committed}, + opts, + platforms, + lock_runner + ) do + case AndroidDeployLock.release(committed, lock_runner) do + :ok -> + ios_result = + if :ios in platforms do + opts + |> Keyword.put(:platforms, [:ios]) + |> deploy_all_unleased() + else + {[], [], []} + end + + {merge_device_results({deployed, [], []}, ios_result), nil} + + {:error, %{lease: retained}} -> + reason = "Fast Android deploy committed but lease release is ambiguous" + failures = Enum.map(deployed, &failed_android_device(&1, reason)) + {{[], failures, []}, retained} + end + end + + defp finalize_fast_android_operation({result, retained}, _opts, _platforms, _lock_runner), + do: {result, retained} + + defp merge_device_results({deployed_a, failed_a, skipped_a}, {deployed_b, failed_b, skipped_b}) do + {deployed_a ++ deployed_b, failed_a ++ failed_b, skipped_a ++ skipped_b} + end + + defp deploy_native_android_with_lease(opts, lease, plan) do + package = bundle_id() + serials = Keyword.get(opts, :canonical_android_serials, []) + lock_runner = Keyword.get(opts, :android_lock_runner, &run_android_lock_command/1) + android_lister = Keyword.get(opts, :android_lister, &Android.list_devices/0) + + devices = + android_lister.() + |> select_android_devices!(nil, serials) + + identity = %{package: package, serials: serials} + + cond do + not AndroidDeployLock.valid?(lease, :native_ready) or lease.bundle_id != package or + lease.serials != serials -> + reason = "Native Android deploy lease identity is invalid" + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, nil} + + not valid_android_payload?(plan, identity) -> + reason = "Authoritative Android payload is invalid or changed" + retained = %{lease | state: :retained_failure} + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + + true -> + deploy_native_android_targets(devices, opts, lease, plan, identity, lock_runner) + end + rescue + _error -> + devices = canonical_android_devices_for_failure(opts) + reason = "Native Android final deploy failed before commit" + retained = if is_map(lease), do: Map.put(lease, :state, :retained_ambiguous), else: nil + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + catch + _kind, _reason -> + devices = canonical_android_devices_for_failure(opts) + reason = "Native Android final deploy failed before commit" + retained = if is_map(lease), do: Map.put(lease, :state, :retained_ambiguous), else: nil + {{[], Enum.map(devices, &failed_android_device(&1, reason)), []}, retained} + end + + defp deploy_native_android_targets(devices, opts, lease, plan, identity, lock_runner) do + commit_phase = if lease.phase == :native_ready, do: :final_committed, else: :fast_committed + + case deploy_native_android_targets_ordered( + devices, + opts, + lease, + plan, + identity, + lock_runner, + [] + ) do + {:ok, deployed} -> + with :ok <- payload_deploy_barrier(plan, identity, lease, lock_runner), + {:ok, committed} <- + AndroidDeployLock.transition( + lease, + lease.phase, + commit_phase, + lock_runner + ) do + {{Enum.reverse(deployed), [], []}, committed} + else + {:error, %{lease: retained}} -> + reason = "Android final commit became ambiguous; deploy lease retained" + failures = Enum.map(Enum.reverse(deployed), &failed_android_device(&1, reason)) + {{[], failures, []}, retained} + + {:error, _reason} -> + reason = "Android payload changed before final commit; deploy lease retained" + retained = %{lease | state: :retained_failure} + failures = Enum.map(Enum.reverse(deployed), &failed_android_device(&1, reason)) + {{[], failures, []}, retained} + end + + {:error, deployed, failed, remaining, retained} -> + reason = failed.error || "Native Android final deploy failed" + + indeterminate = + deployed + |> Enum.reverse() + |> Enum.map( + &failed_android_device( + &1, + "Device mutation is indeterminate because the exact set did not commit" + ) + ) + + halted = Enum.map(remaining, &failed_android_device(&1, "Operation halted: #{reason}")) + {{[], indeterminate ++ [failed | halted], []}, retained} + end + end + + defp deploy_native_android_targets_ordered( + [], + _opts, + _lease, + _plan, + _identity, + _lock_runner, + deployed + ), + do: {:ok, deployed} + + defp deploy_native_android_targets_ordered( + [device | remaining], + opts, + lease, + plan, + identity, + lock_runner, + deployed + ) do + result = + with :ok <- payload_deploy_barrier(plan, identity, lease, lock_runner) do + case Keyword.get(opts, :device_deployer) do + deployer when is_function(deployer, 1) -> deployer.(device) + nil -> deploy_android_payload_plan(device, plan, identity, lease, lock_runner, opts) + _invalid -> {:error, "Android device deployer is invalid"} + end + end + + case result do + {:ok, %Device{} = deployed_device} -> + deploy_native_android_targets_ordered( + remaining, + opts, + lease, + plan, + identity, + lock_runner, + [deployed_device | deployed] + ) + + {:skipped, reason} -> + retained = retained_lease_after_failure(lease, lock_runner) + failed = failed_android_device(device, bounded_deploy_reason(reason)) + {:error, deployed, failed, remaining, retained} + + {:error, reason} -> + retained = retained_lease_after_failure(lease, lock_runner) + failed = failed_android_device(device, bounded_deploy_reason(reason)) + {:error, deployed, failed, remaining, retained} + + _invalid -> + retained = retained_lease_after_failure(lease, lock_runner) + failed = failed_android_device(device, "Android device deploy returned an invalid result") + {:error, deployed, failed, remaining, retained} + end + end + + defp deploy_android_payload_plan(device, plan, identity, lease, lock_runner, opts) do + serial = device.serial + package = identity.package + runner = Keyword.get(opts, :android_runner, &run_adb/1) + + fenced_runner = fn args -> + case payload_deploy_barrier(plan, identity, lease, lock_runner) do + :ok -> runner.(args) + {:error, _reason} -> {:error, "Android payload or deploy lease changed"} + end + end + + restart = Map.fetch!(plan.restart_by_serial, serial) + app_data = "/data/data/#{package}/files" + beams_dir = "#{app_data}/otp/#{app_name()}" + + with :installed <- + probe_installed_android_package(serial, package, plan, identity, lease, lock_runner), + :ok <- ensure_erts_on_device(serial, package, fenced_runner), + :ok <- + verify_elixir_runtime_version_android( + serial, + package, + app_data, + plan.beam.runtime_version, + fenced_runner + ), + {:ok, %{beam_checks: beam_checks}} <- registered_android_payload(plan), + :ok <- + push_staged_beams( + fenced_runner, + serial, + package, + beams_dir, + plan.beam.archive.path, + plan.beam.stage_device, + plan.beam.app_stage, + plan.beam.app_backup, + plan.beam.activation_lock, + beam_checks + ), + :ok <- deploy_payload_exqlite(serial, package, plan.exqlite, fenced_runner), + :ok <- + if(restart.restart?, + do: + restart_android( + serial, + [ + package: restart.package, + activity: restart.activity, + dist_port: restart.dist_port, + node_suffix: restart.node_suffix, + sleeper: Keyword.get(opts, :sleeper, &:timer.sleep/1), + operation_authority: {plan, identity, lease, lock_runner} + ], + fenced_runner + ), + else: :ok + ) do + {:ok, device} + else + :absent -> {:error, "Native Android target became unavailable after install"} + {:error, reason} -> {:error, bounded_deploy_reason(reason)} + _invalid -> {:error, "Android payload deployment failed closed"} + end + end + + defp deploy_payload_exqlite(_serial, _package, nil, _runner), do: :ok + + defp deploy_payload_exqlite(serial, package, exqlite, runner) do + live_dir = "/data/data/#{package}/files/otp/lib/exqlite-#{exqlite.app_version}" + + with {:ok, nif_target} <- resolve_exqlite_nif_target(serial, package, runner, []), + :ok <- + push_staged_exqlite( + runner, + serial, + package, + exqlite.archive.path, + exqlite.stage_device, + live_dir, + exqlite.app_stage, + exqlite.app_backup, + exqlite.activation_lock, + nif_target, + exqlite.beam_sentinel + ) do + :ok + end + end + + defp probe_installed_android_package(serial, package, plan, identity, lease, lock_runner) do + with :ok <- payload_deploy_barrier(plan, identity, lease, lock_runner) do + lock_runner.(["-s", serial, "shell", "pm", "list", "packages", package]) + |> classify_android_package_probe(package) + end + end + + defp payload_deploy_barrier(plan, identity, lease, lock_runner) do + with true <- valid_deploy_payload?(plan, identity), + :ok <- verify_android_lease_set(lease, lock_runner) do + :ok + else + false -> {:error, :payload_changed} + {:error, _failure} = error -> error + end + end + + defp validate_android_operation_authority( + {plan, %{package: package, serials: serials} = identity, lease, lock_runner}, + serial, + package + ) + when is_list(serials) and is_function(lock_runner, 1) do + if serial in serials and is_map(lease) and lease.serials == serials do + case payload_deploy_barrier(plan, identity, lease, lock_runner) do + :ok -> :ok + {:error, _reason} -> {:error, "Android operation authority is not current"} + end + else + {:error, "Android operation authority does not cover this target"} + end + end + + defp validate_android_operation_authority(_authority, _serial, _package), + do: {:error, "Android mutation requires an operation-wide deploy lease"} + + defp fenced_android_operation_runner(authority, serial, package, runner) do + fn args -> + case validate_android_operation_authority(authority, serial, package) do + :ok -> runner.(args) + {:error, _reason} -> {:error, "Android operation authority changed"} + end + end + end + + defp verify_android_lease_set(lease, lock_runner) do + Enum.reduce_while(lease.serials, :ok, fn serial, :ok -> + case AndroidDeployLock.verify_owner(lease, serial, lock_runner) do + :ok -> {:cont, :ok} + {:error, failure} -> {:halt, {:error, failure}} + end + end) + end + + defp retained_lease_after_failure(lease, lock_runner) do + case verify_android_lease_set(lease, lock_runner) do + :ok -> %{lease | state: :retained_failure} + {:error, %{lease: retained}} -> retained + {:error, _failure} -> %{lease | state: :retained_ambiguous} + end + end + + defp canonical_android_devices_for_failure(opts) do + opts + |> Keyword.get(:canonical_android_serials, []) + |> Enum.filter(&is_binary/1) + |> Enum.map(&%Device{platform: :android, serial: &1}) + end + + defp failed_android_device(%Device{} = device, reason) do + %{device | status: :error, error: bounded_deploy_reason(reason)} + end + + defp bounded_deploy_reason(reason) when is_binary(reason) do + if String.valid?(reason), do: String.slice(reason, 0, 512), else: "Android deploy failed" + end + + defp bounded_deploy_reason(_reason), do: "Android deploy failed" + + defp run_android_lock_command(args) do + System.cmd("adb", args, stderr_to_stdout: true) + rescue + _error -> {"", 1} + catch + _kind, _reason -> {"", 1} + end + + @doc """ + Discovers devices, pushes BEAMs, and optionally restarts apps. + Returns `{deployed, failed, skipped}` lists of `%Device{}`. + `skipped` is the deploy-isn't-applicable case — e.g. the app + isn't installed on a device because only the other platform was + built. Distinct from `failed` (real error during push). + """ + @spec deploy_all(keyword()) :: {[Device.t()], [Device.t()], [Device.t()]} + def deploy_all(opts \\ []) do + {result, _lease} = deploy_all_with_lease(opts) + result + end + + defp deploy_all_unleased(opts) do + restart = Keyword.get(opts, :restart, true) + platforms = Keyword.get(opts, :platforms, [:android, :ios]) + force_fs = Keyword.get(opts, :force_fs, false) + device_id = Keyword.get(opts, :device, nil) + ios_device_id = Keyword.get(opts, :ios_device, nil) + canonical_android_serials = Keyword.get(opts, :canonical_android_serials, nil) + android_lister = Keyword.get(opts, :android_lister, &Android.list_devices/0) + ios_lister = Keyword.get(opts, :ios_lister, &IOS.list_devices/0) + device_deployer = Keyword.get(opts, :device_deployer, nil) + beam_flags = Keyword.get(opts, :beam_flags, nil) + ios_restart_opts = Keyword.take(opts, [:ios_launcher, :ios_physical_restarter]) + beam_dirs = collect_beam_dirs() + + android = + if :android in platforms, + do: + android_lister.() + |> select_android_devices!(device_id, canonical_android_serials), + else: [] + + ios = + if :ios in platforms, + do: ios_lister.() |> filter_by_device_id(ios_device_id || device_id), + else: [] + + all = android ++ ios + + if all == [] do + IO.puts(" #{color(:yellow)}No devices found.#{color(:reset)}") + {[], [], []} + else + IO.puts(" Pushing #{count_beams(beam_dirs)} BEAM file(s) to #{length(all)} device(s)...") + + # Try Erlang dist first — hot-loads modules with no restart. We set up + # tunnels and attempt Node.connect for each device; those that respond + # get BEAMs via RPC, the rest fall back to adb/cp + restart. + # force_fs: true skips dist and always writes to the filesystem — required + # after a native build/install where the old BEAM process is dead. + dist_nodes = if force_fs or is_function(device_deployer, 1), do: [], else: connect_dist(all) + + # Manual overrides from `mix mob.deploy --dist-port N --node-suffix X`. + # When set, all targeted devices share the same port/suffix (the user + # is being explicit about a single device they care about). The + # auto-allocated per-device values (one port per index, suffix per + # serial/UDID) only apply when these are nil. + dist_port_override = Keyword.get(opts, :dist_port) + node_suffix_override = Keyword.get(opts, :node_suffix) + + results = + all + |> Enum.map(fn device -> + IO.write(" #{device.name || device.serial} → pushing...") + # Serial-derived so the port a device is deployed to listen on matches + # what `mix mob.connect` later forwards to (same crc32(serial) base). + dist_port = dist_port_override || Tunnel.serial_base_port(device.serial) + node = Device.node_name(device) + + {method, result} = + cond do + is_function(device_deployer, 1) -> + {:injected, device_deployer.(device)} + + node in dist_nodes -> + {:dist, push_via_dist(node, device)} + + true -> + fallback = + case device.platform do + :android -> + deploy_android(device, beam_dirs, + restart: restart, + dist_port: dist_port, + node_suffix: node_suffix_override, + beam_flags: beam_flags, + android_deploy_lock: Keyword.get(opts, :android_deploy_lock) + ) + + :ios -> + ios_opts = + [ + restart: restart, + dist_port: dist_port, + node_suffix: node_suffix_override, + beam_flags: beam_flags + ] ++ ios_restart_opts + + deploy_ios(device, beam_dirs, ios_opts) + end + + {:adb, fallback} + end + + case result do + {:ok, d} -> + suffix = if method == :dist, do: " (dist, no restart)", else: "" + IO.puts(" #{color(:green)}✓#{suffix}#{color(:reset)}") + {:ok, d} + + {:skipped, reason} -> + # Yellow dash, not a red x — this device wasn't a target. + IO.puts( + " #{color(:yellow)}—#{color(:reset)} #{color(:faint)}#{reason}#{color(:reset)}" + ) + + {:skipped, %{device | status: :skipped, error: reason}} + + {:error, reason} -> + IO.puts(" #{color(:red)}✗#{color(:reset)}") + IO.puts(" #{color(:red)}#{reason}#{color(:reset)}") + {:error, %{device | status: :error, error: reason}} + end + end) + + categorize_results(results) + end + end + + @doc """ + Bucket a per-device results list into `{deployed, failed, skipped}`. + + Three outcomes: + + * `:ok` — push succeeded → `deployed` + * `:skipped` — device wasn't a target (e.g. app not installed, + typical when only one platform was built) → `skipped` + * `:error` — push attempted and failed for a real reason → `failed` + + Public so the categorization invariant (skipped never crosses into + failed; an unknown outcome isn't silently dropped) can be tested + independent of the hardware-dependent push pipeline. + """ + @spec categorize_results([{:ok | :skipped | :error, Device.t()}]) :: + {[Device.t()], [Device.t()], [Device.t()]} + def categorize_results(results) do + deployed = for {:ok, d} <- results, do: d + failed = for {:error, d} <- results, do: d + skipped = for {:skipped, d} <- results, do: d + {deployed, failed, skipped} + end + + @doc """ + True when the `adb shell pm list packages <pkg>` output indicates + `<pkg>` is installed on the device. + + The check is a substring match for `package:<pkg>` because adb's + output is one `package:<name>` line per matching package — empty + output means "no match" (not "package called empty"). + + Public so the rule can be regression-tested without an emulator. + """ + @spec android_package_installed?(String.t(), String.t()) :: boolean() + def android_package_installed?(pm_output, package_name) when is_binary(pm_output) do + if byte_size(pm_output) <= @max_android_query_output_bytes and String.valid?(pm_output) do + marker = "package:#{package_name}" + + pm_output + |> String.split("\n") + |> Enum.any?(&(String.trim(&1) == marker)) + else + false + end + end + + @doc false + @spec classify_android_package_probe(term(), String.t()) :: + :installed | :absent | {:error, String.t()} + def classify_android_package_probe({output, 0}, package_name) when is_binary(output) do + cond do + byte_size(output) > @max_android_query_output_bytes or not String.valid?(output) -> + {:error, "verify installed Android app failed: invalid adb output"} + + android_package_installed?(output, package_name) -> + :installed + + true -> + :absent + end + end + + def classify_android_package_probe({_output, status}, _package_name) when is_integer(status), + do: {:error, "verify installed Android app failed"} + + def classify_android_package_probe(_result, _package_name), + do: {:error, "verify installed Android app failed: invalid command result"} + + # ── Device filtering ───────────────────────────────────────────────────────── + + @doc false + @spec select_canonical_android_devices([Device.t()], [String.t()]) :: + {:ok, [Device.t()]} | {:error, atom()} + def select_canonical_android_devices(devices, canonical_serials) + when is_list(devices) and is_list(canonical_serials) do + cond do + canonical_serials == [] -> + {:error, :invalid_canonical_targets} + + Enum.any?(canonical_serials, &(not is_binary(&1) or &1 == "" or not String.valid?(&1))) -> + {:error, :invalid_canonical_targets} + + Enum.uniq(canonical_serials) != canonical_serials -> + {:error, :duplicate_canonical_target} + + true -> + Enum.reduce_while(canonical_serials, {:ok, []}, fn serial, {:ok, selected} -> + case select_canonical_android_device(devices, serial) do + {:ok, device} -> {:cont, {:ok, [device | selected]}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + |> case do + {:ok, selected} -> {:ok, Enum.reverse(selected)} + {:error, reason} -> {:error, reason} + end + end + end + + def select_canonical_android_devices(_devices, _canonical_serials), + do: {:error, :invalid_canonical_targets} + + defp select_android_devices!(devices, device_id, nil) do + devices + |> Enum.reject(&(&1.status == :unauthorized)) + |> filter_by_device_id(device_id) + end + + defp select_android_devices!(devices, _device_id, canonical_serials) do + case select_canonical_android_devices(devices, canonical_serials) do + {:ok, selected} -> + selected + + {:error, _reason} -> + Mix.raise( + "Canonical Android target set no longer matches discovery; refusing final deploy" + ) + end + end + + defp select_canonical_android_device(devices, serial) do + case_insensitive = Enum.filter(devices, &same_android_serial?(&1, serial, :case_insensitive)) + exact = Enum.filter(case_insensitive, &same_android_serial?(&1, serial, :exact)) + + cond do + exact == [] and case_insensitive != [] -> {:error, :canonical_case_collision} + exact == [] -> {:error, :canonical_target_missing} + length(exact) > 1 -> {:error, :canonical_target_duplicated} + length(case_insensitive) > 1 -> {:error, :canonical_case_collision} + not canonical_android_device_ready?(hd(exact)) -> {:error, :canonical_target_unavailable} + true -> {:ok, hd(exact)} + end + end + + defp same_android_serial?(%Device{serial: candidate}, serial, :exact), + do: candidate == serial + + defp same_android_serial?(%Device{serial: candidate}, serial, :case_insensitive) + when is_binary(candidate) do + String.valid?(candidate) and String.downcase(candidate) == String.downcase(serial) + end + + defp same_android_serial?(_device, _serial, :case_insensitive), do: false + + defp canonical_android_device_ready?(%Device{platform: :android, status: status}), + do: status in [:discovered, :connected, :tunneled] + + defp canonical_android_device_ready?(_device), do: false + + defp filter_by_device_id(devices, nil), do: devices + + defp filter_by_device_id(devices, id) do + case Enum.filter(devices, &Device.match_id?(&1, id)) do + [] -> + IO.puts(" #{color(:red)}No device matched \"#{id}\".#{color(:reset)}") + + IO.puts( + " Run #{color(:cyan)}mix mob.devices#{color(:reset)} to see available device IDs." + ) + + [] + + matched -> + matched + end + end + + # ── Android ───────────────────────────────────────────────────────────────── + + defp deploy_android(%Device{} = device, beam_dirs, opts) do + deploy_android_device(device, beam_dirs, opts) + end + + @doc false + @spec deploy_android_device(Device.t(), [String.t()], keyword(), keyword()) :: + {:ok | :skipped | :error, Device.t() | String.t()} + def deploy_android_device(%Device{serial: serial}, _beam_dirs, _opts, _deps \\ []) do + with :ok <- validate_adb_serial(serial) do + {:error, + "Direct Android device mutation is disabled; use deploy_all/1 for a fenced transaction"} + end + end + + # Verify the OTP runtime (erts-X.Y/bin/erl_child_setup) is present on + # the device. Without this, the BEAM can't start — symlinks fail with + # ENOENT, the app crashes immediately. This typically happens when the + # device wasn't connected during a previous `mix mob.deploy --native`. + # + # Returns :ok if ERTS is present, {:error, message} with a helpful hint + # if missing. + @doc false + @spec ensure_erts_on_device(String.t(), String.t(), ([String.t()] -> tuple())) :: + :ok | {:error, String.t()} + def ensure_erts_on_device(serial, pkg, runner \\ &run_adb/1) do + with :ok <- validate_adb_serial(serial), + :ok <- validate_android_package(pkg) do + # The wildcard must be expanded *inside* the run-as sandbox — `run-as` + # itself does not invoke a shell, and the outer adb-shell shell can't + # see /data/data/<pkg>/, so expand the wildcard in an app-context shell. + # `test -r` deliberately has no output contract: exit status is the + # authoritative readability check. + cmd = + "run-as #{pkg} sh -c 'test -r /data/data/#{pkg}/files/otp/erts-*/bin/erl_child_setup'" + + case runner.(["-s", serial, "shell", cmd]) do + {:ok, _out} -> + :ok + + {:error, _reason} -> + {:error, + "Could not verify OTP runtime on #{bounded_device_label(serial)}; adb probe failed"} + + _other -> + {:error, + "Could not verify OTP runtime on #{bounded_device_label(serial)}: invalid adb result"} + end + end + end + + # If the Elixir stdlib on the device was installed by a different Elixir version + # than the host (e.g. after an Elixir upgrade), regex literals and other stdlib + # internals will be incompatible. An online three-directory replacement cannot + # be made atomic with the app BEAM swap, so fail closed and require the native + # deployment path to replace the complete OTP runtime. + @doc false + @spec verify_elixir_runtime_version_android( + String.t(), + String.t(), + String.t(), + String.t(), + ([String.t()] -> tuple()) + ) :: :ok | {:error, String.t()} + def verify_elixir_runtime_version_android(serial, pkg, app_data, host_vsn, runner) do + with :ok <- validate_adb_serial(serial), + :ok <- validate_android_package(pkg), + :ok <- validate_android_app_data(app_data, pkg), + true <- is_binary(host_vsn) do + elixir_app = "#{app_data}/otp/lib/elixir/ebin/elixir.app" + + case runner.(["-s", serial, "shell", "run-as #{pkg} cat #{elixir_app}"]) do + {:ok, content} + when is_binary(content) and byte_size(content) <= @max_android_elixir_app_bytes -> + if String.valid?(content) and MobDev.AppFile.vsn_from_content(content) == host_vsn do + :ok + else + {:error, "Elixir runtime version mismatch; rerun mix mob.deploy --native"} + end + + {:ok, content} when is_binary(content) -> + {:error, "Could not verify Elixir runtime version: output_too_large"} + + {:error, _reason} -> + {:error, "Could not verify Elixir runtime version; rerun mix mob.deploy --native"} + + _other -> + {:error, "Could not verify Elixir runtime version: invalid adb result"} + end + else + false -> {:error, "Invalid host Elixir version; refusing Android deploy"} + {:error, _reason} = error -> error + end + end + + @doc false + @spec setup_exqlite_android_runas(String.t(), String.t(), String.t(), keyword()) :: + :ok | {:error, String.t()} + def setup_exqlite_android_runas(serial, exqlite_ebin, vsn, opts \\ []) do + package = Keyword.get(opts, :package, android_package()) + app_data = Keyword.get(opts, :app_data, android_app_data()) + runner = Keyword.get(opts, :runner, &run_adb/1) + local_runner = Keyword.get(opts, :local_runner, &run_local_command/3) + tmp_root = Keyword.get(opts, :tmp_root, System.tmp_dir!()) + + with :ok <- validate_adb_serial(serial), + :ok <- validate_android_package(package), + :ok <- validate_android_app_data(app_data, package), + :ok <- + validate_android_operation_authority( + Keyword.get(opts, :operation_authority), + serial, + package + ), + :ok <- validate_exqlite_version(vsn), + {:ok, beam_sentinel} <- validate_exqlite_source(exqlite_ebin, vsn), + {:ok, attempt_id} <- android_attempt_id(opts) do + runner = + fenced_android_operation_runner( + Keyword.fetch!(opts, :operation_authority), + serial, + package, + runner + ) + + stage_local = Path.join(tmp_root, "mob_exqlite_#{attempt_id}.tar") + stage_device = "/data/local/tmp/mob_exqlite_#{attempt_id}.tar" + tmp = Path.join(tmp_root, "mob_exqlite_stage_#{attempt_id}") + lib_parent = "#{app_data}/otp/lib" + live_dir = "#{lib_parent}/exqlite-#{vsn}" + app_stage = "#{lib_parent}/.mob_exqlite_stage_#{attempt_id}" + app_backup = "#{lib_parent}/.mob_exqlite_backup_#{attempt_id}" + activation_lock = "#{lib_parent}/.mob_exqlite_activation_lock" + + local_result = + try do + File.rm_rf!(tmp) + + with :ok <- prepare_exqlite_local_stage(tmp), + :ok <- + checked_local_command(local_runner, "stage exqlite ebin", "cp", [ + "-r", + "#{exqlite_ebin}/.", + Path.join(tmp, "ebin") + ]), + :ok <- + checked_local_command( + local_runner, + "create exqlite archive", + "tar", + ["cf", stage_local, "-C", tmp, "."], + env: [{"COPYFILE_DISABLE", "1"}] + ) do + :ok + end + after + File.rm_rf(tmp) + end + + try do + case local_result do + :ok -> + with {:ok, nif_target} <- resolve_exqlite_nif_target(serial, package, runner, opts) do + push_staged_exqlite( + runner, + serial, + package, + stage_local, + stage_device, + live_dir, + app_stage, + app_backup, + activation_lock, + nif_target, + beam_sentinel + ) + end + + {:error, _reason} = error -> + error + end + after + File.rm(stage_local) + end + end + end + + defp prepare_exqlite_local_stage(tmp) do + with :ok <- File.mkdir_p(Path.join(tmp, "ebin")), + :ok <- File.mkdir_p(Path.join(tmp, "priv")) do + :ok + else + {:error, _reason} -> {:error, "prepare local exqlite stage failed"} + end + end + + defp validate_exqlite_source(exqlite_ebin, expected_vsn) when is_binary(exqlite_ebin) do + beam_sentinels = + exqlite_ebin + |> Path.join("*.beam") + |> Path.wildcard() + |> Enum.filter(&File.regular?/1) + |> Enum.map(&Path.basename/1) + |> Enum.sort() + + app_file = Path.join(exqlite_ebin, "exqlite.app") + + with true <- File.dir?(exqlite_ebin), + {:ok, app_content} <- File.read(app_file), + true <- byte_size(app_content) <= @max_android_query_output_bytes, + true <- String.valid?(app_content), + ^expected_vsn <- MobDev.AppFile.vsn_from_content(app_content), + [beam_sentinel | _] <- beam_sentinels, + {:ok, safe_sentinel} <- validate_beam_sentinel(beam_sentinel) do + {:ok, safe_sentinel} + else + _ -> {:error, "Configured exqlite ebin is incomplete; refusing Android deploy"} + end + end + + defp validate_exqlite_source(_exqlite_ebin, _expected_vsn), + do: {:error, "Configured exqlite ebin is invalid; refusing Android deploy"} + + defp validate_exqlite_version(vsn) when is_binary(vsn) do + if byte_size(vsn) in 1..128 and String.valid?(vsn) and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9._-]+\\z"), vsn), + do: :ok, + else: {:error, "Invalid exqlite version; refusing Android deploy"} + end + + defp validate_exqlite_version(_vsn), + do: {:error, "Invalid exqlite version; refusing Android deploy"} + + defp resolve_exqlite_nif_target(serial, package, runner, opts) do + case Keyword.fetch(opts, :nif_target) do + {:ok, target} -> + validate_nif_target(target) + + :error -> + with {:ok, path_output} <- + checked_android_query(runner, "locate Android package", [ + "-s", + serial, + "shell", + "pm path #{package}" + ]), + {:ok, apk_dir} <- android_apk_dir(path_output), + {:ok, nif_output} <- + checked_android_query(runner, "locate exqlite NIF", [ + "-s", + serial, + "shell", + "ls #{apk_dir}/lib/*/libsqlite3_nif.so 2>/dev/null" + ]), + {:ok, target} <- exact_exqlite_nif_target(nif_output), + {:ok, safe_target} <- validate_nif_target(target) do + {:ok, safe_target} + else + {:error, _reason} = error -> error + end + end + end + + defp android_apk_dir(path_output) when is_binary(path_output) do + if byte_size(path_output) <= @max_android_query_output_bytes and String.valid?(path_output) do + directories = + path_output + |> String.split("\n", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reduce_while([], fn + "package:" <> path, directories -> + if safe_android_device_path?(path) do + {:cont, [Path.dirname(path) | directories]} + else + {:halt, :invalid} + end + + _unexpected_line, _directories -> + {:halt, :invalid} + end) + + case directories do + directories when is_list(directories) -> + case Enum.uniq(directories) do + [directory] -> + {:ok, directory} + + _none_or_ambiguous -> + {:error, "Ambiguous Android package path; refusing exqlite setup"} + end + + :invalid -> + {:error, "Invalid Android package path; refusing exqlite setup"} + end + else + {:error, "Invalid Android package path; refusing exqlite setup"} + end + end + + defp validate_nif_target(target) when is_binary(target) do + if safe_android_device_path?(target) and String.ends_with?(target, "/libsqlite3_nif.so") do + {:ok, target} + else + {:error, "Invalid exqlite NIF path; refusing Android deploy"} + end + end + + defp validate_nif_target(_target), + do: {:error, "Invalid exqlite NIF path; refusing Android deploy"} + + defp exact_exqlite_nif_target(output) when is_binary(output) do + targets = normalized_exqlite_nif_targets(String.split(output, "\n", trim: true)) + + case targets do + [target] -> {:ok, target} + [] -> {:error, "Could not locate exqlite NIF; refusing Android deploy"} + _multiple -> {:error, "Ambiguous exqlite NIF targets; refusing Android deploy"} + end + end + + defp normalized_exqlite_nif_targets(lines) when is_list(lines) do + lines + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.filter(&String.ends_with?(&1, "/libsqlite3_nif.so")) + |> Enum.uniq() + end + + defp normalized_exqlite_nif_targets(_lines), do: [] + + defp safe_android_device_path?(path) do + is_binary(path) and byte_size(path) <= 1_024 and String.valid?(path) and + Regex.match?(Regex.compile!("\\A/[A-Za-z0-9._/+=~:-]+\\z"), path) and + not Enum.member?(Path.split(path), "..") + end + + defp push_staged_exqlite( + runner, + serial, + package, + stage_local, + stage_device, + live_dir, + app_stage, + app_backup, + activation_lock, + nif_target, + beam_sentinel + ) do + push_result = + checked_android_command(runner, "push exqlite archive", [ + "-s", + serial, + "push", + stage_local, + stage_device + ]) + + case push_result do + :ok -> + deploy_result = + with :ok <- + checked_android_command(runner, "prepare exqlite staging directory", [ + "-s", + serial, + "shell", + "run-as #{package} sh -c 'test ! -e #{app_backup} && rm -rf #{app_stage} && mkdir -p #{app_stage}'" + ]), + :ok <- + checked_android_command(runner, "extract exqlite archive", [ + "-s", + serial, + "shell", + "run-as #{package} tar xof #{stage_device} -C #{app_stage}/" + ]), + :ok <- + checked_android_command(runner, "link staged exqlite NIF", [ + "-s", + serial, + "shell", + "run-as #{package} ln -sf #{nif_target} #{app_stage}/priv/sqlite3_nif.so" + ]), + :ok <- + verify_staged_exqlite(runner, serial, package, app_stage, beam_sentinel), + :ok <- + checked_android_command(runner, "activate exqlite runtime", [ + "-s", + serial, + "shell", + exqlite_activation_command( + package, + live_dir, + app_stage, + app_backup, + activation_lock, + beam_sentinel + ) + ]), + :ok <- + verify_active_exqlite( + runner, + serial, + package, + live_dir, + beam_sentinel + ), + :ok <- + checked_android_command(runner, "release exqlite activation lock", [ + "-s", + serial, + "shell", + android_activation_lock_release_command(package, activation_lock) + ]), + :ok <- + checked_android_command(runner, "clean exqlite activation backup", [ + "-s", + serial, + "shell", + android_activation_backup_cleanup_command(package, app_backup) + ]) do + :ok + end + + case deploy_result do + :ok -> + merge_deploy_and_cleanup_results( + :ok, + cleanup_android_exqlite_stage( + runner, + serial, + package, + stage_device, + app_stage + ) + ) + + {:error, _reason} = error -> + error + end + + {:error, _reason} = error -> + error + end + end + + defp verify_staged_exqlite(runner, serial, package, app_stage, beam_sentinel) do + checked_android_command(runner, "verify staged exqlite runtime", [ + "-s", + serial, + "shell", + "run-as #{package} sh -c 'test -r #{app_stage}/ebin/exqlite.app && test -r #{app_stage}/ebin/#{beam_sentinel} && test -L #{app_stage}/priv/sqlite3_nif.so && test -r #{app_stage}/priv/sqlite3_nif.so'" + ]) + end + + defp verify_active_exqlite(runner, serial, package, live_dir, beam_sentinel) do + checked_android_command(runner, "verify active exqlite runtime", [ + "-s", + serial, + "shell", + "run-as #{package} sh -c 'test -r #{live_dir}/ebin/exqlite.app && test -r #{live_dir}/ebin/#{beam_sentinel} && test -L #{live_dir}/priv/sqlite3_nif.so && test -r #{live_dir}/priv/sqlite3_nif.so'" + ]) + end + + defp exqlite_activation_command( + package, + live_dir, + app_stage, + app_backup, + activation_lock, + beam_sentinel + ) do + checks = + "test -r #{live_dir}/ebin/exqlite.app && " <> + "test -r #{live_dir}/ebin/#{beam_sentinel} && " <> + "test -L #{live_dir}/priv/sqlite3_nif.so && test -r #{live_dir}/priv/sqlite3_nif.so" + + "run-as #{package} sh -c 'set -e; mkdir #{activation_lock}; had_live=0; " <> + "if [ -e #{live_dir} ]; then mv #{live_dir} #{app_backup}; had_live=1; fi; " <> + "if mv #{app_stage} #{live_dir} && #{checks}; then :; " <> + "else rm -rf #{live_dir}; if [ \"$had_live\" -eq 1 ]; then " <> + "mv #{app_backup} #{live_dir}; fi; exit 1; fi'" + end + + defp cleanup_android_exqlite_stage( + runner, + serial, + package, + stage_device, + app_stage + ) do + cleanup_results = [ + checked_android_command(runner, "clean app-private exqlite staging directory", [ + "-s", + serial, + "shell", + "run-as #{package} rm -rf #{app_stage}" + ]), + cleanup_remote_exqlite_archive(runner, serial, stage_device) + ] + + Enum.find(cleanup_results, :ok, &match?({:error, _reason}, &1)) + end + + defp cleanup_remote_exqlite_archive(runner, serial, stage_device) do + checked_android_command(runner, "clean remote exqlite archive", [ + "-s", + serial, + "shell", + "rm -f #{stage_device}" + ]) + end + + defp checked_android_query(runner, operation, args) do + case runner.(args) do + {:ok, output} + when is_binary(output) and byte_size(output) <= @max_android_query_output_bytes -> + if String.valid?(output), + do: {:ok, output}, + else: {:error, "#{operation} failed: invalid adb output"} + + {:ok, _output} -> + {:error, "#{operation} failed: invalid adb output"} + + {:error, _reason} -> + {:error, "#{operation} failed"} + + _other -> + {:error, "#{operation} failed: invalid adb result"} + end + end + + # The native lib lands under `lib/<abi>/` — `arm64-v8a` → "arm64", + # `armeabi-v7a` → "arm". Android extracts only the device's active ABI, so a + # glob matches exactly one file. Probe for it rather than assuming 64-bit, so + # 32-bit devices (older / low-end phones) get a real target instead of a + # dangling `lib/arm64` symlink (which left exqlite `:nif_not_loaded` and + # crashed boot). Returns the absolute path or nil. + @doc false + @spec __sqlite_nif_target__([String.t()]) :: String.t() | nil + def __sqlite_nif_target__(ls_lines) do + case normalized_exqlite_nif_targets(ls_lines) do + [target] -> target + _none_or_ambiguous -> nil + end + end + + defp exqlite_version, do: MobDev.AppFile.dep_version(:exqlite) + + @doc false + @spec push_beams_android_runas(String.t(), [String.t()], keyword()) :: + :ok | {:error, String.t()} + def push_beams_android_runas(serial, beam_dirs, opts \\ []) do + package = Keyword.get(opts, :package, android_package()) + beams_dir = Keyword.get(opts, :beams_dir, android_beams_dir()) + runner = Keyword.get(opts, :runner, &run_adb/1) + local_runner = Keyword.get(opts, :local_runner, &run_local_command/3) + file_writer = Keyword.get(opts, :file_writer, &File.write/2) + tmp_root = Keyword.get(opts, :tmp_root, System.tmp_dir!()) + beam_flags = Keyword.get(opts, :beam_flags) + priv_dir = Keyword.get(opts, :priv_dir) + + with :ok <- validate_adb_serial(serial), + :ok <- validate_android_package(package), + :ok <- validate_android_beams_dir(beams_dir, package), + :ok <- + validate_android_operation_authority( + Keyword.get(opts, :operation_authority), + serial, + package + ), + {:ok, attempt_id} <- android_attempt_id(opts) do + runner = + fenced_android_operation_runner( + Keyword.fetch!(opts, :operation_authority), + serial, + package, + runner + ) + + stage_local = Path.join(tmp_root, "mob_beams_#{attempt_id}.tar") + stage_device = "/data/local/tmp/mob_beams_#{attempt_id}.tar" + tmp = Path.join(tmp_root, "mob_beam_stage_#{attempt_id}") + app_stage = "#{Path.dirname(beams_dir)}/.mob_beams_stage_#{attempt_id}" + app_backup = "#{Path.dirname(beams_dir)}/.mob_beams_backup_#{attempt_id}" + activation_lock = "#{Path.dirname(beams_dir)}/.mob_beams_activation_lock" + + local_result = + try do + File.rm_rf!(tmp) + File.mkdir_p!(tmp) + + with {:ok, sentinel} <- beam_sentinel(beam_dirs), + :ok <- stage_android_beam_dirs(beam_dirs, tmp, local_runner), + {:ok, flag_checks} <- stage_android_beam_flags(tmp, beam_flags, file_writer), + {:ok, priv_checks} <- stage_android_priv(tmp, priv_dir, local_runner), + :ok <- + checked_local_command( + local_runner, + "create BEAM archive", + "tar", + ["cf", stage_local, "-C", tmp, "."], + env: [{"COPYFILE_DISABLE", "1"}] + ) do + {:ok, [{:file, sentinel} | flag_checks ++ priv_checks]} + end + after + File.rm_rf(tmp) + end + + try do + case local_result do + {:ok, verification_checks} -> + push_staged_beams( + runner, + serial, + package, + beams_dir, + stage_local, + stage_device, + app_stage, + app_backup, + activation_lock, + verification_checks + ) + + {:error, _reason} = error -> + error + end + after + File.rm(stage_local) + end + end + end + + defp push_staged_beams( + runner, + serial, + package, + beams_dir, + stage_local, + stage_device, + app_stage, + app_backup, + activation_lock, + verification_checks + ) do + push_result = + checked_android_command(runner, "push BEAM archive", [ + "-s", + serial, + "push", + stage_local, + stage_device + ]) + + case push_result do + :ok -> + deploy_result = + with :ok <- + checked_android_command(runner, "prepare BEAM directory", [ + "-s", + serial, + "shell", + "run-as #{package} sh -c 'test ! -e #{app_backup} && rm -rf #{app_stage} && mkdir -p #{app_stage}'" + ]), + :ok <- + checked_android_command(runner, "extract BEAM archive", [ + "-s", + serial, + "shell", + "run-as #{package} tar xof #{stage_device} -C #{app_stage}/" + ]), + :ok <- + verify_android_payload( + serial, + package, + app_stage, + verification_checks, + runner + ), + :ok <- + checked_android_command(runner, "activate deployed BEAMs", [ + "-s", + serial, + "shell", + beam_activation_command( + package, + beams_dir, + app_stage, + app_backup, + activation_lock, + verification_checks + ) + ]), + :ok <- + verify_android_payload( + serial, + package, + beams_dir, + verification_checks, + runner + ), + :ok <- + checked_android_command(runner, "release BEAM activation lock", [ + "-s", + serial, + "shell", + android_activation_lock_release_command(package, activation_lock) + ]), + :ok <- + checked_android_command(runner, "clean BEAM activation backup", [ + "-s", + serial, + "shell", + android_activation_backup_cleanup_command(package, app_backup) + ]) do + :ok + end + + case deploy_result do + :ok -> + merge_deploy_and_cleanup_results( + :ok, + cleanup_android_beam_stage(runner, serial, package, stage_device, app_stage) + ) + + {:error, _reason} = error -> + error + end + + {:error, _reason} = error -> + error + end end - @doc """ - Discovers devices, pushes BEAMs, and optionally restarts apps. - Returns `{deployed, failed}` lists of `%Device{}`. - """ - @spec deploy_all(keyword()) :: {[Device.t()], [Device.t()]} - def deploy_all(opts \\ []) do - restart = Keyword.get(opts, :restart, true) - platforms = Keyword.get(opts, :platforms, [:android, :ios]) - force_fs = Keyword.get(opts, :force_fs, false) - beam_dirs = collect_beam_dirs() + @doc false + @spec restart_android(String.t(), keyword(), ([String.t()] -> tuple())) :: + :ok | {:error, String.t()} + def restart_android(serial, opts, runner \\ &run_adb/1) do + with :ok <- validate_adb_serial(serial) do + dist_port = Keyword.get(opts, :dist_port, 9100) + node_suffix = Keyword.get(opts, :node_suffix) || Android.device_node_suffix(serial) + package = Keyword.get(opts, :package, android_package()) + activity = Keyword.get(opts, :activity, @android_activity) + sleeper = Keyword.get(opts, :sleeper, &:timer.sleep/1) - android = if :android in platforms, - do: Android.list_devices() |> Enum.reject(&(&1.status == :unauthorized)), - else: [] - ios = if :ios in platforms, do: IOS.list_simulators(), else: [] - all = android ++ ios + with :ok <- validate_android_package(package), + :ok <- validate_android_activity(activity), + :ok <- validate_android_node_suffix(node_suffix), + :ok <- validate_android_dist_port(dist_port), + :ok <- + validate_android_operation_authority( + Keyword.get(opts, :operation_authority), + serial, + package + ) do + runner = + fenced_android_operation_runner( + Keyword.fetch!(opts, :operation_authority), + serial, + package, + runner + ) - if all == [] do - IO.puts(" #{color(:yellow)}No devices found.#{color(:reset)}") - {[], []} + restart_stopped_android( + serial, + package, + activity, + dist_port, + node_suffix, + sleeper, + runner + ) + end + end + end + + defp restart_stopped_android( + serial, + package, + activity, + dist_port, + node_suffix, + sleeper, + runner + ) do + with :ok <- + checked_android_command(runner, "force-stop Android app", [ + "-s", + serial, + "shell", + "am", + "force-stop", + package + ]) do + sleeper.(300) + + checked_android_launch(runner, [ + "-s", + serial, + "shell", + "am", + "start", + "-W", + "-n", + "#{package}/#{activity}", + "--ei", + "mob_dist_port", + to_string(dist_port), + "--es", + "mob_node_suffix", + node_suffix + ]) + end + end + + defp beam_sentinel(beam_dirs) do + sentinels = + beam_dirs + |> Enum.flat_map(&Path.wildcard(Path.join(&1, "*.beam"))) + |> Enum.filter(&File.regular?/1) + |> Enum.sort() + + case sentinels do + [sentinel | _] -> validate_beam_sentinel(Path.basename(sentinel)) + [] -> {:error, "No local BEAM sentinel found; refusing Android deploy"} + end + end + + defp validate_beam_sentinel(sentinel) + when is_binary(sentinel) and byte_size(sentinel) <= 255 do + if String.valid?(sentinel) and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_.-]+\\.beam\\z"), sentinel) do + {:ok, sentinel} else - IO.puts(" Pushing #{count_beams(beam_dirs)} BEAM file(s) to #{length(all)} device(s)...") + {:error, "Unsafe local BEAM sentinel name; refusing Android deploy"} + end + end - # Try Erlang dist first — hot-loads modules with no restart. We set up - # tunnels and attempt Node.connect for each device; those that respond - # get BEAMs via RPC, the rest fall back to adb/cp + restart. - # force_fs: true skips dist and always writes to the filesystem — required - # after a native build/install where the old BEAM process is dead. - dist_nodes = if force_fs, do: [], else: connect_dist(all) + defp validate_beam_sentinel(_sentinel), + do: {:error, "Unsafe local BEAM sentinel name; refusing Android deploy"} + + defp stage_android_beam_dirs(beam_dirs, tmp, local_runner) do + Enum.reduce_while(beam_dirs, :ok, fn dir, :ok -> + case checked_local_command( + local_runner, + "stage BEAM files", + "cp", + ["-r", "#{dir}/.", tmp] + ) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp stage_android_beam_flags(_tmp, nil, _file_writer), do: {:ok, []} + + defp stage_android_beam_flags(tmp, flags, file_writer) when is_binary(flags) do + if byte_size(flags) <= 4_096 and String.valid?(flags) do + case file_writer.(Path.join(tmp, "mob_beam_flags"), flags) do + :ok -> {:ok, [{:file, "mob_beam_flags"}]} + {:error, _reason} -> {:error, "stage Android BEAM flags failed"} + end + else + {:error, "stage Android BEAM flags failed: invalid flags"} + end + end + + defp stage_android_beam_flags(_tmp, _flags, _file_writer), + do: {:error, "stage Android BEAM flags failed: invalid flags"} + + defp stage_android_priv(_tmp, nil, _local_runner), do: {:ok, []} + + defp stage_android_priv(tmp, priv_dir, local_runner) when is_binary(priv_dir) do + with true <- File.dir?(priv_dir), + {:ok, verification_check} <- android_priv_verification_check(priv_dir), + :ok <- File.mkdir_p(Path.join(tmp, "priv")), + :ok <- + checked_local_command(local_runner, "stage Android priv files", "cp", [ + "-r", + "#{priv_dir}/.", + Path.join(tmp, "priv") + ]) do + {:ok, [verification_check]} + else + false -> {:error, "stage Android priv files failed: directory missing"} + {:error, reason} = error when is_binary(reason) -> error + {:error, _reason} -> {:error, "stage Android priv files failed"} + end + end + + defp stage_android_priv(_tmp, _priv_dir, _local_runner), + do: {:error, "stage Android priv files failed: invalid directory"} + + defp android_priv_verification_check(priv_dir) do + safe_file = + priv_dir + |> Path.join("**/*") + |> Path.wildcard() + |> Enum.filter(&File.regular?/1) + |> Enum.map(&Path.relative_to(&1, priv_dir)) + |> Enum.sort() + |> Enum.find(&safe_android_relative_path?/1) + + case {safe_file, File.ls(priv_dir)} do + {safe_file, _listing} when is_binary(safe_file) -> + {:ok, {:file, Path.join("priv", safe_file)}} + + {nil, {:ok, []}} -> + {:ok, {:dir, "priv"}} + + {nil, {:ok, _entries}} -> + {:error, "No safe Android priv sentinel found; refusing deploy"} + + {nil, {:error, _reason}} -> + {:error, "Could not inspect Android priv directory; refusing deploy"} + end + end + + defp safe_android_relative_path?(path) when is_binary(path) do + byte_size(path) <= 1_024 and String.valid?(path) and not String.starts_with?(path, "/") and + not Enum.member?(Path.split(path), "..") and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_./-]+\\z"), path) + end + + defp verify_android_payload(serial, package, beams_dir, checks, runner) do + shell_checks = android_payload_checks(beams_dir, checks) + + checked_android_command(runner, "verify deployed BEAM", [ + "-s", + serial, + "shell", + "run-as #{package} sh -c '#{shell_checks}'" + ]) + end - results = all |> Enum.with_index() |> Enum.map(fn {device, idx} -> - IO.write(" #{device.name || device.serial} → pushing...") - dist_port = Tunnel.dist_port(idx) - node = Device.node_name(device) + defp beam_activation_command( + package, + beams_dir, + app_stage, + app_backup, + activation_lock, + checks + ) do + shell_checks = android_payload_checks(beams_dir, checks) - {method, result} = - if node in dist_nodes do - {:dist, push_via_dist(node, device)} + "run-as #{package} sh -c 'set -e; mkdir #{activation_lock}; had_live=0; " <> + "if [ -e #{beams_dir} ]; then mv #{beams_dir} #{app_backup}; had_live=1; fi; " <> + "if mv #{app_stage} #{beams_dir} && #{shell_checks}; then " <> + ":; else rm -rf #{beams_dir}; " <> + "if [ \"$had_live\" -eq 1 ]; then mv #{app_backup} #{beams_dir}; fi; exit 1; fi'" + end + + defp android_activation_lock_release_command(package, activation_lock), + do: "run-as #{package} rmdir #{activation_lock}" + + defp android_activation_backup_cleanup_command(package, app_backup), + do: "run-as #{package} rm -rf #{app_backup}" + + defp android_payload_checks(base_dir, checks) do + Enum.map_join(checks, " && ", fn + {:file, relative_path} -> "test -r #{Path.join(base_dir, relative_path)}" + {:dir, relative_path} -> "test -d #{Path.join(base_dir, relative_path)}" + end) + end + + defp cleanup_android_beam_stage( + runner, + serial, + package, + stage_device, + app_stage + ) do + cleanup_results = [ + checked_android_command(runner, "clean app-private BEAM staging directory", [ + "-s", + serial, + "shell", + "run-as #{package} rm -rf #{app_stage}" + ]), + cleanup_remote_beam_archive(runner, serial, stage_device) + ] + + Enum.find(cleanup_results, :ok, &match?({:error, _reason}, &1)) + end + + defp cleanup_remote_beam_archive(runner, serial, stage_device) do + checked_android_command(runner, "clean remote BEAM archive", [ + "-s", + serial, + "shell", + "rm -f #{stage_device}" + ]) + end + + defp merge_deploy_and_cleanup_results(:ok, :ok), do: :ok + + defp merge_deploy_and_cleanup_results(:ok, {:error, _reason} = cleanup_error), + do: cleanup_error + + defp checked_android_command(runner, operation, args) do + case runner.(args) do + {:ok, output} + when is_binary(output) and byte_size(output) <= @max_android_query_output_bytes -> + if String.valid?(output), + do: :ok, + else: {:error, "#{operation} failed: invalid adb output"} + + {:ok, _output} -> + {:error, "#{operation} failed: invalid adb output"} + + {:error, reason} -> + {:error, android_command_error(operation, reason)} + + _other -> + {:error, "#{operation} failed: invalid adb result"} + end + end + + defp checked_android_launch(runner, args) do + case runner.(args) do + {:ok, output} + when is_binary(output) and byte_size(output) <= @max_android_launch_output_bytes -> + if String.valid?(output) do + lines = output |> String.split("\n") |> Enum.map(&String.trim/1) + status_lines = Enum.filter(lines, &String.starts_with?(&1, "Status:")) + + if status_lines == ["Status: ok"] and + not Enum.any?(lines, &String.starts_with?(&1, "Error")) do + :ok else - fallback = case device.platform do - :android -> deploy_android(device, beam_dirs, restart: restart, dist_port: dist_port) - :ios -> deploy_ios(device, beam_dirs, restart: restart, dist_port: dist_port) - end - {:adb, fallback} + {:error, "launch Android app failed: adb returned no success status"} end - - case result do - {:ok, d} -> - suffix = if method == :dist, do: " (dist, no restart)", else: "" - IO.puts(" #{color(:green)}✓#{suffix}#{color(:reset)}") - {:ok, d} - {:error, reason} -> - IO.puts(" #{color(:red)}✗#{color(:reset)}") - IO.puts(" #{color(:red)}#{reason}#{color(:reset)}") - {:error, %{device | status: :error, error: reason}} + else + {:error, "launch Android app failed: invalid adb output"} end - end) - deployed = for {:ok, d} <- results, do: d - failed = for {:error, d} <- results, do: d - {deployed, failed} + {:ok, _output} -> + {:error, "launch Android app failed: invalid adb output"} + + {:error, _reason} -> + {:error, "launch Android app failed"} + + _other -> + {:error, "launch Android app failed: invalid adb result"} end end - # ── Android ───────────────────────────────────────────────────────────────── + defp checked_local_command(local_runner, operation, executable, args, opts \\ []) do + case local_runner.(executable, args, Keyword.put_new(opts, :stderr_to_stdout, true)) do + {output, 0} + when is_binary(output) and byte_size(output) <= @max_android_query_output_bytes -> + if String.valid?(output), + do: :ok, + else: {:error, "#{operation} failed: invalid command output"} - defp deploy_android(%Device{serial: serial} = device, beam_dirs, opts) do - restart = Keyword.get(opts, :restart, true) - dist_port = Keyword.get(opts, :dist_port, 9100) + {_output, 0} -> + {:error, "#{operation} failed: invalid command output"} - case push_beams_android(serial, beam_dirs) do - :ok -> - if restart, do: restart_android(serial, dist_port: dist_port) - {:ok, device} - {:error, reason} -> - {:error, reason} + {output, _status} -> + {:error, android_command_error(operation, output)} + + _other -> + {:error, "#{operation} failed: invalid command result"} end end - defp push_beams_android(serial, beam_dirs) do - # Try adb root first (works on emulators and eng builds). - # Check the output text — non-rooted devices return exit 0 with - # "cannot run as root in production builds". - rooted? = case run_adb(["-s", serial, "root"]) do - {:ok, out} -> out =~ "restarting" or out =~ "already running as root" - _ -> false + defp run_local_command(executable, args, opts) do + System.cmd(executable, args, Keyword.put_new(opts, :stderr_to_stdout, true)) + end + + defp android_command_error(operation, _output), do: "#{operation} failed" + + defp android_attempt_id(opts) do + attempt_id = + case Keyword.get(opts, :attempt_id) do + nil -> :crypto.strong_rand_bytes(12) |> Base.url_encode64(padding: false) + attempt_id -> attempt_id + end + + if is_binary(attempt_id) and String.valid?(attempt_id) and + Regex.match?(Regex.compile!(@android_attempt_id_pattern), attempt_id) do + {:ok, attempt_id} + else + {:error, "Invalid Android deploy attempt id; refusing BEAM delivery"} end + end - if rooted? do - :timer.sleep(600) - run_adb(["-s", serial, "shell", "mkdir -p #{android_beams_dir()}"]) - result = Enum.reduce_while(beam_dirs, :ok, fn dir, _ -> - case run_adb(["-s", serial, "push", "#{Path.expand(dir)}/.", - "#{android_beams_dir()}/"]) do - {:ok, _} -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, "push failed: #{reason}"}} - end - end) - # Fix SELinux MCS categories on pushed files. adb push (as root) labels - # files with the root process's categories, not the app's. restorecon - # only restores the type label — it cannot fix MCS categories. We use - # chcon with the context from the app's own files/ directory instead. - run_adb(["-s", serial, "shell", - "chcon -hR $(stat -c %C #{android_app_data()}) #{android_app_data()}/otp"]) - result + defp validate_adb_serial(serial) when is_binary(serial) do + valid? = + byte_size(serial) in 1..@max_adb_serial_bytes and not String.starts_with?(serial, "-") and + serial + |> :binary.bin_to_list() + |> Enum.all?(fn byte -> + byte in ?0..?9 or byte in ?A..?Z or byte in ?a..?z or byte in ~c".:-_" + end) + + if valid? do + :ok + else + {:error, "Invalid adb serial; refusing BEAM delivery"} + end + end + + defp validate_adb_serial(_serial), + do: {:error, "Invalid adb serial; refusing BEAM delivery"} + + defp validate_android_package(package) when is_binary(package) do + if byte_size(package) <= 255 and String.valid?(package) and + Regex.match?( + Regex.compile!("\\A[A-Za-z][A-Za-z0-9_]*(?:\\.[A-Za-z0-9_]+)+\\z"), + package + ) do + :ok + else + {:error, "Invalid Android package; refusing deploy"} + end + end + + defp validate_android_package(_package), + do: {:error, "Invalid Android package; refusing deploy"} + + defp validate_android_app_data(app_data, package) do + if app_data == "/data/data/#{package}/files" do + :ok + else + {:error, "Invalid Android app-data path; refusing deploy"} + end + end + + defp validate_android_beams_dir(beams_dir, package) when is_binary(beams_dir) do + app_data = "/data/data/#{package}/files" + + if safe_android_device_path?(beams_dir) and + String.starts_with?(beams_dir, "#{app_data}/otp/") do + :ok else - # Fall back to run-as tar (non-rooted physical devices). - push_beams_android_runas(serial, beam_dirs) + {:error, "Invalid Android BEAM path; refusing deploy"} end end - defp push_beams_android_runas(serial, beam_dirs) do - stage_local = System.tmp_dir!() |> Path.join("mob_beams_#{serial}.tar") - stage_device = "/data/local/tmp/mob_beams.tar" + defp validate_android_beams_dir(_beams_dir, _package), + do: {:error, "Invalid Android BEAM path; refusing deploy"} + + defp validate_android_activity(activity) when is_binary(activity) do + if byte_size(activity) in 1..255 and String.valid?(activity) and + Regex.match?(Regex.compile!("\\A\\.?[A-Za-z][A-Za-z0-9_.]*\\z"), activity), + do: :ok, + else: {:error, "Invalid Android activity; refusing launch"} + end + + defp validate_android_activity(_activity), + do: {:error, "Invalid Android activity; refusing launch"} + + defp validate_android_node_suffix(node_suffix) when is_binary(node_suffix) do + if byte_size(node_suffix) in 1..128 and String.valid?(node_suffix) and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_]+\\z"), node_suffix), + do: :ok, + else: {:error, "Invalid Android node suffix; refusing launch"} + end + + defp validate_android_node_suffix(_node_suffix), + do: {:error, "Invalid Android node suffix; refusing launch"} + + defp validate_android_dist_port(port) when is_integer(port) and port in 1..65_535, do: :ok + + defp validate_android_dist_port(_port), + do: {:error, "Invalid Android distribution port; refusing launch"} + + defp bounded_device_label(serial) when is_binary(serial), + do: String.slice(serial, 0, 128) + + # ── iOS ───────────────────────────────────────────────────────────────────── + + defp deploy_ios(%Device{type: :physical} = device, beam_dirs, opts) do + deploy_ios_physical(device, beam_dirs, opts) + end + + defp deploy_ios(device, beam_dirs, opts) do + deploy_ios_simulator(device, beam_dirs, opts) + end + + defp deploy_ios_simulator(%Device{serial: udid} = device, beam_dirs, opts) do + restart = Keyword.get(opts, :restart, true) + dist_port = Keyword.get(opts, :dist_port, 9100) + beam_flags = Keyword.get(opts, :beam_flags, nil) try do - tmp = Path.join(System.tmp_dir!(), "mob_beam_stage_#{serial}") - File.rm_rf!(tmp) - File.mkdir_p!(tmp) + File.mkdir_p!(ios_beams_dir()) + # Use rsync rather than cp -r for two reasons that hit Nix users hard: + # + # 1. macOS BSD `cp` preserves source mode in practice (despite what the + # man page implies). Sources in /nix/store are mode 444, so cp leaves + # 444 files in our managed runtime dir; the next deploy then trips on + # `cp: cannot create regular file ... Permission denied` when trying + # to overwrite them. + # + # 2. cp -r unconditionally overwrites every file, even when nothing has + # changed. rsync's mtime+size check skips identical files, so a + # no-op deploy after a previous successful one is genuinely a no-op. + # + # `--no-perms` keeps existing destination permissions untouched and uses + # the user's umask for newly-created files (so we don't propagate Nix's + # 444 mode). rsync's atomic-rename writer can also overwrite a 444 + # destination cleanly without needing a chmod first. Enum.each(beam_dirs, fn dir -> - System.cmd("cp", ["-r", "#{dir}/.", tmp], stderr_to_stdout: true) + abs_dir = Path.expand(dir) + + case System.cmd( + "rsync", + ["-a", "--no-perms", "#{abs_dir}/", "#{ios_beams_dir()}/"], + stderr_to_stdout: true + ) do + {_, 0} -> :ok + {out, _} -> throw({:error, "rsync failed: #{out}"}) + end end) - case System.cmd("tar", ["cf", stage_local, "-C", Path.dirname(tmp), - Path.basename(tmp)], stderr_to_stdout: true) do - {_, 0} -> :ok - {out, _} -> throw({:error, "tar create failed: #{out}"}) - end + # Push priv/ alongside the BEAMs so migrations and other priv assets are + # available at runtime. On iOS, beams_dir = $RUNTIME_DIR/APP_NAME and + # MOB_DATA_DIR = the app's Documents directory — these are two different + # paths, so we can't derive beams_dir from MOB_DATA_DIR. mob_beam.m sets + # MOB_BEAMS_DIR=beams_dir explicitly so app code always knows where to look. + local_priv = Path.join(File.cwd!(), "priv") - case run_adb(["-s", serial, "push", stage_local, stage_device]) do - {:ok, _} -> :ok - {:error, r} -> throw({:error, "adb push failed: #{r}"}) + if File.dir?(local_priv) do + priv_dest = Path.join(ios_beams_dir(), "priv") + File.mkdir_p!(priv_dest) + + case System.cmd( + "rsync", + ["-a", "--no-perms", "#{Path.expand(local_priv)}/", "#{priv_dest}/"], + stderr_to_stdout: true + ) do + {_, 0} -> :ok + {out, _} -> IO.puts(" (warning: iOS priv push failed: #{out})") + end end - run_adb(["-s", serial, "shell", - "run-as #{android_package()} mkdir -p #{android_beams_dir()}"]) + if beam_flags do + File.write!(Path.join(ios_beams_dir(), "mob_beam_flags"), beam_flags) + end - cmd = "run-as #{android_package()} tar xf #{stage_device} " <> - "-C #{android_beams_dir()}/ --strip-components=1" - case run_adb(["-s", serial, "shell", cmd]) do - {:ok, _} -> :ok - {:error, r} -> throw({:error, "run-as tar failed: #{r}"}) + case restart_ios_simulator(restart, udid, ios_bundle_id(), + dist_port: dist_port, + node_suffix: Keyword.get(opts, :node_suffix), + ios_launcher: Keyword.get(opts, :ios_launcher, &IOS.launch_app/3) + ) do + :ok -> :ok + {:error, reason} -> throw({:error, reason}) end - run_adb(["-s", serial, "shell", "rm -f #{stage_device}"]) - :ok + {:ok, device} catch {:error, reason} -> {:error, reason} - after - File.rm(stage_local) end end - defp restart_android(serial, opts) do - dist_port = Keyword.get(opts, :dist_port, 9100) - run_adb(["-s", serial, "shell", "am", "force-stop", android_package()]) - # Heal SELinux MCS category mismatch before start — APK reinstall changes - # the app's category but leaves OTP files with stale labels. chcon copies - # the correct context from the app's own files/ directory. - run_adb(["-s", serial, "shell", - "chcon -hR $(stat -c %C #{android_app_data()}) #{android_app_data()}/otp"]) - :timer.sleep(300) - run_adb(["-s", serial, "shell", "am", "start", - "-n", "#{android_package()}/#{@android_activity}", - "--ei", "mob_dist_port", to_string(dist_port)]) - :ok - end + # Physical iOS deploy: push BEAMs into the app's Documents container via + # `xcrun devicectl`. mob_beam.m (MOB_BUNDLE_OTP build) checks + # Documents/otp/<app>/ at startup and prefers it over the read-only in-bundle + # copy, enabling fast deploys without a full Xcode rebuild. + # + # The merged staging dir is named <app> so that devicectl's directory-copy + # semantics land the files at Documents/otp/<app>/ on device. + defp deploy_ios_physical(%Device{serial: udid} = device, beam_dirs, opts) do + restart = Keyword.get(opts, :restart, true) + beam_flags = Keyword.get(opts, :beam_flags, nil) + bundle = ios_bundle_id() + app = app_name() - # ── iOS ───────────────────────────────────────────────────────────────────── + # When discovered via WiFi-only EPMD scan the serial is the IP address, which + # xcrun devicectl does not accept as a --device argument. Resolve to a hardware + # UDID before proceeding. + udid = resolve_ios_udid_if_ip(udid) - defp deploy_ios(%Device{serial: udid} = device, beam_dirs, opts) do - restart = Keyword.get(opts, :restart, true) - dist_port = Keyword.get(opts, :dist_port, 9100) + if Regex.match?(Regex.compile!("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"), udid) do + throw( + {:error, + "device only reachable via WiFi (#{udid}) — use `mix mob.push` for BEAM-only updates, or connect via USB for a native deploy"} + ) + end + + # Stage all BEAMs (and priv/) into a temp dir named <app>. + staging_parent = + Path.join(System.tmp_dir!(), "mob_ios_deploy_#{:erlang.unique_integer([:positive])}") + + staging_dir = Path.join(staging_parent, app) + File.mkdir_p!(staging_dir) try do - File.mkdir_p!(ios_beams_dir()) Enum.each(beam_dirs, fn dir -> - abs_dir = Path.expand(dir) - case System.cmd("cp", ["-r", "#{abs_dir}/.", ios_beams_dir()], stderr_to_stdout: true) do + case System.cmd("cp", ["-r", "#{Path.expand(dir)}/.", staging_dir], + stderr_to_stdout: true + ) do {_, 0} -> :ok {out, _} -> throw({:error, "cp failed: #{out}"}) end end) - if restart do - IOS.terminate_app(udid, ios_bundle_id()) - :timer.sleep(300) - IOS.launch_app(udid, ios_bundle_id(), dist_port: dist_port) + local_priv = Path.join(File.cwd!(), "priv") + + if File.dir?(local_priv) do + priv_dest = Path.join(staging_dir, "priv") + File.mkdir_p!(priv_dest) + + case System.cmd("cp", ["-r", "#{Path.expand(local_priv)}/.", priv_dest], + stderr_to_stdout: true + ) do + {_, 0} -> :ok + {out, _} -> IO.puts(" (warning: priv copy failed: #{out})") + end + end + + if beam_flags do + File.write!(Path.join(staging_dir, "mob_beam_flags"), beam_flags) + end + + # devicectl copies the contents of --source into --destination. + # To land BEAMs at Documents/otp/<app>/, the destination must include + # the app subdirectory explicitly (staging_dir naming alone is not enough). + case System.cmd( + "xcrun", + [ + "devicectl", + "device", + "copy", + "to", + "--device", + udid, + "--domain-type", + "appDataContainer", + "--domain-identifier", + bundle, + "--source", + staging_dir, + "--destination", + "Documents/otp/#{app}" + ], + stderr_to_stdout: true + ) do + {_, 0} -> + :ok + + {out, _} -> + reason = + if String.contains?(out, "ContainerLookupErrorDomain") do + """ + App '#{bundle}' is not installed on this device. + + To fix this, you need to build and install the app on the device first. + The easiest way is to open the ios/ directory in Xcode and run on device: + + open ios/*.xcodeproj (or ios/*.xcworkspace) + + Then select your device in Xcode and press Run (⌘R). + + Alternatively, if you have another app with a different bundle ID already + installed on the device, update bundle_id in mob.exs to match it: + + config :mob_dev, bundle_id: "com.yourcompany.yourapp" + """ + else + "devicectl copy failed: #{out}" + end + + throw({:error, reason}) + end + + case restart_ios_physical(restart, udid, bundle, + ios_physical_restarter: + Keyword.get(opts, :ios_physical_restarter, &IOS.restart_app_physical/2) + ) do + :ok -> :ok + {:error, reason} -> throw({:error, reason}) end {:ok, device} catch {:error, reason} -> {:error, reason} + after + File.rm_rf!(staging_parent) + end + end + + @doc false + @spec restart_ios_simulator(boolean(), String.t(), String.t(), keyword()) :: + :ok | {:error, String.t()} + def restart_ios_simulator(false, _udid, _bundle, _opts), do: :ok + + def restart_ios_simulator(true, udid, bundle, opts) + when is_binary(udid) and is_binary(bundle) and is_list(opts) do + launcher = Keyword.get(opts, :ios_launcher, &IOS.launch_app/3) + + execute_ios_restart(fn -> + launcher.(udid, bundle, + dist_port: Keyword.get(opts, :dist_port), + node_suffix: Keyword.get(opts, :node_suffix) + ) + end) + end + + def restart_ios_simulator(_restart, _udid, _bundle, _opts), + do: {:error, "iOS simulator restart inputs are invalid"} + + @doc false + @spec restart_ios_physical(boolean(), String.t(), String.t(), keyword()) :: + :ok | {:error, String.t()} + def restart_ios_physical(false, _udid, _bundle, _opts), do: :ok + + def restart_ios_physical(true, udid, bundle, opts) + when is_binary(udid) and is_binary(bundle) and is_list(opts) do + restarter = Keyword.get(opts, :ios_physical_restarter, &IOS.restart_app_physical/2) + execute_ios_restart(fn -> restarter.(udid, bundle) end) + end + + def restart_ios_physical(_restart, _udid, _bundle, _opts), + do: {:error, "iOS physical restart inputs are invalid"} + + @doc false + @spec execute_ios_restart((-> term())) :: :ok | {:error, String.t()} + def execute_ios_restart(restart) when is_function(restart, 0) do + try do + interpret_ios_restart_result(restart.()) + catch + _kind, _reason -> {:error, "iOS app restart failed before an authoritative result"} + end + end + + def execute_ios_restart(_invalid), + do: {:error, "iOS app restart callback is invalid"} + + @doc false + @spec interpret_ios_restart_result(term()) :: :ok | {:error, String.t()} + def interpret_ios_restart_result({output, 0}) when is_binary(output), do: :ok + + def interpret_ios_restart_result({_output, status}) when is_integer(status) do + {:error, "iOS app restart failed with exit status #{status}"} + end + + def interpret_ios_restart_result(_malformed), + do: {:error, "iOS app restart returned a malformed result"} + + # ── iOS WiFi UDID resolution ────────────────────────────────────────────────── + + # When a physical device was discovered only via LAN EPMD scan (no USB), its + # serial is the IP address. xcrun devicectl requires a hardware UDID or + # CoreDevice UUID for --device. This function resolves an IP to a UDID. + defp resolve_ios_udid_if_ip(udid) do + if Regex.match?(Regex.compile!("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"), udid) do + resolve_udid_from_ip(udid) || udid + else + udid + end + end + + defp resolve_udid_from_ip(ip) do + # Strategy 1: idevice_id -n lists network-connected device UDIDs. + # If exactly one is found, it must be the WiFi-only device we're targeting. + # Strategy 2: xcrun devicectl list devices, subtract known USB devices. + from_idevice_id_network(ip) || + from_devicectl_list(ip) + end + + defp from_idevice_id_network(ip) do + with true <- not is_nil(System.find_executable("idevice_id")), + {out, 0} <- System.cmd("idevice_id", ["-n"], stderr_to_stdout: true), + udids <- + out |> String.split("\n") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")), + true <- udids != [] do + # With a single network device, return it immediately without querying its IP. + # With multiple, use ideviceinfo to find which UDID has the target IP. + case udids do + [single] -> + single + + many -> + Enum.find_value(many, fn udid -> + case System.cmd("ideviceinfo", ["--network", "-u", udid, "-k", "IPAddress"], + stderr_to_stdout: true + ) do + {ip_out, 0} -> + if String.trim(ip_out) == ip, do: udid, else: nil + + _ -> + nil + end + end) + end + else + _ -> nil + end + end + + defp from_devicectl_list(_ip) do + usb_udids = + if System.find_executable("idevice_id") do + case System.cmd("idevice_id", ["-l"], stderr_to_stdout: true) do + {out, 0} -> + out + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> MapSet.new() + + _ -> + MapSet.new() + end + else + MapSet.new() + end + + with {json, 0} <- + System.cmd("xcrun", ["devicectl", "list", "devices", "--json-output", "-"], + stderr_to_stdout: true + ), + {:ok, data} <- Jason.decode(json), + devices <- get_in(data, ["result", "devices"]) || [], + wifi_only <- + Enum.reject(devices, fn d -> + hw_udid = get_in(d, ["hardwareProperties", "udid"]) || "" + MapSet.member?(usb_udids, hw_udid) + end), + [device] <- wifi_only, + udid when not is_nil(udid) <- get_in(device, ["hardwareProperties", "udid"]) do + udid + else + _ -> nil end end @@ -249,6 +3465,7 @@ defmodule MobDev.Deployer do # connected node atoms. Devices that don't respond are left for the adb fallback. defp connect_dist(devices) do ensure_local_dist() + Enum.flat_map(devices, fn device -> node = Device.node_name(device) Node.set_cookie(node, @cookie) @@ -265,10 +3482,35 @@ defmodule MobDev.Deployer do end end - # Push all compiled BEAMs to a single dist-connected node. + # Push all compiled BEAMs to a single dist-connected node, then trigger + # a re-render of the currently displayed screen so the user sees the changes + # immediately without a full restart. + # + # WHY THE RE-RENDER MESSAGE IS NECESSARY + # + # Erlang hot code loading (`code:load_binary`) replaces the module in the + # code server but does NOT cause running processes to re-execute. A + # Mob.Screen GenServer that is already mounted and displaying will continue + # to sit in its receive loop waiting for the next message. Until something + # sends it a message, `render/1` never runs again — so the user sees the + # old UI even though the new code is live in memory. + # + # The fix: immediately after the push, RPC-send `:__mob_hot_reload__` to the + # `:mob_screen` registered process on the device. Mob.Screen's handle_info + # catch-all receives it, delegates to the user module's handle_info (which + # ignores unknown messages), then calls do_render/2 using the now-current + # version of the screen module. The screen repaints with the new code, with + # no restart and no loss of GenServer state. + # + # This is why `mix mob.deploy` appeared to do nothing before this fix — the + # code WAS pushed correctly, the screen just had no trigger to repaint. defp push_via_dist(node, device) do {_pushed, failed} = HotPush.push_all([node]) + if failed == [] do + # Best-effort: ignored if no screen is currently registered (nav edge + # cases, app in background, etc.). + :rpc.call(node, :erlang, :send, [:mob_screen, :__mob_hot_reload__]) {:ok, device} else mods = Enum.map_join(failed, ", ", fn {mod, _} -> inspect(mod) end) @@ -279,12 +3521,142 @@ defmodule MobDev.Deployer do # ── Helpers ────────────────────────────────────────────────────────────────── defp collect_beam_dirs do - case File.ls("_build/dev/lib") do - {:ok, libs} -> - libs - |> Enum.map(&"_build/dev/lib/#{&1}/ebin") - |> Enum.filter(&File.dir?/1) - {:error, _} -> [] + # Use the same runtime-dep filter as HotPush so we don't push dev-only + # tooling (mob_dev, credo, etc.) to the device filesystem. + app_dirs = HotPush.runtime_beam_dirs() + + # EEx is part of the Elixir stdlib but not in _build/dev/lib/. Ecto depends + # on it, so include it in every push so it lands in the flat beams_dir + # (which is already on the -pa code path on both Android and iOS). + eex_ebin = Path.join(to_string(:code.lib_dir(:eex)), "ebin") + stdlib_dirs = if File.dir?(eex_ebin), do: [eex_ebin], else: [] + + # ssl is a required OTP app (thousand_island lists it as a dependency) but the + # iOS and Android OTP builds omit it. ssl is pure Erlang (no NIFs), so host + # BEAM files run identically on both targets. For HTTP-only Phoenix at loopback, + # ssl starts but no TLS sockets are opened. + ssl_ebin = Path.join(to_string(:code.lib_dir(:ssl)), "ebin") + ssl_dirs = if File.dir?(ssl_ebin), do: [ssl_ebin], else: [] + + # crypto is a required OTP app. Older device-side OTP builds (pre-2026-05) + # were configured `--without-ssl` to skip OpenSSL, so the device runtime + # had no real crypto and we shipped a deliberately-insecure shim + # (md5-only hash, no x25519, no AEAD) just to let `ensure_all_started` + # succeed for HTTP-only Phoenix at loopback. + # + # Newer tarballs ship a real crypto.so NIF + crypto.beam built against + # OpenSSL 3.x. When the host has a cached OTP runtime that already + # contains crypto.beam, we MUST NOT push the shim — its `crypto.beam` + # would land first in the on-device code path and shadow the real one, + # making `crypto:generate_key/2` undef even though the NIF is loaded. + shim_dirs = + if real_device_crypto_available?() do + [] + else + case generate_crypto_shim() do + {:ok, dir} -> [dir] + _ -> [] + end + end + + app_dirs ++ stdlib_dirs ++ ssl_dirs ++ shim_dirs + end + + # Detects whether any cached device-side OTP runtime under ~/.mob/cache/ + # already ships a real crypto.beam. If yes, the deployer must skip the + # shim — pushing the shim's crypto.beam shadows the real one in the + # on-device code path, making :crypto.generate_key/2 (and friends) undef + # despite the NIF being loaded. + @spec real_device_crypto_available?() :: boolean() + defp real_device_crypto_available? do + cache = Path.join([System.user_home!(), ".mob", "cache"]) + + Path.wildcard(Path.join([cache, "otp-*", "lib", "crypto-*", "ebin", "crypto.beam"])) + |> Enum.any?() + end + + # Generates crypto.beam + crypto.app in a temp dir and returns {:ok, dir}. + # Returns {:error, reason} if erlc is not available. + # + # Used only as a fallback when the device-side OTP runtime was built + # `--without-ssl` (pre-2026-05 tarballs). Modern tarballs ship a real + # crypto.so NIF; in that case `real_device_crypto_available?/0` returns + # true and this shim is skipped. + @doc false + @spec generate_crypto_shim() :: {:ok, String.t()} | {:error, term()} + def generate_crypto_shim do + dir = Path.join(System.tmp_dir!(), "mob_crypto_shim") + File.mkdir_p!(dir) + + src = Path.join(dir, "crypto.erl") + + File.write!(src, """ + -module(crypto). + -export([strong_rand_bytes/1, hash/2, mac/4, mac/3, supports/1, pbkdf2_hmac/5, exor/2]). + + strong_rand_bytes(N) -> rand:bytes(N). + + hash(_Type, Data) -> erlang:md5(Data). + + %% HMAC-MD5 (ignores hash algorithm) — dev-only shim, no OpenSSL required. + mac(hmac, _Alg, Key, Data) -> hmac_md5(Key, Data); + mac(_Type, _SubType, _Key, _Data) -> <<>>. + + mac(hmac, Key, Data) -> hmac_md5(Key, Data); + mac(_Type, _Key, _Data) -> <<>>. + + supports(_Type) -> []. + + %% PBKDF2-HMAC shim using HMAC-MD5 as PRF. Not cryptographically secure; + %% suitable only for local dev on-device where 127.0.0.1 is the only listener. + pbkdf2_hmac(_Hash, Password0, Salt0, Iterations, DerivedLen) -> + Password = iolist_to_binary(Password0), + Salt = iolist_to_binary(Salt0), + Blocks = (DerivedLen + 15) div 16, + Derived = iolist_to_binary([pbkdf2_block(Password, Salt, Iterations, I) + || I <- lists:seq(1, Blocks)]), + binary:part(Derived, 0, DerivedLen). + + pbkdf2_block(Password, Salt, Iterations, BlockNum) -> + U1 = hmac_md5(Password, <<Salt/binary, BlockNum:32/big>>), + pbkdf2_iterate(Password, U1, Iterations - 1, U1). + + pbkdf2_iterate(_Password, _Prev, 0, Acc) -> Acc; + pbkdf2_iterate(Password, Prev, N, Acc) -> + U = hmac_md5(Password, Prev), + pbkdf2_iterate(Password, U, N - 1, xor_bins(Acc, U)). + + hmac_md5(Key0, Data0) -> + Key = iolist_to_binary(Key0), + Data = iolist_to_binary(Data0), + BS = 64, + K = case byte_size(Key) > BS of + true -> erlang:md5(Key); + false -> Key + end, + Pad = binary:copy(<<0>>, BS - byte_size(K)), + KPad = <<K/binary, Pad/binary>>, + IKey = << <<(X bxor 16#36)>> || <<X>> <= KPad >>, + OKey = << <<(X bxor 16#5c)>> || <<X>> <= KPad >>, + erlang:md5(<<OKey/binary, (erlang:md5(<<IKey/binary, Data/binary>>))/binary>>). + + xor_bins(A, B) -> + list_to_binary([X bxor Y || {X, Y} <- lists:zip(binary_to_list(A), binary_to_list(B))]). + + exor(A, B) -> + xor_bins(iolist_to_binary(A), iolist_to_binary(B)). + """) + + app = + "{application,crypto,[{modules,[crypto]},{applications,[kernel,stdlib]}," <> + "{description,\"Crypto shim for mobile (no OpenSSL; uses rand:bytes)\"}," <> + "{registered,[]},{vsn,\"5.6\"}]}." + + File.write!(Path.join(dir, "crypto.app"), app) + + case System.cmd("erlc", ["-o", dir, src], stderr_to_stdout: true) do + {_, 0} -> {:ok, dir} + {out, _} -> {:error, "crypto shim compile failed: #{out}"} end end @@ -299,13 +3671,15 @@ defmodule MobDev.Deployer do defp run_adb(args) do case System.cmd("adb", args, stderr_to_stdout: true) do - {output, 0} -> {:ok, String.trim(output)} - {output, _} -> {:error, String.trim(output)} + {output, 0} -> {:ok, output} + {output, _} -> {:error, output} end end - defp color(:green), do: IO.ANSI.green() + defp color(:green), do: IO.ANSI.green() defp color(:yellow), do: IO.ANSI.yellow() - defp color(:red), do: IO.ANSI.red() - defp color(:reset), do: IO.ANSI.reset() + defp color(:red), do: IO.ANSI.red() + defp color(:cyan), do: IO.ANSI.cyan() + defp color(:faint), do: IO.ANSI.faint() + defp color(:reset), do: IO.ANSI.reset() end diff --git a/lib/mob_dev/device.ex b/lib/mob_dev/device.ex index e23498a..fcb7ea0 100644 --- a/lib/mob_dev/device.ex +++ b/lib/mob_dev/device.ex @@ -7,15 +7,41 @@ defmodule MobDev.Device do @enforce_keys [:platform, :serial] defstruct [ - :platform, # :android | :ios - :serial, # "emulator-5554" | "R5CW3089HVB" | "78354490-EF38-..." - :name, # "Pixel 8" | "iPhone 17" - :version, # "Android 15" | "iOS 18" - :type, # :emulator | :simulator | :physical - :node, # :"mob_demo_android@127.0.0.1" - :dist_port, # 9100 - :status, # :discovered | :unauthorized | :tunneled | :connected | :error - :error # error message string if status == :error + # :android | :ios + :platform, + # "emulator-5554" | "R5CW3089HVB" | "78354490-EF38-..." + :serial, + # "Pixel 8" | "iPhone 17" + :name, + # "Android 15" | "iOS 18" + :version, + # :emulator | :simulator | :physical + :type, + # :"mob_demo_android@127.0.0.1" + :node, + # 9100 + :dist_port, + # Per-device suffix appended to the BEAM node name to keep concurrent + # devices distinguishable in Mac's EPMD. Auto-derived from the device + # serial (Android) or short UDID hex (iOS) by default; the `mix + # mob.deploy --node-suffix X` flag overrides for scripted scenarios + # (multiple builds on one sim, custom naming schemes). Sanitised + # (lowercase a-z0-9_) before being applied at launch time. + :node_suffix, + # Device IP for physical iOS: USB link-local (169.254.x.x), WiFi LAN, or Tailscale + :host_ip, + # :discovered | :unauthorized | :tunneled | :connected | :error + :status, + # error message string if status == :error + :error, + # Android: "arm64-v8a" | "armeabi-v7a" | "x86_64" | "x86" + # iOS: nil — Apple devices are arm64 across the supported floor (iOS 13+) + # and the simulator picks arch from the host. Captured via + # MobDev.SupportMatrix derivation, not adb getprop. + :abi, + # Android API level (29 = Android 10, 33 = Android 13, etc.) + # iOS major version as integer (17 from "iOS 17.4.1") + :sdk_level ] @doc """ @@ -40,42 +66,115 @@ defmodule MobDev.Device do @doc """ Returns the Erlang node name atom for a device. - Uses 127.0.0.1 for USB-connected devices (tunneled). - Node names are `<app>_<platform>@127.0.0.1` where `<app>` is the OTP - application name from the current Mix project (e.g. `my_app_android@127.0.0.1`). - - Multi-device support (where unique per-device names are needed) is future work - and will require the app to receive its node name dynamically via intent extras. + - Android (emulator/physical): `<app>_android_<serial-stub>@127.0.0.1` + (unique per device — Mac's EPMD is shared via adb-reverse so the suffix + is required to avoid collisions when two phones run the same app) + - iOS simulator: `<app>_ios_<8-char-udid>@127.0.0.1` (unique per simulator, + matches the name mob_beam.m builds using SIMULATOR_UDID) + - iOS physical: `<app>_ios@<device-ip>` (mob_beam.m finds IP: USB > WiFi/LAN > Tailscale) """ @spec node_name(t()) :: atom() + def node_name(%__MODULE__{platform: :android, serial: serial}) when is_binary(serial) do + suffix = MobDev.Discovery.Android.node_suffix_for(serial) + :"#{app_name()}_android_#{suffix}@127.0.0.1" + end + def node_name(%__MODULE__{platform: :android}) do :"#{app_name()}_android@127.0.0.1" end + def node_name(%__MODULE__{platform: :ios, host_ip: ip}) when is_binary(ip) do + :"#{app_name()}_ios@#{ip}" + end + + def node_name(%__MODULE__{platform: :ios, type: :simulator, serial: serial}) do + # SIMULATOR_UDID has the same value as the UDID we discover from simctl. + # mob_beam.m takes the first 8 hex chars (lowercase) for the unique suffix. + short = serial |> String.replace("-", "") |> String.slice(0, 8) |> String.downcase() + :"#{app_name()}_ios_#{short}@127.0.0.1" + end + def node_name(%__MODULE__{platform: :ios}) do :"#{app_name()}_ios@127.0.0.1" end defp app_name, do: Mix.Project.config()[:app] + @doc """ + Returns the short ID shown in `mix mob.devices` and accepted by `--device`. + + - Android: the serial as-is (`emulator-5554`, `R5CW3089HVB`) + - iOS simulator: first 8 hex chars of the UDID, lowercased (`78354490`) — + same prefix used in the node name + - iOS physical: full UDID + """ + @spec display_id(t()) :: String.t() + def display_id(%__MODULE__{platform: :android, serial: serial}), do: serial + + def display_id(%__MODULE__{platform: :ios, type: :simulator, serial: serial}) do + serial |> String.replace("-", "") |> String.slice(0, 8) |> String.downcase() + end + + def display_id(%__MODULE__{platform: :ios, serial: serial}), do: serial + + @doc """ + True for devices that aren't a development emulator/simulator. + + Used as a safety predicate by destructive Mix tasks (`mix + mob.uninstall --all-devices`) so that the broad-sweep flags only + hit dev-disposable targets by default. Sweeping a personal + iPhone or shared physical Android is opt-in via `--all-physical` + or `--device <id>`. + + iex> MobDev.Device.physical?(%MobDev.Device{type: :physical}) + true + + iex> MobDev.Device.physical?(%MobDev.Device{type: :emulator}) + false + + iex> MobDev.Device.physical?(%MobDev.Device{type: :simulator}) + false + """ + @spec physical?(t()) :: boolean() + def physical?(%__MODULE__{type: :physical}), do: true + def physical?(%__MODULE__{}), do: false + + @doc """ + Returns true if `input` identifies this device. + + Matches `display_id/1` or the full serial, case-insensitively. Used by + `mix mob.deploy --device <id>` to target a specific device. + """ + @spec match_id?(t(), String.t()) :: boolean() + def match_id?(%__MODULE__{} = device, input) when is_binary(input) do + normalized = String.downcase(input) + + String.downcase(display_id(device)) == normalized or + String.downcase(device.serial) == normalized + end + @doc "Human-readable one-line summary." @spec summary(t()) :: String.t() def summary(%__MODULE__{} = d) do - type_label = case d.type do - :emulator -> "emulator" - :simulator -> "simulator" - :physical -> "physical" - nil -> "device" - end - status_icon = case d.status do - :connected -> "✓" - :tunneled -> "⟳" - :discovered -> "·" - :unauthorized -> "✗" - :error -> "!" - _ -> "?" - end + type_label = + case d.type do + :emulator -> "emulator" + :simulator -> "simulator" + :physical -> "physical" + nil -> "device" + end + + status_icon = + case d.status do + :connected -> "✓" + :tunneled -> "⟳" + :discovered -> "·" + :unauthorized -> "✗" + :error -> "!" + _ -> "?" + end + name = d.name || d.serial version = if d.version, do: " (#{d.version})", else: "" "#{status_icon} #{name}#{version} [#{type_label}] #{d.serial}" diff --git a/lib/mob_dev/discovery/android.ex b/lib/mob_dev/discovery/android.ex index f579d71..cbae3d8 100644 --- a/lib/mob_dev/discovery/android.ex +++ b/lib/mob_dev/discovery/android.ex @@ -8,7 +8,7 @@ defmodule MobDev.Discovery.Android do def list_devices do case System.find_executable("adb") do nil -> [] - _ -> do_list() + _ -> do_list() end end @@ -19,7 +19,8 @@ defmodule MobDev.Discovery.Android do |> parse_devices_output() |> Enum.map(&enrich/1) - {:error, _} -> [] + {:error, _} -> + [] end end @@ -32,7 +33,8 @@ defmodule MobDev.Discovery.Android do def parse_devices_output(output) do output |> String.split("\n") - |> Enum.drop(1) # skip "List of devices attached" header + # skip "List of devices attached" header + |> Enum.drop(1) |> Enum.reject(&(String.trim(&1) == "")) |> Enum.map(&parse_device_line/1) |> Enum.reject(&is_nil/1) @@ -43,36 +45,128 @@ defmodule MobDev.Discovery.Android do # R5CW3089HVB unauthorized # 192.168.1.5:5555 device defp parse_device_line(line) do - case String.split(line, ~r/\s+/, parts: 2) do + # adb's output uses both spaces and tabs as field separators; split on + # the first whitespace run via a runtime-compiled regex. Compile-time + # `~r/\s+/` literals are unsafe on Elixir 1.19+ / OTP 28.0 because they + # rely on `:re.import/1`, which OTP 28.0 removed. + case String.split(line, Regex.compile!("\\s+"), parts: 2) do [serial, rest] -> cond do String.contains?(rest, "unauthorized") -> - %Device{platform: :android, serial: serial, status: :unauthorized, - error: "USB debugging not authorized — check device for prompt"} + %Device{ + platform: :android, + serial: serial, + status: :unauthorized, + error: "USB debugging not authorized — check device for prompt" + } + String.contains?(rest, "offline") -> nil + String.starts_with?(rest, "device") or String.starts_with?(rest, "no permissions") -> type = if String.starts_with?(serial, "emulator"), do: :emulator, else: :physical %Device{platform: :android, serial: serial, type: type, status: :discovered} + true -> nil end - _ -> nil + + _ -> + nil end end defp enrich(%Device{status: :unauthorized} = d), do: d + defp enrich(%Device{serial: serial} = d) do - name = getprop(serial, "ro.product.model") + name = getprop(serial, "ro.product.model") version = getprop(serial, "ro.build.version.release") - node = Device.node_name(d) - %{d | name: name, version: "Android #{version}", node: node} + abi = getprop(serial, "ro.product.cpu.abi") + sdk_level = parse_int(getprop(serial, "ro.build.version.sdk")) + + # Compute the node name to match what `Mob.Dist.ensure_started/1` + # actually registers on the device side. The device receives + # `MOB_NODE_SUFFIX` via the launch intent, set by + # `restart_app/4` from `device_node_suffix/1` — so we must use the + # same derivation here. `device_node_suffix/1` prefers the stable + # hardware serial (`ro.serialno`) for physical devices (USB and + # WiFi-adb collapse to the same atom) but short-circuits for + # `emulator-NNNN` adb ids because every running emulator burns in + # the same placeholder `EMULATOR36X5X10X0` serial — trusting that + # serial would put every emulator under the same node-name suffix + # and EPMD would collide with `eaddrinuse`. The previous + # implementation here bypassed that short-circuit and produced + # `your_app_android_emulator36x5x10x0@127.0.0.1`, which nobody + # could connect to. + app = Mix.Project.config()[:app] + node = :"#{app}_android_#{device_node_suffix(serial)}@127.0.0.1" + + # Skip IP discovery for emulators — `ip route get` returns the + # emulator's internal NAT subnet (10.0.2.x) which isn't reachable + # from the host and just creates confusion in the devices listing. + host_ip = if d.type == :emulator, do: nil, else: device_ip(serial) + + %{ + d + | name: name, + version: "Android #{version}", + node: node, + host_ip: host_ip, + abi: abi, + sdk_level: sdk_level + } + end + + defp parse_int(nil), do: nil + + defp parse_int(str) when is_binary(str) do + case Integer.parse(str) do + {n, _} -> n + :error -> nil + end end defp getprop(serial, prop) do case run_adb(["-s", serial, "shell", "getprop", prop]) do {:ok, val} -> String.trim(val) - _ -> nil + _ -> nil + end + end + + # Returns the device's WiFi IPv4 address, or nil if unavailable. + # WiFi-adb-connected devices have IP:port as their serial — extract from there. + # USB-connected devices need a shell call: `ip route get 1` returns a line + # whose 7th token is the device's WiFi IP for outbound traffic. + defp device_ip(serial) do + case extract_ip_from_serial(serial) do + nil -> shell_ip(serial) + ip -> ip + end + end + + defp extract_ip_from_serial(serial) do + case Regex.run(Regex.compile!("^(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}):\\d+$"), serial) do + [_, ip] -> ip + _ -> nil + end + end + + # Try `ip route get 1.1.1.1` — returns the source IP used for outbound + # traffic. More portable than parsing `ip addr show wlan0` since the + # interface name varies (wlan0, rmnet_data*, etc.). + defp shell_ip(serial) do + case run_adb(["-s", serial, "shell", "ip", "route", "get", "1.1.1.1"]) do + {:ok, out} -> + case Regex.run( + Regex.compile!("\\bsrc\\s+(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})"), + out + ) do + [_, ip] -> ip + _ -> nil + end + + _ -> + nil end end @@ -82,11 +176,18 @@ defmodule MobDev.Discovery.Android do """ @spec developer_mode(String.t()) :: :enabled | :disabled | :unknown def developer_mode(serial) do - case run_adb(["-s", serial, "shell", "settings", "get", "global", - "development_settings_enabled"]) do + case run_adb([ + "-s", + serial, + "shell", + "settings", + "get", + "global", + "development_settings_enabled" + ]) do {:ok, "1\n"} -> :enabled {:ok, "0\n"} -> :disabled - _ -> :unknown + _ -> :unknown end end @@ -96,8 +197,11 @@ defmodule MobDev.Discovery.Android do Runs `chcon` before `am start` to heal any SELinux MCS category mismatch on OTP files. This mismatch happens when the APK is reinstalled and Android assigns a new MCS category to the package — files pushed via `adb push` retain the old label and - the BEAM can't access them. The `chcon` copies the correct context from the app's - own `files/` directory (which `installd` always keeps up to date). + the BEAM can't access them. + + The label is copied from the app's `cache/` directory, not `files/`. On Android 15 + the `files/` directory itself lacks MCS categories (`s0` only), whereas `cache/` + always carries the full `s0:cXXX,cYYY` label that installd assigns to the package. The `chcon` requires root (`adb root`) — it's silently skipped on non-rooted devices where the OTP files were pushed with the correct label to begin with. @@ -106,23 +210,136 @@ defmodule MobDev.Discovery.Android do {:ok, String.t()} | {:error, String.t()} def restart_app(serial, package, activity, opts \\ []) do dist_port = Keyword.get(opts, :dist_port, 9100) - app_data = "/data/data/#{package}/files" + node_suffix = Keyword.get(opts, :node_suffix) || device_node_suffix(serial) + + app_data = "/data/data/#{package}/files" + app_cache = "/data/data/#{package}/cache" run_adb(["-s", serial, "shell", "am", "force-stop", package]) - # Heal SELinux MCS category mismatch: APK reinstall changes the app's assigned - # category but leaves OTP files with the old label. This causes the BEAM to crash - # on launch (symlink creation fails with EACCES, then erl_start aborts). - run_adb(["-s", serial, "shell", - "chcon -hR $(stat -c %C #{app_data}) #{app_data}/otp"]) + # Read MCS label from cache/ (has full s0:cXXX,cYYY) not files/ (bare s0 on Android 15). + run_adb(["-s", serial, "shell", "chcon -hR $(stat -c %C #{app_cache}) #{app_data}/otp"]) :timer.sleep(300) - run_adb(["-s", serial, "shell", "am", "start", - "-n", "#{package}/#{activity}", - "--ei", "mob_dist_port", to_string(dist_port)]) + + run_adb([ + "-s", + serial, + "shell", + "am", + "start", + "-n", + "#{package}/#{activity}", + "--ei", + "mob_dist_port", + to_string(dist_port), + "--es", + "mob_node_suffix", + node_suffix + ]) + end + + @doc """ + Sanitizes a string into a Mob node-name suffix. Pure — no adb calls. + + node_suffix_for("ZY22CRLMWK") → "zy22crlmwk" + node_suffix_for("10.0.0.82:5555") → "10_0_0_82" + node_suffix_for("emulator-5554") → "emulator_5554" + + Used as the final transformation step by `device_node_suffix/1` (which + asks the device for a stable hardware serial and runs it through here). + Tests use it directly to verify the sanitization rules. + """ + @spec node_suffix_for(String.t()) :: String.t() + def node_suffix_for(serial) when is_binary(serial) do + # Strip the :port (WiFi-adb form), then sanitize: lowercase, replace any + # non-alphanumeric run with a single underscore, trim leading/trailing. + serial + |> String.split(":", parts: 2) + |> hd() + |> String.downcase() + |> String.replace(Regex.compile!("[^a-z0-9]+"), "_") + |> String.trim("_") + end + + @doc """ + Returns the Mob node-name suffix for the device reachable via the given + adb identifier. The suffix is derived from the device's hardware serial + (`ro.serialno`), which is stable across USB and WiFi-adb identifiers for + the same physical phone — so a deploy that targets `ZY22K6BSJM` (USB) + and a bench that targets `10.0.0.17:5555` (WiFi) both end up using the + same node name. + + Falls back to sanitizing the adb identifier itself when `getprop` fails + (e.g. unrooted device, missing executable, dead transport). The + fallback is the legacy behaviour, kept so a bench against a + pre-suffix-aware deploy still converges on *some* deterministic name. + + Single adb shell call (~100–300 ms). Suitable for once-per-launch use + by the deployer and bench. + """ + @spec device_node_suffix(String.t()) :: String.t() + def device_node_suffix(adb_id) when is_binary(adb_id) do + # Android emulators all hardcode `ro.serialno` to the same placeholder + # (`EMULATOR36X5X10X0` on AOSP image, similar on others). Trusting that + # serial would put every running emulator under the same node-name + # suffix and they'd collide on the host EPMD with `eaddrinuse`. The + # adb identifier (e.g. `emulator-5554` vs `emulator-5556`) IS unique + # per running emulator, so prefer that for emulator targets. The + # short-circuit on `emulator-` adb ids skips the unnecessary + # adb-shell call too. + if emulator_adb_id?(adb_id) do + node_suffix_for(adb_id) + else + case System.cmd("adb", ["-s", adb_id, "shell", "getprop", "ro.serialno"], + stderr_to_stdout: true + ) do + {output, 0} -> + hardware_serial = String.trim(output) + + cond do + hardware_serial == "" -> + node_suffix_for(adb_id) + + emulator_serial?(hardware_serial) -> + # Some emulator launchers report the placeholder serial even + # when the adb id wasn't `emulator-NNNN` (e.g. genymotion via + # network-adb). Defensive: also fall back to adb_id here. + node_suffix_for(adb_id) + + true -> + node_suffix_for(hardware_serial) + end + + _ -> + node_suffix_for(adb_id) + end + end end + # The adb identifier the Android emulator registers as. Genymotion etc. + # use IP-based adb ids that don't match this prefix, so they fall through + # to the serial check. + @spec emulator_adb_id?(String.t()) :: boolean() + def emulator_adb_id?(adb_id), do: String.starts_with?(adb_id, "emulator-") + + # The placeholder hardware serial Android emulator images burn in. AOSP + # uses `EMULATOR36X5X10X0`; the prefix match accommodates other vendor + # variants without churning the regex on each new emulator image. + @spec emulator_serial?(String.t()) :: boolean() + def emulator_serial?(serial), do: String.starts_with?(serial, "EMULATOR") + + # Pure-Elixir timeout via Task — avoids depending on the GNU `timeout` + # binary, which doesn't ship with macOS or BSD by default. Calls adb + # directly via System.cmd/3 (no shell, no quoting concerns). defp run_adb(args) do - case System.cmd("adb", args, stderr_to_stdout: true) do - {output, 0} -> {:ok, output} - {output, _} -> {:error, output} + task = + Task.async(fn -> + System.cmd("adb", args, stderr_to_stdout: true) + end) + + case Task.yield(task, 8_000) || Task.shutdown(task, :brutal_kill) do + {:ok, {output, 0}} -> {:ok, output} + {:ok, {output, _rc}} -> {:error, output} + nil -> {:error, "adb timed out"} + {:exit, reason} -> {:error, "adb crashed: #{inspect(reason)}"} end end end diff --git a/lib/mob_dev/discovery/ios.ex b/lib/mob_dev/discovery/ios.ex index b910488..e90c60a 100644 --- a/lib/mob_dev/discovery/ios.ex +++ b/lib/mob_dev/discovery/ios.ex @@ -13,30 +13,204 @@ defmodule MobDev.Discovery.IOS do def list_simulators do case System.find_executable("xcrun") do nil -> [] - _ -> do_list_simulators() + _ -> do_list_simulators() end end - @doc "Returns connected physical iOS devices (requires libimobiledevice)." + @doc """ + Returns connected physical iOS devices. + + Always runs both USB discovery (`ideviceinfo`) and a LAN EPMD scan in + parallel. The LAN scan finds the device's actual node IP (which is + WiFi-first since mob_beam.m prefers a stable LAN address). The USB scan + provides the UDID and device name. Results are merged: one device with the + correct WiFi IP and full USB metadata. + + If only one path finds the device, that result is used directly — so this + works on USB-only setups and WiFi-only setups equally. When USB finds a + device but LAN scan doesn't (cold ARP, rapid app launch, etc.), the + result is enriched via `xcrun devicectl` — we ask for the device's known + hostnames + tunnel IPs, resolve to IPv4, and probe each with EPMD. Single + TCP probe per candidate, so it costs ~50 ms in the success case and + doesn't slow down the no-iOS path. + """ @spec list_physical() :: [Device.t()] def list_physical do - case System.find_executable("ideviceinfo") do - nil -> [] - _ -> do_list_physical() + lan = scan_lan_for_physical() + usb = if System.find_executable("ideviceinfo"), do: do_list_physical(), else: [] + + case {lan, usb} do + # Both found exactly one device — merge: keep WiFi IP for dist, use USB serial for devicectl. + {[lan_dev], [usb_dev]} -> + [%{lan_dev | serial: usb_dev.serial, name: usb_dev.name, version: usb_dev.version}] + + # Multiple LAN devices + USB devices — can't auto-correlate IPs to UDIDs. + # Return USB devices (have proper UDIDs for devicectl) plus any LAN devices + # whose IP doesn't match a USB device. LAN-only devices will fall back to + # dist-only in the deployer. + {[_ | _], [_ | _]} -> + usb_serials = MapSet.new(usb, & &1.serial) + lan_only = Enum.reject(lan, fn d -> MapSet.member?(usb_serials, d.serial) end) + usb ++ lan_only + + # LAN found devices, USB didn't (WiFi-only environment). + {[_ | _], []} -> + lan + + # USB found devices, LAN didn't — try devicectl-driven enrichment so the + # IP shows up in `mix mob.devices` and the bench can short-circuit to + # `--wifi-ip <ip>` next time. + {[], [_ | _]} -> + enrich_with_devicectl(usb) + + {[], []} -> + [] + end + end + + # For each USB-discovered device, ask devicectl for known hostnames and + # tunnel IPs, resolve them to IPv4, and probe each with EPMD. The first + # successful probe attaches host_ip + node + dist_port to the USB device. + # If no probe succeeds, return the USB device unchanged. + defp enrich_with_devicectl(usb_devices) do + ips = devicectl_ipv4_addresses() + + if ips == [] do + usb_devices + else + Enum.map(usb_devices, fn d -> + Enum.find_value(ips, d, fn ip -> + case find_physical_at(ip) do + %Device{} = lan_d -> + %{d | host_ip: ip, node: lan_d.node, dist_port: lan_d.dist_port} + + _ -> + nil + end + end) + end) end end + @doc """ + Returns the IPv4 addresses every connected physical device is known to + reach Mac at, derived from `xcrun devicectl list devices --json-output`. + Sources, in order: + + 1. `connectionProperties.tunnelIPAddress` if it's an IPv4 (CoreDevice + USB tunnel; sometimes IPv6, which Erlang dist doesn't speak) + 2. `connectionProperties.localHostnames` resolved via `:inet.gethostbyname/1` + (mDNS hostnames like `Kevins-iPhone.coredevice.local`, which usually + resolve to the device's WiFi IPv4) + + Returns `[]` if `xcrun` isn't installed, the JSON parse fails, or no + device has any IPv4. Pure of side effects beyond the temp file used to + capture devicectl's JSON output. + """ + @spec devicectl_ipv4_addresses() :: [String.t()] + def devicectl_ipv4_addresses do + if System.find_executable("xcrun") do + tmp = + Path.join( + System.tmp_dir!(), + "mob_devs_ipv4_#{System.unique_integer([:positive])}.json" + ) + + try do + case System.cmd("xcrun", ["devicectl", "list", "devices", "--json-output", tmp], + stderr_to_stdout: true + ) do + {_, 0} -> + tmp + |> File.read!() + |> Jason.decode!() + |> get_in(["result", "devices"]) + |> List.wrap() + |> Enum.flat_map(&device_ipv4_candidates/1) + |> Enum.uniq() + + _ -> + [] + end + rescue + _ -> [] + after + File.rm(tmp) + end + else + [] + end + end + + defp device_ipv4_candidates(dev) do + conn = Map.get(dev, "connectionProperties", %{}) + + tunnel = + case conn["tunnelIPAddress"] do + ip when is_binary(ip) -> + if String.contains?(ip, ":"), do: nil, else: ip + + _ -> + nil + end + + hostname_ips = + conn["localHostnames"] + |> List.wrap() + |> Enum.flat_map(&resolve_hostname_to_ipv4/1) + + [tunnel | hostname_ips] |> Enum.reject(&is_nil/1) + end + + defp resolve_hostname_to_ipv4(hostname) when is_binary(hostname) do + case :inet.gethostbyname(String.to_charlist(hostname)) do + {:ok, {:hostent, _, _, :inet, 4, addrs}} when is_list(addrs) -> + Enum.map(addrs, fn addr -> addr |> Tuple.to_list() |> Enum.join(".") end) + + _ -> + [] + end + end + + defp resolve_hostname_to_ipv4(_), do: [] + @doc "Returns all iOS devices (simulators + physical)." @spec list_devices() :: [Device.t()] def list_devices do list_simulators() ++ list_physical() end + @doc """ + Queries EPMD at a specific IP for any `*_ios` node and returns a Device, or + nil if no iOS BEAM node is reachable there. Used for direct connection when + the IP is already known (e.g. from xcrun devicectl) and ARP may not be warm. + """ + @spec find_physical_at(String.t()) :: Device.t() | nil + def find_physical_at(ip) do + case query_ios_epmd(ip) do + {:ok, short_name, dist_port} -> + %Device{ + platform: :ios, + type: :physical, + serial: ip, + name: "iPhone (#{ip})", + host_ip: ip, + dist_port: dist_port, + status: :discovered, + node: :"#{short_name}@#{ip}" + } + + _ -> + nil + end + end + defp do_list_simulators do case System.cmd("xcrun", ["simctl", "list", "devices", "booted", "--json"], - stderr_to_stdout: true) do + stderr_to_stdout: true + ) do {output, 0} -> parse_simctl_json(output) - _ -> [] + _ -> [] end rescue # Jason not available — fall back to simpler text parsing @@ -60,10 +234,9 @@ defmodule MobDev.Discovery.IOS do end defp list_simulators_text do - case System.cmd("xcrun", ["simctl", "list", "devices", "booted"], - stderr_to_stdout: true) do + case System.cmd("xcrun", ["simctl", "list", "devices", "booted"], stderr_to_stdout: true) do {output, 0} -> parse_simctl_text(output) - _ -> [] + _ -> [] end end @@ -81,38 +254,45 @@ defmodule MobDev.Discovery.IOS do # Parse lines like: # iPhone 17 (78354490-EF38-44D7-A437-DD941C20524D) (Booted) defp parse_simctl_text_line(line) do - case Regex.run(~r/^\s+(.+?) \(([0-9A-F-]{36})\) \(Booted\)/i, line) do + case Regex.run(Regex.compile!("^\\s+(.+?) \\(([0-9A-F-]{36})\\) \\(Booted\\)", "i"), line) do [_, name, udid] -> d = %Device{ platform: :ios, - serial: udid, - name: name, - type: :simulator, - status: :booted, + serial: udid, + name: name, + type: :simulator, + status: :booted } + [%{d | node: Device.node_name(d)}] - _ -> [] + + _ -> + [] end end defp sim_to_device(%{"udid" => udid, "name" => name, "state" => "Booted"}, version) do d = %Device{ platform: :ios, - serial: udid, - name: name, - version: version, - type: :simulator, - status: :booted, + serial: udid, + name: name, + version: version, + type: :simulator, + status: :booted } + %{d | node: Device.node_name(d)} end + defp sim_to_device(_, _), do: nil @doc "Parses a CoreSimulator runtime key into a human-readable version string. Exposed for testing." @spec parse_runtime_version(String.t()) :: String.t() def parse_runtime_version(runtime) do - case Regex.run(~r/iOS-(\d+)-(\d+)/, runtime) do - [_, major, minor] -> "iOS #{major}.#{minor}" + case Regex.run(Regex.compile!("iOS-(\\d+)-(\\d+)"), runtime) do + [_, major, minor] -> + "iOS #{major}.#{minor}" + _ -> # "com.apple.CoreSimulator.SimRuntime.iOS-18-0" style runtime |> String.split(".") |> List.last() |> String.replace("-", ".") @@ -125,41 +305,320 @@ defmodule MobDev.Discovery.IOS do udid = String.trim(udid) name = ideviceinfo(udid, "DeviceName") version = ideviceinfo(udid, "ProductVersion") + d = %Device{ platform: :ios, - serial: udid, - name: name, - version: "iOS #{version}", - type: :physical, - status: :discovered, + serial: udid, + name: name, + version: "iOS #{version}", + type: :physical, + status: :discovered } + [%{d | node: Device.node_name(d)}] - _ -> [] + + _ -> + [] end end defp ideviceinfo(_udid, key) do case System.cmd("ideviceinfo", ["-k", key], stderr_to_stdout: true) do {val, 0} -> String.trim(val) - _ -> nil + _ -> nil + end + end + + # Scan the local ARP table for any host running an iOS EPMD node (*_ios). + # Builds a Device using the node name and IP directly from the EPMD response, + # so the app name in the Mix project running mob_dev is irrelevant. + defp scan_lan_for_physical do + own_ips = local_ipv4_addresses() + + lan_ips = + case System.cmd("arp", ["-a"], stderr_to_stdout: true) do + {out, 0} -> + out + |> String.split("\n") + |> Enum.flat_map(fn line -> + case Regex.run( + Regex.compile!("\\((\\d+\\.\\d+\\.\\d+\\.\\d+)\\) at [0-9a-f]{2}:[0-9a-f]{2}"), + line + ) do + [_, ip] -> + cond do + String.starts_with?(ip, "169.254.") -> [] + ip in own_ips -> [] + true -> [ip] + end + + _ -> + [] + end + end) + + _ -> + [] + end + + Enum.flat_map(lan_ips, fn ip -> + case query_ios_epmd(ip) do + {:ok, short_name, dist_port} -> + node = :"#{short_name}@#{ip}" + + d = %Device{ + platform: :ios, + type: :physical, + serial: ip, + name: "iPhone (#{ip})", + host_ip: ip, + dist_port: dist_port, + status: :discovered, + node: node + } + + [d] + + _ -> + [] + end + end) + end + + # Query EPMD at ip:4369 for any *_ios node. + # Returns {:ok, short_name, dist_port} using the actual name from EPMD, + # so the result is independent of which Mix project is running mob_dev. + # + # Validates the dist port to avoid a phantom hit: an Android phone with + # `adb reverse tcp:4369 tcp:4369` configured will forward LAN connections + # to its port 4369 *back to Mac's EPMD*, so we'd see the simulator's + # entries and think they live on the Android device. The simulator's dist + # port isn't tunneled the same way, so probing it tells us whether the + # EPMD entry actually corresponds to a reachable BEAM at this IP. + defp query_ios_epmd(ip) do + host = String.to_charlist(ip) + + with {:ok, s} <- :gen_tcp.connect(host, 4369, [:binary, active: false], 1000), + :ok <- :gen_tcp.send(s, <<0, 1, ?n>>), + {:ok, <<_::32, names::binary>>} = recv <- :gen_tcp.recv(s, 0, 1000) do + :gen_tcp.close(s) + + candidate = + names + |> String.split("\n") + |> Enum.find_value(fn line -> + case Regex.run(Regex.compile!("name ([a-z0-9_]+_ios[^\\s]*) at port (\\d+)", "i"), line) do + [_, short_name, port] -> {short_name, String.to_integer(port)} + _ -> nil + end + end) + + _ = recv + + case candidate do + nil -> + {:error, :not_ios_node} + + {short_name, dist_port} -> + if dist_port_reachable?(ip, dist_port) do + {:ok, short_name, dist_port} + else + {:error, :dist_phantom} + end + end + else + _ -> {:error, :epmd_unreachable} + end + end + + # TCP-probe the claimed dist port to confirm the EPMD entry isn't a phantom + # (e.g. tunneled-EPMD case described above). 500 ms is plenty for a LAN + # connect and short enough that scanning N hosts stays under a second total. + defp dist_port_reachable?(ip, port) do + case :gen_tcp.connect(String.to_charlist(ip), port, [:binary, active: false], 500) do + {:ok, s} -> + :gen_tcp.close(s) + true + + _ -> + false + end + end + + # Returns the Mac's own IPv4 addresses, used to filter the LAN scan so we + # don't mistake the Mac's local EPMD (which may have a simulator registered) + # for a physical iPhone. + defp local_ipv4_addresses do + case :inet.getifaddrs() do + {:ok, ifs} -> + for {_name, props} <- ifs, + {:addr, {a, b, c, d}} <- props, + a in 0..255, + "#{a}.#{b}.#{c}.#{d}" != "127.0.0.1" do + "#{a}.#{b}.#{c}.#{d}" + end + + _ -> + [] end end @doc """ Launches the app on a booted simulator. - Passes MOB_DIST_PORT as an environment variable (xcrun simctl launch supports this). + + Passes env vars through to the simulator app via `simctl`'s + `SIMCTL_CHILD_*` mechanism (the prefix is stripped before delivery + to the child process): + + * `MOB_DIST_PORT` — Erlang dist listen port + * `MOB_NODE_SUFFIX` — appended to the BEAM node name. When + absent, `mob_beam.m` falls back to deriving a suffix from + `SIMULATOR_UDID` so concurrent sims still get unique names. + * `MOB_SIM_RUNTIME_DIR` — directory the OTP runtime was written + to; `mob_beam.m` reads from the same place `ios/build.sh` wrote. + + Options: + + * `:dist_port` — pin the dist listen port (default `9100`). + * `:node_suffix` — override the BEAM node-name suffix. `nil` lets + `mob_beam.m` auto-derive from `SIMULATOR_UDID`. """ @spec launch_app(String.t(), String.t(), keyword()) :: {String.t(), non_neg_integer()} def launch_app(udid, bundle_id, opts \\ []) do + runtime_dir = MobDev.Paths.sim_runtime_dir() + env = build_simctl_env(opts, runtime_dir) + + System.cmd("xcrun", build_simctl_launch_args(udid, bundle_id), + stderr_to_stdout: true, + env: env + ) + end + + @doc false + @spec build_simctl_launch_args(String.t(), String.t()) :: [String.t()] + def build_simctl_launch_args(udid, bundle_id) + when is_binary(udid) and is_binary(bundle_id) do + ["simctl", "launch", "--terminate-running-process", udid, bundle_id] + end + + @doc """ + Builds the `SIMCTL_CHILD_*` env-var list `launch_app/3` passes to + simctl. Extracted as a pure function so the override behaviour can be + unit-tested without spawning subprocesses. + + Always emits: + + * `SIMCTL_CHILD_MOB_DIST_PORT` — `:dist_port` opt, default 9100 + * `SIMCTL_CHILD_MOB_SIM_RUNTIME_DIR` — runtime_dir arg + + Conditionally emits: + + * `SIMCTL_CHILD_MOB_NODE_SUFFIX` — only when `:node_suffix` is a + non-empty string. nil / "" → mob_beam.m auto-derives from + SIMULATOR_UDID. + """ + @spec build_simctl_env(keyword(), String.t()) :: [{String.t(), String.t()}] + def build_simctl_env(opts, runtime_dir) do dist_port = Keyword.get(opts, :dist_port, 9100) - # xcrun simctl passes SIMCTL_CHILD_* env vars to the launched app (prefix stripped). - System.cmd("xcrun", ["simctl", "launch", udid, bundle_id], - stderr_to_stdout: true, - env: [{"SIMCTL_CHILD_MOB_DIST_PORT", to_string(dist_port)}]) + node_suffix = Keyword.get(opts, :node_suffix) + + base = [ + {"SIMCTL_CHILD_MOB_DIST_PORT", to_string(dist_port)}, + {"SIMCTL_CHILD_MOB_SIM_RUNTIME_DIR", runtime_dir} + ] + + if node_suffix && node_suffix != "" do + base ++ [{"SIMCTL_CHILD_MOB_NODE_SUFFIX", node_suffix}] + else + base + end end @spec terminate_app(String.t(), String.t()) :: {String.t(), non_neg_integer()} def terminate_app(udid, bundle_id) do System.cmd("xcrun", ["simctl", "terminate", udid, bundle_id], stderr_to_stdout: true) end + + @doc """ + Restarts only the target app on a physical iOS device via xcrun devicectl. + `--terminate-existing` atomically replaces an existing instance of that exact + bundle without terminating unrelated user applications. + """ + @spec restart_app_physical(String.t(), String.t()) :: {String.t(), non_neg_integer()} + def restart_app_physical(udid, bundle_id) do + restart_app_physical(udid, bundle_id, &System.cmd/3) + end + + @doc false + @spec restart_app_physical(String.t(), String.t(), function()) :: + {String.t(), non_neg_integer()} + def restart_app_physical(udid, bundle_id, runner) + when is_binary(udid) and is_binary(bundle_id) and is_function(runner, 3) do + runner.( + "xcrun", + build_devicectl_launch_args(udid, bundle_id), + stderr_to_stdout: true + ) + end + + @doc false + @spec build_devicectl_launch_args(String.t(), String.t()) :: [String.t()] + def build_devicectl_launch_args(udid, bundle_id) + when is_binary(udid) and is_binary(bundle_id) do + [ + "devicectl", + "device", + "process", + "launch", + "--device", + udid, + "--terminate-existing", + bundle_id + ] + end + + @doc """ + Enables the iOS accessibility system for the given simulator (or "booted"). + + SwiftUI lazily populates its accessibility tree only when an accessibility + service is active. `pegleg_nif:ui_tree/0` requires this to be called once + per simulator session before it can return elements. Writes the VoiceOver + preference into the simulator's preference store and posts the Darwin + notification that UIKit listens to. + + Safe to call repeatedly — idempotent. + """ + @spec enable_accessibility(String.t()) :: :ok + def enable_accessibility(udid) do + System.cmd( + "xcrun", + [ + "simctl", + "spawn", + udid, + "defaults", + "write", + "com.apple.Accessibility", + "VoiceOverTouchEnabled", + "-bool", + "YES" + ], + stderr_to_stdout: true + ) + + System.cmd( + "xcrun", + [ + "simctl", + "spawn", + udid, + "notifyutil", + "-p", + "com.apple.accessibility.voiceover.status.changed" + ], + stderr_to_stdout: true + ) + + :ok + end end diff --git a/lib/mob_dev/download.ex b/lib/mob_dev/download.ex new file mode 100644 index 0000000..e705299 --- /dev/null +++ b/lib/mob_dev/download.ex @@ -0,0 +1,31 @@ +defmodule MobDev.Download do + @moduledoc false + + # Tiny wrappers around `curl` and `tar` so the various downloaders + # (OTP, Python apple/android, MLX) don't all reimplement the same + # `System.cmd` + error pattern. + + @doc """ + Fetch `url` to `dest` via curl. + """ + @spec curl(String.t(), Path.t()) :: :ok | {:error, String.t()} + def curl(url, dest) do + case System.cmd("curl", ["-L", "--fail", "--progress-bar", "-o", dest, url], + stderr_to_stdout: false + ) do + {_, 0} -> :ok + {out, rc} -> {:error, "curl failed (exit #{rc}): #{String.trim(out)}"} + end + end + + @doc """ + Extract `tarball` into `dest_dir` via `tar xzf`. + """ + @spec untar(Path.t(), Path.t()) :: :ok | {:error, String.t()} + def untar(tarball, dest_dir) do + case System.cmd("tar", ["xzf", tarball, "-C", dest_dir], stderr_to_stdout: true) do + {_, 0} -> :ok + {out, rc} -> {:error, "tar failed (exit #{rc}): #{String.trim(out)}"} + end + end +end diff --git a/lib/mob_dev/duration.ex b/lib/mob_dev/duration.ex new file mode 100644 index 0000000..f5fe726 --- /dev/null +++ b/lib/mob_dev/duration.ex @@ -0,0 +1,20 @@ +defmodule MobDev.Duration do + @moduledoc false + + @doc """ + Format a microsecond duration as a human-readable string. + + iex> MobDev.Duration.format_us(42) + "42μs" + + iex> MobDev.Duration.format_us(4_200) + "4.2ms" + + iex> MobDev.Duration.format_us(4_200_000) + "4.2s" + """ + @spec format_us(number()) :: String.t() + def format_us(us) when us < 1_000, do: "#{us}μs" + def format_us(us) when us < 1_000_000, do: "#{Float.round(us / 1_000, 1)}ms" + def format_us(us), do: "#{Float.round(us / 1_000_000, 2)}s" +end diff --git a/lib/mob_dev/emulators.ex b/lib/mob_dev/emulators.ex new file mode 100644 index 0000000..606955a --- /dev/null +++ b/lib/mob_dev/emulators.ex @@ -0,0 +1,316 @@ +defmodule MobDev.Emulators do + @moduledoc """ + List, start, and stop Android emulators (AVDs) and iOS simulators. + + Backs `mix mob.emulators`. Pure-ish — each function shells out to `emulator`, + `adb`, or `xcrun simctl` exactly once and returns a parsed result. UI-shape + decisions (formatting, colors, exit codes) live in the Mix task. + + ## Naming + + Android calls them "emulators", iOS calls them "simulators". This module + uses "emulator" for the cross-platform concept (configured-but-runnable + virtual device) and reserves "simulator" for iOS-specific descriptions in + the help text. The struct's `:platform` field disambiguates. + """ + + defstruct [:platform, :name, :id, :running, :serial, :runtime] + + @type t :: %__MODULE__{ + platform: :android | :ios, + name: String.t(), + # Android: AVD name (same as `:name`). iOS: UDID. + id: String.t(), + running: boolean(), + # Android: adb serial (e.g. "emulator-5554") when running, else nil. + # iOS: same as `:id` (sims have stable UDIDs whether booted or not). + serial: String.t() | nil, + # iOS only — e.g. "iOS 26.4". + runtime: String.t() | nil + } + + # ── Listing ───────────────────────────────────────────────────────────────── + + @doc """ + Returns all configured Android AVDs, including whether each is currently + running. Returns `{:error, reason}` when the Android SDK isn't reachable. + """ + @spec list_android() :: {:ok, [t()]} | {:error, String.t()} + def list_android do + with {:ok, emulator_bin} <- find_emulator_binary(), + {avds_out, 0} <- run_cmd(emulator_bin, ["-list-avds"]), + running <- running_android_serials() do + avds = + avds_out + |> String.split("\n", trim: true) + |> Enum.reject(&String.starts_with?(&1, "INFO")) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.map(fn avd_name -> + serial = Map.get(running, avd_name) + + %__MODULE__{ + platform: :android, + name: avd_name, + id: avd_name, + running: serial != nil, + serial: serial, + runtime: nil + } + end) + + {:ok, avds} + else + {:error, reason} -> {:error, reason} + {output, _exit} -> {:error, "emulator command failed: #{String.trim(output)}"} + end + end + + @doc """ + Returns all installed iOS simulators (across runtimes) marked with their + current state. Returns `{:error, reason}` on a non-macOS host or when + xcrun isn't available. + """ + @spec list_ios() :: {:ok, [t()]} | {:error, String.t()} + def list_ios do + cond do + not macos?() -> + {:error, "iOS simulators require macOS"} + + System.find_executable("xcrun") == nil -> + {:error, "xcrun not found — install Xcode command-line tools"} + + true -> + case run_cmd("xcrun", ["simctl", "list", "devices", "--json"]) do + {output, 0} -> {:ok, parse_simctl_json(output)} + {output, _} -> {:error, "simctl failed: #{String.trim(output)}"} + end + end + end + + # ── Starting ──────────────────────────────────────────────────────────────── + + @doc """ + Starts an Android AVD by name. Returns `:ok` once the emulator process is + spawned (it boots in the background; `adb wait-for-device` is the caller's + responsibility if they need to know when it's ready). + """ + @spec start_android(String.t()) :: :ok | {:error, String.t()} + def start_android(avd_name) when is_binary(avd_name) do + case find_emulator_binary() do + {:ok, emulator_bin} -> + # Detach: emulator runs in background; we don't wait. stdin/out/err + # are pointed at /dev/null so this Mix process can exit cleanly. + port = + Port.open({:spawn_executable, emulator_bin}, [ + :binary, + :exit_status, + args: ["-avd", avd_name], + env: [{~c"DYLD_FALLBACK_LIBRARY_PATH", false}] + ]) + + # Detach the port so the emulator survives our exit. + Port.close(port) + :ok + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Boots an iOS simulator by UDID and brings the Simulator.app to focus. + No-op-with-success if the sim is already booted. + """ + @spec start_ios(String.t()) :: :ok | {:error, String.t()} + def start_ios(udid) when is_binary(udid) do + case run_cmd("xcrun", ["simctl", "boot", udid]) do + {_, 0} -> + # Open Simulator.app so the user can see it. `-a Simulator` is a no-op + # if it's already open. Errors here are non-fatal. + run_cmd("open", ["-a", "Simulator"]) + :ok + + {output, _} -> + # `simctl boot` returns non-zero when already booted — treat that as ok. + if String.contains?(output, "Booted") or String.contains?(output, "current state") do + :ok + else + {:error, "simctl boot failed: #{String.trim(output)}"} + end + end + end + + # ── Stopping ──────────────────────────────────────────────────────────────── + + @doc """ + Shuts down a running Android emulator by adb serial (e.g. "emulator-5554"). + """ + @spec stop_android(String.t()) :: :ok | {:error, String.t()} + def stop_android(serial) when is_binary(serial) do + case run_cmd("adb", ["-s", serial, "emu", "kill"]) do + {_, 0} -> :ok + {output, _} -> {:error, "adb emu kill failed: #{String.trim(output)}"} + end + end + + @doc """ + Shuts down a booted iOS simulator by UDID. Pass the literal string `"all"` + to shut down every booted simulator at once (`xcrun simctl shutdown all`). + """ + @spec stop_ios(String.t()) :: :ok | {:error, String.t()} + def stop_ios(udid_or_all) when is_binary(udid_or_all) do + case run_cmd("xcrun", ["simctl", "shutdown", udid_or_all]) do + {_, 0} -> :ok + {output, _} -> {:error, "simctl shutdown failed: #{String.trim(output)}"} + end + end + + # ── Locate the Android `emulator` binary ──────────────────────────────────── + # + # Resolution order, picking the first that exists: + # 1. `<project>/android/local.properties` `sdk.dir` + /emulator/emulator + # (matches what mix mob.doctor / mix mob.deploy already use) + # 2. `$ANDROID_HOME` env var + # 3. `$ANDROID_SDK_ROOT` env var (older form) + # 4. `~/Library/Android/sdk` (Android Studio default on macOS) + # 5. `~/Android/Sdk` (Android Studio default on Linux) + + @doc false + @spec find_emulator_binary(String.t() | nil) :: {:ok, String.t()} | {:error, String.t()} + def find_emulator_binary(project_dir \\ nil) do + candidates = + [ + sdk_dir_from_project(project_dir), + System.get_env("ANDROID_HOME"), + System.get_env("ANDROID_SDK_ROOT"), + Path.expand("~/Library/Android/sdk"), + Path.expand("~/Android/Sdk") + ] + |> Enum.reject(&is_nil/1) + |> Enum.map(&Path.join([&1, "emulator", "emulator"])) + + case Enum.find(candidates, &File.exists?/1) do + nil -> + {:error, + "Could not find the Android `emulator` binary. Set ANDROID_HOME or " <> + "configure android/local.properties (run from a project directory)."} + + bin -> + {:ok, bin} + end + end + + defp sdk_dir_from_project(nil) do + case File.cwd() do + {:ok, cwd} -> sdk_dir_from_project(cwd) + _ -> nil + end + end + + defp sdk_dir_from_project(dir) do + case MobDev.NativeBuild.read_sdk_dir(dir) do + {:ok, sdk} -> sdk + _ -> nil + end + end + + # ── Currently running Android emulators (avd_name → serial) ───────────────── + + defp running_android_serials do + case run_cmd("adb", ["devices", "-l"]) do + {output, 0} -> + output + |> String.split("\n", trim: true) + |> Enum.drop(1) + |> Enum.map(&adb_line_serial_if_emulator/1) + |> Enum.reject(&is_nil/1) + |> Enum.map(fn serial -> {serial, avd_name_for_serial(serial)} end) + |> Enum.reject(fn {_serial, avd} -> is_nil(avd) end) + |> Map.new(fn {serial, avd} -> {avd, serial} end) + + _ -> + %{} + end + end + + defp adb_line_serial_if_emulator(line) do + case String.split(line, " ", parts: 2) do + [serial | _] when serial != "" -> + if String.starts_with?(serial, "emulator-"), do: serial, else: nil + + _ -> + nil + end + end + + defp avd_name_for_serial(serial) do + case run_cmd("adb", ["-s", serial, "emu", "avd", "name"]) do + {output, 0} -> + # `adb emu avd name` returns the AVD name on the first line, then "OK". + output + |> String.split("\n", trim: true) + |> Enum.find(&(&1 != "" and &1 != "OK")) + |> case do + nil -> nil + line -> String.trim(line) + end + + _ -> + nil + end + end + + # ── simctl JSON parser ────────────────────────────────────────────────────── + + @doc false + @spec parse_simctl_json(String.t()) :: [t()] + def parse_simctl_json(json) do + case decode_json(json) do + {:ok, %{"devices" => runtimes}} -> + Enum.flat_map(runtimes, fn {runtime_id, sims} -> + runtime_label = pretty_runtime(runtime_id) + + sims + |> Enum.filter(fn sim -> + # Some entries have isAvailable=false (deprecated runtimes); skip. + Map.get(sim, "isAvailable", true) + end) + |> Enum.map(fn sim -> + %__MODULE__{ + platform: :ios, + name: sim["name"], + id: sim["udid"], + running: sim["state"] == "Booted", + serial: sim["udid"], + runtime: runtime_label + } + end) + end) + + _ -> + [] + end + end + + # com.apple.CoreSimulator.SimRuntime.iOS-26-4 → "iOS 26.4" + # com.apple.CoreSimulator.SimRuntime.watchOS-11-0 → "watchOS 11.0" + defp pretty_runtime(id) do + case Regex.run(Regex.compile!("SimRuntime\\.([A-Za-z]+)-(\\d+)-(\\d+)$"), id) do + [_, os, major, minor] -> "#{os} #{major}.#{minor}" + _ -> id + end + end + + defp decode_json(json) do + {:ok, :json.decode(json)} + rescue + _ -> :error + end + + # ── Helpers ───────────────────────────────────────────────────────────────── + + defp run_cmd(cmd, args), do: System.cmd(cmd, args, stderr_to_stdout: true) + + defp macos?, do: match?({:unix, :darwin}, :os.type()) +end diff --git a/lib/mob_dev/enable.ex b/lib/mob_dev/enable.ex new file mode 100644 index 0000000..b3a4a29 --- /dev/null +++ b/lib/mob_dev/enable.ex @@ -0,0 +1,531 @@ +defmodule MobDev.Enable do + @moduledoc """ + Pure helpers for `mix mob.enable` — extracted for testability. + + ## LiveView bridge architecture + + Enabling LiveView mode involves three coordinated patches. Understanding why + all three are necessary prevents subtle bugs when setting up projects manually. + + ### The two bridges + + The native WebView (iOS WKWebView / Android WebView) injects a `window.mob` + JavaScript object into every page it loads. This object routes calls through + the NIF bridge: + + window.mob.send(data) // JS → NIF → Elixir handle_info + window.mob.onMessage(fn) // registers handler for NIF → JS messages + window.mob._dispatch(json) // called by the NIF to deliver messages to JS + + In LiveView mode you want a different routing: JS messages should travel over + the LiveView WebSocket so that `handle_event/3` in your LiveView receives them + and `push_event/3` delivers server messages to JS. The MobHook replaces + `window.mob` with a LiveView-backed version on mount: + + window.mob.send(data) // JS → pushEvent("mob_message") → handle_event/3 + window.mob.onMessage(fn) // registers handler for handleEvent("mob_push") + window.mob._dispatch // no-op: server messages arrive via handleEvent + + ### Why a DOM element is required (the non-obvious part) + + Phoenix LiveView hooks only execute their `mounted()` callback when an element + carrying `phx-hook="MobHook"` is present in the rendered HTML *and* the + LiveView WebSocket has connected. Registering MobHook in the `hooks:` map in + `app.js` is necessary but not sufficient — the hook is dormant until LiveView + finds a matching DOM element. + + Without the element: + - MobHook never mounts + - `window.mob` is never replaced with the LiveView version + - `window.mob.send()` routes through the native NIF bridge instead of LiveView + - `handle_event/3` never fires; your LiveView cannot receive JS messages + + The element is a hidden `<div>` placed immediately after the opening `<body>` + tag in `root.html.heex`: + + <div id="mob-bridge" phx-hook="MobHook" style="display:none"></div> + + Placing it at the top of `<body>` ensures the hook mounts as early as possible, + so `window.mob` is overridden before any page-specific JS runs. + + ### Android timing note + + iOS injects the native `window.mob` shim via `WKUserScript` at + `.atDocumentStart` — before any page JS runs. Android injects it via + `evaluateJavascript` in `onPageFinished` — after the page has loaded. Between + page load and `onPageFinished` on Android, `window.mob` is undefined. In + practice LiveView connects after `onPageFinished`, so both shims are available + by the time the MobHook mounts. If you call `window.mob` during + `DOMContentLoaded`, guard with `if (window.mob)`. + """ + + @mob_hook_js ~S""" + // MobHook — Mob LiveView bridge. Added by `mix mob.enable liveview`. + // + // WHY THIS EXISTS: The native WebView injects window.mob pointing at the NIF + // bridge (postMessage on iOS, JavascriptInterface on Android). In LiveView + // mode we want window.mob to route through the LiveView WebSocket instead so + // handle_event/3 in your LiveView receives JS messages and push_event/3 + // delivers server messages back to JS. + // + // This hook replaces window.mob on mount. It requires a DOM element with + // phx-hook="MobHook" — see root.html.heex. Without that element this hook + // never runs and messages silently use the native bridge instead. + const MobHook = { + mounted() { + window.mob = { + // JS → LiveView: arrives as handle_event("mob_message", data, socket) + send: (data) => this.pushEvent("mob_message", data), + // LiveView → JS: push_event(socket, "mob_push", data) calls all handlers + onMessage: (handler) => this.handleEvent("mob_push", handler), + // No-op in LiveView mode. The native bridge calls this to deliver + // webview_post_message results, but in LiveView mode server messages + // arrive via handleEvent("mob_push") instead. + _dispatch: () => {} + } + } + } + """ + + # The hidden bridge element injected into root.html.heex. + # id="mob-bridge" is used as the idempotency sentinel — do not change it. + @mob_bridge_element ~s(<div id="mob-bridge" phx-hook="MobHook" style="display:none"></div>) + + @doc """ + Returns the MobHook JS constant to inject into app.js. + """ + @spec mob_hook_js() :: String.t() + def mob_hook_js, do: @mob_hook_js + + @doc """ + Returns the hidden bridge `<div>` element that must appear in `root.html.heex`. + + See the module doc for why this element is required. + """ + @spec mob_bridge_element() :: String.t() + def mob_bridge_element, do: @mob_bridge_element + + @doc """ + Injects the MobHook definition and registration into `content` (the full + text of `assets/js/app.js`). + + - Inserts the hook constant after the last top-level `import` line. + - Registers `MobHook` in the `hooks:` option passed to `LiveSocket`. + + Returns the patched JS string. Idempotency (skip if already present) is + handled by the calling task, not by this function. + """ + @spec inject_mob_hook(String.t()) :: String.t() + def inject_mob_hook(content) do + content + |> insert_hook_definition() + |> register_hook_in_live_socket() + end + + @doc """ + Injects the hidden bridge `<div>` into `content` (a `root.html.heex` file). + + The element is placed immediately after the opening `<body>` tag. This is + the mount point for MobHook — without it the hook never executes and + `window.mob` is never replaced with the LiveView version. See the module doc + for the full explanation. + + Returns the patched HTML string unchanged if `id="mob-bridge"` is already + present. + """ + @spec inject_mob_bridge_element(String.t()) :: String.t() + def inject_mob_bridge_element(content) do + if String.contains?(content, "mob-bridge") do + content + else + Regex.replace( + Regex.compile!("<body([^>]*)>"), + content, + "<body\\1>\n #{@mob_bridge_element}", + global: false + ) + end + end + + @doc """ + Finds `root.html.heex` in a Phoenix project rooted at `project_dir`. + + Checks both the Phoenix 1.7+ convention: + + lib/<app_name>_web/components/layouts/root.html.heex + + and the pre-1.7 convention: + + lib/<app_name>_web/templates/layout/root.html.heex + + Returns the path string or `nil` if neither file exists. + """ + @spec find_root_html(String.t(), String.t()) :: String.t() | nil + def find_root_html(project_dir, app_name) do + web = app_name <> "_web" + + candidates = [ + Path.join([project_dir, "lib", web, "components", "layouts", "root.html.heex"]), + Path.join([project_dir, "lib", web, "templates", "layout", "root.html.heex"]) + ] + + Enum.find(candidates, &File.exists?/1) + end + + @doc """ + Reads the `app:` atom from the given `mix.exs` path and returns the app + name as a string, or raises. + """ + @spec read_app_name_from(String.t()) :: String.t() + def read_app_name_from(mix_exs_path) do + case File.read(mix_exs_path) do + {:ok, content} -> + case Regex.run(Regex.compile!("app:\\s+:([a-z0-9_]+)"), content) do + [_, name] -> name + _ -> raise "Could not read app name from #{mix_exs_path}" + end + + _ -> + raise "Could not read #{mix_exs_path}" + end + end + + @doc """ + Builds a plist `<key>/<value>` entry for Info.plist injection. + + Options: + - `type: :bool` — emits `<true/>` or `<false/>` instead of `<string>` + """ + @spec build_plist_entry(String.t(), term(), keyword()) :: String.t() + def build_plist_entry(key, value, opts \\ []) do + if opts[:type] == :bool do + "\t<key>#{key}</key>\n\t<#{value}/>" + else + "\t<key>#{key}</key>\n\t<string>#{value}</string>" + end + end + + @network_security_config_xml """ + <?xml version="1.0" encoding="utf-8"?> + <network-security-config> + <domain-config cleartextTrafficPermitted="true"> + <domain includeSubdomains="false">127.0.0.1</domain> + <domain includeSubdomains="false">localhost</domain> + </domain-config> + </network-security-config> + """ + + @doc "Returns the XML content for the Android network security config." + @spec network_security_config_xml() :: String.t() + def network_security_config_xml, do: @network_security_config_xml + + @doc """ + Adds `android:networkSecurityConfig="@xml/network_security_config"` to the + `<application>` tag in an AndroidManifest.xml string. + + Idempotent — returns the content unchanged if the attribute is already present. + """ + @spec inject_android_network_security_config(String.t()) :: String.t() + def inject_android_network_security_config(manifest_content) do + if String.contains?(manifest_content, "networkSecurityConfig") do + manifest_content + else + String.replace( + manifest_content, + Regex.compile!("(<application\\b)"), + "\\1\n android:networkSecurityConfig=\"@xml/network_security_config\"", + global: false + ) + end + end + + # ── Pythonx feature ─────────────────────────────────────────────────────── + + @pythonx_dep_version "~> 0.4" + + @doc """ + Patches `mix.exs` content to add `{:pythonx, "#{@pythonx_dep_version}"}` to the + `deps` list when missing. Idempotent. + + Returns the (possibly-modified) content. Returns the original content + unchanged when there's no recognizable `defp deps do [` block — caller is + expected to fall back to a friendly "couldn't find deps block" message. + """ + @spec inject_pythonx_dep(String.t()) :: String.t() + def inject_pythonx_dep(content) do + cond do + String.contains?(content, ":pythonx") -> + content + + Regex.match?(Regex.compile!(~S{defp\s+deps\s+do\s*\[}), content) -> + Regex.replace( + Regex.compile!(~S{(defp\s+deps\s+do\s*\[)}), + content, + ~s(\\1\n {:pythonx, "#{@pythonx_dep_version}"},), + global: false + ) + + true -> + content + end + end + + @doc """ + Returns the canonical `pyproject.toml` string for a freshly-enabled + Pythonx project. Used by the on_start template generator and by the + desktop `Pythonx.Uv.fetch/init` calls in user code. + """ + @spec default_pyproject_toml(String.t()) :: String.t() + def default_pyproject_toml(app_name) when is_binary(app_name) do + """ + [project] + name = "#{app_name}" + version = "0.1.0" + requires-python = "==3.13.*" + dependencies = [] + """ + end + + @doc """ + Inspects the project's existing native build templates for the markers + `mix mob.deploy --native` expects when Pythonx is enabled. Returns a + list of `{relative_path, missing_marker}` tuples for every file that + exists but is missing the marker. An empty list means everything looks + fresh. + + We deliberately do not auto-patch — these files are typically + hand-customized after `mix mob.new`, and silently inserting blocks is + riskier than asking the user to copy from the template. + + Files that don't exist yet (e.g. a project that never generated an + ios/build.sh) are skipped — this is "stale-template detection," not + "missing-platform detection." + """ + @spec detect_stale_pythonx_templates(Path.t(), String.t()) :: + [{String.t(), String.t()}] + def detect_stale_pythonx_templates(project_dir, _app_name) do + # Phase 2 iter 13b/c: ios/build.sh + ios/build_device.sh both eliminated; + # their Pythonx blocks moved into MobDev.NativeBuild. Only the Android + # CMakeLists.txt + MainActivity checks remain meaningful. + fixed = [ + {Path.join(["android", "app", "src", "main", "jni", "CMakeLists.txt"]), "enif_keepalive.c"} + ] + + # Java package layout varies by app. Glob instead of guessing. + main_activities = + Path.wildcard(Path.join(project_dir, "android/app/src/main/java/**/MainActivity.kt")) + + activity_checks = + Enum.map(main_activities, fn abs -> + rel = Path.relative_to(abs, project_dir) + {rel, "extractPythonAssetsIfNeeded"} + end) + + (fixed ++ activity_checks) + |> Enum.flat_map(fn {rel, marker} -> + abs = + if Path.type(rel) == :absolute, + do: rel, + else: Path.join(project_dir, rel) + + cond do + not File.exists?(abs) -> [] + File.read!(abs) =~ marker -> [] + true -> [{to_string(rel), marker}] + end + end) + end + + @doc """ + Returns the source for the `<App>.PythonPaths` module that + `mix mob.enable pythonx` writes to `lib/<app>/python_paths.ex`. + + Pure — no filesystem access. The generated module supports iOS + (paths under `<otp_root>/python/`) and Android (paths from + `MOB_PYTHON_HOME` / `MOB_PYTHON_DL` env vars set by the user's + `MainActivity.kt` before BEAM startup). + """ + @spec python_paths_module_template(String.t()) :: String.t() + def python_paths_module_template(module_name) when is_binary(module_name) do + """ + defmodule #{module_name}.PythonPaths do + @moduledoc \"\"\" + Detects bundled CPython at runtime and reports the paths needed + for `Pythonx.init/4` (dl_path, home_path, stdlib_path). + + Pure detection logic — see your app's `App` module for how the + result is fed into `Pythonx.init/4` at boot. + + ## Per-platform layout + + * **iOS**: ios/build_device.sh bundles `Python.framework`, + stdlib, and lib-dynload at `<App>.app/otp/python/`. Detection + reads `:code.root_dir/0` and inspects that subtree. + + * **Android**: `mix mob.deploy --native` bundles libpython.so + into the APK's `jniLibs/<abi>/` (auto-extracted by the + installer to `applicationInfo.nativeLibraryDir`) and + stdlib + lib-dynload into `assets/python/` (extracted to + `filesDir/python/` by `MainActivity.onCreate` on first + launch). MainActivity exports the resolved paths via + `MOB_PYTHON_DL` and `MOB_PYTHON_HOME` env vars before + starting the BEAM. + + ## Returns + + * `:desktop` — no platform bundle found. Pythonx's + `Application.start/2` handles desktop init via `:uv_init`. + * `{:ios, paths}` — iOS bundle present; pass into + `Pythonx.init/4`. + * `{:android, paths}` — Android bundle present; pass into + `Pythonx.init/4`. + * `{:partial, missing}` — bundle is incomplete; surface to + the user. + \"\"\" + + @type python_paths :: %{ + dl_path: String.t(), + home_path: String.t(), + stdlib_path: String.t() + } + + @type detection :: + :desktop + | {:ios, python_paths()} + | {:android, python_paths()} + | {:partial, [atom()]} + + @python_version "python3.13" + + @doc \"\"\" + Decide which platform's bundle (if any) is present. `otp_root` is + typically `to_string(:code.root_dir())` and is used for the iOS + layout — Android resolution comes from env vars MainActivity + exports. + \"\"\" + @spec detect(String.t()) :: detection() + def detect(otp_root) when is_binary(otp_root) do + cond do + File.dir?(Path.join(otp_root, "python")) -> + paths = build_ios_paths(otp_root) + + case missing(paths) do + [] -> {:ios, paths} + missing -> {:partial, missing} + end + + android_python?() -> + paths = build_android_paths() + + case missing(paths) do + [] -> {:android, paths} + missing -> {:partial, missing} + end + + true -> + :desktop + end + end + + @doc \"\"\" + Construct the iOS path map under `<otp_root>/python/`. Pure — + no filesystem access. + \"\"\" + @spec build_ios_paths(String.t()) :: python_paths() + def build_ios_paths(otp_root) when is_binary(otp_root) do + python_dir = Path.join(otp_root, "python") + + %{ + dl_path: Path.join([python_dir, "Python.framework", "Python"]), + home_path: python_dir, + stdlib_path: Path.join([python_dir, "lib", @python_version]) + } + end + + @doc \"\"\" + Construct the Android path map from `MOB_PYTHON_HOME` and + `MOB_PYTHON_DL` env vars. Returns the empty-string default when + vars aren't set so callers see :partial rather than crashing. + + Android stdlib lives at `<home>/lib/python3.13/` to match + Python's PYTHONHOME bootstrap contract (Python looks for + `encodings/` and friends at that path before sys.path is set up). + \"\"\" + @spec build_android_paths() :: python_paths() + def build_android_paths do + home = System.get_env("MOB_PYTHON_HOME") || "" + dl = System.get_env("MOB_PYTHON_DL") || "" + + %{ + dl_path: dl, + home_path: home, + stdlib_path: if(home == "", do: "", else: Path.join([home, "lib", @python_version])) + } + end + + @doc \"\"\" + Returns the keys (`:dl_path` / `:home_path` / `:stdlib_path`) + whose artifacts are absent on disk. Empty list means the bundle + is complete. + \"\"\" + @spec missing(python_paths()) :: [atom()] + def missing(%{dl_path: dl, home_path: home, stdlib_path: stdlib}) do + [ + {:dl_path, File.exists?(dl)}, + {:home_path, File.dir?(home)}, + {:stdlib_path, File.dir?(stdlib)} + ] + |> Enum.reject(fn {_, present?} -> present? end) + |> Enum.map(&elem(&1, 0)) + end + + defp android_python? do + System.get_env("MOB_PYTHON_HOME") != nil + end + end + """ + end + + # ── Private ─────────────────────────────────────────────────────────────── + + defp insert_hook_definition(content) do + lines = String.split(content, "\n") + + last_import_idx = + lines + |> Enum.with_index() + |> Enum.filter(fn {line, _} -> String.starts_with?(String.trim(line), "import ") end) + |> Enum.map(fn {_, idx} -> idx end) + |> List.last() + + insert_at = (last_import_idx || -1) + 1 + hook_lines = String.split(@mob_hook_js, "\n") + + (Enum.take(lines, insert_at) ++ [""] ++ hook_lines ++ Enum.drop(lines, insert_at)) + |> Enum.join("\n") + end + + defp register_hook_in_live_socket(content) do + cond do + String.contains?(content, "hooks: {}") -> + String.replace(content, "hooks: {}", "hooks: {MobHook}") + + Regex.match?(Regex.compile!("hooks:\\s*\\{"), content) -> + Regex.replace(Regex.compile!("(hooks:\\s*\\{)"), content, "\\1MobHook, ", global: false) + + true -> + Regex.replace( + Regex.compile!("(new LiveSocket\\([^)]+)\\)"), + content, + fn full, prefix -> + if String.contains?(full, "{") do + String.replace(full, "}", ", hooks: {MobHook}}", global: false) + else + "#{prefix}, {hooks: {MobHook}})" + end + end, + global: false + ) + end + end +end diff --git a/lib/mob_dev/enable/igniter.ex b/lib/mob_dev/enable/igniter.ex new file mode 100644 index 0000000..8a9d469 --- /dev/null +++ b/lib/mob_dev/enable/igniter.ex @@ -0,0 +1,754 @@ +defmodule MobDev.Enable.Igniter do + @moduledoc """ + Igniter-aware feature handlers for `mix mob.enable`. + + One function per `<feature>` returning `igniter -> igniter`. Phase 4 of + the build-system migration moves each handler off the legacy + string-mutation path (where each helper writes files immediately) and + onto Igniter's `update_file` / `create_new_file` flow (where every + change rolls into a single dry-run-able diff before any file is + written). + + Per-feature state: + + | Feature | Igniter-routed (iter 1) | Elixir AST-aware | + |----------------|-------------------------|-------------------| + | camera | yes | (no Elixir surface) | + | photo_library | yes | (no Elixir surface) | + | location | yes | (no Elixir surface) | + | file_sharing | yes | (no Elixir surface) | + | notifications | yes | (no Elixir surface) | + | liveview | yes | mob_screen.ex via `create_module` (iter 1) | + | python | yes | dep via `add_dep` (iter 2); paths module via `create_module` (iter 1) | + + iter 1 wrapped every feature in Igniter so the diff preview + atomic + apply flow applies uniformly. iter 2 swapped python's mix.exs + dep-injection from `MobDev.Enable.inject_pythonx_dep` (regex) to + `Igniter.Project.Deps.add_dep` (AST). The remaining text-level + patches (assets/js/app.js, root.html.heex) are non-Elixir source + and stay text-level — AST tooling for those isn't a win. + + All handlers are called with the project root as cwd (Igniter expects + paths relative to cwd). The `app_name` arg is the project's :app + Mix config (a string like "my_app") for any feature that needs to + template it into generated source. + """ + + alias MobDev.Enable + + # ── camera ──────────────────────────────────────────────────────────────── + + @spec enable_camera(Igniter.t(), String.t()) :: Igniter.t() + def enable_camera(igniter, _app_name) do + igniter + |> add_ios_plist_key("NSCameraUsageDescription", "This app uses the camera.") + |> add_android_permission("android.permission.CAMERA") + end + + # ── photo_library ───────────────────────────────────────────────────────── + + @spec enable_photo_library(Igniter.t(), String.t()) :: Igniter.t() + def enable_photo_library(igniter, _app_name) do + igniter + |> add_ios_plist_key( + "NSPhotoLibraryAddUsageDescription", + "This app saves photos to your library." + ) + |> Igniter.add_notice("photo_library: no Android manifest change needed on API 29+.") + end + + # ── location ────────────────────────────────────────────────────────────── + + @spec enable_location(Igniter.t(), String.t()) :: Igniter.t() + def enable_location(igniter, _app_name) do + igniter + |> add_ios_plist_key( + "NSLocationWhenInUseUsageDescription", + "This app uses your location." + ) + |> add_android_permission("android.permission.ACCESS_FINE_LOCATION") + end + + # ── file_sharing ────────────────────────────────────────────────────────── + + @spec enable_file_sharing(Igniter.t(), String.t()) :: Igniter.t() + def enable_file_sharing(igniter, _app_name) do + igniter + |> add_ios_plist_key("UIFileSharingEnabled", "true", type: :bool) + |> add_ios_plist_key("LSSupportsOpeningDocumentsInPlace", "true", type: :bool) + |> add_android_file_provider() + end + + # ── notifications ───────────────────────────────────────────────────────── + + @spec enable_notifications(Igniter.t(), String.t()) :: Igniter.t() + def enable_notifications(igniter, app_name) do + igniter + |> create_ios_push_entitlements(app_name) + |> Igniter.add_notice( + "notifications: Android POST_NOTIFICATIONS is requested at runtime, no manifest key needed." + ) + |> Igniter.add_notice( + "notifications: run `mix mob.provision` to download a push-capable provisioning profile." + ) + end + + # ── liveview ────────────────────────────────────────────────────────────── + + @spec enable_liveview(Igniter.t(), String.t()) :: Igniter.t() + def enable_liveview(igniter, app_name) do + igniter + |> create_mob_screen_module(app_name) + |> inject_mob_hook() + |> inject_mob_bridge_element(app_name) + |> ensure_mob_exs_liveview_port() + |> add_android_liveview_network_config() + end + + # ── mlx ─────────────────────────────────────────────────────────────────── + + @doc """ + Enables MLX + EMLX (Apple's MLX numerics + the EMLX Nx backend) for + iOS. Adds `:nx` and `:emlx` to deps; generates a tiny + `<App>.MLInit` helper that picks `EMLX.Backend` at boot with a clean + fallback to `Nx.BinaryBackend` if the NIF can't load. + + The `:emlx_nif` static NIF entry is already in `MobDev.StaticNifs` + defaults — `MobDev.NativeBuild` auto-detects the `:emlx` dep, downloads + the cross-compiled MLX bundle, and sets `MOB_STATIC_EMLX_NIF` so the + driver_tab + linker include EMLX. So `mob.enable mlx` is mostly about + the dep wiring and the helper module. + """ + @spec enable_mlx(Igniter.t(), String.t()) :: Igniter.t() + def enable_mlx(igniter, app_name) do + igniter + |> inject_mlx_deps() + |> create_ml_init_module(app_name) + end + + # ── nxeigen ─────────────────────────────────────────────────────────────── + + @doc """ + Enables the NxEigen Nx backend (Eigen-backed C++ NIF) on both iOS and + Android. mob_dev cross-compiles `libnx_eigen.a` per arch from the + `:nx_eigen` Hex dep + the Eigen header tarball it auto-downloads. + + The `:nx_eigen` static-NIF entry is already in + `MobDev.StaticNifs` defaults — `MobDev.NativeBuild` auto-detects the + `:nx_eigen` dep and sets `MOB_STATIC_NX_EIGEN_NIF` so the driver_tab + + linker include it. `mob.enable nxeigen` does the dep wiring + the + helper module. + """ + @spec enable_nxeigen(Igniter.t(), String.t()) :: Igniter.t() + def enable_nxeigen(igniter, app_name) do + igniter + |> inject_nxeigen_deps() + |> create_nxeigen_init_module(app_name) + end + + # ── tflite ──────────────────────────────────────────────────────────────── + + @doc """ + Enables the TensorFlow Lite NIF (`:nx_tflite_mob`) on both iOS and + Android. Adds the dep, generates a small `<App>.TfliteInit` helper that + surfaces per-platform default opts (NNAPI accelerator on Android, Core + ML delegate on iOS), and registers the static-NIF guard via the + build pipeline. + + Unlike `mlx` / `nxeigen`, this is not an Nx backend — `NxTfliteMob` + wraps TFLite's model-inference API directly. The user loads a + `.tflite` model and calls `NxTfliteMob.call(handle, inputs)`. The + generated `<App>.TfliteInit` only provides convenience helpers for + picking the right delegate + accelerator for the host platform. + """ + @spec enable_tflite(Igniter.t(), String.t()) :: Igniter.t() + def enable_tflite(igniter, app_name) do + igniter + |> inject_tflite_deps() + |> create_tflite_init_module(app_name) + end + + # ── python ──────────────────────────────────────────────────────────────── + + @spec enable_python(Igniter.t(), String.t()) :: Igniter.t() + def enable_python(igniter, app_name) do + igniter + |> inject_pythonx_dep() + |> create_python_paths_module(app_name) + |> python_native_template_check(app_name) + end + + # ── Shared helpers (text-level, but rolled into Igniter's diff) ─────────── + # + # Plist + AndroidManifest patches stay text-level for now — the AST-based + # XML/plist tools are out of scope for Phase 4. The win we're after here + # is the diff-preview + atomic-apply, which Igniter.update_file gives us + # without touching the patch logic itself. + + @doc """ + Adds an iOS Info.plist `<key>...<string>...` pair if not already present. + + No-op (with a notice) when no Info.plist is found under `ios/`. The + insertion is idempotent — runs that find the key already present skip + the patch silently. + """ + @spec add_ios_plist_key(Igniter.t(), String.t(), String.t(), keyword()) :: Igniter.t() + def add_ios_plist_key(igniter, key, value, opts \\ []) do + case find_ios_plist(igniter) do + nil -> + Igniter.add_notice(igniter, "iOS: no Info.plist found under ios/ — skipped #{key}.") + + plist -> + igniter + |> Igniter.include_existing_file(plist) + |> Igniter.update_file(plist, fn source -> + content = Rewrite.Source.get(source, :content) + + if String.contains?(content, key) do + source + else + entry = Enable.build_plist_entry(key, value, opts) + patched = String.replace(content, "</dict>\n</plist>", "#{entry}\n</dict>\n</plist>") + Rewrite.Source.update(source, :content, patched) + end + end) + end + end + + @doc """ + Adds an Android `<uses-permission>` line to AndroidManifest.xml. + + No-op (with a notice) when no AndroidManifest.xml is found. Idempotent + on the permission name — re-running with the same permission skips + the patch silently. + """ + @spec add_android_permission(Igniter.t(), String.t()) :: Igniter.t() + def add_android_permission(igniter, permission) do + case find_android_manifest(igniter) do + nil -> + Igniter.add_notice( + igniter, + "Android: no AndroidManifest.xml found — skipped permission #{permission}." + ) + + manifest -> + igniter + |> Igniter.include_existing_file(manifest) + |> Igniter.update_file(manifest, fn source -> + content = Rewrite.Source.get(source, :content) + + if String.contains?(content, permission) do + source + else + tag = ~s(<uses-permission android:name="#{permission}"/>) + + patched = + String.replace(content, "<application", "#{tag}\n <application", global: false) + + Rewrite.Source.update(source, :content, patched) + end + end) + end + end + + # ── file_sharing: Android FileProvider ──────────────────────────────────── + + defp add_android_file_provider(igniter) do + case find_android_manifest(igniter) do + nil -> + Igniter.add_notice( + igniter, + "Android: no AndroidManifest.xml found — skipped FileProvider." + ) + + manifest -> + igniter + |> Igniter.include_existing_file(manifest) + |> Igniter.update_file(manifest, fn source -> + content = Rewrite.Source.get(source, :content) + + if String.contains?(content, "FileProvider") do + source + else + patched = + String.replace( + content, + "</application>", + file_provider_xml() <> "\n </application>", + global: false + ) + + Rewrite.Source.update(source, :content, patched) + end + end) + |> create_file_provider_paths_xml() + end + end + + defp create_file_provider_paths_xml(igniter) do + path = "android/app/src/main/res/xml/file_provider_paths.xml" + + if File.exists?(path) do + igniter + else + Igniter.create_new_file(igniter, path, """ + <?xml version="1.0" encoding="utf-8"?> + <paths> + <files-path name="mob_files" path="." /> + <cache-path name="mob_cache" path="." /> + <external-files-path name="mob_external" path="." /> + </paths> + """) + end + end + + defp file_provider_xml do + " <provider\n" <> + " android:name=\"androidx.core.content.FileProvider\"\n" <> + " android:authorities=\"${applicationId}.fileprovider\"\n" <> + " android:exported=\"false\"\n" <> + " android:grantUriPermissions=\"true\">\n" <> + " <meta-data\n" <> + " android:name=\"android.support.FILE_PROVIDER_PATHS\"\n" <> + " android:resource=\"@xml/file_provider_paths\"/>\n" <> + " </provider>" + end + + # ── notifications: iOS push entitlements file ───────────────────────────── + + defp create_ios_push_entitlements(igniter, app_name) do + path = "ios/#{app_name}.entitlements" + + if File.exists?(path) do + igniter + else + Igniter.create_new_file(igniter, path, """ + <?xml version="1.0" encoding="UTF-8"?> + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> + <plist version="1.0"> + <dict> + <key>aps-environment</key> + <string>development</string> + </dict> + </plist> + """) + end + end + + # ── liveview: helpers ───────────────────────────────────────────────────── + + defp create_mob_screen_module(igniter, app_name) do + module_name = Macro.camelize(app_name) + module = Module.concat([module_name, "MobScreen"]) + {exists?, igniter} = Igniter.Project.Module.module_exists(igniter, module) + + if exists? do + igniter + else + body = mob_screen_body() + Igniter.Project.Module.create_module(igniter, module, body) + end + end + + defp mob_screen_body do + """ + @moduledoc \"\"\" + Mob.Screen that wraps the Phoenix LiveView app in a native WebView. + + Add this to your supervision tree or call from Mob.App.on_start/0: + + Mob.Screen.start_root(__MODULE__) + \"\"\" + use Mob.Screen + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def render(_assigns) do + Mob.UI.webview( + url: Mob.LiveView.local_url("/"), + show_url: false + ) + end + """ + end + + defp inject_mob_hook(igniter) do + path = "assets/js/app.js" + + if not File.exists?(path) do + Igniter.add_notice( + igniter, + "liveview: assets/js/app.js not found — add MobHook manually (see `Mob.LiveView` docs)." + ) + else + igniter + |> Igniter.include_existing_file(path) + |> Igniter.update_file(path, fn source -> + content = Rewrite.Source.get(source, :content) + + if String.contains?(content, "MobHook") do + source + else + Rewrite.Source.update(source, :content, Enable.inject_mob_hook(content)) + end + end) + end + end + + defp inject_mob_bridge_element(igniter, app_name) do + case Enable.find_root_html(File.cwd!(), app_name) do + nil -> + Igniter.add_notice(igniter, """ + liveview: root.html.heex not found. Add this manually inside <body>: + #{Enable.mob_bridge_element()} + + Without this element MobHook never mounts and window.mob will not + route through LiveView. See guides/liveview.md. + """) + + abs_path -> + rel = Path.relative_to(abs_path, File.cwd!()) + + igniter + |> Igniter.include_existing_file(rel) + |> Igniter.update_file(rel, fn source -> + content = Rewrite.Source.get(source, :content) + + if String.contains?(content, "mob-bridge") do + source + else + Rewrite.Source.update(source, :content, Enable.inject_mob_bridge_element(content)) + end + end) + end + end + + defp ensure_mob_exs_liveview_port(igniter) do + line = "config :mob, liveview_port: 4000" + + igniter + |> Igniter.create_or_update_file("mob.exs", "import Config\n\n#{line}\n", fn source -> + content = Rewrite.Source.get(source, :content) + + cond do + String.contains?(content, "liveview_port") -> + {:ok, source} + + true -> + {:ok, Rewrite.Source.update(source, :content, content <> "\n#{line}\n")} + end + end) + end + + defp add_android_liveview_network_config(igniter) do + case find_android_manifest(igniter) do + nil -> + Igniter.add_notice( + igniter, + "Android: no AndroidManifest.xml found — skipped LiveView networkSecurityConfig." + ) + + manifest -> + igniter + |> Igniter.include_existing_file(manifest) + |> Igniter.update_file(manifest, fn source -> + content = Rewrite.Source.get(source, :content) + + if String.contains?(content, "networkSecurityConfig") do + source + else + Rewrite.Source.update( + source, + :content, + Enable.inject_android_network_security_config(content) + ) + end + end) + end + end + + # ── mlx: helpers ────────────────────────────────────────────────────────── + + defp inject_mlx_deps(igniter) do + igniter + |> Igniter.Project.Deps.add_dep({:nx, "~> 0.10"}) + |> Igniter.Project.Deps.add_dep({:emlx, "~> 0.2"}) + end + + # ── nxeigen: helpers ────────────────────────────────────────────────────── + + defp inject_nxeigen_deps(igniter) do + igniter + |> Igniter.Project.Deps.add_dep({:nx, "~> 0.10"}) + |> Igniter.Project.Deps.add_dep({:nx_eigen, "~> 0.1"}) + end + + defp create_nxeigen_init_module(igniter, app_name) do + module_name = Macro.camelize(app_name) + module = Module.concat([module_name, "NxEigenInit"]) + {exists?, igniter} = Igniter.Project.Module.module_exists(igniter, module) + + if exists? do + igniter + else + Igniter.Project.Module.create_module(igniter, module, nxeigen_init_module_body()) + end + end + + # ── tflite: helpers ─────────────────────────────────────────────────────── + + defp inject_tflite_deps(igniter) do + igniter + |> Igniter.Project.Deps.add_dep({:nx, "~> 0.10"}) + |> Igniter.Project.Deps.add_dep({:nx_tflite_mob, "~> 0.0.3"}) + end + + defp create_tflite_init_module(igniter, app_name) do + module_name = Macro.camelize(app_name) + module = Module.concat([module_name, "TfliteInit"]) + {exists?, igniter} = Igniter.Project.Module.module_exists(igniter, module) + + if exists? do + igniter + else + Igniter.Project.Module.create_module(igniter, module, tflite_init_module_body()) + end + end + + # Body for the generated `<App>.TfliteInit` module. Picks per-platform + # default TFLite delegate options — NNAPI/mtk-gpu_shim on Android (the + # vendor GPU HAL path, ~155 ms YOLOv8n on the Moto BXM-8-256), Core ML + # delegate on iOS (Apple Neural Engine when available, ~30-80 ms YOLOv8n + # depending on op coverage). + defp tflite_init_module_body do + """ + @moduledoc \"\"\" + Default TFLite delegate options per platform. Surface convenience for + `NxTfliteMob.load_module/2` callers — pass the result of `default_opts/0` + and you get the best-available accelerator on this device. + + tflite = File.read!("priv/yolov8n_full_integer_quant.tflite") + {:ok, m} = NxTfliteMob.load_module(tflite, MyApp.TfliteInit.default_opts()) + \"\"\" + + require Logger + + @doc \"Best-available TFLite delegate opts for this platform.\" + def default_opts do + case :os.type() do + {:unix, :darwin} -> + # iOS (or Mac dev host). Core ML delegate hits Apple Neural Engine + # when ops are supported; falls back to CPU/GPU otherwise. + [delegate: "coreml", coreml_ane_only: false] + + {:unix, :linux} -> + # Android (or Linux dev host). NNAPI's mtk-gpu_shim is the + # MediaTek-blessed name on Dimensity-class devices. On other + # OEMs the accelerator name may differ — qti-gpu (Qualcomm), + # samsung-gpu (Exynos), google-edgetpu (Pixel). + [delegate: "nnapi", accelerator: "mtk-gpu_shim", allow_fp16: true] + + _ -> + # Desktop dev (Windows or other Unix). Stay on the bundled CPU path. + [delegate: "xnnpack"] + end + end + + @doc \"Log whether the NIF appears loadable. Call once at app boot.\" + def configure do + case Code.ensure_loaded?(NxTfliteMob) do + true -> + Logger.info("NxTfliteMob loaded; default delegate opts: \#\{inspect(default_opts())\}") + :ok + + false -> + Logger.warning("NxTfliteMob not loaded — TFLite inference will fail until built") + {:error, :not_loaded} + end + end + """ + end + + # Body for the generated `<App>.NxEigenInit` module. Picks NxEigen as + # the Nx global default with a clean fall-back to `Nx.BinaryBackend`. + # Parallel to MLInit but used on Android (where EMLX isn't an option) + # or on iOS apps that want NxEigen's specific behaviour. + defp nxeigen_init_module_body do + """ + @moduledoc \"\"\" + Picks NxEigen as the Nx backend. Called from `Mob.App.on_start/0` + once the app and its deps have started. + + NxEigen is an Eigen-backed CPU Nx backend (C++ template library, + vectorised via NEON on ARM). Works on both iOS and Android — Eigen + is header-only, so mob_dev cross-compiles a single libnx_eigen.a + per arch and statically links it into the app. + + Falls back to `Nx.BinaryBackend` (pure Elixir) when the NIF can't + load — keeps the app running on builds that haven't cross-compiled + NxEigen yet. + \"\"\" + require Logger + + @doc \"Configure the global Nx backend. Returns the chosen backend module.\" + def configure do + case Application.ensure_all_started(:nx_eigen) do + {:ok, _} -> + Nx.global_default_backend(NxEigen.Backend) + Logger.info("Nx backend: NxEigen (Eigen CPU)") + NxEigen.Backend + + {:error, reason} -> + Logger.warning(\"NxEigen failed to start: \#\{inspect(reason)\}; using Nx.BinaryBackend\") + Nx.global_default_backend(Nx.BinaryBackend) + Nx.BinaryBackend + end + end + """ + end + + defp create_ml_init_module(igniter, app_name) do + module_name = Macro.camelize(app_name) + module = Module.concat([module_name, "MLInit"]) + {exists?, igniter} = Igniter.Project.Module.module_exists(igniter, module) + + if exists? do + igniter + else + Igniter.Project.Module.create_module(igniter, module, ml_init_module_body()) + end + end + + # Body for the generated `<App>.MLInit` module. Kept inline because the + # template is small and doesn't need a separate `.eex` file. The string is + # the *body* — `Igniter.Project.Module.create_module/3` wraps it in + # `defmodule <module> do ... end`. + defp ml_init_module_body do + """ + @moduledoc \"\"\" + Picks the right Nx backend for this build. Called from + `Mob.App.on_start/0` once the app and its deps have started. + + On iOS device + simulator: EMLX (Apple's MLX, statically linked into the + app via mob_dev's MLX integration). Falls back to `Nx.BinaryBackend` + (pure Elixir) when the NIF can't load — keeps the app running even if + MLX isn't available on this build (e.g. an Android build that hasn't + cross-compiled MLX yet). + + The default device is `:cpu` because v1 of the EMLX iOS integration + ships CPU-only. Update to `:gpu` once the Metal variant lands. + \"\"\" + require Logger + + @doc \"Configure the global Nx backend. Returns the chosen backend module.\" + def configure do + case Application.ensure_all_started(:emlx) do + {:ok, _} -> + Nx.global_default_backend({EMLX.Backend, device: :cpu}) + Logger.info("Nx backend: EMLX (cpu)") + EMLX.Backend + + {:error, reason} -> + Logger.warning(\"EMLX failed to start: \#\{inspect(reason)\}; using Nx.BinaryBackend\") + Nx.global_default_backend(Nx.BinaryBackend) + Nx.BinaryBackend + end + end + """ + end + + # ── python: helpers ─────────────────────────────────────────────────────── + + defp inject_pythonx_dep(igniter) do + # AST-aware (Phase 4 iter 2) — `Igniter.Project.Deps.add_dep` parses + # the project's mix.exs, locates the `defp deps do [...]` list, and + # appends the dep tuple in-place. Idempotent: a duplicate + # `{:pythonx, ...}` is detected and skipped. Replaces the previous + # regex sweep in `MobDev.Enable.inject_pythonx_dep/1` which had to + # guess at indentation + trailing comma shape. + Igniter.Project.Deps.add_dep(igniter, {:pythonx, "~> 0.4"}) + end + + defp create_python_paths_module(igniter, app_name) do + module_name = Macro.camelize(app_name) + module = Module.concat([module_name, "PythonPaths"]) + {exists?, igniter} = Igniter.Project.Module.module_exists(igniter, module) + + if exists? do + igniter + else + # The existing template renders a full `defmodule ... do ... end` — + # strip the wrapper so `Igniter.Project.Module.create_module` can + # apply its own `defmodule` shell. + body = strip_defmodule_wrapper(Enable.python_paths_module_template(module_name)) + Igniter.Project.Module.create_module(igniter, module, body) + end + end + + defp strip_defmodule_wrapper(source) do + source + |> String.split("\n") + |> Enum.drop(1) + |> Enum.drop(-1) + |> Enum.drop(-1) + |> Enum.join("\n") + end + + defp python_native_template_check(igniter, app_name) do + case Enable.detect_stale_pythonx_templates(File.cwd!(), app_name) do + [] -> + Igniter.add_notice(igniter, "python: native templates look up to date.") + + stale -> + files = + Enum.map_join(stale, "\n", fn {file, marker} -> " - #{file} (missing: #{marker})" end) + + Igniter.add_warning(igniter, """ + Native build templates are stale — Pythonx requires extra build steps that aren't present: + #{files} + + Either generate a fresh project with `mix mob.new` and copy your app code over, + or copy the missing blocks from ~/.mix/archives/mob_new-*/priv/templates/mob.new/. + """) + end + end + + # ── File discovery (Igniter-aware so test_project virtual files work) ──── + + defp find_ios_plist(igniter) do + cwd = File.cwd!() + + abs = + cwd + |> Path.join("ios/**/Info.plist") + |> Path.wildcard() + |> List.first() + + cond do + # Disk hit (real project) — return relative path so Igniter's diff + # matches what the user sees. + abs -> + Path.relative_to(abs, cwd) + + # No disk hit — check Igniter's known sources for ios/**/Info.plist + # (covers Igniter.test_project where files are virtualized in + # `igniter.rewrite` rather than written to disk). + true -> + igniter.rewrite + |> Rewrite.paths() + |> Enum.find(&String.match?(&1, ~r{^ios/.*Info\.plist$})) + end + end + + defp find_android_manifest(igniter) do + path = "android/app/src/main/AndroidManifest.xml" + + cond do + File.exists?(path) -> path + Igniter.exists?(igniter, path) -> path + true -> nil + end + end +end diff --git a/lib/mob_dev/google_play.ex b/lib/mob_dev/google_play.ex new file mode 100644 index 0000000..1f81583 --- /dev/null +++ b/lib/mob_dev/google_play.ex @@ -0,0 +1,232 @@ +defmodule MobDev.GooglePlay do + @moduledoc """ + Google Play Developer API client for uploading Android App Bundles. + + Authenticates using a service account JSON key (RSA JWT → OAuth2 access token), + then drives the Play edit workflow: + create edit → upload .aab → assign track → commit + + ## Service account setup (one-time) + + 1. Go to Play Console → Setup → API access → link to a Google Cloud project. + 2. In Google Cloud Console → IAM → Service Accounts → Create a service account. + 3. Download the JSON key for that service account. + 4. Back in Play Console → Setup → API access → grant the service account + "Release manager" (or "Admin") permission. + + ## mob.exs config + + config :mob_dev, + google_play: [ + package_name: "com.example.myapp", + service_account_json: "~/.google_play/my-service-account.json", + track: "internal" # internal | alpha | beta | production + ] + """ + + alias MobDev.GooglePlay.HTTP + + @play "https://androidpublisher.googleapis.com/androidpublisher/v3/applications" + @upload "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications" + @token_url "https://oauth2.googleapis.com/token" + @scope "https://www.googleapis.com/auth/androidpublisher" + + @doc """ + Uploads `aab_path` to Google Play and assigns it to `track`. + + Options (all required unless noted): + - `:service_account_json` — path to the service account JSON key file + - `:package_name` — Android applicationId (e.g. "com.beyondagronomy.aircartmax") + - `:track` — "internal" | "alpha" | "beta" | "production" (default: "internal") + + Returns `{:ok, version_code}` or `{:error, reason}`. + """ + @spec upload(Path.t(), keyword()) :: {:ok, integer()} | {:error, String.t()} + def upload(aab_path, opts) do + sa_path = Keyword.fetch!(opts, :service_account_json) |> Path.expand() + package = Keyword.fetch!(opts, :package_name) + track = Keyword.get(opts, :track, "internal") + + ensure_started!() + + with {:ok, sa} <- load_service_account(sa_path), + log("Authenticating with Google..."), + {:ok, token} <- fetch_access_token(sa), + log("Creating edit..."), + {:ok, edit_id} <- create_edit(token, package), + log("Uploading #{file_size(aab_path)}..."), + {:ok, version_code} <- upload_bundle(token, package, edit_id, aab_path), + log("Assigning versionCode #{version_code} to #{track} track..."), + :ok <- assign_track(token, package, edit_id, track, version_code), + log("Committing edit..."), + :ok <- commit_edit(token, package, edit_id) do + {:ok, version_code} + end + end + + # ── Service account ────────────────────────────────────────────────────────── + + defp load_service_account(path) do + case File.read(path) do + {:ok, json} -> + Jason.decode(json) + + {:error, reason} -> + {:error, "Cannot read service account file #{path}: #{:file.format_error(reason)}"} + end + end + + # ── JWT / OAuth2 ───────────────────────────────────────────────────────────── + + defp fetch_access_token(sa) do + jwt = sign_jwt(sa) + + body = + URI.encode_query(%{ + "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion" => jwt + }) + + case post(@token_url, [{"content-type", "application/x-www-form-urlencoded"}], body) do + {:ok, %{"access_token" => token}} -> {:ok, token} + {:ok, resp} -> {:error, "Token exchange failed: #{inspect(resp)}"} + {:error, _} = err -> err + end + end + + defp sign_jwt(sa) do + now = System.os_time(:second) + + header = + %{"alg" => "RS256", "typ" => "JWT"} + |> Jason.encode!() + |> Base.url_encode64(padding: false) + + payload = + %{ + "iss" => sa["client_email"], + "scope" => @scope, + "aud" => @token_url, + "iat" => now, + "exp" => now + 3600 + } + |> Jason.encode!() + |> Base.url_encode64(padding: false) + + input = "#{header}.#{payload}" + key = decode_private_key(sa["private_key"]) + sig = :public_key.sign(input, :sha256, key) |> Base.url_encode64(padding: false) + "#{input}.#{sig}" + end + + # Google service account keys are PKCS#8 ("BEGIN PRIVATE KEY"). + # pem_entry_decode/1 handles unwrapping to the inner RSAPrivateKey. + defp decode_private_key(pem) do + [entry] = :public_key.pem_decode(pem) + :public_key.pem_entry_decode(entry) + end + + # ── Play API edit workflow ─────────────────────────────────────────────────── + + defp create_edit(token, package) do + case post("#{@play}/#{package}/edits", json_headers(token), "{}") do + {:ok, %{"id" => id}} -> {:ok, id} + {:ok, resp} -> {:error, "Create edit: #{inspect(resp)}"} + err -> err + end + end + + defp upload_bundle(token, package, edit_id, aab_path) do + url = "#{@upload}/#{package}/edits/#{edit_id}/bundles?uploadType=media" + aab = File.read!(aab_path) + + headers = [ + {"authorization", "Bearer #{token}"}, + {"content-type", "application/octet-stream"} + ] + + case post(url, headers, aab) do + {:ok, %{"versionCode" => vc}} -> {:ok, vc} + {:ok, resp} -> {:error, "Upload bundle: #{inspect(resp)}"} + err -> err + end + end + + defp assign_track(token, package, edit_id, track, version_code) do + url = "#{@play}/#{package}/edits/#{edit_id}/tracks/#{track}" + + body = + Jason.encode!(%{ + "releases" => [ + %{ + "versionCodes" => [Integer.to_string(version_code)], + "status" => "completed" + } + ] + }) + + case put(url, json_headers(token), body) do + {:ok, _} -> :ok + err -> err + end + end + + defp commit_edit(token, package, edit_id) do + url = "#{@play}/#{package}/edits/#{edit_id}:commit" + + case post(url, json_headers(token), "{}") do + {:ok, _} -> + :ok + + {:error, msg} = err -> + if needs_changes_not_sent_for_review?(msg) do + # Google won't auto-send these changes for review (e.g. a release while + # the app is under policy review). Commit without sending — the changes + # are then sent for review from the Play Console UI. + log( + "changes can't be auto-sent for review — committing with " <> + "changesNotSentForReview=true (send for review from the Console)" + ) + + case post("#{url}?changesNotSentForReview=true", json_headers(token), "{}") do + {:ok, _} -> :ok + retry_err -> retry_err + end + else + err + end + end + end + + # True when a commit failed solely because Google requires the + # changesNotSentForReview flag (app under policy review, etc). The Play API + # names the query parameter verbatim in the 400 body. + @doc false + @spec needs_changes_not_sent_for_review?(String.t()) :: boolean() + def needs_changes_not_sent_for_review?(error_message) when is_binary(error_message) do + String.contains?(error_message, "changesNotSentForReview") + end + + # ── HTTP ───────────────────────────────────────────────────────────────────── + + defp post(url, headers, body), do: HTTP.post(url, headers, body) + defp put(url, headers, body), do: HTTP.put(url, headers, body) + defp json_headers(token), do: HTTP.json_headers(token) + + defp ensure_started!, do: HTTP.ensure_started!() + + defp file_size(path) do + case File.stat(path) do + {:ok, %{size: b}} when b >= 1_048_576 -> + :io_lib.format("~.1fMB", [b / 1_048_576]) |> List.flatten() |> to_string() + + {:ok, %{size: b}} -> + :io_lib.format("~.1fKB", [b / 1024]) |> List.flatten() |> to_string() + + _ -> + "?" + end + end + + defp log(msg), do: Mix.shell().info(" #{msg}") +end diff --git a/lib/mob_dev/google_play/cloud_setup.ex b/lib/mob_dev/google_play/cloud_setup.ex new file mode 100644 index 0000000..bed9fd7 --- /dev/null +++ b/lib/mob_dev/google_play/cloud_setup.ex @@ -0,0 +1,211 @@ +defmodule MobDev.GooglePlay.CloudSetup do + @moduledoc """ + Google Cloud REST API operations for the Play Store setup wizard. + + Covers the three Google Cloud steps that `mix mob.setup.google_play` automates: + - Enabling the Android Publisher API in a Cloud project + - Creating a `play-publisher` service account + - Generating and saving a JSON key for that service account + + All functions take an OAuth2 `access_token` obtained from `MobDev.GooglePlay.OAuth`. + + ## API surface used + + | Step | API | + |------|-----| + | List projects | Cloud Resource Manager v3 | + | Enable Android Publisher API | Service Usage v1 | + | Create service account | IAM v1 | + | Create JSON key | IAM v1 | + """ + + alias MobDev.GooglePlay.HTTP + + @crm "https://cloudresourcemanager.googleapis.com/v3" + @iam "https://iam.googleapis.com/v1" + @service_usage "https://serviceusage.googleapis.com/v1" + @publisher_api "androidpublisher.googleapis.com" + @service_account_id "play-publisher" + + # ── Projects ───────────────────────────────────────────────────────────────── + + @doc """ + Lists Google Cloud projects accessible to the authenticated user. + + Returns `{:ok, [%{"projectId" => id, "displayName" => name, ...}]}` or + `{:error, reason}`. + """ + @spec list_projects(String.t()) :: {:ok, [map()]} | {:error, String.t()} + def list_projects(token) do + HTTP.ensure_started!() + url = "#{@crm}/projects?pageSize=100" + + case HTTP.get(url, HTTP.json_headers(token)) do + {:ok, %{"projects" => projects}} -> {:ok, projects} + {:ok, resp} -> {:ok, Map.get(resp, "projects", [])} + {:error, _} = err -> err + end + end + + @doc """ + Parses the `projects` list from a Cloud Resource Manager response body. + + Pure function — used for testing without HTTP calls. + """ + @spec parse_projects_response(map()) :: [map()] + def parse_projects_response(%{"projects" => projects}), do: projects + def parse_projects_response(_), do: [] + + # ── Enable API ─────────────────────────────────────────────────────────────── + + @doc """ + Enables the Android Publisher API in the given Cloud project. + + The operation may take up to 60 seconds; this function polls until complete. + Returns `:ok` or `{:error, reason}`. + """ + @spec enable_publisher_api(String.t(), String.t()) :: :ok | {:error, String.t()} + def enable_publisher_api(token, project_id) do + HTTP.ensure_started!() + url = build_enable_api_url(project_id, @publisher_api) + + case HTTP.post(url, HTTP.json_headers(token), "{}") do + {:ok, %{"name" => op_name}} -> + poll_operation(token, op_name) + + {:ok, %{"done" => true}} -> + :ok + + {:ok, resp} -> + {:error, "Unexpected enable API response: #{inspect(resp)}"} + + {:error, _} = err -> + err + end + end + + @doc """ + Builds the Service Usage API URL for enabling a specific service. + + Pure function — useful for testing and debugging. + """ + @spec build_enable_api_url(String.t(), String.t()) :: String.t() + def build_enable_api_url(project_id, service_name) do + "#{@service_usage}/projects/#{project_id}/services/#{service_name}:enable" + end + + # ── Service account ────────────────────────────────────────────────────────── + + @doc """ + Creates the `play-publisher` service account in the given Cloud project. + + Returns `{:ok, email}` where `email` is the service account email, or + `{:error, reason}`. + + If the service account already exists (HTTP 409), returns its email + without error. + """ + @spec create_service_account(String.t(), String.t(), String.t()) :: + {:ok, String.t()} | {:error, String.t()} + def create_service_account(token, project_id, display_name \\ "Mob Play Publisher") do + HTTP.ensure_started!() + url = "#{@iam}/projects/#{project_id}/serviceAccounts" + + body = + Jason.encode!(%{ + "accountId" => @service_account_id, + "serviceAccount" => %{"displayName" => display_name} + }) + + case HTTP.post(url, HTTP.json_headers(token), body) do + {:ok, %{"email" => email}} -> + {:ok, email} + + {:error, "HTTP 409: " <> _} -> + # Already exists — derive the email from the project ID. + {:ok, "#{@service_account_id}@#{project_id}.iam.gserviceaccount.com"} + + {:error, _} = err -> + err + end + end + + # ── JSON key ───────────────────────────────────────────────────────────────── + + @doc """ + Creates a JSON key for the given service account and saves it to disk. + + The key is written to `~/.google_play/{filename}.json` (mode 600). + Returns `{:ok, path}` where `path` is the absolute path to the saved file, + or `{:error, reason}`. + """ + @spec create_and_save_key(String.t(), String.t(), String.t(), String.t()) :: + {:ok, Path.t()} | {:error, String.t()} + def create_and_save_key(token, project_id, service_account_email, filename) do + HTTP.ensure_started!() + url = "#{@iam}/projects/#{project_id}/serviceAccounts/#{service_account_email}/keys" + + case HTTP.post(url, HTTP.json_headers(token), "{}") do + {:ok, %{"privateKeyData" => b64_json}} -> + save_key_file(b64_json, filename) + + {:ok, resp} -> + {:error, "Unexpected key creation response: #{inspect(resp)}"} + + {:error, _} = err -> + err + end + end + + @doc """ + Decodes a base64url-encoded service account JSON key and saves it to disk. + + The `b64_json` parameter is the `privateKeyData` field from the IAM keys API + response (standard base64, not URL-safe). Saved to `~/.google_play/{filename}.json` + with mode 600. + + Pure IO side-effect (no HTTP) — useful for testing. + """ + @spec save_key_file(String.t(), String.t()) :: {:ok, Path.t()} | {:error, String.t()} + def save_key_file(b64_json, filename) do + dir = Path.expand("~/.google_play") + File.mkdir_p!(dir) + path = Path.join(dir, "#{filename}.json") + + with {:ok, json_bytes} <- Base.decode64(b64_json, padding: true), + :ok <- File.write(path, json_bytes), + :ok <- File.chmod(path, 0o600) do + {:ok, path} + else + :error -> {:error, "Could not base64-decode the key data returned by Google"} + {:error, reason} -> {:error, "Could not write key file: #{inspect(reason)}"} + end + end + + # ── Long-running operation polling ────────────────────────────────────────── + + defp poll_operation(token, op_name, attempts \\ 0) + + defp poll_operation(_token, op_name, 20) do + {:error, "Timed out waiting for operation #{op_name} to complete"} + end + + defp poll_operation(token, op_name, attempts) do + url = "#{@service_usage}/#{op_name}" + + case HTTP.get(url, HTTP.json_headers(token)) do + {:ok, %{"done" => true, "error" => err}} -> + {:error, "Operation failed: #{inspect(err)}"} + + {:ok, %{"done" => true}} -> + :ok + + {:ok, _} -> + Process.sleep(3_000 + attempts * 1_000) + poll_operation(token, op_name, attempts + 1) + + {:error, _} = err -> + err + end + end +end diff --git a/lib/mob_dev/google_play/http.ex b/lib/mob_dev/google_play/http.ex new file mode 100644 index 0000000..f3c0c92 --- /dev/null +++ b/lib/mob_dev/google_play/http.ex @@ -0,0 +1,98 @@ +defmodule MobDev.GooglePlay.HTTP do + @moduledoc false + # Shared `:httpc` wrapper used by GooglePlay, CloudSetup, PlaySetup, and OAuth. + + @type result :: {:ok, map()} | {:error, String.t()} + + @spec get(String.t(), list()) :: result() + def get(url, headers), do: request(:get, url, headers, nil) + + @spec post(String.t(), list(), String.t()) :: result() + def post(url, headers, body), do: request(:post, url, headers, body) + + @spec put(String.t(), list(), String.t()) :: result() + def put(url, headers, body), do: request(:put, url, headers, body) + + @spec json_headers(String.t()) :: list() + def json_headers(token) do + [{"authorization", "Bearer #{token}"}, {"content-type", "application/json"}] + end + + @spec ensure_started!() :: :ok + def ensure_started! do + Application.ensure_all_started(:inets) + Application.ensure_all_started(:ssl) + :ok + end + + @spec request(atom(), String.t(), list(), String.t() | nil) :: result() + def request(:get, url, headers, _body) do + host = URI.parse(url).host + httpc_headers = encode_headers(headers) + + :httpc.request( + :get, + {to_charlist(url), httpc_headers}, + [ssl: ssl_opts(host), timeout: 30_000, connect_timeout: 15_000], + body_format: :binary + ) + |> decode_response() + end + + def request(method, url, headers, body) do + {ct, rest} = pop_content_type(headers) + host = URI.parse(url).host + httpc_headers = encode_headers(rest) + body_bin = if is_binary(body), do: body, else: "" + + :httpc.request( + method, + {to_charlist(url), httpc_headers, to_charlist(ct), body_bin}, + [ssl: ssl_opts(host), timeout: 300_000, connect_timeout: 15_000], + body_format: :binary + ) + |> decode_response() + end + + defp decode_response({:ok, {{_, status, _}, _hdrs, resp}}) when status in 200..299 do + {:ok, Jason.decode!(resp)} + end + + defp decode_response({:ok, {{_, status, _}, _hdrs, resp}}) do + msg = + case Jason.decode(resp) do + {:ok, %{"error" => %{"message" => m}}} -> m + {:ok, %{"error" => %{"errors" => [%{"message" => m} | _]}}} -> m + {:ok, %{"error_description" => m}} -> m + _ -> inspect(resp) + end + + {:error, "HTTP #{status}: #{msg}"} + end + + defp decode_response({:error, reason}) do + {:error, "HTTP request failed: #{inspect(reason)}"} + end + + defp encode_headers(headers) do + Enum.map(headers, fn {k, v} -> {to_charlist(k), to_charlist(v)} end) + end + + defp pop_content_type(headers) do + case Enum.split_with(headers, fn {k, _} -> String.downcase(k) == "content-type" end) do + {[{_, ct} | _], rest} -> {ct, rest} + {[], rest} -> {"application/json", rest} + end + end + + defp ssl_opts(host) do + [ + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + server_name_indication: to_charlist(host), + customize_hostname_check: [ + match_fun: :public_key.pkix_verify_hostname_match_fun(:https) + ] + ] + end +end diff --git a/lib/mob_dev/google_play/oauth.ex b/lib/mob_dev/google_play/oauth.ex new file mode 100644 index 0000000..ad216b5 --- /dev/null +++ b/lib/mob_dev/google_play/oauth.ex @@ -0,0 +1,268 @@ +defmodule MobDev.GooglePlay.OAuth do + @moduledoc """ + OAuth2 browser-based authorization for Google APIs. + + Opens the user's default browser to Google's consent screen, then listens + on a random localhost port for the authorization code callback. No external + CLI tool (gcloud, etc.) is required — only a browser. + + ## OAuth client registration + + This module ships with placeholder client credentials. Before `authorize/1` + will work you must register a Google OAuth "Desktop app" client: + + 1. Go to https://console.cloud.google.com/apis/credentials + 2. Click **Create credentials → OAuth client ID** + 3. Application type: **Desktop app** — name it `mob_dev CLI` + 4. Click **Create** — note the **Client ID** and **Client secret** + 5. Fill in `@default_client_id` and `@default_client_secret` in this file + + For installed CLI tools, the client_secret is not actually secret — this + follows Google's documented guidance for desktop applications (the same + model used by the gcloud CLI). The token is only obtainable by a user who + explicitly grants consent via their browser. + + You can also override with environment variables: + `GOOGLE_OAUTH_CLIENT_ID` / `GOOGLE_OAUTH_CLIENT_SECRET`. + """ + + alias MobDev.GooglePlay.HTTP + + # TODO: register at https://console.cloud.google.com/apis/credentials + # Application type: Desktop app. Fill in the values below after registration. + @default_client_id "TODO_REGISTER.apps.googleusercontent.com" + @default_client_secret "TODO_REGISTER_SECRET" + + @auth_url "https://accounts.google.com/o/oauth2/v2/auth" + @token_url "https://oauth2.googleapis.com/token" + + # Scopes for the setup wizard: + # cloud-platform → enable APIs, create service accounts and keys (GCP APIs) + # androidpublisher → grant Play Console access to the service account + @setup_scopes [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/androidpublisher" + ] + + @doc """ + Returns the scopes requested during the setup wizard OAuth flow. + """ + @spec setup_scopes() :: [String.t()] + def setup_scopes, do: @setup_scopes + + @doc """ + Runs the browser-based OAuth2 flow and returns a bearer access token. + + Opens the user's browser to the Google consent screen, then waits up to + `timeout_ms` milliseconds (default 120 000) for the callback redirect. + + Options: + - `:scopes` — list of OAuth scope strings (required) + - `:timeout_ms` — callback wait timeout in ms (default: 120_000) + + Returns `{:ok, access_token}` or `{:error, reason}`. + """ + @spec authorize(keyword()) :: {:ok, String.t()} | {:error, String.t()} + def authorize(opts \\ []) do + scopes = Keyword.fetch!(opts, :scopes) + timeout_ms = Keyword.get(opts, :timeout_ms, 120_000) + client_id = System.get_env("GOOGLE_OAUTH_CLIENT_ID", @default_client_id) + client_secret = System.get_env("GOOGLE_OAUTH_CLIENT_SECRET", @default_client_secret) + + if String.starts_with?(client_id, "TODO") do + {:error, + "OAuth client not registered yet. " <> + "See MobDev.GooglePlay.OAuth moduledoc for registration steps."} + else + HTTP.ensure_started!() + + with {:ok, port} <- find_free_port(), + redirect_uri = "http://localhost:#{port}/callback", + url = build_auth_url(client_id, scopes, redirect_uri), + :ok <- open_browser(url), + {:ok, code} <- await_callback(port, timeout_ms), + {:ok, tokens} <- exchange_code(client_id, client_secret, code, redirect_uri) do + {:ok, tokens["access_token"]} + end + end + end + + @doc """ + Builds the Google OAuth2 authorization URL. + + Pure function — useful for testing and for displaying the URL in case + the automatic browser open fails. + """ + @spec build_auth_url(String.t(), [String.t()], String.t()) :: String.t() + def build_auth_url(client_id, scopes, redirect_uri) do + params = %{ + "client_id" => client_id, + "redirect_uri" => redirect_uri, + "response_type" => "code", + "scope" => Enum.join(scopes, " "), + "access_type" => "offline", + "prompt" => "consent" + } + + "#{@auth_url}?#{URI.encode_query(params)}" + end + + @doc """ + Parses the authorization code from an OAuth callback HTTP request line. + + The request line has the form: + GET /callback?code=AUTH_CODE&scope=... HTTP/1.1 + + Returns `{:ok, code}` or `{:error, reason}`. + """ + @spec parse_callback_request(String.t()) :: {:ok, String.t()} | {:error, String.t()} + def parse_callback_request(request_line) do + case Regex.run(Regex.compile!(~S{(?:GET|HEAD) /[^?]*\?([^ ]+)}), request_line) do + [_, query] -> + params = URI.decode_query(query) + + case {params["code"], params["error"]} do + {code, nil} when is_binary(code) and code != "" -> + {:ok, code} + + {_, error} when is_binary(error) -> + {:error, "Google denied access: #{error}"} + + _ -> + {:error, "No code or error in callback query: #{query}"} + end + + _ -> + {:error, "Unexpected callback request: #{String.slice(request_line, 0, 100)}"} + end + end + + # ── Internals ──────────────────────────────────────────────────────────────── + + defp find_free_port do + case :gen_tcp.listen(0, [:binary, active: false]) do + {:ok, sock} -> + {:ok, port} = :inet.port(sock) + :gen_tcp.close(sock) + {:ok, port} + + {:error, reason} -> + {:error, "Could not find a free port: #{inspect(reason)}"} + end + end + + defp open_browser(url) do + cmd = + case :os.type() do + {:unix, :darwin} -> {"open", [url]} + {:unix, _} -> {"xdg-open", [url]} + {:win32, _} -> {"cmd", ["/c", "start", url]} + end + + Mix.shell().info(" Opening browser to Google sign-in...") + Mix.shell().info(" If it doesn't open automatically, visit:") + Mix.shell().info(" #{url}") + Mix.shell().info("") + + {bin, args} = cmd + + case System.find_executable(bin) do + nil -> :ok + _ -> System.cmd(bin, args, stderr_to_stdout: true) + end + + :ok + end + + defp await_callback(port, timeout_ms) do + case :gen_tcp.listen(port, [:binary, packet: :line, active: false, reuseaddr: true]) do + {:ok, server} -> + Mix.shell().info(" Waiting for browser sign-in (#{div(timeout_ms, 1000)}s timeout)...") + + result = + case :gen_tcp.accept(server, timeout_ms) do + {:ok, conn} -> + handle_callback_connection(conn) + + {:error, :timeout} -> + {:error, "Timed out waiting for OAuth callback — did the browser open?"} + + {:error, reason} -> + {:error, "Callback listener error: #{inspect(reason)}"} + end + + :gen_tcp.close(server) + result + + {:error, reason} -> + {:error, "Could not start callback listener on port #{port}: #{inspect(reason)}"} + end + end + + defp handle_callback_connection(conn) do + result = + case :gen_tcp.recv(conn, 0, 10_000) do + {:ok, request_line} -> parse_callback_request(request_line) + {:error, reason} -> {:error, "Could not read callback request: #{inspect(reason)}"} + end + + html = callback_html(result) + + response = + "HTTP/1.1 200 OK\r\n" <> + "Content-Type: text/html\r\n" <> + "Content-Length: #{byte_size(html)}\r\n" <> + "Connection: close\r\n\r\n" <> + html + + :gen_tcp.send(conn, response) + :gen_tcp.close(conn) + result + end + + defp callback_html({:ok, _}) do + "<html><body><h1>Authenticated</h1>" <> + "<p>Sign-in successful. You can close this tab and return to the terminal.</p>" <> + "</body></html>" + end + + defp callback_html({:error, reason}) do + "<html><body><h1>Authentication failed</h1>" <> + "<p>#{html_escape(reason)}</p>" <> + "<p>Close this tab and check the terminal for details.</p>" <> + "</body></html>" + end + + # Minimal HTML entity escaping — the error message comes from Google, not user input, + # but be safe. + defp html_escape(str) do + str + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + end + + defp exchange_code(client_id, client_secret, code, redirect_uri) do + body = + URI.encode_query(%{ + "code" => code, + "client_id" => client_id, + "client_secret" => client_secret, + "redirect_uri" => redirect_uri, + "grant_type" => "authorization_code" + }) + + headers = [{"content-type", "application/x-www-form-urlencoded"}] + + case HTTP.post(@token_url, headers, body) do + {:ok, %{"access_token" => _} = tokens} -> + {:ok, tokens} + + {:ok, resp} -> + {:error, "Token exchange returned unexpected response: #{inspect(resp)}"} + + {:error, _} = err -> + err + end + end +end diff --git a/lib/mob_dev/google_play/play_setup.ex b/lib/mob_dev/google_play/play_setup.ex new file mode 100644 index 0000000..c080a64 --- /dev/null +++ b/lib/mob_dev/google_play/play_setup.ex @@ -0,0 +1,112 @@ +defmodule MobDev.GooglePlay.PlaySetup do + @moduledoc """ + Google Play Developer API operations for granting service account access. + + Handles the one automatable step in the Play Console setup: granting + "Release manager" permissions to the service account via the Play + Developer API `accounts.grants` resource. + + ## What this replaces + + The manual steps documented in the publishing guide (Part A and Part B of + section 1.4.4) grant the service account two types of access: + + - **API access page** (Part A) — account-level permissions to manage releases + - **Users and permissions** (Part B) — the same grant surfaced via a different + Play Console UI path + + Both are handled by a single `accounts.grants.create` API call here. + + ## Prerequisite that cannot be automated + + Before this call will work, you must manually link your Google Cloud project + to Play Console: + + Play Console → Setup → API access → Link to a Google Cloud project + + This one-time step has no API — it must be done in the browser. + + ## Finding your developer account ID + + The `developer_account_id` is the numeric ID visible in the Play Console URL: + + https://play.google.com/console/u/0/developers/**5074092065751960701**/... + + It is also printed on the Play Console dashboard. + + ## Permission note + + The Release Manager role grants `CAN_MANAGE_RELEASES` and `CAN_ACCESS_DRAFT_APPS`. + Verify the exact permission enum values against the Play Developer API docs if this + call returns a 400 — Google has not always published these consistently. + """ + + alias MobDev.GooglePlay.HTTP + + @play "https://androidpublisher.googleapis.com/androidpublisher/v3" + + # Permissions that together approximate the "Release Manager" role in Play Console. + # Verify these at: https://developers.google.com/android-publisher/api-ref/rest/v3/accounts.grants + @release_manager_permissions ["CAN_MANAGE_RELEASES", "CAN_ACCESS_DRAFT_APPS"] + + @doc """ + Grants Release Manager access to a service account on the developer's Play account. + + `developer_account_id` — the numeric ID from the Play Console URL (see moduledoc). + `service_account_email` — the service account email (e.g. `play-publisher@project.iam.gserviceaccount.com`). + `package_name` — optional; if provided, grants app-level access only. + Omit (or pass `nil`) for account-level access. + + Returns `:ok` or `{:error, reason}`. + """ + @spec grant_release_manager(String.t(), String.t(), String.t(), String.t() | nil) :: + :ok | {:error, String.t()} + def grant_release_manager( + token, + developer_account_id, + service_account_email, + package_name \\ nil + ) do + HTTP.ensure_started!() + url = "#{@play}/accounts/#{developer_account_id}/grants" + + body = build_grant_request(service_account_email, package_name) + + case HTTP.post(url, HTTP.json_headers(token), Jason.encode!(body)) do + {:ok, _} -> :ok + {:error, "HTTP 409: " <> _} -> :ok + {:error, _} = err -> err + end + end + + @doc """ + Builds the request body for an `accounts.grants.create` API call. + + Pure function — useful for testing without making HTTP calls. + + When `package_name` is nil, returns an account-level grant (Release Manager + permissions on all apps in the developer account). When `package_name` is + provided, returns an app-level grant for that package only. + """ + @spec build_grant_request(String.t(), String.t() | nil) :: map() + def build_grant_request(service_account_email, nil) do + %{ + "grantee" => service_account_email, + "developerAccountPermissions" => @release_manager_permissions + } + end + + def build_grant_request(service_account_email, package_name) do + %{ + "grantee" => service_account_email, + "packageName" => package_name, + "appLevelPermissions" => @release_manager_permissions + } + end + + @doc """ + Returns the permission strings used for the Release Manager role. + """ + @spec release_manager_permissions() :: [String.t()] + def release_manager_permissions, do: @release_manager_permissions +end diff --git a/lib/mob_dev/google_play/setup_wizard.ex b/lib/mob_dev/google_play/setup_wizard.ex new file mode 100644 index 0000000..0abdcc2 --- /dev/null +++ b/lib/mob_dev/google_play/setup_wizard.ex @@ -0,0 +1,511 @@ +defmodule MobDev.GooglePlay.SetupWizard do + @moduledoc """ + Interactive wizard for the Google Play one-time setup. + + Automates the Google Cloud steps in the publishing guide (section 1.4), + leaving only the irreducible manual steps that have no API: + + **Automated by this wizard:** + - Browser OAuth sign-in (no gcloud required) + - Selecting or confirming the Google Cloud project + - Enabling the Android Publisher API + - Creating the `play-publisher` service account + - Generating and saving the JSON key to `~/.google_play/` + - Granting Release Manager access via the Play Developer API + - Generating the upload keystore (if not already present) + - Printing the `mob.exs` config block to add + + **Manual steps that cannot be automated (all one-time per developer account):** + 1. Create the Google Play Developer account (requires $25 payment) + 2. Complete identity verification (government ID upload) + 3. Create the app record in Play Console (no create-app API) + 4. Link the Google Cloud project to Play Console: + Play Console → Setup → API access → Link to a Google Cloud project + + See `guides/publishing_to_google_play.md` for the full step-by-step guide. + """ + + alias MobDev.GooglePlay.{CloudSetup, OAuth, PlaySetup} + + @doc """ + Runs the full interactive setup wizard. + + Options: + - `:package_name` — Android applicationId (e.g. "com.example.myapp"). If not + provided, the wizard will prompt for it. + - `:key_filename` — base filename for the service account JSON (without `.json`). + Defaults to the last segment of the package name (e.g. `"myapp"`). + - `:dry_run` — when `true`, prints every step but makes no HTTP calls, writes no + files, opens no browser, and answers all prompts automatically. Use this to + preview the wizard before running it for real. + + Returns `:ok` on success or `{:error, reason}` on any unrecoverable step. + """ + @spec run(keyword()) :: :ok | {:error, String.t()} + def run(opts \\ []) do + shell = Mix.shell() + dry? = Keyword.get(opts, :dry_run, false) + + shell.info("") + + if dry? do + shell.info(yellow() <> "=== Mob Google Play Setup (DRY RUN) ===" <> reset()) + shell.info("") + shell.info("Showing every step the wizard would take — no changes will be made.") + shell.info("No browser will open, no API calls will be made, no files will be written.") + else + shell.info(cyan() <> "=== Mob Google Play Setup ===" <> reset()) + shell.info("") + shell.info("This wizard automates the Google Cloud steps in the publishing guide.") + shell.info("You will need to complete 4 manual steps — the wizard will prompt you.") + end + + shell.info("") + + package_name = opts[:package_name] || prompt_package_name(shell, dry?) + key_filename = opts[:key_filename] || default_key_filename(package_name) + + with :ok <- step_keystore(shell, dry?), + :ok <- step_manual_account_creation(shell, dry?), + log_step(shell, "1/6", "Sign in to Google", dry?), + {:ok, token} <- step_oauth(shell, dry?), + log_step(shell, "2/6", "Select Google Cloud project", dry?), + {:ok, project_id} <- step_select_project(shell, token, dry?), + log_step(shell, "3/6", "Enable Android Publisher API", dry?), + :ok <- step_enable_api(shell, token, project_id, dry?), + log_step(shell, "4/6", "Create service account", dry?), + {:ok, sa_email} <- step_create_service_account(shell, token, project_id, dry?), + log_step(shell, "5/6", "Generate and save JSON key", dry?), + {:ok, key_path} <- + step_create_key(shell, token, project_id, sa_email, key_filename, dry?), + log_step(shell, "6/6", "Grant Play Console access", dry?), + :ok <- step_grant_access(shell, token, sa_email, package_name, dry?) do + step_print_config(shell, package_name, key_path, dry?) + :ok + end + end + + # ── Step helpers ───────────────────────────────────────────────────────────── + + defp step_keystore(shell, dry?) do + keystore_path = Path.expand("android/upload_jks.keystore") + + cond do + dry? -> + shell.info(dry_tag() <> " Would check for #{keystore_path}") + shell.info(dry_tag() <> " If missing: prompt to generate it with keytool (-keyalg RSA,") + shell.info(" -keysize 2048, -validity 10000, -storetype JKS)") + shell.info(dry_tag() <> " Would also write android/keystore.properties") + :ok + + File.exists?(keystore_path) -> + shell.info(green() <> "✓" <> reset() <> " Keystore already exists: #{keystore_path}") + :ok + + true -> + shell.info(yellow() <> "!" <> reset() <> " No upload keystore found.") + + if shell.yes?(" Generate android/upload_jks.keystore now?") do + generate_keystore(shell, keystore_path) + else + shell.info(" Skipping keystore. Generate it later with:") + shell.info(" keytool -genkey -v -keystore android/upload_jks.keystore \\") + + shell.info( + " -alias upload -keyalg RSA -keysize 2048 -validity 10000 -storetype JKS" + ) + + :ok + end + end + end + + defp generate_keystore(shell, path) do + shell.info("") + passphrase = shell.prompt(" Keystore passphrase (remember this — back it up!): ") + name = shell.prompt(" Your full name: ") + org = shell.prompt(" Organisation (or your name): ") + + args = [ + "-genkey", + "-v", + "-keystore", + path, + "-alias", + "upload", + "-keyalg", + "RSA", + "-keysize", + "2048", + "-validity", + "10000", + "-storetype", + "JKS", + "-dname", + "CN=#{name}, O=#{org}, L=Unknown, ST=Unknown, C=Unknown", + "-storepass", + passphrase, + "-keypass", + passphrase + ] + + case System.cmd("keytool", args, stderr_to_stdout: true) do + {_, 0} -> + props_path = Path.expand("android/keystore.properties") + write_keystore_properties(props_path, passphrase) + + shell.info(green() <> "✓" <> reset() <> " Keystore created at #{path}") + + shell.info( + " IMPORTANT: Back up #{path} and the passphrase — you cannot update your app without them." + ) + + :ok + + {out, rc} -> + {:error, "keytool failed (exit #{rc}): #{out}"} + end + end + + defp write_keystore_properties(path, passphrase) do + unless File.exists?(path) do + File.write!(path, """ + storeFile=upload_jks.keystore + storePassword=#{passphrase} + keyAlias=upload + keyPassword=#{passphrase} + """) + end + end + + defp step_manual_account_creation(shell, dry?) do + shell.info("") + shell.info(yellow() <> "── Manual prerequisite steps ──" <> reset()) + shell.info("") + shell.info("These four steps cannot be automated. If you haven't done them yet, do") + shell.info("them now before continuing:") + shell.info("") + shell.info(" 1. Create a Google Play Developer account ($25 one-time fee)") + shell.info(" https://play.google.com/console") + shell.info("") + shell.info(" 2. Complete identity verification (government ID upload)") + shell.info(" This appears as a prompt in the Play Console.") + shell.info("") + shell.info(" 3. Create the app record in Play Console") + shell.info(" Play Console → Create app → fill in name, language, etc.") + shell.info("") + shell.info(" 4. Link your Google Cloud project to Play Console") + shell.info(" Play Console → Setup → API access → Link to a Google Cloud project") + shell.info(" (Do this AFTER step 3 of this wizard creates the Cloud project)") + shell.info("") + + if dry? do + shell.info(dry_tag() <> " [auto-yes] Have you completed steps 1-3?") + :ok + else + if shell.yes?( + "Have you completed steps 1-3 above (step 4 comes after the wizard creates the project)?" + ) do + :ok + else + shell.info("") + shell.info("Complete the steps above, then re-run: mix mob.setup.google_play") + {:error, "setup paused — prerequisites not complete"} + end + end + end + + defp step_oauth(shell, true = _dry?) do + shell.info(dry_tag() <> " Would open browser to Google OAuth consent screen") + shell.info(dry_tag() <> " Would listen on a random localhost port for the callback") + shell.info(dry_tag() <> " Would exchange authorization code for access token") + {:ok, "dry_run_token"} + end + + defp step_oauth(_shell, false) do + OAuth.authorize(scopes: OAuth.setup_scopes()) + end + + defp step_select_project(shell, _token, true = _dry?) do + shell.info(dry_tag() <> " Would call Cloud Resource Manager API to list your projects") + shell.info(dry_tag() <> " Would prompt you to pick one if multiple exist") + shell.info(dry_tag() <> " Using placeholder: your-gcp-project-id") + {:ok, "your-gcp-project-id"} + end + + defp step_select_project(shell, token, false) do + case CloudSetup.list_projects(token) do + {:ok, []} -> + shell.info(" No Google Cloud projects found.") + shell.info(" Create one at https://console.cloud.google.com and re-run the wizard.") + {:error, "no Cloud projects found"} + + {:ok, [%{"projectId" => id, "displayName" => name}]} -> + shell.info(" Using project: #{name} (#{id})") + {:ok, id} + + {:ok, projects} -> + shell.info(" Found #{length(projects)} Cloud projects:") + shell.info("") + + projects + |> Enum.with_index(1) + |> Enum.each(fn {%{"projectId" => id, "displayName" => name}, n} -> + shell.info(" #{n}. #{name} (#{id})") + end) + + shell.info("") + answer = shell.prompt(" Enter project number or ID: ") + pick_project(projects, String.trim(answer)) + + {:error, _} = err -> + err + end + end + + defp pick_project(projects, input) do + case Integer.parse(input) do + {n, ""} when n >= 1 and n <= length(projects) -> + {:ok, Enum.at(projects, n - 1)["projectId"]} + + _ -> + if Enum.any?(projects, &(&1["projectId"] == input)) do + {:ok, input} + else + {:error, "Unknown project: #{input}"} + end + end + end + + defp step_enable_api(shell, _token, project_id, true = _dry?) do + shell.info(dry_tag() <> " Would POST to Service Usage API:") + shell.info(" serviceusage.googleapis.com/v1/projects/#{project_id}/services/") + shell.info(" androidpublisher.googleapis.com:enable") + shell.info(dry_tag() <> " Would poll the returned operation until done (up to 60s)") + :ok + end + + defp step_enable_api(shell, token, project_id, false) do + shell.info(" Enabling androidpublisher.googleapis.com in #{project_id}...") + shell.info(" (This can take up to 60 seconds.)") + + case CloudSetup.enable_publisher_api(token, project_id) do + :ok -> + shell.info(green() <> " ✓ Android Publisher API enabled" <> reset()) + :ok + + {:error, _} = err -> + err + end + end + + defp step_create_service_account(shell, _token, project_id, true = _dry?) do + email = "play-publisher@#{project_id}.iam.gserviceaccount.com" + shell.info(dry_tag() <> " Would POST to IAM API to create service account `play-publisher`") + shell.info(dry_tag() <> " If it already exists (HTTP 409), would reuse it") + shell.info(dry_tag() <> " Resulting email: #{email}") + {:ok, email} + end + + defp step_create_service_account(shell, token, project_id, false) do + shell.info(" Creating play-publisher service account in #{project_id}...") + + case CloudSetup.create_service_account(token, project_id) do + {:ok, email} -> + shell.info(green() <> " ✓ Service account: #{email}" <> reset()) + {:ok, email} + + {:error, _} = err -> + err + end + end + + defp step_create_key(shell, _token, _project_id, sa_email, filename, true = _dry?) do + path = Path.expand("~/.google_play/#{filename}.json") + shell.info(dry_tag() <> " Would POST to IAM API to generate a JSON key for #{sa_email}") + shell.info(dry_tag() <> " Would base64-decode the response and write to #{path} (mode 600)") + shell.info(dry_tag() <> " Key file is a one-time download — cannot be recovered if lost") + {:ok, path} + end + + defp step_create_key(shell, token, project_id, sa_email, filename, false) do + shell.info(" Generating JSON key for #{sa_email}...") + + case CloudSetup.create_and_save_key(token, project_id, sa_email, filename) do + {:ok, path} -> + shell.info(green() <> " ✓ Key saved to #{path}" <> reset()) + shell.info(" IMPORTANT: Back up this file — it cannot be recovered if lost.") + {:ok, path} + + {:error, _} = err -> + err + end + end + + defp step_grant_access(shell, _token, sa_email, _package_name, true = _dry?) do + shell.info("") + shell.info(yellow() <> "── Manual step required ──" <> reset()) + shell.info("") + shell.info("Before the service account can publish, you must link your Cloud project") + shell.info("to Play Console (if you haven't done so already):") + shell.info("") + shell.info(" Play Console → Setup → API access → Link to a Google Cloud project") + shell.info("") + + shell.info( + dry_tag() <> " Would prompt for developer account ID (the number in the Play Console URL)" + ) + + shell.info(dry_tag() <> " Would POST to Play Developer API accounts.grants.create:") + + shell.info( + " androidpublisher.googleapis.com/androidpublisher/v3/accounts/{id}/grants" + ) + + shell.info(" grantee: #{sa_email}") + shell.info(" permissions: CAN_MANAGE_RELEASES, CAN_ACCESS_DRAFT_APPS") + shell.info(dry_tag() <> " Falls back to printing manual instructions if API grant fails") + :ok + end + + defp step_grant_access(shell, token, sa_email, package_name, false) do + shell.info("") + shell.info(yellow() <> "── Manual step required ──" <> reset()) + shell.info("") + shell.info("Before the service account can publish, you must link your Cloud project") + shell.info("to Play Console (if you haven't done so already):") + shell.info("") + shell.info(" Play Console → Setup → API access → Link to a Google Cloud project") + shell.info("") + shell.info("This is a one-time step and has no API — it must be done in the browser.") + shell.info("") + + if shell.yes?("Have you linked the Cloud project to Play Console?") do + attempt_grant_via_api(shell, token, sa_email, package_name) + else + shell.info("") + print_manual_grant_instructions(shell, sa_email) + :ok + end + end + + defp attempt_grant_via_api(shell, token, sa_email, package_name) do + shell.info("") + shell.info(" To grant access automatically, I need your Play developer account ID.") + shell.info(" Find it in the Play Console URL:") + shell.info(" https://play.google.com/console/u/0/developers/XXXXXXXXXX/...") + shell.info(" (it's the long number after /developers/)") + shell.info("") + + account_id = shell.prompt(" Enter developer account ID (or press Enter to skip): ") + account_id = String.trim(account_id) + + if account_id == "" do + shell.info("") + print_manual_grant_instructions(shell, sa_email) + :ok + else + shell.info(" Granting Release Manager access via Play API...") + + case PlaySetup.grant_release_manager(token, account_id, sa_email, package_name) do + :ok -> + shell.info(green() <> " ✓ Release Manager access granted" <> reset()) + :ok + + {:error, reason} -> + shell.info(yellow() <> " ! API grant failed: #{reason}" <> reset()) + shell.info(" Falling back to manual instructions:") + print_manual_grant_instructions(shell, sa_email) + :ok + end + end + end + + defp print_manual_grant_instructions(shell, sa_email) do + shell.info(" Complete these two steps in Play Console to grant publishing access:") + shell.info("") + shell.info(" Part A — API access page:") + shell.info(" Play Console → Setup → API access → Grant access next to the service account") + shell.info(" Role: Release manager → Apply → Invite user") + shell.info("") + shell.info(" Part B — Users and permissions:") + shell.info(" Play Console → Users and permissions → Invite new users") + shell.info(" Email: #{sa_email}") + shell.info(" Role: Release manager → Apply → Invite user") + shell.info("") + shell.info(" Both parts are required. Service accounts auto-accept invitations.") + end + + defp step_print_config(shell, package_name, key_path, dry?) do + shell.info("") + + if dry? do + shell.info(yellow() <> "=== Dry run complete ===" <> reset()) + shell.info("") + shell.info("The above shows every step the real wizard would take.") + shell.info("Run without --dry-run to execute for real:") + shell.info("") + shell.info(" mix mob.setup.google_play") + shell.info("") + shell.info("When the real run completes, it will print a mob.exs block like this:") + else + shell.info(green() <> "=== Setup complete! ===" <> reset()) + shell.info("") + shell.info("Add this block to your mob.exs:") + end + + shell.info("") + shell.info(" config :mob_dev,") + shell.info(" google_play: [") + shell.info(" package_name: \"#{package_name}\",") + shell.info(" service_account_json: \"#{key_path}\",") + shell.info(" track: \"internal\"") + shell.info(" ]") + shell.info("") + + unless dry? do + shell.info("Then run: mix mob.republish --android") + shell.info("") + end + end + + # ── Prompts ────────────────────────────────────────────────────────────────── + + defp prompt_package_name(shell, true = _dry?) do + shell.info("The Android package name is your applicationId (e.g. com.example.myapp).") + shell.info("Find it in android/app/build.gradle under defaultConfig.applicationId.") + shell.info("") + shell.info(dry_tag() <> " [auto] Using placeholder: com.example.myapp") + "com.example.myapp" + end + + defp prompt_package_name(shell, false) do + shell.info("The Android package name is your applicationId (e.g. com.example.myapp).") + shell.info("Find it in android/app/build.gradle under defaultConfig.applicationId.") + shell.info("") + String.trim(shell.prompt("Package name (applicationId): ")) + end + + defp default_key_filename(package_name) do + package_name + |> String.split(".") + |> List.last() + |> Kernel.<>("-service-account") + end + + # ── Logging helpers ────────────────────────────────────────────────────────── + + defp log_step(shell, n, label, dry?) do + shell.info("") + prefix = if dry?, do: yellow() <> "[dry run] " <> reset(), else: cyan() + shell.info(prefix <> "── Step #{n}: #{label} ──" <> reset()) + :ok + end + + defp dry_tag, do: yellow() <> "[dry run]" <> reset() + + defp green, do: IO.ANSI.green() + defp cyan, do: IO.ANSI.cyan() + defp yellow, do: IO.ANSI.yellow() + defp reset, do: IO.ANSI.reset() +end diff --git a/lib/mob_dev/hot_push.ex b/lib/mob_dev/hot_push.ex index da8c23e..ffa1e38 100644 --- a/lib/mob_dev/hot_push.ex +++ b/lib/mob_dev/hot_push.ex @@ -9,10 +9,22 @@ defmodule MobDev.HotPush do `mix mob.deploy` first). """ - alias MobDev.{Tunnel} + alias MobDev.{AndroidDeployLock, Config, Tunnel} alias MobDev.Discovery.{Android, IOS} @cookie :mob_secret + @max_beam_files 20_000 + @max_beam_file_bytes 16 * 1024 * 1024 + @max_beam_total_bytes 256 * 1024 * 1024 + @max_beam_path_bytes 4_096 + @max_android_targets 32 + + @type prepared_beam :: %{ + required(:module) => module(), + required(:path) => String.t(), + required(:binary) => binary(), + required(:sha256) => binary() + } @doc """ Sets up adb tunnels (idempotent) and connects to all running device nodes. @@ -24,19 +36,19 @@ defmodule MobDev.HotPush do nodes = (Android.list_devices() ++ IOS.list_simulators()) - |> Enum.with_index() - |> Enum.flat_map(fn {device, idx} -> - case Tunnel.setup(device, idx) do + |> Enum.flat_map(fn device -> + case Tunnel.setup(device) do {:ok, d} -> [d] - _ -> [] + _ -> [] end end) |> Enum.flat_map(fn device -> ensure_local_dist(cookie) Node.set_cookie(device.node, cookie) + case Node.connect(device.node) do true -> [device.node] - _ -> [] + _ -> [] end end) @@ -55,8 +67,10 @@ defmodule MobDev.HotPush do """ @spec push_all([node()]) :: {non_neg_integer(), list()} def push_all(nodes) do - beams = runtime_beam_paths() - push_beams(nodes, beams) + case prepare(runtime_beam_paths()) do + {:ok, snapshot} -> push_with_ordinary_android_lease(nodes, snapshot) + {:error, _reason} -> {0, [{:snapshot, :invalid}]} + end end @doc """ @@ -68,10 +82,12 @@ defmodule MobDev.HotPush do def snapshot_beams do runtime_beam_paths() |> Map.new(fn path -> - mtime = case File.stat(path, time: :posix) do - {:ok, %{mtime: t}} -> t - _ -> 0 - end + mtime = + case File.stat(path, time: :posix) do + {:ok, %{mtime: t}} -> t + _ -> 0 + end + {path, mtime} end) end @@ -85,16 +101,72 @@ defmodule MobDev.HotPush do beams = runtime_beam_paths() |> Enum.filter(fn path -> - current_mtime = case File.stat(path, time: :posix) do - {:ok, %{mtime: t}} -> t - _ -> 0 - end + current_mtime = + case File.stat(path, time: :posix) do + {:ok, %{mtime: t}} -> t + _ -> 0 + end + current_mtime != Map.get(snapshot, path, 0) end) - push_beams(nodes, beams) + case prepare(beams) do + {:ok, prepared} -> push_with_ordinary_android_lease(nodes, prepared) + {:error, _reason} -> {0, [{:snapshot, :invalid}]} + end end + @doc false + @spec prepare([String.t()]) :: {:ok, [prepared_beam()]} | {:error, String.t()} + def prepare(paths) when is_list(paths) do + ordered_paths = Enum.sort(paths) + + cond do + length(ordered_paths) > @max_beam_files -> + {:error, "BEAM snapshot exceeds file-count limit"} + + Enum.uniq(ordered_paths) != ordered_paths -> + {:error, "BEAM snapshot contains duplicate paths"} + + true -> + prepare_paths(ordered_paths) + end + end + + def prepare(_paths), do: {:error, "BEAM snapshot paths are invalid"} + + @doc """ + Loads an already prepared immutable snapshot. + + Raw prepared pushes are iOS-only. Android callers must use + `push_prepared_fenced/3`; Android-looking nodes are rejected before any RPC. + User-facing hot pushes go through `push_all/1` or `push_changed/2`, which + acquire, commit, and release an ordinary Android lease around the RPC phase. + """ + @spec push_prepared([node()], [prepared_beam()]) :: {non_neg_integer(), list()} + def push_prepared(nodes, snapshot) do + push_prepared(nodes, snapshot, fn node, module, filename, binary -> + :rpc.call(node, :code, :load_binary, [module, filename, binary]) + end) + end + + @doc false + @spec push_prepared([node()], [prepared_beam()], (node(), module(), charlist(), binary() -> + term())) :: + {non_neg_integer(), list()} + def push_prepared(nodes, snapshot, rpc) when is_list(nodes) and is_function(rpc, 4) do + with :ok <- validate_nodes(nodes), + :ok <- validate_prepared_snapshot(snapshot), + false <- Enum.any?(nodes, &android_node?/1) do + push_prepared_internal(nodes, snapshot, rpc, fn _node -> :ok end) + else + true -> {0, [{:android_deploy_lock, :required}]} + {:error, _reason} -> {0, [{:snapshot, :invalid}]} + end + end + + def push_prepared(_nodes, _snapshot, _rpc), do: {0, [{:snapshot, :invalid}]} + # ── Runtime dep filtering ──────────────────────────────────────────────────── # Returns only BEAM paths that belong to the app's runtime dependency tree. @@ -102,6 +174,7 @@ defmodule MobDev.HotPush do # their transitive deps (resolved via OTP .app files). defp runtime_beam_paths do runtime = runtime_lib_names() + Path.wildcard("_build/dev/lib/*/ebin/*.beam") |> Enum.filter(fn path -> lib = path |> Path.split() |> Enum.at(-3) @@ -109,6 +182,26 @@ defmodule MobDev.HotPush do end) end + @doc """ + Returns ebin directories for runtime deps only (no dev-only tooling). + Used by `Deployer` so the filesystem push matches the dist push scope. + """ + @spec runtime_beam_dirs() :: [String.t()] + def runtime_beam_dirs do + runtime = runtime_lib_names() + + case File.ls("_build/dev/lib") do + {:ok, libs} -> + libs + |> Enum.filter(&MapSet.member?(runtime, &1)) + |> Enum.map(&"_build/dev/lib/#{&1}/ebin") + |> Enum.filter(&File.dir?/1) + + {:error, _} -> + [] + end + end + defp runtime_lib_names do project_app = to_string(Mix.Project.config()[:app]) @@ -133,9 +226,13 @@ defmodule MobDev.HotPush do case :file.consult(String.to_charlist(app_file)) do {:ok, [{:application, _app, props}]} -> (props[:applications] || []) |> Enum.map(&to_string/1) - _ -> [] + + _ -> + [] end - [] -> [] + + [] -> + [] end end) |> MapSet.new() @@ -150,13 +247,15 @@ defmodule MobDev.HotPush do # Returns the app name as a string if this dep is a runtime dep, else []. defp dep_runtime_name(dep) do - {app, opts} = case dep do - {app, _version, opts} when is_list(opts) -> {app, opts} - {app, opts} when is_list(opts) -> {app, opts} - {app, _version} -> {app, []} - app when is_atom(app) -> {app, []} - end - only = Keyword.get(opts, :only) + {app, opts} = + case dep do + {app, _version, opts} when is_list(opts) -> {app, opts} + {app, opts} when is_list(opts) -> {app, opts} + {app, _version} -> {app, []} + app when is_atom(app) -> {app, []} + end + + only = Keyword.get(opts, :only) runtime = Keyword.get(opts, :runtime, true) dev_only = only == :dev or only == [:dev] or (is_list(only) and only == [:dev]) if dev_only or not runtime, do: [], else: [to_string(app)] @@ -164,37 +263,542 @@ defmodule MobDev.HotPush do # ── Private ───────────────────────────────────────────────────────────────── - defp push_beams(_nodes, []), do: {0, []} + defp prepare_paths(paths) do + paths + |> Enum.reduce_while({:ok, [], 0, MapSet.new()}, fn path, + {:ok, prepared, total_bytes, modules} -> + with :ok <- validate_beam_path(path), + {:ok, stat} <- File.stat(path), + true <- stat.type == :regular and stat.size <= @max_beam_file_bytes, + true <- total_bytes + stat.size <= @max_beam_total_bytes, + {:ok, binary} <- File.read(path), + true <- byte_size(binary) == stat.size and byte_size(binary) <= @max_beam_file_bytes, + {:ok, module} <- beam_module(binary), + true <- Atom.to_string(module) == Path.basename(path, ".beam"), + false <- MapSet.member?(modules, module) do + entry = %{ + module: module, + path: path, + binary: binary, + sha256: :crypto.hash(:sha256, binary) + } + + {:cont, + {:ok, [entry | prepared], total_bytes + byte_size(binary), MapSet.put(modules, module)}} + else + _invalid -> {:halt, {:error, "BEAM snapshot source is invalid"}} + end + end) + |> case do + {:ok, prepared, _total_bytes, _modules} -> {:ok, Enum.reverse(prepared)} + {:error, _reason} = error -> error + end + end + + @doc false + @spec validate_prepared_snapshot(term()) :: :ok | {:error, atom()} + def validate_prepared_snapshot(snapshot) when is_list(snapshot) do + snapshot + |> Enum.reduce_while({:ok, 0, MapSet.new(), MapSet.new()}, fn entry, + {:ok, total, modules, paths} -> + with %{ + module: module, + path: path, + binary: binary, + sha256: sha256 + } <- entry, + true <- map_size(entry) == 4, + true <- is_atom(module), + :ok <- validate_beam_path(path), + true <- is_binary(binary) and byte_size(binary) <= @max_beam_file_bytes, + true <- is_binary(sha256) and byte_size(sha256) == 32, + true <- :crypto.hash(:sha256, binary) == sha256, + {:ok, ^module} <- beam_module(binary), + true <- Atom.to_string(module) == Path.basename(path, ".beam"), + false <- MapSet.member?(modules, module), + false <- MapSet.member?(paths, path), + true <- total + byte_size(binary) <= @max_beam_total_bytes do + {:cont, + {:ok, total + byte_size(binary), MapSet.put(modules, module), MapSet.put(paths, path)}} + else + _invalid -> {:halt, {:error, :invalid_snapshot}} + end + end) + |> case do + {:ok, _total, _modules, _paths} -> + if length(snapshot) <= @max_beam_files, + do: :ok, + else: {:error, :too_many_files} + + {:error, _reason} = error -> + error + end + end + + def validate_prepared_snapshot(_snapshot), do: {:error, :invalid_snapshot} + + defp push_with_ordinary_android_lease(nodes, snapshot) do + push_prepared_fenced(nodes, snapshot, []) + end + + @doc false + @spec push_prepared_fenced([node()], [prepared_beam()], keyword()) :: + {non_neg_integer(), list()} + def push_prepared_fenced(nodes, snapshot, opts) + when is_list(nodes) and is_list(opts) do + with :ok <- validate_nodes(nodes), + :ok <- validate_prepared_snapshot(snapshot), + {:ok, post_push} <- validate_post_push(Keyword.get(opts, :post_push)), + {:ok, serials} <- android_serials_for_nodes(nodes, opts) do + {android_nodes, other_nodes} = Enum.split_with(nodes, &android_node?/1) + + case {Keyword.get(opts, :android_deploy_lock), serials} do + {lease, serials} when not is_nil(lease) -> + push_with_existing_android_lease( + android_nodes, + other_nodes, + snapshot, + serials, + lease, + post_push, + opts + ) + + {nil, []} -> + push_without_android(nodes, snapshot, post_push, opts) + + {nil, serials} -> + push_with_acquired_android_lease( + android_nodes, + other_nodes, + snapshot, + serials, + post_push, + opts + ) + end + else + {:error, :android_target_ambiguous} -> {0, [{:android_deploy_lock, :target_ambiguous}]} + {:error, :invalid_post_push} -> {0, [{:android_post_push, :invalid}]} + {:error, _reason} -> {0, [{:snapshot, :invalid}]} + end + end + + def push_prepared_fenced(_nodes, _snapshot, _opts), do: {0, [{:snapshot, :invalid}]} + + defp push_without_android(nodes, snapshot, nil, opts) do + push_prepared_internal( + nodes, + snapshot, + Keyword.get(opts, :rpc, &load_binary_rpc/4), + fn _node -> :ok end + ) + end + + defp push_without_android(_nodes, _snapshot, _post_push, _opts), + do: {0, [{:android_post_push, :requires_android_lease}]} + + defp push_with_acquired_android_lease( + android_nodes, + other_nodes, + snapshot, + serials, + post_push, + opts + ) do + package = Keyword.get(opts, :package, Config.bundle_id()) + runner = Keyword.get(opts, :lock_runner, &run_adb_lock_command/1) + rpc = Keyword.get(opts, :rpc, &load_binary_rpc/4) + + case AndroidDeployLock.acquire(package, serials, runner) do + {:ok, lease} -> + case run_ordinary_hot_push( + android_nodes, + snapshot, + lease, + runner, + rpc, + post_push + ) do + {:ok, pushed} -> + push_other_nodes_after_android_release(other_nodes, snapshot, rpc, pushed) + + {:error, failures} -> + {0, failures} + end + + {:error, %{lease: %{state: state}}} + when state in [:retained_failure, :retained_ambiguous] -> + {0, + [ + {:android_deploy_lock, :acquire_ambiguous}, + {:android_deploy_lock, :retained} + ]} + + {:error, _failure} -> + {0, [{:android_deploy_lock, :unavailable}]} + end + end + + defp run_ordinary_hot_push(nodes, snapshot, lease, runner, rpc, post_push) do + fence = fn _node -> verify_hot_push_lease(lease, runner) end + + with :ok <- verify_hot_push_lease(lease, runner) do + case push_prepared_internal(nodes, snapshot, rpc, fence) do + {pushed, []} -> + with :ok <- run_fenced_post_push(post_push, nodes, lease, runner), + :ok <- verify_hot_push_lease(lease, runner) do + commit_and_release_hot_push(pushed, lease, runner) + else + {:error, :post_push_ambiguous} -> + {:error, + [ + {:android_post_push, :ambiguous}, + {:android_deploy_lock, :retained} + ]} + + {:error, _failure} -> + {:error, + [ + {:android_deploy_lock, :authority_ambiguous}, + {:android_deploy_lock, :retained} + ]} + end + + {_pushed, failed} -> + {:error, failed ++ [{:android_deploy_lock, :retained}]} + end + else + {:error, _failure} -> + {:error, + [ + {:android_deploy_lock, :authority_ambiguous}, + {:android_deploy_lock, :retained} + ]} + end + end + + defp push_with_existing_android_lease( + nodes, + other_nodes, + snapshot, + serials, + lease, + post_push, + opts + ) do + package = Keyword.get(opts, :package, Config.bundle_id()) + runner = Keyword.get(opts, :lock_runner, &run_adb_lock_command/1) + rpc = Keyword.get(opts, :rpc, &load_binary_rpc/4) + expected_phase = Keyword.get(opts, :expected_lock_phase) - defp push_beams(nodes, beam_files) do - results = Enum.map(beam_files, fn path -> - module = beam_path_to_module(path) - case File.read(path) do - {:ok, binary} -> load_on_nodes(nodes, module, path, binary) - {:error, reason} -> {:error, {module, reason}} + with true <- other_nodes == [], + true <- expected_phase in [:acquired, :native_ready], + true <- AndroidDeployLock.valid?(lease, expected_phase), + true <- lease.bundle_id == package, + true <- serials == lease.serials, + :ok <- verify_hot_push_lease(lease, runner) do + case push_prepared_internal(nodes, snapshot, rpc, fn _node -> + verify_hot_push_lease(lease, runner) + end) do + {pushed, []} -> + with :ok <- run_fenced_post_push(post_push, nodes, lease, runner), + :ok <- verify_hot_push_lease(lease, runner) do + {pushed, []} + else + {:error, :post_push_ambiguous} -> + {0, + [ + {:android_post_push, :ambiguous}, + {:android_deploy_lock, :retained} + ]} + + {:error, _failure} -> + {0, + [ + {:android_deploy_lock, :authority_ambiguous}, + {:android_deploy_lock, :retained} + ]} + end + + {_pushed, failed} -> + {0, failed ++ [{:android_deploy_lock, :retained}]} + end + else + _invalid_or_ambiguous -> + {0, + [ + {:android_deploy_lock, :authority_ambiguous}, + {:android_deploy_lock, :retained} + ]} + end + end + + defp push_other_nodes_after_android_release([], _snapshot, _rpc, pushed), + do: {pushed, []} + + defp push_other_nodes_after_android_release(nodes, snapshot, rpc, _android_pushed) do + case push_prepared_internal(nodes, snapshot, rpc, fn _node -> :ok end) do + {pushed, []} -> {pushed, []} + {_pushed, failed} -> {0, failed ++ [{:hot_push, :partial_after_android_commit}]} + end + end + + defp run_fenced_post_push(nil, _nodes, _lease, _runner), do: :ok + + defp run_fenced_post_push(post_push, nodes, lease, runner) do + with :ok <- invoke_post_push_on_nodes(post_push, nodes, lease, runner), + :ok <- verify_hot_push_lease(lease, runner) do + :ok + else + _failure_or_ambiguity -> {:error, :post_push_ambiguous} + end + end + + defp invoke_post_push_on_nodes(post_push, nodes, lease, runner) do + Enum.reduce_while(nodes, :ok, fn node, :ok -> + with :ok <- verify_hot_push_lease(lease, runner), + :ok <- invoke_post_push(post_push, node) do + {:cont, :ok} + else + _failure_or_ambiguity -> {:halt, {:error, :post_push_ambiguous}} end end) + end - pushed = Enum.count(results, &match?(:ok, &1)) - failed = for {:error, pair} <- results, do: pair - {pushed, failed} + defp invoke_post_push(post_push, node) do + try do + case post_push.(node) do + :ok -> :ok + _failure -> {:error, :post_push_ambiguous} + end + rescue + _error -> {:error, :post_push_ambiguous} + catch + _kind, _reason -> {:error, :post_push_ambiguous} + end end - defp load_on_nodes(nodes, module, path, binary) do - fname = String.to_charlist(path) - errors = Enum.flat_map(nodes, fn node -> - case :rpc.call(node, :code, :load_binary, [module, fname, binary]) do - {:module, ^module} -> [] - {:error, :on_load_failure} -> [] # NIF modules already loaded — safe to ignore - {:badrpc, reason} -> [{node, reason}] - {:error, reason} -> [{node, reason}] + defp validate_post_push(nil), do: {:ok, nil} + defp validate_post_push(post_push) when is_function(post_push, 1), do: {:ok, post_push} + defp validate_post_push(_post_push), do: {:error, :invalid_post_push} + + defp verify_hot_push_lease(lease, runner) do + Enum.reduce_while(lease.serials, :ok, fn serial, :ok -> + case AndroidDeployLock.verify_owner(lease, serial, runner) do + :ok -> {:cont, :ok} + {:error, _failure} = error -> {:halt, error} end end) - if errors == [], do: :ok, else: {:error, {module, errors}} end - defp beam_path_to_module(path) do - path |> Path.basename(".beam") |> String.to_atom() + defp commit_and_release_hot_push(pushed, lease, runner) do + case AndroidDeployLock.transition(lease, :acquired, :fast_committed, runner) do + {:ok, committed} -> + case AndroidDeployLock.release(committed, runner) do + :ok -> + {:ok, pushed} + + {:error, _failure} -> + {:error, + [ + {:android_deploy_lock, :release_ambiguous}, + {:android_deploy_lock, :retained} + ]} + end + + {:error, _failure} -> + {:error, + [ + {:android_deploy_lock, :transition_ambiguous}, + {:android_deploy_lock, :retained} + ]} + end + end + + defp android_serials_for_nodes([], _opts), do: {:ok, []} + + defp android_serials_for_nodes(nodes, opts) do + android_nodes = Enum.filter(nodes, &android_node?/1) + + if android_nodes == [] do + {:ok, []} + else + with {:ok, devices} <- android_devices(opts), + {:ok, serials} <- exact_android_serials(android_nodes, devices), + true <- length(serials) <= @max_android_targets do + {:ok, serials} + else + _missing_duplicate_or_excessive -> {:error, :android_target_ambiguous} + end + end + end + + defp android_devices(opts) do + case Keyword.fetch(opts, :android_devices) do + {:ok, devices} when is_list(devices) -> {:ok, devices} + {:ok, _invalid} -> {:error, :invalid_discovery} + :error -> discover_android_devices() + end + end + + defp discover_android_devices do + try do + case Android.list_devices() do + devices when is_list(devices) -> {:ok, devices} + _invalid -> {:error, :invalid_discovery} + end + rescue + _error -> {:error, :invalid_discovery} + catch + _kind, _reason -> {:error, :invalid_discovery} + end + end + + defp exact_android_serials(nodes, devices) do + grouped = + devices + |> Enum.flat_map(fn + %{platform: :android, node: node, serial: serial} + when is_atom(node) and is_binary(serial) -> + [{node, serial}] + + _invalid -> + [] + end) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + + nodes + |> Enum.reduce_while({:ok, []}, fn node, {:ok, serials} -> + case Map.get(grouped, node) do + [serial] -> {:cont, {:ok, [serial | serials]}} + _missing_or_ambiguous -> {:halt, {:error, :android_target_ambiguous}} + end + end) + |> case do + {:ok, serials} -> + ordered = Enum.sort(serials) + + if Enum.uniq(ordered) == ordered, + do: {:ok, ordered}, + else: {:error, :android_target_ambiguous} + + {:error, _reason} = error -> + error + end + end + + defp android_node?(node) when is_atom(node) do + app = Mix.Project.config()[:app] |> to_string() + String.starts_with?(Atom.to_string(node), "#{app}_android") + end + + defp run_adb_lock_command(args) do + System.cmd("adb", args, stderr_to_stdout: true) + end + + defp load_binary_rpc(node, module, filename, binary) do + :rpc.call(node, :code, :load_binary, [module, filename, binary]) + end + + defp push_prepared_internal(nodes, snapshot, rpc, before_rpc) do + snapshot + |> Enum.reduce_while({0, []}, fn prepared, {count, []} -> + case load_prepared_on_nodes(nodes, prepared, rpc, before_rpc) do + :ok -> {:cont, {count + 1, []}} + {:error, failure} -> {:halt, {count, [failure]}} + end + end) + end + + defp load_prepared_on_nodes(nodes, prepared, rpc, before_rpc) do + filename = String.to_charlist(prepared.path) + + Enum.reduce_while(nodes, :ok, fn node, :ok -> + case invoke_before_rpc(before_rpc, node) do + :ok -> + load_prepared_on_node(rpc, node, prepared, filename) + + {:error, _reason} -> + {:halt, {:error, {prepared.module, [{node, :authority_ambiguous}]}}} + end + end) + end + + defp load_prepared_on_node(rpc, node, prepared, filename) do + case invoke_rpc(rpc, node, prepared.module, filename, prepared.binary) do + {:module, module} when module == prepared.module -> + {:cont, :ok} + + {:badrpc, _reason} -> + {:halt, {:error, {prepared.module, [{node, :badrpc}]}}} + + {:error, :on_load_failure} -> + {:halt, {:error, {prepared.module, [{node, :on_load_failure}]}}} + + {:error, _reason} -> + {:halt, {:error, {prepared.module, [{node, :load_failed}]}}} + + _unexpected -> + {:halt, {:error, {prepared.module, [{node, :unexpected_reply}]}}} + end + end + + defp invoke_before_rpc(before_rpc, node) do + try do + case before_rpc.(node) do + :ok -> :ok + _failure -> {:error, :authority_ambiguous} + end + rescue + _error -> {:error, :authority_ambiguous} + catch + _kind, _reason -> {:error, :authority_ambiguous} + end + end + + defp invoke_rpc(rpc, node, module, filename, binary) do + try do + rpc.(node, module, filename, binary) + rescue + _error -> {:error, :rpc_exception} + catch + _kind, _reason -> {:error, :rpc_exception} + end + end + + defp validate_nodes(nodes) do + if Enum.all?(nodes, &is_atom/1) and Enum.uniq(nodes) == nodes, + do: :ok, + else: {:error, :invalid_nodes} + end + + defp validate_beam_path(path) when is_binary(path) do + if byte_size(path) in 1..@max_beam_path_bytes and String.valid?(path) and + String.ends_with?(path, ".beam"), + do: :ok, + else: {:error, :invalid_path} + end + + defp validate_beam_path(_path), do: {:error, :invalid_path} + + defp beam_module(binary) when is_binary(binary) do + try do + case :beam_lib.info(binary) do + info when is_list(info) -> + case Keyword.fetch(info, :module) do + {:ok, module} when is_atom(module) -> {:ok, module} + _missing -> {:error, :invalid_beam} + end + + _invalid -> + {:error, :invalid_beam} + end + rescue + _error -> {:error, :invalid_beam} + catch + _kind, _reason -> {:error, :invalid_beam} + end end defp ensure_local_dist(cookie) do diff --git a/lib/mob_dev/icon_generator.ex b/lib/mob_dev/icon_generator.ex index c4aee98..305e110 100644 --- a/lib/mob_dev/icon_generator.ex +++ b/lib/mob_dev/icon_generator.ex @@ -1,13 +1,15 @@ defmodule MobDev.IconGenerator do - # Image and Avatarz are provided by the parent project (mob_dev is a dev dep). - # They're on the code path at runtime but not visible when mob_dev is compiled - # as a dependency, so suppress the compile-time undefined-module warnings. + # Image and Avatarz are optional deps — only needed for custom/random icon generation. + # When they're absent we fall back to the bundled pre-built mob_logo PNGs. @compile {:no_warn_undefined, [Image, Avatarz, Avatarz.Sets.Robot]} @moduledoc """ Generates app icons for Android and iOS from either a random robot avatar (using Avatarz) or a provided source image (using Image). + When the `image` dep is not available, falls back to the bundled Mob logo + (pre-built PNGs shipped with mob_dev, no system tools required). + ## Android sizes (mipmap buckets) | Bucket | px | @@ -42,14 +44,28 @@ defmodule MobDev.IconGenerator do | App Store |1024 | """ + @mob_logo_dir :code.priv_dir(:mob_dev) |> Path.join("mob_logo") + @android_sizes %{ - "mipmap-mdpi" => 48, - "mipmap-hdpi" => 72, - "mipmap-xhdpi" => 96, - "mipmap-xxhdpi" => 144, + "mipmap-mdpi" => 48, + "mipmap-hdpi" => 72, + "mipmap-xhdpi" => 96, + "mipmap-xxhdpi" => 144, "mipmap-xxxhdpi" => 192 } + # Adaptive icon canvas is 108×108 dp; foreground PNGs are written at the + # density-scaled equivalent so the launcher gets a sharp foreground at any + # device density. Anything outside the centre 66×66 dp may be cropped by + # the launcher mask, so foreground content should be centre-weighted. + @adaptive_sizes %{ + "mipmap-mdpi" => 108, + "mipmap-hdpi" => 162, + "mipmap-xhdpi" => 216, + "mipmap-xxhdpi" => 324, + "mipmap-xxxhdpi" => 432 + } + @ios_sizes [20, 29, 40, 58, 60, 76, 80, 87, 120, 152, 167, 180, 1024] @doc """ @@ -62,50 +78,212 @@ defmodule MobDev.IconGenerator do Returns `:ok` on success. """ - @spec generate_random(output_dir :: String.t()) :: :ok - def generate_random(output_dir) do - renders_path = Path.join(output_dir, ".icon_renders") - File.mkdir_p!(renders_path) + @spec generate_random(output_dir :: String.t(), keyword()) :: :ok + def generate_random(output_dir, opts \\ []) do + if image_available?() do + renders_path = Path.join(output_dir, ".icon_renders") + File.mkdir_p!(renders_path) + + seed = Path.basename(output_dir) + avatar = Avatarz.render(seed, Avatarz.Sets.Robot, :robot, renders_path) - seed = Path.basename(output_dir) - avatar = - Avatarz.render(seed, Avatarz.Sets.Robot, :robot, renders_path) + source_png = Path.join(output_dir, "icon_source.png") + Image.write!(avatar.image, source_png) - source_png = Path.join(output_dir, "icon_source.png") - Image.write!(avatar.image, source_png) + resize_for_platforms(source_png, output_dir, opts) + else + Mix.shell().info(""" + \nNote: the `image` dependency is not available so a random icon could not + be generated. Using the Mob logo as a placeholder instead. + Run `mix mob.icon` after adding `{:image, "~> 0.54"}` to your deps to + replace it with a custom or generated icon.\n + """) - resize_for_platforms(source_png, output_dir) + use_mob_logo(output_dir) + end + end + + @doc """ + Copies the bundled Mob logo (pre-built PNGs) to all platform icon directories + in `output_dir`. Used as the default placeholder icon by `mix mob.install`. + No extra dependencies or system tools required. + + Returns `:ok`. + """ + @spec use_mob_logo(output_dir :: String.t()) :: :ok + def use_mob_logo(output_dir) do + write_android_icons_from_priv(output_dir) + write_ios_icons_from_priv(output_dir) + :ok end @doc """ Resizes an existing image at `source_path` to all platform icon sizes, writing them into `output_dir`. + ## Options + * `:background_color` — hex string like `"#E8B53C"` used to flatten the + **iOS** icons (which must be opaque — Apple rejects any alpha channel on + the App Store marketing icon, error 90717). Android icons keep their + transparency. If absent, the colour is sampled from the source (same as + the adaptive-icon background), so the two platforms stay consistent. + Returns `:ok`. """ - @spec generate_from_source(source_path :: String.t(), output_dir :: String.t()) :: :ok - def generate_from_source(source_path, output_dir) do - resize_for_platforms(source_path, output_dir) + @spec generate_from_source(source_path :: String.t(), output_dir :: String.t(), keyword()) :: + :ok + def generate_from_source(source_path, output_dir, opts \\ []) do + if image_available?() do + resize_for_platforms(source_path, output_dir, opts) + else + Mix.raise(""" + The `image` dependency is required to generate icons from a source file. + Add `{:image, "~> 0.54"}` to your deps and run `mix deps.get`. + """) + end end @doc """ - Returns the map of Android mipmap bucket names to pixel dimensions. + Returns the map of Android mipmap bucket names to pixel dimensions + for legacy (single-layer) icons. """ @spec android_sizes() :: %{String.t() => pos_integer()} def android_sizes, do: @android_sizes + @doc """ + Returns the map of Android mipmap bucket names to pixel dimensions + for adaptive-icon foreground layers (108×108 dp scaled per density). + """ + @spec adaptive_sizes() :: %{String.t() => pos_integer()} + def adaptive_sizes, do: @adaptive_sizes + @doc """ Returns the list of iOS icon pixel dimensions. """ @spec ios_sizes() :: [pos_integer()] def ios_sizes, do: @ios_sizes + @doc """ + Generates adaptive Android icons from a source image. + + Writes: + - `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` + referencing `@mipmap/ic_launcher_foreground` and + `@color/ic_launcher_background` + - `mipmap-<bucket>/ic_launcher_foreground.png` at the adaptive icon + canvas size for each density bucket + - `values/ic_launcher_background.xml` defining the background color + + ## Options + * `:background_color` — hex string like `"#E8B53C"`. If absent, sampled + from the source image at top-centre (10% from the top). + + Legacy `ic_launcher.png`/`ic_launcher_round.png` are written separately + by `generate_from_source/2` for older Android versions. + """ + @spec generate_adaptive(source_path :: String.t(), output_dir :: String.t(), keyword()) :: :ok + def generate_adaptive(source_path, output_dir, opts \\ []) do + unless image_available?() do + Mix.raise(""" + The `image` dependency is required to generate adaptive Android icons. + Add `{:image, "~> 0.54"}` to your deps and run `mix deps.get`. + """) + end + + source = Image.open!(source_path) + + bg_hex = + case opts[:background_color] do + hex when is_binary(hex) -> normalise_hex!(hex) + _ -> extract_background_color(source) + end + + write_adaptive_foregrounds(source, output_dir) + write_adaptive_xml(output_dir) + write_background_color(output_dir, bg_hex) + :ok + end + + @doc """ + Returns the XML body for `mipmap-anydpi-v26/ic_launcher.xml`. + + Uses `@mipmap/ic_launcher_foreground` for the foreground and + `@color/ic_launcher_background` for the background. + """ + @spec adaptive_icon_xml() :: String.t() + def adaptive_icon_xml do + """ + <?xml version="1.0" encoding="utf-8"?> + <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@color/ic_launcher_background"/> + <foreground android:drawable="@mipmap/ic_launcher_foreground"/> + </adaptive-icon> + """ + end + + @doc """ + Returns the XML body for `values/ic_launcher_background.xml` defining + the adaptive icon background colour. + + Accepts hex strings with or without a leading `#` (case-insensitive). + Raises `ArgumentError` for non-hex input. + """ + @spec background_color_xml(String.t()) :: String.t() + def background_color_xml(hex) do + hex = normalise_hex!(hex) + + """ + <?xml version="1.0" encoding="utf-8"?> + <resources> + <color name="ic_launcher_background">#{hex}</color> + </resources> + """ + end + + @doc false + @spec normalise_hex!(String.t()) :: String.t() + def normalise_hex!(hex) when is_binary(hex) do + raw = String.trim(hex) |> String.upcase() |> String.trim_leading("#") + + if Regex.match?(Regex.compile!("^[0-9A-F]{6}$"), raw) do + "#" <> raw + else + raise ArgumentError, + "expected hex colour like \"#E8B53C\" or \"E8B53C\", got: #{inspect(hex)}" + end + end + + @doc false + @spec rgb_to_hex(non_neg_integer(), non_neg_integer(), non_neg_integer()) :: String.t() + def rgb_to_hex(r, g, b) do + [r, g, b] + |> Enum.map(&clamp_byte/1) + |> Enum.map(&format_byte/1) + |> Enum.join() + |> then(&("#" <> &1)) + end + + defp clamp_byte(n) when is_number(n) do + n |> round() |> max(0) |> min(255) + end + + defp format_byte(n) do + n |> Integer.to_string(16) |> String.pad_leading(2, "0") + end + # ── private ────────────────────────────────────────────────────────────────── - defp resize_for_platforms(source_png, output_dir) do + defp image_available? do + Code.ensure_loaded?(Image) + end + + defp resize_for_platforms(source_png, output_dir, opts) do source = Image.open!(source_png) + # Android keeps the source's transparency — adaptive-icon foregrounds and + # legacy launcher icons rely on it, and a flat background renders badly on + # some launchers. iOS is flattened opaque (see write_ios_icons). write_android_icons(source, output_dir) - write_ios_icons(source, output_dir) + write_ios_icons(source, output_dir, opts) :ok end @@ -114,54 +292,132 @@ defmodule MobDev.IconGenerator do dest_dir = Path.join([output_dir, "android", "app", "src", "main", "res", bucket]) File.mkdir_p!(dest_dir) dest = Path.join(dest_dir, "ic_launcher.png") - source - |> Image.thumbnail!(px) - |> Image.write!(dest) + source |> Image.thumbnail!(px) |> Image.write!(dest) + end) + end + + defp write_android_icons_from_priv(output_dir) do + Enum.each(@android_sizes, fn {bucket, px} -> + dest_dir = Path.join([output_dir, "android", "app", "src", "main", "res", bucket]) + File.mkdir_p!(dest_dir) + File.cp!(Path.join(@mob_logo_dir, "#{px}.png"), Path.join(dest_dir, "ic_launcher.png")) end) end - defp write_ios_icons(source, output_dir) do + defp write_ios_icons(source, output_dir, opts) do dest_dir = Path.join([output_dir, "ios", "Assets.xcassets", "AppIcon.appiconset"]) File.mkdir_p!(dest_dir) + # iOS icons must be opaque: Apple rejects any alpha channel on the App Store + # marketing icon (error 90717 "Invalid large app icon … can't be + # transparent"), and iOS applies its own corner mask, so a flat, full-bleed + # icon is correct. Flatten the source once, then resize the opaque result. + ios_source = flatten_for_ios(source, opts) + Enum.each(@ios_sizes, fn px -> dest = Path.join(dest_dir, "icon_#{px}.png") + ios_source |> Image.thumbnail!(px) |> Image.write!(dest) + end) + + write_ios_contents_json(dest_dir) + end + + # Flatten an alpha-bearing source onto an opaque background for iOS. Reuses the + # adaptive-icon background colour (explicit `:background_color` or sampled), so + # the iOS icon's fill matches the Android adaptive background. A source with no + # alpha is returned unchanged. + defp flatten_for_ios(source, opts) do + if Image.has_alpha?(source) do + bg = + case opts[:background_color] do + hex when is_binary(hex) -> normalise_hex!(hex) + _ -> extract_background_color(source) + end + + Image.flatten!(source, background_color: bg) + else source - |> Image.thumbnail!(px) - |> Image.write!(dest) + end + end + + defp write_ios_icons_from_priv(output_dir) do + dest_dir = Path.join([output_dir, "ios", "Assets.xcassets", "AppIcon.appiconset"]) + File.mkdir_p!(dest_dir) + + Enum.each(@ios_sizes, fn px -> + File.cp!(Path.join(@mob_logo_dir, "#{px}.png"), Path.join(dest_dir, "icon_#{px}.png")) end) write_ios_contents_json(dest_dir) end + defp write_adaptive_foregrounds(source, output_dir) do + Enum.each(@adaptive_sizes, fn {bucket, px} -> + dest_dir = Path.join([output_dir, "android", "app", "src", "main", "res", bucket]) + File.mkdir_p!(dest_dir) + dest = Path.join(dest_dir, "ic_launcher_foreground.png") + source |> Image.thumbnail!(px) |> Image.write!(dest) + end) + end + + defp write_adaptive_xml(output_dir) do + dir = Path.join([output_dir, "android", "app", "src", "main", "res", "mipmap-anydpi-v26"]) + File.mkdir_p!(dir) + body = adaptive_icon_xml() + File.write!(Path.join(dir, "ic_launcher.xml"), body) + File.write!(Path.join(dir, "ic_launcher_round.xml"), body) + end + + defp write_background_color(output_dir, hex) do + dir = Path.join([output_dir, "android", "app", "src", "main", "res", "values"]) + File.mkdir_p!(dir) + File.write!(Path.join(dir, "ic_launcher_background.xml"), background_color_xml(hex)) + end + + # Sample top-centre pixel (10% from top) — for our typical icons that's + # inside the artwork's background rather than at a corner that might be + # transparent or border-styled. + defp extract_background_color(image) do + width = Image.width(image) + height = Image.height(image) + x = div(width, 2) + y = div(height, 10) |> max(1) + + case Image.get_pixel(image, x, y) do + {:ok, [r, g, b | _]} -> rgb_to_hex(r, g, b) + {:ok, [grey | _]} -> rgb_to_hex(grey, grey, grey) + _ -> "#FFFFFF" + end + end + defp write_ios_contents_json(dest_dir) do images = [ - %{idiom: "iphone", scale: "2x", size: "20x20", px: 40}, - %{idiom: "iphone", scale: "3x", size: "20x20", px: 60}, - %{idiom: "iphone", scale: "2x", size: "29x29", px: 58}, - %{idiom: "iphone", scale: "3x", size: "29x29", px: 87}, - %{idiom: "iphone", scale: "2x", size: "40x40", px: 80}, - %{idiom: "iphone", scale: "3x", size: "40x40", px: 120}, - %{idiom: "iphone", scale: "2x", size: "60x60", px: 120}, - %{idiom: "iphone", scale: "3x", size: "60x60", px: 180}, - %{idiom: "ipad", scale: "1x", size: "20x20", px: 20}, - %{idiom: "ipad", scale: "2x", size: "20x20", px: 40}, - %{idiom: "ipad", scale: "1x", size: "29x29", px: 29}, - %{idiom: "ipad", scale: "2x", size: "29x29", px: 58}, - %{idiom: "ipad", scale: "1x", size: "40x40", px: 40}, - %{idiom: "ipad", scale: "2x", size: "40x40", px: 80}, - %{idiom: "ipad", scale: "1x", size: "76x76", px: 76}, - %{idiom: "ipad", scale: "2x", size: "76x76", px: 152}, - %{idiom: "ipad", scale: "2x", size: "83.5x83.5", px: 167}, + %{idiom: "iphone", scale: "2x", size: "20x20", px: 40}, + %{idiom: "iphone", scale: "3x", size: "20x20", px: 60}, + %{idiom: "iphone", scale: "2x", size: "29x29", px: 58}, + %{idiom: "iphone", scale: "3x", size: "29x29", px: 87}, + %{idiom: "iphone", scale: "2x", size: "40x40", px: 80}, + %{idiom: "iphone", scale: "3x", size: "40x40", px: 120}, + %{idiom: "iphone", scale: "2x", size: "60x60", px: 120}, + %{idiom: "iphone", scale: "3x", size: "60x60", px: 180}, + %{idiom: "ipad", scale: "1x", size: "20x20", px: 20}, + %{idiom: "ipad", scale: "2x", size: "20x20", px: 40}, + %{idiom: "ipad", scale: "1x", size: "29x29", px: 29}, + %{idiom: "ipad", scale: "2x", size: "29x29", px: 58}, + %{idiom: "ipad", scale: "1x", size: "40x40", px: 40}, + %{idiom: "ipad", scale: "2x", size: "40x40", px: 80}, + %{idiom: "ipad", scale: "1x", size: "76x76", px: 76}, + %{idiom: "ipad", scale: "2x", size: "76x76", px: 152}, + %{idiom: "ipad", scale: "2x", size: "83.5x83.5", px: 167}, %{idiom: "ios-marketing", scale: "1x", size: "1024x1024", px: 1024} ] |> Enum.map(fn %{idiom: idiom, scale: scale, size: size, px: px} -> %{ "filename" => "icon_#{px}.png", - "idiom" => idiom, - "scale" => scale, - "size" => size + "idiom" => idiom, + "scale" => scale, + "size" => size } end) diff --git a/lib/mob_dev/mlx_downloader.ex b/lib/mob_dev/mlx_downloader.ex new file mode 100644 index 0000000..a3d2dc6 --- /dev/null +++ b/lib/mob_dev/mlx_downloader.ex @@ -0,0 +1,235 @@ +defmodule MobDev.MLXDownloader do + @moduledoc """ + Downloads and caches pre-built Apple MLX + EMLX NIF static archives so + iOS Mob apps can ship `EMLX.Backend` as an Nx backend without + cross-compiling MLX themselves. + + Mirrors the `MobDev.OtpDownloader` / `MobDev.PythonAppleSupport` pattern: + hashed URL + cached download at `~/.mob/cache/mlx-<version>-<target>/`, + validated against the expected layout. Reused across projects. + + Used by `MobDev.NativeBuild` when the project's deps include `:emlx` or + `mob.exs` declares `mlx_enabled: true`. The build template sources + `MLX_DIR` from `dir/1` and links `libmlx.a` + `libemlx.a` from there. + + ## Scope (v1) + + CPU-only MLX. Metal-on-iOS is gated behind a separate tarball variant + (`libmlx-<ver>-ios-device-metal.tar.gz`) that requires the iOS-Metal + CMakeLists patch and the Xcode Metal Toolchain installed at build + time. v1 ships CPU + Accelerate framework — already + ~10-50x faster than `Nx.BinaryBackend` for typical Nx workloads. + + ## Local-build override + + Set `MOB_MLX_LOCAL_TARBALL_DIR=/path/to/dir` to bypass the GitHub + download and use locally-built tarballs (named exactly as + `tarball_name/1` returns). Useful when iterating on the cross-compile + scripts in `mob_dev/scripts/release/mlx/`. + """ + + # Pinned MLX upstream version. Bump in lock-step with EMLX (deps/emlx/mix.exs + # @mlx_version) and the cross-compile scripts in + # scripts/release/mlx/_lib.sh. + @mlx_version "0.25.1" + + # Distinct release tag from the OTP tarballs so MLX can move independently. + # Repo-hosted on the same `GenericJam/mob` release surface OtpDownloader uses. + # + # TODO(2026-05-17): switch `@base_url` to `cocoa-xu/mlx-build/releases/...` + # once that repo publishes iOS assets. The iOS build workflow merged + # upstream in cocoa-xu/mlx-build#2 (GenericJam:ios-metal-builds, merged + # 2026-05-17), but the latest release (v0.31.2) predates the workflow + # and has no iOS assets yet. Next upstream release that triggers + # `ios.yml` should produce `mlx-arm64-apple-ios-*.tar.gz` + + # `mlx-arm64-apple-iossimulator-*.tar.gz` assets we can consume + # directly, retiring our own iOS cross-compile in + # scripts/release/mlx/. Until then we keep self-hosting. + @release_tag "mlx-#{@mlx_version}" + @base_url "https://github.com/GenericJam/mob/releases/download/#{@release_tag}" + + @typedoc "Target slice this downloader supports." + @type target :: :ios_device | :ios_sim + + # ── Public API ────────────────────────────────────────────────────────────── + + @doc """ + Ensure the MLX bundle for `target` is cached and extracted. + Returns `{:ok, path}` where `path` is the unpacked root containing + `lib/libmlx.a`, `lib/libemlx.a`, `include/mlx/...`, `VERSION`. + """ + @spec ensure(target()) :: {:ok, String.t()} | {:error, term()} + def ensure(target) when target in [:ios_device, :ios_sim] do + dest = dir(target) + + if valid_dir?(dest) do + {:ok, dest} + else + # Stale or partial — clean and re-download. Same rationale as + # OtpDownloader: previous failed extraction or schema bump. + if File.dir?(dest), do: File.rm_rf!(dest) + download_and_extract(target, dest) + end + end + + @doc "Convenience for iOS device." + @spec ensure_ios_device() :: {:ok, String.t()} | {:error, term()} + def ensure_ios_device, do: ensure(:ios_device) + + @doc "Convenience for iOS simulator." + @spec ensure_ios_sim() :: {:ok, String.t()} | {:error, term()} + def ensure_ios_sim, do: ensure(:ios_sim) + + @doc """ + Cached MLX root directory for `target`. May not exist if `ensure/1` + hasn't been called. + """ + @spec dir(target()) :: String.t() + def dir(target) do + Path.join(cache_dir(), name(target)) + end + + @doc """ + Returns true if the cache directory has the expected layout + (`lib/libmlx.a`, `lib/libemlx.a`, `include/mlx/`, `VERSION`). + + Public for testing and so `NativeBuild` can cheaply probe for a partial + cache without parsing the VERSION file. + """ + @spec valid_dir?(String.t()) :: boolean() + def valid_dir?(dir) do + File.regular?(Path.join([dir, "lib", "libmlx.a"])) and + File.regular?(Path.join([dir, "lib", "libemlx.a"])) and + File.dir?(Path.join([dir, "include", "mlx"])) and + File.regular?(Path.join([dir, "VERSION"])) + end + + @doc """ + Path to `mlx.metallib` if this bundle ships Metal GPU kernels, or + `nil` if it's a CPU-only bundle. + + The CPU build (`scripts/release/mlx/ios_device.sh`) doesn't produce + a metallib. The Metal build (`ios_device_metal.sh`) puts one at + `lib/mlx.metallib` alongside the static archives. Callers use this + to decide whether to copy the metallib into the iOS .app bundle so + EMLX's runtime `device: :gpu` path can find it (via MLX's + `load_colocated_library`). + """ + @spec metallib_path(String.t()) :: nil | String.t() + def metallib_path(dir) do + path = Path.join([dir, "lib", "mlx.metallib"]) + if File.regular?(path), do: path, else: nil + end + + @doc "Bundle name (no extension, no path) for `target` — `libmlx-0.25.1-ios-device` etc." + @spec name(target()) :: String.t() + def name(:ios_device), do: "libmlx-#{@mlx_version}-ios-device" + def name(:ios_sim), do: "libmlx-#{@mlx_version}-ios-sim" + + @doc "Tarball file name for `target` (e.g. `libmlx-0.25.1-ios-device.tar.gz`)." + @spec tarball_name(target()) :: String.t() + def tarball_name(target), do: name(target) <> ".tar.gz" + + @doc "Download URL for the `target` tarball." + @spec download_url(target()) :: String.t() + def download_url(target), do: "#{@base_url}/#{tarball_name(target)}" + + @doc "Pinned MLX upstream version (e.g. `0.25.1`)." + @spec mlx_version() :: String.t() + def mlx_version, do: @mlx_version + + @doc "GitHub release tag this downloader targets." + @spec release_tag() :: String.t() + def release_tag, do: @release_tag + + # ── Private ───────────────────────────────────────────────────────────────── + + defp cache_dir do + System.get_env("MOB_CACHE_DIR") || + Path.join([System.get_env("HOME") || ".", ".mob", "cache"]) + end + + defp local_tarball_dir do + case System.get_env("MOB_MLX_LOCAL_TARBALL_DIR") do + nil -> nil + "" -> nil + v -> v + end + end + + defp download_and_extract(target, dest_dir) do + File.mkdir_p!(Path.dirname(dest_dir)) + + with {:ok, tarball_path} <- fetch_tarball(target), + :ok <- extract(tarball_path, dest_dir), + :ok <- verify_layout(dest_dir) do + # Only clean up the tarball when we downloaded it ourselves. + # Local-override tarballs are user-managed; don't surprise them. + if String.starts_with?(tarball_path, System.tmp_dir!()) do + File.rm(tarball_path) + end + + IO.puts(" Cached MLX (#{target}) at #{dest_dir}") + {:ok, dest_dir} + else + {:error, reason} -> + File.rm_rf(dest_dir) + {:error, reason} + end + end + + defp fetch_tarball(target) do + case local_tarball_dir() do + nil -> download(target) + dir -> use_local(target, dir) + end + end + + defp use_local(target, dir) do + path = Path.join(dir, tarball_name(target)) + + if File.regular?(path) do + IO.puts(" Using local MLX tarball: #{path}") + {:ok, path} + else + {:error, + "MOB_MLX_LOCAL_TARBALL_DIR is set to #{dir} but " <> + "#{tarball_name(target)} is not there."} + end + end + + defp download(target) do + url = download_url(target) + tmp_file = Path.join(System.tmp_dir!(), tarball_name(target)) + + IO.puts(" Downloading MLX #{@mlx_version} (#{target})...") + IO.puts(" URL: #{url}") + + case System.cmd("curl", ["-L", "--fail", "--progress-bar", "-o", tmp_file, url], + stderr_to_stdout: false + ) do + {_, 0} -> {:ok, tmp_file} + {out, rc} -> {:error, "curl failed (exit #{rc}): #{String.trim(out)}"} + end + end + + defp extract(tarball, dest_dir) do + parent = Path.dirname(dest_dir) + File.mkdir_p!(parent) + + # The tarball's top-level dir matches `name/1`, so extracting into the + # parent of `dest_dir` lands the right layout. Verify happens afterward. + MobDev.Download.untar(tarball, parent) + end + + defp verify_layout(dir) do + if valid_dir?(dir) do + :ok + else + {:error, + "MLX bundle at #{dir} is missing expected paths.\n" <> + " Expected lib/libmlx.a, lib/libemlx.a, include/mlx/, VERSION.\n" <> + " Tarball may have an unexpected layout."} + end + end +end diff --git a/lib/mob_dev/native_build.ex b/lib/mob_dev/native_build.ex index 1915a40..10283e3 100644 --- a/lib/mob_dev/native_build.ex +++ b/lib/mob_dev/native_build.ex @@ -1,4 +1,57 @@ defmodule MobDev.NativeBuild do + alias MobDev.{AndroidDeployLock, AndroidDeployRecoveryProof, Release} + + @max_android_update_targets 32 + @max_adb_serial_bytes 128 + @max_adb_discovery_bytes 8_192 + @max_adb_install_result_bytes 4_096 + @max_android_apk_bytes 1_073_741_824 + @max_android_apk_entries 100_000 + @max_android_apk_entry_bytes 1_024 + @max_android_apk_required_entry_bytes 268_435_456 + @android_attempt_id_pattern "\\A[A-Za-z0-9_-]{16}\\z" + @android_erts_sentinel_pattern "\\Aerts-[A-Za-z0-9._-]+/bin/erl_child_setup\\z" + @android_erts_helpers [ + {"erl_child_setup", "liberl_child_setup.so"}, + {"inet_gethost", "libinet_gethost.so"}, + {"epmd", "libepmd.so"} + ] + + @type android_update_failure_reason :: + :insufficient_storage + | :signature_mismatch + | :version_downgrade + | :offline + | :unauthorized + | :unavailable + | :install_rejected + | :suspicious_success + | :unknown_failure + | :invalid_target + + @type android_update_failure :: %{ + required(:serial) => String.t(), + required(:reason) => android_update_failure_reason() + } + + @type android_update_outcome :: %{ + required(:succeeded) => [String.t()], + required(:failed) => [android_update_failure()] + } + + @type build_outcome :: %{ + required(:ok?) => boolean(), + required(:android_device_disposition) => + :not_attempted | :artifact_only | :held | :failed | :retained | :partial_update, + required(:android_serials) => [String.t()], + required(:android_deploy_lock) => map() | nil, + required(:android_payload_plan) => map() | nil + } + + @type command_runner :: (String.t(), [String.t()] -> {String.t(), integer()}) + @type command_runner_with_opts :: + (String.t(), [String.t()], keyword() -> {String.t(), integer()}) + @moduledoc """ Builds native binaries (APK for Android, .app bundle for iOS simulator) for the current Mob project. @@ -13,345 +66,9204 @@ defmodule MobDev.NativeBuild do * `:mob_dir` — mob library repo (native C/ObjC/Swift source) * `:elixir_lib` — Elixir stdlib lib dir + * `:project_swift_sources` — optional extra Swift sources compiled into + the iOS app module """ @doc """ Builds native binaries for all platforms present in the project. Runs Android Gradle build if `android/` dir exists. - Runs iOS build script if `ios/build.sh` exists. + Runs the Mix-driven iOS pipeline (delegating native compile + link + to `ios/build.zig` for sim, `ios/build_device.zig` for device) when + `ios/build.zig` exists. Selection between sim and device is driven + by the `device:` opt. """ - @spec build_all(keyword()) :: [:ok | {:error, term()}] + @spec build_all(keyword()) :: boolean() def build_all(opts \\ []) do - cfg = load_config() + opts + |> Keyword.put(:android_device_phase, false) + |> build_all_with_outcome() + |> Map.fetch!(:ok?) + end + + @doc false + @spec build_all_with_outcome(keyword()) :: build_outcome() + def build_all_with_outcome(opts \\ []) do + cfg = load_config() platforms = Keyword.get(opts, :platforms, [:android, :ios]) + device_id = Keyword.get(opts, :device, nil) + slim = Keyword.get(opts, :slim, true) + platforms = narrow_platforms_for_device(platforms, device_id) + Process.put(:mob_slim, slim) + + # Always regenerate the runtime plugin manifest from the CURRENT activated + # plugins before bundling priv — like the driver_tab, it's derived state, not + # a hand-maintained file. Regenerating on every build (not just when the + # `:plugins` list changes) means adding/changing a plugin's tier-3/4 sections + # can't silently ship a stale manifest (the lifecycle/settings/notification + # handlers just wouldn't activate on device, with no error). + regen_runtime_manifest!() + + # Same treatment for the static-NIF driver table: it's derived state + # (mob.exs :static_nifs + the activated plugins' NIFs), but it used to be a + # checked-in artifact only `mix mob.regen_driver_tab` refreshed. Activating + # a NIF plugin against a stale table links the <module>_nif_init symbol but + # never registers it — every call then raises :nif_not_loaded at runtime + # with nothing pointing at the cause. Regenerate on every native build. + regen_driver_tab!() + + # Tier-3 build-time file merges (platform-agnostic; run once before the + # per-platform builds): copy plugin migrations into the host migrations dir + # and plugin images into the host bundle assets. Fonts are merged per-platform + # (iOS Info.plist + bundle, Android assets) inside the build chains. + apply_plugin_migrations!() + apply_plugin_images!() + + # Manual host-app obligations a plugin declared (e.g. an AndroidManifest + # <service> fragment the plugin system can't contribute) — print every + # build, because forgetting one builds + boots clean and only fails at + # first feature use (a SecurityException with nothing pointing here). + warn_host_requirements!() results = [] - results = if :android in platforms and File.dir?("android"), - do: [build_android(cfg) | results], else: results - results = if :ios in platforms and File.exists?("ios/build.sh"), - do: [build_ios(cfg) | results], else: results + + # Skip Android when its toolchain isn't installed instead of failing the + # build half an hour into a partial-setup user's first deploy. Default + # `mix mob.deploy` (no platform flag) targets every platform with a + # `<dir>/` scaffold, but users who only set up iOS hit a Gradle error + # half a build later — annoying, and fixable by checking up front. + results = + cond do + :android not in platforms -> + results + + not File.dir?("android") -> + results + + not android_toolchain_available?() -> + warn_skipped_android() + results + + true -> + [build_android(cfg, device_id, opts) | results] + end + + try do + finish_native_builds(results, cfg, platforms, device_id, opts) + catch + _kind, _reason -> + IO.puts( + " #{IO.ANSI.red()}✗ native build failed unexpectedly; retained Android deploy state remains authoritative#{IO.ANSI.reset()}" + ) + + build_outcome([{:error, "Native", "unexpected native build failure"} | results], opts) + end + end + + defp finish_native_builds(results, cfg, platforms, device_id, opts) do + results = + case ios_phase_decision( + results, + platforms, + Keyword.get(opts, :android_device_phase, false) + ) do + :run -> finish_ios_native_build(results, cfg, device_id) + :defer -> results + :suppress -> results + :skip -> results + end if results == [] do - IO.puts(" #{IO.ANSI.yellow()}No native build targets found (missing android/ or ios/build.sh)#{IO.ANSI.reset()}") + IO.puts( + " #{IO.ANSI.yellow()}No native build targets found (missing android/ or ios/build.zig, or toolchains)#{IO.ANSI.reset()}" + ) end Enum.each(results, fn {:ok, platform} -> IO.puts(" #{IO.ANSI.green()}✓ #{platform} native build complete#{IO.ANSI.reset()}") + + {:ok, platform, _metadata} -> + IO.puts(" #{IO.ANSI.green()}✓ #{platform} native build complete#{IO.ANSI.reset()}") + {:error, platform, reason} -> - IO.puts(" #{IO.ANSI.red()}✗ #{platform} native build failed: #{reason}#{IO.ANSI.reset()}") + IO.puts( + " #{IO.ANSI.red()}✗ #{platform} native build failed: #{reason}#{IO.ANSI.reset()}" + ) + + {:error, platform, reason, _retained_lock} -> + IO.puts( + " #{IO.ANSI.red()}✗ #{platform} native build failed: #{reason} (deploy lock retained)#{IO.ANSI.reset()}" + ) + + {:error, platform, reason, _retained_lock, :partial_update} -> + IO.puts( + " #{IO.ANSI.red()}✗ #{platform} native update partially applied: #{reason} (deploy lock retained)#{IO.ANSI.reset()}" + ) end) - ok_count = Enum.count(results, &match?({:ok, _}, &1)) - ok_count == length(results) + build_outcome(results, opts) end - # ── Android ────────────────────────────────────────────────────────────────── + @doc false + @spec ios_phase_decision([tuple()], [atom()], term()) :: :run | :defer | :suppress | :skip + def ios_phase_decision(results, platforms, android_device_phase) + when is_list(results) and is_list(platforms) do + cond do + :ios not in platforms -> + :skip - defp build_android(cfg) do - IO.puts(" Building Android APK...") - bundle_id = cfg[:bundle_id] || MobDev.Config.bundle_id() - apk = "android/app/build/outputs/apk/debug/app-debug.apk" + android_device_phase != true -> + :run - with {:ok, otp_dir} <- MobDev.OtpDownloader.ensure_android(), - :ok <- ensure_jni_libs(otp_dir), - :ok <- gradle_assemble(), - :ok <- adb_install_all(apk, bundle_id), - :ok <- push_otp_release_android(bundle_id, otp_dir, cfg[:elixir_lib]) do - {:ok, "Android"} - else - {:error, reason} -> {:error, "Android", reason} + true -> + case Enum.filter(results, &android_build_result?/1) do + [] -> :run + [result] -> if held_android_device_phase?(result), do: :defer, else: :suppress + _multiple -> :suppress + end end end - # Copies ERTS helper executables into jniLibs as lib*.so so Android grants - # them the apk_data_file SELinux label (required for execve). - defp ensure_jni_libs(otp_dir) do - jni_libs = "android/app/src/main/jniLibs/arm64-v8a" - File.mkdir_p!(jni_libs) + def ios_phase_decision(_results, _platforms, _android_device_phase), do: :suppress + + defp android_build_result?(result) when is_tuple(result) and tuple_size(result) >= 2, + do: elem(result, 1) == "Android" + + defp android_build_result?(_result), do: false - erts_bins = Path.wildcard("#{otp_dir}/erts-*/bin") |> List.first() + defp held_android_device_phase?({:ok, "Android", %{serials: serials, deploy_lock: lock}}) + when is_list(serials) and serials != [] and is_map(lock) do + serials == Enum.sort(serials) and Enum.uniq(serials) == serials and + MobDev.AndroidDeployLock.valid?(lock, :native_ready) and lock.serials == serials + end + + defp held_android_device_phase?(_result), do: false - if erts_bins do - for {exe, lib} <- [ - {"erl_child_setup", "liberl_child_setup.so"}, - {"inet_gethost", "libinet_gethost.so"}, - {"epmd", "libepmd.so"} - ] do - src = Path.join(erts_bins, exe) - dst = Path.join(jni_libs, lib) - if File.exists?(src), do: cp(src, dst) + defp finish_ios_native_build(results, cfg, device_id) do + physical_udid = + cond do + is_binary(device_id) and ios_physical_udid?(device_id) -> device_id + is_nil(device_id) -> auto_detect_physical_ios() + true -> nil end + + cond do + not ios_toolchain_available?() -> + warn_skipped_ios() + results + + physical_udid -> + [build_ios_physical(cfg, physical_udid) | results] + + File.exists?("ios/build.zig") -> + [build_ios(cfg, device_id) | results] + + true -> + results end + end - :ok + @doc false + @spec build_outcome([tuple()]) :: build_outcome() + def build_outcome(results) when is_list(results) do + ok? = not Enum.empty?(results) and Enum.all?(results, &successful_native_build?/1) + + %{ + ok?: ok?, + android_device_disposition: android_device_disposition(results), + android_serials: android_serials_from_results(results), + android_deploy_lock: android_deploy_lock_from_results(results), + android_payload_plan: if(ok?, do: android_payload_plan_from_results(results), else: nil) + } end - defp gradle_assemble do - IO.puts(" Running Gradle assembleDebug...") - android_dir = Path.join(File.cwd!(), "android") - gradlew = Path.join(android_dir, "gradlew") + @doc false + @spec build_outcome([tuple()], keyword()) :: build_outcome() + def build_outcome(results, opts) when is_list(results) and is_list(opts) do + outcome = build_outcome(results) - if File.exists?(gradlew) do - case System.cmd(gradlew, ["assembleDebug", "-q"], - cd: android_dir, stderr_to_stdout: true) do - {_, 0} -> :ok - {out, _} -> {:error, "Gradle failed:\n#{String.slice(out, -500, 500)}"} - end + if outcome.ok? do + outcome else - {:error, "gradlew not found at #{gradlew}"} + case cleanup_successful_android_payloads(results, opts) do + :ok -> + outcome + + {:error, _cleanup_reason} -> + IO.puts( + " #{IO.ANSI.yellow()}⚠ Android payload cleanup failed; deploy lease identity retained#{IO.ANSI.reset()}" + ) + + outcome + end end end - defp adb_install_all(apk, bundle_id) do - case System.cmd("adb", ["devices"], stderr_to_stdout: true) do - {output, 0} -> - serials = - output - |> String.split("\n") - |> Enum.drop(1) - |> Enum.filter(&String.contains?(&1, "\tdevice")) - |> Enum.map(&hd(String.split(&1, "\t"))) - - Enum.each(serials, fn serial -> - IO.puts(" Installing APK on #{serial}...") - System.cmd("adb", ["-s", serial, "shell", "am", "force-stop", bundle_id], - stderr_to_stdout: true) - System.cmd("adb", ["-s", serial, "uninstall", bundle_id], stderr_to_stdout: true) - System.cmd("adb", ["-s", serial, "install", apk], stderr_to_stdout: true) - fix_erts_helper_labels(serial, bundle_id) - end) + # ── Android ────────────────────────────────────────────────────────────────── - :ok + defp build_android(cfg, device_id, opts) do + bundle_id = cfg[:bundle_id] || MobDev.Config.bundle_id() + apk = "android/app/build/outputs/apk/debug/app-debug.apk" + mob_dir = Path.expand(cfg[:mob_dir]) - {out, _} -> - {:error, "adb devices failed: #{out}"} + with {:ok, update_targets} <- android_update_targets_for_phase(device_id, opts), + :ok <- print_android_build_start(), + {:ok, otp_arm64} <- MobDev.OtpDownloader.ensure_android("arm64-v8a"), + {:ok, otp_arm32} <- MobDev.OtpDownloader.ensure_android("armeabi-v7a"), + {:ok, otp_x86_64} <- MobDev.OtpDownloader.ensure_android("x86_64"), + {:ok, python_android_bundle} <- maybe_ensure_python_android_bundle(), + :ok <- ensure_jni_libs(otp_arm64, "arm64-v8a"), + :ok <- ensure_jni_libs(otp_arm32, "armeabi-v7a"), + :ok <- ensure_jni_libs(otp_x86_64, "x86_64"), + :ok <- ensure_python_android_libs(python_android_bundle), + :ok <- install_nx_eigen_otp_lib(otp_arm64), + :ok <- install_nx_eigen_otp_lib(otp_arm32), + :ok <- zig_build_android_objects(mob_dir, otp_arm64, otp_arm32, otp_x86_64), + :ok <- apply_plugin_android_manifest!(), + :ok <- apply_plugin_gradle_deps!(), + :ok <- apply_plugin_android_kotlin!(), + :ok <- apply_plugin_android_res!(), + :ok <- apply_fonts_to_android!(), + :ok <- gradle_assemble(), + {:ok, metadata} <- + maybe_install_android_runtime( + apk, + update_targets, + bundle_id, + cfg[:elixir_lib], + otp_arm64, + otp_arm32, + otp_x86_64, + opts + ) do + {:ok, "Android", Map.put(metadata, :serials, update_targets)} + else + {:error, {:partial_update, reason}, deploy_lock} -> + {:error, "Android", reason, deploy_lock, :partial_update} + + {:error, reason, deploy_lock} -> + {:error, "Android", reason, deploy_lock} + + {:error, reason} -> + {:error, "Android", reason} end end - # Android 15 streaming install labels ERTS helper .so files as app_data_file - # instead of apk_data_file, blocking execute_no_trans by untrusted_app. - # Fix by chcon-ing them back to apk_data_file (requires root / emulator). - defp fix_erts_helper_labels(serial, bundle_id) do - adb = fn args -> System.cmd("adb", ["-s", serial | args], stderr_to_stdout: true) end + defp android_update_targets_for_phase(device_id, opts) do + case Keyword.get(opts, :android_device_phase, false) do + false -> {:ok, []} + true -> android_update_targets(device_id) + _invalid -> {:error, "Invalid Android device-phase option"} + end + end - # Only works on rooted/emulator builds — silently skip on real devices. - rooted? = case adb.(["root"]) do - {out, 0} -> out =~ "restarting" or out =~ "already running as root" - _ -> false + defp maybe_install_android_runtime( + _apk, + [], + _bundle_id, + _elixir_lib, + _otp_arm64, + _otp_arm32, + _otp_x86_64, + opts + ) do + case Keyword.get(opts, :android_device_phase, false) do + false -> {:ok, %{deploy_lock: nil, payload_plan: nil}} + _device_phase -> {:error, "Android device phase requires explicit canonical targets"} end + end + + defp maybe_install_android_runtime( + apk, + update_targets, + bundle_id, + elixir_lib, + otp_arm64, + otp_arm32, + otp_x86_64, + opts + ) do + install_and_deliver_android_runtime( + apk, + update_targets, + bundle_id, + elixir_lib, + otp_arm64, + otp_arm32, + otp_x86_64, + opts + ) + end - if rooted? do - :timer.sleep(800) - {lib_dir_out, _} = adb.(["shell", - "pm dump #{bundle_id} | grep nativeLibraryDir | head -1 | awk '{print $NF}'"]) - lib_dir = String.trim(lib_dir_out) + defp print_android_build_start do + IO.puts(" Building Android APK...") + :ok + end + + defp successful_native_build?({:ok, _platform}), do: true + defp successful_native_build?({:ok, _platform, _metadata}), do: true + defp successful_native_build?(_result), do: false - if lib_dir != "" do - for lib <- ["liberl_child_setup.so", "libinet_gethost.so", "libepmd.so"] do - adb.(["shell", "chcon", "u:object_r:apk_data_file:s0", "#{lib_dir}/#{lib}"]) + defp android_device_disposition(results) do + case Enum.filter(results, &android_build_result?/1) do + [] -> + :not_attempted + + [result] -> + classify_android_device_result(result) + + multiple -> + cond do + Enum.any?(multiple, &partial_android_update?/1) -> :partial_update + Enum.any?(multiple, &android_authority_present?/1) -> :retained + true -> :failed end - end end end - defp push_otp_release_android(bundle_id, otp_dir, elixir_lib) do - app_data = "/data/data/#{bundle_id}/files" + defp classify_android_device_result({:error, "Android", _reason, lock, :partial_update}) + when is_map(lock), + do: :partial_update - IO.puts(" Pushing OTP release to device(s)...") + defp classify_android_device_result({:error, "Android", _reason, lock}) when is_map(lock), + do: :retained - case System.cmd("adb", ["devices"], stderr_to_stdout: true) do - {output, 0} -> - serials = parse_adb_serials(output) - if serials == [], do: IO.puts(" (no devices connected, skipping OTP push)") - Enum.reduce_while(serials, :ok, fn serial, _ -> - case push_otp_to_device(serial, bundle_id, app_data, otp_dir, elixir_lib) do + defp classify_android_device_result(result) do + cond do + held_android_device_phase?(result) -> :held + artifact_only_android_result?(result) -> :artifact_only + true -> :failed + end + end + + defp artifact_only_android_result?({:ok, "Android"}), do: true + + defp artifact_only_android_result?( + {:ok, "Android", %{serials: [], deploy_lock: nil, payload_plan: nil}} + ), + do: true + + defp artifact_only_android_result?(_result), do: false + + defp partial_android_update?({:error, "Android", _reason, lock, :partial_update}) + when is_map(lock), + do: true - :ok -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, reason}} + defp partial_android_update?(_result), do: false + + defp android_authority_present?({:error, "Android", _reason, lock, :partial_update}) + when is_map(lock), + do: true + + defp android_authority_present?({:error, "Android", _reason, lock}) when is_map(lock), + do: true + + defp android_authority_present?({:ok, "Android", %{deploy_lock: lock}}) when is_map(lock), + do: true + + defp android_authority_present?(_result), do: false + + defp android_serials_from_results(results) do + Enum.find_value(results, [], fn + {:ok, "Android", %{serials: serials, deploy_lock: %{}}} -> serials + {:error, "Android", _reason, %{serials: serials}, :partial_update} -> serials + _build_only_or_absent -> nil + end) + end + + defp android_deploy_lock_from_results(results) do + Enum.find_value(results, fn + {:ok, "Android", %{deploy_lock: lock}} -> lock + {:error, "Android", _reason, lock, :partial_update} -> lock + {:error, "Android", _reason, lock} -> lock + _result -> nil + end) + end + + defp android_payload_plan_from_results(results) do + Enum.find_value(results, fn + {:ok, "Android", %{payload_plan: plan}} -> plan + _result -> nil + end) + end + + defp cleanup_successful_android_payloads(results, opts) do + case Keyword.get(opts, :android_preinstall_cleanup) do + cleanup when is_function(cleanup, 1) -> + results + |> Enum.flat_map(fn + {:ok, "Android", %{payload_plan: plan}} when is_map(plan) -> [plan] + _result -> [] + end) + |> Enum.uniq() + |> Enum.reduce_while(:ok, fn plan, :ok -> + case cleanup_android_payload(cleanup, plan) do + :ok -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} end end) - {out, _} -> - {:error, "adb devices failed: #{out}"} + + _missing -> + :ok end end - defp push_otp_to_device(serial, bundle_id, app_data, otp_dir, elixir_lib) do - adb = fn args -> System.cmd("adb", ["-s", serial | args], stderr_to_stdout: true) end + # Phase 2 iter 8: invoke build.zig per-ABI before Gradle. Produces + # android/app/build/zig-out/<abi>/driver_tab_android.o which CMakeLists.txt + # picks up via its `if(EXISTS ${ZIG_DRIVER_TAB_O})` check; the per-ABI + # path is what CMake's ${ANDROID_ABI} variable resolves to. + # + # Skips silently if the project has no jni/build.zig (older projects from + # before this iter still work via CMake compiling driver_tab_android.c + # directly through the Phase 0 fallback). + defp zig_build_android_objects(mob_dir, otp_arm64, otp_arm32, otp_x86_64) do + build_zig = "android/app/src/main/jni/build.zig" + # The CMake fallback (used when zig can't run) tries to compile this C + # source straight out of the mob dep. mob 0.7+ ships it as .zig instead, + # so on a current mob the fallback is a dead end; see zig_build_plan/3. + legacy_c = Path.join(mob_dir, "android/jni/mob_nif.c") - # Launch briefly so the app creates its files directory, then stop. - adb.(["shell", "am", "start", "-n", "#{bundle_id}/.MainActivity"]) - :timer.sleep(2000) - adb.(["shell", "am", "force-stop", bundle_id]) - :timer.sleep(500) + case zig_build_plan(File.exists?(build_zig), zig_available?(), File.exists?(legacy_c)) do + :skip_no_build_zig -> + :ok - case adb.(["root"]) do - {out, 0} -> - if out =~ "restarting" or out =~ "already running as root" do - :timer.sleep(1000) - push_otp_root(adb, app_data, otp_dir, elixir_lib) - else - push_otp_runas(serial, bundle_id, app_data, otp_dir, elixir_lib) + :legacy_cmake -> + IO.puts( + " #{IO.ANSI.yellow()}zig not on PATH — skipping build.zig step (CMake will compile sources directly)#{IO.ANSI.reset()}" + ) + + :ok + + :zig_required -> + {:error, zig_required_message()} + + :run_zig -> + driver_tab = resolve_driver_tab_android(mob_dir) + erts_vsn = detect_erts_vsn(otp_arm64) || "erts-17.0" + + IO.puts(" Compiling Android C objects via zig build (per-ABI)...") + + # Cross-compile project Rust/Zig NIFs once per ABI. Each + # invocation targets `aarch64-linux-android` or + # `armv7-linux-androideabi` (Rust) / `arm-linux-androideabi` + # (Zig) and produces its own per-target `.a` paths. NIFs whose + # `mob.exs` `:archs` entry lists `[:android_arm64]` only + # appear in the arm64 build; same for arm32; `[:all]` and + # `[:android]` land in both. The per-ABI build.zig invocation + # in `run_zig_android_objects` then receives the right archive + # set and links them into its `lib<app>.so`. + with {:ok, arm64_nif_args} <- project_nif_zig_args(:android_arm64), + {:ok, arm32_nif_args} <- project_nif_zig_args(:android_arm32), + {:ok, x86_64_nif_args} <- project_nif_zig_args(:android_x86_64), + {:ok, arm64_nxeigen} <- maybe_build_nxeigen(:android_arm64), + {:ok, arm32_nxeigen} <- maybe_build_nxeigen(:android_arm32), + {:ok, arm64_tflite} <- maybe_build_tflite(:android_arm64), + {:ok, arm32_tflite} <- maybe_build_tflite(:android_arm32) do + build_zig_src = + case inject_page_size_flag(File.read!(build_zig)) do + {:already, src} -> + src + + {:patched, src} -> + File.write!(build_zig, src) + + IO.puts( + " Added 16 KB page-size alignment to #{build_zig} " <> + "(Android 15+ / Play requirement; build.zig predated the flag)." + ) + + src + + {:no_match, src} -> + IO.puts( + " #{IO.ANSI.yellow()}Could not auto-add the 16 KB page-size flag to " <> + "#{build_zig} — add -Wl,-z,max-page-size=16384 to the -shared link " <> + "manually, or regenerate build.zig from mob_new.#{IO.ANSI.reset()}" + ) + + src + end + + [ + {otp_arm64, "arm64-v8a", arm64_nif_args, arm64_nxeigen, arm64_tflite}, + {otp_arm32, "armeabi-v7a", arm32_nif_args, arm32_nxeigen, arm32_tflite}, + {otp_x86_64, "x86_64", x86_64_nif_args, nil, nil} + ] + |> Enum.filter(fn {_otp, abi, _nif, _nx, _tf} -> + build_zig_supports_abi?(build_zig_src, abi) || + warn_skip_abi(build_zig, abi) + end) + |> Enum.reduce_while(:ok, fn {otp_dir, abi, abi_nif_args, abi_nxeigen, abi_tflite}, + _acc -> + # Drop the TFLite runtime .so into jniLibs/<abi>/ alongside + # the static-NIF archive that gets linked into native-lib. + # No-op when TFLite isn't enabled. + :ok = copy_tflite_runtime_lib_android(abi_tflite, abi) + + case run_zig_android_objects( + build_zig, + abi, + otp_dir, + erts_vsn, + mob_dir, + driver_tab, + abi_nif_args, + abi_nxeigen, + abi_tflite + ) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) end - _ -> - push_otp_runas(serial, bundle_id, app_data, otp_dir, elixir_lib) end end - defp push_otp_root(adb, app_data, otp_dir, elixir_lib) do - try do - adb.(["shell", "mkdir -p #{app_data}/otp"]) + # Pure kernel behind zig_build_android_objects/4, extracted so the + # "obvious failure" case is testable without a toolchain or a device. + # Decides the JNI build step from the three facts that determine whether + # a native build can succeed at all: + # + # build_zig? does the project ship jni/build.zig? + # zig? is `zig` on PATH (so build.zig can actually run)? + # legacy_c? does the mob dep still ship the C JNI source the CMake + # fallback would compile (android/jni/mob_nif.c)? + # + # Outcomes: + # :skip_no_build_zig no build.zig, nothing for this step to do. + # :run_zig zig present, drive the real build.zig path. + # :legacy_cmake no zig, but the mob dep still has the C sources, + # so CMake can compile them directly (old mob). + # :zig_required no zig AND no C sources (mob 0.7+): the build + # cannot succeed, so fail fast with a clear cause + # instead of limping into a cryptic CMake error. + @doc false + @spec zig_build_plan(boolean(), boolean(), boolean()) :: + :skip_no_build_zig | :run_zig | :legacy_cmake | :zig_required + def zig_build_plan(build_zig?, zig?, legacy_c?) + def zig_build_plan(false, _zig?, _legacy_c?), do: :skip_no_build_zig + def zig_build_plan(true, true, _legacy_c?), do: :run_zig + def zig_build_plan(true, false, true), do: :legacy_cmake + def zig_build_plan(true, false, false), do: :zig_required - case adb.(["push", "#{otp_dir}/.", "#{app_data}/otp/"]) do - {_, 0} -> :ok - {out, _} -> throw({:error, "push OTP release failed: #{String.slice(out, -300, 300)}"}) - end + # The actionable error shown when an Android native build needs `zig` but + # it is not on PATH and the mob dep no longer ships the C fallback sources. + # Public so the test suite can pin the guidance without driving a build. + @doc false + @spec zig_required_message() :: String.t() + def zig_required_message do + """ + zig is not on your PATH, and this project's Android native build needs it. - adb.(["shell", "mkdir -p #{app_data}/otp/lib/elixir/ebin"]) - adb.(["shell", "mkdir -p #{app_data}/otp/lib/logger/ebin"]) + mob 0.7+ compiles the Android JNI layer with build.zig. The legacy CMake + fallback would reference C sources (deps/mob/android/jni/mob_nif.c) that no + longer ship with mob, so the build cannot succeed without zig. - case adb.(["push", "#{elixir_lib}/elixir/ebin/.", "#{app_data}/otp/lib/elixir/ebin/"]) do - {_, 0} -> :ok - {out, _} -> throw({:error, "push elixir failed: #{String.slice(out, -300, 300)}"}) - end + Install zig 0.15.x, then re-run `mix mob.deploy --native --android`: + asdf: asdf plugin add zig && asdf install zig 0.15.2 && asdf global zig 0.15.2 + manual: https://ziglang.org/download/ (then put `zig` on your PATH) - case adb.(["push", "#{elixir_lib}/logger/ebin/.", "#{app_data}/otp/lib/logger/ebin/"]) do - {_, 0} -> :ok - {out, _} -> throw({:error, "push logger failed: #{String.slice(out, -300, 300)}"}) - end + Verify your toolchain any time with `mix mob.doctor`.\ + """ + end + + # True if the app's build.zig handles `abi`. mob_dev builds all of + # arm64-v8a/armeabi-v7a/x86_64 by default, but an app's app-owned build.zig + # (copied at `mix mob.new` time) may predate x86_64 support (mob_new < 0.4.5) + # and reject it, which used to fail the whole native build — aborting before + # the plugin-bootstrap regen. Each handled ABI appears as a quoted string + # literal in the build.zig's abi_to_target/ndk_arch_triple switches, so check + # for that. Safe to skip: gradle abiFilters won't ship an ABI the build.zig + # can't compile. Real failures of a SUPPORTED ABI still halt the build. + @doc false + @spec build_zig_supports_abi?(String.t(), String.t()) :: boolean() + def build_zig_supports_abi?(build_zig_src, abi) do + String.contains?(build_zig_src, ~s("#{abi}")) + end - # Fix ownership so the app can read its own files. - {uid_out, _} = adb.(["shell", "stat -c %u #{app_data}/.."]) - uid = String.trim(uid_out) - if uid != "", do: adb.(["shell", "chown -R #{uid}:#{uid} #{app_data}"]) + defp warn_skip_abi(build_zig, abi) do + IO.puts( + " #{IO.ANSI.yellow()}Skipping ABI #{abi}: not handled by #{build_zig} " <> + "(regenerate from mob_new >= 0.4.5 to add x86_64).#{IO.ANSI.reset()}" + ) - :ok - catch - {:error, reason} -> {:error, reason} + false + end + + # The app's `-shared` link command, and that command with the 16 KB page-size + # flag added. Android 15+ devices use 16 KB memory pages; Google Play requires + # every bundled .so to have 16 KB-aligned LOAD segments. New apps get this from + # the mob_new template, but an app-owned build.zig copied at `mix mob.new` time + # predates the flag and links 4 KB-aligned .so. The link line is identical + # across template versions, so we patch the command-array form (independent of + # the `run`/var name and any later addArg lines). + @shared_link ", \"-shared\" })" + @shared_link_aligned ", \"-shared\", \"-Wl,-z,max-page-size=16384\" })" + + # Ensure the app's build.zig links 16 KB-aligned .so. Returns the (possibly + # patched) source plus a status: `:already` (flag present), `:patched` (flag + # injected into the -shared link), or `:no_match` (link line not recognized — + # can't auto-fix). Pure; the caller writes the file + logs. + @doc false + @spec inject_page_size_flag(String.t()) :: {:already | :patched | :no_match, String.t()} + def inject_page_size_flag(build_zig_src) do + cond do + String.contains?(build_zig_src, "max-page-size") -> + {:already, build_zig_src} + + String.contains?(build_zig_src, @shared_link) -> + {:patched, String.replace(build_zig_src, @shared_link, @shared_link_aligned)} + + true -> + {:no_match, build_zig_src} end end - defp push_otp_runas(serial, bundle_id, app_data, otp_dir, elixir_lib) do - stage_local = Path.join(System.tmp_dir!(), "mob_otp_#{serial}.tar") - stage_device = "/data/local/tmp/mob_otp.tar" + defp run_zig_android_objects( + build_zig, + abi, + otp_dir, + erts_vsn, + mob_dir, + driver_tab, + nif_args, + nxeigen_archive, + tflite_build + ) do + app_name = Mix.Project.config() |> Keyword.fetch!(:app) |> Atom.to_string() + project_root = Path.expand(".") + project_jni_dir = Path.join(project_root, "android/app/src/main/jni") + jni_libs_abi = Path.join([project_root, "android/app/src/main/jniLibs", abi]) + File.mkdir_p!(jni_libs_abi) - try do - tmp = Path.join(System.tmp_dir!(), "mob_otp_stage_#{serial}") - File.rm_rf!(tmp) - otp_tmp = Path.join(tmp, "otp") - File.mkdir_p!(otp_tmp) + # Activated plugins' C NIF sources (one .c per nif, named after the NIF + # module). Empty when no NIF-bearing plugin is activated; the build.zig + # template orelse's "" so the flag is always safe to emit. + plugin_c_nifs = + MobDev.Plugin.Merge.nif_sources(MobDev.Plugin.activated(), :android) |> Enum.join(",") - System.cmd("cp", ["-r", "#{otp_dir}/.", otp_tmp], stderr_to_stdout: true) + # Activated plugins' zig NIF sources (one .zig per nif, lang: :zig). Same + # shape as plugin_c_nifs but compiled via addZigObject with named-module + # imports for mob-core bindings. Empty when no zig-NIF plugin is activated. + plugin_zig_nifs = + MobDev.Plugin.Merge.zig_nif_sources(MobDev.Plugin.activated(), :android) |> Enum.join(",") - File.mkdir_p!(Path.join(otp_tmp, "lib/elixir/ebin")) - File.mkdir_p!(Path.join(otp_tmp, "lib/logger/ebin")) - System.cmd("cp", ["-r", "#{elixir_lib}/elixir/ebin/.", Path.join(otp_tmp, "lib/elixir/ebin")], - stderr_to_stdout: true) - System.cmd("cp", ["-r", "#{elixir_lib}/logger/ebin/.", Path.join(otp_tmp, "lib/logger/ebin")], - stderr_to_stdout: true) + # Activated plugins' JNI-thunk C sources (android.jni_source). Plain C + # objects (no NIF-init libname) compiled + linked into the app .so so a + # plugin's Java_<pkg>_<Class>_* thunks resolve. Empty when none. + plugin_jni_sources = + MobDev.Plugin.Merge.jni_sources(MobDev.Plugin.activated()) |> Enum.join(",") - case System.cmd("tar", ["cf", stage_local, "-C", tmp, "otp"], stderr_to_stdout: true) do - {_, 0} -> :ok - {out, _} -> throw({:error, "tar create failed: #{out}"}) - end + base_args = [ + "build", + "native-lib", + "--build-file", + build_zig, + "--prefix", + "android/app/build/zig-out", + "-Dabi=#{abi}", + "-Dotp_dir=#{otp_dir}", + "-Derts_vsn=#{erts_vsn}", + "-Dmob_dir=#{mob_dir}", + "-Ddriver_tab=#{driver_tab}", + "-Dproject_jni_dir=#{project_jni_dir}", + "-Dndk_sysroot=#{ndk_sysroot()}", + "-Dapp_name=#{app_name}", + "-Dproject_root=#{project_root}", + "-Dexqlite_src=#{Path.join(project_root, "deps/exqlite/c_src")}" + ] - case System.cmd("adb", ["-s", serial, "push", stage_local, stage_device], - stderr_to_stdout: true) do - {_, 0} -> :ok - {out, _} -> throw({:error, "adb push failed: #{out}"}) + # Only emit -Dplugin_* when non-empty. A plugin-aware build.zig defaults + # these to "" (so omitting them is equivalent there), but an app scaffolded + # before the plugin system has no such option and Zig rejects the unknown + # -D flag. Gating keeps non-plugin apps on older mob scaffolding building. + plugin_args = + for {name, val} <- [ + {"plugin_c_nifs", plugin_c_nifs}, + {"plugin_zig_nifs", plugin_zig_nifs}, + {"plugin_jni_sources", plugin_jni_sources} + ], + val != "", + do: "-D#{name}=#{val}" + + # `project_nif_zig_args/1` also emits `-Dproject_root=` (since the + # iOS templates need it and don't have a baseline equivalent). The + # Android base_args above already supply it for the existing + # jniLibs install path, so drop the duplicate from `nif_args` + # before concatenating — Zig 0.16's option parser rejects + # `-Dproject_root=...` appearing twice with "expected a string, + # but received a list". + nif_args_no_root = Enum.reject(nif_args, &String.starts_with?(&1, "-Dproject_root=")) + + # Activated plugins' cpp_archive NIFs (e.g. an Nx CPU backend): each + # cross-compiled to lib<mod>.a for this ABI and static-linked via + # -Dplugin_static_libs. {:ok, []} when no such plugin is active; raises a + # hard build error when one IS active on an ABI CppArchive can't target + # (x86_64 emulator) rather than shipping an unresolved-symbol link failure. + plugin_archive_result = + case android_abi_to_cpp_target(abi) do + nil -> {:ok, []} + target_id -> build_plugin_static_archives(target_id, :android, otp_dir) end - cmd = "run-as #{bundle_id} mkdir -p #{app_data} && " <> - "run-as #{bundle_id} tar xf #{stage_device} -C #{app_data}" - case System.cmd("adb", ["-s", serial, "shell", cmd], stderr_to_stdout: true) do + with {:ok, plugin_archives} <- plugin_archive_result do + args = + base_args ++ + plugin_args ++ + nif_args_no_root ++ + nxeigen_zig_args_android(nxeigen_archive) ++ + tflite_zig_args_android(tflite_build) ++ + plugin_static_lib_args(plugin_archives) + + case System.cmd("zig", args, stderr_to_stdout: true, into: IO.stream()) do {_, 0} -> :ok - {out, _} -> throw({:error, "run-as tar failed: #{out}"}) + {_, code} -> {:error, "zig build for #{abi} exited #{code}"} end - - System.cmd("adb", ["-s", serial, "shell", "rm -f #{stage_device}"], stderr_to_stdout: true) - :ok - catch - {:error, reason} -> {:error, reason} - after - File.rm(stage_local) - File.rm_rf(Path.join(System.tmp_dir!(), "mob_otp_stage_#{serial}")) end end - defp parse_adb_serials(output) do - output - |> String.split("\n") - |> Enum.drop(1) - |> Enum.filter(&String.contains?(&1, "\tdevice")) - |> Enum.map(&hd(String.split(&1, "\t"))) + # Single source of truth in MobDev.NdkVersion (honors ANDROID_HOME / + # ANDROID_SDK_ROOT + host detection) — shared with cpp_archive / nx_eigen_nif + # so the NDK path can't diverge again (MOB-89). + defp ndk_sysroot, do: MobDev.NdkVersion.sysroot() + + defp resolve_driver_tab_android(mob_dir) do + resolve_driver_tab(mob_dir, "android", ["android", "jni"]) end - # ── iOS ────────────────────────────────────────────────────────────────────── + defp resolve_driver_tab_ios(mob_dir) do + resolve_driver_tab(mob_dir, "ios", ["ios"]) + end - defp build_ios(cfg) do - with :ok <- check_path(cfg[:mob_dir], "mob_dir"), - :ok <- check_path(cfg[:elixir_lib], "elixir_lib"), - {:ok, otp_root} <- MobDev.OtpDownloader.ensure_ios_sim() do - IO.puts(" Building iOS simulator app...") + defp resolve_driver_tab(mob_dir, platform, mob_subdir) do + # Prefer .zig (Phase 6a) > generated .c > mob's reference .zig > .c. + # The build.zig auto-detects extension via `addZigObject` vs + # `addCObject`, so either extension flows through correctly. + generated_zig = "priv/generated/driver_tab_#{platform}.zig" + generated_c = "priv/generated/driver_tab_#{platform}.c" - env = [ - {"MOB_DIR", Path.expand(cfg[:mob_dir])}, - {"MOB_ELIXIR_LIB", Path.expand(cfg[:elixir_lib])}, - {"MOB_IOS_OTP_ROOT", otp_root} - ] + mob_zig = + Path.join(mob_subdir ++ ["driver_tab_#{platform}.zig"]) |> then(&Path.join([mob_dir, &1])) - case System.cmd("bash", ["ios/build.sh"], env: env, stderr_to_stdout: true, into: IO.stream()) do - {_, 0} -> {:ok, "iOS"} - {_, _} -> {:error, "iOS", "build.sh failed — check output above"} - end - else - {:error, reason} -> {:error, "iOS", reason} + mob_c = + Path.join(mob_subdir ++ ["driver_tab_#{platform}.c"]) |> then(&Path.join([mob_dir, &1])) + + cond do + File.exists?(generated_zig) -> Path.expand(generated_zig) + File.exists?(generated_c) -> Path.expand(generated_c) + File.exists?(mob_zig) -> mob_zig + true -> mob_c end end - # ── Config ─────────────────────────────────────────────────────────────────── + defp detect_erts_vsn(otp_dir) do + case File.ls(otp_dir) do + {:ok, entries} -> + entries + |> Enum.filter(&String.starts_with?(&1, "erts-")) + |> Enum.sort(:desc) + |> List.first() - defp load_config do - config_file = Path.join(File.cwd!(), "mob.exs") + _ -> + nil + end + end - unless File.exists?(config_file) do - Mix.raise(""" - mob.exs not found in #{File.cwd!()}. + defp zig_available?, do: not is_nil(System.find_executable("zig")) - Run `mix mob.install` to configure your project, or - `mix mob.doctor` to diagnose your environment. - """) + # Downloads Chaquopy's CPython distribution iff Pythonx is a dep. + # Returns `{:ok, nil}` for projects without Pythonx so the rest of the + # Android pipeline runs unchanged. + defp maybe_ensure_python_android_bundle do + if pythonx_in_project?() do + MobDev.PythonAndroidSupport.ensure() + else + {:ok, nil} end + end - cfg = Config.Reader.read!(config_file) |> Keyword.get(:mob_dev, []) + # Copies Chaquopy's libpython*.so + bundled OpenSSL/SQLite into the + # user's android/app/src/main/jniLibs/<abi>/ tree so the APK packager + # picks them up. lib-dynload + stdlib go into assets/python/ for + # runtime extraction by the user's app on first launch. + # + # No-op when bundle is nil (project without Pythonx). + # + # NOTE: this places the Python distribution but does NOT yet + # cross-compile libpythonx.so (the Pythonx NIF) for Android NDK. + # Without that, Pythonx.NIF.__on_load__/0 raises at runtime. The NDK + # cross-compile is the next piece — see python_embedding guide. + @android_python_abis ~w(arm64-v8a x86_64) + defp ensure_python_android_libs(nil), do: :ok - # elixir_lib is always detectable from the running BEAM — no need to store it - # in mob.exs. If the stored value is missing or stale (e.g. after a version - # upgrade or on a different developer's machine), detect it automatically. - elixir_lib = resolve_elixir_lib(cfg[:elixir_lib]) - Keyword.put(cfg, :elixir_lib, elixir_lib) + defp ensure_python_android_libs(bundle_dir) do + Enum.each(@android_python_abis, fn abi -> + copy_python_jni_libs(bundle_dir, abi) + cross_compile_libpythonx_android(abi, bundle_dir) + end) + + generate_android_enif_keepalive() + install_pythonx_otp_lib_android() + copy_python_assets(bundle_dir) + :ok end - # Use the mob.exs value if it exists on disk; otherwise detect from the running BEAM. - defp resolve_elixir_lib(configured) when is_binary(configured) do - expanded = Path.expand(configured) - if File.exists?(expanded), do: configured, else: detect_elixir_lib() + # Mirrors the iOS enif_* keep-alive table. Without it, --gc-sections + # in the user's CMakeLists strips enif_* symbols from + # libpython_android_test.so, and dlopen of libpythonx.so fails at + # runtime with "cannot locate symbol enif_keep_resource". + # + # Generates `android/app/src/main/jni/enif_keepalive.c` with one + # __attribute__((used)) reference per `T _enif_*` exported by + # erl_nif.o inside the Android OTP cache's libbeam.a. The CMakeLists + # template (in mob_new) picks it up if present. + defp generate_android_enif_keepalive do + otp_dir = MobDev.OtpDownloader.android_otp_dir("arm64-v8a") + + libbeam = + Path.wildcard("#{otp_dir}/erts-*/lib/libbeam.a") + |> List.first() + + cond do + is_nil(libbeam) or not File.exists?(libbeam) -> + :ok + + true -> + # macOS BSD `ar` chokes on the Linux-format archive Mob ships + # (entries listed as "erl_nif.o/" with trailing slash). Use the + # NDK's llvm-ar / llvm-nm, which handle either format cleanly. + sdk_root = MobDev.NdkVersion.sdk_root() + ndk_version = MobDev.NdkVersion.effective() + + bin = + Path.join([ + sdk_root || "", + "ndk", + ndk_version, + "toolchains", + "llvm", + "prebuilt", + android_ndk_host(), + "bin" + ]) + + ar = Path.join(bin, "llvm-ar") + nm = Path.join(bin, "llvm-nm") + + if File.regular?(ar) and File.regular?(nm) do + generate_android_enif_keepalive_with(ar, nm, libbeam) + else + IO.puts(" ⚠ NDK llvm-ar / llvm-nm not found — skipping enif_* keep-alive table") + :ok + end + end end - defp resolve_elixir_lib(_), do: detect_elixir_lib() - defp detect_elixir_lib do - :code.lib_dir(:elixir) |> to_string() |> Path.dirname() + defp generate_android_enif_keepalive_with(ar, nm, libbeam) do + tmp = System.tmp_dir!() |> Path.join("mob_enif_extract_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + + System.cmd(ar, ["x", libbeam, "erl_nif.o"], cd: tmp, stderr_to_stdout: true) + erl_nif_o = Path.join(tmp, "erl_nif.o") + + if File.exists?(erl_nif_o) do + {nm_out, _} = System.cmd(nm, [erl_nif_o], stderr_to_stdout: true) + + symbols = + nm_out + |> String.split("\n") + |> Enum.filter(&Regex.match?(Regex.compile!(~S{\sT\senif_}), &1)) + |> Enum.map(fn line -> line |> String.split() |> List.last() end) + |> Enum.uniq() + + out = "android/app/src/main/jni/enif_keepalive.c" + File.mkdir_p!(Path.dirname(out)) + File.write!(out, generate_android_enif_keepalive_source(symbols)) + IO.puts(" ✓ generated #{out} (#{length(symbols)} enif_* symbols pinned)") + end + + File.rm_rf(tmp) + :ok end - # ── Helpers ────────────────────────────────────────────────────────────────── + defp generate_android_enif_keepalive_source(symbols) do + refs = + symbols + |> Enum.map_join("\n", fn sym -> + "extern void #{sym}(void); __attribute__((used)) static void *_keep_#{sym} = (void *)&#{sym};" + end) - defp check_path(path, key) do - expanded = if is_binary(path), do: Path.expand(path), else: path + """ + /* Auto-generated by mob_dev/native_build.ex. + * References every enif_* symbol exported by libbeam.a's erl_nif.o + * so the user's CMakeLists --gc-sections doesn't strip them. Without + * these references, dlopen of dynamic NIFs (libpythonx.so) fails at + * runtime with "cannot locate symbol enif_*". + * + * Regenerated on every `mix mob.deploy --native --device <android>`. + */ + #{refs} + """ + end + + # Mirrors the "Installing pythonx as OTP library" step in iOS's + # build_device.sh: places the pythonx beams + .app into + # <otp_arm64>/lib/pythonx-VSN/ebin and copies libpythonx.so to + # priv/, so `:code.priv_dir(:pythonx)` resolves on device once the + # OTP runtime is pushed. + # + # Only the arm64-v8a NIF goes into priv/ — Mob's Android OTP cache + # is per-cache, not per-device. x86_64 emulator support would require + # a per-device push (TODO). Apple Silicon Macs run arm64 Android + # emulators natively, so the arm64-only restriction is rarely felt. + defp install_pythonx_otp_lib_android do + pythonx_vsn = read_pythonx_version() + + if pythonx_vsn do + otp_dir = MobDev.OtpDownloader.android_otp_dir("arm64-v8a") + pythonx_lib_dir = Path.join([otp_dir, "lib", "pythonx-#{pythonx_vsn}"]) + + File.rm_rf!(pythonx_lib_dir) + File.mkdir_p!(Path.join(pythonx_lib_dir, "ebin")) + File.mkdir_p!(Path.join(pythonx_lib_dir, "priv")) + + Path.wildcard("_build/dev/lib/pythonx/ebin/*.beam") + |> Enum.each(fn src -> + cp(src, Path.join([pythonx_lib_dir, "ebin", Path.basename(src)])) + end) + + if File.exists?("_build/dev/lib/pythonx/ebin/pythonx.app") do + cp( + "_build/dev/lib/pythonx/ebin/pythonx.app", + Path.join([pythonx_lib_dir, "ebin", "pythonx.app"]) + ) + end + + src = "android/app/src/main/jniLibs/arm64-v8a/libpythonx.so" + + if File.exists?(src) do + cp(src, Path.join([pythonx_lib_dir, "priv", "libpythonx.so"])) + end + end + + :ok + end + + defp read_pythonx_version do cond do - is_nil(path) or path =~ "/path/to/" -> - {:error, "#{key} not configured in mob.exs — run `mix mob.doctor` for setup help"} - not File.exists?(expanded) -> - {:error, "#{key} path not found: #{path} — run `mix mob.doctor` to diagnose"} + File.exists?("_build/dev/lib/pythonx/ebin/pythonx.app") -> + case File.read("_build/dev/lib/pythonx/ebin/pythonx.app") do + {:ok, content} -> + case Regex.run(Regex.compile!(~S<{vsn,\s*"([^"]+)"}>), content) do + [_, vsn] -> vsn + _ -> nil + end + + _ -> + nil + end + + true -> + nil + end + end + + # Cross-compiles Pythonx's NIF (deps/pythonx/c_src/{pythonx,python}.cpp) + # for Android against the NDK and the Chaquopy headers. Output drops + # into android/app/src/main/jniLibs/<abi>/libpythonx.so so the APK + # packager picks it up alongside the OTP runtime helper libs. + # + # Pythonx's design — runtime dlopen+dlsym for libpython AND enif_* + # symbols resolved by the loaded BEAM — means libpythonx.so has many + # undefined symbols at link time. `--unresolved-symbols=ignore-all` + # tells lld this is intentional. Apps that load the NIF via + # `:erlang.load_nif/2` resolve enif_* against the host BEAM, and + # Pythonx.init/4 resolves Py* against the dlopen'd libpython.so. + defp cross_compile_libpythonx_android(abi, bundle_dir) do + pythonx_src = "deps/pythonx/c_src" + + if File.dir?(pythonx_src) do + ndk_version = MobDev.NdkVersion.effective() + sdk_root = MobDev.NdkVersion.sdk_root() + + cond do + is_nil(sdk_root) -> + IO.puts(" ⚠ Android SDK not found — skipping libpythonx.so cross-compile") + :ok + + not MobDev.NdkVersion.installed?(ndk_version) -> + IO.puts(" ⚠ Android NDK #{ndk_version} not installed — skipping libpythonx.so") + IO.puts(" Install with: #{MobDev.NdkVersion.install_command()}") + :ok + + true -> + do_cross_compile_libpythonx_android(abi, bundle_dir, sdk_root, ndk_version) + end + else + :ok + end + end + + defp do_cross_compile_libpythonx_android(abi, bundle_dir, sdk_root, ndk_version) do + triple = android_triple(abi) + api = android_api_level() + host = android_ndk_host() + + bin_dir = + Path.join([sdk_root, "ndk", ndk_version, "toolchains", "llvm", "prebuilt", host, "bin"]) + + clang = Path.join(bin_dir, "#{triple}#{api}-clang++") + + unless File.regular?(clang) do + IO.puts(" ⚠ NDK clang++ not found at #{clang} — skipping libpythonx.so") + :ok + end + + pythonx_src = "deps/pythonx/c_src" + fine_inc = "deps/fine/c_include" + python_inc = MobDev.PythonAndroidSupport.headers_dir(bundle_dir, abi) + + erts_inc = + Path.wildcard("#{MobDev.OtpDownloader.android_otp_dir()}/erts-*/include") + |> List.first() + + out = "android/app/src/main/jniLibs/#{abi}/libpythonx.so" + File.mkdir_p!(Path.dirname(out)) + + # libpythonx.so references enif_* symbols defined by the user's + # libpython_android_test.so (via libbeam.a, statically linked into + # it by Gradle). For Android's loader to resolve those at dlopen + # time, libpythonx.so needs a `NEEDED libpython_android_test.so` + # entry — otherwise the lookup happens in the wrong namespace and + # fails with "cannot locate symbol enif_*". + # + # The real lib is built by Gradle AFTER this step. We work around + # the chicken-and-egg with a tiny stub library that exports the + # enif_* symbols and carries SONAME=libpython_android_test.so. At + # runtime Android resolves the NEEDED entry by SONAME match, so + # the real lib (already loaded via System.loadLibrary) provides + # the implementations. + stub_so = build_libpython_android_test_stub_so(bin_dir, triple, api, abi) + + # `-l<name>` looks for `lib<name>.so` — derive `<name>` from the + # actual stub SONAME we just built so projects whose main lib + # isn't `libpython_android_test.so` (every real project) still + # link. Strip the `lib` prefix and `.so` suffix. + stub_lib_name = + if stub_so do + stub_so + |> Path.basename() + |> String.replace_prefix("lib", "") + |> String.replace_suffix(".so", "") + end + + # Static-link libc++ so the resulting .so doesn't depend on + # libc++_shared.so being in the app's jniLibs/. + # Link against the stub: gives libpythonx.so a NEEDED + # lib<app>.so entry plus link-time symbol resolution for enif_*. + # The stub itself is discarded after link. + args = + ["-shared", "-fPIC", "-fvisibility=hidden", "-std=c++17", "-Os"] ++ + ["-ffunction-sections", "-fdata-sections"] ++ + ["-static-libstdc++"] ++ + ["-I", erts_inc, "-I", "#{erts_inc}/internal"] ++ + ["-I", fine_inc, "-I", python_inc] ++ + ["-Wno-unused-parameter", "-Wno-comment"] ++ + if(stub_so, do: ["-L", Path.dirname(stub_so), "-l#{stub_lib_name}"], else: []) ++ + ["#{pythonx_src}/pythonx.cpp", "#{pythonx_src}/python.cpp"] ++ + ["-o", out] + + case System.cmd(clang, args, stderr_to_stdout: true) do + {_, 0} -> + IO.puts(" ✓ cross-compiled #{out}") + if stub_so, do: File.rm_rf!(Path.dirname(stub_so)) + :ok + + {output, code} -> + if stub_so, do: File.rm_rf!(Path.dirname(stub_so)) + IO.puts(:stderr, " ✗ libpythonx.so cross-compile failed (exit #{code})") + IO.puts(:stderr, output) + {:error, "libpythonx.so cross-compile failed for #{abi}"} + end + end + + # Builds a tiny stub `libpython_android_test.so` (or whatever the user's + # main lib is called, which we read from the keepalive .c we generated) + # that exports the enif_* symbols libpythonx.so references. Link-time only; + # the real lib provides symbols at runtime via SONAME match. + defp build_libpython_android_test_stub_so(bin_dir, triple, api, abi) do + cc = Path.join(bin_dir, "#{triple}#{api}-clang") + keepalive = "android/app/src/main/jni/enif_keepalive.c" + + if File.regular?(cc) and File.exists?(keepalive) do + build_stub_with_symbols(cc, keepalive, abi) + end + end + + defp build_stub_with_symbols(cc, keepalive, abi) do + symbols = + keepalive + |> File.read!() + |> String.split("\n") + |> Enum.map(&Regex.run(Regex.compile!(~S{extern void (enif_\w+)}), &1)) + |> Enum.reject(&is_nil/1) + |> Enum.map(fn [_, name] -> name end) + |> Enum.uniq() + + if symbols != [] do + # Mob's main lib SONAME is `lib<app_name>.so`. Detect from the + # generated CMakeLists' `add_library` directive. + soname = detect_main_lib_soname() || "libpython_android_test.so" + + tmp = + System.tmp_dir!() + |> Path.join("mob_pythonx_stub_#{abi}_#{System.unique_integer([:positive])}") + + File.mkdir_p!(tmp) + stub_c = Path.join(tmp, "stub.c") + stub_so = Path.join(tmp, soname) + + body = Enum.map_join(symbols, "\n", fn sym -> "void #{sym}(void) {}" end) + File.write!(stub_c, body <> "\n") + + case System.cmd( + cc, + ["-shared", "-fPIC", "-Wl,-soname,#{soname}", stub_c, "-o", stub_so], + stderr_to_stdout: true + ) do + {_, 0} -> + stub_so + + _ -> + File.rm_rf!(tmp) + nil + end + end + end + + defp detect_main_lib_soname do + case File.read("android/app/src/main/jni/CMakeLists.txt") do + {:ok, content} -> + case Regex.run(Regex.compile!(~S{add_library\(\s*(\S+)\s+SHARED}), content) do + [_, name] -> "lib#{name}.so" + _ -> nil + end + + _ -> + nil + end + end + + defp android_triple("arm64-v8a"), do: "aarch64-linux-android" + defp android_triple("x86_64"), do: "x86_64-linux-android" + + # Match mob_new's android template `minSdk 28`. Using API 28 keeps + # the NIF compatible with the same device floor as the rest of the + # Mob-generated app code. + defp android_api_level, do: "28" + + # Pre-built NDK toolchains are named for the host that runs them. + # Mob is a macOS-first dev environment; Linux hosts use the same + # `darwin-x86_64` directory name on Apple Silicon thanks to Rosetta. + defp android_ndk_host do + case :os.type() do + {:unix, :darwin} -> "darwin-x86_64" + {:unix, _} -> "linux-x86_64" + _ -> "darwin-x86_64" + end + end + + defp copy_python_jni_libs(bundle_dir, abi) do + src_dir = MobDev.PythonAndroidSupport.jni_libs_dir(bundle_dir, abi) + dst_dir = "android/app/src/main/jniLibs/#{abi}" + File.mkdir_p!(dst_dir) + + if File.dir?(src_dir) do + Path.wildcard(Path.join(src_dir, "*.so")) + |> Enum.each(fn src -> + dst = Path.join(dst_dir, Path.basename(src)) + cp(src, dst) + end) + end + + copy_project_python_jni_libs(abi, dst_dir) + + :ok + end + + # Project-supplied native libs that need to land in jniLibs/<abi>/ + # rather than site-packages. Wheels for cffi-using packages + # (cryptography, etc.) reference `libffi.so` via a NEEDED entry, + # which the Android dynamic loader resolves out of the app's + # `nativeLibraryDir` — i.e. the jniLibs/<abi>/ contents. Putting + # the .so under filesDir/python/ doesn't help because the loader + # has already given up by the time Python imports happen. + # + # Convention: project drops <name>.so files into + # `priv/python_jni_libs/<abi>/`. Each one is copied into + # `android/app/src/main/jniLibs/<abi>/` verbatim. Mob doesn't try + # to know what's inside — that's the project's call. + defp copy_project_python_jni_libs(abi, dst_dir) do + src_dir = Path.join(["priv", "python_jni_libs", abi]) + + if File.dir?(src_dir) do + Path.wildcard(Path.join(src_dir, "*.so")) + |> Enum.each(fn src -> + dst = Path.join(dst_dir, Path.basename(src)) + cp(src, dst) + end) + end + + :ok + end + + # Place the (slice-independent) stdlib and per-abi lib-dynload into + # android/app/src/main/assets/python/. The APK packager will ship + # them as packaged assets; the user's app extracts them to internal + # storage on first launch (see <App>.PythonPaths). + defp copy_python_assets(bundle_dir) do + # Extract layout follows the PYTHONHOME contract: + # <filesDir>/python/lib/python3.13/ ← shared pure-Python stdlib + # <filesDir>/python/lib/python3.13/lib-dynload/<abi>/ ← arch-specific .so + # Mirrors iOS's <App>.app/otp/python/ layout — Python's own bootstrap + # walks `<home>/lib/pythonX.Y` to find encodings/ etc. before sys.path + # is even initialized. + assets_root = "android/app/src/main/assets/python" + File.mkdir_p!(assets_root) + + stdlib_src = MobDev.PythonAndroidSupport.stdlib_dir(bundle_dir) + stdlib_dst = Path.join([assets_root, "lib", "python3.13"]) + + if File.dir?(stdlib_src) and not File.dir?(stdlib_dst) do + File.mkdir_p!(stdlib_dst) + System.cmd("cp", ["-R", stdlib_src <> "/.", stdlib_dst]) + end + + Enum.each(@android_python_abis, fn abi -> + ld_src = MobDev.PythonAndroidSupport.lib_dynload_dir(bundle_dir, abi) + # lib-dynload nests under stdlib for Python's own discovery, but + # we keep per-abi subdirs since we ship multiple architectures. + ld_dst = Path.join([assets_root, "lib", "python3.13", "lib-dynload", abi]) + + if File.dir?(ld_src) and not File.dir?(ld_dst) do + File.mkdir_p!(ld_dst) + System.cmd("cp", ["-R", ld_src <> "/.", ld_dst]) + end + end) + + copy_project_python_wheels(assets_root) + + :ok + end + + # Drops project-supplied Python packages from `priv/python_wheels/` + # into the per-platform site-packages directory under `python_root`. + # + # `python_root` is the platform's Python install root: + # * Android: `android/app/src/main/assets/python` (APK assets dir) + # * iOS: `<otp_root>/python` (under the .app bundle) + # + # Both layouts share the `lib/python3.13/site-packages` suffix, so a + # single helper works for all three callers (`copy_python_assets/1` for + # Android, `maybe_setup_pythonx_sim/5` + `maybe_setup_pythonx_device/5` + # for iOS). + # + # Each subdirectory of `priv/python_wheels/` is treated as an + # already-extracted wheel — copy the directory contents directly into + # site-packages. Wheel-extraction is the project's job (the wheel + # format is package-specific and per-platform), but landing the + # extracted layout into the per-platform bundle is a generic step + # worth owning here so every Mob+Pythonx project doesn't reimplement + # asset placement. + # + # Layout convention: `priv/python_wheels/<wheel-name>/` contains the + # wheel's unzipped contents. A typical `cryptography-X.Y/` directory + # holds `cryptography/`, `cryptography-X.Y.dist-info/`, and any + # platform-specific `.so` / `.dylib` files. Everything inside gets + # copied verbatim — site-packages discovery handles the rest. + defp copy_project_python_wheels(python_root) do + wheels_dir = Path.join("priv", "python_wheels") + + if File.dir?(wheels_dir) do + site_packages = Path.join([python_root, "lib", "python3.13", "site-packages"]) + File.mkdir_p!(site_packages) + + wheels_dir + |> File.ls!() + |> Enum.each(fn entry -> + src = Path.join(wheels_dir, entry) + + if File.dir?(src) do + System.cmd("cp", ["-R", src <> "/.", site_packages]) + end + end) + end + + :ok + end + + @doc """ + iOS-flavoured counterpart to `copy_project_python_wheels/1`. Same + `priv/python_wheels/` convention, same site-packages destination, + but skips any wheel directory containing a `.so` file at any depth. + + Today's wheel set ships Android-built binaries (Chaquopy-compatible) + under names like `_cffi_backend.so` and `_rust.so` — no `"android"` + in the filename — so a name-based heuristic misses them. Until the + wheels directory holds platform-tagged subdirs (or an iOS-specific + source), treating "has any `.so`" as "Android-only, skip on iOS" + matches the current reality: pure-Python wheels (rns, lxmf, + pyserial, pycparser) are the only iOS-safe ones. RNS falls back to + its internal crypto provider when `cryptography` isn't importable, + so this is enough to bring the Reticulum stack up on iOS device + builds. + + Public so the iOS-specific filter can be tested independently of + the rest of the bundle pipeline. + """ + @spec copy_ios_safe_project_python_wheels(String.t(), String.t()) :: :ok + def copy_ios_safe_project_python_wheels(python_root, wheels_dir) do + if File.dir?(wheels_dir) do + site_packages = Path.join([python_root, "lib", "python3.13", "site-packages"]) + File.mkdir_p!(site_packages) + + wheels_dir + |> File.ls!() + |> Enum.each(fn entry -> + src = Path.join(wheels_dir, entry) + + cond do + not File.dir?(src) -> + :skip + + wheel_has_native_extension?(src) -> + IO.puts( + " [ios-wheels] skipped wheel with native extensions (assumed non-iOS): #{entry}" + ) + + true -> + IO.puts(" [ios-wheels] copied #{entry}") + System.cmd("cp", ["-R", src <> "/.", site_packages]) + end + end) + end + + :ok + end + + @doc """ + True if `wheel_dir` contains at least one `.so` file at any depth. + Used by `copy_ios_safe_project_python_wheels/2` to detect + Android-only wheels. + """ + @spec wheel_has_native_extension?(String.t()) :: boolean() + def wheel_has_native_extension?(wheel_dir) do + case Path.wildcard(Path.join([wheel_dir, "**", "*.so"])) do + [] -> false + _ -> true + end + end + + # Copies ERTS helper executables into jniLibs as lib*.so so Android grants + # them the apk_data_file SELinux label (required for execve). + defp ensure_jni_libs(otp_dir, abi) do + jni_libs = "android/app/src/main/jniLibs/#{abi}" + + with [erts_bins] <- Path.wildcard("#{otp_dir}/erts-*/bin"), + :ok <- validate_android_erts_helpers(erts_bins), + :ok <- mkdir_android_jni_libs(jni_libs) do + Enum.reduce_while(@android_erts_helpers, :ok, fn {exe, lib}, :ok -> + case replace_android_jni_helper( + Path.join(erts_bins, exe), + Path.join(jni_libs, lib) + ) do + :ok -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} + end + end) + else + {:error, _reason} = error -> error + _missing_or_ambiguous -> {:error, "Android ERTS helper source is missing or ambiguous"} + end + end + + defp validate_android_erts_helpers(erts_bins) do + if Enum.all?(@android_erts_helpers, fn {exe, _lib} -> + File.regular?(Path.join(erts_bins, exe)) + end) do + :ok + else + {:error, "Android ERTS helper source is incomplete"} + end + end + + defp mkdir_android_jni_libs(jni_libs) do + case File.mkdir_p(jni_libs) do + :ok -> :ok + {:error, _reason} -> {:error, "Could not prepare Android JNI library directory"} + end + end + + defp replace_android_jni_helper(source, destination) do + staged = + destination <> + ".mob-stage-#{System.unique_integer([:positive, :monotonic])}" + + try do + with :ok <- File.cp(source, staged), + :ok <- File.rename(staged, destination), + {:ok, source_bytes} <- File.read(source), + {:ok, destination_bytes} <- File.read(destination), + true <- + :crypto.hash(:sha256, source_bytes) == + :crypto.hash(:sha256, destination_bytes) do + :ok + else + _failure -> {:error, "Could not stage Android ERTS helpers"} + end + after + File.rm(staged) + end + end + + defp gradle_assemble do + IO.puts(" Running Gradle assembleDebug...") + IO.puts(" (first build may take a few minutes while CMake compiles native code)") + android_dir = Path.join(File.cwd!(), "android") + gradlew = Path.join(android_dir, "gradlew") + + # Stale Gradle daemon registry locks accumulate when builds are killed (Ctrl+C, + # force-stop, etc.) and cause subsequent runs to hang silently while the wrapper + # waits to acquire the lock. Clear them before every build. + clear_stale_gradle_locks() + remove_stale_release_otp_zip(android_dir) + + if File.exists?(gradlew) do + # Run gradlew as `bash scriptpath args` rather than exec-ing it directly + # or using `bash -c "cmd"`. + # + # When System.cmd exec's gradlew directly, Gradle's worker subprocesses + # may inherit the Erlang port's I/O pipes. If they outlive the main JVM, + # the pipe stays open and System.cmd never receives EOF — hanging forever. + # + # `bash scriptpath args` keeps bash as the parent process. bash exits when + # the script finishes (even if exec'd children remain), cleanly closing the + # pipe to Erlang. + # + # NOTE: Kotlin errors appear before "* What went wrong:" in the output. + # If the build fails, scroll up or run `cd android && ./gradlew assembleDebug`. + case System.cmd("bash", [gradlew, "assembleDebug", "--no-daemon"], + cd: android_dir, + stderr_to_stdout: true, + into: IO.stream() + ) do + {_, 0} -> + :ok + + {_, _} -> + {:error, + "Gradle failed — scroll up for errors\n (or run: cd android && ./gradlew assembleDebug)"} + end + else + {:error, "gradlew not found at #{gradlew}"} + end + end + + # `mix mob.release --android` used to write the release OTP bundle to the + # shared `src/main/assets/otp.zip`, which Gradle merges into every build + # variant including debug. A prior release build then poisons every + # subsequent debug deploy: MobBridge.kt's extractOtpIfNeeded() re-extracts + # that stale zip on the next app launch (keyed off PackageInfo.lastUpdateTime, + # which changes on every reinstall), silently overwriting freshly pushed dev + # BEAMs with the release snapshot. Release builds now write to the + # variant-scoped `src/release/assets/` instead (never merged into debug), but + # existing checkouts may still carry a leftover `src/main/assets/otp.zip` + # from before that fix — remove it so debug builds can't be poisoned by it. + @doc false + @spec remove_stale_release_otp_zip(String.t()) :: :ok + def remove_stale_release_otp_zip(android_dir) do + stale = Path.join([android_dir, "app", "src", "main", "assets", "otp.zip"]) + if File.exists?(stale), do: File.rm!(stale) + :ok + end + + # Remove stale Gradle lock files left behind when a build is interrupted + # (Ctrl+C, kill, etc.). These cause the next run to hang indefinitely while + # the wrapper waits to acquire the lock. + defp clear_stale_gradle_locks do + gradle_home = + System.get_env("GRADLE_USER_HOME") || + Path.join(System.user_home!(), ".gradle") + + patterns = [ + "#{gradle_home}/daemon/*/registry.bin.lock", + "#{gradle_home}/wrapper/dists/**/*.lck", + "#{gradle_home}/native/**/*.lock", + "#{gradle_home}/caches/**/*.lock", + "#{gradle_home}/caches/**/*.lck" + ] + + Enum.each(patterns, fn pattern -> + Path.wildcard(pattern, match_dot: true) |> Enum.each(&File.rm/1) + end) + end + + @doc false + @spec resolve_android_update_targets(String.t() | nil) :: + {:ok, [String.t()]} | {:error, atom()} + def resolve_android_update_targets(device_id) do + resolve_android_update_targets(device_id, &run_system_command/2) + end + + @doc false + @spec resolve_android_update_targets(String.t() | nil, command_runner()) :: + {:ok, [String.t()]} | {:error, atom()} + def resolve_android_update_targets(nil, runner) do + case invoke_command(runner, "adb", ["devices"]) do + {:ok, output, 0} -> resolve_adb_targets(output, nil) + _ -> {:error, :device_discovery_failed} + end + end + + def resolve_android_update_targets(device_id, runner) when is_binary(device_id) do + if valid_adb_serial?(device_id) do + case invoke_command(runner, "adb", ["devices"]) do + {:ok, output, 0} -> resolve_adb_targets(output, device_id) + _ -> {:error, :device_discovery_failed} + end + else + {:error, :invalid_target} + end + end + + def resolve_android_update_targets(_device_id, _runner), do: {:error, :invalid_target} + + @doc false + @deprecated "use install_and_deliver_android_runtime/8 with an authoritative payload plan" + @spec install_android_updates(String.t(), [String.t()]) :: + {:ok, android_update_outcome()} + | {:error, android_update_outcome() | atom()} + def install_android_updates(apk, serials) do + install_android_updates(apk, serials, &run_system_command/2) + end + + @doc false + @deprecated "use install_and_deliver_android_runtime/8 with an authoritative payload plan" + @spec install_android_updates(String.t(), [String.t()], command_runner()) :: + {:ok, android_update_outcome()} + | {:error, android_update_outcome() | atom()} + def install_android_updates(apk, serials, runner) + + def install_android_updates(apk, serials, _runner) + when not is_binary(apk) or apk == "" or not is_list(serials), + do: {:error, :invalid_update_request} + + def install_android_updates(_apk, [], _runner), do: {:error, :no_explicit_targets} + + def install_android_updates(_apk, serials, _runner) + when length(serials) > @max_android_update_targets, + do: {:error, :too_many_targets} + + def install_android_updates(_apk, serials, _runner) do + with :ok <- validate_android_update_serials(serials) do + {:error, :authoritative_transaction_required} + end + end + + @doc false + @deprecated "use install_and_deliver_android_runtime/8 with an authoritative payload plan" + @spec install_and_deliver_android( + String.t(), + [String.t()], + command_runner(), + (String.t() -> :ok | {:error, term()}) + ) :: :ok | {:error, String.t()} + def install_and_deliver_android(apk, serials, runner, _deliver) do + case install_android_updates(apk, serials, runner) do + {:error, reason} -> + {:error, android_update_request_error(reason)} + end + end + + @doc false + @spec install_and_deliver_android_runtime( + String.t(), + [String.t()], + String.t(), + String.t(), + String.t(), + String.t(), + String.t(), + keyword() + ) :: + {:ok, map()} + | {:error, String.t()} + | {:error, String.t(), map()} + | {:error, {:partial_update, String.t()}, map()} + def install_and_deliver_android_runtime( + apk, + serials, + bundle_id, + elixir_lib, + otp_arm64, + otp_arm32, + otp_x86_64, + opts \\ [] + ) do + runner = Keyword.get(opts, :probe_runner, &run_system_command/2) + manifest_runner = Keyword.get(opts, :manifest_runner, &run_system_command/2) + otp_runner = Keyword.get(opts, :otp_runner, &run_system_command/3) + app_data = "/data/data/#{bundle_id}/files" + + with :ok <- validate_android_bundle_id(bundle_id), + :ok <- validate_android_app_data(app_data, bundle_id), + {:ok, canonical_serials} <- canonical_android_runtime_serials(serials), + :ok <- preflight_android_otp_candidates(otp_arm64, otp_arm32, otp_x86_64, elixir_lib), + {:ok, preinstall, cleanup} <- android_preinstall_callbacks(opts), + {:ok, apk_snapshot} <- snapshot_android_apk(apk, opts) do + try do + with :ok <- validate_android_apk_identity(apk_snapshot.path, bundle_id, manifest_runner), + :ok <- preflight_installed_android_targets(canonical_serials, bundle_id, runner), + {:ok, selections} <- + select_android_otp_sources( + canonical_serials, + otp_arm64, + otp_arm32, + otp_x86_64, + runner + ), + {:ok, payload_plan} <- + invoke_android_preinstall( + preinstall, + cleanup, + bundle_id, + canonical_serials, + selections, + apk_snapshot + ) do + case Keyword.get(opts, :resume_native_ready, false) do + true -> + resume_android_native_ready( + payload_plan, + selections, + elixir_lib, + cleanup, + runner, + opts + ) + + false -> + run_android_runtime_transaction( + apk_snapshot, + canonical_serials, + bundle_id, + app_data, + elixir_lib, + selections, + payload_plan, + cleanup, + runner, + otp_runner, + opts + ) + + _invalid -> + cleanup_android_payload(cleanup, payload_plan) + {:error, "Invalid Android native-ready recovery option"} + end + end + after + File.rm(apk_snapshot.path) + end + end + end + + defp resume_android_native_ready(payload_plan, selections, elixir_lib, cleanup, runner, opts) do + adb_runner = fn args -> runner.("adb", args) end + + with {:ok, runtime_provenance} <- + android_recovery_runtime_provenance(payload_plan.serials, selections, elixir_lib) do + recovery_opts = + opts + |> Keyword.get(:android_recovery_opts, []) + |> Keyword.put(:runtime_provenance, runtime_provenance) + + case AndroidDeployRecoveryProof.resume(payload_plan, adb_runner, recovery_opts) do + {:ok, lease} -> + {:ok, %{deploy_lock: lease, payload_plan: payload_plan}} + + {:error, :recovery_cas_ambiguous, retained_lease} -> + cleanup_android_payload(cleanup, payload_plan) + {:error, "Android native-ready recovery became ambiguous", retained_lease} + + {:error, {:recovery_proof_refused, code}} -> + cleanup_android_payload(cleanup, payload_plan) + + {:error, + "Android native-ready recovery proof was refused (#{recovery_refusal_label(code)})"} + + {:error, _refused} -> + cleanup_android_payload(cleanup, payload_plan) + + {:error, + "Android native-ready recovery proof was refused (recovery_transition_refused)"} + end + else + _unproven_runtime -> + cleanup_android_payload(cleanup, payload_plan) + {:error, "Android native-ready recovery runtime provenance was refused"} + end + end + + defp recovery_refusal_label(code) + when code in [ + :payload_identity_invalid, + :payload_invalid, + :host_lock_unavailable, + :transport_identity_mismatch, + :lease_record_invalid, + :apk_signature_invalid, + :apk_identity_mismatch, + :runtime_provenance_mismatch, + :staging_not_clear, + :recovery_transition_refused + ], + do: Atom.to_string(code) + + defp recovery_refusal_label(_unknown), do: "recovery_transition_refused" + + defp android_recovery_runtime_provenance([serial], selections, elixir_lib) do + with %{otp_dir: otp_dir} <- Map.get(selections, serial), + {:ok, sentinels} <- android_runtime_sentinels(otp_dir, elixir_lib), + {:ok, provenance} <- hash_android_runtime_sentinels(sentinels, otp_dir, elixir_lib) do + {:ok, provenance} + else + _invalid_or_missing -> {:error, :runtime_provenance_unavailable} + end + end + + defp android_recovery_runtime_provenance(_serials, _selections, _elixir_lib), + do: {:error, :runtime_provenance_unavailable} + + defp hash_android_runtime_sentinels(sentinels, otp_dir, elixir_lib) do + Enum.reduce_while(sentinels, {:ok, []}, fn sentinel, {:ok, acc} -> + with {:ok, local_path} <- runtime_sentinel_local_path(sentinel, otp_dir, elixir_lib), + true <- File.regular?(local_path), + {:ok, digest} <- file_sha256(local_path) do + entry = %{path: sentinel, sha256: Base.encode16(digest, case: :lower)} + {:cont, {:ok, [entry | acc]}} + else + _missing_or_changed -> {:halt, {:error, :runtime_provenance_unavailable}} + end + end) + |> case do + {:ok, entries} -> {:ok, Enum.reverse(entries)} + error -> error + end + end + + defp runtime_sentinel_local_path("otp/erts-" <> _rest = sentinel, otp_dir, _elixir_lib), + do: {:ok, Path.join(otp_dir, String.replace_prefix(sentinel, "otp/", ""))} + + defp runtime_sentinel_local_path("otp/lib/" <> rest, _otp_dir, elixir_lib), + do: {:ok, Path.join(elixir_lib, rest)} + + defp runtime_sentinel_local_path(_sentinel, _otp_dir, _elixir_lib), + do: {:error, :runtime_provenance_unavailable} + + @doc false + @spec validate_android_recovery_payload(map()) :: :ok | {:error, :invalid_recovery_payload} + def validate_android_recovery_payload(payload_plan) when is_map(payload_plan) do + with %{ + version: 1, + package: package, + serials: serials, + selected_abis: selected_abis, + selected_abis_by_serial: selected_by_serial, + apk: %{path: apk_path, size: apk_size, sha256: apk_sha256} + } <- payload_plan, + input <- %{ + bundle_id: package, + serials: serials, + selected_abis: selected_abis, + selected_abis_by_serial: selected_by_serial, + apk: apk_path, + apk_size: apk_size, + apk_sha256: apk_sha256 + }, + {:ok, ^payload_plan} <- validate_android_payload_plan(payload_plan, input), + :ok <- validate_android_local_file_identity(payload_plan.apk, @max_android_apk_bytes), + selections <- Map.new(selected_by_serial, fn {serial, abi} -> {serial, %{abi: abi}} end), + :ok <- validate_android_apk_runtime(apk_path, selections, payload_plan) do + :ok + else + _invalid_or_changed -> {:error, :invalid_recovery_payload} + end + end + + def validate_android_recovery_payload(_payload_plan), + do: {:error, :invalid_recovery_payload} + + defp snapshot_android_apk(apk, opts) when is_binary(apk) do + tmp_root = Keyword.get(opts, :tmp_root, System.tmp_dir!()) + snapshot_id = :crypto.strong_rand_bytes(12) |> Base.url_encode64(padding: false) + snapshot = Path.join(tmp_root, "mob_apk_#{snapshot_id}.apk") + + with {:ok, %{size: source_size}} + when source_size > 0 and + source_size <= @max_android_apk_bytes <- + File.stat(apk), + :ok <- File.cp(apk, snapshot), + :ok <- File.chmod(snapshot, 0o400), + {:ok, %{size: ^source_size}} <- File.stat(snapshot), + {:ok, sha256} <- file_sha256(snapshot) do + {:ok, %{path: snapshot, size: source_size, sha256: sha256}} + else + _failure -> + File.rm(snapshot) + {:error, "Could not snapshot exact Android APK; refusing update"} + end + end + + defp snapshot_android_apk(_apk, _opts), + do: {:error, "Android APK path is invalid; refusing update"} + + defp file_sha256(path) do + case File.open(path, [:read, :binary], fn io -> + hash_file_chunks(io, :crypto.hash_init(:sha256)) + end) do + {:ok, digest} when is_binary(digest) -> {:ok, digest} + _failure -> {:error, :hash_failed} + end + end + + defp hash_file_chunks(io, context) do + case IO.binread(io, 1_048_576) do + :eof -> :crypto.hash_final(context) + {:error, _reason} -> {:error, :read_failed} + bytes when is_binary(bytes) -> hash_file_chunks(io, :crypto.hash_update(context, bytes)) + end + end + + defp android_preinstall_callbacks(opts) do + preinstall = Keyword.get(opts, :android_preinstall) + cleanup = Keyword.get(opts, :android_preinstall_cleanup) + + if is_function(preinstall, 1) and is_function(cleanup, 1) do + {:ok, preinstall, cleanup} + else + {:error, "Android device phase requires an authoritative payload plan"} + end + end + + defp invoke_android_preinstall( + preinstall, + cleanup, + bundle_id, + serials, + selections, + apk_snapshot + ) do + selected_abis_by_serial = + Map.new(selections, fn {serial, %{abi: abi}} -> {serial, abi} end) + + input = %{ + apk: apk_snapshot.path, + apk_sha256: Base.encode16(apk_snapshot.sha256, case: :lower), + apk_size: apk_snapshot.size, + bundle_id: bundle_id, + serials: Enum.sort(serials), + selected_abis: selected_android_abis(selections), + selected_abis_by_serial: selected_abis_by_serial + } + + try do + case preinstall.(input) do + {:ok, payload_plan} -> + case validate_android_payload_plan(payload_plan, input) do + {:ok, _payload_plan} = ok -> + ok + + {:error, _reason} = error -> + cleanup_android_payload_after_failure(cleanup, payload_plan, error) + end + + {:error, _bounded_reason} -> + {:error, "Could not prepare authoritative Android payload"} + + _invalid -> + {:error, "Authoritative Android payload plan is invalid"} + end + catch + _kind, _reason -> {:error, "Could not prepare authoritative Android payload"} + end + end + + defp validate_android_payload_plan(payload_plan, input) + when is_map(payload_plan) and is_map(input) do + with true <- + exact_map_keys?(payload_plan, [ + :version, + :package, + :attempt_id, + :serials, + :selected_abis, + :selected_abis_by_serial, + :apk, + :beam, + :exqlite, + :restart_by_serial + ]), + 1 <- payload_plan.version, + true <- payload_plan.package == input.bundle_id, + true <- payload_plan.serials == input.serials, + true <- payload_plan.selected_abis == input.selected_abis, + true <- payload_plan.selected_abis_by_serial == input.selected_abis_by_serial, + true <- valid_android_attempt_id?(payload_plan.attempt_id), + :ok <- validate_android_plan_apk(payload_plan.apk, input), + :ok <- + validate_android_beam_plan( + payload_plan.beam, + payload_plan.package, + payload_plan.attempt_id + ), + :ok <- + validate_android_exqlite_plan( + payload_plan.exqlite, + payload_plan.package, + payload_plan.attempt_id, + payload_plan.selected_abis + ), + :ok <- + validate_android_restart_plan( + payload_plan.restart_by_serial, + payload_plan.package, + payload_plan.serials + ), + true <- android_plan_local_paths_distinct?(payload_plan) do + {:ok, payload_plan} + else + _invalid -> {:error, "Authoritative Android payload plan identity is invalid"} + end + end + + defp validate_android_payload_plan(_payload_plan, _input), + do: {:error, "Authoritative Android payload plan identity is invalid"} + + defp exact_map_keys?(map, keys) do + MapSet.new(Map.keys(map)) == MapSet.new(keys) + end + + defp valid_android_attempt_id?(attempt_id) when is_binary(attempt_id) do + String.valid?(attempt_id) and + Regex.match?(Regex.compile!(@android_attempt_id_pattern), attempt_id) + end + + defp valid_android_attempt_id?(_attempt_id), do: false + + defp validate_android_plan_apk(apk, input) do + with :ok <- validate_android_local_file_identity(apk, @max_android_apk_bytes), + true <- apk.path != input.apk, + true <- apk.size == input.apk_size, + true <- apk.sha256 == input.apk_sha256 do + :ok + else + _invalid -> {:error, :invalid_plan_apk} + end + end + + defp validate_android_local_file_identity(identity, max_bytes) when is_map(identity) do + with true <- exact_map_keys?(identity, [:path, :size, :sha256]), + true <- safe_android_local_plan_path?(identity.path), + true <- is_integer(identity.size) and identity.size > 0 and identity.size <= max_bytes, + true <- valid_sha256_hex?(identity.sha256), + {:ok, %{type: :regular, size: size, mode: mode}} <- File.stat(identity.path), + true <- size == identity.size, + true <- Bitwise.band(mode, 0o222) == 0, + {:ok, digest} <- file_sha256(identity.path), + true <- Base.encode16(digest, case: :lower) == identity.sha256 do + :ok + else + _invalid -> {:error, :invalid_local_plan_file} + end + end + + defp validate_android_local_file_identity(_identity, _max_bytes), + do: {:error, :invalid_local_plan_file} + + defp safe_android_local_plan_path?(path) do + is_binary(path) and byte_size(path) in 1..2_048 and String.valid?(path) and + Path.type(path) == :absolute and not Enum.member?(Path.split(path), "..") + end + + defp valid_sha256_hex?(sha256) when is_binary(sha256) do + Regex.match?(Regex.compile!("\\A[0-9a-f]{64}\\z"), sha256) + end + + defp valid_sha256_hex?(_sha256), do: false + + defp validate_android_beam_plan(beam, package, attempt_id) when is_map(beam) do + with true <- + exact_map_keys?(beam, [ + :archive, + :stage_device, + :app_stage, + :app_backup, + :activation_lock, + :dist_snapshot, + :runtime_version, + :beam_flags + ]), + :ok <- validate_android_local_file_identity(beam.archive, @max_android_apk_bytes), + :ok <- + validate_android_beam_remote_paths(beam, package, attempt_id), + :ok <- validate_android_dist_snapshot(beam.dist_snapshot), + true <- beam.runtime_version == System.version(), + true <- valid_android_beam_flags?(beam.beam_flags) do + :ok + else + _invalid -> {:error, :invalid_beam_plan} + end + end + + defp validate_android_beam_plan(_beam, _package, _attempt_id), + do: {:error, :invalid_beam_plan} + + defp validate_android_beam_remote_paths(plan, package, attempt_id) do + app_data = "/data/data/#{package}/files" + + if plan.stage_device == "/data/local/tmp/mob_beams_#{attempt_id}.tar" and + plan.app_stage == "#{app_data}/.mob_beams_stage_#{attempt_id}" and + plan.app_backup == "#{app_data}/.mob_beams_backup_#{attempt_id}" and + plan.activation_lock == "#{app_data}/.mob_beams_activation_lock" do + :ok + else + {:error, :invalid_remote_plan_paths} + end + end + + defp validate_android_dist_snapshot(snapshot) do + case MobDev.HotPush.validate_prepared_snapshot(snapshot) do + :ok -> :ok + {:error, _reason} -> {:error, :invalid_dist_snapshot} + end + end + + defp valid_android_beam_flags?(nil), do: true + + defp valid_android_beam_flags?(flags) when is_binary(flags), + do: byte_size(flags) <= 4_096 and String.valid?(flags) + + defp valid_android_beam_flags?(_flags), do: false + + defp validate_android_exqlite_plan(nil, _package, _attempt_id, _selected_abis), do: :ok + + defp validate_android_exqlite_plan(exqlite, package, attempt_id, selected_abis) + when is_map(exqlite) do + with true <- + exact_map_keys?(exqlite, [ + :archive, + :stage_device, + :app_stage, + :app_backup, + :activation_lock, + :app_version, + :beam_sentinel, + :nif + ]), + :ok <- validate_android_local_file_identity(exqlite.archive, @max_android_apk_bytes), + :ok <- + validate_android_exqlite_remote_paths(exqlite, package, attempt_id), + true <- valid_android_plan_component?(exqlite.app_version, 128), + true <- valid_android_beam_sentinel?(exqlite.beam_sentinel), + :ok <- validate_android_exqlite_nif(exqlite.nif, selected_abis) do + :ok + else + _invalid -> {:error, :invalid_exqlite_plan} + end + end + + defp validate_android_exqlite_plan(_exqlite, _package, _attempt_id, _selected_abis), + do: {:error, :invalid_exqlite_plan} + + defp validate_android_exqlite_remote_paths(plan, package, attempt_id) do + lib_parent = "/data/data/#{package}/files/otp/lib" + + if plan.stage_device == "/data/local/tmp/mob_exqlite_#{attempt_id}.tar" and + plan.app_stage == "#{lib_parent}/.mob_exqlite_stage_#{attempt_id}" and + plan.app_backup == "#{lib_parent}/.mob_exqlite_backup_#{attempt_id}" and + plan.activation_lock == "#{lib_parent}/.mob_exqlite_activation_lock" do + :ok + else + {:error, :invalid_remote_plan_paths} + end + end + + defp validate_android_exqlite_nif(nif, selected_abis) when is_map(nif) do + expected_entries = Map.new(selected_abis, &{&1, "lib/#{&1}/libsqlite3_nif.so"}) + + if exact_map_keys?(nif, [ + :source, + :filename, + :selected_abis, + :required_apk_entries + ]) and nif.source == :installed_apk and nif.filename == "libsqlite3_nif.so" and + nif.selected_abis == selected_abis and nif.required_apk_entries == expected_entries do + :ok + else + {:error, :invalid_exqlite_nif} + end + end + + defp validate_android_exqlite_nif(_nif, _selected_abis), + do: {:error, :invalid_exqlite_nif} + + defp valid_android_plan_component?(value, max_bytes) when is_binary(value) do + byte_size(value) in 1..max_bytes and String.valid?(value) and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9._-]+\\z"), value) + end + + defp valid_android_plan_component?(_value, _max_bytes), do: false + + defp valid_android_beam_sentinel?(sentinel) when is_binary(sentinel) do + byte_size(sentinel) in 1..255 and String.valid?(sentinel) and + Path.basename(sentinel) == sentinel and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_.-]+\\.beam\\z"), sentinel) + end + + defp valid_android_beam_sentinel?(_sentinel), do: false + + defp validate_android_restart_plan(restarts, package, serials) when is_map(restarts) do + if Enum.sort(Map.keys(restarts)) == serials and + Enum.all?(restarts, fn {serial, restart} -> + valid_android_restart_entry?(serial, restart, package) + end) do + :ok + else + {:error, :invalid_restart_plan} + end + end + + defp validate_android_restart_plan(_restarts, _package, _serials), + do: {:error, :invalid_restart_plan} + + defp valid_android_restart_entry?(serial, restart, package) when is_map(restart) do + exact_map_keys?(restart, [ + :package, + :activity, + :restart?, + :mode, + :dist_port, + :node_suffix + ]) and restart.package == package and valid_adb_serial?(serial) and + valid_android_activity?(restart.activity) and + valid_android_node_suffix?(restart.node_suffix) and + is_integer(restart.dist_port) and restart.dist_port in 1_024..65_535 and + restart.restart? == true and restart.mode == :checked_restart + end + + defp valid_android_restart_entry?(_serial, _restart, _package), do: false + + defp valid_android_activity?(activity) when is_binary(activity) do + byte_size(activity) in 1..255 and String.valid?(activity) and + Regex.match?(Regex.compile!("\\A\\.?[A-Za-z][A-Za-z0-9_.]*\\z"), activity) + end + + defp valid_android_activity?(_activity), do: false + + defp valid_android_node_suffix?(suffix) when is_binary(suffix) do + byte_size(suffix) in 1..128 and String.valid?(suffix) and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_]+\\z"), suffix) + end + + defp valid_android_node_suffix?(_suffix), do: false + + defp android_plan_local_paths_distinct?(payload_plan) do + paths = + [payload_plan.apk.path, payload_plan.beam.archive.path] ++ + case payload_plan.exqlite do + nil -> [] + exqlite -> [exqlite.archive.path] + end + + Enum.uniq(paths) == paths + end + + defp run_android_runtime_transaction( + apk_snapshot, + serials, + bundle_id, + app_data, + elixir_lib, + selections, + payload_plan, + cleanup, + runner, + otp_runner, + opts + ) do + try do + result = + with :ok <- verify_android_apk_snapshot(apk_snapshot), + :ok <- validate_android_local_file_identity(payload_plan.apk, @max_android_apk_bytes), + :ok <- validate_android_apk_runtime(payload_plan.apk.path, selections, payload_plan), + :ok <- verify_android_apk_snapshot(apk_snapshot), + {:ok, prepared} <- + prepare_selected_otp_archives(selections, app_data, elixir_lib, opts), + :ok <- validate_android_native_otp_plan(prepared, selections, app_data) do + try do + with {:ok, lock} <- acquire_android_deploy_lock(serials, bundle_id, runner, opts) do + run_locked_android_transaction(lock, fn -> + with :ok <- validate_android_native_otp_plan(prepared, selections, app_data) do + case deploy_locked_android_otp( + payload_plan.apk, + serials, + bundle_id, + app_data, + selections, + prepared, + lock, + runner, + otp_runner + ) do + :ok -> + transition_android_after_update(lock, runner) + + {:error, {:android_partial_update, state, reason}} + when state in [:retained_failure, :retained_ambiguous] -> + {:error, {:partial_update, reason}, %{lock | state: state}} + + {:error, {:android_deploy_lease_ambiguous, reason}} -> + {:error, reason, %{lock | state: :retained_ambiguous}} + + {:error, reason} -> + {:error, reason, %{lock | state: :retained_failure}} + end + else + {:error, reason} -> {:error, reason, %{lock | state: :retained_failure}} + end + end) + end + after + cleanup_prepared_otp_archives(prepared) + end + end + + case result do + {:ok, lock} -> + {:ok, %{deploy_lock: lock, payload_plan: payload_plan}} + + {:error, _reason, _lock} = error -> + cleanup_android_payload_after_failure(cleanup, payload_plan, error) + + {:error, _reason} = error -> + cleanup_android_payload_after_failure(cleanup, payload_plan, error) + + _invalid -> + cleanup_android_payload_after_failure( + cleanup, + payload_plan, + {:error, "Authoritative Android runtime transaction returned an invalid result"} + ) + end + catch + kind, reason -> + stacktrace = __STACKTRACE__ + cleanup_android_payload(cleanup, payload_plan) + :erlang.raise(kind, reason, stacktrace) + end + end + + defp run_locked_android_transaction(lock, transaction) do + try do + transaction.() + catch + _kind, _reason -> + {:error, "Android device transaction became ambiguous; deploy lease retained", + %{lock | state: :retained_ambiguous}} + end + end + + defp transition_android_after_update(lock, runner) do + try do + case transition_android_deploy_lock(lock, :acquired, :native_ready, runner) do + {:ok, _transitioned} = ok -> + ok + + {:error, reason, retained} -> + {:error, + {:partial_update, + "Android target set was updated but native-ready commit failed: #{reason}"}, retained} + end + catch + _kind, _reason -> + {:error, + {:partial_update, + "Android native-ready commit became ambiguous after device update; deploy lease retained"}, + %{lock | state: :retained_ambiguous}} + end + end + + defp verify_android_apk_snapshot(%{path: path, size: size, sha256: expected_sha256}) do + with {:ok, %{size: ^size}} <- File.stat(path), + {:ok, ^expected_sha256} <- file_sha256(path) do + :ok + else + _changed -> {:error, "Exact Android APK snapshot changed; refusing update"} + end + end + + defp cleanup_android_payload(cleanup, payload_plan) do + try do + case cleanup.(payload_plan) do + :ok -> :ok + _invalid -> {:error, "Could not clean authoritative Android payload"} + end + catch + _kind, _reason -> {:error, "Could not clean authoritative Android payload"} + end + end + + defp cleanup_android_payload_after_failure(cleanup, payload_plan, failure) do + cleanup_android_payload(cleanup, payload_plan) + failure + end + + defp deploy_locked_android_otp( + apk, + serials, + bundle_id, + app_data, + selections, + prepared, + lock, + runner, + otp_runner + ) do + context = %{ + apk: apk, + bundle_id: bundle_id, + app_data: app_data, + selections: selections, + prepared: prepared, + lock: lock, + runner: runner, + otp_runner: otp_runner + } + + deploy_locked_android_targets(serials, context, false) + end + + defp deploy_locked_android_targets([], _context, _updated?), do: :ok + + defp deploy_locked_android_targets([serial | remaining], context, updated?) do + try do + %{abi: expected_abi, otp_dir: otp_dir} = Map.fetch!(context.selections, serial) + plan = Map.fetch!(context.prepared, otp_dir) + + preinstall_result = + with :ok <- verify_android_prepared_otp_archive(plan, otp_dir, expected_abi), + :ok <- validate_android_local_file_identity(context.apk, @max_android_apk_bytes), + :ok <- verify_android_deploy_lock_set(context.lock, context.runner) do + :ok + end + + case preinstall_result do + :ok -> + install_locked_android_target( + serial, + remaining, + expected_abi, + otp_dir, + plan, + context, + updated? + ) + + {:error, _reason} = error -> + maybe_partial_android_update(error, updated?) + end + catch + kind, reason -> + if updated? do + partial_android_update_ambiguous() + else + :erlang.raise(kind, reason, __STACKTRACE__) + end + end + end + + defp install_locked_android_target( + serial, + remaining, + expected_abi, + otp_dir, + plan, + context, + updated? + ) do + case invoke_android_install(context.apk.path, serial, context.runner) do + {:ok, ^serial} -> + case deliver_android_otp_after_install(serial, expected_abi, otp_dir, plan, context) do + :ok -> deploy_locked_android_targets(remaining, context, true) + {:error, _reason} = error -> partial_android_update_error(error) + end + + {:error, %{reason: reason}} -> + if definite_android_install_rejection?(reason) do + maybe_partial_android_update( + {:error, "APK update failed: #{android_update_reason(reason)}"}, + updated? + ) + else + partial_android_update_ambiguous( + "Android APK update result was not authoritative; deploy lease retained" + ) + end + + _invalid_or_ambiguous -> + partial_android_update_ambiguous( + "Android APK update result was not authoritative; deploy lease retained" + ) + end + end + + defp invoke_android_install(apk, serial, runner) do + try do + install_android_update(apk, serial, runner) + catch + _kind, _reason -> + {:error, + {:android_install_ambiguous, + "Android APK update result was not authoritative; deploy lease retained"}} + end + end + + defp deliver_android_otp_after_install(serial, expected_abi, otp_dir, plan, context) do + try do + with :ok <- verify_android_deploy_lock_set(context.lock, context.runner), + :ok <- + repair_erts_helper_labels( + serial, + context.bundle_id, + expected_abi, + context.lock, + context.runner + ), + :ok <- verify_android_deploy_lock_set(context.lock, context.runner), + :ok <- verify_android_prepared_otp_archive(plan, otp_dir, expected_abi) do + deploy_prepared_otp( + context.otp_runner, + context.runner, + context.lock, + serial, + context.bundle_id, + context.app_data, + plan + ) + end + catch + _kind, _reason -> + {:error, + {:android_deploy_lease_ambiguous, + "Android device transaction became ambiguous after APK update; deploy lease retained"}} + end + end + + defp maybe_partial_android_update({:error, _reason} = error, false), do: error + + defp maybe_partial_android_update({:error, _reason} = error, true), + do: partial_android_update_error(error) + + defp partial_android_update_error({:error, {:android_deploy_lease_ambiguous, reason}}), + do: partial_android_update_ambiguous(reason) + + defp partial_android_update_error({:error, reason}) when is_binary(reason) do + {:error, + {:android_partial_update, :retained_failure, + "Android target set was partially updated before failure: #{reason}; deploy lease retained"}} + end + + defp partial_android_update_error(_invalid), do: partial_android_update_ambiguous() + + defp partial_android_update_ambiguous( + reason \\ "Android device transaction became ambiguous after APK update; deploy lease retained" + ) do + {:error, {:android_partial_update, :retained_ambiguous, reason}} + end + + defp verify_android_deploy_lock_owner( + lock, + serial, + runner + ) do + case AndroidDeployLock.verify_owner(lock, serial, android_lock_runner(runner)) do + :ok -> :ok + {:error, _failure} -> {:error, "Android deploy lease owner could not be verified"} + end + end + + defp verify_android_deploy_lock_set(lock, runner) do + Enum.reduce_while(lock.serials, :ok, fn serial, :ok -> + case verify_android_deploy_lock_owner(lock, serial, runner) do + :ok -> + {:cont, :ok} + + {:error, _reason} -> + {:halt, + {:error, + {:android_deploy_lease_ambiguous, + "Android deploy lease set could not be verified; refusing mutation"}}} + end + end) + end + + defp repair_erts_helper_labels(serial, bundle_id, expected_abi, lock, runner) do + case invoke_command(runner, "adb", ["-s", serial, "root"]) do + {:ok, output, status} + when is_binary(output) and is_integer(status) and + byte_size(output) <= @max_adb_install_result_bytes -> + if String.valid?(output) do + classify_android_root_response(String.trim(output), status) + |> case do + :not_rootable -> + :ok + + {:rooted, restarted?} -> + with :ok <- maybe_wait_for_rooted_adb(serial, restarted?, runner), + :ok <- verify_android_deploy_lock_set(lock, runner), + {:ok, ^expected_abi} <- probe_android_abi(serial, runner), + {:ok, apk_dir} <- probe_android_apk_dir(serial, bundle_id, runner), + :ok <- relabel_erts_helpers(serial, apk_dir, expected_abi, lock, runner) do + :ok + else + {:error, _reason} = error -> error + _abi_drift -> {:error, "Android ABI changed during deploy; refusing OTP delivery"} + end + + :invalid -> + {:error, "Could not classify adb root response; refusing OTP delivery"} + end + else + {:error, "Invalid adb root response; refusing OTP delivery"} + end + + _invalid -> + {:error, "adb root probe failed; refusing OTP delivery"} + end + end + + defp classify_android_root_response(output, 0) do + cond do + output in ["adbd is already running as root", "adbd already running as root"] -> + {:rooted, false} + + output in ["restarting adbd as root", "restarting adbd as root\n"] -> + {:rooted, true} + + output == "adbd cannot run as root in production builds" -> + :not_rootable + + true -> + :invalid + end + end + + defp classify_android_root_response(output, _status) do + if output == "adbd cannot run as root in production builds", + do: :not_rootable, + else: :invalid + end + + defp maybe_wait_for_rooted_adb(_serial, false, _runner), do: :ok + + defp maybe_wait_for_rooted_adb(serial, true, runner) do + checked_empty_android_command(runner, serial, ["wait-for-device"], "wait for rooted adb") + end + + defp probe_android_abi(serial, runner) do + runner + |> invoke_command("adb", ["-s", serial, "shell", "getprop", "ro.product.cpu.abi"]) + |> android_abi_from_probe() + end + + defp probe_android_apk_dir(serial, bundle_id, runner) do + with {:ok, output, 0} <- + invoke_command(runner, "adb", ["-s", serial, "shell", "pm", "path", bundle_id]), + true <- byte_size(output) <= @max_adb_discovery_bytes, + true <- String.valid?(output) do + dirs = + output + |> String.split("\n", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reduce_while([], fn + "package:" <> path, dirs -> + if safe_android_apk_path?(path), + do: {:cont, [Path.dirname(path) | dirs]}, + else: {:halt, :invalid} + + _unexpected, _dirs -> + {:halt, :invalid} + end) + + case dirs do + dirs when is_list(dirs) -> + case Enum.uniq(dirs) do + [dir] -> {:ok, dir} + _ -> {:error, "Android APK path is missing or ambiguous"} + end + + :invalid -> + {:error, "Android APK path is invalid"} + end + else + _ -> {:error, "Could not locate Android APK path"} + end + end + + defp relabel_erts_helpers(serial, apk_dir, abi, lock, runner) do + abi_dir = if abi == "armeabi-v7a", do: "arm", else: abi |> String.replace("-v8a", "") + lib_dir = Path.join([apk_dir, "lib", abi_dir]) + + if safe_android_absolute_path?(lib_dir) do + ["liberl_child_setup.so", "libinet_gethost.so", "libepmd.so"] + |> Enum.reduce_while(:ok, fn lib, :ok -> + case checked_empty_locked_android_command( + lock, + runner, + serial, + ["shell", "chcon", "u:object_r:apk_data_file:s0", Path.join(lib_dir, lib)], + "repair ERTS helper label" + ) do + :ok -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} + end + end) + else + {:error, "Android native library path is invalid"} + end + end + + defp checked_empty_android_command(runner, serial, args, operation) do + case invoke_command(runner, "adb", ["-s", serial | args]) do + {:ok, "", 0} -> :ok + _failure_or_ambiguity -> {:error, "#{operation} failed"} + end + end + + defp checked_empty_locked_android_command(lock, runner, serial, args, operation) do + with :ok <- verify_android_deploy_lock_set(lock, runner) do + checked_empty_android_command(runner, serial, args, operation) + end + end + + defp safe_android_absolute_path?(path) do + is_binary(path) and byte_size(path) <= 1_024 and String.valid?(path) and + Regex.match?(Regex.compile!("\\A/[A-Za-z0-9._/+=~:-]+\\z"), path) and + not Enum.member?(Path.split(path), "..") + end + + defp safe_android_apk_path?(path) do + parent = if is_binary(path), do: Path.dirname(path), else: "" + + safe_android_absolute_path?(path) and String.starts_with?(parent, "/data/app/") and + parent != "/data/app" and String.ends_with?(path, ".apk") and + Path.basename(path) not in ["", ".", ".."] + end + + defp validate_android_apk_identity(apk, bundle_id, runner) + when is_binary(apk) and is_function(runner, 2) do + if File.regular?(apk) do + case invoke_command(runner, "apkanalyzer", ["manifest", "application-id", apk]) do + {:ok, output, 0} + when byte_size(output) <= @max_adb_install_result_bytes -> + lines = + if String.valid?(output) do + output + |> String.split("\n", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + else + [] + end + + if lines == [bundle_id] do + :ok + else + {:error, "Android APK application id does not match configured bundle id"} + end + + _failure_or_malformed -> + {:error, "Could not verify Android APK application id; refusing update"} + end + else + {:error, "Android APK is missing; refusing update"} + end + end + + defp validate_android_apk_identity(_apk, _bundle_id, _runner), + do: {:error, "Android APK path is invalid; refusing update"} + + defp validate_android_apk_runtime(apk, selections, payload_plan) do + with {:ok, helper_sources} <- android_apk_helper_sources(selections), + {:ok, payload_entries} <- android_payload_apk_entries(payload_plan, selections), + {:ok, entries} <- android_apk_entries(apk), + :ok <- + validate_required_android_apk_entries( + entries, + Map.keys(helper_sources) ++ payload_entries + ), + :ok <- validate_android_apk_helper_content(apk, entries, helper_sources) do + :ok + end + end + + defp android_apk_helper_sources(selections) when is_map(selections) do + selections + |> Map.values() + |> Enum.uniq_by(& &1.abi) + |> Enum.reduce_while({:ok, %{}}, fn %{abi: abi, otp_dir: otp_dir}, {:ok, sources} -> + case Path.wildcard(Path.join(otp_dir, "erts-*/bin")) do + [erts_bin] -> + case validate_android_erts_helpers(erts_bin) do + :ok -> + next = + Enum.reduce(@android_erts_helpers, sources, fn {source, packaged}, acc -> + Map.put( + acc, + "lib/#{abi}/#{packaged}", + Path.join(erts_bin, source) + ) + end) + + {:cont, {:ok, next}} + + {:error, _reason} = error -> + {:halt, error} + end + + _missing_or_ambiguous -> + {:halt, {:error, "Android ERTS helper source is missing or ambiguous"}} + end + end) + end + + defp android_payload_apk_entries(nil, _selections), do: {:ok, []} + + defp android_payload_apk_entries(%{exqlite: nil}, _selections), do: {:ok, []} + + defp android_payload_apk_entries( + %{ + exqlite: %{ + nif: %{ + filename: "libsqlite3_nif.so", + selected_abis: plan_abis, + required_apk_entries: entries + } + } + }, + selections + ) do + selected_abis = selected_android_abis(selections) + expected = Map.new(selected_abis, &{&1, "lib/#{&1}/libsqlite3_nif.so"}) + + if plan_abis == selected_abis and entries == expected do + {:ok, expected |> Map.values() |> Enum.sort()} + else + {:error, "Android payload APK requirements do not match selected ABIs"} + end + end + + defp android_payload_apk_entries(_invalid, _selections), + do: {:error, "Android payload APK requirements are invalid"} + + defp android_apk_entries(apk) do + with {:ok, %{size: size}} when size > 0 and size <= @max_android_apk_bytes <- + File.stat(apk), + {:ok, zip_entries} <- :zip.list_dir(String.to_charlist(apk)), + true <- length(zip_entries) <= @max_android_apk_entries do + Enum.reduce_while(zip_entries, {:ok, []}, fn + {:zip_comment, _comment}, {:ok, entries} -> + {:cont, {:ok, entries}} + + {:zip_file, name, file_info, _comment, _offset, _compressed_size}, {:ok, entries} -> + with {:ok, normalized} <- normalize_android_apk_entry_name(name), + {:ok, uncompressed_size} <- android_zip_entry_size(file_info) do + {:cont, {:ok, [{normalized, uncompressed_size} | entries]}} + else + {:error, _reason} = error -> {:halt, error} + end + + _invalid, _acc -> + {:halt, {:error, "Android APK ZIP directory is malformed"}} + end) + else + false -> {:error, "Android APK ZIP directory exceeds the safety limit"} + {:ok, %{size: _invalid}} -> {:error, "Android APK size is invalid"} + _failure -> {:error, "Could not inspect Android APK ZIP directory"} + end + end + + defp normalize_android_apk_entry_name(name) when is_list(name) do + try do + normalized = List.to_string(name) + + if byte_size(normalized) > 0 and byte_size(normalized) <= @max_android_apk_entry_bytes and + String.valid?(normalized) do + {:ok, normalized} + else + {:error, "Android APK ZIP entry name is invalid"} + end + rescue + _error -> {:error, "Android APK ZIP entry name is invalid"} + end + end + + defp normalize_android_apk_entry_name(_invalid), + do: {:error, "Android APK ZIP entry name is invalid"} + + defp android_zip_entry_size(file_info) + when is_tuple(file_info) and tuple_size(file_info) > 1 and + elem(file_info, 0) == :file_info and is_integer(elem(file_info, 1)) and + elem(file_info, 1) >= 0 and + elem(file_info, 1) <= @max_android_apk_required_entry_bytes, + do: {:ok, elem(file_info, 1)} + + defp android_zip_entry_size(_invalid), + do: {:error, "Android APK ZIP entry size is invalid"} + + defp validate_required_android_apk_entries(entries, required) do + frequencies = Enum.frequencies_by(entries, &elem(&1, 0)) + + if Enum.all?(required, &(Map.get(frequencies, &1) == 1)) do + :ok + else + {:error, "Android APK is missing an exact selected-ABI runtime entry"} + end + end + + defp validate_android_apk_helper_content(apk, entries, helper_sources) do + required_names = Map.keys(helper_sources) |> Enum.sort() + sizes = Map.new(entries) + + with true <- Enum.all?(required_names, &(Map.fetch!(sizes, &1) > 0)), + {:ok, extracted} <- + :zip.extract( + String.to_charlist(apk), + [:memory, {:file_list, Enum.map(required_names, &String.to_charlist/1)}] + ), + true <- length(extracted) == length(required_names) do + extracted + |> Enum.reduce_while(:ok, fn {entry_name, bytes}, :ok -> + with {:ok, normalized} <- normalize_android_apk_entry_name(entry_name), + source when is_binary(source) <- Map.fetch!(helper_sources, normalized), + {:ok, source_bytes} <- File.read(source), + true <- + byte_size(bytes) == byte_size(source_bytes) and + :crypto.hash(:sha256, bytes) == :crypto.hash(:sha256, source_bytes) do + {:cont, :ok} + else + _mismatch -> {:halt, {:error, "Android APK ERTS helper provenance mismatch"}} + end + end) + else + _missing_or_invalid -> {:error, "Android APK ERTS helper provenance mismatch"} + end + end + + defp selected_android_abis(selections) do + selections + |> Map.values() + |> Enum.map(& &1.abi) + |> Enum.uniq() + |> Enum.sort() + end + + defp select_android_otp_sources(serials, otp_arm64, otp_arm32, otp_x86_64, runner) do + Enum.reduce_while(serials, {:ok, %{}}, fn serial, {:ok, selections} -> + case device_otp_selection(serial, otp_arm64, otp_arm32, otp_x86_64, runner) do + {:ok, selection} -> {:cont, {:ok, Map.put(selections, serial, selection)}} + {:error, _reason} = error -> {:halt, error} + end + end) + end + + defp prepare_selected_otp_archives(selections, app_data, elixir_lib, opts) do + otp_dirs = + selections + |> Map.values() + |> Enum.map(& &1.otp_dir) + |> Enum.uniq() + |> Enum.sort() + + multiple? = length(otp_dirs) > 1 + + Enum.reduce_while(otp_dirs, {:ok, %{}}, fn otp_dir, {:ok, prepared} -> + archive_opts = + opts + |> Keyword.take([:attempt_id, :tmp_root, :otp_runner]) + |> then(fn archive_opts -> + if multiple?, do: Keyword.delete(archive_opts, :attempt_id), else: archive_opts + end) + |> Keyword.put(:runner, Keyword.get(opts, :otp_runner, &run_system_command/3)) + + case prepare_otp_archive(app_data, otp_dir, elixir_lib, archive_opts) do + {:ok, plan} -> + selected_abis = + selections + |> Map.values() + |> Enum.filter(&(&1.otp_dir == otp_dir)) + |> Enum.map(& &1.abi) + |> Enum.uniq() + |> Enum.sort() + + plan = + plan + |> Map.put(:otp_dir, otp_dir) + |> Map.put(:selected_abis, selected_abis) + + {:cont, {:ok, Map.put(prepared, otp_dir, plan)}} + + {:error, _reason} = error -> + cleanup_prepared_otp_archives(prepared) + {:halt, error} + end + end) + end + + defp cleanup_prepared_otp_archives(prepared) do + Enum.each(prepared, fn + {_otp_dir, %{archive: %{path: path}}} when is_binary(path) -> File.rm(path) + _invalid -> :ok + end) + end + + defp validate_android_native_otp_plan(prepared, selections, app_data) + when is_map(prepared) and is_map(selections) do + expected_dirs = + selections + |> Map.values() + |> Enum.map(& &1.otp_dir) + |> Enum.uniq() + |> Enum.sort() + + paths = + Enum.flat_map(prepared, fn {_otp_dir, plan} -> + [plan.archive.path, plan.stage_device, plan.app_stage, plan.app_backup] + end) + + with true <- Enum.sort(Map.keys(prepared)) == expected_dirs, + true <- Enum.uniq(paths) == paths, + true <- + Enum.all?(prepared, fn {otp_dir, plan} -> + expected_abis = + selections + |> Map.values() + |> Enum.filter(&(&1.otp_dir == otp_dir)) + |> Enum.map(& &1.abi) + |> Enum.uniq() + |> Enum.sort() + + validate_android_native_otp_entry(plan, otp_dir, expected_abis, app_data) == :ok + end) do + :ok + else + _invalid -> {:error, "Authoritative Android OTP archive plan is invalid"} + end + end + + defp validate_android_native_otp_plan(_prepared, _selections, _app_data), + do: {:error, "Authoritative Android OTP archive plan is invalid"} + + defp validate_android_native_otp_entry(plan, otp_dir, expected_abis, app_data) + when is_map(plan) do + with true <- + exact_map_keys?(plan, [ + :activation_lock, + :app_backup, + :app_stage, + :archive, + :attempt_id, + :otp_dir, + :selected_abis, + :sentinels, + :stage_device + ]), + true <- plan.otp_dir == otp_dir, + true <- plan.selected_abis == expected_abis and expected_abis != [], + true <- valid_android_attempt_id?(plan.attempt_id), + :ok <- validate_android_local_file_identity(plan.archive, @max_android_apk_bytes), + true <- plan.stage_device == "/data/local/tmp/mob_otp_#{plan.attempt_id}.tar", + true <- plan.app_stage == "#{app_data}/.mob_otp_stage_#{plan.attempt_id}", + true <- plan.app_backup == "#{app_data}/.mob_otp_backup_#{plan.attempt_id}", + true <- plan.activation_lock == "#{app_data}/.mob_otp_activation_lock", + true <- valid_android_runtime_sentinels?(plan.sentinels) do + :ok + else + _invalid -> {:error, :invalid_native_otp_entry} + end + end + + defp validate_android_native_otp_entry(_plan, _otp_dir, _expected_abis, _app_data), + do: {:error, :invalid_native_otp_entry} + + defp verify_android_prepared_otp_archive(plan, otp_dir, expected_abi) do + with true <- plan.otp_dir == otp_dir, + true <- expected_abi in plan.selected_abis, + :ok <- validate_android_local_file_identity(plan.archive, @max_android_apk_bytes) do + :ok + else + _invalid -> {:error, "Exact Android OTP archive changed; refusing update"} + end + end + + defp valid_android_runtime_sentinels?(sentinels) + when is_list(sentinels) and sentinels != [] and length(sentinels) <= 32 do + Enum.uniq(sentinels) == sentinels and + Enum.all?(sentinels, fn sentinel -> + is_binary(sentinel) and byte_size(sentinel) in 1..1_024 and String.valid?(sentinel) and + Path.type(sentinel) == :relative and String.starts_with?(sentinel, "otp/") and + not Enum.member?(Path.split(sentinel), "..") and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_./-]+\\z"), sentinel) + end) + end + + defp valid_android_runtime_sentinels?(_sentinels), do: false + + defp acquire_android_deploy_lock(serials, bundle_id, runner, opts) do + lock_opts = + case Keyword.fetch(opts, :lock_owner) do + {:ok, owner} -> [owner: owner] + :error -> [] + end + + case AndroidDeployLock.acquire( + bundle_id, + serials, + android_lock_runner(runner), + lock_opts + ) do + {:ok, lease} -> + {:ok, lease} + + {:error, %{lease: %{state: :not_acquired}} = failure} -> + {:error, AndroidDeployLock.message(failure)} + + {:error, %{lease: lease} = failure} -> + {:error, AndroidDeployLock.message(failure), lease} + end + end + + defp transition_android_deploy_lock(lock, expected_phase, next_phase, runner) do + case AndroidDeployLock.transition( + lock, + expected_phase, + next_phase, + android_lock_runner(runner) + ) do + {:ok, transitioned} -> + {:ok, transitioned} + + {:error, %{lease: lease} = failure} -> + {:error, AndroidDeployLock.message(failure), lease} + end + end + + defp android_lock_runner(runner) do + fn args -> + case invoke_command(runner, "adb", args) do + {:ok, output, status} -> {output, status} + {:error, _reason} -> {"", 255} + end + end + end + + @doc false + @spec release_android_deploy_lock(map(), keyword()) :: :ok | {:error, String.t()} + def release_android_deploy_lock(lock_info, opts \\ []) do + runner = Keyword.get(opts, :probe_runner, &run_system_command/2) + + case AndroidDeployLock.release(lock_info, android_lock_runner(runner)) do + :ok -> :ok + {:error, failure} -> {:error, AndroidDeployLock.message(failure)} + end + end + + defp preflight_installed_android_targets(serials, bundle_id, runner) do + Enum.reduce_while(serials, :ok, fn serial, :ok -> + case ensure_android_package_for_otp(runner, serial, bundle_id) do + :ok -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} + end + end) + end + + @doc false + @spec interpret_adb_update(String.t(), integer()) :: + :updated | {:failed, android_update_failure_reason()} + def interpret_adb_update(output, exit_code) + when is_binary(output) and is_integer(exit_code) and + byte_size(output) <= @max_adb_install_result_bytes do + if String.valid?(output) do + case known_adb_failure(output) do + nil -> interpret_adb_update_status(output, exit_code) + reason -> {:failed, reason} + end + else + {:failed, :unknown_failure} + end + end + + def interpret_adb_update(_output, _exit_code), do: {:failed, :unknown_failure} + + defp android_update_targets(device_id) do + case resolve_android_update_targets(device_id) do + {:ok, serials} -> {:ok, serials} + {:error, reason} -> {:error, android_target_error(device_id, reason)} + end + end + + defp resolve_adb_targets(output, nil) do + with {:ok, states} <- parse_adb_device_states(output) do + case Enum.find(states, fn {_serial, state} -> state != "device" end) do + {_serial, "offline"} -> {:error, :offline} + {_serial, "unauthorized"} -> {:error, :unauthorized} + {_serial, _state} -> {:error, :unknown_state} + nil -> ready_android_targets(states) + end + end + end + + defp resolve_adb_targets(output, device_id) do + with {:ok, states} <- parse_adb_device_states(output) do + matches = + Enum.filter(states, fn {serial, _state} -> matching_adb_serial?(serial, device_id) end) + + case matches do + [{serial, "device"}] -> {:ok, [serial]} + [{_serial, "offline"}] -> {:error, :offline} + [{_serial, "unauthorized"}] -> {:error, :unauthorized} + [{_serial, _state}] -> {:error, :unknown_state} + [] -> {:error, :target_not_connected} + _ -> {:error, :ambiguous_target} + end + end + end + + defp ready_android_targets([]), do: {:error, :no_targets} + + defp ready_android_targets(states) do + serials = + states + |> Enum.map(fn {serial, "device"} -> serial end) + |> Enum.sort() + + {:ok, serials} + end + + defp parse_adb_device_states(output) + when is_binary(output) and byte_size(output) > @max_adb_discovery_bytes, + do: {:error, :discovery_output_too_large} + + defp parse_adb_device_states(output) when is_binary(output) do + if String.valid?(output) do + lines = + output + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + with {:ok, rows} <- adb_device_rows(lines), + {:ok, states} <- parse_adb_device_rows(rows), + :ok <- validate_adb_device_states(states) do + {:ok, states} + end + else + {:error, :malformed_discovery} + end + end + + defp parse_adb_device_states(_output), do: {:error, :malformed_discovery} + + defp adb_device_rows(lines) do + {_notices, after_notices} = Enum.split_while(lines, &adb_daemon_notice?/1) + + case after_notices do + ["List of devices attached" | rows] -> {:ok, rows} + _ -> {:error, :malformed_discovery} + end + end + + defp adb_daemon_notice?(line), do: String.starts_with?(line, "* daemon ") + + defp parse_adb_device_rows(rows) do + result = + Enum.reduce_while(rows, [], fn row, states -> + case String.split(row) do + [serial, state] -> + if valid_adb_serial?(serial) do + {:cont, [{serial, state} | states]} + else + {:halt, :error} + end + + _ -> + {:halt, :error} + end + end) + + case result do + :error -> {:error, :malformed_discovery} + states -> {:ok, Enum.reverse(states)} + end + end + + defp validate_adb_device_states(states) do + serials = Enum.map(states, &elem(&1, 0)) + + cond do + Enum.uniq(serials) != serials -> {:error, :duplicate_target} + casefold_duplicates?(serials) -> {:error, :ambiguous_target} + length(states) > @max_android_update_targets -> {:error, :too_many_targets} + true -> :ok + end + end + + defp validate_android_update_serials(serials) do + cond do + Enum.any?(serials, &(not valid_adb_serial?(&1))) -> {:error, :invalid_target} + Enum.uniq(serials) != serials -> {:error, :duplicate_target} + casefold_duplicates?(serials) -> {:error, :ambiguous_target} + true -> :ok + end + end + + defp canonical_android_runtime_serials(serials) do + with {:ok, proper_serials} <- + collect_android_runtime_serials(serials, [], 0), + :ok <- validate_android_update_serials(proper_serials) do + {:ok, Enum.sort(proper_serials)} + else + {:error, reason} -> {:error, android_update_request_error(reason)} + end + end + + defp collect_android_runtime_serials([], [], 0), do: {:error, :no_explicit_targets} + defp collect_android_runtime_serials([], serials, _count), do: {:ok, Enum.reverse(serials)} + + defp collect_android_runtime_serials([_serial | _rest], _serials, @max_android_update_targets), + do: {:error, :too_many_targets} + + defp collect_android_runtime_serials([serial | rest], serials, count), + do: collect_android_runtime_serials(rest, [serial | serials], count + 1) + + defp collect_android_runtime_serials(_improper_or_invalid, _serials, _count), + do: {:error, :invalid_target} + + defp casefold_duplicates?(serials) do + normalized = Enum.map(serials, &String.downcase/1) + Enum.uniq(normalized) != normalized + end + + defp matching_adb_serial?(serial, device_id) do + serial == device_id or serial == "#{device_id}:5555" or strip_port(serial) == device_id + end + + defp install_android_update(apk, serial, runner) do + if valid_adb_serial?(serial) do + IO.puts(" Updating APK on #{serial} (preserving app data)...") + + result = + case invoke_command(runner, "adb", ["-s", serial, "install", "-r", apk]) do + {:ok, output, exit_code} -> interpret_adb_update(output, exit_code) + {:error, _reason} -> {:failed, :unknown_failure} + end + + case result do + :updated -> + {:ok, serial} + + {:failed, reason} -> + IO.puts( + " #{IO.ANSI.yellow()}⚠ #{serial}: APK update failed " <> + "(#{android_update_reason(reason)}); app data preserved#{IO.ANSI.reset()}" + ) + + {:error, %{serial: serial, reason: reason}} + end + else + {:error, %{serial: bounded_serial_label(serial), reason: :invalid_target}} + end + end + + defp interpret_adb_update_status(output, 0) do + lines = + output + |> String.split("\n", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + allowed = ["Performing Streamed Install", "Performing Incremental Install"] + + case Enum.reverse(lines) do + ["Success" | preceding] -> + if Enum.all?(preceding, &(&1 in allowed)), + do: :updated, + else: {:failed, :suspicious_success} + + _ -> + {:failed, :suspicious_success} + end + end + + defp interpret_adb_update_status(_output, _exit_code), do: {:failed, :unknown_failure} + + defp known_adb_failure(output) do + lower = String.downcase(output) + + cond do + String.contains?(output, "INSTALL_FAILED_INSUFFICIENT_STORAGE") -> + :insufficient_storage + + String.contains?(output, "INSTALL_FAILED_UPDATE_INCOMPATIBLE") or + String.contains?(output, "INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES") or + String.contains?(output, "INSTALL_FAILED_SHARED_USER_INCOMPATIBLE") -> + :signature_mismatch + + String.contains?(output, "INSTALL_FAILED_VERSION_DOWNGRADE") -> + :version_downgrade + + String.contains?(lower, "unauthorized") -> + :unauthorized + + String.contains?(lower, "device offline") or String.contains?(lower, "offline") -> + :offline + + String.contains?(lower, "no devices/emulators found") or + String.contains?(lower, "device not found") -> + :unavailable + + String.contains?(output, "INSTALL_FAILED_") or + String.contains?(output, "INSTALL_PARSE_FAILED_") -> + :install_rejected + + true -> + nil + end + end + + defp definite_android_install_rejection?(reason), + do: + reason in [ + :insufficient_storage, + :signature_mismatch, + :version_downgrade, + :install_rejected + ] + + defp android_target_error(nil, :no_targets), do: "No connected Android update targets found" + + defp android_target_error(nil, reason) do + "Connected Android update targets are #{android_update_reason(reason)}" + end + + defp android_target_error(device_id, reason) do + "Android update target #{bounded_serial_label(device_id)} is " <> + android_update_reason(reason) + end + + defp android_update_request_error(:no_explicit_targets), + do: "Android APK update requires at least one explicit target" + + defp android_update_request_error(:too_many_targets), + do: "Android APK update target count exceeds the safety limit" + + defp android_update_request_error(:authoritative_transaction_required), + do: "Android APK updates require the authoritative payload transaction" + + defp android_update_request_error(_reason), do: "Android APK update request is invalid" + + defp android_update_reason(:device_discovery_failed), do: "not discoverable" + defp android_update_reason(:discovery_output_too_large), do: "too large to validate safely" + defp android_update_reason(:malformed_discovery), do: "malformed" + defp android_update_reason(:duplicate_target), do: "duplicated" + defp android_update_reason(:too_many_targets), do: "over the target safety limit" + defp android_update_reason(:target_not_connected), do: "not connected" + defp android_update_reason(:ambiguous_target), do: "ambiguous" + defp android_update_reason(:unknown_state), do: "in an unknown adb state" + defp android_update_reason(:insufficient_storage), do: "out of storage" + defp android_update_reason(:signature_mismatch), do: "signed by a different key" + defp android_update_reason(:version_downgrade), do: "a version downgrade" + defp android_update_reason(:offline), do: "offline" + defp android_update_reason(:unauthorized), do: "unauthorized" + defp android_update_reason(:unavailable), do: "unavailable" + defp android_update_reason(:install_rejected), do: "rejected by Android" + defp android_update_reason(:suspicious_success), do: "an unverified adb success" + defp android_update_reason(:unknown_failure), do: "an unknown adb failure" + defp android_update_reason(:invalid_target), do: "an invalid target" + + defp bounded_serial_label(serial) when is_binary(serial) do + if valid_adb_serial?(serial), do: serial, else: "<invalid>" + end + + defp bounded_serial_label(_serial), do: "<invalid>" + + defp valid_adb_serial?(serial) when is_binary(serial) do + byte_size(serial) in 1..@max_adb_serial_bytes and not String.starts_with?(serial, "-") and + serial + |> :binary.bin_to_list() + |> Enum.all?(fn byte -> + byte in ?0..?9 or byte in ?A..?Z or byte in ?a..?z or byte in ~c".:-_" + end) + end + + defp valid_adb_serial?(_serial), do: false + + defp invoke_command(runner, executable, args) do + case runner.(executable, args) do + {output, exit_code} when is_binary(output) and is_integer(exit_code) -> + {:ok, output, exit_code} + + _ -> + {:error, :invalid_command_result} + end + end + + defp run_system_command(executable, args) do + case System.find_executable(executable) do + nil -> {"", 127} + path -> System.cmd(path, args, stderr_to_stdout: true) + end + end + + defp run_system_command(executable, args, opts) do + case System.find_executable(executable) do + nil -> {"", 127} + path -> System.cmd(path, args, Keyword.put_new(opts, :stderr_to_stdout, true)) + end + end + + @doc false + @deprecated "OTP mutation is only supported by install_and_deliver_android_runtime/8" + @spec deliver_android_otp_release( + String.t(), + String.t(), + String.t(), + String.t(), + String.t(), + String.t(), + keyword() + ) :: :ok | {:error, String.t()} + def deliver_android_otp_release( + _serial, + _bundle_id, + _elixir_lib, + _otp_arm64, + _otp_arm32, + _otp_x86_64, + _opts \\ [] + ), + do: {:error, "Android OTP delivery requires the authoritative payload transaction"} + + defp preflight_android_otp_candidates(otp_arm64, otp_arm32, otp_x86_64, elixir_lib) do + [otp_arm64, otp_arm32, otp_x86_64] + |> Enum.reduce_while(:ok, fn otp_dir, :ok -> + case android_runtime_sentinels(otp_dir, elixir_lib) do + {:ok, _sentinels} -> {:cont, :ok} + {:error, _reason} = error -> {:halt, error} + end + end) + end + + defp device_otp_selection(serial, otp_arm64, otp_arm32, otp_x86_64, runner) do + probe = + invoke_command(runner, "adb", [ + "-s", + serial, + "shell", + "getprop", + "ro.product.cpu.abi" + ]) + + with {:ok, abi} <- android_abi_from_probe(probe), + {:ok, otp_dir} <- + android_otp_dir_from_abi_probe(probe, otp_arm64, otp_arm32, otp_x86_64) do + {:ok, %{abi: abi, otp_dir: otp_dir}} + end + end + + @doc false + @spec android_otp_dir_from_abi_probe( + term(), + String.t(), + String.t(), + String.t() + ) :: {:ok, String.t()} | {:error, String.t()} + def android_otp_dir_from_abi_probe({:ok, output, 0}, otp_arm64, otp_arm32, otp_x86_64) + when is_binary(output) and byte_size(output) <= 128 do + with {:ok, abi} <- android_abi_from_probe({:ok, output, 0}) do + case abi do + "arm64-v8a" -> {:ok, otp_arm64} + "armeabi-v7a" -> {:ok, otp_arm32} + "x86_64" -> {:ok, otp_x86_64} + end + end + end + + def android_otp_dir_from_abi_probe({:ok, _output, status}, _arm64, _arm32, _x86_64) + when is_integer(status), + do: {:error, "Android ABI probe failed; refusing OTP delivery"} + + def android_otp_dir_from_abi_probe(_probe, _arm64, _arm32, _x86_64), + do: {:error, "Invalid Android ABI probe result; refusing OTP delivery"} + + defp android_abi_from_probe({:ok, output, 0}) + when is_binary(output) and byte_size(output) <= 128 do + if String.valid?(output) do + case String.trim(output) do + abi when abi in ["arm64-v8a", "armeabi-v7a", "x86_64"] -> {:ok, abi} + _unsupported -> {:error, "Unsupported or missing Android ABI; refusing OTP delivery"} + end + else + {:error, "Invalid Android ABI probe output; refusing OTP delivery"} + end + end + + defp android_abi_from_probe({:ok, _output, status}) when is_integer(status), + do: {:error, "Android ABI probe failed; refusing OTP delivery"} + + defp android_abi_from_probe(_probe), + do: {:error, "Invalid Android ABI probe result; refusing OTP delivery"} + + @doc "Returns the OTP directory for the given Android ABI string." + @spec otp_dir_for_abi(String.t(), String.t(), String.t()) :: String.t() + def otp_dir_for_abi("armeabi-v7a", _arm64, arm32), do: arm32 + def otp_dir_for_abi(_abi, arm64, _arm32), do: arm64 + + @doc "Returns the OTP directory for the given Android ABI string." + @spec otp_dir_for_abi(String.t(), String.t(), String.t(), String.t()) :: String.t() + def otp_dir_for_abi("armeabi-v7a", _arm64, arm32, _x86_64), do: arm32 + def otp_dir_for_abi("x86_64", _arm64, _arm32, x86_64), do: x86_64 + def otp_dir_for_abi(_abi, arm64, _arm32, _x86_64), do: arm64 + + defp ensure_android_package_for_otp(runner, serial, bundle_id) do + case invoke_command(runner, "adb", [ + "-s", + serial, + "shell", + "pm", + "list", + "packages", + bundle_id + ]) do + {:ok, pm_out, 0} -> + if android_package_listed?(pm_out, bundle_id) do + :ok + else + {:error, "Updated Android app is not installed; refusing OTP delivery"} + end + + {:ok, output, _status} -> + {:error, android_command_error("verify installed Android app", output)} + + {:error, _reason} -> + {:error, "verify installed Android app failed: invalid command result"} + end + end + + @doc false + @spec android_package_listed?(term(), String.t()) :: boolean() + def android_package_listed?(pm_out, bundle_id) + when is_binary(pm_out) and is_binary(bundle_id) do + valid_output? = + byte_size(pm_out) <= @max_adb_discovery_bytes and String.valid?(pm_out) + + valid_output? and + Enum.any?(String.split(pm_out, "\n"), &(String.trim(&1) == "package:#{bundle_id}")) + end + + def android_package_listed?(_pm_out, _bundle_id), do: false + + @doc false + @deprecated "OTP mutation is only supported by install_and_deliver_android_runtime/8" + @spec push_otp_runas( + String.t(), + String.t(), + String.t(), + String.t(), + String.t(), + keyword() + ) :: :ok | {:error, String.t()} + def push_otp_runas( + _serial, + _bundle_id, + _app_data, + _otp_dir, + _elixir_lib, + _opts \\ [] + ), + do: {:error, "Android OTP delivery requires the authoritative payload transaction"} + + defp prepare_otp_archive(app_data, otp_dir, elixir_lib, opts) do + runner = Keyword.get(opts, :runner, &run_system_command/3) + tmp_root = Keyword.get(opts, :tmp_root, System.tmp_dir!()) + + with {:ok, attempt_id} <- android_attempt_id(opts), + {:ok, sentinels} <- android_runtime_sentinels(otp_dir, elixir_lib) do + stage_local = Path.join(tmp_root, "mob_otp_#{attempt_id}.tar") + tmp = Path.join(tmp_root, "mob_otp_stage_#{attempt_id}") + otp_tmp = Path.join(tmp, "otp") + + local_result = + try do + File.rm(stage_local) + File.rm_rf!(tmp) + File.mkdir_p!(otp_tmp) + + with :ok <- + checked_command(runner, "stage OTP runtime", "cp", [ + "-r", + "#{otp_dir}/.", + otp_tmp + ]), + :ok <- prepare_elixir_stage(otp_tmp), + :ok <- + checked_command(runner, "stage Elixir runtime", "cp", [ + "-r", + "#{elixir_lib}/elixir/ebin/.", + Path.join(otp_tmp, "lib/elixir/ebin") + ]), + :ok <- + checked_command(runner, "stage Logger runtime", "cp", [ + "-r", + "#{elixir_lib}/logger/ebin/.", + Path.join(otp_tmp, "lib/logger/ebin") + ]), + :ok <- + checked_command(runner, "stage EEx runtime", "cp", [ + "-r", + "#{elixir_lib}/eex/ebin/.", + Path.join(otp_tmp, "lib/eex/ebin") + ]), + :ok <- + checked_command( + runner, + "create OTP archive", + "tar", + ["cf", stage_local, "-C", tmp, "otp"], + env: [{"COPYFILE_DISABLE", "1"}] + ), + {:ok, archive} <- freeze_android_otp_archive(stage_local) do + {:ok, + %{ + archive: archive, + attempt_id: attempt_id, + stage_device: "/data/local/tmp/mob_otp_#{attempt_id}.tar", + app_stage: "#{app_data}/.mob_otp_stage_#{attempt_id}", + app_backup: "#{app_data}/.mob_otp_backup_#{attempt_id}", + activation_lock: "#{app_data}/.mob_otp_activation_lock", + sentinels: sentinels + }} + end + after + File.rm_rf(tmp) + end + + case local_result do + {:ok, _prepared} = ok -> + ok + + {:error, _reason} = error -> + File.rm(stage_local) + error + end + end + end + + defp freeze_android_otp_archive(path) do + with :ok <- File.chmod(path, 0o400), + {:ok, %{type: :regular, size: size, mode: mode}} + when size > 0 and size <= @max_android_apk_bytes and Bitwise.band(mode, 0o222) == 0 <- + File.stat(path), + {:ok, sha256} <- file_sha256(path) do + {:ok, + %{ + path: path, + size: size, + sha256: Base.encode16(sha256, case: :lower) + }} + else + _failure -> {:error, "Could not freeze exact Android OTP archive"} + end + end + + defp deploy_prepared_otp( + runner, + owner_runner, + lock, + serial, + bundle_id, + app_data, + prepared + ) do + push_staged_otp( + runner, + owner_runner, + lock, + serial, + bundle_id, + app_data, + prepared.archive.path, + prepared.stage_device, + prepared.app_stage, + prepared.app_backup, + prepared.activation_lock, + prepared.sentinels + ) + end + + defp push_staged_otp( + runner, + owner_runner, + lock, + serial, + bundle_id, + app_data, + stage_local, + stage_device, + app_stage, + app_backup, + activation_lock, + sentinels + ) do + push_result = + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "push OTP archive", + ["push", stage_local, stage_device] + ) + + case push_result do + :ok -> + deploy_result = + with :ok <- + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "prepare app-private OTP staging directory", + [ + "shell", + "run-as #{bundle_id} sh -c 'test ! -e #{app_backup} && rm -rf #{app_stage} && mkdir -p #{app_stage}'" + ] + ), + :ok <- + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "extract OTP archive", + [ + "shell", + "run-as #{bundle_id} tar xof #{stage_device} -C #{app_stage}" + ] + ), + :ok <- + checked_command(runner, "verify staged OTP runtime", "adb", [ + "-s", + serial, + "shell", + runtime_verification_command(bundle_id, app_stage, sentinels) + ]), + :ok <- + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "activate OTP runtime", + [ + "shell", + otp_activation_command( + bundle_id, + app_data, + app_stage, + app_backup, + activation_lock, + sentinels + ) + ] + ), + :ok <- + checked_command(runner, "verify active OTP runtime", "adb", [ + "-s", + serial, + "shell", + runtime_verification_command(bundle_id, app_data, sentinels) + ]), + :ok <- + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "release OTP activation lock", + ["shell", activation_lock_release_command(bundle_id, activation_lock)] + ), + :ok <- + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "clean OTP activation backup", + ["shell", activation_backup_cleanup_command(bundle_id, app_backup)] + ) do + :ok + end + + case deploy_result do + :ok -> + merge_deploy_and_cleanup_results( + :ok, + cleanup_android_otp_stage( + runner, + owner_runner, + lock, + serial, + bundle_id, + stage_device, + app_stage + ) + ) + + {:error, _reason} = error -> + error + end + + {:error, _reason} = error -> + error + end + end + + defp prepare_elixir_stage(otp_tmp) do + for app <- ["elixir", "logger", "eex"] do + File.mkdir_p!(Path.join(otp_tmp, "lib/#{app}/ebin")) + end + + :ok + end + + defp android_runtime_sentinels(otp_dir, elixir_lib) + when is_binary(otp_dir) and is_binary(elixir_lib) do + erts_bin_dirs = Path.wildcard(Path.join(otp_dir, "erts-*/bin")) + + kernel_sentinel = Path.join(elixir_lib, "elixir/ebin/Elixir.Kernel.beam") + + with [erts_bin] <- erts_bin_dirs, + :ok <- validate_android_erts_helpers(erts_bin), + true <- File.regular?(kernel_sentinel), + {:ok, logger_sentinel} <- runtime_app_beam_sentinel(elixir_lib, "logger"), + {:ok, eex_sentinel} <- runtime_app_beam_sentinel(elixir_lib, "eex") do + relative_erts = Path.relative_to(Path.join(erts_bin, "erl_child_setup"), otp_dir) + + if Regex.match?(Regex.compile!(@android_erts_sentinel_pattern), relative_erts) do + relative_erts_bin = Path.dirname(relative_erts) + + {:ok, + [ + Path.join("otp", relative_erts), + Path.join(["otp", relative_erts_bin, "inet_gethost"]), + Path.join(["otp", relative_erts_bin, "epmd"]), + "otp/lib/elixir/ebin/Elixir.Kernel.beam", + Path.join("otp/lib/logger/ebin", logger_sentinel), + Path.join("otp/lib/eex/ebin", eex_sentinel) + ]} + else + {:error, "Unsafe Android runtime sentinel; refusing OTP delivery"} + end + else + _ -> + {:error, "Android runtime sentinel missing or ambiguous; refusing OTP delivery"} + end + end + + defp android_runtime_sentinels(_otp_dir, _elixir_lib), + do: {:error, "Android runtime source is invalid; refusing OTP delivery"} + + defp runtime_app_beam_sentinel(elixir_lib, app) do + sentinels = + elixir_lib + |> Path.join("#{app}/ebin/*.beam") + |> Path.wildcard() + |> Enum.filter(&File.regular?/1) + |> Enum.map(&Path.basename/1) + |> Enum.filter(fn basename -> + byte_size(basename) <= 255 and String.valid?(basename) and + Regex.match?(Regex.compile!("\\A[A-Za-z0-9_.-]+\\.beam\\z"), basename) + end) + |> Enum.sort() + + case sentinels do + [sentinel | _] -> {:ok, sentinel} + [] -> {:error, :missing_runtime_app_beam} + end + end + + defp runtime_verification_command(bundle_id, app_data, sentinels) do + checks = Enum.map_join(sentinels, " && ", &"test -r #{Path.join(app_data, &1)}") + "run-as #{bundle_id} sh -c '#{checks}'" + end + + defp otp_activation_command( + bundle_id, + app_data, + app_stage, + app_backup, + activation_lock, + sentinels + ) do + live_otp = Path.join(app_data, "otp") + staged_otp = Path.join(app_stage, "otp") + checks = Enum.map_join(sentinels, " && ", &"test -r #{Path.join(app_data, &1)}") + + "run-as #{bundle_id} sh -c 'set -e; mkdir #{activation_lock}; had_live=0; " <> + "if [ -e #{live_otp} ]; then mv #{live_otp} #{app_backup}; had_live=1; fi; " <> + "if mv #{staged_otp} #{live_otp} && #{checks}; then " <> + ":; else rm -rf #{live_otp}; " <> + "if [ \"$had_live\" -eq 1 ]; then mv #{app_backup} #{live_otp}; fi; exit 1; fi'" + end + + defp activation_lock_release_command(bundle_id, activation_lock), + do: "run-as #{bundle_id} rmdir #{activation_lock}" + + defp activation_backup_cleanup_command(bundle_id, app_backup), + do: "run-as #{bundle_id} rm -rf #{app_backup}" + + defp cleanup_android_otp_stage( + runner, + owner_runner, + lock, + serial, + bundle_id, + stage_device, + app_stage + ) do + cleanup_results = [ + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "clean app-private OTP staging directory", + ["shell", "run-as #{bundle_id} rm -rf #{app_stage}"] + ), + cleanup_remote_otp_archive(runner, owner_runner, lock, serial, stage_device) + ] + + Enum.find(cleanup_results, :ok, &match?({:error, _reason}, &1)) + end + + defp cleanup_remote_otp_archive(runner, owner_runner, lock, serial, stage_device) do + checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + "clean remote OTP archive", + ["shell", "rm -f #{stage_device}"] + ) + end + + defp checked_locked_android_command( + lock, + owner_runner, + runner, + serial, + operation, + args + ) do + with :ok <- verify_android_deploy_lock_set(lock, owner_runner) do + checked_command(runner, operation, "adb", ["-s", serial | args]) + end + end + + defp merge_deploy_and_cleanup_results(:ok, :ok), do: :ok + + defp merge_deploy_and_cleanup_results(:ok, {:error, _reason} = cleanup_error), + do: cleanup_error + + defp android_attempt_id(opts) do + attempt_id = + case Keyword.get(opts, :attempt_id) do + nil -> :crypto.strong_rand_bytes(12) |> Base.url_encode64(padding: false) + attempt_id -> attempt_id + end + + if is_binary(attempt_id) and String.valid?(attempt_id) and + Regex.match?(Regex.compile!(@android_attempt_id_pattern), attempt_id) do + {:ok, attempt_id} + else + {:error, "Invalid Android deploy attempt id; refusing OTP delivery"} + end + end + + defp validate_android_bundle_id(bundle_id) when is_binary(bundle_id) do + if byte_size(bundle_id) <= 255 and String.valid?(bundle_id) and + Regex.match?( + Regex.compile!("\\A[A-Za-z][A-Za-z0-9_]*(?:\\.[A-Za-z0-9_]+)+\\z"), + bundle_id + ) do + :ok + else + {:error, "Invalid Android bundle id; refusing OTP delivery"} + end + end + + defp validate_android_bundle_id(_bundle_id), + do: {:error, "Invalid Android bundle id; refusing OTP delivery"} + + defp validate_android_app_data(app_data, bundle_id) do + if app_data == "/data/data/#{bundle_id}/files" do + :ok + else + {:error, "Invalid Android app-data path; refusing OTP delivery"} + end + end + + defp checked_command(runner, operation, executable, args, opts \\ []) do + case runner.(executable, args, Keyword.put_new(opts, :stderr_to_stdout, true)) do + {output, 0} + when is_binary(output) and byte_size(output) <= @max_adb_discovery_bytes -> + if String.valid?(output), + do: :ok, + else: {:error, "#{operation} failed: invalid command output"} + + {_output, 0} -> + {:error, "#{operation} failed: invalid command output"} + + {output, _status} -> + {:error, android_command_error(operation, output)} + + _other -> + {:error, "#{operation} failed: invalid command result"} + end + end + + defp android_command_error(operation, _output), do: "#{operation} failed" + + # Filters a list of adb serials by `--device <id>`. The id is matched against + # the serial directly, against an `IP:port` form (auto-strip `:5555`), and + # against a bare IP for WiFi-adb devices. Returns all serials when device_id + # is nil. Returns empty + warning if device_id matches no connected serial. + @doc false + @spec filter_serials([String.t()], String.t() | nil) :: [String.t()] + def filter_serials(serials, nil), do: serials + + def filter_serials(serials, id) when is_binary(id) do + matches = + Enum.filter(serials, fn s -> + s == id or s == "#{id}:5555" or strip_port(s) == id + end) + + if matches == [] do + IO.puts( + " #{IO.ANSI.yellow()}⚠ --device #{id} matched no connected adb device — skipping#{IO.ANSI.reset()}" + ) + end + + matches + end + + defp strip_port(s) do + case String.split(s, ":", parts: 2) do + [host, _port] -> host + _ -> s + end + end + + # ── iOS ────────────────────────────────────────────────────────────────────── + + defp build_ios(cfg, device_id) do + with :ok <- check_path(cfg[:mob_dir], "mob_dir"), + :ok <- check_path(cfg[:elixir_lib], "elixir_lib"), + {:ok, otp_root} <- MobDev.OtpDownloader.ensure_ios_sim(), + {:ok, python_bundle} <- maybe_ensure_python_bundle(), + {:ok, mlx_dir} <- maybe_ensure_mlx_dir(:ios_sim), + {:ok, nxeigen_archive} <- maybe_build_nxeigen(:ios_sim), + {:ok, tflite_build} <- maybe_build_tflite(:ios_sim) do + IO.puts(" Building iOS simulator app...") + + mob_dir = Path.expand(cfg[:mob_dir]) + elixir_lib = Path.expand(cfg[:elixir_lib]) + app_module = Mix.Project.config() |> Keyword.fetch!(:app) |> Atom.to_string() + display_name = ios_display_name() + erts_vsn = detect_erts_vsn(otp_root) + project_swift_sources = project_swift_sources_arg(cfg) + + with {:ok, sdkroot} <- xcrun_sdk_path("iphonesimulator"), + :ok <- compile_elixir_for_ios(), + :ok <- copy_app_beams(otp_root, app_module), + :ok <- install_exqlite_otp_lib(otp_root), + :ok <- cross_compile_exqlite_nif_sim(otp_root, erts_vsn, sdkroot), + :ok <- install_emlx_otp_lib(otp_root), + :ok <- install_nx_eigen_otp_lib(otp_root), + :ok <- maybe_setup_pythonx_sim(otp_root, erts_vsn, sdkroot, python_bundle, app_module), + :ok <- maybe_install_crypto_shim(otp_root, app_module), + :ok <- maybe_copy_ssl_beams(otp_root, app_module), + :ok <- maybe_build_phoenix_assets(otp_root, app_module), + :ok <- copy_priv_repo_assets(otp_root, app_module), + :ok <- copy_elixir_stdlib_to_otp(elixir_lib, otp_root), + :ok <- copy_eex_stdlib_to_app(elixir_lib, otp_root, app_module), + :ok <- sync_otp_runtime_sim(otp_root), + :ok <- copy_mob_logos_sim(mob_dir, otp_root), + :ok <- spot_check_app_beams(otp_root, app_module, display_name), + {:ok, build_dir} <- create_native_build_dir("ios_sim"), + :ok <- generate_enif_keepalive(otp_root, erts_vsn, build_dir), + :ok <- + zig_build_binary_ios_sim( + mob_dir, + otp_root, + erts_vsn, + sdkroot, + build_dir, + display_name, + project_swift_sources, + mlx_dir, + nxeigen_archive, + tflite_build + ), + {:ok, sim_id} <- pick_ios_sim(device_id), + binary_path = "ios/zig-out/#{display_name}", + :ok <- check_path(binary_path, "iOS binary"), + {:ok, app_path} <- bundle_ios_app(binary_path, display_name), + :ok <- + copy_tflite_frameworks_ios( + tflite_build, + "ios-arm64_x86_64-simulator", + Path.join(app_path, "Frameworks") + ), + :ok <- install_ios_sim(sim_id, app_path) do + {:ok, "iOS"} + else + {:error, reason} -> {:error, "iOS", reason} + end + else + {:error, reason} -> {:error, "iOS", reason} + end + end + + # Phase 2 iter 13b: iOS sim build pipeline ported out of build.sh. + # Each helper mirrors a section of the prior shell script. + + defp xcrun_sdk_path(sdk) do + case System.cmd("xcrun", ["-sdk", sdk, "--show-sdk-path"], stderr_to_stdout: true) do + {out, 0} -> {:ok, String.trim(out)} + {_, _} -> {:error, "xcrun -sdk #{sdk} --show-sdk-path failed — Xcode CLT installed?"} + end + end + + defp compile_elixir_for_ios do + # Tells `mix compile` we're building for iOS so any `unless + # System.get_env("MOB_TARGET") == "ios" do …` compile-time gates short-circuit. + System.put_env("MOB_TARGET", "ios") + Mix.Task.run("compile") + :ok + end + + defp copy_app_beams(otp_root, app_module) do + beams_dir = Path.join(otp_root, app_module) + File.mkdir_p!(beams_dir) + chmod_writable(beams_dir) + + # Glob every compiled dep's ebin dir — covers vanilla (mob + ecto + + # ecto_sqlite3 + decimal + telemetry + jason + nimble_parsec) AND + # LiveView (Phoenix + Plug + Bandit + thousand_island + websock + etc.) + # without having to maintain a hardcoded dep list. + Path.wildcard("_build/dev/lib/*/ebin/*") + |> Enum.each(fn src -> + File.cp!(src, Path.join(beams_dir, Path.basename(src))) + end) + + :ok + end + + defp liveview_project? do + # Treat as LV iff the project has its own Phoenix asset pipeline + # (`assets/` at project root with package.json or tailwind config). + # Just having phoenix_live_view as a transitive dep (via mob) doesn't + # qualify — vanilla mob apps pull it in but have no `assets/` dir and + # no `mix assets.build` task. Without this guard, `Mix.Task.run("assets.build")` + # raises on vanilla projects. + File.exists?("assets/tailwind.config.js") or + File.exists?("assets/package.json") or + File.exists?("assets/css/app.css") + end + + defp chmod_writable(dir) do + _ = System.cmd("chmod", ["-R", "u+w", dir], stderr_to_stdout: true) + :ok + end + + # Stages nx_eigen + fine into the on-device OTP lib structure for the + # same reason as install_emlx_otp_lib/1 — without an emlx-VSN/-style + # dir, `:code.priv_dir(:nx_eigen)` returns `{:error, :bad_name}` and + # NxEigen.NIF.load_nif/0 crashes on Path.join. Staging an empty priv/ + # gives it a valid path; the static-NIF table then resolves the + # `libnx_eigen` lookup by basename. Also stages `:fine` (NxEigen's + # binding helper dep) so any code that consults `:code.priv_dir(:fine)` + # doesn't blow up. + @doc false + @spec install_nx_eigen_otp_lib(String.t(), Path.t()) :: :ok + def install_nx_eigen_otp_lib(otp_root, project_root \\ File.cwd!()) do + Enum.each([:nx_eigen, :fine], fn app -> + stage_empty_priv_otp_lib(otp_root, Atom.to_string(app), project_root) + end) + + :ok + end + + @doc false + @spec stage_empty_priv_otp_lib(String.t(), String.t(), Path.t()) :: :ok + def stage_empty_priv_otp_lib(otp_root, app, project_root \\ File.cwd!()) do + # `project_root` defaults to the current working directory, which is what + # the production caller (mix mob.deploy --native) wants. Tests can pass + # an explicit path so they don't have to wrap calls in File.cd!/2 — that + # changes process-wide cwd and races other async tests during parallel + # compilation (Kernel.ParallelCompiler.require_file picks up wrong paths). + ebin = Path.join([project_root, "_build", "dev", "lib", app, "ebin"]) + + if File.dir?(ebin) do + vsn = detect_dep_version(app) || read_app_vsn(Path.join(ebin, "#{app}.app")) || "0.0.0" + IO.puts(" === Installing #{app} as OTP library (priv/ empty — NIF static-linked)") + lib_dir = Path.join([otp_root, "lib", "#{app}-#{vsn}"]) + File.rm_rf!(Path.join(otp_root, "lib/#{app}-")) + File.mkdir_p!(Path.join(lib_dir, "ebin")) + File.mkdir_p!(Path.join(lib_dir, "priv")) + + Path.wildcard("#{ebin}/*.beam") + |> Enum.each(&File.cp!(&1, Path.join([lib_dir, "ebin", Path.basename(&1)]))) + + if File.exists?(Path.join(ebin, "#{app}.app")) do + File.cp!(Path.join(ebin, "#{app}.app"), Path.join([lib_dir, "ebin", "#{app}.app"])) + end + end + + :ok + end + + # Stages emlx into the on-device OTP lib structure (emlx-VSN/ebin + priv/) + # when :emlx is a project dep. Without this, `:code.priv_dir(:emlx)` returns + # `{:error, :bad_name}` and EMLX.NIF.load_nifs/0 can't compute its path arg + # — so the static-NIF table lookup never gets a chance to fire. + # + # The priv/ dir is intentionally empty: libemlx.a is statically linked into + # the main binary (not shipped as a .so), so EMLX.NIF.load_nifs's call to + # `:erlang.load_nif("priv/libemlx", 0)` resolves via the static table even + # though no .so exists at that path. + defp install_emlx_otp_lib(otp_root) do + ebin = Path.join(["_build", "dev", "lib", "emlx", "ebin"]) + + if not File.dir?(ebin) do + :ok + else + vsn = detect_dep_version("emlx") || read_app_vsn(Path.join(ebin, "emlx.app")) || "0.0.0" + IO.puts(" === Installing emlx as OTP library (priv/ empty — NIF is statically linked)") + lib_dir = Path.join([otp_root, "lib", "emlx-#{vsn}"]) + File.rm_rf!(Path.join(otp_root, "lib/emlx-")) + File.mkdir_p!(Path.join(lib_dir, "ebin")) + File.mkdir_p!(Path.join(lib_dir, "priv")) + + Path.wildcard("#{ebin}/*.beam") + |> Enum.each(&File.cp!(&1, Path.join([lib_dir, "ebin", Path.basename(&1)]))) + + if File.exists?(Path.join(ebin, "emlx.app")) do + File.cp!(Path.join(ebin, "emlx.app"), Path.join([lib_dir, "ebin", "emlx.app"])) + end + + :ok + end + end + + # Reads the `vsn` from an `<app>.app` Erlang term file. Used as a fallback + # when `detect_dep_version/1` (which reads mix.lock) misses. + defp read_app_vsn(app_file) do + with true <- File.exists?(app_file), + {:ok, content} <- File.read(app_file), + [match] <- Regex.run(~r/\{vsn,\s*"([^"]+)"\}/, content, capture: :all_but_first) do + match + else + _ -> nil + end + end + + defp install_exqlite_otp_lib(otp_root) do + ebin = Path.join(["_build", "dev", "lib", "exqlite", "ebin"]) + vsn = detect_dep_version("exqlite") + + case install_exqlite_decision(vsn, ebin) do + :noop -> + :ok + + :stale -> + IO.puts( + " [exqlite] stale mix.lock entry — not compiled in _build/dev/lib/exqlite, skipping" + ) + + :ok + + {:install, vsn} -> + IO.puts(" === Installing exqlite as OTP library") + lib_dir = Path.join([otp_root, "lib", "exqlite-#{vsn}"]) + # Remove any previous broken empty-version dir from older builds. + File.rm_rf!(Path.join(otp_root, "lib/exqlite-")) + File.mkdir_p!(Path.join(lib_dir, "ebin")) + File.mkdir_p!(Path.join(lib_dir, "priv")) + + Path.wildcard("#{ebin}/*.beam") + |> Enum.each(&File.cp!(&1, Path.join([lib_dir, "ebin", Path.basename(&1)]))) + + File.cp!(Path.join(ebin, "exqlite.app"), Path.join([lib_dir, "ebin", "exqlite.app"])) + :ok + end + end + + @doc """ + Decides what to do for the exqlite install step. + + * `:noop` — no exqlite lock entry; project doesn't use it. + * `:stale` — lock entry exists but the dep isn't compiled in + `_build/dev/lib/exqlite/`. Common cause: `ecto_sqlite3` was once + a dep, was removed, and the transitive `exqlite` lock entry + stayed behind (mix.lock isn't auto-pruned). Returning `:stale` + makes the caller skip cleanly instead of crashing on a + missing-source `File.cp!`. + * `{:install, vsn}` — version is locked and the `.app` file is + present; safe to install. + + Public so the stale-lock guard can be regression-tested without + setting up an end-to-end build. + """ + @spec install_exqlite_decision(String.t() | nil, String.t()) :: + :noop | :stale | {:install, String.t()} + def install_exqlite_decision(nil, _ebin), do: :noop + + def install_exqlite_decision(vsn, ebin) when is_binary(vsn) do + if File.exists?(Path.join(ebin, "exqlite.app")) do + {:install, vsn} + else + :stale + end + end + + defp cross_compile_exqlite_nif_sim(otp_root, erts_vsn, sdkroot) do + if File.dir?("deps/exqlite/c_src") do + vsn = detect_dep_version("exqlite") + out_so = Path.join([otp_root, "lib/exqlite-#{vsn}/priv/sqlite3_nif.so"]) + IO.puts(" === Cross-compiling sqlite3_nif.so for iOS simulator") + + # The macOS-compiled NIF from `mix deps.compile` is incompatible with + # the iOS sim (wrong platform tag). Recompile against iphonesimulator + # SDK so dlopen succeeds inside the simulator process. + args = [ + "-sdk", + "iphonesimulator", + "cc", + "-arch", + "arm64", + "-mios-simulator-version-min=17.0", + "-isysroot", + sdkroot, + "-Os", + "-ffunction-sections", + "-fdata-sections", + "-dynamiclib", + "-undefined", + "dynamic_lookup", + "-I", + "deps/exqlite/c_src", + "-I", + "#{otp_root}/#{erts_vsn}/include", + "-I", + "#{otp_root}/#{erts_vsn}/include/aarch64-apple-iossimulator", + "-DSQLITE_THREADSAFE=1", + "-Wno-#warnings", + "deps/exqlite/c_src/sqlite3_nif.c", + "deps/exqlite/c_src/sqlite3.c", + "-o", + out_so + ] + + case System.cmd("xcrun", args, stderr_to_stdout: true, into: IO.stream()) do + {_, 0} -> :ok + {_, _} -> {:error, "exqlite NIF cross-compile failed for iOS sim"} + end + else + :ok + end + end + + defp maybe_setup_pythonx_sim(_otp_root, _erts_vsn, _sdkroot, nil, _app_module), do: :ok + + defp maybe_setup_pythonx_sim(otp_root, erts_vsn, sdkroot, python_bundle, app_module) do + if File.dir?("_build/dev/lib/pythonx") do + vsn = detect_dep_version("pythonx") + lib_dir = Path.join([otp_root, "lib", "pythonx-#{vsn}"]) + beams_dir = Path.join(otp_root, app_module) + + IO.puts(" === Installing pythonx as OTP library") + # Wipe any previous version + Path.wildcard(Path.join(otp_root, "lib/pythonx-*")) |> Enum.each(&File.rm_rf!/1) + File.mkdir_p!(Path.join(lib_dir, "ebin")) + File.mkdir_p!(Path.join(lib_dir, "priv")) + + ebin = "_build/dev/lib/pythonx/ebin" + + Path.wildcard("#{ebin}/*.beam") + |> Enum.each(&File.cp!(&1, Path.join([lib_dir, "ebin", Path.basename(&1)]))) + + File.cp!(Path.join(ebin, "pythonx.app"), Path.join([lib_dir, "ebin", "pythonx.app"])) + + Path.wildcard("#{ebin}/*") + |> Enum.each(&File.cp!(&1, Path.join(beams_dir, Path.basename(&1)))) + + if File.dir?("_build/dev/lib/fine") do + Path.wildcard("_build/dev/lib/fine/ebin/*") + |> Enum.each(&File.cp!(&1, Path.join(beams_dir, Path.basename(&1)))) + end + + python_framework = + Path.join([ + python_bundle, + "Python.xcframework/ios-arm64_x86_64-simulator/Python.framework" + ]) + + python_stdlib = Path.join([python_bundle, "Python.xcframework/lib/python3.13"]) + + python_lib_dynload = + Path.join([ + python_bundle, + "Python.xcframework/ios-arm64_x86_64-simulator/lib-arm64/python3.13/lib-dynload" + ]) + + cond do + not File.dir?(python_framework) -> + {:error, "Python.framework missing at #{python_framework}"} + + not File.dir?(python_stdlib) -> + {:error, "Python stdlib missing at #{python_stdlib}"} + + not File.dir?(python_lib_dynload) -> + {:error, "lib-dynload missing at #{python_lib_dynload}"} + + true -> + IO.puts(" === Cross-compiling libpythonx.so for iOS simulator") + + xcrun_args = [ + "-sdk", + "iphonesimulator", + "clang++", + "-arch", + "arm64", + "-dynamiclib", + "-undefined", + "dynamic_lookup", + "-fPIC", + "-fvisibility=hidden", + "-std=c++17", + "-isysroot", + sdkroot, + "-mios-simulator-version-min=17.0", + "-install_name", + "@rpath/libpythonx.so", + "-Os", + "-ffunction-sections", + "-fdata-sections", + "-I", + "#{otp_root}/#{erts_vsn}/include", + "-I", + "#{otp_root}/#{erts_vsn}/include/aarch64-apple-iossimulator", + "-I", + "deps/fine/c_include", + "-Wno-unused-parameter", + "-Wno-comment", + "deps/pythonx/c_src/pythonx.cpp", + "deps/pythonx/c_src/python.cpp", + "-o", + Path.join(lib_dir, "priv/libpythonx.so") + ] + + case System.cmd("xcrun", xcrun_args, stderr_to_stdout: true, into: IO.stream()) do + {_, 0} -> + IO.puts(" === Bundling Python.framework + stdlib + lib-dynload (sim slice)") + python_dir = Path.join(otp_root, "python") + File.mkdir_p!(Path.join(python_dir, "lib")) + chmod_writable(python_dir) + + File.rm_rf!(Path.join(python_dir, "Python.framework")) + File.rm_rf!(Path.join(python_dir, "lib/python3.13")) + + copy_dir!(python_framework, Path.join(python_dir, "Python.framework")) + copy_dir!(python_stdlib, Path.join(python_dir, "lib/python3.13")) + copy_dir!(python_lib_dynload, Path.join(python_dir, "lib/python3.13/lib-dynload")) + # Project-supplied wheels into site-packages (matches Android's + # ensure_python_android_libs path). Without this, projects that + # bundle e.g. rns / lxmf in priv/python_wheels/ boot the + # simulator, hit `import RNS`, and hang on the launch spinner. + # The iOS *device* path (maybe_setup_pythonx_device) gets the + # same call below — see nif_future.md item #4 for the original + # bug report. The ios-safe variant filters wheels that + # contain Android-only `.so` extensions (cffi, cryptography + # etc.) which would otherwise crash iOS Python at import. + copy_ios_safe_project_python_wheels( + python_dir, + Path.join("priv", "python_wheels") + ) + + :ok + + {_, _} -> + {:error, "pythonx NIF cross-compile failed"} + end + end + else + :ok + end + end + + defp copy_dir!(src, dst) do + {_, 0} = System.cmd("cp", ["-R", src, dst], stderr_to_stdout: true) + :ok + end + + defp detect_dep_version(name) do + # Try mix.lock first; fall back to .app file's vsn. + lock_match = + case File.read("mix.lock") do + {:ok, content} -> + Regex.run(~r/"#{name}"[^"]*"([0-9]+\.[0-9]+\.[0-9]+)"/, content) + + _ -> + nil + end + + case lock_match do + [_, vsn] -> + vsn + + _ -> + app_file = "_build/dev/lib/#{name}/ebin/#{name}.app" + + case File.read(app_file) do + {:ok, content} -> + case Regex.run(~r/\{vsn,\s*"([^"]+)"\}/, content) do + [_, vsn] -> vsn + _ -> nil + end + + _ -> + nil + end + end + end + + defp maybe_install_crypto_shim(otp_root, app_module) do + if liveview_project?() do + # The iOS OTP build does not include OpenSSL/crypto. Phoenix and + # plug_crypto declare :crypto as a required application; without a + # shim, application:ensure_started(:crypto) fails and the app won't + # boot. The shim implements the subset of crypto functions + # plug_crypto + Plug.CSRFProtection actually call in the loopback + # HTTP-only path: pbkdf2_hmac/5 (KeyGenerator), exor/2 (CSRF token + # masking), mac/3+/4 (HMAC-MD5 via erlang:md5/1), and start/2. + IO.puts(" === Creating crypto shim (LV)") + + crypto_tmp = + Path.join(System.tmp_dir!(), "mob_crypto_#{System.unique_integer([:positive])}") + + File.mkdir_p!(crypto_tmp) + beams_dir = Path.join(otp_root, app_module) + + try do + File.write!(Path.join(crypto_tmp, "crypto.erl"), crypto_shim_erl()) + + case System.cmd("erlc", ["-o", beams_dir, Path.join(crypto_tmp, "crypto.erl")], + stderr_to_stdout: true, + into: IO.stream() + ) do + {_, 0} -> + File.write!(Path.join(beams_dir, "crypto.app"), crypto_shim_app()) + :ok + + {_, _} -> + {:error, "crypto shim erlc failed"} + end + after + File.rm_rf!(crypto_tmp) + end + else + :ok + end + end + + defp crypto_shim_erl do + """ + -module(crypto). + -behaviour(application). + -export([start/2, stop/1, strong_rand_bytes/1, rand_bytes/1, + hash/2, mac/4, mac/3, supports/1, exor/2, + generate_key/2, compute_key/4, sign/4, verify/5, + pbkdf2_hmac/5]). + start(_Type, _Args) -> {ok, self()}. + stop(_State) -> ok. + strong_rand_bytes(N) -> rand:bytes(N). + rand_bytes(N) -> rand:bytes(N). + hash(_Type, Data) -> erlang:md5(iolist_to_binary(Data)). + supports(_Type) -> []. + generate_key(_Alg, _Params) -> {<<>>, <<>>}. + compute_key(_Alg, _OtherKey, _MyKey, _Params) -> <<>>. + sign(_Alg, _DigestType, _Msg, _Key) -> <<>>. + verify(_Alg, _DigestType, _Msg, _Signature, _Key) -> true. + exor(A, B) -> xor_bytes(iolist_to_binary(A), iolist_to_binary(B)). + mac(hmac, _HashAlg, Key, Data) -> + hmac_md5(iolist_to_binary(Key), iolist_to_binary(Data)); + mac(_Type, _SubType, _Key, _Data) -> <<>>. + mac(_Type, _Key, _Data) -> <<>>. + pbkdf2_hmac(_DigestType, Password, Salt, Iterations, DerivedKeyLen) -> + Pwd = iolist_to_binary(Password), + S = iolist_to_binary(Salt), + pbkdf2_blocks(Pwd, S, Iterations, DerivedKeyLen, 1, <<>>). + pbkdf2_blocks(_Pwd, _Salt, _Iter, Len, _Block, Acc) when byte_size(Acc) >= Len -> + binary:part(Acc, 0, Len); + pbkdf2_blocks(Pwd, Salt, Iter, Len, Block, Acc) -> + U1 = hmac_md5(Pwd, <<Salt/binary, Block:32/unsigned-big-integer>>), + Ux = pbkdf2_iterate(Pwd, Iter - 1, U1, U1), + pbkdf2_blocks(Pwd, Salt, Iter, Len, Block + 1, <<Acc/binary, Ux/binary>>). + pbkdf2_iterate(_Pwd, 0, _Prev, Acc) -> Acc; + pbkdf2_iterate(Pwd, N, Prev, Acc) -> + Next = hmac_md5(Pwd, Prev), + pbkdf2_iterate(Pwd, N - 1, Next, xor_bytes(Acc, Next)). + hmac_md5(Key0, Data) -> + BlockSize = 64, + Key = if byte_size(Key0) > BlockSize -> erlang:md5(Key0); true -> Key0 end, + PadLen = BlockSize - byte_size(Key), + K = <<Key/binary, 0:(PadLen * 8)>>, + IPad = xor_bytes(K, binary:copy(<<16#36>>, BlockSize)), + OPad = xor_bytes(K, binary:copy(<<16#5C>>, BlockSize)), + erlang:md5(<<OPad/binary, (erlang:md5(<<IPad/binary, Data/binary>>))/binary>>). + xor_bytes(A, B) -> xor_bytes(A, B, []). + xor_bytes(<<X, Ra/binary>>, <<Y, Rb/binary>>, Acc) -> + xor_bytes(Ra, Rb, [X bxor Y | Acc]); + xor_bytes(<<>>, <<>>, Acc) -> + list_to_binary(lists:reverse(Acc)). + """ + end + + defp crypto_shim_app do + ~S|{application,crypto,[{modules,[crypto]},{applications,[kernel,stdlib]},{description,"Crypto shim for iOS (HTTP-only; no OpenSSL)"},{registered,[]},{vsn,"5.6"},{mod,{crypto,[]}}]}.| <> + "\n" + end + + defp maybe_copy_ssl_beams(otp_root, app_module) do + if liveview_project?() do + # thousand_island lists :ssl as a required app. Pure Erlang — host + # macOS .beam files run unchanged on iOS sim. We grab the latest + # available ssl-* dir from the host OTP install. + home = System.get_env("HOME") || "" + host_ssl_dirs = Path.wildcard(home <> "/.local/share/mise/installs/erlang/*/lib/ssl-*") + latest_ssl = host_ssl_dirs |> Enum.sort(:desc) |> List.first() + + case latest_ssl do + nil -> + IO.puts(" === ssl beams not found in host OTP -- thousand_island may fail to start") + :ok + + host_ssl -> + IO.puts(" === Copying ssl beams from host OTP") + beams_dir = Path.join(otp_root, app_module) + ebin = Path.join(host_ssl, "ebin") + + if File.dir?(ebin) do + Path.wildcard("#{ebin}/*.beam") + |> Enum.each(&File.cp!(&1, Path.join(beams_dir, Path.basename(&1)))) + + ssl_app = Path.join(ebin, "ssl.app") + if File.exists?(ssl_app), do: File.cp!(ssl_app, Path.join(beams_dir, "ssl.app")) + IO.puts(" * ssl copied from #{host_ssl}") + end + + :ok + end + else + :ok + end + end + + defp maybe_build_phoenix_assets(otp_root, app_module) do + if liveview_project?() do + IO.puts(" === Building Phoenix static assets") + Mix.Task.run("assets.build") + + beams_dir = Path.join(otp_root, app_module) + static_src = "priv/static" + + if File.dir?(static_src) do + static_dst = Path.join(beams_dir, "priv/static") + File.mkdir_p!(static_dst) + copy_dir!(static_src <> "/.", static_dst) + end + end + + :ok + end + + defp copy_priv_repo_assets(otp_root, app_module) do + # Copy the WHOLE priv/ into BEAMS_DIR/priv (not just repo/migrations), so + # Application.app_dir(:<app>, "priv/...") resolves on device/sim — e.g. + # priv/cacerts.pem (Mob.Certs.load_cacerts!), priv/mix + priv/hex ebins + # (on-device Mix.install), a vendored lib's priv/ (Livebook's static), etc. + # Mirrors the device release's full-priv rsync; without it the sim boot + # crashed at Mob.Certs.load_cacerts! with :enoent on priv/cacerts.pem. + if File.dir?("priv") do + IO.puts(" === Copying priv/ (full)") + dst = Path.join([otp_root, app_module, "priv"]) + File.mkdir_p!(dst) + chmod_writable(dst) + + {_, status} = + System.cmd("rsync", ["-a", "--no-perms", "priv/", "#{dst}/"], stderr_to_stdout: true) + + if status != 0, do: raise("rsync priv/ -> #{dst} failed") + end + + :ok + end + + defp copy_elixir_stdlib_to_otp(elixir_lib, otp_root) do + IO.puts(" === Copying Elixir stdlib") + + for app <- ~w(elixir logger) do + dst = Path.join([otp_root, "lib", app, "ebin"]) + File.mkdir_p!(dst) + chmod_writable(dst) + + src_ebin = Path.join([elixir_lib, app, "ebin"]) + + if File.dir?(src_ebin) do + Path.wildcard("#{src_ebin}/*.beam") + |> Enum.each(&File.cp!(&1, Path.join(dst, Path.basename(&1)))) + + app_file = Path.join(src_ebin, "#{app}.app") + + if File.exists?(app_file), + do: File.cp!(app_file, Path.join(dst, "#{app}.app")) + end + end + + :ok + end + + defp copy_eex_stdlib_to_app(elixir_lib, otp_root, app_module) do + # The iOS sim's mob_beam.m doesn't add lib/<app>/ebin to the code path, so + # the Elixir-distribution apps that copy_elixir_stdlib_to_otp drops under + # lib/ (elixir, logger) — plus eex — are invisible there: boot fails at + # `ensure_all_started(:elixir)` with "elixir.app not found". Drop their .app + # + beams into BEAMS_DIR (flat), which IS on the path, so they resolve. + # (eex was already needed for Ecto's startup; elixir/logger are needed for + # the sim to boot at all. Harmless on device, which also has them in lib/.) + IO.puts(" === Copying Elixir-distribution apps (elixir, logger, eex) to BEAMS_DIR") + dst = Path.join(otp_root, app_module) + File.mkdir_p!(dst) + + for app <- ~w(elixir logger eex) do + src_ebin = Path.join([elixir_lib, app, "ebin"]) + + if File.dir?(src_ebin) do + Path.wildcard("#{src_ebin}/*.beam") + |> Enum.each(&File.cp!(&1, Path.join(dst, Path.basename(&1)))) + + app_file = Path.join(src_ebin, "#{app}.app") + if File.exists?(app_file), do: File.cp!(app_file, Path.join(dst, "#{app}.app")) + end + end + + :ok + end + + defp sync_otp_runtime_sim(otp_root) do + runtime_dir = System.get_env("MOB_SIM_RUNTIME_DIR") || Path.expand("~/.mob/runtime/ios-sim") + IO.puts(" === Syncing OTP runtime to #{runtime_dir}") + File.mkdir_p!(runtime_dir) + chmod_writable(runtime_dir) + + # --no-perms is essential on Nix systems where ELIXIR_LIB lives in + # /nix/store at mode 444 — without it, BSD cp's preserved-mode would + # leave 444 .beam files in OTP_ROOT, which then carry over into + # RUNTIME_DIR and break the next deploy's overwrite. + {_, status} = + System.cmd("rsync", ["-a", "--delete", "--no-perms", "#{otp_root}/", "#{runtime_dir}/"], + stderr_to_stdout: true, + into: IO.stream() + ) + + chmod_writable(runtime_dir) + if status == 0, do: :ok, else: {:error, "rsync to #{runtime_dir} failed"} + end + + defp copy_mob_logos_sim(mob_dir, otp_root) do + runtime_dir = System.get_env("MOB_SIM_RUNTIME_DIR") || Path.expand("~/.mob/runtime/ios-sim") + IO.puts(" === Copying Mob logos") + + for variant <- ~w(dark light) do + src = Path.join(mob_dir, "assets/logo/logo_#{variant}.png") + dst = Path.join(runtime_dir, "mob_logo_#{variant}.png") + if File.exists?(src), do: File.cp!(src, dst) + end + + _ = otp_root + :ok + end + + defp spot_check_app_beams(otp_root, app_module, display_name) do + IO.puts(" === Spot-check") + beams_dir = Path.join(otp_root, app_module) + + candidates = [ + "Elixir.#{display_name}.App.beam", + "Elixir.#{display_name}.HomeScreen.beam" + ] + + Enum.each(candidates, fn beam -> + path = Path.join(beams_dir, beam) + if File.exists?(path), do: IO.puts(" ✓ #{path}") + end) + + :ok + end + + defp create_native_build_dir(suffix) do + dir = Path.join(System.tmp_dir!(), "mob_#{suffix}_#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + {:ok, dir} + end + + defp generate_enif_keepalive(otp_root, erts_vsn, build_dir) do + # Pull every `T _enif_*` symbol out of erl_nif.o inside libbeam.a and + # generate a __attribute__((used)) reference for each. -dead_strip on + # the final link otherwise drops them, and runtime dlopen of dynamic + # NIFs (libpythonx.so etc.) fails with "symbol not found in flat + # namespace '_enif_is_pid'". + IO.puts(" === Generating enif_* keep-alive table") + libbeam = Path.join([otp_root, erts_vsn, "lib/libbeam.a"]) + nif_o_dir = Path.join(System.tmp_dir!(), "mob_nifo_#{System.unique_integer([:positive])}") + File.mkdir_p!(nif_o_dir) + + try do + ar = ar_path() + + # Try the GNU ar --output flag first, fall back to BSD ar (extracts to cwd). + _ = + case System.cmd(ar, ["x", libbeam, "--output=#{nif_o_dir}", "erl_nif.o"], + stderr_to_stdout: true + ) do + {_, 0} -> + :ok + + _ -> + {_, 0} = + System.cmd(ar, ["x", libbeam, "erl_nif.o"], cd: nif_o_dir, stderr_to_stdout: true) + + :ok + end + + erl_nif_o = Path.join(nif_o_dir, "erl_nif.o") + if not File.exists?(erl_nif_o), do: throw({:error, "ar x failed to extract erl_nif.o"}) + + {nm_out, 0} = + System.cmd("xcrun", ["nm", "-arch", "arm64", erl_nif_o], stderr_to_stdout: true) + + symbols = + nm_out + |> String.split("\n") + |> Enum.flat_map(fn line -> + case Regex.run(~r/ T _(enif_\w+)$/, line) do + [_, sym] -> [sym] + _ -> [] + end + end) + |> Enum.uniq() + + content = + [ + "/* Auto-generated. References every enif_* in erl_nif.o so dead_strip keeps them. */\n" + | Enum.map(symbols, fn sym -> + "extern void #{sym}(void); __attribute__((used)) static void *_keep_#{sym} = (void *)&#{sym};\n" + end) + ] + + File.write!(Path.join(build_dir, "enif_keepalive.c"), content) + IO.puts(" #{length(symbols)} enif_* symbols pinned") + :ok + after + File.rm_rf!(nif_o_dir) + end + end + + defp ar_path do + case System.cmd("xcrun", ["-find", "ar"], stderr_to_stdout: true) do + {out, 0} -> String.trim(out) + _ -> "ar" + end + end + + # Emits the iOS plugin bootstrap Swift file the build step later compiles + # alongside the project + plugin Swift sources. Returns the absolute path of + # the written file so the caller can append it to `plugin_swift_files`. + # + # The file is regenerated on every iOS build. The content is purely a + # function of the activated-plugin manifests, so this is cheap and keeps + # the build cache aligned with the manifest set (no stale registrations + # from a plugin that just got deactivated). + defp generate_ios_plugin_bootstrap(build_dir) do + out_path = Path.join(build_dir, "mob_plugin_bootstrap.swift") + source = MobDev.Plugin.IOSBootstrap.swift_source(MobDev.Plugin.activated()) + File.write!(out_path, source) + out_path + end + + @doc false + # Pure kernel: does an iOS build file (build.zig / build_device.zig) accept the + # `plugin_swift_files` option? Presence of that token means the app was + # scaffolded from a plugin-aware template — whose AppDelegate also always calls + # `mob_register_plugins()`. So even with zero plugins activated we must still + # feed it the (empty) bootstrap that defines that symbol, or the link fails + # with `undefined _mob_register_plugins` (MOB-7). Legacy scaffolds lack the + # token *and* never call the symbol, so they stay on the empty-flags path. + @spec build_file_supports_plugins?(String.t()) :: boolean() + def build_file_supports_plugins?(content), do: String.contains?(content, "plugin_swift_files") + + @doc false + # Thin I/O wrapper around build_file_supports_plugins?/1. Missing/unreadable + # file ⇒ false (treat as legacy: omit the flags rather than risk an unknown + # -D option on an old build.zig). Public (@doc false) so the missing-file + # branch is testable. + @spec ios_build_file_supports_plugins?(String.t()) :: boolean() + def ios_build_file_supports_plugins?(path) do + case File.read(path) do + {:ok, content} -> build_file_supports_plugins?(content) + _ -> false + end + end + + @doc false + # Pure decision — which iOS plugin-swift mode applies. Extracted from + # ios_plugin_swift_and_frameworks/3 so the MOB-7 branch (`:bootstrap_only` — + # no plugins, but a plugin-aware build file whose AppDelegate still calls + # mob_register_plugins) is unit-testable without file I/O or the plugin + # registry. A regression flipping that branch back to `:none` reintroduces + # MOB-7, so it must be pinned. + @spec ios_plugin_swift_mode([term()], boolean()) :: :with_plugins | :bootstrap_only | :none + def ios_plugin_swift_mode([], true), do: :bootstrap_only + def ios_plugin_swift_mode([], false), do: :none + def ios_plugin_swift_mode(_activated, _supports?), do: :with_plugins + + # Resolves the {plugin_swift_files, plugin_frameworks} pair for an iOS build, + # shared by the sim and device paths. Activated plugins ⇒ their Swift + the + # bootstrap. No plugins but a plugin-aware build file ⇒ just the bootstrap (so + # mob_register_plugins is defined — see MOB-7). Otherwise empty (flags omitted). + defp ios_plugin_swift_and_frameworks(activated_plugins, build_dir, ios_build_file) do + # `and` short-circuits: with plugins activated we never read the build file, + # so the activated path is byte-identical to before this MOB-7 change. + supports? = activated_plugins == [] and ios_build_file_supports_plugins?(ios_build_file) + + case ios_plugin_swift_mode(activated_plugins, supports?) do + :with_plugins -> + bootstrap_path = generate_ios_plugin_bootstrap(build_dir) + + swift = + (MobDev.Plugin.Merge.swift_files(activated_plugins) ++ [bootstrap_path]) + |> Enum.join(",") + + frameworks = + activated_plugins |> MobDev.Plugin.Merge.ios_frameworks() |> Enum.join(",") + + {swift, frameworks} + + :bootstrap_only -> + {generate_ios_plugin_bootstrap(build_dir), ""} + + :none -> + {"", ""} + end + end + + defp zig_build_binary_ios_sim( + mob_dir, + otp_root, + erts_vsn, + sdkroot, + build_dir, + display_name, + project_swift_sources, + mlx_dir, + nxeigen_archive, + tflite_build + ) do + driver_tab = resolve_driver_tab_ios(mob_dir) + + # Plugin-contributed Swift sources + extra iOS frameworks gathered from + # activated plugin manifests. Empty strings when no plugin contributes, + # so the build.zig templates `orelse ""` and the flags are safe to emit + # unconditionally (mirrors the Android plugin_c_nifs pattern at the top + # of run_zig_android_objects). + activated_plugins = MobDev.Plugin.activated() + + # Capability enforcement (see MOB_PLUGIN_SECURITY.md, Layer 2): refuse to + # link an activated plugin whose Swift source imports a framework — or + # whose AndroidManifest references a permission — its manifest doesn't + # declare. Raises with the full list of drifts when any are found. + MobDev.Plugin.Validator.raise_on_capability_drift!(activated_plugins) + + # Generated bootstrap Swift gets compiled alongside the plugins' own Swift + # files via -Dplugin_swift_files. The bootstrap also defines + # mob_register_plugins(), which a plugin-aware AppDelegate always calls — so + # even a zero-plugin app on a current build.zig needs it (see MOB-7). Legacy + # scaffolds (no plugin_swift_files option, no call to the symbol) get empty + # flags, omitted below, keeping them building. + {plugin_swift_files, plugin_frameworks} = + ios_plugin_swift_and_frameworks( + activated_plugins, + build_dir, + Path.expand("ios/build.zig") + ) + + # Activated plugins' C NIF sources (tier-1 plugins). Mirrors the Android + # plugin_c_nifs path and the iOS project_c_nifs path: each .c is compiled + + # linked into the app so the plugin's <module>_nif_init symbol (referenced + # by the generated driver_tab_ios, which resolved_nifs/0 already populates) + # resolves at link time. Empty when no NIF-bearing plugin is activated. + # (zig plugin NIFs on iOS aren't wired yet — no current plugin needs one; + # bt's zig NIF was Android-only. Add a plugin_zig_nifs path here + in + # ios/build.zig if a future plugin ships an iOS zig NIF.) + plugin_c_nifs = MobDev.Plugin.Merge.nif_sources(activated_plugins, :ios) |> Enum.join(",") + + base_args = [ + "build", + "binary", + "--build-file", + "ios/build.zig", + "-Dmob_dir=#{mob_dir}", + "-Dotp_root=#{otp_root}", + "-Derts_vsn=#{erts_vsn}", + "-Dsdkroot=#{sdkroot}", + "-Ddriver_tab=#{driver_tab}", + "-Denif_keepalive=#{Path.join(build_dir, "enif_keepalive.c")}", + "-Dproject_ios_dir=#{Path.expand("ios")}", + "-Dmodule_name=#{display_name}", + "-Dproject_swift_sources=#{project_swift_sources}" + ] + + # Omit -Dplugin_* when empty (no plugins) so apps on pre-plugin ios/build.zig + # don't choke on unknown options; a plugin-aware build.zig defaults them to "". + plugin_args = + for {name, val} <- [ + {"plugin_swift_files", plugin_swift_files}, + {"plugin_frameworks", plugin_frameworks}, + {"plugin_c_nifs", plugin_c_nifs} + ], + val != "", + do: "-D#{name}=#{val}" + + with {:ok, nif_args} <- project_nif_zig_args(:ios_sim), + {:ok, plugin_archives} <- build_plugin_static_archives(:ios_sim, :ios, otp_root) do + args = + base_args ++ + plugin_args ++ + nif_args ++ + mlx_zig_args(mlx_dir) ++ + nxeigen_zig_args_ios(nxeigen_archive) ++ + tflite_zig_args_ios(tflite_build) ++ + plugin_static_lib_args(plugin_archives) + + case System.cmd("zig", args, stderr_to_stdout: true, into: IO.stream()) do + {_, 0} -> :ok + {_, code} -> {:error, "zig build binary (iOS sim) exited #{code}"} + end + end + end + + # Returns the zig -D options that enable static linking of MLX + EMLX into + # the iOS app binary. `mlx_dir` is the cached extraction root from + # MobDev.MLXDownloader — containing lib/libmlx.a, lib/libemlx.a, include/. + # `nil` means EMLX isn't in the project, so emit no MLX flags. + defp mlx_zig_args(nil), do: [] + + defp mlx_zig_args(mlx_dir) when is_binary(mlx_dir), + do: ["-Dmlx_static=true", "-Dmlx_dir=#{mlx_dir}"] + + # ── NxEigen ──────────────────────────────────────────────────────────── + # Mirrors the MLX hooks but with one important difference: MLX is + # downloaded as a pre-built bundle (MobDev.MLXDownloader pulls a + # tarball); NxEigen we cross-compile ourselves from sources in the + # user's `deps/nx_eigen/` via MobDev.NxEigenNif.build/2. The output + # lives in `_build/<env>/nxeigen/<target>/` so `mix clean` removes it. + + @doc false + @spec nxeigen_in_project?() :: boolean() + def nxeigen_in_project? do + Mix.Project.config() + |> Keyword.get(:deps, []) + |> Enum.any?(fn + {:nx_eigen, _} -> true + {:nx_eigen, _, _} -> true + _ -> false + end) + end + + # Build libnx_eigen.a for the given target if nx_eigen is in the project's + # deps. Returns `{:ok, archive_path}` on success, `{:ok, nil}` if nx_eigen + # isn't a dep, or a tagged-tuple error. + defp maybe_build_nxeigen(target_id) + when target_id in [:android_arm64, :android_arm32, :ios_sim, :ios_device] do + cond do + # An activated cpp_archive plugin (mob_nx_eigen) provides nx_eigen_nif_init + # itself, so the legacy core build must yield — otherwise both emit + # libnx_eigen*.a with the same symbol and the link fails on a duplicate. + # Lets the plugin path be device-verified before the core hooks are + # removed; the core path stays the fallback for apps not yet on the plugin. + nxeigen_provided_by_plugin?() -> + {:ok, nil} + + nxeigen_in_project?() -> + do_build_nxeigen(target_id) + + true -> + {:ok, nil} + end + end + + # True when an activated plugin contributes a cpp_archive NIF whose init symbol + # is nx_eigen's (`nx_eigen_nif_init`) — i.e. the plugin supersedes the core + # NxEigen build. + @doc false + @spec nxeigen_provided_by_plugin?() :: boolean() + def nxeigen_provided_by_plugin? do + MobDev.Plugin.Merge.static_archives(MobDev.Plugin.activated(), :all) + |> Enum.any?(&(&1[:nm_symbol] == "nx_eigen_nif_init")) + end + + defp do_build_nxeigen(target_id) do + deps_path = Mix.Project.deps_path() + nx_eigen_dir = Path.join(deps_path, "nx_eigen") + fine_dir = Path.join(deps_path, "fine") + + erts_inc = + case nxeigen_erts_include(target_id) do + {:ok, path} -> path + {:error, _} = err -> throw(err) + end + + out_dir = nxeigen_out_dir(target_id) + + IO.puts(" === Building libnx_eigen.a (#{target_id})") + + case MobDev.NxEigenNif.build(target_id, + nx_eigen_dir: nx_eigen_dir, + fine_dir: fine_dir, + erts_include: erts_inc, + out_dir: out_dir + ) do + {:ok, info} -> + IO.puts(" ✓ #{info.archive}") + {:ok, info.archive} + + {:error, {tag, detail}} -> + {:error, "NxEigen cross-compile failed (#{target_id}, #{tag}): #{inspect(detail)}"} + end + catch + {:error, _} = err -> err + end + + defp nxeigen_out_dir(target_id) do + Path.join([Mix.Project.build_path(), "nxeigen", Atom.to_string(target_id)]) + end + + defp nxeigen_erts_include(:ios_sim) do + otp_dir = MobDev.OtpDownloader.ios_sim_otp_dir() + resolve_erts_include(otp_dir) + end + + defp nxeigen_erts_include(:ios_device) do + otp_dir = MobDev.OtpDownloader.ios_device_otp_dir() + resolve_erts_include(otp_dir) + end + + defp nxeigen_erts_include(:android_arm64) do + otp_dir = MobDev.OtpDownloader.android_otp_dir("arm64-v8a") + resolve_erts_include(otp_dir) + end + + defp nxeigen_erts_include(:android_arm32) do + otp_dir = MobDev.OtpDownloader.android_otp_dir("armeabi-v7a") + resolve_erts_include(otp_dir) + end + + defp resolve_erts_include(otp_dir) do + case Path.wildcard(Path.join([otp_dir, "erts-*", "include"])) do + [path | _] -> {:ok, path} + [] -> {:error, "no erts-*/include found under #{otp_dir} — was the OTP tarball extracted?"} + end + end + + # Returns the iOS-side zig -D flags. The iOS template expects + # `nxeigen_dir` (a directory containing `libnx_eigen.a`); since we + # build to `<out_dir>/libnx_eigen.a`, dirname is the right value. + # + # `nil` means NxEigen isn't in this build → emit no flags. + # Public for testing; @doc false keeps it out of the published docs. + @doc false + @spec nxeigen_zig_args_ios(nil | String.t()) :: [String.t()] + def nxeigen_zig_args_ios(nil), do: [] + + def nxeigen_zig_args_ios(archive_path) when is_binary(archive_path) do + ["-Dnxeigen_static=true", "-Dnxeigen_dir=#{Path.dirname(archive_path)}"] + end + + # Returns the Android-side zig -D flags. The Android template expects + # `nxeigen_lib` (an absolute path to libnx_eigen.a for this ABI), since + # the per-ABI archives live in different out_dirs. + # + # `nil` means NxEigen isn't in this build → emit no flags. + # Public for testing; @doc false keeps it out of the published docs. + @doc false + @spec nxeigen_zig_args_android(nil | String.t()) :: [String.t()] + def nxeigen_zig_args_android(nil), do: [] + + def nxeigen_zig_args_android(archive_path) when is_binary(archive_path) do + ["-Dnxeigen_static=true", "-Dnxeigen_lib=#{archive_path}"] + end + + # ── Generic plugin cpp_archive integration ───────────────────────────────── + # The plugin-system replacement for the bespoke nxeigen/tflite hooks above: + # activated plugins declare `lang: :cpp_archive` NIFs (MobDev.Plugin.Merge + + # CppArchive), each cross-compiled to lib<mod>.a and static-linked. One + # `-Dplugin_static_libs=<comma-paths>` flag carries them all to build.zig. + + # The zig `-Dplugin_static_libs` flag for a list of built archive paths. + # Emitted only when non-empty so apps on a pre-plugin-archive build.zig (no + # such option) don't choke on an unknown `-D` flag — same gating as + # `-Dplugin_c_nifs`. Pure; public (@doc false) for testing. + @doc false + @spec plugin_static_lib_args([Path.t()]) :: [String.t()] + def plugin_static_lib_args([]), do: [] + + def plugin_static_lib_args(paths) when is_list(paths), + do: ["-Dplugin_static_libs=#{Enum.join(paths, ",")}"] + + # Build every activated cpp_archive plugin NIF for one target ABI. Returns + # `{:ok, archive_paths}` (empty when no such plugin is active), or a tagged + # error string from the cross-compile. + # + # When a cpp_archive plugin IS active but `target_id` is an ABI CppArchive + # can't build (today the x86_64 Android emulator → :android_x86_64), this is + # a HARD build error rather than a logged skip: the per-platform driver_tab + # still references `<module>_nif_init` as a strong-undefined symbol, so + # silently returning {:ok, []} produces an unresolved-symbol link failure + # later (and only on-device). Failing here, by name, is the actionable + # signal. When no cpp_archive plugin is active the ABI gap is harmless, so + # the `specs == []` clause short-circuits first and unsupported ABIs build + # fine. + @spec build_plugin_static_archives(atom(), :ios | :android, Path.t()) :: + {:ok, [Path.t()]} | {:error, String.t()} + defp build_plugin_static_archives(target_id, platform, otp_dir) do + specs = MobDev.Plugin.Merge.static_archives(MobDev.Plugin.activated(), platform) + + case cpp_archive_target_decision(specs, target_id) do + :none -> {:ok, []} + {:error, msg} -> raise msg + :build -> do_build_plugin_static_archives(specs, target_id, otp_dir) + end + end + + @doc false + # Pure decision for `build_plugin_static_archives/3`, extracted so the + # short-circuit (no active cpp_archive plugin) and the hard-error (active + # plugin on an unsupported ABI) are unit-testable without driving a build: + # + # * `:none` — no cpp_archive spec needs building (ABI gap is + # harmless; an unsupported ABI like x86_64 builds fine) + # * `{:error, msg}` — a spec IS present but `target_id` isn't one CppArchive + # can build → hard build error (the driver_tab still + # references `<module>_nif_init`, so {:ok, []} would + # defer an unresolved-symbol link failure to on-device) + # * `:build` — a spec is present and the ABI is supported + @spec cpp_archive_target_decision([map()], atom()) :: + :none | :build | {:error, String.t()} + def cpp_archive_target_decision([], _target_id), do: :none + + def cpp_archive_target_decision(specs, target_id) do + if target_id in MobDev.Plugin.CppArchive.targets(), + do: :build, + else: {:error, unsupported_cpp_archive_target_error(specs, target_id)} + end + + @doc false + # Build-error message for an active cpp_archive plugin on an ABI CppArchive + # can't target yet. Pure string builder, public so the failure path is + # testable without driving a full Android build. + @spec unsupported_cpp_archive_target_error([map()], atom()) :: String.t() + def unsupported_cpp_archive_target_error(specs, target_id) do + names = Enum.map_join(specs, ", ", &"#{&1.plugin}/#{&1.module}") + + "cpp_archive plugin NIF(s) [#{names}] cannot be built for #{inspect(target_id)} — " <> + "MobDev.Plugin.CppArchive has no target for this ABI. cpp_archive currently " <> + "supports Android arm64 (:android_arm64) and arm32 (:android_arm32) only " <> + "(plus :ios_sim / :ios_device). The x86_64 Android emulator ABI is not " <> + "supported: building it would link <module>_nif_init as an unresolved symbol " <> + "and fail at link time on-device. Drop x86_64 from this build (target an " <> + "arm64/arm32 device or emulator), or remove the cpp_archive plugin for x86_64." + end + + defp do_build_plugin_static_archives(specs, target_id, otp_dir) do + with {:ok, erts_inc} <- resolve_erts_include(otp_dir) do + out_dir = + Path.join([Mix.Project.build_path(), "plugin_archives", Atom.to_string(target_id)]) + + specs + |> Enum.reduce_while({:ok, []}, fn spec, {:ok, acc} -> + IO.puts( + " === Building #{MobDev.Plugin.CppArchive.archive_name(spec.module)} (#{target_id}, #{spec.plugin})" + ) + + case MobDev.Plugin.CppArchive.build(spec, target_id, + out_dir: out_dir, + erts_include: erts_inc + ) do + {:ok, info} -> + IO.puts(" ✓ #{info.archive}") + {:cont, {:ok, [info.archive | acc]}} + + {:error, {tag, detail}} -> + {:halt, + {:error, + "plugin cpp_archive #{spec.plugin}/#{spec.module} failed " <> + "(#{target_id}, #{tag}): #{inspect(detail)}"}} + end + end) + |> case do + {:ok, paths} -> {:ok, Enum.reverse(paths)} + err -> err + end + end + end + + @doc false + # Android ABI string → CppArchive target id. x86_64 maps to :android_x86_64, + # which CppArchive doesn't build yet — `build_plugin_static_archives/3` raises + # a hard build error (rather than silently linking an unresolved init symbol) + # when a cpp_archive plugin is actually active for that ABI. nil = a truly + # unknown ABI string. Public for unit testing the mapping matrix. + @spec android_abi_to_cpp_target(String.t()) :: atom() | nil + def android_abi_to_cpp_target("arm64-v8a"), do: :android_arm64 + def android_abi_to_cpp_target("arm64"), do: :android_arm64 + def android_abi_to_cpp_target("armeabi-v7a"), do: :android_arm32 + def android_abi_to_cpp_target("arm32"), do: :android_arm32 + def android_abi_to_cpp_target("x86_64"), do: :android_x86_64 + def android_abi_to_cpp_target(_), do: nil + + # ── TFLite NIF integration (mirrors NxEigen above) ───────────────────────── + # Same shape as nxeigen but with TFLite-specific details: the runtime + # library (libtensorflowlite_jni.so on Android, TensorFlowLiteC.framework + # on iOS) is fetched via TfliteDownloader and the NIF is cross-compiled + # via TfliteNif. Both .so / framework get bundled into the deployed app + # alongside the static NIF archive — see `copy_tflite_runtime_lib_android/2` + # and the iOS framework-copy step in the device assembly path. + + defp tflite_in_project? do + Mix.Project.config()[:deps] |> Enum.any?(&match_tflite_dep?/1) + end + + defp match_tflite_dep?(dep) do + case dep do + {:nx_tflite_mob, _} -> true + {:nx_tflite_mob, _, _} -> true + _ -> false + end + end + + defp maybe_build_tflite(target_id) + when target_id in [:android_arm64, :android_arm32, :ios_sim, :ios_device] do + if tflite_in_project?() do + do_build_tflite(target_id) + else + {:ok, nil} + end + end + + defp do_build_tflite(target_id) do + # Use Mix.Project.deps_paths()[:nx_tflite_mob] rather than + # Path.join(deps_path, "nx_tflite_mob") so `path:` and `github:` deps + # both resolve correctly. `path:` deps don't get symlinked into + # deps/ — they're consumed in-place from the user's source. + nx_tflite_mob_dir = Mix.Project.deps_paths()[:nx_tflite_mob] + + with :ok <- + (if nx_tflite_mob_dir do + :ok + else + {:error, + "Mix.Project.deps_paths() has no :nx_tflite_mob entry — is it in mix deps?"} + end), + {:ok, tflite_dir} <- MobDev.TfliteDownloader.ensure(target_id), + {:ok, erts_inc} <- tflite_erts_include(target_id) do + out_dir = tflite_out_dir(target_id) + IO.puts(" === Building libtflite_nif.a (#{target_id})") + + case MobDev.TfliteNif.build(target_id, + nx_tflite_mob_dir: nx_tflite_mob_dir, + tflite_dir: tflite_dir, + erts_include: erts_inc, + out_dir: out_dir + ) do + {:ok, info} -> + IO.puts(" ✓ #{info.archive}") + {:ok, %{archive: info.archive, tflite_dir: tflite_dir}} + + {:error, {tag, detail}} -> + {:error, "TFLite cross-compile failed (#{target_id}, #{tag}): #{inspect(detail)}"} + end + end + end + + defp tflite_out_dir(target_id), + do: Path.join([Mix.Project.build_path(), "tflite", Atom.to_string(target_id)]) + + defp tflite_erts_include(:ios_sim), + do: resolve_erts_include(MobDev.OtpDownloader.ios_sim_otp_dir()) + + defp tflite_erts_include(:ios_device), + do: resolve_erts_include(MobDev.OtpDownloader.ios_device_otp_dir()) + + defp tflite_erts_include(:android_arm64), + do: resolve_erts_include(MobDev.OtpDownloader.android_otp_dir("arm64-v8a")) + + defp tflite_erts_include(:android_arm32), + do: resolve_erts_include(MobDev.OtpDownloader.android_otp_dir("armeabi-v7a")) + + @doc false + @spec tflite_zig_args_android(nil | map()) :: [String.t()] + def tflite_zig_args_android(nil), do: [] + + def tflite_zig_args_android(%{archive: archive_path}) when is_binary(archive_path) do + ["-Dtflite_static=true", "-Dtflite_lib=#{archive_path}"] + end + + @doc false + @spec tflite_zig_args_ios(nil | map()) :: [String.t()] + def tflite_zig_args_ios(nil), do: [] + + def tflite_zig_args_ios(%{archive: archive_path, tflite_dir: tflite_dir}) + when is_binary(archive_path) and is_binary(tflite_dir) do + [ + "-Dtflite_static=true", + "-Dtflite_dir=#{Path.dirname(archive_path)}", + "-Dtflite_framework_dir=#{Path.join(tflite_dir, "Frameworks")}" + ] + end + + @doc """ + Copy the TFLite runtime library (`libtensorflowlite_jni.so`) into the + Android app's `jniLibs/<abi>/` so the APK packager includes it. Called + during the Android assemble step when TFLite is enabled. + + `project_root` defaults to the current working directory — that's the + Mob-app project root in normal `mix mob.deploy` invocations. Tests + pass an explicit path to avoid cd'ing into a temp dir (which would + race other tests' parallel compilation). + + No-op when `tflite_build` is nil (TFLite not enabled in this project). + """ + @spec copy_tflite_runtime_lib_android(nil | map(), String.t(), Path.t() | nil) :: :ok + def copy_tflite_runtime_lib_android(tflite_build, abi, project_root \\ nil) + def copy_tflite_runtime_lib_android(nil, _abi, _project_root), do: :ok + + def copy_tflite_runtime_lib_android(%{tflite_dir: tflite_dir}, abi, project_root) do + root = project_root || File.cwd!() + src = Path.join([tflite_dir, "jni", abi, "libtensorflowlite_jni.so"]) + dst_dir = Path.join([root, "android/app/src/main/jniLibs", abi]) + File.mkdir_p!(dst_dir) + dst = Path.join(dst_dir, "libtensorflowlite_jni.so") + + if File.regular?(src) do + File.cp!(src, dst) + IO.puts(" === Copied libtensorflowlite_jni.so to #{dst}") + :ok + else + raise "TFLite runtime lib missing at #{src}" + end + end + + @doc """ + Copy the TFLite frameworks (Core + CoreML + Metal) into the iOS app's + `Frameworks/` dir so the .app bundle ships them. Called during iOS + app assembly when TFLite is enabled. + + Same pattern as `Python.framework` embedding. Codesigning happens at + the app-bundle level — the frameworks just need to be present in the + bundle when the codesign step runs. + + `slice` is either `"ios-arm64"` (device) or + `"ios-arm64_x86_64-simulator"` (sim). + + No-op when `tflite_build` is nil. + """ + @spec copy_tflite_frameworks_ios(nil | map(), String.t(), Path.t()) :: :ok + def copy_tflite_frameworks_ios(nil, _slice, _app_frameworks_dir), do: :ok + + def copy_tflite_frameworks_ios(%{tflite_dir: _tflite_dir}, _slice, _app_frameworks_dir) do + # No-op: TFLite's xcframework slices ship binaries as MH_OBJECT + # (filetype=1, relocatable object) rather than MH_DYLIB. The linker + # at build time (-F<path> -framework TensorFlowLiteC via swiftc) + # pulls the object content directly into the app's main Mach-O + # binary, statically. There's no runtime dylib to resolve, so the + # .framework bundles do NOT need to be embedded in the .app's + # Frameworks/ dir. Doing so would also fail iOS install: + # + # * the bundles lack Info.plist (CocoaPods generates them) + # * the bundles' MH_OBJECT binaries can't be re-signed in a way + # modern iOS accepts ("code signature version is no longer + # supported" — codesign only produces v3 sigs for MH_EXECUTE / + # MH_DYLIB) + # + # If a future TFLite release ships true MH_DYLIB frameworks, we'll + # need to re-enable the copy + framework codesign step. For now this + # caller is kept around as a documentation hook + future-compat + # point. + IO.puts(" === TFLite frameworks linked statically (no .app embedding)") + :ok + end + + # ── iOS device-specific build helpers (Phase 2 iter 13c) ───────────────────── + # Mirror the iOS-sim helpers above, with iphoneos SDK + arm64 single-arch + + # static-NIF (.a) packaging. Only the divergent steps live here; the rest + # (compile_elixir_for_ios, copy_app_beams, install_exqlite_otp_lib, + # maybe_install_crypto_shim, maybe_build_phoenix_assets, copy_priv_repo_assets, + # copy_elixir_stdlib_to_otp, copy_eex_stdlib_to_app, generate_enif_keepalive, + # spot_check_app_beams) are shared. + + defp cross_compile_exqlite_nif_device(otp_root, erts_vsn, sdkroot) do + if File.dir?("deps/exqlite/c_src") do + vsn = detect_dep_version("exqlite") + out_a = Path.join([otp_root, "lib/exqlite-#{vsn}/priv/sqlite3_nif.a"]) + IO.puts(" === Building sqlite3_nif.a (static NIF for iOS device)") + + build_dir_tmp = + Path.join(System.tmp_dir!(), "mob_exqlite_#{System.unique_integer([:positive])}") + + File.mkdir_p!(build_dir_tmp) + nif_o = Path.join(build_dir_tmp, "sqlite3_nif.o") + sqlite_o = Path.join(build_dir_tmp, "sqlite3.o") + + common_cc = [ + "-arch", + "arm64", + "-miphoneos-version-min=17.0", + "-isysroot", + sdkroot, + "-Os", + "-ffunction-sections", + "-fdata-sections" + ] + + with :ok <- + run_cc( + common_cc ++ + [ + "-I", + "deps/exqlite/c_src", + "-I", + "#{otp_root}/#{erts_vsn}/include", + "-I", + "#{otp_root}/#{erts_vsn}/include/internal", + "-DSQLITE_THREADSAFE=1", + "-DSTATIC_ERLANG_NIF_LIBNAME=sqlite3_nif", + "-Wno-#warnings", + "-c", + "deps/exqlite/c_src/sqlite3_nif.c", + "-o", + nif_o + ] + ), + :ok <- + run_cc( + common_cc ++ + [ + "-I", + "deps/exqlite/c_src", + "-DSQLITE_THREADSAFE=1", + "-Wno-#warnings", + "-c", + "deps/exqlite/c_src/sqlite3.c", + "-o", + sqlite_o + ] + ), + :ok <- run_ar(["rcs", out_a, nif_o, sqlite_o]) do + File.rm_rf!(build_dir_tmp) + :ok + end + else + :ok + end + end + + defp run_cc(args) do + case System.cmd("xcrun", ["cc" | args], stderr_to_stdout: true, into: IO.stream()) do + {_, 0} -> :ok + {_, _} -> {:error, "iOS device cc failed"} + end + end + + defp run_ar(args) do + case System.cmd("xcrun", ["ar" | args], stderr_to_stdout: true, into: IO.stream()) do + {_, 0} -> :ok + {_, _} -> {:error, "iOS device ar failed"} + end + end + + defp maybe_setup_pythonx_device(_otp_root, _erts_vsn, _sdkroot, nil, _app_module), do: :ok + + defp maybe_setup_pythonx_device(otp_root, erts_vsn, sdkroot, python_bundle, app_module) do + if File.dir?("_build/dev/lib/pythonx") do + pythonx_vsn = detect_dep_version("pythonx") + pythonx_lib_dir = Path.join([otp_root, "lib", "pythonx-#{pythonx_vsn}"]) + beams_dir = Path.join(otp_root, app_module) + + IO.puts(" === Installing pythonx as OTP library") + File.rm_rf!(Path.join(otp_root, "lib/pythonx-")) + File.mkdir_p!(Path.join(pythonx_lib_dir, "ebin")) + File.mkdir_p!(Path.join(pythonx_lib_dir, "priv")) + + pythonx_ebin = Path.join(["_build", "dev", "lib", "pythonx", "ebin"]) + + Path.wildcard("#{pythonx_ebin}/*.beam") + |> Enum.each(&File.cp!(&1, Path.join([pythonx_lib_dir, "ebin", Path.basename(&1)]))) + + pythonx_app = Path.join(pythonx_ebin, "pythonx.app") + + if File.exists?(pythonx_app), + do: File.cp!(pythonx_app, Path.join([pythonx_lib_dir, "ebin", "pythonx.app"])) + + # Mirror beams into the app's flat -pa dir so module load works + # without -boot-time path negotiation. + Path.wildcard("#{pythonx_ebin}/*") + |> Enum.each(fn src -> + if not File.dir?(src), + do: File.cp!(src, Path.join(beams_dir, Path.basename(src))) + end) + + fine_ebin = "_build/dev/lib/fine/ebin" + + if File.dir?(fine_ebin) do + Path.wildcard("#{fine_ebin}/*") + |> Enum.each(fn src -> + if not File.dir?(src), + do: File.cp!(src, Path.join(beams_dir, Path.basename(src))) + end) + end + + framework = Path.join(python_bundle, "Python.xcframework/ios-arm64/Python.framework") + stdlib = Path.join(python_bundle, "Python.xcframework/lib/python3.13") + + lib_dynload = + Path.join(python_bundle, "Python.xcframework/ios-arm64/lib-arm64/python3.13/lib-dynload") + + cond do + not File.dir?(framework) -> + {:error, "Python.framework missing at #{framework}"} + + not File.dir?(stdlib) -> + {:error, "Python stdlib missing at #{stdlib}"} + + not File.dir?(lib_dynload) -> + {:error, "lib-dynload missing at #{lib_dynload}"} + + true -> + IO.puts(" === Cross-compiling libpythonx.so for iOS device (iphoneos arm64)") + + out_so = Path.join([pythonx_lib_dir, "priv", "libpythonx.so"]) + + xcrun_args = [ + "-sdk", + "iphoneos", + "clang++", + "-arch", + "arm64", + "-dynamiclib", + "-undefined", + "dynamic_lookup", + "-fPIC", + "-fvisibility=hidden", + "-std=c++17", + "-isysroot", + sdkroot, + "-miphoneos-version-min=17.0", + "-install_name", + "@rpath/libpythonx.so", + "-Os", + "-ffunction-sections", + "-fdata-sections", + "-I", + "#{otp_root}/#{erts_vsn}/include", + "-I", + "#{otp_root}/#{erts_vsn}/include/internal", + "-I", + "deps/fine/c_include", + "-Wno-unused-parameter", + "-Wno-comment", + "deps/pythonx/c_src/pythonx.cpp", + "deps/pythonx/c_src/python.cpp", + "-o", + out_so + ] + + with {_, 0} <- + System.cmd("xcrun", xcrun_args, stderr_to_stdout: true, into: IO.stream()) do + IO.puts(" === Bundling Python.framework + stdlib + lib-dynload (device arch)") + python_dst = Path.join(otp_root, "python") + File.mkdir_p!(Path.join(python_dst, "lib")) + chmod_writable(python_dst) + + File.rm_rf!(Path.join(python_dst, "Python.framework")) + File.rm_rf!(Path.join(python_dst, "lib/python3.13")) + + cp_r!(framework, Path.join(python_dst, "Python.framework")) + cp_r!(stdlib, Path.join(python_dst, "lib/python3.13")) + cp_r!(lib_dynload, Path.join(python_dst, "lib/python3.13/lib-dynload")) + # Project-supplied wheels into site-packages — mirrors Android's + # ensure_python_android_libs path and the sim path above. Without + # this, real-device Python apps boot and hang on `import RNS` + # because `priv/python_wheels/` never landed in the .app bundle. + # See nif_future.md item #4. The ios-safe variant filters + # wheels with Android-only `.so` extensions. + copy_ios_safe_project_python_wheels( + python_dst, + Path.join("priv", "python_wheels") + ) + + :ok + else + _ -> {:error, "pythonx NIF cross-compile failed"} + end + end + else + IO.puts(" === pythonx not in project — skipping CPython bundle") + :ok + end + end + + defp cp_r!(src, dst) do + {_, 0} = + System.cmd("cp", ["-R", src, dst], stderr_to_stdout: true, into: IO.stream()) + + :ok + end + + defp maybe_install_ssl_shim(otp_root, app_module) do + if liveview_project?() do + IO.puts(" === Creating ssl shim (LV)") + ssl_tmp = Path.join(System.tmp_dir!(), "mob_ssl_#{System.unique_integer([:positive])}") + File.mkdir_p!(ssl_tmp) + beams_dir = Path.join(otp_root, app_module) + + try do + File.write!(Path.join(ssl_tmp, "ssl.erl"), ssl_shim_erl()) + + case System.cmd("erlc", ["-o", beams_dir, Path.join(ssl_tmp, "ssl.erl")], + stderr_to_stdout: true, + into: IO.stream() + ) do + {_, 0} -> + File.write!(Path.join(beams_dir, "ssl.app"), ssl_shim_app()) + :ok + + {_, _} -> + {:error, "ssl shim erlc failed"} + end + after + File.rm_rf!(ssl_tmp) + end + else + :ok + end + end + + defp ssl_shim_erl do + """ + -module(ssl). + -behaviour(application). + -export([start/2, stop/1, start/0, stop/0, + connect/3, connect/4, connect/5, + listen/2, accept/2, accept/3, + close/1, send/2, recv/2, recv/3, + controlling_process/2, getopts/2, setopts/2, + peername/1, sockname/1, peercert/1, + negotiated_protocol/1, cipher_suites/0, + cipher_suites/2, cipher_suites/3, + versions/0, format_error/1, + clear_pem_cache/0, handshake/1, handshake/2, handshake/3, + handshake_continue/2, handshake_continue/3, + handshake_cancel/1, shutdown/2, + transport_info/1, connection_information/1, + connection_information/2]). + start(_Type, _Args) -> + Pid = spawn(fun() -> receive stop -> ok end end), + {ok, Pid}. + stop(_State) -> ok. + start() -> ok. + stop() -> ok. + connect(_, _, _) -> {error, ssl_not_supported}. + connect(_, _, _, _) -> {error, ssl_not_supported}. + connect(_, _, _, _, _) -> {error, ssl_not_supported}. + listen(_, _) -> {error, ssl_not_supported}. + accept(_, _) -> {error, ssl_not_supported}. + accept(_, _, _) -> {error, ssl_not_supported}. + close(_) -> ok. + send(_, _) -> {error, closed}. + recv(_, _) -> {error, closed}. + recv(_, _, _) -> {error, closed}. + controlling_process(_, _) -> ok. + getopts(_, _) -> {ok, []}. + setopts(_, _) -> ok. + peername(_) -> {error, ssl_not_supported}. + sockname(_) -> {error, ssl_not_supported}. + peercert(_) -> {error, ssl_not_supported}. + negotiated_protocol(_) -> {error, ssl_not_supported}. + cipher_suites() -> []. + cipher_suites(_, _) -> []. + cipher_suites(_, _, _) -> []. + versions() -> []. + format_error(_) -> "ssl not available on iOS (HTTP-only)". + clear_pem_cache() -> ok. + handshake(_) -> {error, ssl_not_supported}. + handshake(_, _) -> {error, ssl_not_supported}. + handshake(_, _, _) -> {error, ssl_not_supported}. + handshake_continue(_, _) -> {error, ssl_not_supported}. + handshake_continue(_, _, _) -> {error, ssl_not_supported}. + handshake_cancel(_) -> ok. + shutdown(_, _) -> ok. + transport_info(_) -> {error, ssl_not_supported}. + connection_information(_) -> {error, ssl_not_supported}. + connection_information(_, _) -> {error, ssl_not_supported}. + """ + end + + defp ssl_shim_app do + ~S|{application,ssl,[{modules,[ssl]},{applications,[kernel,stdlib,crypto,public_key]},{description,"SSL shim for iOS (HTTP-only)"},{registered,[]},{vsn,"11.2"},{mod,{ssl,[]}}]}.| <> + "\n" + end + + defp copy_otp_libs_for_phoenix(otp_root) do + IO.puts(" === Copying OTP standard libraries (Phoenix deps)") + # Phoenix and its deps require runtime_tools (extra_applications), + # asn1 (public_key dep), and public_key (cookie/cert infra). The Mob + # iOS-device tarball does not bundle these; copy from the host. + for app <- ~w(runtime_tools asn1 public_key) do + case :code.lib_dir(String.to_atom(app)) do + src when is_list(src) -> + src_dir = to_string(src) + ebin = Path.join(src_dir, "ebin") + + if File.dir?(ebin) do + vsn_dir = Path.basename(src_dir) + dst_ebin = Path.join([otp_root, "lib", vsn_dir, "ebin"]) + File.mkdir_p!(dst_ebin) + + Path.wildcard("#{ebin}/*.beam") + |> Enum.each(&File.cp!(&1, Path.join(dst_ebin, Path.basename(&1)))) + + app_file = Path.join(ebin, "#{app}.app") + if File.exists?(app_file), do: File.cp!(app_file, Path.join(dst_ebin, "#{app}.app")) + IO.puts(" + #{vsn_dir}") + else + IO.puts(" ! #{app} not found on host — skipping") + end + + _ -> + IO.puts(" ! #{app} not found on host — skipping") + end + end + + :ok + end + + defp install_app_in_otp_lib(otp_root, app_module) do + # Plug.Static (from: :app_name) resolves the priv dir via code:lib_dir/1, + # which requires a code-path entry named "app_name-vsn" (not just + # "app_name"). Install the app into $OTP_ROOT/lib/<app>-<vsn>/ alongside + # runtime_tools, asn1, etc. + beams_dir = Path.join(otp_root, app_module) + app_file = Path.join(beams_dir, "#{app_module}.app") + + case File.read(app_file) do + {:ok, content} -> + case Regex.run(~r/\{vsn,\s*"([^"]+)"\}/, content) do + [_, vsn] -> + IO.puts(" === Installing app into OTP lib/ (required for code:priv_dir)") + app_lib_dir = Path.join([otp_root, "lib", "#{app_module}-#{vsn}"]) + File.rm_rf!(app_lib_dir) + File.mkdir_p!(Path.join(app_lib_dir, "ebin")) + File.cp!(app_file, Path.join([app_lib_dir, "ebin", "#{app_module}.app"])) + + priv_src = Path.join(beams_dir, "priv") + + if File.dir?(priv_src) do + File.mkdir_p!(Path.join(app_lib_dir, "priv")) + copy_dir!(priv_src <> "/.", Path.join(app_lib_dir, "priv")) + end + + IO.puts(" + #{app_module}-#{vsn}") + :ok + + _ -> + IO.puts(" ! Could not read version — code:priv_dir(:#{app_module}) may not work") + :ok + end + + _ -> + :ok + end + end + + defp copy_mob_logos_to_otp_root(mob_dir, otp_root) do + IO.puts(" === Copying logos") + + for variant <- ~w(dark light) do + src = Path.join(mob_dir, "assets/logo/logo_#{variant}.png") + dst = Path.join(otp_root, "mob_logo_#{variant}.png") + if File.exists?(src), do: File.cp!(src, dst) + end + + :ok + end + + defp patch_epmd_source(epmd_build_src) do + # Stock OTP epmd.c calls `run_daemon(g)` unconditionally inside + # `if (g->is_daemon)`. With -DNO_DAEMON the function body is stripped + # but the call site still references the symbol, so the link fails + # with "Undefined symbols: _run_daemon". Idempotent inline patch wraps + # the call in `#ifndef NO_DAEMON` so both halves go away together. + epmd_c = Path.join([epmd_build_src, "erts/epmd/src/epmd.c"]) + + cond do + not File.exists?(epmd_c) -> + :ok + + File.read!(epmd_c) =~ "ifndef NO_DAEMON" -> + :ok + + true -> + IO.puts(" patching #{epmd_c} (NO_DAEMON guard around run_daemon call)") + original = File.read!(epmd_c) + + # Match the exact signature from the OTP source. + pattern = ~r/( if \(g->is_daemon\) \{\n)(\trun_daemon\(g\);\n)( \} else \{\n)/ + + case Regex.replace(pattern, original, "\\1#ifndef NO_DAEMON\n\\2#endif\n\\3", + global: false + ) do + ^original -> + {:error, "epmd.c patch pattern did not match — manual fix required"} + + patched -> + File.write!(epmd_c, patched) + :ok + end + end + end + + @doc false + # `def` (not `defp`) so the test suite can pin the contract. This shim + # has been mistakenly removed before — the test exists to flag the + # next attempt as a test failure rather than an iOS device link + # failure. + # + # erl_errno_id_unknown is missing from libbeam.a in the iOS-device OTP + # tarball — it's referenced only by erl_posix_str.o (the legacy + # implementation), which the linker pulls in because it comes before + # the newer erl_errno_str.o in the archive's `ar` index AND because + # the iOS BEAM startup path (mob_beam.m) doesn't reference + # erts_errno_init, so erl_errno_str.o is never pulled in to provide + # the symbol the modern way. Net: legacy file wins, needs the + # `_unknown` helper, we provide it weakly here. A weak definition + # loses to the real symbol if a future tarball includes it. + # + # The iOS-sim and Android tarballs don't have erl_posix_str.o at all + # (only erl_errno_str.o) and don't need this shim. If the iOS-device + # OTP tarball is ever rebuilt without erl_posix_str.c (matching the + # sim/Android configs), this shim becomes obsolete — but verify with + # `nm libbeam.a | grep erl_errno_id_unknown` first; the iOS-device + # link surfaces the regression as + # `Undefined symbols: _erl_errno_id_unknown`. + # Visible to tests; private callers can pretend `defp`. + @spec project_nif_user_entries() :: [MobDev.StaticNifs.nif_entry()] + def project_nif_user_entries do + default_modules = + MobDev.StaticNifs.default_nifs() |> MapSet.new(& &1.module) + + Mix.Tasks.Mob.RegenDriverTab.resolved_nifs() + |> Enum.reject(fn entry -> MapSet.member?(default_modules, entry.module) end) + end + + @spec classify_project_nif(MobDev.StaticNifs.nif_entry()) :: + {:c, Path.t()} | {:rust, Path.t()} | {:zig, atom()} | :elixir_only + def classify_project_nif(entry), do: classify_project_nif(entry, File.cwd!()) + + @doc false + @spec classify_project_nif(MobDev.StaticNifs.nif_entry(), Path.t()) :: + {:c, Path.t()} | {:rust, Path.t()} | {:zig, atom()} | :elixir_only + def classify_project_nif(entry, project_root) do + name = to_string(entry.module) + c_src = Path.join(project_root, "c_src/#{name}.c") + rust_manifest = Path.join(project_root, "native/#{name}/Cargo.toml") + + cond do + # C wins if both exist — the user has explicitly written C. + File.exists?(c_src) -> {:c, c_src} + File.exists?(rust_manifest) -> {:rust, rust_manifest} + true -> classify_via_zig_stub(name, project_root) + end + end + + # Detect Zigler-backed NIFs by `use Zig` in the generated stub. + # `mob.add_nif --type zigler` puts the stub at `lib/<app>/nifs/<name>.ex`. + # If it contains `use Zig,`, treat the NIF as Zigler-backed and record + # the BEAM module atom (needed for `Zig.Builder.staging_directory/1`). + defp classify_via_zig_stub(name, project_root) do + app = + case Application.fetch_env(:mob_dev, :__app_name__) do + {:ok, app} -> app + :error -> Mix.Project.config() |> Keyword.get(:app) + end + + if is_nil(app) do + :elixir_only + else + stub = Path.join(project_root, "lib/#{app}/nifs/#{name}.ex") + + if File.exists?(stub) and File.read!(stub) =~ ~r/use\s+Zig\b/ do + # Module follows the resolve_module/3 convention in mob.add_nif: + # <AppCamel>.Nifs.<NameCamel> + camel = app |> to_string() |> Macro.camelize() + nif_camel = name |> Macro.camelize() + {:zig, Module.concat([camel, "Nifs", nif_camel])} + else + :elixir_only + end + end + end + + # Build args to pass to `zig build`. Returns + # `{:ok, ["-Dproject_c_nifs=…", "-Dproject_rust_libs=…", "-Dproject_root=…"]}` + # or `{:error, reason}` if a Rust cross-compile fails. + @spec project_nif_zig_args( + :ios_device + | :ios_sim + | :android_arm64 + | :android_arm32 + | :android_x86_64 + ) :: + {:ok, [String.t()]} | {:error, String.t()} + @doc false + def project_nif_zig_args(platform) do + project_root = File.cwd!() + # Respect each entry's `:archs` field — a NIF with + # `archs: [:ios]` (in mob.exs `:static_nifs`) should be skipped on + # the Android build path entirely (and vice versa), and a NIF with + # `archs: [:android_arm64]` should only land in the arm64 build, + # not the armv7 one. The driver_tab generator already honours this + # via `on_platform?/2`; the cross-compile path now does too. Maps + # the mob_dev build atom → the StaticNifs arch atom that + # `on_platform?/2` understands. + target_arch = + case platform do + p when p in [:ios_device, :ios_sim] -> p + :android_arm64 -> :android_arm64 + :android_arm32 -> :android_arm32 + :android_x86_64 -> :android + end + + entries = + project_nif_user_entries() + |> Enum.filter(&MobDev.StaticNifs.on_platform?(&1, target_arch)) + + {c_names, rust_manifests, zig_modules} = + Enum.reduce(entries, {[], [], []}, fn entry, {c_acc, rust_acc, zig_acc} -> + case classify_project_nif(entry, project_root) do + {:c, _path} -> + {[to_string(entry.module) | c_acc], rust_acc, zig_acc} + + {:rust, manifest} -> + {c_acc, [{to_string(entry.module), manifest} | rust_acc], zig_acc} + + {:zig, module} -> + {c_acc, rust_acc, [{to_string(entry.module), module} | zig_acc]} + + :elixir_only -> + {c_acc, rust_acc, zig_acc} + end + end) + + # Per-ABI external static archives declared via `:extra_static_libs` on the + # already platform-filtered entries. These resolve a project NIF's `extern` + # symbols at the app link without making the host-rendered NIF link against + # an archive for the wrong architecture. + extra_static_libs = + Enum.flat_map(entries, fn entry -> + case entry |> Map.get(:extra_static_libs, %{}) |> Map.get(platform) do + nil -> [] + path -> [Path.expand(path, project_root)] + end + end) + + nif_static_flags = + for entry <- entries, Map.has_key?(entry, :guard), do: "-D#{entry.module}_static=true" + + with {:ok, rust_libs} <- cross_compile_rust_nifs(rust_manifests, platform), + {:ok, zig_libs} <- cross_compile_zig_nifs(zig_modules, platform) do + # Pass both Rust and Zig static archives via the same flag + # (they go to the same linker step). Name kept legacy-flavored + # for the build template's existing consumer; sweep up later. + static_libs = Enum.reverse(rust_libs) ++ Enum.reverse(zig_libs) ++ extra_static_libs + + {:ok, + [ + "-Dproject_root=#{project_root}", + "-Dproject_c_nifs=#{Enum.join(Enum.reverse(c_names), ",")}", + "-Dproject_rust_libs=#{Enum.join(static_libs, ",")}" + ] ++ nif_static_flags} + end + end + + @spec cross_compile_rust_nifs([{String.t(), Path.t()}], atom()) :: + {:ok, [Path.t()]} | {:error, String.t()} + defp cross_compile_rust_nifs([], _platform), do: {:ok, []} + + defp cross_compile_rust_nifs(manifests, platform) do + target = rust_target_for(platform) + + Enum.reduce_while(manifests, {:ok, []}, fn {name, manifest}, {:ok, acc} -> + case cross_compile_rust_nif(name, manifest, target) do + {:ok, a_path} -> {:cont, {:ok, [a_path | acc]}} + {:error, _} = err -> {:halt, err} + end + end) + end + + defp cross_compile_rust_nif(name, manifest, target) do + Mix.shell().info(" === Cross-compiling Rust NIF #{name} for #{target}") + + args = [ + "rustc", + "--release", + "--target", + target, + "--crate-type", + "staticlib", + "--manifest-path", + manifest + ] + + case System.cmd("cargo", args, stderr_to_stdout: true, into: IO.stream()) do + {_, 0} -> + a = Path.expand("native/#{name}/target/#{target}/release/lib#{name}.a") + + if File.exists?(a) do + {:ok, a} + else + {:error, + "cargo rustc for '#{name}' succeeded but #{a} not found — " <> + "check that the crate-type includes staticlib"} + end + + {_, code} -> + {:error, + "cargo rustc for Rust NIF '#{name}' (target=#{target}) exited #{code}.\n" <> + " Common causes:\n" <> + " 1. `rustup target add #{target}` not run — check `rustup target list --installed`.\n" <> + " 2. Cargo.toml's [lib] crate-type doesn't include \"staticlib\".\n" <> + " 3. The Rust source has a compile error — see the cargo output above."} + end + end + + # Apple toolchains differ between simulator and device targets. Both + # are arm64 on Apple Silicon Macs; sim has the `-sim` suffix because + # the SDK headers differ. Android splits per-ABI: arm64-v8a uses + # `aarch64-linux-android`; armeabi-v7a uses `armv7-linux-androideabi`. + defp rust_target_for(:ios_device), do: "aarch64-apple-ios" + defp rust_target_for(:ios_sim), do: "aarch64-apple-ios-sim" + defp rust_target_for(:android_arm64), do: "aarch64-linux-android" + defp rust_target_for(:android_arm32), do: "armv7-linux-androideabi" + defp rust_target_for(:android_x86_64), do: "x86_64-linux-android" + + # ── Zigler cross-compile (issue #15 final piece) ───────────────────────── + + @spec cross_compile_zig_nifs([{String.t(), module()}], atom()) :: + {:ok, [Path.t()]} | {:error, String.t()} + defp cross_compile_zig_nifs([], _platform), do: {:ok, []} + + defp cross_compile_zig_nifs(zig_modules, platform) do + target = zig_build_target_for(platform) + + with {:ok, sdkroot_args} <- sdkroot_args_for(platform) do + Enum.reduce_while(zig_modules, {:ok, []}, fn {name, module}, {:ok, acc} -> + case cross_compile_zig_nif(name, module, target, sdkroot_args) do + {:ok, a_path} -> {:cont, {:ok, [a_path | acc]}} + {:error, _} = err -> {:halt, err} + end + end) + end + end + + # Resolve the SDK / NDK sysroot that Zigler's cImport-bearing + # modules need when cross-compiling. Returns the list of `-D...=` + # args to append to `zig build` (empty list for desktop builds + # where Zig's host libc headers cover everything). + # + # * iOS device/sim — need Apple SDK for `<sys/types.h>` etc. + # * Android — need NDK sysroot; Zig 0.16's bundled libc for the + # `aarch64-linux-android` target doesn't ship `<sys/types.h>`, + # so erl_nif.h's transitive cImport fails without this. + defp sdkroot_args_for(:ios_device) do + with {:ok, path} <- xcrun_sdk_path("iphoneos") do + {:ok, ["-Dapple_sdkroot=#{path}"]} + end + end + + defp sdkroot_args_for(:ios_sim) do + with {:ok, path} <- xcrun_sdk_path("iphonesimulator") do + {:ok, ["-Dapple_sdkroot=#{path}"]} + end + end + + defp sdkroot_args_for(:android_arm64), do: {:ok, ["-Dandroid_sdkroot=#{ndk_sysroot()}"]} + defp sdkroot_args_for(:android_arm32), do: {:ok, ["-Dandroid_sdkroot=#{ndk_sysroot()}"]} + defp sdkroot_args_for(:android_x86_64), do: {:ok, ["-Dandroid_sdkroot=#{ndk_sysroot()}"]} + + # Drives Zigler's build pipeline a SECOND time against the staging + # directory (which Zigler set up during the normal `mix compile` host + # build), with the static-link + alias options the GenericJam/zigler + # fork added. Produces `libElixir.<Module>.a` in `zig-out/lib/` of + # the staging dir, suitable for linking into the iOS device binary. + defp cross_compile_zig_nif(name, module, target, sdkroot_args) do + # Resolve `Zig.Builder` dynamically — it only exists when the + # consuming project has `:zigler` as a dep. mob_dev itself doesn't + # depend on Zigler, so a static `Zig.Builder.staging_directory/1` + # reference would emit an "undefined module" warning at compile + # time and refuse `--warnings-as-errors`. + builder = Module.concat([:Zig, :Builder]) + + staging_dir = + if Code.ensure_loaded?(builder) and + function_exported?(builder, :staging_directory, 1) do + builder.staging_directory(module) + end + + cond do + is_nil(staging_dir) -> + {:error, + "Zigler not loaded — can't cross-compile NIF '#{name}'. " <> + "Is `:zigler` in the project's deps?"} + + not File.dir?(staging_dir) -> + {:error, + "Zigler staging dir missing for '#{name}': #{staging_dir}\n" <> + " Has `mix compile` run yet? The host build sets up the staging dir."} + + true -> + Mix.shell().info(" === Cross-compiling Zig NIF #{name} for #{target}") + + # Resolve Zig via Zigler's own lookup (cache → ZIG_ARCHIVE_PATH + # → PATH) instead of `System.find_executable("zig")`. mob_dev + # users typically have a different Zig on PATH (mob's own + # pin) than the one Zigler 0.15.x expects (0.16 from + # the user cache). Hard-coding to PATH would pick the wrong + # version and break the build. + zig_cmd = Module.concat([:Zig, :Command]) + + zig_exe = + if Code.ensure_loaded?(zig_cmd) and + function_exported?(zig_cmd, :executable_path, 0) do + zig_cmd.executable_path() + else + "zig" + end + + # `-Dtarget=...` is consumed by zig's `standardTargetOptions` + # at build time — overrides whatever the rendered build.zig + # had as its default. We can't use Zigler's TARGET_ARCH/OS/ABI + # env vars here because the staging build.zig was already + # rendered during the host `mix compile` (with default target). + # + # `--prefix zig-out-<target>` keeps per-target outputs in + # separate directories so running this twice (Android arm64 + # + armv7) doesn't overwrite the first build's archive — the + # default `zig-out/` would clobber on the second invocation. + prefix = "zig-out-#{target}" + path_args = zigler_build_path_args(module, staging_dir) + + args = + [ + "build", + "-Dtarget=#{target}", + "-Dnif_linkage=static", + "-Dnif_init_alias=#{name}_nif_init", + "--prefix", + prefix + ] ++ path_args ++ sdkroot_args + + case System.cmd(zig_exe, args, + cd: staging_dir, + stderr_to_stdout: true, + into: IO.stream() + ) do + {_, 0} -> + # Zigler names the output `libElixir.<Module>.a`. + a = Path.join([staging_dir, "#{prefix}/lib/libElixir.#{module_basename(module)}.a"]) + + cond do + not File.exists?(a) -> + {:error, + "zig build for '#{name}' succeeded but #{a} not found — " <> + "did Zigler change its output naming?"} + + # Apple ld64 requires .a archive members to be 8-byte + # aligned. Zig 0.16's archive output isn't aligned and + # ld64 rejects it with "not 8-byte aligned". Re-archive + # with xcrun ar to produce a Mach-O-compatible static + # library. macOS's `ar` doesn't understand ELF archives + # (Zig's Android output) — running it against an ELF .a + # extracts zero members and the rearchive errors with + # "ar: no archive members specified". The NDK linker + # accepts Zig's archive directly, so skip the rearchive + # for non-Apple targets. + String.contains?(target, "-ios") -> + case rearchive_for_apple_ld(a) do + :ok -> {:ok, a} + {:error, _} = err -> err + end + + :else -> + {:ok, a} + end + + {_, code} -> + {:error, "zig build for Zig NIF '#{name}' exited #{code}"} + end + end + end + + defp zigler_build_path_args(module, staging_dir) do + erts_include = + Path.join([:code.root_dir(), "erts-#{:erlang.system_info(:version)}", "include"]) + + zigler_priv = :zigler |> :code.priv_dir() |> to_string() + erl_nif_win_path = Path.join(zigler_priv, "erl_nif_win") + + erl_nif_header = + if :os.type() == {:win32, :nt}, + do: Path.join(erl_nif_win_path, "erl_nif_win.h"), + else: Path.join(erts_include, "erl_nif.h") + + module_root = zigler_module_root(module, staging_dir) + + [ + "-Derts_include=#{erts_include}", + "-Derl_nif_header=#{erl_nif_header}", + "-Derl_nif_win_path=#{erl_nif_win_path}", + "-Dzigler_priv=#{zigler_priv}", + "-Dmodule_root=#{module_root}" + ] + end + + defp zigler_module_root(module, staging_dir) do + build_zig = Path.join(staging_dir, "build.zig") + + with {:ok, source} <- File.read(build_zig), + [_match, basename] <- Regex.run(~r/&\.\{\s*module_root,\s*"([^"]+)"\s*\}/, source), + [path | _] <- Path.wildcard(Path.join(File.cwd!(), "**/#{basename}"), match_dot: true) do + Path.dirname(path) + else + _ -> module_source_root(module) + end + end + + defp module_source_root(module) do + module.module_info(:compile) + |> Keyword.fetch!(:source) + |> to_string() + |> Path.dirname() + end + + # Re-archives a Zig-built `.a` using `xcrun ar` so its members are + # 8-byte aligned (required by Apple's ld64). Extracts the .o members + # from the existing archive into a temp dir, then re-archives them + # in-place. Idempotent — calling on an already-aligned archive is fine. + defp rearchive_for_apple_ld(archive_path) do + tmp = Path.join(System.tmp_dir!(), "mob_zig_ar_#{:erlang.unique_integer([:positive])}") + File.mkdir_p!(tmp) + + try do + case System.cmd("xcrun", ["ar", "-x", archive_path], cd: tmp, stderr_to_stdout: true) do + {_, 0} -> + members = + tmp + |> File.ls!() + |> Enum.reject(&String.starts_with?(&1, "__.")) + + # File perms on extracted members can be 000; chmod to readable. + Enum.each(members, fn m -> File.chmod!(Path.join(tmp, m), 0o644) end) + + case System.cmd("xcrun", ["ar", "rcs", archive_path | members], + cd: tmp, + stderr_to_stdout: true + ) do + {_, 0} -> :ok + {out, code} -> {:error, "xcrun ar rcs failed (#{code}): #{out}"} + end + + {out, code} -> + {:error, "xcrun ar -x failed (#{code}): #{out}"} + end + after + File.rm_rf!(tmp) + end + end + + defp module_basename(module) do + module |> Module.split() |> Enum.join(".") + end + + # Zig target triples passed to `-Dtarget=...`. iOS device + sim + # differ via the `.simulator` ABI suffix. + defp zig_build_target_for(:ios_device), do: "aarch64-ios-none" + defp zig_build_target_for(:ios_sim), do: "aarch64-ios-simulator" + defp zig_build_target_for(:android_arm64), do: "aarch64-linux-android" + # Zig's armv7-android triple uses `arm-linux-androideabi`; the + # `androideabi` ABI marker matches Rust's `armv7-linux-androideabi` + # for ELF-level compatibility when both libs land in the same .so. + defp zig_build_target_for(:android_arm32), do: "arm-linux-androideabi" + defp zig_build_target_for(:android_x86_64), do: "x86_64-linux-android" + + @spec generate_erl_errno_compat_stub(Path.t()) :: :ok + def generate_erl_errno_compat_stub(build_dir) do + File.write!( + Path.join(build_dir, "erl_errno_id_compat.c"), + """ + __attribute__((weak)) const char *erl_errno_id_unknown(int error) { + (void)error; + return "unknown"; + } + """ + ) + + :ok + end + + defp zig_build_binary_ios_device( + mob_dir, + otp_root, + erts_vsn, + otp_release, + sdkroot, + epmd_build_src, + build_dir, + display_name, + project_swift_sources, + sqlite_static_lib, + mlx_dir, + nxeigen_archive, + tflite_build + ) do + driver_tab = resolve_driver_tab_ios(mob_dir) + + # Plugin contributions, same as the sim path above. + activated_plugins = MobDev.Plugin.activated() + + # Capability enforcement — same one-liner the sim path uses. See + # MOB_PLUGIN_SECURITY.md, Layer 2. + MobDev.Plugin.Validator.raise_on_capability_drift!(activated_plugins) + + # See sim build for the rationale (MOB-7). A plugin-aware build_device.zig + # implies an AppDelegate that always calls mob_register_plugins(), so a + # zero-plugin app still needs the bootstrap; legacy scaffolds get empty flags. + {plugin_swift_files, plugin_frameworks} = + ios_plugin_swift_and_frameworks( + activated_plugins, + build_dir, + Path.expand("ios/build_device.zig") + ) + + # Activated plugins' C NIF sources — see the sim build for the full + # rationale. Same path on device; the iPhone uses build_device.zig. + plugin_c_nifs = MobDev.Plugin.Merge.nif_sources(activated_plugins, :ios) |> Enum.join(",") + + base_args = [ + "build", + "binary", + "--build-file", + "ios/build_device.zig", + "-Dmob_dir=#{mob_dir}", + "-Dotp_root=#{otp_root}", + "-Derts_vsn=#{erts_vsn}", + "-Dotp_release=#{otp_release}", + "-Dsdkroot=#{sdkroot}", + "-Ddriver_tab=#{driver_tab}", + "-Denif_keepalive=#{Path.join(build_dir, "enif_keepalive.c")}", + "-Dproject_ios_dir=#{Path.expand("ios")}", + "-Dmodule_name=#{display_name}", + "-Depmd_build_src=#{epmd_build_src}", + "-Derrno_compat=#{Path.join(build_dir, "erl_errno_id_compat.c")}", + "-Dproject_swift_sources=#{project_swift_sources}" + ] + + plugin_args = + for {name, val} <- [ + {"plugin_swift_files", plugin_swift_files}, + {"plugin_frameworks", plugin_frameworks}, + {"plugin_c_nifs", plugin_c_nifs} + ], + val != "", + do: "-D#{name}=#{val}" + + with {:ok, nif_args} <- project_nif_zig_args(:ios_device), + {:ok, plugin_archives} <- build_plugin_static_archives(:ios_device, :ios, otp_root) do + sqlite_args = + case sqlite_static_lib do + nil -> [] + path -> ["-Dsqlite_static=true", "-Dsqlite_static_lib=#{path}"] + end + + args = + base_args ++ + plugin_args ++ + nif_args ++ + sqlite_args ++ + mlx_zig_args(mlx_dir) ++ + nxeigen_zig_args_ios(nxeigen_archive) ++ + tflite_zig_args_ios(tflite_build) ++ + plugin_static_lib_args(plugin_archives) + + case System.cmd("zig", args, stderr_to_stdout: true, into: IO.stream()) do + {_, 0} -> + File.cp!("ios/zig-out/#{display_name}", Path.join(build_dir, display_name)) + :ok + + {_, code} -> + {:error, "zig build binary (iOS device) exited #{code}"} + end + end + end + + defp xcrun_sdk_path_device do + case System.cmd("xcrun", ["-sdk", "iphoneos", "--show-sdk-path"], stderr_to_stdout: true) do + {path, 0} -> {:ok, String.trim(path)} + {_, _} -> {:error, "xcrun -sdk iphoneos failed — Xcode missing?"} + end + end + + defp detect_otp_release(otp_root) do + releases = Path.join(otp_root, "releases") + + case File.ls(releases) do + {:ok, entries} -> + # Plain integer-string filter — avoids a literal `~r` regex + # because OTP 28.0 trips on `:re.import/1` for module-level + # compiled regexes. Switch back to `~r/.../` once the + # project's OTP pin is 28.1+ or ≤ 27. + entries + |> Enum.filter(&match?({_, ""}, Integer.parse(&1))) + |> Enum.sort_by(&String.to_integer/1, :desc) + |> List.first() + + _ -> + nil + end + end + + # Phase 2 iter 6: Bundle assembly + simctl install moved out of build.sh. + # The shell script now ends after `zig build binary`; everything below + # used to live as the `# ── Bundle + install ──` block in ios/build.sh.eex. + + defp ios_display_name do + Mix.Project.config() + |> Keyword.fetch!(:app) + |> Atom.to_string() + |> Macro.camelize() + end + + # The user-facing `--device` accepts any case-insensitive prefix of + # a booted simulator's UDID (e.g. `defd4bdc` for + # `DEFD4BDC-CA42-4CD2-93A1-62BE425E7A78`). Previously the prefix got + # passed straight to `xcrun simctl install` which only accepts full + # UDIDs and refused with `Invalid device: <prefix>`. Resolve to a + # full UDID via `simctl list devices booted` first. + defp pick_ios_sim(device_id) do + case System.cmd("xcrun", ~w(simctl list devices booted -j), stderr_to_stdout: true) do + {json, 0} -> + with {:ok, %{"devices" => by_runtime}} <- Jason.decode(json), + udid when is_binary(udid) <- resolve_booted_udid(by_runtime, device_id) do + {:ok, udid} + else + _ -> + {:error, sim_lookup_error_message(device_id)} + end + + _ -> + {:error, "xcrun simctl list failed — is Xcode installed?"} + end + end + + @doc """ + Given the JSON-decoded `xcrun simctl list devices booted -j` result + and an optional `device_id` (full UDID or any case-insensitive + prefix of one), return the matching booted simulator's full UDID + or nil. + + When `device_id` is nil, exactly one booted simulator must exist. + When `device_id` is a string, exactly one case-insensitive prefix + match must exist. Malformed inventories, duplicate entries, empty + identifiers, and ambiguous matches return nil. Public for testing — + the JSON shape and fail-closed uniqueness are the contract. + """ + @spec resolve_booted_udid(map(), String.t() | nil) :: String.t() | nil + def resolve_booted_udid(by_runtime, device_id) when is_map(by_runtime) do + with true <- valid_optional_ios_target_id?(device_id), + {:ok, booted_udids} <- collect_booted_udids(Map.values(by_runtime), []) do + matches = + case device_id do + nil -> + booted_udids + + id -> + needle = String.downcase(id) + + Enum.filter(booted_udids, fn udid -> + String.starts_with?(String.downcase(udid), needle) + end) + end + + case matches do + [udid] -> udid + _none_or_ambiguous -> nil + end + else + _invalid_or_malformed -> nil + end + end + + def resolve_booted_udid(_invalid_inventory, _device_id), do: nil + + defp collect_booted_udids([], acc), do: {:ok, Enum.reverse(acc)} + + defp collect_booted_udids([devices | remaining_runtimes], acc) do + with {:ok, next_acc} <- collect_booted_runtime_devices(devices, acc) do + collect_booted_udids(remaining_runtimes, next_acc) + end + end + + defp collect_booted_udids(_improper_runtime_list, _acc), do: {:error, :malformed_inventory} + + defp collect_booted_runtime_devices([], acc), do: {:ok, acc} + + defp collect_booted_runtime_devices([%{"state" => "Booted", "udid" => udid} | devices], acc) do + if valid_ios_target_id?(udid) do + collect_booted_runtime_devices(devices, [udid | acc]) + else + {:error, :malformed_booted_device} + end + end + + defp collect_booted_runtime_devices([%{"state" => "Booted"} | _devices], _acc), + do: {:error, :malformed_booted_device} + + defp collect_booted_runtime_devices( + [%{"state" => state, "udid" => udid} | devices], + acc + ) + when state in ["Shutdown", "Shutting Down", "Creating"] do + if valid_ios_target_id?(udid) do + collect_booted_runtime_devices(devices, acc) + else + {:error, :malformed_non_booted_device} + end + end + + defp collect_booted_runtime_devices(_malformed_devices, _acc), + do: {:error, :malformed_inventory} + + defp valid_optional_ios_target_id?(nil), do: true + defp valid_optional_ios_target_id?(id), do: valid_ios_target_id?(id) + + defp valid_ios_target_id?(id) when is_binary(id), + do: byte_size(id) in 1..256 and String.valid?(id) + + defp valid_ios_target_id?(_invalid), do: false + + defp sim_lookup_error_message(nil), + do: "No booted simulator. Boot one in Simulator.app or pass `--device <UDID>`." + + defp sim_lookup_error_message(id), + do: + "No booted simulator matched `--device #{id}`. " <> + "Pass a full UDID or a case-insensitive prefix that matches " <> + "exactly one booted sim. Run `mix mob.devices` to see what's available." + + defp bundle_ios_app(binary_path, display_name) do + build_dir = + Path.join(System.tmp_dir!(), "mob_ios_bundle_#{System.unique_integer([:positive])}") + + File.mkdir_p!(build_dir) + app_path = Path.join(build_dir, "#{display_name}.app") + File.rm_rf!(app_path) + File.mkdir_p!(app_path) + + IO.puts(" Building .app bundle at #{app_path}...") + File.cp!(binary_path, Path.join(app_path, display_name)) + + cond do + not File.exists?("ios/Info.plist") -> + {:error, "ios/Info.plist not found — required for the .app bundle"} + + true -> + info_plist = Path.join(app_path, "Info.plist") + File.cp!("ios/Info.plist", info_plist) + apply_plugin_plist_keys!(info_plist) + apply_fonts_to_ios_bundle!(info_plist, app_path) + if File.dir?("ios/Assets.xcassets/AppIcon.appiconset"), do: compile_ios_icons(app_path) + {:ok, app_path} + end + end + + defp compile_ios_icons(app_path) do + actool_plist = + Path.join(System.tmp_dir!(), "mob_actool_#{System.unique_integer([:positive])}.plist") + + # actool can be flaky and is non-critical (the binary still runs without + # icons compiled — just shows the system default). Mirror the build.sh + # `2>/dev/null || true` posture so a broken Assets.xcassets doesn't kill + # an otherwise-successful native build. + case System.cmd( + "xcrun", + [ + "actool", + "ios/Assets.xcassets", + "--compile", + app_path, + "--platform", + "iphonesimulator", + "--minimum-deployment-target", + "17.0", + "--app-icon", + "AppIcon", + "--output-partial-info-plist", + actool_plist + ], + stderr_to_stdout: true + ) do + {_, 0} -> + _ = + System.cmd( + "/usr/libexec/PlistBuddy", + [ + "-c", + "Merge #{actool_plist}", + Path.join(app_path, "Info.plist") + ], + stderr_to_stdout: true + ) + + _ -> + :ok + end + + File.rm(actool_plist) + end + + defp install_ios_sim(sim_id, app_path) do + IO.puts(" Installing on simulator #{sim_id}...") + + case System.cmd("xcrun", ["simctl", "install", sim_id, app_path], + stderr_to_stdout: true, + into: IO.stream() + ) do + {_, 0} -> :ok + {_, _} -> {:error, "xcrun simctl install failed — check output above"} + end + end + + # Physical iOS: compile for device SDK, bundle OTP, sign, install via devicectl. + # Mirrors the mob_qa build_device.sh approach but driven from mob.exs config. + # + # Required mob.exs keys: + # ios_team_id — Apple Developer Team ID (10-char alphanumeric) + # ios_sign_identity — codesign identity string (from `security find-identity -v -p codesigning`) + # ios_profile_uuid — provisioning profile UUID (filename without .mobileprovision) + # + # Optional mob.exs key: + # ios_epmd_build_src — path to an OTP tree that exposes EPMD source under + # erts/epmd/src/ and iOS headers under erts/include/. + # Defaults to the iOS-device OTP cache, which ships + # these files starting with the post-(c) tarball. + defp build_ios_physical(cfg, udid) do + IO.puts(" Building iOS app for physical device #{udid}...") + + with {:ok, cfg} <- check_device_signing_config(cfg), + {:ok, otp_root} <- MobDev.OtpDownloader.ensure_ios_device(), + {:ok, python_bundle} <- maybe_ensure_python_bundle(), + {:ok, mlx_dir} <- maybe_ensure_mlx_dir(:ios_device), + {:ok, nxeigen_archive} <- maybe_build_nxeigen(:ios_device), + {:ok, tflite_build} <- maybe_build_tflite(:ios_device), + {:ok, sdkroot} <- xcrun_sdk_path_device(), + erts_vsn = detect_erts_vsn(otp_root) || "erts-17.0", + otp_release = detect_otp_release(otp_root) || "27", + mob_dir = Path.expand(cfg[:mob_dir]), + elixir_lib = Path.expand(resolve_elixir_lib(cfg[:elixir_lib])), + epmd_build_src = cfg[:ios_epmd_build_src] || otp_root, + app_module = Mix.Project.config() |> Keyword.fetch!(:app) |> Atom.to_string(), + display_name = ios_display_name(), + project_swift_sources = project_swift_sources_arg(cfg), + build_dir = + Path.join(System.tmp_dir!(), "mob_ios_device_#{System.unique_integer([:positive])}"), + _ = File.mkdir_p!(build_dir), + :ok <- compile_elixir_for_ios(), + :ok <- copy_app_beams(otp_root, app_module), + :ok <- install_exqlite_otp_lib(otp_root), + :ok <- cross_compile_exqlite_nif_device(otp_root, erts_vsn, sdkroot), + :ok <- install_emlx_otp_lib(otp_root), + :ok <- install_nx_eigen_otp_lib(otp_root), + {:ok, sqlite_static_lib} = {:ok, sqlite_device_static_path(otp_root)}, + :ok <- + maybe_setup_pythonx_device(otp_root, erts_vsn, sdkroot, python_bundle, app_module), + :ok <- maybe_install_crypto_shim(otp_root, app_module), + :ok <- maybe_install_ssl_shim(otp_root, app_module), + :ok <- copy_elixir_stdlib_to_otp(elixir_lib, otp_root), + :ok <- copy_eex_stdlib_to_app(elixir_lib, otp_root, app_module), + :ok <- copy_otp_libs_for_phoenix(otp_root), + :ok <- copy_priv_repo_assets(otp_root, app_module), + :ok <- maybe_build_phoenix_assets(otp_root, app_module), + :ok <- install_app_in_otp_lib(otp_root, app_module), + :ok <- copy_mob_logos_to_otp_root(mob_dir, otp_root), + :ok <- patch_epmd_source(epmd_build_src), + :ok <- generate_erl_errno_compat_stub(build_dir), + :ok <- generate_enif_keepalive(otp_root, erts_vsn, build_dir), + :ok <- + zig_build_binary_ios_device( + mob_dir, + otp_root, + erts_vsn, + otp_release, + sdkroot, + epmd_build_src, + build_dir, + display_name, + project_swift_sources, + sqlite_static_lib, + mlx_dir, + nxeigen_archive, + tflite_build + ), + binary_path = Path.join(build_dir, display_name), + :ok <- check_path(binary_path, "iOS device binary"), + {:ok, app_path} <- bundle_ios_device_app(binary_path, otp_root, cfg, build_dir), + :ok <- + copy_tflite_frameworks_ios( + tflite_build, + "ios-arm64", + Path.join(app_path, "Frameworks") + ), + :ok <- maybe_slim_otp_bundle(app_path, cfg), + :ok <- embed_provisioning_profile(app_path, cfg[:ios_profile_uuid]), + :ok <- codesign_ios_device_app(app_path, cfg, build_dir), + :ok <- devicectl_install(udid, app_path) do + {:ok, "iOS (device)"} + else + {:error, reason} -> {:error, "iOS", reason} + end + end + + defp sqlite_device_static_path(otp_root) do + case detect_dep_version("exqlite") do + nil -> + nil + + vsn -> + path = Path.join([otp_root, "lib/exqlite-#{vsn}/priv/sqlite3_nif.a"]) + if File.exists?(path), do: path, else: nil + end + end + + # Phase 2 iter 12d: bundle + codesign + devicectl install moved out of + # build_device.sh. The shell script now ends after `zig build binary` + # produces the Mach-O at MOB_BUILD_DIR/<app_name>; everything below used + # to live as the `# ── Bundle / Code signing / Installing ──` blocks. + + defp bundle_ios_device_app(binary_path, otp_root, cfg, build_dir) do + app_name = ios_display_name() + app_module = Mix.Project.config() |> Keyword.fetch!(:app) |> Atom.to_string() + bundle_id = cfg[:ios_bundle_id] || cfg[:bundle_id] + + if is_nil(bundle_id), do: throw_bundle_id_error() + + erts_vsn = detect_erts_vsn(otp_root) || "erts-17.0" + app_path = Path.join(build_dir, "#{app_name}.app") + + IO.puts(" === Building .app bundle at #{app_path}") + File.rm_rf!(app_path) + File.mkdir_p!(app_path) + File.cp!(binary_path, Path.join(app_path, app_name)) + + cond do + not File.exists?("ios/Info.plist") -> + {:error, "ios/Info.plist not found"} + + true -> + info_plist = Path.join(app_path, "Info.plist") + File.cp!("ios/Info.plist", info_plist) + apply_plugin_plist_keys!(info_plist) + apply_fonts_to_ios_bundle!(info_plist, app_path) + plist_set!(info_plist, ":CFBundleIdentifier", bundle_id) + plist_set!(info_plist, ":CFBundleExecutable", app_name) + plist_set!(info_plist, ":CFBundleName", app_name) + + if File.dir?("ios/Assets.xcassets/AppIcon.appiconset"), + do: compile_ios_device_icons(app_path) + + bundle_otp_runtime(app_path, otp_root, app_module, erts_vsn) + maybe_bundle_mlx_metallib(app_path) + {:ok, app_path} + end + end + + # MLX's Metal backend looks for a colocated `mlx.metallib` next to the + # running binary (`get_binary_directory()/mlx.metallib`). When mob ships + # a Metal-enabled MLX bundle the cached MLX_DIR contains a + # `lib/mlx.metallib` next to the static archives — copy it into the + # .app bundle alongside the main binary so MLX can find it at runtime. + # No-op when the bundle is CPU-only (`device: :gpu` then returns the + # "Cannot get gpu stream" error from EMLX). + @doc false + @spec maybe_bundle_mlx_metallib(String.t()) :: :ok + def maybe_bundle_mlx_metallib(app_path) do + with {:ok, mlx_dir} <- MobDev.MLXDownloader.ensure_ios_device(), + src when is_binary(src) <- MobDev.MLXDownloader.metallib_path(mlx_dir) do + File.cp!(src, Path.join(app_path, "mlx.metallib")) + IO.puts(" === Copied mlx.metallib (Metal GPU kernels) into .app") + :ok + else + _ -> :ok + end + end + + defp plist_set!(plist, key, value) do + {_, 0} = + System.cmd("/usr/libexec/PlistBuddy", ["-c", "Set #{key} #{value}", plist], + stderr_to_stdout: true + ) + + :ok + end + + # Adds plugin-declared Info.plist keys via PlistBuddy `Add`. Add fails (and is + # ignored) when the key is already present, giving us "project Info.plist wins + # on conflict; plugins fill gaps" semantics — so a plugin can ship a default + # NSCameraUsageDescription that the app author can override in their own + # Info.plist without changing the plugin. See ADR + # decisions/2026-05-28-plugin-plist-keys-merge.md. + defp apply_plugin_plist_keys!(info_plist) do + activated_plugins = MobDev.Plugin.activated() + + for {key, value} <- MobDev.Plugin.Merge.plist_keys(activated_plugins) do + cond do + # Array-valued keys (e.g. UIBackgroundModes) MERGE into any existing + # array — append the missing string entries, deduped — rather than + # clobber. Lets a plugin contribute `bluetooth-central` without wiping a + # host's `audio` entry (mob_background) and vice versa. + is_list(value) -> + plist_merge_array!(info_plist, key, value) + + true -> + case plist_add_type(value) do + {:ok, type, str_value} -> + plist_add(info_plist, ":#{key}", type, str_value) + + :unsupported -> + Mix.shell().info( + " [plugin plist] skipping :#{key} — unsupported value type #{inspect(value)}" + ) + end + end + end + + :ok + end + + # Ensure `key` is an `<array>` in the plist and append every string item in + # `items` that isn't already present (order-preserving, deduped against what's + # on disk). PlistBuddy `Add :key array` is a no-op when the array already + # exists (the duplicate-key swallow in plist_add/4), so this composes with a + # host plist that already declares the key. + defp plist_merge_array!(plist, key, items) do + plist_add(plist, ":#{key}", "array", "") + + existing = plist_array_entries(plist, ":#{key}") + + for item <- plist_array_additions(existing, items) do + plist_add(plist, ":#{key}:", "string", item) + end + + :ok + end + + @doc false + # Pure: given the array entries already on disk and a plugin's desired items, + # return the string items to append — binaries only, not already present, + # de-duplicated, input order preserved. Extracted so the merge decision is + # unit-testable without PlistBuddy. + @spec plist_array_additions([String.t()], [term()]) :: [String.t()] + def plist_array_additions(existing, items) do + items + |> Enum.filter(&is_binary/1) + |> Enum.uniq() + |> Enum.reject(&(&1 in existing)) + end + + # Read the current string entries of an `<array>` plist key via PlistBuddy + # `Print`. Returns [] when the key is absent or not an array. + defp plist_array_entries(plist, key) do + case System.cmd("/usr/libexec/PlistBuddy", ["-c", "Print #{key}", plist], + stderr_to_stdout: true + ) do + {out, 0} -> + out + |> String.split("\n") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 in ["Array {", "}", ""])) + + _ -> + [] + end + end + + defp plist_add_type(value) when is_binary(value), do: {:ok, "string", value} + defp plist_add_type(true), do: {:ok, "bool", "true"} + defp plist_add_type(false), do: {:ok, "bool", "false"} + + defp plist_add_type(value) when is_integer(value), + do: {:ok, "integer", Integer.to_string(value)} + + defp plist_add_type(_other), do: :unsupported + + # PlistBuddy `Add` is non-zero on duplicate-key (and on a few other failure + # modes we'd want to know about). We swallow the duplicate-key case + # deliberately — that's our project-wins mechanism — and accept that other + # PlistBuddy errors will pass silently. The first plugin that hits a real + # problem here can extend this to inspect stderr and surface non-duplicate + # failures. + defp plist_add(plist, key, type, value) do + System.cmd( + "/usr/libexec/PlistBuddy", + ["-c", "Add #{key} #{type} #{value}", plist], + stderr_to_stdout: true + ) + + :ok + end + + # ── Android plugin contributions: manifest + gradle ────────────────────────── + + @android_manifest_path "android/app/src/main/AndroidManifest.xml" + @android_app_gradle_path "android/app/build.gradle" + @android_java_root "android/app/src/main/java" + # Generated startup hook (package io.mob.plugin). MainActivity.onCreate calls + # io.mob.plugin.MobPluginBootstrap.registerAll(this). + @plugin_bootstrap_path "android/app/src/main/java/io/mob/plugin/MobPluginBootstrap.kt" + # Generated stable contract: a bridge class implements MobActivityAware to be + # handed the host Activity by registerAll. Always written next to the bootstrap. + @plugin_activity_aware_path "android/app/src/main/java/io/mob/plugin/MobActivityAware.kt" + # Generated stable contract: a bridge class implements MobPermissionProvider to + # supply the cap->Android-permission-string mapping for a capability core no + # longer knows about (the permission-registry extension). Always written. + @plugin_permission_provider_path "android/app/src/main/java/io/mob/plugin/MobPermissionProvider.kt" + # Generated stable seam: notification-delivery state shared between HOST + # delivery code (MobFirebaseService / MainActivity / NotificationReceiver, + # app package) and the mob_notify plugin bridge (io.mob.notify) — neither + # can reference the other's package directly. Always written. + @plugin_notify_hub_path "android/app/src/main/java/io/mob/plugin/MobNotifyHub.kt" + + # Inserts `<uses-permission android:name="..."/>` lines for each permission + # declared by activated plugins into `AndroidManifest.xml`. Idempotent: skips + # any permission name already present in the manifest (covers both the + # project's hand-rolled declarations and a previous run's plugin merge). + # + # No-op (with a notice) when the manifest is missing — mirrors how + # `MobDev.Enable.Igniter.add_android_permission/2` handles the absence at + # `mix mob.enable` time. + defp apply_plugin_android_manifest! do + activated = MobDev.Plugin.activated() + + # Capability enforcement — same call the iOS sim/device paths use; runs + # the AndroidManifest-fragment + Swift-import scans across every + # activated plugin and raises on drift. See MOB_PLUGIN_SECURITY.md, + # Layer 2. + MobDev.Plugin.Validator.raise_on_capability_drift!(activated) + + permissions = MobDev.Plugin.Merge.android_permissions(activated) + snippets = for s <- MobDev.Plugin.Merge.android_manifest_snippets(activated), do: s.snippet + + case File.read(@android_manifest_path) do + {:error, :enoent} -> + if permissions != [] or snippets != [] do + IO.puts( + " [plugin android] #{@android_manifest_path} not found — skipping plugin " <> + "permissions + manifest components." + ) + end + + :ok + + {:ok, content} -> + patched = + content + |> merge_android_permissions(permissions) + |> merge_android_manifest_components(snippets) + + if patched != content, do: File.write!(@android_manifest_path, patched) + + :ok + end + end + + # Inserts `implementation "<dep>"` lines for each gradle dependency declared + # by activated plugins into the app-level `build.gradle`'s `dependencies { }` + # block. Idempotent: skips any dep string already mentioned anywhere in the + # file (the substring check is intentionally broad — Gradle allows several + # syntaxes for the same dep, and we'd rather under-add than duplicate). + # + # No-op (with a notice) when the gradle file is missing. + defp apply_plugin_gradle_deps! do + case File.read(@android_app_gradle_path) do + {:error, :enoent} -> + if MobDev.Plugin.Merge.gradle_deps(MobDev.Plugin.activated()) != [] do + IO.puts( + " [plugin android] #{@android_app_gradle_path} not found — skipping plugin gradle_deps." + ) + end + + :ok + + {:ok, content} -> + deps = MobDev.Plugin.Merge.gradle_deps(MobDev.Plugin.activated()) + patched = merge_gradle_deps(content, deps) + + if patched != content, do: File.write!(@android_app_gradle_path, patched) + + :ok + end + end + + @host_migrations_dir "priv/repo/migrations" + @plugin_assets_root "priv/generated/plugin_assets" + @plugin_artifact_ledger_dir "priv/generated/.mob_plugin_artifacts" + + @doc false + # The removal half of the add/remove plugin lifecycle. A plugin's tier-3 + # merges COPY files into the host tree (bridge Kotlin into the Kotlin + # sourceSet, migrations into priv/repo/migrations, images into the asset + # bundle); the runtime manifest + driver_tab are recomputed from scratch each + # build, but these copies linger after a plugin is removed — an orphaned + # bridge .kt can even break the Gradle compile. This deletes the files a + # prior build wrote for one merge concern (`scope`) that the current build no + # longer produces: it reads the scope's ledger of relative paths, removes + # (previous − current), then persists `current`. Per-scope and only called + # when that concern's merge runs, so an iOS-only build never prunes Android + # artifacts. Returns the pruned paths (for tests). + # The relative paths a prior build recorded for a plugin-artifact `scope` + # (empty when none). Shared by the prune and the res host-clobber guard. + defp read_plugin_artifact_ledger(scope) do + case File.read(Path.join(@plugin_artifact_ledger_dir, to_string(scope))) do + {:ok, body} -> String.split(body, "\n", trim: true) + _ -> [] + end + end + + @spec __prune_plugin_artifacts__(atom(), [Path.t()]) :: [Path.t()] + def __prune_plugin_artifacts__(scope, current) do + ledger = Path.join(@plugin_artifact_ledger_dir, to_string(scope)) + current = current |> Enum.map(&Path.relative_to_cwd/1) |> Enum.uniq() + + previous = read_plugin_artifact_ledger(scope) + + pruned = + for stale <- previous -- current, File.exists?(stale) do + File.rm!(stale) + IO.puts(" ✓ pruned orphaned plugin artifact (plugin removed): #{stale}") + stale + end + + File.mkdir_p!(@plugin_artifact_ledger_dir) + File.write!(ledger, Enum.join(current, "\n")) + pruned + end + + # Tier 3: copies each activated plugin's migration files into the host's + # migrations dir, namespaced by `repo_namespace` (version-preserving) so the + # host's existing `Ecto.Migrator` picks them up. Idempotent. No-op when no + # plugin declares `:migrations`. + # Rebuilds priv/generated/mob_plugins.exs (the host's runtime plugin manifest) + # from the activated plugins' current manifests, so the on-device tier-3/4 + # wiring always matches what the plugins declare at build time. + @doc false + # Regenerates the on-disk static-NIF driver tables for every format the + # project already uses (zig and/or c). A project with no generated driver_tab + # files is normally left untouched — a plain app relies on mob's core table at + # link time. BUT activating a NIF-bearing plugin adds entries the core table + # lacks: without an app-level table the plugin's `<module>_nif_init` links yet + # never registers, so `load_nif/2` falls back to dlopen and the NIF is + # `:nif_not_loaded` on device. So when plugins contribute NIFs and the app has + # no table yet, create one (zig — the default format). Public for tests (and + # exercised on every `build_all`). + @spec regen_driver_tab!() :: :ok + def regen_driver_tab! do + formats = + __regen_formats__(__driver_tab_formats__(&File.exists?/1), __plugin_nifs_present__()) + + for fmt <- formats do + paths = Mix.Tasks.Mob.RegenDriverTab.target_paths(fmt) + + expected = %{ + paths.ios => + MobDev.StaticNifs.generate(:ios, Mix.Tasks.Mob.RegenDriverTab.resolved_nifs(:ios), + format: fmt + ) + |> IO.iodata_to_binary(), + paths.android => + MobDev.StaticNifs.generate( + :android, + Mix.Tasks.Mob.RegenDriverTab.resolved_nifs(:android), + format: fmt + ) + |> IO.iodata_to_binary() + } + + for {path, src} <- expected, + File.read(path) != {:ok, src} do + existed? = File.exists?(path) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, src) + verb = if existed?, do: "regenerated (was stale)", else: "created (plugin NIFs)" + IO.puts(" ✓ driver_tab #{verb}: #{path}") + end + end + + :ok + end + + @doc false + # True when any activated plugin contributes a NIF — the trigger for creating + # an app-level driver_tab where none exists (see regen_driver_tab!/0). + @spec __plugin_nifs_present__() :: boolean() + def __plugin_nifs_present__ do + MobDev.Plugin.Merge.nifs(MobDev.Plugin.activated()) != [] + end + + @doc false + # Pure kernel: which driver_tab formats to (re)generate. An app with existing + # tables keeps its format(s). An app with NONE normally generates nothing (it + # links against mob's core table) — UNLESS a NIF-bearing plugin is active, in + # which case it needs its own table (core + plugin entries), defaulting to + # zig. Public for tests. + @spec __regen_formats__([:zig | :c], boolean()) :: [:zig | :c] + def __regen_formats__([], true), do: [:zig] + def __regen_formats__([], false), do: [] + def __regen_formats__(existing, _plugin_nifs?), do: existing + + defp warn_host_requirements! do + case __host_requirements_warning__( + MobDev.Plugin.Merge.host_requirements(MobDev.Plugin.activated()) + ) do + nil -> :ok + msg -> IO.puts(msg) + end + + :ok + end + + @doc false + # Pure kernel: render the host-obligation warning block (nil when no plugin + # declares any). Public for tests. + @spec __host_requirements_warning__([%{plugin: atom(), requirement: String.t()}]) :: + String.t() | nil + def __host_requirements_warning__([]), do: nil + + def __host_requirements_warning__(reqs) do + lines = for %{plugin: p, requirement: r} <- reqs, do: " [#{p}] #{r}" + + IO.ANSI.yellow() <> + " ⚠ plugin host requirements — manual steps the build can NOT do for you:\n" <> + Enum.join(lines, "\n") <> IO.ANSI.reset() + end + + @doc false + # Pure kernel: which driver_tab formats the project uses, decided from file + # existence alone (`exists?` is injected so tests don't touch the disk). + @spec __driver_tab_formats__((String.t() -> boolean())) :: [:zig | :c] + def __driver_tab_formats__(exists?) when is_function(exists?, 1) do + for fmt <- [:zig, :c], + paths = Mix.Tasks.Mob.RegenDriverTab.target_paths(fmt), + exists?.(paths.ios) or exists?.(paths.android), + do: fmt + end + + defp regen_runtime_manifest! do + manifest = MobDev.Plugin.RuntimeManifest.build(MobDev.Plugin.activated()) + MobDev.Plugin.RuntimeManifest.write(File.cwd!(), manifest) + %{screens: s, lifecycle: l, settings: st, notification_handlers: n} = manifest + + IO.puts( + " ✓ runtime plugin manifest (#{length(s)} screens, #{length(l)} lifecycle, " <> + "#{length(st)} settings, #{length(n)} handlers)" + ) + + :ok + end + + defp apply_plugin_migrations! do + migrations = MobDev.Plugin.Merge.migrations(MobDev.Plugin.activated()) + + written = + if migrations != [] do + File.mkdir_p!(@host_migrations_dir) + + plugin_migs = + for m <- migrations do + %{ + repo_namespace: m.repo_namespace, + files: Path.wildcard(Path.join(m.migrations_dir, "*.exs")) + } + end + + for {src, dest} <- + MobDev.Plugin.Assets.migration_copies(plugin_migs, @host_migrations_dir) do + File.cp!(src, dest) + IO.puts(" ✓ plugin migration → #{Path.relative_to_cwd(dest)}") + dest + end + else + [] + end + + # Prune migrations a removed plugin left in the host dir. Deleting the file + # does not roll back an already-applied migration (schema_migrations keeps + # the record); it just stops Ecto re-running it and keeps the dir honest. + __prune_plugin_artifacts__(:migrations, written) + + :ok + end + + # Tier 3: copies each activated plugin's images into the host bundle under + # `priv/generated/plugin_assets/assets/plugin/<plugin>/<file>` — the path the + # core `Mob.Plugins.resolve_image/1` (`plugin://<plugin>/<file>`) resolves to. + # No-op when no plugin declares image assets. + defp apply_plugin_images! do + written = + for %{plugin: plugin, images: images} <- + MobDev.Plugin.Merge.assets(MobDev.Plugin.activated()), + src <- images do + rel = MobDev.Plugin.Assets.image_bundle_path(plugin, Path.basename(src)) + dest = Path.join(@plugin_assets_root, rel) + File.mkdir_p!(Path.dirname(dest)) + File.cp!(src, dest) + IO.puts(" ✓ plugin image → #{Path.relative_to_cwd(dest)}") + dest + end + + # Prune images a removed plugin left in the bundle. + __prune_plugin_artifacts__(:images, written) + + :ok + end + + @android_res_font "android/app/src/main/res/font" + + # App-level (`priv/fonts/*.ttf|otf`) + plugin (`assets.fonts`) custom fonts. + defp collect_all_fonts do + app_fonts = Path.wildcard("priv/fonts/*.{ttf,otf,TTF,OTF}") + + plugin_fonts = + for %{fonts: fonts} <- MobDev.Plugin.Merge.assets(MobDev.Plugin.activated()), + f <- fonts, + do: f + + Enum.uniq(app_fonts ++ plugin_fonts) + end + + # Copies the app's + plugins' fonts into the iOS `.app` bundle root and lists + # them in `Info.plist` UIAppFonts so iOS registers them at launch (the SwiftUI + # `Font.custom(name, …)` path in MobRootView then resolves them by name). No-op + # when there are no fonts. + defp apply_fonts_to_ios_bundle!(info_plist, app_path) do + fonts = collect_all_fonts() + + if fonts != [] do + copies = + case MobDev.Plugin.Assets.plan_ios_font_bundle(fonts) do + {:ok, copies} -> + copies + + {:error, {:font_basename_collision, name, srcs}} -> + Mix.raise( + "Font bundle collision: multiple fonts share the iOS bundle name #{name}:\n " <> + Enum.join(srcs, "\n ") <> + "\nRename one so the .app bundle + UIAppFonts stay unambiguous." + ) + end + + for {src, dest} <- copies, do: File.cp!(src, Path.join(app_path, dest)) + basenames = Enum.map(copies, fn {_src, dest} -> dest end) + plist = File.read!(info_plist) + File.write!(info_plist, MobDev.Plugin.Assets.merge_ui_app_fonts(plist, basenames)) + IO.puts(" ✓ bundled #{length(copies)} font(s) + UIAppFonts") + end + + :ok + end + + # Copies the app's + plugins' fonts into the Android `res/font/` dir under a + # normalised resource name (lowercase + underscores; the renderer normalises + # the `font:` prop the same way to look them up via `getIdentifier`). Unlike + # `assets/`, `res/font/` entries are stored uncompressed, which Android's font + # loader requires. No-op when there are no fonts. + defp apply_fonts_to_android! do + fonts = collect_all_fonts() + + if fonts != [] do + copies = + case MobDev.Plugin.Assets.plan_android_font_copies(fonts) do + {:ok, copies} -> + copies + + {:error, {:font_resource_collision, res_name, srcs}} -> + Mix.raise( + "Font resource collision: multiple fonts normalise to the Android resource #{res_name}:\n " <> + Enum.join(srcs, "\n ") <> + "\nRename one (Android collapses '-', '_', ' ' etc. to '_')." + ) + end + + File.mkdir_p!(@android_res_font) + + for {src, res_filename} <- copies do + dest = Path.join(@android_res_font, res_filename) + File.cp!(src, dest) + IO.puts(" ✓ android font → #{Path.relative_to_cwd(dest)}") + end + end + + :ok + end + + @android_res_root "android/app/src/main" + + # Copies each activated plugin's `android.res_files` into the app's `res/` + # tree (at its declared `res/<type>/<file>` destination), so a manifest + # component's `@xml/…` reference resolves at build time. Ledger-pruned like + # bridge_kt (a res file left by a since-removed plugin is deleted). Raises on + # two plugins targeting the same destination with different sources — the + # cross-plugin validator catches this at activation, this is the build-time + # backstop. No-op (with a notice) when the res root is missing. + # + # Two safety guards (the manifest validator enforces the first at activation + # too; these are the build-time backstop, since `activated/0` can feed + # unvalidated manifests): + # * containment — the resolved destination must stay under the app `res/` + # dir, so a `..`-bearing path can't make `File.cp!` write plugin bytes + # anywhere on the build host (path traversal). + # * no host clobber — refuse to overwrite a file this build didn't write on + # a previous run (tracked in the ledger); otherwise a plugin could replace + # a host-owned resource (e.g. res/values/styles.xml) and, worse, the + # ledger prune would later delete the host's file on plugin removal. + defp apply_plugin_android_res! do + res_files = MobDev.Plugin.Merge.android_res_files(MobDev.Plugin.activated()) + + cond do + res_files == [] -> + :ok + + not File.dir?(@android_res_root) -> + IO.puts(" [plugin android] #{@android_res_root} not found — skipping plugin res files.") + :ok + + true -> + raise_on_res_dest_collision!(res_files) + prior = read_plugin_artifact_ledger(:android_res) + + written = + for %{src: src, dest: dest} <- res_files do + target = safe_res_target!(dest) + rel = Path.relative_to_cwd(target) + + if File.exists?(target) and rel not in prior do + Mix.raise( + "plugin res file #{dest} would overwrite host-owned #{rel} — rename it in the plugin" + ) + end + + File.mkdir_p!(Path.dirname(target)) + File.cp!(src, target) + IO.puts(" ✓ android res → #{rel}") + target + end + + __prune_plugin_artifacts__(:android_res, written) + :ok + end + end + + # Resolve a plugin res destination to a copy target, raising if it escapes the + # app `res/` dir. The hard security boundary behind the manifest validator's + # `..` rejection. + defp safe_res_target!(dest) do + case __res_target__(@android_res_root, dest) do + {:ok, target} -> + target + + {:error, :escapes_res_dir} -> + Mix.raise( + "plugin res file destination escapes the app res/ dir: #{dest} " <> + "(path traversal — declared res_files must not contain \"..\")" + ) + end + end + + @doc false + # Pure: {:ok, copy_target} when `dest` (joined onto `root`) stays inside + # `root/res`, else {:error, :escapes_res_dir}. `..` and absolute escapes are + # normalised by Path.expand before the containment check. + @spec __res_target__(String.t(), String.t()) :: {:ok, String.t()} | {:error, :escapes_res_dir} + def __res_target__(root, dest) do + res_dir = Path.expand(Path.join(root, "res")) + target_abs = Path.expand(Path.join(root, dest)) + + if target_abs == res_dir or String.starts_with?(target_abs, res_dir <> "/"), + do: {:ok, Path.join(root, dest)}, + else: {:error, :escapes_res_dir} + end + + defp raise_on_res_dest_collision!(res_files) do + res_files + |> Enum.group_by(& &1.dest, & &1.src) + |> Enum.each(fn {dest, srcs} -> + case Enum.uniq(srcs) do + [_single] -> + :ok + + many -> + Mix.raise( + "Android res collision: multiple plugins target #{dest}:\n " <> + Enum.join(many, "\n ") <> "\nRename one so plugin res files don't clash." + ) + end + end) + end + + # Copies each activated plugin's `bridge_kt` into the app's Kotlin sourceSet + # (at its own package path, read from the file's `package` line) so Gradle + # compiles it, and (re)generates `io.mob.plugin.MobPluginBootstrap` whose + # `registerAll(activity)` calls each `bridge_class`'s `register()` and then + # hands the Activity to any bridge implementing `MobActivityAware`. + # MainActivity calls `MobPluginBootstrap.registerAll(this)` in `onCreate`. + # The `MobActivityAware` contract is written alongside the bootstrap, and + # both are always written (empty registerAll body when no plugin declares a + # bridge_class) so the MainActivity call always resolves. No-op (with a + # notice) when the java root is missing. + defp apply_plugin_android_kotlin! do + if File.dir?(@android_java_root) do + activated = MobDev.Plugin.activated() + + written = + Enum.flat_map(MobDev.Plugin.Merge.bridge_kt_sources(activated), fn src -> + case File.read(src) do + {:ok, content} -> + case __parse_kotlin_package__(content) do + nil -> + IO.puts(" [plugin android] #{src} has no `package` line — skipping copy.") + [] + + package -> + dest = __bridge_kt_dest__(@android_java_root, package, Path.basename(src)) + File.mkdir_p!(Path.dirname(dest)) + File.write!(dest, content) + [dest] + end + + {:error, reason} -> + IO.puts(" [plugin android] cannot read #{src}: #{inspect(reason)} — skipping.") + [] + end + end) + + # Delete bridge .kt left in the sourceSet by plugins since removed — an + # orphaned bridge can break the Gradle compile. The generated glue below + # is overwritten at fixed paths each build, so only the per-plugin bridge + # copies (scattered by package) need ledger-tracked pruning. + __prune_plugin_artifacts__(:android_kotlin, written) + + write_generated_kotlin!(@plugin_activity_aware_path, __activity_aware_kotlin__()) + write_generated_kotlin!(@plugin_permission_provider_path, __permission_provider_kotlin__()) + write_generated_kotlin!(@plugin_notify_hub_path, __notify_hub_kotlin__()) + + write_generated_kotlin!( + @plugin_bootstrap_path, + __bootstrap_kotlin__(MobDev.Plugin.Merge.bridge_classes(activated)) + ) + + :ok + else + if MobDev.Plugin.Merge.bridge_kt_sources(MobDev.Plugin.activated()) != [] do + IO.puts(" [plugin android] #{@android_java_root} not found — skipping plugin Kotlin.") + end + + :ok + end + end + + # Extracts the FQ package from a Kotlin source, or nil if none. + @doc false + @spec __parse_kotlin_package__(String.t()) :: String.t() | nil + def __parse_kotlin_package__(content) do + case Regex.run(~r/^\s*package\s+([\w.]+)/m, content) do + [_, package] -> package + _ -> nil + end + end + + @doc false + @spec __bridge_kt_dest__(String.t(), String.t(), String.t()) :: String.t() + def __bridge_kt_dest__(java_root, package, basename) do + Path.join([java_root, String.replace(package, ".", "/"), basename]) + end + + # Writes a generated Kotlin file, creating its dir and skipping the write + # when the content is byte-identical (keeps Gradle's up-to-date checks happy). + defp write_generated_kotlin!(path, content) do + File.mkdir_p!(Path.dirname(path)) + if File.read(path) != {:ok, content}, do: File.write!(path, content) + :ok + end + + # Source for io.mob.plugin.MobNotifyHub — see @plugin_notify_hub_path. + @doc false + @spec __notify_hub_kotlin__() :: String.t() + def __notify_hub_kotlin__ do + """ + // GENERATED by mob_dev — do not edit. Stable cross-package seam for + // notification-delivery state: host delivery code (MobFirebaseService / + // MainActivity / NotificationReceiver, app package) and the mob_notify + // plugin bridge (io.mob.notify) both use it; neither can reference the + // other's generated package directly. + package io.mob.plugin + + object MobNotifyHub { + // Local-notification channel id — the host NotificationReceiver posts + // to it; the plugin's notify_schedule creates it. + const val CHANNEL_ID = "mob_notifications" + + // The screen process registered via MobNotify.register_push/1. Host + // delivery paths (FCM foreground push, notification tap) send to it + // via core's nativeDeliver* thunks; 0 = no screen registered. + @Volatile @JvmStatic var notifyPid: Long = 0 + + // FCM token that refreshed while no screen was registered; the + // plugin's notify_register_push drains it. + @Volatile @JvmStatic var pendingToken: String? = null + } + """ + end + + # Source for io.mob.plugin.MobPluginBootstrap. registerAll(activity) calls each + # bridge class's register(), then hands the Activity to any bridge implementing + # MobActivityAware via the handOff helper. The body is uniform per bridge, so + # the generator needs no per-plugin knowledge. handOff takes `Any` so the + # `as?` runtime check is valid for every bridge type — a direct + # `(SomeFinalObject as? MobActivityAware)` would draw a "cast can never + # succeed" warning for bridges that don't opt in. + @doc false + @spec __bootstrap_kotlin__([String.t()]) :: String.t() + def __bootstrap_kotlin__(bridge_classes) do + calls = + bridge_classes + |> Enum.map(fn cls -> + " #{cls}.register()\n handOff(#{cls}, activity)\n collectPermissionProvider(#{cls})" + end) + |> Enum.join("\n") + + {body, helpers} = + if calls == "" do + {"", ""} + else + {"\n" <> calls <> "\n ", + "\n\n // Hands the Activity to a bridge that opts in via" <> + " MobActivityAware.\n" <> + " private fun handOff(bridge: Any, activity: Activity) {\n" <> + " (bridge as? MobActivityAware)?.setActivity(activity)\n" <> + " }\n\n" <> + " // Records a bridge that opts in via MobPermissionProvider so" <> + " core\n" <> + " // MobBridge.request_permission can fall through to it for a" <> + " capability\n" <> + " // core no longer knows about.\n" <> + " private fun collectPermissionProvider(bridge: Any) {\n" <> + " (bridge as? MobPermissionProvider)?.let {\n" <> + " if (!permissionProviders.contains(it)) permissionProviders.add(it)\n" <> + " }\n" <> + " }"} + end + + """ + // Generated by mob_dev (MobDev.NativeBuild) — do not edit. + // Calls each activated plugin's bridge-class register() at startup, then + // hands the Activity to any bridge implementing MobActivityAware and records + // any bridge implementing MobPermissionProvider; invoked from + // MainActivity.onCreate as registerAll(this). + package io.mob.plugin + + import android.app.Activity + + object MobPluginBootstrap { + private val permissionProviders = mutableListOf<MobPermissionProvider>() + + @JvmStatic + fun registerAll(activity: Activity) {#{body}} + + // Returns the first plugin-supplied Android permission mapping for `cap`, + // or null if no activated plugin provides this capability. Core + // MobBridge.request_permission consults this in its `else` branch. + @JvmStatic + fun permissionsFor(cap: String): Array<String>? { + for (provider in permissionProviders) { + val perms = provider.permissionsFor(cap) + if (perms != null) return perms + } + return null + }#{helpers} + } + """ + end + + # Source for io.mob.plugin.MobPermissionProvider — the stable opt-in contract a + # plugin bridge class implements to supply the cap->Android-permission-string + # mapping for a capability core no longer hardcodes. Generated (never changes) + # so existing apps and mob_new projects get it without a template edit. + @doc false + @spec __permission_provider_kotlin__() :: String.t() + def __permission_provider_kotlin__ do + """ + // Generated by mob_dev (MobDev.NativeBuild) — do not edit. + // A plugin bridge class implements this to supply the Android permission + // strings for a capability; MobPluginBootstrap collects providers at + // registerAll and core MobBridge.request_permission consults them. + package io.mob.plugin + + interface MobPermissionProvider { + // Return the Android permission strings for `cap`, or null if this + // provider does not handle the capability. + fun permissionsFor(cap: String): Array<String>? + } + """ + end + + # Source for io.mob.plugin.MobActivityAware — the stable opt-in contract a + # plugin bridge class implements to be handed the host Activity. Generated + # (never changes) so existing apps and mob_new projects get it without a + # template edit. + @doc false + @spec __activity_aware_kotlin__() :: String.t() + def __activity_aware_kotlin__ do + """ + // Generated by mob_dev (MobDev.NativeBuild) — do not edit. + // A plugin bridge class implements this to receive the host Activity from + // MobPluginBootstrap.registerAll, right after register(). + package io.mob.plugin + + import android.app.Activity + + interface MobActivityAware { + fun setActivity(activity: Activity) + } + """ + end + + @doc false + @spec __merge_android_permissions__(String.t(), [String.t()]) :: String.t() + def __merge_android_permissions__(manifest, permissions), + do: merge_android_permissions(manifest, permissions) + + @doc false + @spec __merge_gradle_deps__(String.t(), [String.t()]) :: String.t() + def __merge_gradle_deps__(content, deps), do: merge_gradle_deps(content, deps) + + @doc false + @spec __merge_android_manifest_components__(String.t(), [String.t()]) :: String.t() + def __merge_android_manifest_components__(manifest, snippets), + do: merge_android_manifest_components(manifest, snippets) + + # Pure transform: splice each plugin `<application>` snippet (a <service>, + # <receiver>, …) in just before `</application>`. Idempotent per component: + # skips any snippet whose `android:name` (or, lacking one, whose trimmed body) + # is already present, so re-runs and hand-added copies don't duplicate. Each + # snippet's own indentation is preserved and shifted 8 spaces to sit inside + # <application>. No `</application>` → returns the manifest untouched rather + # than risk corrupting it. + # Managed-block markers (MobDev.Plugin.ManagedBlock) fence each plugin + # contribution so the region is regenerated every build and vanishes when the + # plugin is removed — the app manifest / build.gradle are host-owned and + # hand-edited, so there's no ledger to prune (unlike bridge_kt / res_files). + # Comment syntax matches the host file (XML for the manifest, `//` for Gradle). + @perm_markers { + " <!-- mob:plugin-permissions BEGIN (managed — regenerated each build; do not edit) -->", + " <!-- mob:plugin-permissions END -->" + } + @component_markers { + " <!-- mob:plugin-components BEGIN (managed — regenerated each build; do not edit) -->", + " <!-- mob:plugin-components END -->" + } + @gradle_dep_markers { + " // mob:plugin-deps BEGIN (managed — regenerated each build; do not edit)", + " // mob:plugin-deps END" + } + + # Splice plugin `<application>` components (a `<service>`, `<receiver>`, …) + # into a managed region just before `</application>`. De-duped against + # host-authored content (post-strip) so a hand-declared component isn't + # doubled; removed automatically when no plugin contributes one (empty region + # → stripped). + defp merge_android_manifest_components(manifest, snippets) when is_binary(manifest) do + stripped = MobDev.Plugin.ManagedBlock.strip(manifest, @component_markers) + missing = Enum.reject(snippets, &manifest_component_present?(stripped, &1)) + body = Enum.map_join(missing, "\n\n", &indent_manifest_snippet/1) + + MobDev.Plugin.ManagedBlock.upsert( + manifest, + @component_markers, + body, + &place_before_application_close/2 + ) + end + + defp place_before_application_close(stripped, region) do + if String.contains?(stripped, "</application>") do + MobDev.Plugin.ManagedBlock.insert_before(stripped, "</application>", region) + else + stripped + end + end + + defp manifest_component_present?(manifest, snippet) do + case Regex.run(~r/android:name="([^"]+)"/, snippet) do + [_, name] -> String.contains?(manifest, ~s(android:name="#{name}")) + _ -> String.contains?(manifest, String.trim(snippet)) + end + end + + defp indent_manifest_snippet(snippet) do + snippet + |> String.trim("\n") + |> String.split("\n") + |> Enum.map_join("\n", fn + "" -> "" + line -> " " <> line + end) + end + + # Splice plugin `<uses-permission>` tags into a managed region before + # `<application` (or `</manifest>`). De-duped against host-authored content so + # a hand-declared permission isn't doubled; removed on plugin removal. + defp merge_android_permissions(manifest, permissions) when is_binary(manifest) do + stripped = MobDev.Plugin.ManagedBlock.strip(manifest, @perm_markers) + missing = Enum.reject(permissions, &permission_present?(stripped, &1)) + body = Enum.map_join(missing, "\n", &~s( <uses-permission android:name="#{&1}" />)) + MobDev.Plugin.ManagedBlock.upsert(manifest, @perm_markers, body, &place_before_application/2) + end + + defp permission_present?(manifest, permission) do + String.contains?(manifest, ~s(android:name="#{permission}")) and + String.contains?(manifest, "uses-permission") + end + + defp place_before_application(stripped, region) do + cond do + String.contains?(stripped, "<application") -> + MobDev.Plugin.ManagedBlock.insert_before(stripped, "<application", region) + + String.contains?(stripped, "</manifest>") -> + MobDev.Plugin.ManagedBlock.insert_before(stripped, "</manifest>", region) + + true -> + # Pathological manifest (no <application and no </manifest>): append the + # region as whole lines at EOF so strip/2 still reverses it. + sep = if stripped == "" or String.ends_with?(stripped, "\n"), do: "", else: "\n" + stripped <> sep <> region <> "\n" + end + end + + # Splice plugin `implementation "<dep>"` lines into a managed region inside the + # top-level `dependencies { }` block. Broad substring de-dupe against + # host-authored content (Gradle allows several syntaxes for one dep, so we'd + # rather under-add than duplicate); removed on plugin removal. + defp merge_gradle_deps(content, deps) when is_binary(content) do + stripped = MobDev.Plugin.ManagedBlock.strip(content, @gradle_dep_markers) + missing = Enum.reject(deps, &String.contains?(stripped, &1)) + body = Enum.map_join(missing, "\n", &~s( implementation "#{&1}")) + + MobDev.Plugin.ManagedBlock.upsert( + content, + @gradle_dep_markers, + body, + &place_in_dependencies/2 + ) + end + + # Insert the region just before the matching close-brace of the top-level + # `dependencies { ... }` block; fall back to a fresh appended block (Gradle + # merges multiple `dependencies {}` blocks) when it can't be located. + defp place_in_dependencies(stripped, region) do + case Regex.run(~r/^dependencies\s*\{/m, stripped, return: :index) do + [{start_idx, len}] -> + open_brace_idx = start_idx + len - 1 + + case find_matching_close_brace(stripped, open_brace_idx) do + {:ok, close_idx} -> + MobDev.Plugin.ManagedBlock.insert_before_index(stripped, close_idx, region) + + :not_found -> + stripped <> "\ndependencies {\n#{region}\n}\n" + end + + nil -> + stripped <> "\ndependencies {\n#{region}\n}\n" + end + end + + # Given an index pointing at an opening `{` byte, return the index of the + # matching `}`. Operates on bytes — fine for Gradle files which are ASCII + # in practice; if a project sneaks in a UTF-8 brace-lookalike, we'd just + # miss it and fall back to the append path. + defp find_matching_close_brace(content, open_idx) do + scan_brace(content, open_idx + 1, 1) + end + + defp scan_brace(content, idx, depth) when idx < byte_size(content) do + case binary_part(content, idx, 1) do + "{" -> scan_brace(content, idx + 1, depth + 1) + "}" when depth == 1 -> {:ok, idx} + "}" -> scan_brace(content, idx + 1, depth - 1) + _ -> scan_brace(content, idx + 1, depth) + end + end + + defp scan_brace(_content, _idx, _depth), do: :not_found + + defp compile_ios_device_icons(app_path) do + actool_plist = + Path.join(System.tmp_dir!(), "mob_actool_#{System.unique_integer([:positive])}.plist") + + case System.cmd( + "xcrun", + [ + "actool", + "ios/Assets.xcassets", + "--compile", + app_path, + "--platform", + "iphoneos", + "--minimum-deployment-target", + "17.0", + "--app-icon", + "AppIcon", + "--output-partial-info-plist", + actool_plist + ], + stderr_to_stdout: true + ) do + {_, 0} -> + _ = + System.cmd( + "/usr/libexec/PlistBuddy", + [ + "-c", + "Merge #{actool_plist}", + Path.join(app_path, "Info.plist") + ], + stderr_to_stdout: true + ) + + _ -> + :ok + end + + File.rm(actool_plist) + end + + defp bundle_otp_runtime(app_path, otp_root, app_module, erts_vsn) do + IO.puts(" === Bundling OTP runtime inside .app") + otp_bundle = Path.join(app_path, "otp") + File.mkdir_p!(otp_bundle) + + rsync_dir!(Path.join(otp_root, "lib") <> "/", Path.join(otp_bundle, "lib") <> "/") + rsync_dir!(Path.join(otp_root, "releases") <> "/", Path.join(otp_bundle, "releases") <> "/") + rsync_dir!(Path.join(otp_root, app_module) <> "/", Path.join(otp_bundle, app_module) <> "/") + + python_src = Path.join(otp_root, "python") + + if File.dir?(python_src) do + rsync_dir!(python_src <> "/", Path.join(otp_bundle, "python") <> "/") + # Mirrors the Android `copy_project_python_wheels/1` call in + # `copy_python_assets/1`. iOS device builds nuke and rebuild + # `<otp_root>/python/lib/python3.13/` on every run (see + # `ios/build_device.sh` PYTHON_STDLIB block), so staging wheels + # into the OTP cache wouldn't survive. Doing the copy here, after + # the rsync into the .app bundle, lands them where Python's + # site-packages discovery will find them at runtime. + copy_ios_safe_project_python_wheels( + Path.join(otp_bundle, "python"), + Path.join("priv", "python_wheels") + ) + end + + for ext <- ["png", "jpg"] do + Path.wildcard("#{otp_root}/*.#{ext}") + |> Enum.each(&File.cp!(&1, Path.join(otp_bundle, Path.basename(&1)))) + end + + File.mkdir_p!(Path.join([otp_bundle, erts_vsn, "bin"])) + + {size, _} = System.cmd("du", ["-sh", otp_bundle]) + IO.puts(" OTP bundle: #{size |> String.split() |> List.first()}") + :ok + end + + defp rsync_dir!(src, dst) do + {_, 0} = + System.cmd("rsync", ["-a", "--delete", src, dst], stderr_to_stdout: true, into: IO.stream()) + + :ok + end + + defp maybe_slim_otp_bundle(app_path, cfg) do + if System.get_env("MOB_SLIM") == "1" do + otp_bundle = Path.join(app_path, "otp") + slim_opts = Keyword.get(cfg, :slim, []) + + IO.puts(" === Slim strip pass") + + audit_input = maybe_run_audit(otp_bundle, slim_opts) + + {:ok, result} = + MobDev.OtpAudit.Slim.slim_bundle(otp_bundle, + keep_libs: Keyword.get(slim_opts, :keep_libs, []), + drop_libs: Keyword.get(slim_opts, :drop_libs, []), + audit_input: audit_input, + on_step: fn %{label: label, before_kb: before, after_kb: after_size} -> + delta = before - after_size + IO.puts(" [SLIM:#{label}] #{before} KB → #{after_size} KB (-#{delta} KB)") + end + ) + + mb = Float.round(result.final_kb / 1024, 1) + IO.puts(" Slim OTP bundle: #{mb}M") + else + IO.puts(" [SLIM:skipped] MOB_SLIM=0 — keeping full OTP runtime") + end + + :ok + end + + # Returns a MobDev.OtpAudit.report when slim_opts says to run the audit, + # nil otherwise. The Slim module's audit_expansion gracefully treats nil + # as "no expansion." + # + # The mob.exs surface is conservative — default off — because the audit + # walks every `.beam` in the bundle (seconds added per build). Users + # opt in once they've captured trace(s) and want to expand the strip set: + # + # config :mob_dev, + # slim: [audit: true, trace_json: "priv/mob_trace.json"] + # + # For production stripping, multi-trace union is strongly recommended — + # a single 60s capture only sees one slice of the app: + # + # config :mob_dev, + # slim: [ + # audit: true, + # trace_jsons: ["priv/boot.json", "priv/ui.json", "priv/auth.json"] + # ] + # + # The union picks "ever called" across all captures: a lib is + # trace-strippable only if NONE of the traces saw any of its modules. + defp maybe_run_audit(otp_bundle, slim_opts) do + if Keyword.get(slim_opts, :audit, false) do + trace_paths = trace_paths_from_opts(slim_opts) + trace_input = union_trace_jsons(trace_paths) + project_deps = infer_project_deps() + + app_name = + case Mix.Project.get() do + nil -> nil + _ -> Mix.Project.config()[:app] + end + + trace_desc = + case {trace_input, length(trace_paths)} do + {nil, _} -> "none" + {ms, 1} -> "#{MapSet.size(ms)} modules from 1 capture" + {ms, n} -> "#{MapSet.size(ms)} unique modules across #{n} captures" + end + + IO.puts( + " [SLIM:audit] running OtpAudit " <> + "(project_deps=#{length(project_deps || [])}, trace=#{trace_desc})" + ) + + MobDev.OtpAudit.audit(otp_bundle, + app_name: app_name, + project_deps: project_deps, + trace_input: trace_input + ) + end + end + + # mob.exs accepts both shapes for back-compat: + # slim: [trace_json: "single.json"] + # slim: [trace_jsons: ["one.json", "two.json"]] + # If both are given, the singular is appended to the plural list. + defp trace_paths_from_opts(slim_opts) do + paths = Keyword.get(slim_opts, :trace_jsons, []) + + case Keyword.get(slim_opts, :trace_json) do + nil -> paths + single -> paths ++ [single] + end + end + + # In the slim build path a failed read should warn but not raise: + # the build keeps going (no trace expansion) so the user still gets + # a slim build, just with fewer libs stripped than they configured. + defp union_trace_jsons(paths) do + MobDev.OtpAudit.union_trace_jsons(paths, fn path, reason -> + IO.warn( + "[SLIM:audit] could not read trace_json #{path}: " <> + "#{inspect(reason)} — skipping that trace" + ) + end) + end + + defp infer_project_deps do + case File.ls("_build/dev/lib") do + {:ok, libs} -> Enum.map(libs, &String.to_atom/1) + _ -> nil + end + end + + defp embed_provisioning_profile(app_path, profile_uuid) do + candidates = [ + Path.expand( + "~/Library/Developer/Xcode/UserData/Provisioning Profiles/#{profile_uuid}.mobileprovision" + ), + Path.expand("~/Library/MobileDevice/Provisioning Profiles/#{profile_uuid}.mobileprovision") + ] + + case Enum.find(candidates, &File.exists?/1) do + nil -> + {:error, + "Provisioning profile #{profile_uuid} not found in either Xcode UserData or MobileDevice paths.\n" <> + "Open Xcode → Settings → Accounts → Download Profiles."} + + profile_path -> + IO.puts(" === Embedding provisioning profile") + File.cp!(profile_path, Path.join(app_path, "embedded.mobileprovision")) + :ok + end + end + + defp codesign_ios_device_app(app_path, cfg, build_dir) do + sign_identity = cfg[:ios_sign_identity] + team_id = cfg[:ios_team_id] + bundle_id = cfg[:ios_bundle_id] || cfg[:bundle_id] + + IO.puts(" === Code signing") + entitlements = resolve_or_generate_entitlements(app_path, build_dir, team_id, bundle_id) + + otp_bundle = Path.join(app_path, "otp") + + if File.dir?(Path.join(otp_bundle, "python")), + do: codesign_python_dylibs(otp_bundle, sign_identity) + + # Sign embedded TFLite frameworks before signing the app bundle. + # iOS requires every nested .framework to carry its own signature; + # the app-level sign then includes the framework hashes in its + # sealed-resources list. + codesign_tflite_frameworks(app_path, sign_identity) + + {_, 0} = + System.cmd( + "codesign", + [ + "--force", + "--sign", + sign_identity, + "--entitlements", + entitlements, + "--timestamp=none", + app_path + ], + stderr_to_stdout: true, + into: IO.stream() + ) + + :ok + end + + defp codesign_tflite_frameworks(app_path, sign_identity) do + frameworks_dir = Path.join(app_path, "Frameworks") + + for fw_name <- ~w(TensorFlowLiteC TensorFlowLiteCCoreML TensorFlowLiteCMetal) do + fw_path = Path.join(frameworks_dir, "#{fw_name}.framework") + + if File.dir?(fw_path) do + IO.puts(" === Signing #{fw_name}.framework") + + # Sign the binary inside the framework first (deepest), then + # sign the framework dir itself. iOS 17+ rejects pre-existing + # CocoaPods-style signatures so --force overwrites any leftover. + # --generate-entitlement-der writes the modern entitlement + # encoding required by iOS 26. + binary = Path.join(fw_path, fw_name) + + if File.exists?(binary) do + {_, 0} = + System.cmd( + "codesign", + [ + "--force", + "--sign", + sign_identity, + "--timestamp=none", + "--generate-entitlement-der", + binary + ], + stderr_to_stdout: true, + into: IO.stream() + ) + end + + {_, 0} = + System.cmd( + "codesign", + [ + "--force", + "--sign", + sign_identity, + "--timestamp=none", + "--generate-entitlement-der", + fw_path + ], + stderr_to_stdout: true, + into: IO.stream() + ) + end + end + + :ok + end + + defp resolve_or_generate_entitlements(app_path, build_dir, team_id, bundle_id) do + case Path.wildcard("ios/*.entitlements") do + [entitlements | _] -> + entitlements + + [] -> + path = Path.join(build_dir, "mob_device.entitlements") + + # Mirror aps-environment from the profile so the binary entitlement + # matches what the profile grants — without this APNs registration + # silently fails and the push token is never delivered. + aps_env = read_aps_environment(Path.join(app_path, "embedded.mobileprovision")) + + File.write!(path, """ + <?xml version="1.0" encoding="UTF-8"?> + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> + <plist version="1.0"> + <dict> + <key>application-identifier</key> + <string>#{team_id}.#{bundle_id}</string> + <key>com.apple.developer.team-identifier</key> + <string>#{team_id}</string> + <key>get-task-allow</key> + <true/> + #{if aps_env, do: " <key>aps-environment</key>\n <string>#{aps_env}</string>\n", else: ""}\ + </dict> + </plist> + """) + + path + end + end + + defp read_aps_environment(profile_path) do + # `security cms -D -i <profile>` decodes the CMS-wrapped XML plist; + # we route it through a temp file because PlistBuddy doesn't read + # stdin reliably and System.cmd doesn't pipe. + tmp_plist = + Path.join(System.tmp_dir!(), "mob_aps_#{System.unique_integer([:positive])}.plist") + + try do + case System.cmd("security", ["cms", "-D", "-i", profile_path, "-o", tmp_plist], + stderr_to_stdout: true + ) do + {_, 0} -> + case System.cmd( + "/usr/libexec/PlistBuddy", + ["-c", "Print :Entitlements:aps-environment", tmp_plist], + stderr_to_stdout: true + ) do + {value, 0} -> String.trim(value) + _ -> nil + end + + _ -> + nil + end + after + File.rm(tmp_plist) + end + end + + defp codesign_python_dylibs(otp_bundle, sign_identity) do + IO.puts(" === Codesigning bundled Python dylibs") + lib_dynload = Path.join([otp_bundle, "python", "lib", "python3.13", "lib-dynload"]) + + so_files = Path.wildcard("#{lib_dynload}/**/*.so") + + Enum.each(so_files, fn so -> + {_, 0} = + System.cmd( + "codesign", + ["--force", "--sign", sign_identity, "--timestamp=none", so], + stderr_to_stdout: true + ) + end) + + IO.puts(" signed #{length(so_files)} lib-dynload extensions") + + framework = Path.join([otp_bundle, "python", "Python.framework", "Python"]) + + if File.exists?(framework) do + {_, 0} = + System.cmd( + "codesign", + ["--force", "--sign", sign_identity, "--timestamp=none", framework], + stderr_to_stdout: true + ) + + IO.puts(" signed Python.framework/Python") + end + + Path.wildcard("#{otp_bundle}/lib/**/libpythonx.so") + |> Enum.each(fn pythonx -> + {_, 0} = + System.cmd( + "codesign", + ["--force", "--sign", sign_identity, "--timestamp=none", pythonx], + stderr_to_stdout: true + ) + + IO.puts(" signed #{Path.relative_to(pythonx, otp_bundle)}") + end) + end + + defp devicectl_install(udid, app_path) do + IO.puts(" === Installing on device #{udid}") + + case System.cmd( + "xcrun", + ["devicectl", "device", "install", "app", "--device", udid, app_path], + stderr_to_stdout: true, + into: IO.stream() + ) do + {_, 0} -> :ok + {_, code} -> {:error, "devicectl install failed (exit #{code}) — check output above"} + end + end + + defp throw_bundle_id_error, + do: throw({:error, "bundle_id not set in mob.exs"}) + + # Returns {:ok, cfg_with_signing} or {:error, reason}. + # Values already in mob.exs are kept; missing ones are auto-detected from the + # keychain and provisioning profile directories. Fails with a clear message only + # when auto-detection itself finds multiple candidates and can't pick one. + defp check_device_signing_config(cfg) do + bundle_id = cfg[:bundle_id] + + with {:ok, identity} <- resolve_sign_identity(cfg[:ios_sign_identity], cfg[:ios_team_id]), + {:ok, {profile_uuid, team_id}} <- + resolve_profile_uuid(cfg[:ios_profile_uuid], bundle_id, cfg[:ios_team_id]) do + {:ok, + cfg + |> Keyword.put(:ios_sign_identity, identity) + |> Keyword.put(:ios_team_id, team_id) + |> Keyword.put(:ios_profile_uuid, profile_uuid)} + end + end + + # Resolves signing identity. Returns {:ok, identity} or {:error, reason}. + defp resolve_sign_identity(identity, _team_id) when is_binary(identity), do: {:ok, identity} + + defp resolve_sign_identity(_identity, _team_id) do + case System.cmd("security", ["find-identity", "-v", "-p", "codesigning"], + stderr_to_stdout: true + ) do + {output, 0} -> + identities = + Regex.scan(Regex.compile!("\\d+\\) [0-9A-F]+ \"([^\"]+)\""), output) + |> Enum.map(fn [_, full] -> full end) + |> Enum.filter(&String.contains?(&1, "Apple Development")) + |> Enum.uniq() + + case identities do + [] -> + {:error, + """ + No Apple Development signing identity found in the keychain. + + One-time setup: + 1. Open Xcode → Settings → Accounts → add your Apple ID + 2. Select your team → click "Download Manual Profiles" + 3. Close Xcode + + This installs a development certificate into your Keychain so mob + can sign device builds without Xcode. + """} + + [identity] -> + IO.puts( + " #{IO.ANSI.cyan()}Auto-detected signing identity: #{identity}#{IO.ANSI.reset()}" + ) + + {:ok, identity} + + many -> + choices = Enum.map_join(many, "\n", &" #{&1}") + + {:error, + """ + Multiple signing identities found — add ios_sign_identity to mob.exs: + + config :mob_dev, + ios_sign_identity: "Apple Development: you@example.com (XXXXXXXXXX)" + + Available identities: + #{choices} + """} + end + + {out, _} -> + {:error, "security find-identity failed: #{out}"} + end + end + + # Resolves provisioning profile UUID + team ID from profiles on disk. + # Returns {:ok, {uuid, team_id}} or {:error, reason}. + # Team ID is read from the profile itself (more reliable than parsing the cert string). + defp resolve_profile_uuid(uuid, _bundle_id, team_id) + when is_binary(uuid) and is_binary(team_id), + do: {:ok, {uuid, team_id}} + + defp resolve_profile_uuid(uuid, bundle_id, _team_id) do + profile_dirs = [ + Path.expand("~/Library/Developer/Xcode/UserData/Provisioning Profiles"), + Path.expand("~/Library/MobileDevice/Provisioning Profiles") + ] + + all_profiles = + Enum.flat_map(profile_dirs, &Path.wildcard(Path.join(&1, "*.mobileprovision"))) + |> Enum.flat_map(&Release.parse_mobileprovision/1) + + # `mix mob.deploy --native` is for installing dev builds on registered + # test devices (via xcrun devicectl). devicectl rejects App Store / Beta + # profiles with "Attempted to install a Beta profile without the proper + # entitlement" — so filter to Development profiles only: + # - Development: provisioned_devices? = true, provisions_all_devices? = false + # - Ad Hoc: provisioned_devices? = true, provisions_all_devices? = false (signed + # with Distribution cert; rare for our flow) + # - App Store: provisioned_devices? = false, provisions_all_devices? = false + # - Enterprise: provisioned_devices? = false, provisions_all_devices? = true + # We require provisioned_devices? = true. App Store profiles fall through to release.ex. + dev_profiles = Enum.filter(all_profiles, & &1.provisioned_devices?) + + # Prefer exact bundle ID match; fall back to wildcard profiles (app_id "TEAMID.*") + exact_profiles = + Enum.filter(dev_profiles, fn %{app_id: app_id} -> + String.ends_with?(app_id, ".#{bundle_id}") + end) + + profiles = + if exact_profiles != [] do + exact_profiles + else + Enum.filter(dev_profiles, fn %{app_id: app_id} -> + String.ends_with?(app_id, ".*") + end) + end + + candidates = + if is_binary(uuid) do + Enum.filter(profiles, &(&1.uuid == uuid)) + else + profiles + end + + case candidates do + [] -> + {:error, no_dev_profile_message(bundle_id, all_profiles)} + + [%{uuid: found_uuid, app_id: app_id, team_id: team}] -> + unless is_binary(uuid) do + IO.puts( + " #{IO.ANSI.cyan()}Auto-detected Development profile: #{found_uuid} (team #{team})#{IO.ANSI.reset()}" + ) + end + + if String.ends_with?(app_id, ".*") do + IO.puts( + " #{IO.ANSI.cyan()} (using wildcard profile — run `mix mob.provision` to create a dedicated profile for #{bundle_id})#{IO.ANSI.reset()}" + ) + end + + {:ok, {found_uuid, team}} + + many -> + choices = Enum.map_join(many, "\n", fn %{uuid: u, app_id: a} -> " #{u} (#{a})" end) + + {:error, + """ + Multiple Development profiles match '#{bundle_id}' — add ios_profile_uuid to mob.exs: + + config :mob_dev, ios_profile_uuid: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + + Matching Development profiles: + #{choices} + """} + end + end + + # Distinguish "no profiles at all" from "have App Store profile but no Development one". + # The latter is the conflict case we hit after setting up TestFlight publishing — + # the user has a working App Store profile but `mob.deploy` needs a Development one. + defp no_dev_profile_message(bundle_id, all_profiles) do + has_app_store_profile? = + Enum.any?(all_profiles, fn %{app_id: app_id} = p -> + not p.provisioned_devices? and not p.provisions_all_devices? and + String.ends_with?(app_id, ".#{bundle_id}") + end) + + if has_app_store_profile? do + """ + No Development profile found for bundle ID '#{bundle_id}', but an App Store + profile exists. `mix mob.deploy --native` needs a Development profile — + App Store profiles can only be installed via TestFlight / App Store, not via + xcrun devicectl. + + To get one (one-time): + 1. Open https://developer.apple.com/account/resources/profiles/list + 2. Click + → iOS App Development → choose '#{bundle_id}' → select your dev cert + and the device(s) you want to install on → Generate + 3. Open Xcode → Settings → Accounts → select your team → "Download Manual Profiles" + 4. Re-run `mix mob.deploy --native` + + Now your machine has both profiles. mob_dev picks Development for `mob.deploy` + and App Store for `mob.release` automatically. + """ + else + """ + No provisioning profile found for bundle ID '#{bundle_id}'. + + One-time setup (only needed once per machine): + 1. Open Xcode + 2. Xcode → Settings → Accounts → add your Apple ID if not already listed + 3. Select your team → click "Download Manual Profiles" + 4. Close Xcode — you won't need to open it again + + After that, `mix mob.deploy --native` will find the profile automatically. + + If the bundle ID is not yet registered in your developer account: + open https://developer.apple.com/account/resources/identifiers/list + """ + end + end + + @doc """ + Returns true when the user's project has a built `:pythonx` dependency. + + Detection is via `_build/dev/lib/pythonx/` rather than scanning `mix.exs` + so users get the same behavior whether they `mix mob.enable python` and + rely on the dep being added, or vendor pythonx some other way. + """ + @spec pythonx_in_project?(String.t()) :: boolean() + def pythonx_in_project?(_project_dir \\ File.cwd!()) do + dep_in_project?(:pythonx) + end + + @doc """ + Returns the PYTHON_APPLE_SUPPORT env entry list when Pythonx is in the + project, otherwise `[]`. Kept public — `mob.release` and other release + paths still call into this when constructing distribution-mode envs. + """ + @spec python_apple_support_env(boolean(), String.t() | nil) :: [{String.t(), String.t()}] + def python_apple_support_env(false, _bundle), do: [] + def python_apple_support_env(true, nil), do: [] + + def python_apple_support_env(true, bundle) when is_binary(bundle), + do: [{"PYTHON_APPLE_SUPPORT", bundle}] + + # Downloads the BeeWare Python-Apple-support bundle iff Pythonx is a dep. + # Skipped silently for projects without Pythonx. + defp maybe_ensure_python_bundle do + if pythonx_in_project?() do + MobDev.PythonAppleSupport.ensure() + else + {:ok, nil} + end + end + + @doc """ + True when the current project has `:emlx` in its dependency tree. + Mirrors `pythonx_in_project?/1` — the trigger for downloading the MLX + bundle and adding `-Dmlx_static=true` to the iOS Zig build. + """ + @spec emlx_in_project?(String.t()) :: boolean() + def emlx_in_project?(_project_dir \\ File.cwd!()) do + dep_in_project?(:emlx) + end + + # The old detector checked `_build/dev/lib/<dep>` exists — a STALE build + # artifact (dep since removed) false-positived, e.g. triggering MLX bundle + # downloads (and their 404 noise) for apps that don't dep emlx at all. + # Mix.Project.deps_paths/0 is the dependency truth: hex + path + transitive, + # immune to leftover _build dirs. + defp dep_in_project?(name) do + __dep_in_project__(Mix.Project.deps_paths(), name) + rescue + # Outside a Mix project context, fall back to "not present". + _ -> false + end + + @doc false + # Pure kernel, public for tests. + @spec __dep_in_project__(%{atom() => Path.t()}, atom()) :: boolean() + def __dep_in_project__(deps_paths, name), do: Map.has_key?(deps_paths, name) + + # Downloads the cross-compiled MLX bundle iff EMLX is a dep, for the given + # target slice. Returns `{:ok, nil}` for projects without EMLX so the + # iOS-sim and iOS-device build paths can pattern-match the same shape. + defp maybe_ensure_mlx_dir(:ios_device) do + if emlx_in_project?() do + MobDev.MLXDownloader.ensure_ios_device() + else + {:ok, nil} + end + end + + defp maybe_ensure_mlx_dir(:ios_sim) do + if emlx_in_project?() do + MobDev.MLXDownloader.ensure_ios_sim() + else + {:ok, nil} + end + end + + @doc """ + Returns the UDID of the sole connected physical iOS device, or nil. + When exactly one physical device is connected, it can be used automatically. + With zero or 2+ physical devices, returns nil. + """ + @spec detect_physical_ios() :: String.t() | nil + def detect_physical_ios do + auto_detect_physical_ios() + end + + defp auto_detect_physical_ios do + if System.find_executable("xcrun") do + all = MobDev.Discovery.IOS.list_devices() + + physical = + Enum.filter(all, &(&1.type == :physical and &1.status in [:connected, :discovered])) + + case physical do + [device] -> + IO.puts( + " #{IO.ANSI.cyan()}Auto-detected physical device: #{device.name || device.serial}#{IO.ANSI.reset()}" + ) + + # If a sim is also booted, surface it so the user knows the + # alternative without having to memorize `mix mob.devices`. + # iter 13d note: this was the discoverability gap from + # issues.md #5 — the iPhone-vs-sim choice was silent. + booted_sims = + Enum.filter(all, &(&1.type == :simulator and &1.status == :booted)) + + case booted_sims do + [sim | _] -> + IO.puts( + " #{IO.ANSI.cyan()} (booted simulator also available — pass `--device #{MobDev.Device.short_id(sim.serial)}` to target #{sim.name} instead)#{IO.ANSI.reset()}" + ) + + [] -> + :ok + end + + device.serial + + [_ | _] -> + IO.puts( + " #{IO.ANSI.yellow()}Multiple physical devices connected — use --device <id> to pick one. Building for simulator.#{IO.ANSI.reset()}" + ) + + nil + + [] -> + nil + end + end + end + + # Physical iOS UDIDs come in several formats: + # Old (pre-2021): 40 hex chars, no dashes (e.g. a1b2c3d4e5f6...) + # Standard UUID: 8-4-4-4-12 hex (e.g. 12345678-ABCD-1234-ABCD-1234567890AB) + # New Apple format: 8-16 hex (e.g. 00008110-001E1C3A34F8401E) + # Simulator display_ids are exactly 8 hex chars. Android serials never match. + @doc """ + When `--device <id>` is given, narrow `platforms` to just the platform + the device lives on. Drops Android when the id resolves to an iOS + device (sim or physical), drops iOS otherwise. + + Public so `mix mob.deploy` can apply the same narrowing before calling + `MobDev.Deployer.deploy_all/1` — otherwise the deployer's per-platform + `filter_by_device_id` complains "No device matched" against the + irrelevant platform even though the build itself was correctly + targeted. + + Returns `platforms` unchanged when `device_id` is nil. + """ + @spec narrow_platforms_for_device([atom()], String.t() | nil) :: [atom()] + def narrow_platforms_for_device(platforms, nil), do: platforms + + def narrow_platforms_for_device(platforms, device_id) when is_binary(device_id) do + narrow_platforms_for_device(platforms, device_id, &MobDev.Discovery.IOS.list_devices/0) + end + + @doc """ + Variant that takes an iOS-discovery function so tests (and other + callers that already have the device list in hand) can avoid the + network-bound `IOS.list_devices/0` LAN scan. + + The lister is called at most once per invocation; both `ios_device?` + and the physical-UDID format fallback consume the same result. + """ + @spec narrow_platforms_for_device([atom()], String.t() | nil, (-> [MobDev.Device.t()])) :: + [atom()] + def narrow_platforms_for_device(platforms, nil, _lister), do: platforms + + def narrow_platforms_for_device(platforms, device_id, lister) + when is_binary(device_id) and is_function(lister, 0) do + devices = lister.() + + if ios_device?(device_id, devices) do + platforms -- [:android] + else + platforms -- [:ios] + end + end + + # iOS device UDID matchers — string-based instead of regex so the check + # works across BEAM/OTP versions (OTP 28 won't reuse precompiled regexes + # stored in module attributes — `:re.import/1` is undefined or private). + defp matches_ios_udid_long?(id) when is_binary(id), + do: byte_size(id) == 40 and all_hex?(id) + + defp matches_ios_udid_long?(_), do: false + + defp matches_ios_udid_short?(id) when is_binary(id) and byte_size(id) == 25 do + case String.split(id, "-", parts: 2) do + [a, b] -> byte_size(a) == 8 and all_hex?(a) and byte_size(b) == 16 and all_hex?(b) + _ -> false + end + end + + defp matches_ios_udid_short?(_), do: false + + defp all_hex?(s) when is_binary(s), do: s |> String.to_charlist() |> Enum.all?(&hex?/1) + + defp hex?(c) + when (c >= ?0 and c <= ?9) or + (c >= ?a and c <= ?f) or + (c >= ?A and c <= ?F), + do: true + + defp hex?(_), do: false + + # True when `id` matches *any* iOS device (sim or physical) in the + # given `devices` list, OR matches an offline physical-UDID format. + # Used to decide whether `--device <id>` narrows `platforms` to iOS or + # Android. Accepts the full serial, the human-friendly `display_id` + # (e.g. the first 8 chars of a sim UUID which `mix mob.devices` + # prints), or — for offline devices that discovery doesn't return — + # the format-based fallback below. + defp ios_device?(id, devices) do + Enum.any?(devices, fn d -> MobDev.Device.match_id?(d, id) end) or + ios_physical_udid?(id, devices) + end + + # Single-arg form used by build_all/1 — fetches iOS discovery itself + # since the caller doesn't already have the list. Tests should use the + # 2-arg form below (or call narrow_platforms_for_device/3) to avoid + # the network-bound LAN scan. + defp ios_physical_udid?(id) do + ios_physical_udid?(id, MobDev.Discovery.IOS.list_devices()) + end + + # True when `id` is recognised as a connected physical iOS device. + # Both simulator and physical UDIDs are UUIDs in modern Xcode (the + # 36-char form), so format-only matching is ambiguous. We resolve by + # consulting `devices` for the device type. Falls back to a format + # check only when discovery returns nothing for that id — covers the + # case where a UDID was passed but the device is offline / not yet + # enumerable, in which case we err on the side of "physical" so the + # device build is attempted (40-char and short forms are + # physical-only). + defp ios_physical_udid?(id, devices) do + case Enum.find(devices, &(&1.serial == id)) do + %MobDev.Device{type: :physical} -> + true + + %MobDev.Device{type: :simulator} -> + false + + nil -> + matches_ios_udid_long?(id) or matches_ios_udid_short?(id) + end + end + + # ── Toolchain availability ────────────────────────────────────────────────── + + @doc """ + Returns true when the Android build toolchain looks usable from the given + project directory. Three signals must all be present: + + 1. `adb` is on PATH (build needs it to install the APK after Gradle) + 2. `<project_dir>/android/local.properties` exists and sets `sdk.dir` + 3. The directory `sdk.dir` points at exists on disk + + Returns false otherwise so the deploy can skip Android cleanly instead of + failing late inside Gradle. Pure of side effects. + """ + @spec android_toolchain_available?(String.t()) :: boolean() + def android_toolchain_available?(project_dir \\ File.cwd!()) do + with true <- adb_available?(), + {:ok, sdk_dir} <- read_sdk_dir(project_dir) do + File.dir?(sdk_dir) + else + _ -> false + end + end + + @doc """ + Returns true when an iOS build is feasible: macOS host with `xcrun` + installed. Linux/Windows always returns false. Pure of side effects. + """ + @spec ios_toolchain_available?() :: boolean() + def ios_toolchain_available? do + macos?() and System.find_executable("xcrun") != nil + end + + @doc false + @spec read_sdk_dir(String.t()) :: {:ok, String.t()} | :error + def read_sdk_dir(project_dir) do + path = Path.join([project_dir, "android", "local.properties"]) + + with {:ok, content} <- File.read(path), + [_, raw] <- Regex.run(Regex.compile!("^\\s*sdk\\.dir\\s*=\\s*(.+?)\\s*$", "m"), content) do + {:ok, expand_sdk_dir(raw)} + else + _ -> :error + end + end + + # Java's `Properties.store()` writes "/Users/me/Android/sdk" but with + # backslash-colons on Windows; on Unix it round-trips fine. Just trim. + defp expand_sdk_dir(raw), do: String.trim(raw) |> Path.expand() + + @doc """ + Generates the fallback entitlements plist that `build_device.sh` writes when + no `ios/*.entitlements` file is found in the project. + + `aps_env` should be `"development"`, `"production"`, or `nil`. When non-nil + the `aps-environment` key is included, allowing APNs push token registration + to succeed. When nil the key is omitted (the historic default, suitable for + apps that do not use push notifications). + + This function is public so it can be unit-tested independently of the shell + script that actually writes the file on device builds. + """ + @spec fallback_entitlements_plist(String.t(), String.t(), String.t() | nil) :: String.t() + def fallback_entitlements_plist(team_id, bundle_id, aps_env \\ nil) do + aps_entry = + if aps_env do + " <key>aps-environment</key>\n <string>#{aps_env}</string>\n" + else + "" + end + + """ + <?xml version="1.0" encoding="UTF-8"?> + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> + <plist version="1.0"> + <dict> + <key>application-identifier</key> + <string>#{team_id}.#{bundle_id}</string> + <key>com.apple.developer.team-identifier</key> + <string>#{team_id}</string> + <key>get-task-allow</key> + <true/> + #{aps_entry}</dict> + </plist> + """ + end + + defp adb_available?, do: System.find_executable("adb") != nil + + defp macos?, do: match?({:unix, :darwin}, :os.type()) + + defp warn_skipped_android do + IO.puts( + " #{IO.ANSI.yellow()}⚠ Skipping Android build — toolchain not detected#{IO.ANSI.reset()}" + ) + + cond do + not adb_available?() -> + IO.puts(" `adb` not found on PATH. Install Android Studio (it bundles") + IO.puts(" adb) or platform-tools, then re-run.") + + not File.exists?(Path.join(["android", "local.properties"])) -> + IO.puts(" android/local.properties is missing. Run `mix mob.install`") + IO.puts(" to generate it (auto-detects ANDROID_HOME / Android Studio).") + + true -> + IO.puts(" android/local.properties has no `sdk.dir` set. Either:") + IO.puts(" export ANDROID_HOME=/path/to/android/sdk && mix mob.install") + IO.puts(" or edit android/local.properties and add a sdk.dir= line.") + end + end + + defp warn_skipped_ios do + IO.puts( + " #{IO.ANSI.yellow()}⚠ Skipping iOS build — Xcode command-line tools not detected#{IO.ANSI.reset()}" + ) + + if macos?() do + IO.puts(" Install Xcode and run `xcode-select --install`, then re-run.") + else + IO.puts(" iOS builds require macOS.") + end + end + + # ── Config ─────────────────────────────────────────────────────────────────── + + @doc false + @spec __load_config__() :: keyword() + def __load_config__, do: load_config() + + @doc false + @spec __resolve_elixir_lib__(String.t() | nil) :: String.t() + def __resolve_elixir_lib__(configured), do: resolve_elixir_lib(configured) + + @doc false + @spec __project_swift_sources_arg__(keyword()) :: String.t() + def __project_swift_sources_arg__(cfg), do: project_swift_sources_arg(cfg) + + defp load_config do + config_file = Path.join(File.cwd!(), "mob.exs") + + unless File.exists?(config_file) do + Mix.raise(""" + mob.exs not found in #{File.cwd!()}. + + Run `mix mob.install` to configure your project, or + `mix mob.doctor` to diagnose your environment. + """) + end + + cfg = Config.Reader.read!(config_file) |> Keyword.get(:mob_dev, []) + + elixir_lib = resolve_elixir_lib(cfg[:elixir_lib]) + bundle_id = cfg[:bundle_id] || MobDev.Config.bundle_id() + + cfg + |> Keyword.put(:elixir_lib, elixir_lib) + |> Keyword.put_new(:bundle_id, bundle_id) + end + + # Use the mob.exs value if it exists on disk AND its Elixir version matches the + # running toolchain; otherwise detect from the running BEAM. + # + # WHY the version check: the app's .beam files are compiled by the toolchain + # that runs `mix` (System.version()). The bundled Elixir stdlib must be the + # SAME version, because macros baked into those BEAMs (Ecto.Migration, regex + # literals, …) emit calls into compiler internals that move between versions — + # e.g. `:elixir_quote.validate_quote/1` exists in 1.20.0 final but not in + # 1.20.0-rc.5. A stale mob.exs `elixir_lib` (rc.5) that still exists on disk + # would silently ship a mismatched stdlib; the skew is invisible until the app + # compiles an .exs at runtime on-device (a migration) and dies with `undef`. + # Android dodged this because its runtime sync auto-detects from the running + # BEAM; the iOS bundle path trusted the config. Prefer a correct build over an + # honored-but-stale config, and warn so the user fixes mob.exs. + defp resolve_elixir_lib(configured) when is_binary(configured) do + expanded = Path.expand(configured) + exists? = File.exists?(expanded) + + configured_vsn = + if exists? do + MobDev.AppFile.vsn_from_path(Path.join(expanded, "elixir/ebin/elixir.app")) + end + + toolchain_vsn = System.version() + + case __elixir_lib_decision__(exists?, configured_vsn, toolchain_vsn) do + {:use_configured} -> + configured + + {:use_detected, reason} -> + detected = detect_elixir_lib() + + if reason == :version_skew do + Mix.shell().info([ + :yellow, + __elixir_lib_skew_warning__(configured, configured_vsn, toolchain_vsn, detected), + :reset + ]) + end + + detected + end + end + + defp resolve_elixir_lib(_), do: detect_elixir_lib() + + defp detect_elixir_lib do + :code.lib_dir(:elixir) |> to_string() |> Path.dirname() + end + + @doc false + # Pure decision kernel for resolve_elixir_lib/1 — which lib dir to bundle. + # exists? — configured path present on disk + # configured_vsn — Elixir version read from <lib>/elixir/ebin/elixir.app (nil if unreadable) + # toolchain_vsn — System.version(), the compiler that built the app's BEAMs + # Falls back to the auto-detected (running-BEAM) lib on a missing path or a + # version skew; honors an unreadable-but-present config (can't prove it wrong). + @spec __elixir_lib_decision__(boolean(), String.t() | nil, String.t()) :: + {:use_configured} | {:use_detected, :missing | :version_skew} + def __elixir_lib_decision__(false, _configured_vsn, _toolchain_vsn), + do: {:use_detected, :missing} + + def __elixir_lib_decision__(true, nil, _toolchain_vsn), do: {:use_configured} + def __elixir_lib_decision__(true, vsn, vsn), do: {:use_configured} + + def __elixir_lib_decision__(true, _configured_vsn, _toolchain_vsn), + do: {:use_detected, :version_skew} + + @doc false + @spec __elixir_lib_skew_warning__(String.t(), String.t() | nil, String.t(), String.t()) :: + String.t() + def __elixir_lib_skew_warning__(configured, configured_vsn, toolchain_vsn, detected) do + """ + * mob.exs elixir_lib is Elixir #{configured_vsn} but the active toolchain is \ + #{toolchain_vsn}. + configured: #{configured} + A mismatched bundled stdlib causes on-device `undef` crashes when the app \ + compiles .exs at runtime (e.g. Ecto migrations: :elixir_quote.validate_quote/1). + Bundling the toolchain's stdlib instead: #{detected} + Update mob.exs `elixir_lib` to silence this warning. + """ + end + + defp project_swift_sources_arg(cfg) do + cfg + |> Keyword.get(:project_swift_sources, []) + |> normalize_project_swift_sources!() + |> Enum.join(",") + end + + defp normalize_project_swift_sources!(nil), do: [] + + defp normalize_project_swift_sources!(source) when is_binary(source) do + normalize_project_swift_sources!([source]) + end + + defp normalize_project_swift_sources!(sources) when is_list(sources) do + sources + |> Enum.map(&normalize_ios_swift_source!/1) + |> Enum.reject(&(&1 == "")) + end + + defp normalize_project_swift_sources!(other) do + Mix.raise( + ":project_swift_sources must be a string or list of strings, got: #{inspect(other)}" + ) + end + + defp normalize_ios_swift_source!(source) when is_binary(source) do + source = String.trim(source) + + if String.contains?(source, ",") do + Mix.raise(":project_swift_sources entries must not contain commas: #{inspect(source)}") + end + + if source == "", do: "", else: Path.expand(source) + end + + defp normalize_ios_swift_source!(other) do + Mix.raise(":project_swift_sources entries must be strings, got: #{inspect(other)}") + end + + # ── Helpers ────────────────────────────────────────────────────────────────── + + defp check_path(path, key) do + expanded = if is_binary(path), do: Path.expand(path), else: path + + cond do + is_nil(path) or path =~ "/path/to/" -> + {:error, "#{key} not configured in mob.exs — run `mix mob.doctor` for setup help"} + + not File.exists?(expanded) -> + {:error, "#{key} path not found: #{path} — run `mix mob.doctor` to diagnose"} + true -> :ok end diff --git a/lib/mob_dev/ndk_version.ex b/lib/mob_dev/ndk_version.ex new file mode 100644 index 0000000..4989c25 --- /dev/null +++ b/lib/mob_dev/ndk_version.ex @@ -0,0 +1,243 @@ +defmodule MobDev.NdkVersion do + @moduledoc """ + Single source of truth for the Android NDK version Mob's bundled OTP + runtime was cross-compiled against. + + The on-device `libbeam.a` (in the `otp-android-*` tarballs) embeds C++ + stdlib symbols using libc++'s versioned inline namespace. NDK 27.2 + uses `std::__ne180000::`; NDK 25 uses `std::__ne140000::`. These + don't link cross-version: an app's `libpigeon.so` built with the + wrong NDK fails with `undefined symbol: __cxa_allocate_exception` + (or similar libc++ ABI symbols). + + This module is consulted by: + + * `mix mob.doctor` — checks the recommended NDK is installed and + that the project's gradle pin (or override) doesn't drift. + * `mix mob.install` — same check during onboarding. + * `mix mob.new`'s gradle template (via `MobNew.NdkVersion`) — sets + the `ndkVersion` literal so AGP picks deterministically. + * `scripts/release/openssl/*.sh` — sources `NDK_VERSION` from + `_lib.sh` so the host-side OpenSSL cross-compile uses the same + NDK as the bundled tarballs. + + ## Recommended vs effective + + `recommended/0` is the version Mob's tarballs were built against. It + changes only when we cross-compile new tarballs. + + `effective/0` returns the recommended version *unless* the user has + overridden it. Two override mechanisms: + + 1. **Environment variable** (`MOB_ANDROID_NDK_VERSION=...`) — + machine-local. Use when one developer needs a specific NDK on + their box and the team's project config should stay clean. + + 2. **Per-project config** in `mob.exs`: + + config :mob_dev, + android_ndk_version: "25.1.8937393" + + Travels with the project. Use when the whole team needs to + build against a non-recommended NDK (legacy library + dependency, hardware-specific toolchain, etc). + + Precedence: env var > mob.exs > recommended. + + ## Override caveat + + When an override is active the user opts out of the libc++ ABI + guarantee against the bundled tarballs. They're navigating that + alone — `mob.doctor` warns but does not fail. Cryptic link errors + against `libbeam.a` are then their problem to debug. See + `~/code/mob/common_fixes.md` "NDK 27 / clang 18 split libc++" + for the symptom and the diagnostic. + """ + + @recommended "27.2.12479018" + + @doc "The NDK version the bundled OTP tarballs were cross-compiled with." + @spec recommended() :: String.t() + def recommended, do: @recommended + + @doc """ + The NDK version the user's build should target. + + Returns `recommended/0` unless overridden via `MOB_ANDROID_NDK_VERSION` + env var or `:android_ndk_version` in `mob.exs`'s `:mob_dev` config. + """ + @spec effective() :: String.t() + def effective do + case override() do + {_source, version} -> version + :none -> @recommended + end + end + + @doc """ + Returns `{:env, version}`, `{:mob_exs, version}`, or `:none` + describing which override mechanism is active (if any). + + Used by `mob.doctor` to explain *why* the effective version differs + from the recommendation. + """ + @spec override() :: {:env | :mob_exs, String.t()} | :none + def override do + cond do + env = System.get_env("MOB_ANDROID_NDK_VERSION") -> {:env, env} + cfg = Application.get_env(:mob_dev, :android_ndk_version) -> {:mob_exs, cfg} + true -> :none + end + end + + @doc """ + True if the given NDK version is installed under the local Android SDK. + + Looks for `<sdk>/ndk/<version>/source.properties` since the directory + alone can be a half-extracted partial install. + """ + @spec installed?(String.t()) :: boolean() + def installed?(version) do + case sdk_root() do + nil -> false + sdk -> File.regular?(Path.join([sdk, "ndk", version, "source.properties"])) + end + end + + @doc """ + Returns all NDK versions present under the local SDK, newest-first by + string sort. + """ + @spec installed_versions() :: [String.t()] + def installed_versions do + case sdk_root() do + nil -> + [] + + sdk -> + ndk_dir = Path.join(sdk, "ndk") + + case File.ls(ndk_dir) do + {:ok, entries} -> + entries + |> Enum.filter(&File.regular?(Path.join([ndk_dir, &1, "source.properties"]))) + |> Enum.sort(:desc) + + _ -> + [] + end + end + end + + @doc """ + Returns the absolute path to the recommended NDK install if present, + or `nil`. + """ + @spec recommended_install_path() :: String.t() | nil + def recommended_install_path do + case sdk_root() do + nil -> + nil + + sdk -> + path = Path.join([sdk, "ndk", @recommended]) + if File.regular?(Path.join(path, "source.properties")), do: path, else: nil + end + end + + @doc """ + Reads the project's `android/app/build.gradle` (or `.kts`) for the + `ndkVersion` literal. Returns the string or `nil` if not pinned. + + Accepts an optional project root; defaults to the current working + directory. + """ + @spec project_pinned(String.t()) :: String.t() | nil + def project_pinned(project_root \\ File.cwd!()) do + candidates = [ + Path.join(project_root, "android/app/build.gradle"), + Path.join(project_root, "android/app/build.gradle.kts") + ] + + # `ndkVersion '27.2.x'` (Groovy DSL) and `ndkVersion = "27.2.x"` (Kotlin DSL) + # are both accepted — the regex tolerates the optional `=`. + # Compiled at runtime to avoid OTP 28.0 `:re.import/1` undefined-function + # crash on sigil-precompiled regexes loaded from beam files. + pattern = Regex.compile!("ndkVersion\\s*=?\\s*[\"']([^\"']+)[\"']") + + Enum.find_value(candidates, fn path -> + case File.read(path) do + {:ok, contents} -> + case Regex.run(pattern, contents, capture: :all_but_first) do + [version] -> version + _ -> nil + end + + _ -> + nil + end + end) + end + + # ── Internals ──────────────────────────────────────────────────────────────── + + @doc false + @spec sdk_root() :: String.t() | nil + def sdk_root do + System.get_env("ANDROID_HOME") || + System.get_env("ANDROID_SDK_ROOT") || + default_sdk_root_for_os() + end + + @doc false + # The Android NDK toolchain host tag — the NDK ships a single prebuilt + # (darwin-x86_64 even on Apple Silicon; Apple's Rosetta 2 covers it). + @spec host() :: String.t() + def host do + case :os.type() do + {:unix, :darwin} -> "darwin-x86_64" + {:unix, :linux} -> "linux-x86_64" + other -> raise "unsupported host for NDK: #{inspect(other)}" + end + end + + @doc false + # NDK root for the effective version, honoring ANDROID_HOME / ANDROID_SDK_ROOT + # (falls back to the OS-conventional SDK dir even when it doesn't exist, so a + # "toolchain not found at <path>" error still names a sensible location). The + # single source of truth for `native_build`, `cpp_archive`, and `nx_eigen_nif` + # — none of them should re-derive this (see MOB-89). + @spec root() :: String.t() + def root, do: Path.join([sdk_root() || os_default_sdk_dir(), "ndk", effective()]) + + @doc false + @spec toolchain_bin() :: String.t() + def toolchain_bin, do: Path.join([root(), "toolchains", "llvm", "prebuilt", host(), "bin"]) + + @doc false + @spec sysroot() :: String.t() + def sysroot, do: Path.join([root(), "toolchains", "llvm", "prebuilt", host(), "sysroot"]) + + # The OS-conventional SDK dir (path only, no existence check) — used by + # `root/0` as the last-resort fallback so error messages name a real path. + defp os_default_sdk_dir do + case :os.type() do + {:unix, :linux} -> Path.expand("~/Android/Sdk") + _ -> Path.expand("~/Library/Android/sdk") + end + end + + defp default_sdk_root_for_os do + path = os_default_sdk_dir() + if File.dir?(path), do: path, else: nil + end + + @doc """ + Build the suggested install command for the recommended NDK. Used by + `mob.doctor` and `mix mob.install` to give the user a one-liner. + """ + @spec install_command() :: String.t() + def install_command do + "sdkmanager --install \"ndk;#{@recommended}\"" + end +end diff --git a/lib/mob_dev/network.ex b/lib/mob_dev/network.ex index c3b4fc0..4c84523 100644 --- a/lib/mob_dev/network.ex +++ b/lib/mob_dev/network.ex @@ -12,7 +12,9 @@ defmodule MobDev.Network do ifaces |> Enum.map(fn {ip, _broadcast, _mask} -> ip end) |> first_lan_ip() - _ -> nil + + _ -> + nil end end @@ -22,9 +24,9 @@ defmodule MobDev.Network do @doc "Returns true if the IP tuple is a private LAN address (non-loopback)." @spec lan_ip?(:inet.ip_address()) :: boolean() - def lan_ip?({127, _, _, _}), do: false - def lan_ip?({10, _, _, _}), do: true - def lan_ip?({172, b, _, _}), do: b >= 16 and b <= 31 - def lan_ip?({192, 168, _, _}), do: true - def lan_ip?(_), do: false + def lan_ip?({127, _, _, _}), do: false + def lan_ip?({10, _, _, _}), do: true + def lan_ip?({172, b, _, _}), do: b >= 16 and b <= 31 + def lan_ip?({192, 168, _, _}), do: true + def lan_ip?(_), do: false end diff --git a/lib/mob_dev/node_util.ex b/lib/mob_dev/node_util.ex new file mode 100644 index 0000000..ced6139 --- /dev/null +++ b/lib/mob_dev/node_util.ex @@ -0,0 +1,27 @@ +defmodule MobDev.NodeUtil do + @moduledoc false + + # Tiny helpers for parsing Erlang node atoms (`name@host`). + + @doc """ + Return the host portion of a node atom, or nil for nil/short names. + + iex> MobDev.NodeUtil.host_from_node(:"mob@10.0.0.1") + "10.0.0.1" + + iex> MobDev.NodeUtil.host_from_node(:mob) + nil + + iex> MobDev.NodeUtil.host_from_node(nil) + nil + """ + @spec host_from_node(atom() | nil) :: String.t() | nil + def host_from_node(nil), do: nil + + def host_from_node(node) when is_atom(node) do + case node |> Atom.to_string() |> String.split("@", parts: 2) do + [_, host] -> host + _ -> nil + end + end +end diff --git a/lib/mob_dev/nx_eigen_nif.ex b/lib/mob_dev/nx_eigen_nif.ex new file mode 100644 index 0000000..c5115ac --- /dev/null +++ b/lib/mob_dev/nx_eigen_nif.ex @@ -0,0 +1,467 @@ +defmodule MobDev.NxEigenNif do + @moduledoc """ + Cross-compiles the `nx_eigen` C++ NIF (Eigen-backed Nx backend) for one + Android or iOS target ABI and archives the result as `libnx_eigen.a`. + The archive gets static-linked into the user app's main native binary + alongside `crypto.a`, `libemlx.a`, and any other static NIFs. + + ## Why static-link this NIF? + + Same constraints as every other NIF mob ships on phones: + + * **Android.** `dlopen`'d children inherit `RTLD_LOCAL`, hiding the + parent's `enif_*` symbols from a separately-loaded `libnx_eigen.so`. + `on_load` then fails with "cannot locate symbol". Static linking + sidesteps that — BEAM finds `nx_eigen_nif_init` via + `dlsym(RTLD_DEFAULT)` against the main app binary. + * **iOS.** App Store forbids loading unsigned dylibs / `dlopen`; every + NIF must already be present in the signed binary. + + ## Per-target deltas + + All four targets compile the same two source files (`@sources`) with the + shared base CXXFLAGS list (`@base_cxxflags`). The deltas are: + + | Target | Arch dir | Toolchain | Extra CXXFLAGS | nm symbol | + |---------------|---------------------------------|--------------------|-----------------------------------------------|----------------------| + | android_arm64 | aarch64-unknown-linux-android | NDK clang++/llvm-ar | Android hardening: branch-protect, stack-clash, _GNU_SOURCE | `nx_eigen_nif_init` | + | android_arm32 | arm-unknown-linux-androideabi | NDK clang++/llvm-ar | Android hardening + `-march=armv7-a -mfloat-abi=softfp -mthumb` | `nx_eigen_nif_init` | + | ios_sim | aarch64-apple-iossimulator | xcrun (sim SDK) | iOS minimal — no Android hardening | `_nx_eigen_nif_init` | + | ios_device | aarch64-apple-ios | xcrun (device SDK) | iOS minimal — no Android hardening | `_nx_eigen_nif_init` | + + ## STATIC_ERLANG_NIF_LIBNAME + + We pass `-DSTATIC_ERLANG_NIF_LIBNAME=nx_eigen` rather than plain + `-DSTATIC_ERLANG_NIF`. The reason: `nx_eigen_nif.cpp` uses Fine's + `FINE_INIT(...)` macro, which expands to `ERL_NIF_INIT_DECL(NAME)` — + passing the literal token `NAME` as MODNAME. With STATIC_ERLANG_NIF + alone the emitted symbol would be the unhelpful `NAME_nif_init`. + Setting LIBNAME overrides MODNAME entirely and forces the symbol to + `nx_eigen_nif_init`, which is what mob's driver_tab references. + + ## FFT — Eigen's built-in kissfft backend + + We don't use NxEigen's bundled FFT variants. Instead, mob_dev ships + its own `priv/cpp_nif/nx_eigen_fft_eigen.cpp` bridge that calls + Eigen's `unsupported/Eigen/FFT` module (kissfft underneath — header + only, embedded in the Eigen tarball). This gives `Nx.fft/3` and + `Nx.ifft/3` working on-device with no additional cross-compile. + + Kissfft is roughly 2x slower than FFTW for large transforms but + microseconds for audio-sized buffers; switch to a FFTW variant later + if a real workload measures a bottleneck. + + ## Phases + + Each `build/2` call: + 1. Precheck — nx_eigen source dir + Fine + Eigen headers + erts + include all present; Android/iOS toolchain reachable. + 2. Compile — for each of @sources, run `<cxx> <cxxflags> -c -o obj src`. + 3. Archive — `<ar> rcs libnx_eigen.a obj1 obj2` then `<ranlib> ...`. + 4. Verify — `<nm> libnx_eigen.a`, scan for the expected symbol. + Symbol missing is a `:precondition_failed` (means our compile + didn't actually produce `nx_eigen_nif_init` — usually because Fine + or NxEigen's source moved out from under us, or LIBNAME wasn't + picked up). + """ + + alias MobDev.NdkVersion + alias MobDev.Release.{Errors, Shell} + + @android_api 28 + @ios_min_version "17.0" + @eigen_version "3.4.0" + + # ── Source list ──────────────────────────────────────────────────────── + # + # Sources come from two roots: NxEigen's own `c_src/` (the main NIF) and + # mob_dev's own `priv/cpp_nif/` (the Eigen-FFT bridge we wrote — see + # the "FFT" section in the moduledoc). Each entry is + # `{root, basename}` where `root` is `:nx_eigen | :bridge`; the build + # resolves to absolute paths once the dep paths are known. + + @sources [ + {:nx_eigen, "nx_eigen_nif.cpp"}, + {:bridge, "nx_eigen_fft_eigen.cpp"} + ] + + @doc """ + Source files compiled for every target — list of `{root, basename}`. + Public so tests can pin the surface. + """ + @spec sources() :: [{:nx_eigen | :bridge, String.t()}] + def sources, do: @sources + + # ── Base CXXFLAGS — shared across all targets ─────────────────────────── + + @base_cxxflags [ + "-fPIC", + "-O3", + "-std=c++17", + "-fvisibility=hidden", + # Exceptions + RTTI stay enabled — Fine throws std::runtime_error / + # std::invalid_argument for decode failures and NxEigen propagates + # them through `try`/`catch` in FINE_INIT. Disabling either flag + # leads to "cannot use 'throw' with exceptions disabled" at compile + # time. Matches Pythonx's working build (also C++17 + exceptions). + "-ffunction-sections", + "-fdata-sections", + # Forces the FINE_INIT-emitted symbol to nx_eigen_nif_init regardless + # of the (broken-looking) NAME token Fine passes through. + "-DSTATIC_ERLANG_NIF_LIBNAME=nx_eigen" + ] + + @doc "Base CXXFLAGS shared across all targets. Public for testing." + @spec base_cxxflags() :: [String.t()] + def base_cxxflags, do: @base_cxxflags + + # Android targets add hardening flags + _GNU_SOURCE. iOS doesn't ship + # these — they're either non-applicable (no branch-protect on iOS arm64, + # Apple's PAC is enabled differently) or Apple's SDK already defines + # equivalents. + @android_extra_cxxflags [ + "-fstrict-flex-arrays=3", + "-mbranch-protection=standard", + "-fstack-clash-protection", + "-D_GNU_SOURCE" + ] + + # arm32 additionally needs ABI flags: armv7-a target arch, softfp ABI + # (Android API contract), -mthumb to match NDK code-gen. + @arm32_extra_cxxflags ["-march=armv7-a", "-mfloat-abi=softfp", "-mthumb"] + + # ── Target spec ───────────────────────────────────────────────────────── + + defmodule Target do + @moduledoc "Per-target description: arch path layout, toolchain factory, extra CXXFLAGS, expected nm symbol." + + @enforce_keys [:id, :arch_dir, :tools_fn, :extra_cxxflags, :nm_symbol] + defstruct [:id, :arch_dir, :tools_fn, :extra_cxxflags, :nm_symbol] + + @type tools :: %{ + cxx: [String.t()], + ar: [String.t()], + ranlib: [String.t()], + nm: [String.t()] + } + + @type t :: %__MODULE__{ + id: :android_arm64 | :android_arm32 | :ios_sim | :ios_device, + arch_dir: String.t(), + tools_fn: (keyword() -> tools()), + extra_cxxflags: [String.t()], + nm_symbol: String.t() + } + end + + @doc "All known NxEigen targets." + @spec targets() :: [atom()] + def targets, do: [:android_arm64, :android_arm32, :ios_sim, :ios_device] + + @doc """ + Per-target spec. Public so tests can lock down the surface (especially + the `extra_cxxflags` lists — silent drops there would silently weaken + released binaries). + """ + @spec target_spec(atom()) :: Target.t() + def target_spec(:android_arm64) do + %Target{ + id: :android_arm64, + arch_dir: "aarch64-unknown-linux-android", + tools_fn: &android_tools(&1, :android_arm64), + extra_cxxflags: @android_extra_cxxflags, + nm_symbol: "nx_eigen_nif_init" + } + end + + def target_spec(:android_arm32) do + %Target{ + id: :android_arm32, + arch_dir: "arm-unknown-linux-androideabi", + tools_fn: &android_tools(&1, :android_arm32), + extra_cxxflags: @arm32_extra_cxxflags ++ @android_extra_cxxflags, + nm_symbol: "nx_eigen_nif_init" + } + end + + def target_spec(:ios_sim) do + %Target{ + id: :ios_sim, + arch_dir: "aarch64-apple-iossimulator", + tools_fn: &ios_tools(&1, :ios_sim), + extra_cxxflags: [], + # Mach-O symbols carry a leading underscore in nm output. + nm_symbol: "_nx_eigen_nif_init" + } + end + + def target_spec(:ios_device) do + %Target{ + id: :ios_device, + arch_dir: "aarch64-apple-ios", + tools_fn: &ios_tools(&1, :ios_device), + extra_cxxflags: [], + nm_symbol: "_nx_eigen_nif_init" + } + end + + # ── CXXFLAGS assembly (pure) ──────────────────────────────────────────── + + @doc """ + Assemble the full CXXFLAGS list for a target plus the include path + list. Pure function for testability — silent flag drops are the exact + regression class this module exists to prevent. + + `includes` is a list of absolute directory paths to be `-I`-prefixed. + Order is preserved. + """ + @spec cxxflags(Target.t(), [Path.t()]) :: [String.t()] + def cxxflags(%Target{} = target, includes) when is_list(includes) do + @base_cxxflags ++ + target.extra_cxxflags ++ + Enum.map(includes, &"-I#{&1}") + end + + # ── Build entrypoint ──────────────────────────────────────────────────── + + @doc """ + Compile + archive + verify libnx_eigen.a for one target. Returns + `{:ok, info}` naming the produced archive, or a tagged error. + + Options: + * `:nx_eigen_dir` — path to the nx_eigen Hex dep (the dir containing + `c_src/` and the Eigen download). **Required.** + * `:fine_dir` — path to the fine Hex dep (containing `c_include/`). + **Required.** + * `:erts_include` — path to the per-target `erts-VSN/include/` dir + (carries `erl_nif.h`, etc.). **Required.** + * `:eigen_dir` — Eigen header root (defaults to + `<nx_eigen_dir>/eigen-#{@eigen_version}`). + * `:bridge_dir` — directory holding the Eigen-FFT bridge source + we ship (defaults to `:code.priv_dir(:mob_dev)/cpp_nif`). + * `:out_dir` — directory the archive + per-arch obj subdir get + written to. **Required.** + * `:ndk_root` — Android NDK root (Android targets only; defaults to + `~/Library/Android/sdk/ndk/<NdkVersion.effective()>`). + """ + @spec build(atom(), keyword()) :: {:ok, map()} | Errors.t() + def build(target_id, opts \\ []) + when target_id in [:android_arm64, :android_arm32, :ios_sim, :ios_device] do + target = target_spec(target_id) + shell = Shell.impl() + + with {:ok, nx_eigen_dir} <- require_opt(opts, :nx_eigen_dir), + {:ok, fine_dir} <- require_opt(opts, :fine_dir), + {:ok, erts_inc} <- require_opt(opts, :erts_include), + {:ok, out_dir} <- require_opt(opts, :out_dir) do + eigen_dir = opts[:eigen_dir] || Path.join(nx_eigen_dir, "eigen-#{@eigen_version}") + bridge_dir = opts[:bridge_dir] || default_bridge_dir() + nx_eigen_src = Path.join(nx_eigen_dir, "c_src") + + paths = %{ + nx_eigen: nx_eigen_src, + bridge: bridge_dir, + obj_dir: Path.join([out_dir, "obj", target.arch_dir]), + lib_dir: out_dir + } + + includes = [ + nx_eigen_src, + eigen_dir, + Path.join(fine_dir, "c_include"), + erts_inc, + Path.join(erts_inc, "internal") + ] + + with :ok <- + precheck(target, shell, paths, eigen_dir, fine_dir, erts_inc, opts), + tools = target.tools_fn.(opts), + flags = cxxflags(target, includes), + :ok <- shell.mkdir_p(paths.obj_dir), + :ok <- shell.mkdir_p(paths.lib_dir), + {:ok, objects} <- compile_sources(shell, tools, flags, paths), + archive = Path.join(paths.lib_dir, "libnx_eigen.a"), + :ok <- shell.rm_f(archive), + {:ok, _} <- shell.cmd(tools.ar ++ ["rcs", archive | objects], []), + {:ok, _} <- shell.cmd(tools.ranlib ++ [archive], []), + :ok <- verify_symbol(shell, tools.nm, archive, target.nm_symbol) do + {:ok, %{target: target_id, archive: archive, objects: objects}} + end + end + end + + # Resolves the priv/cpp_nif dir at runtime. `Application.app_dir/2` + # works in dev, test, and releases (more robust than `:code.priv_dir/1` + # which requires the app to be loaded). + defp default_bridge_dir, do: Application.app_dir(:mob_dev, "priv/cpp_nif") + + defp require_opt(opts, key) do + case Keyword.fetch(opts, key) do + {:ok, val} when is_binary(val) and val != "" -> {:ok, val} + _ -> Errors.precondition("MobDev.NxEigenNif.build/2 requires #{inspect(key)}") + end + end + + defp compile_sources(shell, tools, flags, paths) do + Enum.reduce_while(@sources, {:ok, []}, fn {root, basename}, {:ok, acc} -> + src_path = Path.join(Map.fetch!(paths, root), basename) + obj_path = Path.join(paths.obj_dir, String.replace_suffix(basename, ".cpp", ".o")) + + argv = tools.cxx ++ flags ++ ["-c", "-o", obj_path, src_path] + + case shell.cmd(argv, []) do + {:ok, _} -> {:cont, {:ok, [obj_path | acc]}} + err -> {:halt, err} + end + end) + |> case do + {:ok, objs} -> {:ok, Enum.reverse(objs)} + err -> err + end + end + + defp verify_symbol(shell, nm_argv, archive, expected_symbol) do + case shell.cmd(nm_argv ++ [archive], []) do + {:ok, output} -> check_symbol_present(output, expected_symbol, archive) + err -> err + end + end + + @doc """ + Parse `nm` output and confirm the expected `nx_eigen_nif_init` symbol + is exported (`T` flag in nm's output). Returns `:ok` or a tagged + precondition_failed. + + Mirror of `MobDev.Release.OpenSSL.CryptoNif.check_symbol_present/3` — + the parsing logic is identical, just a different expected symbol. + Missing symbol on an otherwise-successful build almost always means + `STATIC_ERLANG_NIF_LIBNAME=nx_eigen` got dropped from the flags or + Fine's FINE_INIT macro changed shape. + """ + @spec check_symbol_present(binary(), String.t(), Path.t()) :: :ok | Errors.t() + def check_symbol_present(nm_output, expected_symbol, archive) when is_binary(nm_output) do + pattern = ~r/^\s*[0-9a-f]+ T #{Regex.escape(expected_symbol)}\s*$/m + + if Regex.match?(pattern, nm_output) do + :ok + else + Errors.precondition( + "expected `T #{expected_symbol}` not found in #{archive} — " <> + "did Fine's FINE_INIT macro change shape? Did " <> + "-DSTATIC_ERLANG_NIF_LIBNAME=nx_eigen get dropped?" + ) + end + end + + # ── Preconditions ────────────────────────────────────────────────────── + + defp precheck(target, shell, paths, eigen_dir, fine_dir, erts_inc, opts) do + cond do + not shell.dir?(paths.nx_eigen) -> + Errors.precondition( + "nx_eigen source not found at #{paths.nx_eigen} — run `mix deps.get` " <> + "(needs `:nx_eigen` in mix.exs deps)" + ) + + not shell.dir?(paths.bridge) -> + Errors.precondition( + "Eigen-FFT bridge source not found at #{paths.bridge} — mob_dev " <> + "should ship priv/cpp_nif/nx_eigen_fft_eigen.cpp. Is the " <> + "mob_dev install corrupt?" + ) + + not shell.dir?(eigen_dir) -> + Errors.precondition( + "Eigen headers not found at #{eigen_dir} — run `mix deps.compile " <> + "nx_eigen` on host once to trigger the auto-download, or pass " <> + ":eigen_dir explicitly" + ) + + not shell.dir?(Path.join(fine_dir, "c_include")) -> + Errors.precondition( + "Fine headers not found at #{Path.join(fine_dir, "c_include")} " <> + "— check :fine dep is fetched" + ) + + not shell.dir?(erts_inc) -> + Errors.precondition( + "erts include dir missing at #{erts_inc} — needs the per-target " <> + "OTP tarball extracted" + ) + + target.id in [:android_arm64, :android_arm32] -> + android_precheck(shell, opts) + + target.id in [:ios_sim, :ios_device] -> + :ok + end + end + + defp android_precheck(_shell, opts) do + ndk_root = opts[:ndk_root] || default_ndk_root() + ndk_version = NdkVersion.effective() + + cond do + not NdkVersion.installed?(ndk_version) -> + Errors.precondition( + "Android NDK #{ndk_version} not installed — install with: " <> + NdkVersion.install_command() + ) + + not File.dir?(Path.join([ndk_root, "toolchains/llvm/prebuilt"])) -> + Errors.precondition("NDK toolchain not found at #{ndk_root}") + + true -> + :ok + end + end + + # ── Per-target toolchain factories ───────────────────────────────────── + + defp android_tools(opts, target_id) do + toolchain_bin = android_toolchain_bin(opts) + cxx_name = android_cxx_name(target_id) + + %{ + cxx: [Path.join(toolchain_bin, cxx_name)], + ar: [Path.join(toolchain_bin, "llvm-ar")], + ranlib: [Path.join(toolchain_bin, "llvm-ranlib")], + nm: [Path.join(toolchain_bin, "llvm-nm")] + } + end + + defp android_cxx_name(:android_arm64), do: "aarch64-linux-android#{@android_api}-clang++" + defp android_cxx_name(:android_arm32), do: "armv7a-linux-androideabi#{@android_api}-clang++" + + # iOS uses -stdlib=libc++ explicitly so the link step pulls libc++ rather + # than expecting libstdc++ (which Apple's SDK doesn't ship). On Android + # we get libc++ via the NDK's clang++ default + we statically link via + # -static-libstdc++ at the final link step (the app, not here). + defp ios_tools(_opts, target_id) do + sdk = ios_sdk_name(target_id) + min_flag = ios_min_version_flag(target_id) + + %{ + cxx: ["xcrun", "-sdk", sdk, "clang++", "-arch", "arm64", min_flag, "-stdlib=libc++"], + ar: ["xcrun", "-sdk", sdk, "ar"], + ranlib: ["xcrun", "-sdk", sdk, "ranlib"], + nm: ["xcrun", "-sdk", sdk, "nm"] + } + end + + defp ios_sdk_name(:ios_sim), do: "iphonesimulator" + defp ios_sdk_name(:ios_device), do: "iphoneos" + + defp ios_min_version_flag(:ios_sim), do: "-mios-simulator-version-min=#{@ios_min_version}" + defp ios_min_version_flag(:ios_device), do: "-miphoneos-version-min=#{@ios_min_version}" + + # Honors ANDROID_HOME / ANDROID_SDK_ROOT via the shared NdkVersion helper (MOB-89). + defp default_ndk_root, do: NdkVersion.root() + + defp android_toolchain_bin(opts) do + case opts[:ndk_root] do + nil -> NdkVersion.toolchain_bin() + root -> Path.join([root, "toolchains", "llvm", "prebuilt", NdkVersion.host(), "bin"]) + end + end +end diff --git a/lib/mob_dev/otp_asset_bundle.ex b/lib/mob_dev/otp_asset_bundle.ex new file mode 100644 index 0000000..a74cea2 --- /dev/null +++ b/lib/mob_dev/otp_asset_bundle.ex @@ -0,0 +1,238 @@ +defmodule MobDev.OtpAssetBundle do + @moduledoc """ + Builds the `assets/otp.zip` that release-mode Android Mob apps extract on + first launch. + + Mirrors the elixir-desktop example-app pattern (their `Bridge.kt:unpackZip()`) + but with Mob-specific stripping inherited from the iOS release pass — drops + unused OTP libs and standalone executables to shave bundle size and avoid + shipping anything Mob never actually executes. + + Called from `mix mob.release --android` (Workstream 3). Kept as a separate + module so the strip rules + zip layout are testable without spinning up + the full release pipeline. + + ## Layout produced + + otp.zip + ├── erts-VSN/... (BEAM emulator support — minus bin/) + ├── lib/elixir-VSN/... + ├── lib/logger-VSN/... + ├── lib/eex-VSN/... + ├── lib/<other-otp-libs-the-app-uses>/... + └── releases/... + + The `MobBridge.extractOtpIfNeeded()` Kotlin function unzips this into + `<filesDir>/otp/` on first launch, keyed by `PackageInfo.lastUpdateTime` + so app updates trigger a re-extract. + + ## What gets stripped + + Three categories, mirroring the iOS strip pass in `MobDev.Release`: + + 1. **Whole OTP libs the framework doesn't use** — `megaco`, `runtime_tools`, + `wx`, `observer`, `debugger`, etc. These are dead weight in a Mob app. + 2. **Standalone executables** — `priv/bin/*` and `erts-*/bin/*`. The few + that Mob actually executes (`erl_child_setup`, `inet_gethost`, `epmd`) + are packaged separately in `jniLibs/<abi>/` as `lib<name>.so` so they + get the `apk_data_file` SELinux label that allows execve from + untrusted_app. The rest aren't reachable. + 3. **Static archives (`.a`)** — already linked into the app's native lib + at build time. Shipping them inside the runtime tree is pure waste. + + `.so` files inside OTP libs (e.g. `priv/lib/asn1.so`) are KEPT in the zip — + they're loaded by the BEAM at runtime via `dlopen()`, which works fine from + app data dir on Android (only `execve()` is blocked). + """ + + @stripped_lib_prefixes ~w( + megaco runtime_tools erl_interface os_mon wx et eunit + observer debugger diameter edoc tools snmp dialyzer + syntax_tools parsetools xmerl reltool inets ftp tftp + ) + + @doc """ + Builds the OTP asset zip from `source_otp_tree` into `target_zip_path`. + + Returns `{:ok, %{zipped_files: count, original_size_kb: integer, + zip_size_kb: integer}}` on success, `{:error, reason}` on failure. + + ## Options + + * `:strip_extra_prefixes` — additional OTP lib prefixes to drop on top of + the default list. Atoms or strings. + * `:keep_prefixes` — prefixes to KEEP even if in the default strip list. + Lets a specific app opt back into a stripped lib. + """ + @spec build(Path.t(), Path.t(), keyword) :: + {:ok, map} | {:error, term} + def build(source_otp_tree, target_zip_path, opts \\ []) do + with :ok <- check_source(source_otp_tree), + {:ok, staging} <- stage_and_strip(source_otp_tree, opts), + {:ok, info} <- zip_staging(staging, target_zip_path) do + File.rm_rf!(staging) + original_kb = du_kb(source_otp_tree) + {:ok, Map.put(info, :original_size_kb, original_kb)} + end + end + + @doc """ + Returns the list of OTP lib name prefixes that are stripped by default. + Public so tests can assert the policy without re-importing the module-private list. + """ + @spec default_stripped_prefixes() :: [String.t()] + def default_stripped_prefixes, do: @stripped_lib_prefixes + + defp check_source(path) do + cond do + not File.dir?(path) -> + {:error, "OTP source tree not a directory: #{path}"} + + not erts_in?(path) -> + {:error, "no erts-*/ directory under #{path} — not an OTP runtime tree?"} + + true -> + :ok + end + end + + defp erts_in?(path) do + case File.ls(path) do + {:ok, entries} -> Enum.any?(entries, &String.starts_with?(&1, "erts-")) + _ -> false + end + end + + defp stage_and_strip(source, opts) do + staging = Path.join(System.tmp_dir!(), "mob_otp_zip_#{:erlang.unique_integer([:positive])}") + File.rm_rf!(staging) + File.mkdir_p!(staging) + + case System.cmd("cp", ["-R", source <> "/.", staging], stderr_to_stdout: true) do + {_, 0} -> + # slim: false ships the OTP tree untouched. Required for apps that run + # arbitrary user code at runtime (e.g. an embedded Livebook host doing + # Mix.install) — we can't know which OTP libs (inets, ssl, xmerl, + # runtime_tools, …) a user's deps will need, so stripping any is unsafe. + if Keyword.get(opts, :slim, true) do + prefixes = compute_strip_set(opts) + strip_otp_libs(staging, prefixes) + strip_standalone_execs(staging) + strip_static_archives(staging) + strip_source_and_headers(staging) + strip_beam_chunks(staging) + end + + {:ok, staging} + + {out, _} -> + File.rm_rf!(staging) + {:error, "copy failed: #{out}"} + end + end + + # Drop src/ and include/ from every lib. .erl source and .hrl headers + # are needed at compile time, not runtime. Saves ~16 MB on a typical + # OTP tree. Same logic as iOS release.ex's strip pass. + defp strip_source_and_headers(staging) do + Path.wildcard(Path.join(staging, "lib/*/src")) |> Enum.each(&File.rm_rf!/1) + Path.wildcard(Path.join(staging, "lib/*/include")) |> Enum.each(&File.rm_rf!/1) + :ok + end + + # Strip optional chunks (Dbgi/Docs/etc.) from every shipped .beam. + # Same as `mix release --strip-beams` and the iOS release pass. + # Drops ~30% per .beam file. The host's `erl` and the bundled OTP are + # the same major version, so beam_lib:strip_release/1 is binary-safe. + # + # We're tolerant of strip failures: a stage tree from a test fixture + # may contain placeholder "fake beam" files that aren't valid BEAMs. + # In production those don't exist; the strip succeeds and shrinks + # the bundle. In tests, a failed strip just leaves the file untouched + # (which is the same behaviour as not running the step at all). + defp strip_beam_chunks(staging) do + {_, _status} = + System.cmd( + "erl", + [ + "-noinput", + "-boot", + "start_clean", + "-eval", + ~s|catch beam_lib:strip_release("#{staging}"), erlang:halt(0).| + ], + stderr_to_stdout: true + ) + + :ok + end + + defp compute_strip_set(opts) do + extra = opts |> Keyword.get(:strip_extra_prefixes, []) |> Enum.map(&to_string/1) + keep = opts |> Keyword.get(:keep_prefixes, []) |> Enum.map(&to_string/1) + (@stripped_lib_prefixes ++ extra) -- keep + end + + defp strip_otp_libs(staging, prefixes) do + lib_dir = Path.join(staging, "lib") + + case File.ls(lib_dir) do + {:ok, entries} -> + for entry <- entries, + Enum.any?(prefixes, &String.starts_with?(entry, &1 <> "-")) do + File.rm_rf!(Path.join(lib_dir, entry)) + end + + _ -> + :ok + end + end + + defp strip_standalone_execs(staging) do + Path.wildcard(Path.join(staging, "lib/*/priv/bin/*")) + |> Enum.each(&File.rm_rf!/1) + + Path.wildcard(Path.join(staging, "erts-*/bin/*")) + |> Enum.each(&File.rm_rf!/1) + end + + defp strip_static_archives(staging) do + {_, 0} = + System.cmd("find", [staging, "-type", "f", "-name", "*.a", "-delete"], + stderr_to_stdout: true + ) + + :ok + end + + defp zip_staging(staging, target_zip_path) do + target_zip_path = Path.expand(target_zip_path) + File.mkdir_p!(Path.dirname(target_zip_path)) + File.rm(target_zip_path) + + case System.cmd("zip", ["-9rq", target_zip_path, "."], cd: staging, stderr_to_stdout: true) do + {_, 0} -> + zipped = count_files(staging) + zip_size_kb = div(File.stat!(target_zip_path).size, 1024) + {:ok, %{zipped_files: zipped, zip_size_kb: zip_size_kb}} + + {out, code} -> + {:error, "zip failed (#{code}): #{out}"} + end + end + + defp count_files(dir) do + {out, 0} = System.cmd("find", [dir, "-type", "f"], stderr_to_stdout: true) + out |> String.split("\n", trim: true) |> length() + end + + defp du_kb(path) do + case System.cmd("du", ["-sk", path], stderr_to_stdout: true) do + {out, 0} -> + out |> String.split() |> List.first() |> String.to_integer() + + _ -> + 0 + end + end +end diff --git a/lib/mob_dev/otp_audit.ex b/lib/mob_dev/otp_audit.ex new file mode 100644 index 0000000..da9ab0b --- /dev/null +++ b/lib/mob_dev/otp_audit.ex @@ -0,0 +1,530 @@ +defmodule MobDev.OtpAudit do + @moduledoc """ + Reachability analysis for the bundled OTP runtime tree of a Mob app. + + Walks every `.beam` file under an OTP root, extracts the `imports` chunk + to learn who calls whom, and computes the transitive closure starting + from the app's entry-point modules. Anything not reachable is a strip + candidate — modules, whole libs, or duplicate library versions. + + Used by `mix mob.audit_otp` (read-only report) and the planned + `mix mob.release --slim` flag (auto-strip based on the report). + + ## Entry points + + Reachability seeding is deliberately generous: + + * App's `start/2` callback module (read from each `.app` file's `mod` key) + * All exported functions of `kernel` and `stdlib` (BEAM startup needs them) + * `elixir`, `logger`, `eex` (runtime support that gets called via macros) + + Anything reachable from those is kept; the rest is candidate-for-strip. + + ## Output + + Returns a map with: + + * `:libs` — every lib found, with reachable/total module counts and KB + * `:duplicates` — libs that appear multiple times (only newest is kept) + * `:foreign_apps` — non-OTP, non-app code in the lib dir (other projects) + * `:strippable_libs` — libs with zero reachable modules + * `:total_kb` / `:reachable_kb` / `:strippable_kb` — size summary + + All sizes are post-strip — i.e. what `mix mob.release` actually ships, + not raw OTP source. + """ + + @type module_atom :: atom() + @type lib_name :: String.t() + + @type lib_report :: %{ + name: lib_name(), + version: String.t() | nil, + path: String.t(), + modules_total: non_neg_integer(), + modules_reachable: non_neg_integer(), + modules_traced: non_neg_integer() | nil, + kb_total: non_neg_integer(), + kb_reachable: non_neg_integer(), + unreachable_modules: [module_atom()], + untraced_modules: [module_atom()] | nil, + is_app_under_test?: boolean() + } + + @type report :: %{ + otp_root: String.t(), + app_name: String.t() | nil, + libs: [lib_report()], + duplicates: %{lib_name() => [String.t()]}, + foreign_apps: [String.t()], + foreign_app_names: [lib_name()], + strippable_libs: [lib_name()], + trace_strippable_libs: [lib_name()] | nil, + total_kb: non_neg_integer(), + reachable_kb: non_neg_integer(), + strippable_kb: non_neg_integer() + } + + # OTP runtime support that's called implicitly by every BEAM app. + # Some of these don't show up in the static call graph (init invokes + # them dynamically) so we seed them as always-reachable. + @runtime_seed_libs ~w(kernel stdlib elixir logger sasl) + + # Apps that ship with OTP. Used by the foreign-app classifier — anything + # in the bundle whose name is in this set is assumed legitimate. Sourced + # from `lib/` in a stock OTP 28 release; update when OTP adds or removes + # apps. `erts` is here too: in a stock OTP install it lives one level + # above lib/, but mob's iOS bundle layout copies it into lib/ alongside + # the apps, so the audit's `Path.wildcard("<root>/lib/*")` discovers it + # like a normal lib and we have to allow-list it explicitly. Without + # this the BEAM runtime gets classified as foreign cache cruft. + @otp_shipped_libs ~w( + asn1 common_test compiler crypto debugger dialyzer diameter edoc + eldap erl_docgen erl_interface erts et eunit ftp inets jinterface + kernel megaco mnesia observer odbc os_mon parsetools public_key + reltool runtime_tools sasl snmp ssh ssl stdlib syntax_tools tftp + tools wx xmerl + ) + + # Apps that ship with Elixir under the same lib/ tree as OTP libs after + # mob bundles. Same purpose as @otp_shipped_libs — keep these out of + # the foreign-app set. + @elixir_shipped_libs ~w(elixir eex ex_unit iex logger mix) + + @doc """ + Run the audit. `otp_root` is the directory containing `lib/` and `erts-*/` + (typically the runtime tree extracted into the app bundle, or the cache + the release packaging copies from). + + ## Options + + * `:app_name` — the application's atom name (e.g. `:air_cart_max`). + Used to seed reachability from the app's modules. If omitted, every + lib with no `mod` callback is treated as a potential entry point + (broader, finds less to strip). + + * `:project_deps` — list of atoms naming the project's deps (the + transitive closure, as Mix sees them). When given, the foreign-app + classifier uses it as the authoritative set of "non-OTP libs that + are supposed to be in this bundle." Anything in the bundle whose + name isn't OTP-shipped, Elixir-shipped, the app under test, or in + `:project_deps` is classified as foreign and quarantined into + `report.foreign_apps`. The `mix mob.audit_otp` task auto-detects + this from the current `Mix.Project`. + + When omitted, the classifier falls back to a narrow name-pattern + heuristic (`test_`, `toy_`, `mob_test`, `scratch_`) — sufficient + for tests, less accurate in real bundles. + + * `:trace_input` — a `MapSet` (or list) of `module()` atoms that + were observed at runtime during a trace window. Comes from + `MobDev.OtpTrace.capture/1` (local synthetic harness) or + `Mob.Diag.mfa_trace/1` (remote device trace). When given, the + report grows two fields per lib (`modules_traced`, + `untraced_modules`) and one top-level field + (`trace_strippable_libs`) listing libs whose modules were + ALL absent from the trace — i.e., empirically never called. + + Static reachability misses dynamic dispatch (`apply/3`, + `:erlang.load_nif`, runtime config). Trace data catches + everything that actually ran. The intersection `strippable_libs + ∩ trace_strippable_libs` is the high-confidence strip set; + `trace_strippable_libs \\ strippable_libs` is the "static graph + reaches it but nothing actually called it" set that lets you + strip partly-used libs like megaco / snmp / diameter. + """ + @spec audit(String.t(), keyword()) :: report() + def audit(otp_root, opts \\ []) do + app_name = Keyword.get(opts, :app_name) + project_deps = Keyword.get(opts, :project_deps) + trace_input = normalize_trace_input(Keyword.get(opts, :trace_input)) + libs = discover_libs(otp_root) + {duplicates, libs} = collapse_duplicates(libs) + {foreign_apps, foreign_app_names, libs} = split_foreign_apps(libs, app_name, project_deps) + module_to_lib = build_module_index(libs) + imports = build_import_graph(libs) + + seed_modules = compute_seed_modules(libs, app_name) + reachable = bfs(seed_modules, imports) + + lib_reports = build_lib_reports(libs, reachable, trace_input, module_to_lib) + strippable = Enum.filter(lib_reports, &(&1.modules_reachable == 0)) |> Enum.map(& &1.name) + + trace_strippable = + if trace_input do + lib_reports + |> Enum.filter(&(&1.modules_total > 0 and &1.modules_traced == 0)) + |> Enum.map(& &1.name) + end + + %{ + otp_root: otp_root, + app_name: app_name, + libs: lib_reports, + duplicates: duplicates, + foreign_apps: foreign_apps, + foreign_app_names: foreign_app_names, + strippable_libs: strippable, + trace_strippable_libs: trace_strippable, + total_kb: Enum.sum(Enum.map(lib_reports, & &1.kb_total)), + reachable_kb: Enum.sum(Enum.map(lib_reports, & &1.kb_reachable)), + strippable_kb: + lib_reports + |> Enum.filter(&(&1.modules_reachable == 0)) + |> Enum.sum_by(& &1.kb_total) + } + end + + # Accept either nil, a MapSet, a list of atoms, or an `OtpTrace.result` + # / `Mob.Diag.mfa_trace` map (which carries `:modules` of MapSet shape). + # Returns a MapSet or nil. + defp normalize_trace_input(nil), do: nil + + defp normalize_trace_input(%MapSet{} = ms), do: ms + + defp normalize_trace_input(%{modules: %MapSet{} = ms}), do: ms + + defp normalize_trace_input(%{modules: list}) when is_list(list), do: MapSet.new(list) + + defp normalize_trace_input(list) when is_list(list), do: MapSet.new(list) + + @doc """ + Reads one or more JSON trace files written by + `mix mob.trace_otp --json` and unions their `modules` atoms into a + single MapSet suitable for `:trace_input`. + + Returns: + + * `nil` when given an empty list (the audit will take its + no-trace branch). + * `nil` when every read failed — better than handing back an + empty set, which would let the trace-augmented expansion + strip every partly-used lib in the bundle. A warning is + emitted via `on_read_error.(path, reason)` for each failure + so callers can route them through their own logging. + * `MapSet.t/0` of `module()` atoms otherwise. + + Multi-trace union is the right shape for "this lib is never + called" claims: a 60-second window only exercises one slice of + the app. Unioning boot + UI + auth + idle captures lets users + say "across ALL captured sessions, this lib was never touched" + — a much stronger signal than any single trace. + + ## Example + + iex> MobDev.OtpAudit.union_trace_jsons(["priv/boot.json", "priv/ui.json"]) + #MapSet<[:kernel, :Elixir.Enum, :Elixir.Map, ...]> + """ + @spec union_trace_jsons([Path.t()], (Path.t(), term() -> any())) :: MapSet.t() | nil + def union_trace_jsons(paths, on_read_error \\ &default_trace_read_warn/2) + + def union_trace_jsons([], _on_read_error), do: nil + + def union_trace_jsons(paths, on_read_error) when is_list(paths) do + union = + Enum.reduce(paths, MapSet.new(), fn path, acc -> + case load_trace_json(path) do + {:ok, ms} -> + MapSet.union(acc, ms) + + {:error, reason} -> + # Callback is for side-effects only (logging / Mix.raise). + # Its return value MUST NOT affect the accumulator — + # otherwise the union ends up as whatever the callback + # happened to return. + _ = on_read_error.(path, reason) + acc + end + end) + + if MapSet.size(union) == 0, do: nil, else: union + end + + defp load_trace_json(path) do + with {:ok, body} <- File.read(path), + {:ok, decoded} <- Jason.decode(body) do + modules = + decoded + |> Map.get("modules", []) + |> Enum.map(&String.to_atom/1) + |> MapSet.new() + + {:ok, modules} + end + end + + defp default_trace_read_warn(path, reason) do + IO.warn("could not read trace_json #{path}: #{inspect(reason)} — skipping that trace") + nil + end + + # ── Discovery ───────────────────────────────────────────────────────── + + defp discover_libs(otp_root) do + Path.join(otp_root, "lib/*") + |> Path.wildcard() + |> Enum.filter(&File.dir?/1) + |> Enum.map(fn dir -> + {name, version} = parse_lib_dirname(Path.basename(dir)) + + %{ + name: name, + version: version, + path: dir, + beams: Path.wildcard(Path.join(dir, "ebin/*.beam")), + app_callback: read_app_callback(dir, name) + } + end) + end + + defp parse_lib_dirname(dirname) do + case String.split(dirname, "-", parts: 2) do + [name, version] -> {name, version} + [name] -> {name, nil} + end + end + + defp read_app_callback(lib_dir, name) do + app_file = Path.join(lib_dir, "ebin/#{name}.app") + + with true <- File.exists?(app_file), + {:ok, [{:application, _, props}]} <- :file.consult(String.to_charlist(app_file)) do + case Keyword.get(props, :mod) do + {mod, _args} -> mod + _ -> nil + end + else + _ -> nil + end + end + + # When the same lib appears in multiple versions (asn1-5.4, asn1-5.4.3), + # keep the highest version and report the others as duplicates. + defp collapse_duplicates(libs) do + by_name = Enum.group_by(libs, & &1.name) + + {dupes, kept} = + Enum.reduce(by_name, {%{}, []}, fn + {_name, [single]}, {dupes, kept} -> + {dupes, [single | kept]} + + {name, multiple}, {dupes, kept} -> + [latest | older] = Enum.sort_by(multiple, & &1.version, &version_gt/2) + dupe_paths = Enum.map(older, & &1.path) + {Map.put(dupes, name, dupe_paths), [latest | kept]} + end) + + {dupes, Enum.reverse(kept)} + end + + # Best-effort version comparison — handles `5.4` vs `5.4.3` correctly, + # falls back to string compare for anything weirder. + defp version_gt(a, b) when is_binary(a) and is_binary(b) do + case {Version.parse(loosen(a)), Version.parse(loosen(b))} do + {{:ok, va}, {:ok, vb}} -> Version.compare(va, vb) == :gt + _ -> a > b + end + end + + defp version_gt(a, _b) when is_nil(a), do: false + defp version_gt(_a, nil), do: true + + # Pad short versions ("5.4" → "5.4.0") so Version.parse accepts them. + defp loosen(v) do + parts = String.split(v, ".") + + case length(parts) do + 1 -> v <> ".0.0" + 2 -> v <> ".0" + _ -> v + end + end + + # Anything in `lib/` that isn't a known OTP library and isn't the + # current app is foreign (leaked from another project's release tree + # via a shared cache, almost always). Report separately so the user + # can clean their cache. + # + # Returns `{paths, names, kept_libs}`: paths feed report.foreign_apps + # (what to delete), names feed report.foreign_app_names (what to log + # / dedupe against a hardcoded strip list). + defp split_foreign_apps(libs, app_name, project_deps) do + app_str = if app_name, do: to_string(app_name), else: nil + classifier = foreign_classifier(app_str, project_deps) + + {foreign, kept} = Enum.split_with(libs, classifier) + + {Enum.map(foreign, & &1.path), Enum.map(foreign, & &1.name), kept} + end + + # When the caller supplies `:project_deps`, use the strict classifier: + # foreign = NOT shipped with OTP/Elixir AND NOT the app under test AND + # NOT in the project's dep closure. This is the production path (driven + # by `mix mob.audit_otp` reading `Mix.Project.deps_apps/0`) and catches + # any leftover from a shared OTP cache, no matter what the lib is named. + # + # When `:project_deps` is nil (default, tests and ad-hoc CLI use), fall + # back to the historical name-pattern heuristic. It's narrower but + # backward-compatible — tests built against the old behaviour keep + # working. + defp foreign_classifier(app_str, nil) do + fn lib -> + is_nil(lib.app_callback) and lib.name != app_str and not is_nil(lib.version) and + name_pattern_user_app?(lib.name) + end + end + + defp foreign_classifier(app_str, project_deps) when is_list(project_deps) do + allow_list = + project_deps + |> Enum.map(&to_string/1) + |> MapSet.new() + |> MapSet.union(MapSet.new(@otp_shipped_libs)) + |> MapSet.union(MapSet.new(@elixir_shipped_libs)) + |> then(fn s -> if app_str, do: MapSet.put(s, app_str), else: s end) + + fn lib -> + not is_nil(lib.version) and not MapSet.member?(allow_list, lib.name) + end + end + + # Narrow legacy heuristic — kept for backwards compat with tests and + # CLI use that doesn't have a Mix.Project context to provide deps. + defp name_pattern_user_app?(name) do + String.starts_with?(name, "test_") or String.starts_with?(name, "toy_") or + String.starts_with?(name, "scratch_") or + name in ~w(mob_test air_cart_max_test) + end + + # ── Import graph ────────────────────────────────────────────────────── + + defp build_module_index(libs) do + for lib <- libs, + beam <- lib.beams, + into: %{} do + module = beam |> Path.basename(".beam") |> String.to_atom() + {module, lib.name} + end + end + + defp build_import_graph(libs) do + for lib <- libs, + beam <- lib.beams, + into: %{} do + module = beam |> Path.basename(".beam") |> String.to_atom() + imports = read_imports(beam) + {module, MapSet.new(imports, fn {m, _f, _a} -> m end)} + end + end + + defp read_imports(beam) do + case :beam_lib.chunks(String.to_charlist(beam), [:imports]) do + {:ok, {_module, [{:imports, imports}]}} -> imports + _ -> [] + end + end + + # ── Reachability ────────────────────────────────────────────────────── + + defp compute_seed_modules(libs, app_name) do + runtime_modules = + libs + |> Enum.filter(&(&1.name in @runtime_seed_libs)) + |> Enum.flat_map(&modules_in/1) + + app_modules = + if app_name do + case Enum.find(libs, &(&1.name == to_string(app_name))) do + nil -> [] + lib -> modules_in(lib) + end + else + [] + end + + callback_modules = + libs + |> Enum.flat_map(fn + %{app_callback: cb} when is_atom(cb) and not is_nil(cb) -> [cb] + _ -> [] + end) + + MapSet.new(runtime_modules ++ app_modules ++ callback_modules) + end + + defp modules_in(lib) do + Enum.map(lib.beams, fn b -> + b |> Path.basename(".beam") |> String.to_atom() + end) + end + + defp bfs(seed, imports) do + do_bfs(MapSet.new(seed), MapSet.new(seed), imports) + end + + defp do_bfs(reached, frontier, imports) do + next = + frontier + |> Enum.flat_map(fn mod -> Map.get(imports, mod, MapSet.new()) end) + |> MapSet.new() + |> MapSet.difference(reached) + + if MapSet.size(next) == 0 do + reached + else + do_bfs(MapSet.union(reached, next), next, imports) + end + end + + # ── Reporting ───────────────────────────────────────────────────────── + + defp build_lib_reports(libs, reachable, trace_input, _module_to_lib) do + Enum.map(libs, fn lib -> + modules = modules_in(lib) |> MapSet.new() + reach = MapSet.intersection(modules, reachable) + + {traced_count, untraced} = + case trace_input do + nil -> + {nil, nil} + + ms -> + traced = MapSet.intersection(modules, ms) + {MapSet.size(traced), MapSet.difference(modules, ms) |> Enum.sort()} + end + + %{ + name: lib.name, + version: lib.version, + path: lib.path, + modules_total: MapSet.size(modules), + modules_reachable: MapSet.size(reach), + modules_traced: traced_count, + kb_total: dir_size_kb(lib.path), + kb_reachable: beam_size_kb(lib.beams, reach), + unreachable_modules: MapSet.difference(modules, reach) |> Enum.sort(), + untraced_modules: untraced, + is_app_under_test?: false + } + end) + |> Enum.sort_by(& &1.kb_total, :desc) + end + + defp dir_size_kb(path) do + case System.cmd("du", ["-sk", path], stderr_to_stdout: true) do + {out, 0} -> out |> String.split() |> List.first() |> String.to_integer() + _ -> 0 + end + end + + defp beam_size_kb(beams, reachable_modules) do + beams + |> Enum.filter(fn b -> + mod = b |> Path.basename(".beam") |> String.to_atom() + MapSet.member?(reachable_modules, mod) + end) + |> Enum.map(fn beam -> File.stat!(beam).size / 1024 end) + |> Enum.sum() + |> round() + end +end diff --git a/lib/mob_dev/otp_audit/slim.ex b/lib/mob_dev/otp_audit/slim.ex new file mode 100644 index 0000000..17107ae --- /dev/null +++ b/lib/mob_dev/otp_audit/slim.ex @@ -0,0 +1,386 @@ +defmodule MobDev.OtpAudit.Slim do + @moduledoc """ + In-place strip pass for the per-app OTP bundle, called by + `MobDev.NativeBuild` when `MOB_SLIM=1`. Was an inline ~100-line + `defp` in `native_build.ex`; extracted here so it's testable and + has a place for per-app override hooks to live. + + ## What gets stripped + + Six phases run in fixed order against `<app_bundle>/otp`: + + 1. `apple_binaries` — `.so` / `.a` and `priv/bin/*` (Apple-policy + parity: no standalone executables in the bundle) + 2. `prefix_libs` — every `lib/<name>-*` whose `<name>` is in the + computed strip set (see `compute_strip_set/1`) + 3. `foreign_apps` — `lib/{toy_,test_,mob_test,scratch_}*` (other + projects' code that snuck into a shared OTP cache) + 4. `dedup_versions` — when the same lib appears at multiple + versions, keep only the highest + 5. `src_and_headers` — every `src/` and `include/` directory + 6. `beam_chunks` — `:beam_lib.strip_release/1` drops Debug/Doc + chunks from every `.beam` + + Steps are intentionally idempotent so repeat runs are safe. + + ## Strip set composition + + The default strip set is the hardcoded baseline (`hardcoded_prefixes/0`) + — a curated list of OTP libs mobile apps never need (megaco, snmp, + diameter, …). Per-app overrides in `mob.exs` adjust the set: + + config :mob_dev, + slim: [ + drop_libs: ["foo_dep"], # force-strip these too + keep_libs: ["mnesia"] # don't strip these even if baseline says so + ] + + `drop_libs` and `keep_libs` accept plain `<name>` strings — the + same shape `MobDev.OtpAudit`'s report uses, so users can copy + basenames directly out of `mix mob.audit_otp` output. + + ## Audit-driven expansion + the always-keep guardrail + + When the caller passes `:audit_input` (typically via mob.exs + `slim: [audit: true, trace_json: "..."]`), the strip set grows + by: + + * `audit.foreign_app_names` (allow-list-validated cache cruft) + * `strippable_libs ∩ trace_strippable_libs` (both signals agree) + * `trace_strippable_libs \\ strippable_libs` (trace-only — libs + the static graph reaches but the trace says are never called) + + The last category is the powerful one and the dangerous one. A + 60-second trace window can easily miss libs that ARE used but + only at boot (`sasl`) or only in code paths the user didn't + drive during the window (`crypto`, `public_key`, `asn1`, `ssl` + when no TLS happens). Auto-stripping those would break apps. + + The `always_keep_libs/0` set is the safety guardrail: those libs + are NEVER added to the strip set by audit-driven expansion, no + matter what the trace says. The hardcoded baseline still strips + what it strips, and `:drop_libs` still works as the user-explicit + escape hatch — but the trace can't accidentally take down crypto + or sasl. + + Override patterns from `mob.exs`: + + config :mob_dev, + slim: [ + # Force-keep — user override beats everything else. + # Use this if audit-driven strip is taking out a lib you + # actually need at runtime. + keep_libs: ["some_lib"], + + # Force-strip — user override beats the audit. Use this + # to expand beyond what the trace alone would mark. + drop_libs: ["my_unused_dep"] + ] + + Precedence (in order from least to most authoritative): hardcoded + baseline → audit-derived expansion (minus always_keep_libs) → + `:drop_libs` → `:keep_libs`. The user's `:keep_libs` is always + the last word. + """ + + @hardcoded_prefixes ~w( + megaco runtime_tools erl_interface os_mon wx et eunit + observer debugger diameter edoc tools snmp dialyzer + syntax_tools parsetools xmerl reltool inets ftp tftp + common_test mnesia eldap odbc + compiler ssh + ) + + # Libs that the audit-driven expansion is forbidden from + # adding to the strip set, even when both static and trace + # agree the lib is unused. Rationale: a finite-duration trace + # can miss code paths that boot the BEAM (sasl), respond to + # rare events (crypto/ssl/asn1/public_key in TLS handshakes), + # or load lazily (logger handlers). Better to ship a slightly + # fatter bundle than to ship one that crashes at startup. + # + # The user's `:drop_libs` can override this if they're sure + # (drop_libs is added AFTER the audit expansion in the merge). + # That's the intentional escape hatch — opinionated default + # with a user-explicit override. + @always_keep_libs ~w( + kernel stdlib erts elixir logger + sasl + crypto public_key asn1 ssl + ) + + @foreign_app_prefixes ~w(toy_ test_ mob_test scratch_) + + @type step_info :: %{ + label: String.t(), + before_kb: non_neg_integer(), + after_kb: non_neg_integer() + } + + @type slim_result :: %{ + steps: [step_info()], + final_kb: non_neg_integer(), + strip_set: [String.t()] + } + + @doc """ + Hardcoded baseline of OTP libs mobile apps never need. Source of + truth for what `mix mob.deploy --slim` strips by default. + """ + @spec hardcoded_prefixes() :: [String.t()] + def hardcoded_prefixes, do: @hardcoded_prefixes + + @doc """ + Libs that audit-driven expansion is forbidden from auto-stripping. + Source of truth for the safety guardrail — see moduledoc. + Users can still force-strip these via `:drop_libs` in `mob.exs`. + """ + @spec always_keep_libs() :: [String.t()] + def always_keep_libs, do: @always_keep_libs + + @doc """ + Compute the final strip set. Returns a sorted, deduplicated list. + + ## Composition (low to high precedence) + + 1. `hardcoded_prefixes/0` — baseline. + 2. `:audit_input` expansion (when given), minus `always_keep_libs/0` + (the safety guardrail). + 3. `:drop_libs` — user-explicit force-strip. + 4. `:keep_libs` — user-explicit force-keep. Wins over everything. + + ## Recognized opts + + * `:keep_libs` — `[String.t()]`, force-keep (subtracts from set). + Highest precedence — overrides every other source. + * `:drop_libs` — `[String.t()]`, force-strip (adds to set). + Higher precedence than the guardrail: a user can `:drop_libs` + a lib that's in `always_keep_libs/0` if they really mean it. + * `:audit_input` — `MobDev.OtpAudit.report/0` to expand the strip + set with audit-derived libs: + - `report.foreign_app_names` always unions in (allow-list- + validated by OtpAudit's `:project_deps`). + - When the audit was run with `:trace_input`: + - `strippable_libs ∩ trace_strippable_libs` unions in + (both signals agree → high confidence). + - `trace_strippable_libs \\ strippable_libs` unions in + (trace-only — the megaco/snmp/diameter unblocking signal). + - `strippable_libs` alone is NOT unioned without trace + (NIF dispatch like `exqlite` is statically invisible). + - The expansion is then filtered through + `always_keep_libs/0` so trace lies (e.g. trace window + missed sasl boot or crypto's lazy TLS calls) can't break + production. + """ + @spec compute_strip_set(keyword()) :: [String.t()] + def compute_strip_set(opts \\ []) do + keep = opts |> Keyword.get(:keep_libs, []) |> MapSet.new() + drop = opts |> Keyword.get(:drop_libs, []) |> MapSet.new() + audit_expansion = audit_expansion(opts[:audit_input]) + + @hardcoded_prefixes + |> MapSet.new() + |> MapSet.union(audit_expansion) + |> MapSet.union(drop) + |> MapSet.difference(keep) + |> Enum.sort() + end + + # Builds the set of additional strippable lib names from an audit + # report. Returns an empty MapSet when no audit was given. + defp audit_expansion(nil), do: MapSet.new() + + defp audit_expansion(audit_input) do + foreign = MapSet.new(audit_input.foreign_app_names || []) + static = MapSet.new(audit_input.strippable_libs || []) + + expansion = + case audit_input.trace_strippable_libs do + nil -> + # No trace data — only the (allow-list-validated) foreign apps + # are safe to add. Static-only `strippable_libs` is risky + # without trace (NIF dispatch invisible to the import graph). + foreign + + trace -> + trace_set = MapSet.new(trace) + # Both signals agree the lib is dead → safest. + confirmed = MapSet.intersection(static, trace_set) + # Trace says never called even though static reaches it → the + # high-value signal that unlocks megaco / snmp / etc. + trace_only = MapSet.difference(trace_set, static) + + foreign + |> MapSet.union(confirmed) + |> MapSet.union(trace_only) + end + + # Safety guardrail: a finite trace window can miss boot-time or + # event-driven calls (sasl boots before tracing starts; crypto/ssl/ + # public_key/asn1 only fire during TLS or signing). Auto-stripping + # any of those because the trace didn't see them would crash apps. + # The user can still force-strip via `:drop_libs` — but the default + # behaviour is opinionated: protect production from a too-narrow + # trace. + MapSet.difference(expansion, MapSet.new(@always_keep_libs)) + end + + @doc """ + Apply the strip pass to an OTP bundle in place. + + Recognized opts: + * `:keep_libs`, `:drop_libs`, `:audit_input` — see + `compute_strip_set/1`. + * `:strip_set` — short-circuit override. When set, every other + opt that would feed into the computation is ignored. Primarily + for tests. + * `:on_step` — optional callback `fn(step_info) -> any()` invoked + after each phase. Caller uses it for build-log output. + + Returns `{:ok, slim_result()}`. The bundle is mutated in place; if a + phase fails, the bundle is in a partially-stripped state (matches the + pre-extraction behaviour, which had no rollback either). + """ + @spec slim_bundle(Path.t(), keyword()) :: {:ok, slim_result()} + def slim_bundle(otp_bundle, opts \\ []) do + strip_set = opts[:strip_set] || compute_strip_set(opts) + on_step = opts[:on_step] || fn _ -> :ok end + erts_vsn = detect_erts_vsn(otp_bundle) || "" + + steps = + phases() + |> Enum.map(fn {label, fun} -> + before_kb = bundle_size_kb(otp_bundle) + fun.(otp_bundle, strip_set, erts_vsn) + after_kb = bundle_size_kb(otp_bundle) + step = %{label: label, before_kb: before_kb, after_kb: after_kb} + on_step.(step) + step + end) + + {:ok, + %{ + steps: steps, + final_kb: bundle_size_kb(otp_bundle), + strip_set: strip_set + }} + end + + # ── Phase table ──────────────────────────────────────────────────────── + + defp phases do + [ + {"apple_binaries", &strip_apple_binaries/3}, + {"prefix_libs", &strip_prefix_libs/3}, + {"foreign_apps", &strip_foreign_apps/3}, + {"dedup_versions", &strip_dedup_versions/3}, + {"src_and_headers", &strip_src_and_headers/3}, + {"beam_chunks", &strip_beam_chunks/3} + ] + end + + # ── Phase implementations ────────────────────────────────────────────── + + defp strip_apple_binaries(otp_bundle, _strip_set, erts_vsn) do + # Apple-policy parity: no .so/.a in the bundle, no standalone + # executables. NIFs are statically linked into the main binary + # via STATIC_ERLANG_NIF. + Enum.each(Path.wildcard("#{otp_bundle}/**/*.so"), &File.rm!/1) + Enum.each(Path.wildcard("#{otp_bundle}/**/*.a"), &File.rm!/1) + + "#{otp_bundle}/**/priv/bin/*" + |> Path.wildcard() + |> Enum.each(fn p -> if File.regular?(p), do: File.rm!(p) end) + + if erts_vsn != "" do + erts_bin = Path.join([otp_bundle, erts_vsn, "bin"]) + + if File.dir?(erts_bin) do + erts_bin + |> File.ls!() + |> Enum.map(&Path.join(erts_bin, &1)) + |> Enum.each(fn p -> if File.regular?(p), do: File.rm!(p) end) + end + end + end + + defp strip_prefix_libs(otp_bundle, strip_set, _erts_vsn) do + for prefix <- strip_set do + "#{otp_bundle}/lib/#{prefix}-*" + |> Path.wildcard() + |> Enum.each(&File.rm_rf!/1) + end + end + + defp strip_foreign_apps(otp_bundle, _strip_set, _erts_vsn) do + # Cache hygiene — apps from other projects that ended up in a + # shared OTP cache. The heuristic here is intentionally narrow + # (matches naming conventions used inside this repo); tighter + # cross-reference with the project's deps lives in a follow-up. + for prefix <- @foreign_app_prefixes do + "#{otp_bundle}/lib/#{prefix}*-*" + |> Path.wildcard() + |> Enum.each(&File.rm_rf!/1) + end + end + + defp strip_dedup_versions(otp_bundle, _strip_set, _erts_vsn) do + lib_dir = Path.join(otp_bundle, "lib") + + if File.dir?(lib_dir) do + lib_dir + |> File.ls!() + |> Enum.map(&Path.join(lib_dir, &1)) + |> Enum.filter(&File.dir?/1) + |> Enum.group_by(fn dir -> + dir |> Path.basename() |> String.replace(~r/-[\d.]+$/, "") + end) + |> Enum.each(fn {_name, dirs} -> + if length(dirs) > 1 do + latest = Enum.max_by(dirs, &Path.basename/1) + dirs |> Enum.reject(&(&1 == latest)) |> Enum.each(&File.rm_rf!/1) + end + end) + end + end + + defp strip_src_and_headers(otp_bundle, _strip_set, _erts_vsn) do + for name <- ~w(src include) do + "#{otp_bundle}/**/#{name}" + |> Path.wildcard() + |> Enum.filter(&File.dir?/1) + |> Enum.each(&File.rm_rf!/1) + end + end + + defp strip_beam_chunks(otp_bundle, _strip_set, _erts_vsn) do + # :beam_lib.strip_release/1 walks every `.beam` under the dir and + # drops Debug/Doc/Dbgi chunks. Same trick `mix release` uses for + # production builds. + case :beam_lib.strip_release(String.to_charlist(otp_bundle)) do + {:ok, _} -> :ok + # Best-effort — a single corrupt .beam shouldn't fail the whole + # build. The eager-load verifier (`mix mob.verify_strip`) is the + # backstop that catches bundles too aggressively stripped. + {:error, :beam_lib, _reason} -> :ok + end + end + + # ── Internals ────────────────────────────────────────────────────────── + + @doc false + @spec detect_erts_vsn(Path.t()) :: String.t() | nil + def detect_erts_vsn(otp_bundle) do + case Path.wildcard(Path.join(otp_bundle, "erts-*")) do + [path | _] -> Path.basename(path) + [] -> nil + end + end + + defp bundle_size_kb(dir) do + case System.cmd("du", ["-sk", dir], stderr_to_stdout: true) do + {out, 0} -> out |> String.split() |> List.first() |> String.to_integer() + _ -> 0 + end + end +end diff --git a/lib/mob_dev/otp_downloader.ex b/lib/mob_dev/otp_downloader.ex index 6a6f4c6..8e3d3f8 100644 --- a/lib/mob_dev/otp_downloader.ex +++ b/lib/mob_dev/otp_downloader.ex @@ -5,17 +5,24 @@ defmodule MobDev.OtpDownloader do Artifacts are cached at `~/.mob/cache/` and reused across projects. """ - @otp_hash "73ba6e0f" + @otp_hash "5c9c69fc" @release_tag "otp-#{@otp_hash}" - @base_url "https://github.com/GenericJam/mob/releases/download/#{@release_tag}" + @base_url "https://github.com/GenericJam/mob/releases/download/#{@release_tag}" @android_name "otp-android-#{@otp_hash}" + @android_arm32_name "otp-android-arm32-#{@otp_hash}" + @android_x86_64_name "otp-android-x86_64-#{@otp_hash}" @ios_sim_name "otp-ios-sim-#{@otp_hash}" + @ios_device_name "otp-ios-device-#{@otp_hash}" @doc "Ensures the Android OTP release is cached. Returns {:ok, path} or {:error, reason}." - @spec ensure_android() :: {:ok, String.t()} | {:error, term()} - def ensure_android do - ensure(@android_name, "#{@android_name}.tar.gz") + @spec ensure_android(String.t()) :: {:ok, String.t()} | {:error, term()} + def ensure_android(abi \\ "arm64-v8a") do + case abi do + "armeabi-v7a" -> ensure(@android_arm32_name, "#{@android_arm32_name}.tar.gz") + "x86_64" -> ensure(@android_x86_64_name, "#{@android_x86_64_name}.tar.gz") + _ -> ensure(@android_name, "#{@android_name}.tar.gz") + end end @doc "Ensures the iOS simulator OTP release is cached. Returns {:ok, path} or {:error, reason}." @@ -24,45 +31,174 @@ defmodule MobDev.OtpDownloader do ensure(@ios_sim_name, "#{@ios_sim_name}.tar.gz") end + @doc "Ensures the iOS device OTP release is cached. Returns {:ok, path} or {:error, reason}." + @spec ensure_ios_device() :: {:ok, String.t()} | {:error, term()} + def ensure_ios_device do + ensure(@ios_device_name, "#{@ios_device_name}.tar.gz") + end + @doc "Returns the cached Android OTP directory path (may not exist yet)." - @spec android_otp_dir() :: String.t() - def android_otp_dir, do: cache_dir(@android_name) + @spec android_otp_dir(String.t()) :: String.t() + def android_otp_dir(abi \\ "arm64-v8a") do + case abi do + "armeabi-v7a" -> cache_dir(@android_arm32_name) + "x86_64" -> cache_dir(@android_x86_64_name) + _ -> cache_dir(@android_name) + end + end @doc "Returns the cached iOS simulator OTP directory path (may not exist yet)." @spec ios_sim_otp_dir() :: String.t() def ios_sim_otp_dir, do: cache_dir(@ios_sim_name) + @doc "Returns the cached iOS device OTP directory path (may not exist yet)." + @spec ios_device_otp_dir() :: String.t() + def ios_device_otp_dir, do: cache_dir(@ios_device_name) + + @doc """ + Warns (does not fail) when the build's Elixir minor version differs from the + Elixir bundled in the device OTP runtime at `otp_dir`. + + This is the `Enum.__in__/2` class of breakage: the `in` operator and other + macros expand differently per Elixir minor, so beams compiled by 1.20 call + functions a 1.19.5 runtime lacks → `:undef` at boot → black screen with no + obvious cause. Warning (not failing) is deliberate: rc/patch transitions are + common and usually fine, and a hard fail would block legitimate builds — but + a loud warning would have turned that debugging saga into one line. + """ + @spec warn_on_elixir_skew(String.t()) :: :ok + def warn_on_elixir_skew(otp_dir) do + case elixir_skew(System.version(), bundled_elixir_version(otp_dir)) do + :ok -> + :ok + + {:skew, build, bundled} -> + IO.puts(:stderr, [ + IO.ANSI.yellow(), + """ + ⚠ Elixir version skew: building with #{build}, but the device OTP runtime ships #{bundled}. + Beams compiled here may not load on device (e.g. `x in list` compiles to + Enum.__in__/2 under 1.20, which #{bundled} lacks → :undef at boot). + Fix: align .tool-versions to #{bundled}, or rebuild the OTP tarball with #{build}.\ + """, + IO.ANSI.reset() + ]) + + :ok + end + end + + @doc false + @spec elixir_skew(String.t(), String.t() | nil) :: :ok | {:skew, String.t(), String.t()} + def elixir_skew(_build, nil), do: :ok + + def elixir_skew(build, bundled) do + if major_minor(build) == major_minor(bundled), + do: :ok, + else: {:skew, build, bundled} + end + + @doc "Reads the Elixir vsn from `otp_dir/lib/elixir/ebin/elixir.app`, or nil." + @spec bundled_elixir_version(String.t()) :: String.t() | nil + def bundled_elixir_version(otp_dir) do + app = Path.join([otp_dir, "lib", "elixir", "ebin", "elixir.app"]) + + with {:ok, content} <- File.read(app), + [_, vsn] <- Regex.run(~r/\{vsn,\s*"([^"]+)"\}/, content) do + vsn + else + _ -> nil + end + end + # ── Private ────────────────────────────────────────────────────────────────── + # major.minor as a 2-element list, dropping any -pre/+build on the patch. + # "1.20.0-rc.5" -> ["1", "20"]; "1.19.5" -> ["1", "19"]. + defp major_minor(vsn), do: vsn |> String.split(".") |> Enum.take(2) + defp ensure(name, tarball) do dir = cache_dir(name) - if valid_otp_dir?(dir) do - {:ok, dir} - else - # Remove stale/incomplete directory before re-downloading. - # This happens when a previous download attempt failed after mkdir - # but before (or during) extraction — e.g. on Nix where curl may use - # different CA certificates, or on a flaky network. - if File.dir?(dir), do: File.rm_rf!(dir) - download_and_extract(name, tarball, dir) - end + result = + if valid_otp_dir?(dir, name) do + {:ok, dir} + else + # Remove stale/incomplete directory before re-downloading. + # Two cases here: + # 1. previous download attempt failed mid-extraction (Nix curl, flaky net) + # 2. cached tarball predates a schema change — e.g. iOS device tarball + # now ships EPMD source under `erts/epmd/src/`. Re-download picks up + # the new asset at the same URL (same OTP hash, new revision uploaded). + if File.dir?(dir), do: File.rm_rf!(dir) + download_and_extract(name, tarball, dir) + end + + with {:ok, otp_dir} <- result, do: warn_on_elixir_skew(otp_dir) + result end # A valid extracted OTP dir must contain at least one erts-* subdirectory. - defp valid_otp_dir?(dir) do - File.dir?(dir) and Path.wildcard(Path.join(dir, "erts-*")) != [] + # The iOS device tarball additionally must ship EPMD source files at + # `erts/epmd/src/`, because `build_device.sh` static-links EPMD into the app + # and there's no other place to source those .c files from. Older tarballs + # (without source) extract cleanly but fail at iOS device build time with + # `clang: no such file or directory: epmd.c` — so we treat them as invalid + # and force a re-download to pick up the schema-bumped asset. + @doc false + @spec valid_otp_dir?(String.t(), String.t()) :: boolean() + def valid_otp_dir?(dir, name) do + base_valid? = File.dir?(dir) and Path.wildcard(Path.join(dir, "erts-*")) != [] + + cond do + not base_valid? -> false + not crypto_present?(dir) -> false + String.starts_with?(name, "otp-ios-device-") -> ios_device_extras_present?(dir) + true -> true + end + end + + @doc false + @spec crypto_present?(String.t()) :: boolean() + def crypto_present?(dir) do + # Schema bump (2026-05-06): tarballs now ship erts-VSN/lib/crypto.a + + # erts-VSN/lib/libcrypto.a so apps can statically link real OpenSSL. + # Older tarballs at the same release tag predate this change and are + # treated as stale — re-download picks up the new content. + Path.wildcard(Path.join([dir, "erts-*", "lib", "crypto.a"])) != [] and + Path.wildcard(Path.join([dir, "erts-*", "lib", "libcrypto.a"])) != [] + end + + @doc false + @spec ios_device_extras_present?(String.t()) :: boolean() + def ios_device_extras_present?(dir) do + # `build_device.sh` static-links EPMD into the iOS app — it needs both the + # .c sources AND the headers they #include (`epmd.h`, `epmd_int.h` — also + # in erts/epmd/src/). A tarball missing the headers extracts cleanly but + # fails at clang time with `'epmd.h' file not found`, so we treat it as + # invalid and force re-download. + Enum.all?( + ~w[ + erts/epmd/src/epmd.c + erts/epmd/src/epmd_srv.c + erts/epmd/src/epmd_cli.c + erts/epmd/src/epmd.h + erts/epmd/src/epmd_int.h + ], + fn rel -> File.exists?(Path.join(dir, rel)) end + ) end defp cache_dir(name) do base = System.get_env("MOB_CACHE_DIR") || Path.join([System.get_env("HOME"), ".mob", "cache"]) + Path.join(base, name) end defp download_and_extract(name, tarball, dest_dir) do - url = "#{@base_url}/#{tarball}" + url = "#{@base_url}/#{tarball}" tmp_file = Path.join(System.tmp_dir!(), tarball) IO.puts(" Downloading #{name} OTP release...") @@ -84,32 +220,29 @@ defmodule MobDev.OtpDownloader do end end - defp download(url, dest) do - case System.cmd("curl", ["-L", "--fail", "--progress-bar", "-o", dest, url], - stderr_to_stdout: false) do - {_, 0} -> :ok - {out, rc} -> {:error, "curl failed (exit #{rc}): #{String.trim(out)}"} - end - end + defp download(url, dest), do: MobDev.Download.curl(url, dest) defp extract(tarball, dest_dir) do File.mkdir_p!(dest_dir) # The tarball extracts into a single top-level directory; strip it with --strip-components=1. case System.cmd("tar", ["xzf", tarball, "-C", dest_dir, "--strip-components=1"], - stderr_to_stdout: true) do - {_, 0} -> :ok + stderr_to_stdout: true + ) do + {_, 0} -> :ok {out, rc} -> {:error, "tar failed (exit #{rc}): #{String.trim(out)}"} end end defp verify_erts(dir) do case Path.wildcard(Path.join(dir, "erts-*")) do - [_ | _] -> :ok + [_ | _] -> + :ok + [] -> {:error, "OTP extraction produced no erts-* directory in #{dir}.\n" <> - " The tarball may have an unexpected layout.\n" <> - " Run `mix mob.doctor` for diagnosis, or report at https://github.com/GenericJam/mob/issues"} + " The tarball may have an unexpected layout.\n" <> + " Run `mix mob.doctor` for diagnosis, or report at https://github.com/GenericJam/mob/issues"} end end end diff --git a/lib/mob_dev/otp_trace.ex b/lib/mob_dev/otp_trace.ex new file mode 100644 index 0000000..034a253 --- /dev/null +++ b/lib/mob_dev/otp_trace.ex @@ -0,0 +1,96 @@ +defmodule MobDev.OtpTrace do + @moduledoc """ + Runtime call-tracing utility. Wraps `:erlang.trace_pattern/3` and + `:erlang.trace/3` to capture the set of `{module, function, arity}` + actually called during a function's execution. + + Used by the characterization harness (`priv/trace/harness.exs`) to + empirically map what Elixir's runtime calls under the hood — the + baseline that complements `MobDev.OtpAudit`'s static analysis. + + ## Why this exists + + Static reachability misses dynamic dispatch (`apply/3` with a + computed atom, `Code.ensure_loaded/1`, NIF lazy load via + `:erlang.load_nif/2`). Tracing catches everything that *actually + ran* during the trace window — ground truth, no inference. + + Combined with static analysis: `static ∪ trace` gives a high-confidence + reachable set; `static ∩ trace` is the "definitely called and + statically reachable" core. + + ## Usage + + result = MobDev.OtpTrace.capture(fn -> + # any Elixir code — exercise features you want to measure + Enum.map(1..10, &(&1 * 2)) + end) + + result.mfas # MapSet of {module, function, arity} + result.modules # MapSet of modules called + result.elapsed_us # How long the wrapped fn took (incl. trace overhead) + + Trace overhead is real (~10x slowdown for tight loops) — only enable + for measurement runs, never in production. + """ + + @type mfa_set :: MapSet.t({module(), atom(), arity()}) + @type module_set :: MapSet.t(module()) + + @type result :: %{ + mfas: mfa_set(), + modules: module_set(), + elapsed_us: non_neg_integer() + } + + @doc """ + Run `fun` with full call tracing enabled on the calling process and + any processes it spawns during execution. Returns a `result/0` map. + + ## Options + + * `:exclude_modules` — modules whose calls should NOT be recorded. + Defaults to `[__MODULE__, MobDev.OtpTrace.Collector, Agent, Task]` + so we don't pollute the trace with our own machinery. + """ + @spec capture((-> any()), keyword()) :: result() + def capture(fun, opts \\ []) when is_function(fun, 0) do + exclude = + opts + |> Keyword.get(:exclude_modules, [__MODULE__, __MODULE__.Collector, Agent, Task]) + |> MapSet.new() + + {:ok, collector} = MobDev.OtpTrace.Collector.start_link(exclude) + + # Trace ALL local function calls across ALL modules. + # `:local` includes calls within the module too; `:global` would + # only catch external calls. We want both for full coverage. + :erlang.trace_pattern({:_, :_, :_}, true, [:local]) + + # Trace this process + anything it spawns. Collector receives + # {:trace, pid, :call, {m, f, a}} messages. + :erlang.trace(self(), true, [:call, :set_on_spawn, {:tracer, collector}]) + + started = System.monotonic_time(:microsecond) + + try do + fun.() + after + :erlang.trace(self(), false, [:all]) + :erlang.trace_pattern({:_, :_, :_}, false, [:local]) + end + + elapsed = System.monotonic_time(:microsecond) - started + + # Give the collector a beat to process pending trace messages. + Process.sleep(50) + mfas = MobDev.OtpTrace.Collector.snapshot(collector) + MobDev.OtpTrace.Collector.stop(collector) + + %{ + mfas: mfas, + modules: MapSet.new(mfas, fn {m, _, _} -> m end), + elapsed_us: elapsed + } + end +end diff --git a/lib/mob_dev/otp_trace/collector.ex b/lib/mob_dev/otp_trace/collector.ex new file mode 100644 index 0000000..82f3bd0 --- /dev/null +++ b/lib/mob_dev/otp_trace/collector.ex @@ -0,0 +1,52 @@ +defmodule MobDev.OtpTrace.Collector do + @moduledoc false + # GenServer that receives `{:trace, pid, :call, {m, f, a}}` messages + # from `:erlang.trace/3` and accumulates the unique MFAs into a MapSet. + # + # Used by `MobDev.OtpTrace.capture/2`. Excludes a configurable set of + # modules (the trace machinery itself) so we don't poison the result + # with our own bookkeeping. + + use GenServer + + @spec start_link(MapSet.t(module())) :: {:ok, pid()} + def start_link(exclude_modules) when is_struct(exclude_modules, MapSet) do + GenServer.start_link(__MODULE__, exclude_modules) + end + + @spec snapshot(pid()) :: MapSet.t(mfa()) + def snapshot(pid), do: GenServer.call(pid, :snapshot, 5_000) + + @spec stop(pid()) :: :ok + def stop(pid), do: GenServer.stop(pid) + + @impl true + def init(exclude) do + {:ok, %{mfas: MapSet.new(), exclude: exclude}} + end + + @impl true + def handle_info({:trace, _pid, :call, {m, f, args_or_arity}}, state) do + # `:erlang.trace/3` reports the call payload as either an integer arity + # (when invoked via apply/3) or a list of actual args (the common case). + # Normalize to arity so the MapSet entry is comparable across calls. + arity = if is_list(args_or_arity), do: length(args_or_arity), else: args_or_arity + + state = + if MapSet.member?(state.exclude, m) do + state + else + %{state | mfas: MapSet.put(state.mfas, {m, f, arity})} + end + + {:noreply, state} + end + + @impl true + def handle_info(_msg, state), do: {:noreply, state} + + @impl true + def handle_call(:snapshot, _from, state) do + {:reply, state.mfas, state} + end +end diff --git a/lib/mob_dev/otp_trace/harness.ex b/lib/mob_dev/otp_trace/harness.ex new file mode 100644 index 0000000..f46d5cb --- /dev/null +++ b/lib/mob_dev/otp_trace/harness.ex @@ -0,0 +1,371 @@ +# credo:disable-for-this-file Credo.Check.Readability.Specs +# +# This is a characterization fixture: every public function exercises a +# slice of Elixir/OTP and returns whatever the last expression +# evaluates to (often a tuple of locals). Spec'ing each as `:: any()` +# adds noise without information. The module is internal tooling for +# `MobDev.OtpTrace.capture/1` — never an API surface third parties +# build against. +defmodule MobDev.OtpTrace.Harness do + @moduledoc """ + Characterization harness: a curated set of Elixir features exercised + in tight blocks. Designed to be wrapped in `MobDev.OtpTrace.capture/1` + to record the runtime modules touched by typical Elixir code. + + ## Phases + + Run individually or together. Each phase exercises a coherent slice + so the trace can be partitioned into "what does X actually need." + + * `language/0` — pattern match, comprehension, anonymous fns, + struct + protocol dispatch, exception handling + * `collections/0` — Enum, Stream, List, Map, MapSet, Tuple, Range + * `strings/0` — String, Binary, charlist conversion, sigils + * `processes/0` — spawn, send/receive, monitor, link + * `otp/0` — GenServer, Supervisor, Application boot, Logger + * `data/0` — ETS, :persistent_term, Date/Time + * `errors/0` — raise, rescue, throw/catch, exit/trap_exit + * `all/0` — runs everything sequentially + + ## What this is NOT + + - Not a benchmark — timings are meaningless under trace overhead + - Not exhaustive — covers common Elixir, not every language corner + - Not production-safe — runs `:erlang.trace/3` system-wide + + ## How modules are defined + + Modules used by the harness are defined at compile time of THIS module, + NOT inside the traced functions. That's deliberate: tracing inside a + `defmodule` block captures the entire compiler call graph (~80 modules + of `:elixir_*`, `:erl_lint`, `:sys_core_*`). Production apps don't run + the compiler at runtime, so we exclude that surface from the baseline. + """ + + require Logger + + # ── Modules used by the exercises (defined at compile time) ────────── + + defmodule HarnessStruct do + @moduledoc false + defstruct [:a, :b, :c] + def with_a(s, a), do: %{s | a: a} + end + + defprotocol HarnessProto do + @moduledoc false + def describe(thing) + end + + defimpl HarnessProto, for: Integer do + def describe(n), do: "int:#{n}" + end + + defimpl HarnessProto, for: BitString do + def describe(s), do: "str:#{s}" + end + + defimpl HarnessProto, for: HarnessStruct do + def describe(%HarnessStruct{a: a}), do: "struct:#{a}" + end + + defmodule HarnessGS do + @moduledoc false + use GenServer + + def start_link(arg), do: GenServer.start_link(__MODULE__, arg) + def get(pid), do: GenServer.call(pid, :get) + def set(pid, v), do: GenServer.cast(pid, {:set, v}) + + @impl true + def init(arg), do: {:ok, arg} + @impl true + def handle_call(:get, _from, state), do: {:reply, state, state} + @impl true + def handle_cast({:set, v}, _state), do: {:noreply, v} + end + + defmodule HarnessApp do + @moduledoc false + use Application + + @impl true + def start(_type, _args) do + Supervisor.start_link([], strategy: :one_for_one) + end + end + + # ── Phases ──────────────────────────────────────────────────────────── + + def language do + # pattern match + {a, b} = {1, 2} + [_h | _t] = [1, 2, 3] + %{x: x} = %{x: 10, y: 20} + <<n::8, _rest::binary>> = <<42, 1, 2>> + _ = {a, b, x, n} + + # case + guards + _ = + case 5 do + n when n > 10 -> :big + n when n > 0 -> :positive + _ -> :nonpositive + end + + # cond + _ = + cond do + 1 == 2 -> :no + true -> :yes + end + + # anonymous fn + capture + add = fn a, b -> a + b end + add.(1, 2) + inc = &(&1 + 1) + inc.(5) + + # comprehension + _ = for i <- 1..5, j <- 1..3, rem(i + j, 2) == 0, do: {i, j} + + # struct + protocol + s = %HarnessStruct{a: 1, b: 2, c: 3} + HarnessProto.describe(s) + HarnessProto.describe(42) + HarnessProto.describe("hi") + + # try/rescue + _ = + try do + raise ArgumentError, "boom" + rescue + e in [ArgumentError] -> e.message + end + + :ok + end + + def collections do + list = [1, 2, 3, 4, 5] + + # Enum (the workhorse) + Enum.map(list, &(&1 * 2)) + Enum.filter(list, &(&1 > 2)) + Enum.reduce(list, 0, &+/2) + Enum.sort(list) + Enum.zip(list, [:a, :b, :c, :d, :e]) + Enum.into(list, MapSet.new()) + Enum.group_by(list, &rem(&1, 2)) + + # List + List.flatten([[1], [2, [3]], 4]) + List.first(list) + List.last(list) + List.delete(list, 3) + List.keyfind([{:a, 1}, {:b, 2}], :b, 0) + + # Map + m = %{a: 1, b: 2, c: 3} + Map.put(m, :d, 4) + Map.delete(m, :a) + Map.merge(m, %{b: 20}) + Map.update(m, :a, 0, &(&1 + 100)) + + # MapSet + s = MapSet.new([1, 2, 3]) + MapSet.put(s, 4) + MapSet.union(s, MapSet.new([3, 4, 5])) + + # Stream + Range + Stream.map(1..1000, &(&1 * 2)) |> Stream.take(10) |> Enum.to_list() + + # Tuple. Calls below are intentional probes — the return value is + # discarded; we just want each MFA to land in the trace. + Tuple.insert_at({1, 2}, 2, 3) + _ = Tuple.to_list({1, 2, 3}) + + # Keyword + kw = [a: 1, b: 2, c: 3] + Keyword.get(kw, :b) + Keyword.put(kw, :d, 4) + + :ok + end + + def strings do + # String basics + String.upcase("hello") + String.split("a,b,c,d", ",") + String.replace("foo bar baz", " ", "_") + String.contains?("hello world", "world") + String.length("héllo") + String.slice("hello", 1, 3) + String.trim(" spaced ") + + # Binary ops. `_ =` on byte_size / binary_part because the return + # is discarded — we're probing the trace surface. + <<a, b, c>> = "abc" + bin = <<a, b, c>> + _ = byte_size(bin) + _ = binary_part(bin, 0, 2) + :binary.copy("xy", 5) + + # IO data + IO.iodata_to_binary(["one", " ", "two"]) + + # Charlists + String.to_charlist("hello") + List.to_string(~c"world") + + # Codepoints + String.codepoints("aé🌍") + String.graphemes("aé🌍") + + :ok + end + + def processes do + parent = self() + + # spawn + receive + pid = spawn(fn -> send(parent, {self(), :hello}) end) + + receive do + {^pid, :hello} -> :ok + after + 1_000 -> :timeout + end + + # link + {:ok, _} = Task.start_link(fn -> :ok end) + + # monitor + pid2 = spawn(fn -> :ok end) + ref = Process.monitor(pid2) + + receive do + {:DOWN, ^ref, :process, ^pid2, _} -> :ok + after + 1_000 -> :timeout + end + + # Task + task = Task.async(fn -> 21 * 2 end) + Task.await(task) + + # Process dictionary (rarely used but still common) + Process.put(:test_key, :test_val) + Process.get(:test_key) + Process.delete(:test_key) + + :ok + end + + def otp do + # GenServer round-trip + {:ok, gs} = HarnessGS.start_link(:initial) + HarnessGS.get(gs) + HarnessGS.set(gs, :updated) + HarnessGS.get(gs) + GenServer.stop(gs) + + # Supervisor (in-process) + children = [ + Supervisor.child_spec({Task, fn -> Process.sleep(:infinity) end}, id: :worker_a), + Supervisor.child_spec({Task, fn -> Process.sleep(:infinity) end}, id: :worker_b) + ] + + {:ok, sup} = Supervisor.start_link(children, strategy: :one_for_one) + Supervisor.stop(sup) + + # Logger calls (ensure logger backend gets exercised) + Logger.info("trace harness — info") + Logger.warning("trace harness — warning") + + :ok + end + + def data do + # ETS + table = :ets.new(:harness_table, [:set, :public]) + :ets.insert(table, {:key1, "value1"}) + :ets.insert(table, {:key2, "value2"}) + :ets.lookup(table, :key1) + :ets.match(table, {:_, :_}) + :ets.delete(table) + + # persistent_term + :persistent_term.put({__MODULE__, :test}, 42) + :persistent_term.get({__MODULE__, :test}) + :persistent_term.erase({__MODULE__, :test}) + + # Date/Time/DateTime + Date.utc_today() + Time.utc_now() + DateTime.utc_now() + DateTime.add(DateTime.utc_now(), 60, :second) + Calendar.ISO.day_of_week(2026, 5, 2, :default) + + # NaiveDateTime + NaiveDateTime.utc_now() + + # System + System.os_time(:millisecond) + System.monotonic_time() + + :ok + end + + def errors do + # raise/rescue + _ = + try do + raise RuntimeError, "boom" + rescue + e -> Exception.message(e) + end + + # throw/catch + _ = + try do + throw(:my_throw) + catch + :throw, val -> val + end + + # exit/trap_exit + Process.flag(:trap_exit, true) + pid = spawn_link(fn -> exit(:bye) end) + + receive do + {:EXIT, ^pid, :bye} -> :ok + after + 1_000 -> :timeout + end + + Process.flag(:trap_exit, false) + + # Stream of standard exceptions to ensure their __struct__/2 etc fire + _ = %ArgumentError{message: "x"} + _ = %ArithmeticError{} + _ = %FunctionClauseError{} + _ = %KeyError{key: :x, term: %{}} + _ = %MatchError{term: nil} + _ = %RuntimeError{message: "x"} + _ = %UndefinedFunctionError{} + + :ok + end + + def all do + language() + collections() + strings() + processes() + otp() + data() + errors() + :ok + end +end diff --git a/lib/mob_dev/paths.ex b/lib/mob_dev/paths.ex new file mode 100644 index 0000000..9b1c883 --- /dev/null +++ b/lib/mob_dev/paths.ex @@ -0,0 +1,90 @@ +defmodule MobDev.Paths do + @moduledoc """ + Resolution helpers for paths Mob writes to outside the project tree. + + Centralised here so the deployer, the build script, the iOS simulator + app's `mob_beam.m`, the cache-listing task, and the doctor all agree on + one answer. + """ + + @doc """ + Returns the directory where the iOS simulator's OTP runtime lives. + + Resolution order: + + 1. `MOB_SIM_RUNTIME_DIR` env var if set + 2. `~/.mob/runtime/ios-sim` (new default — managed by `mix mob.cache`) + 3. `/tmp/otp-ios-sim` (legacy fallback for projects whose `ios/build.sh` + was generated before the env-var-aware template) + + The third branch is the back-compat path: a project's `ios/build.sh` is + generated once at project creation and kept thereafter, so old projects + still write the OTP runtime to `/tmp/otp-ios-sim`. We detect that case + by looking inside the project's own `ios/build.sh` for the + `MOB_SIM_RUNTIME_DIR` token. If it's missing, the project hasn't been + regenerated against the new mob_new template and we honor its old + hardcoded path so `mix mob.deploy` keeps working. + + When `:project_dir` is passed, the build.sh-presence check uses that + directory; otherwise it uses `File.cwd!/0`. Pure of side effects. + """ + @spec sim_runtime_dir(keyword()) :: String.t() + def sim_runtime_dir(opts \\ []) do + project_dir = Keyword.get(opts, :project_dir, File.cwd!()) + + cond do + env = System.get_env("MOB_SIM_RUNTIME_DIR") -> + env + + build_sh_aware?(project_dir) -> + default_runtime_dir() + + build_zig?(project_dir) -> + # Zig-based iOS builds (ios/build.zig, no ios/build.sh) sync the runtime + # to default_runtime_dir() in sync_otp_runtime_sim. The launcher must + # agree, or the sim looks in /tmp/otp-ios-sim and boots a non-existent + # runtime ("elixir.app not found"). Match the staging path. + default_runtime_dir() + + true -> + legacy_tmp_path() + end + end + + @doc false + @spec build_zig?(String.t()) :: boolean() + def build_zig?(project_dir) do + File.exists?(Path.join([project_dir, "ios", "build.zig"])) + end + + @doc """ + The new default runtime path — under `~/.mob/runtime/` so `mix mob.cache` + can list and clear it the same way it handles the OTP cache. + """ + @spec default_runtime_dir() :: String.t() + def default_runtime_dir do + Path.join([System.user_home!(), ".mob", "runtime", "ios-sim"]) + end + + @doc """ + The pre-runtime-dir-relocation path. Old `ios/build.sh` scripts hardcode + this; we keep recognising it so existing projects keep deploying. + """ + @spec legacy_tmp_path() :: String.t() + def legacy_tmp_path, do: "/tmp/otp-ios-sim" + + @doc """ + True when the project's `ios/build.sh` was generated from a template that + knows about `MOB_SIM_RUNTIME_DIR` (mob_new ≥ 0.1.20). False if the file + is missing or predates the env-var support. + """ + @spec build_sh_aware?(String.t()) :: boolean() + def build_sh_aware?(project_dir) do + path = Path.join([project_dir, "ios", "build.sh"]) + + case File.read(path) do + {:ok, content} -> String.contains?(content, "MOB_SIM_RUNTIME_DIR") + _ -> false + end + end +end diff --git a/lib/mob_dev/plugin.ex b/lib/mob_dev/plugin.ex new file mode 100644 index 0000000..e4a9cef --- /dev/null +++ b/lib/mob_dev/plugin.ex @@ -0,0 +1,121 @@ +defmodule MobDev.Plugin do + @moduledoc """ + Compile-time host-config surface for code-generated plugins. + + Spec-v2 plugins that generate their contributions from the host app's + configuration — e.g. a `mob_ash` plugin reading the host's registered + Ash domains, or a `mob_ecto` plugin reading its schemas — read that + config through this function rather than calling `Application.get_env/3` + directly. Routing every host-config read through one named surface is + what later lets the plugin audit (see `MOB_PLUGINS.md` and + `MOB_PLUGIN_SECURITY.md`) verify exactly which keys a generator touches. + + When a generator runs under `with_host_config_audit/3` (which the + build-time generator runner uses), every read is checked against the + plugin's declared `:host_config_keys` and recorded; an undeclared read + fails the build loudly. Outside an audit scope (e.g. in tests) it is a + plain `Application.get_env/3`. + """ + + # Process-dictionary key holding the active host-config audit scope, if any. + @audit_key :"$mob_plugin_host_config_audit" + + @doc """ + Reads `key` from the host application's environment, returning `default` + when the key is unset. + + `otp_app` is the host app's OTP application name — the atom under which it + registers `config :my_app, ...`. Code-generated plugins call this during + the compile step: + + domains = MobDev.Plugin.host_config(:my_app, :ash_domains, []) + + Under an audit scope, reading a key the plugin didn't declare in its + manifest `:host_config_keys` raises — the generator must declare what it + touches so `mix mob.audit_plugins` can verify it. + """ + @spec host_config(atom(), atom(), term()) :: term() + def host_config(otp_app, key, default \\ nil) + when is_atom(otp_app) and is_atom(key) do + case Process.get(@audit_key) do + nil -> + :ok + + %{plugin: plugin, allowed: allowed} = ctx -> + unless key in allowed do + raise ArgumentError, + "plugin #{inspect(plugin)} read host config key #{inspect(key)} not declared in its " <> + "manifest :host_config_keys (declared: #{inspect(allowed)}). Add it to the manifest." + end + + Process.put(@audit_key, %{ctx | reads: [{otp_app, key} | ctx.reads]}) + end + + Application.get_env(otp_app, key, default) + end + + @doc """ + Runs `fun` with host-config auditing scoped to `plugin` (allowing only the + keys in `allowed`, the plugin's manifest `:host_config_keys`). Returns + `{result, reads}` where `reads` is the ordered list of `{otp_app, key}` the + generator actually touched. Nested scopes restore the prior one on exit. + """ + @spec with_host_config_audit(atom(), [atom()], (-> result)) :: {result, [{atom(), atom()}]} + when result: term() + def with_host_config_audit(plugin, allowed, fun) + when is_atom(plugin) and is_list(allowed) and is_function(fun, 0) do + prev = Process.get(@audit_key) + Process.put(@audit_key, %{plugin: plugin, allowed: allowed, reads: []}) + + try do + result = fun.() + %{reads: reads} = Process.get(@audit_key) + {result, Enum.reverse(reads)} + after + if prev, do: Process.put(@audit_key, prev), else: Process.delete(@audit_key) + end + end + + @doc """ + The activated plugin names — `config :mob, :plugins` from `mob.exs`. + + Activation is the second opt-in step (see `MOB_PLUGINS.md`): a plugin in + `deps` contributes nothing until it appears here. Falls back to the loaded + Application env, then `[]`. + """ + @spec activated_names() :: [atom()] + def activated_names do + config_file = Path.join(File.cwd!(), "mob.exs") + + if File.exists?(config_file) do + config_file + |> Config.Reader.read!() + |> Keyword.get(:mob, []) + |> Keyword.get(:plugins, []) + else + Application.get_env(:mob, :plugins, []) + end + rescue + _ -> Application.get_env(:mob, :plugins, []) + end + + @doc """ + The activated plugins as `{plugin_dir, manifest}` pairs, ready for + `MobDev.Plugin.Merge`. + + Resolves each activated name to its dependency directory and loads its + manifest (nil for a tier-0 plugin). Activated names that don't resolve to a + dep are skipped — `mix mob.plugins` is where that mismatch surfaces to users. + """ + @spec activated() :: [{Path.t(), map() | nil}] + def activated do + deps = Mix.Project.deps_paths() + + for name <- activated_names(), dir = deps[name], not is_nil(dir) do + case MobDev.Plugin.Manifest.load(dir) do + {:ok, manifest} -> {dir, manifest} + {:error, _reason} -> {dir, nil} + end + end + end +end diff --git a/lib/mob_dev/plugin/assets.ex b/lib/mob_dev/plugin/assets.ex new file mode 100644 index 0000000..fe24e21 --- /dev/null +++ b/lib/mob_dev/plugin/assets.ex @@ -0,0 +1,218 @@ +defmodule MobDev.Plugin.Assets do + @moduledoc """ + Pure planners for the tier-3 build-time file merges — migrations, fonts, and + images — that `native_build` copies into the host app at build. + + Unlike the runtime manifest (behavioral data read on device), these are + physical files: a plugin's migration `.exs`, font, and image files are + meaningless as build-machine paths on device, so they're copied into the host + bundle at build time. This module computes *what* gets copied where (pure + + unit-tested); `native_build` does the I/O (listing dirs, copying, patching + Info.plist). + """ + + @doc """ + Plans the migration copies: maps each plugin migration source file to a + destination under the host's `migrations_dir`, prefixed with the plugin's + `repo_namespace` so files from different vendors don't collide. + + Takes `[%{repo_namespace, files: [src_path]}]` (the caller lists each plugin's + migration dir) and the host migrations dir; returns `[{src, dest}]`. + """ + @spec migration_copies([%{repo_namespace: String.t(), files: [Path.t()]}], Path.t()) :: + [{Path.t(), Path.t()}] + def migration_copies(plugin_migrations, dest_dir) do + copies = + for %{repo_namespace: ns, files: files} <- plugin_migrations, + src <- files do + {src, Path.join(dest_dir, namespaced_filename(ns, Path.basename(src)))} + end + + assert_unique_destinations!(copies) + copies + end + + # Two distinct sources mapping to the same destination would make the second + # `File.cp!` silently clobber the first migration. After namespacing this can + # only happen if two plugins share a `repo_namespace` (which cross-validation + # already rejects) — so this is a defensive build-time guard, surfaced loudly + # rather than as silent data loss. + defp assert_unique_destinations!(copies) do + dups = + copies + |> Enum.group_by(fn {_src, dest} -> dest end) + |> Enum.filter(fn {_dest, list} -> length(list) > 1 end) + + unless dups == [] do + detail = + Enum.map_join(dups, "\n", fn {dest, list} -> + " #{Path.basename(dest)} <- #{Enum.map_join(list, ", ", fn {src, _} -> src end)}" + end) + + raise "plugin migration filename collision — distinct sources map to the same destination:\n" <> + detail <> "\nRename the migrations so their <version>_<description> differ." + end + end + + @doc """ + Namespaces a migration filename with the plugin's `repo_namespace`, inserting + it into the *name* part after the `<version>_` prefix so Ecto can still parse + the leading-integer version (`20260101000000_create.exs` → + `20260101000000_kv_create.exs`). Files without a numeric version prefix fall + back to a plain prefix. + + The namespace is **always** inserted — there is no "already namespaced?" guard. + Migration sources are always the plugin author's raw files (never our output), + so re-run idempotency comes from the deterministic source→dest mapping, not + from inspecting the name. A guard that skipped namespacing when the description + happened to begin with the namespace text would silently drop the namespace and + cause cross-vendor collisions, so we don't do it. + """ + @spec namespaced_filename(String.t(), String.t()) :: String.t() + def namespaced_filename(ns, filename) do + case Regex.run(~r/^(\d+)_(.*)$/, filename) do + [_, version, rest] -> "#{version}_#{ns}#{rest}" + _ -> ns <> filename + end + end + + @doc """ + The Android `res/font/` resource name for a font file: lowercase, the + extension dropped, and any character outside `[a-z0-9_]` replaced with `_` + (Android resource-name rules). `"Georgia.ttf"` → `"georgia"`, + `"Inter-Regular.ttf"` → `"inter_regular"`. The renderer normalises the `font:` + prop the same way to find the resource. A leading non-letter is prefixed with + `f_` so the name is a valid resource identifier. + """ + @spec android_font_resource_name(String.t()) :: String.t() + def android_font_resource_name(filename) do + base = + filename + |> Path.basename(Path.extname(filename)) + |> String.downcase() + |> String.replace(~r/[^a-z0-9_]/, "_") + + if base =~ ~r/^[a-z]/, do: base, else: "f_" <> base + end + + @doc """ + Plans the iOS font-bundle copies: each distinct source font copies to the `.app` + root under its basename (and is listed in `UIAppFonts` by basename). Two distinct + sources sharing a basename (e.g. two plugins both shipping `Icons.ttf`) would + silently overwrite each other in the bundle and collapse to a single `UIAppFonts` + entry, so that is surfaced as an error instead of a silent loss. + + Returns `{:ok, [{src, dest_basename}]}` or + `{:error, {:font_basename_collision, basename, [src, ...]}}`. + """ + @spec plan_ios_font_bundle([Path.t()]) :: + {:ok, [{Path.t(), String.t()}]} + | {:error, {:font_basename_collision, String.t(), [Path.t()]}} + def plan_ios_font_bundle(fonts) do + plan_font_copies(fonts, &Path.basename/1, :font_basename_collision) + end + + @doc """ + Plans the Android `res/font/` copies: each distinct source maps to + `<android_font_resource_name>.<ext>`. Two distinct sources normalising to the + same resource name (e.g. `Inter-Regular.ttf` and `Inter_Regular.ttf` both → + `inter_regular.ttf`) would silently overwrite each other, so that is surfaced + as an error. + + Returns `{:ok, [{src, res_filename}]}` or + `{:error, {:font_resource_collision, res_filename, [src, ...]}}`. + """ + @spec plan_android_font_copies([Path.t()]) :: + {:ok, [{Path.t(), String.t()}]} + | {:error, {:font_resource_collision, String.t(), [Path.t()]}} + def plan_android_font_copies(fonts) do + plan_font_copies( + fonts, + fn src -> + android_font_resource_name(Path.basename(src)) <> String.downcase(Path.extname(src)) + end, + :font_resource_collision + ) + end + + defp plan_font_copies(fonts, dest_fun, collision_tag) do + copies = fonts |> Enum.uniq() |> Enum.map(fn src -> {src, dest_fun.(src)} end) + + collision = + copies + |> Enum.group_by(fn {_src, dest} -> dest end) + |> Enum.find(fn {_dest, list} -> length(list) > 1 end) + + case collision do + nil -> {:ok, copies} + {dest, list} -> {:error, {collision_tag, dest, Enum.map(list, fn {src, _} -> src end)}} + end + end + + @doc """ + The on-device bundle path a `plugin://<plugin>/<file>` reference resolves to. + Plugin images are copied here at build time; the core `plugin://` resolver + maps to the same convention. Returns a path relative to the app bundle root. + """ + @spec image_bundle_path(atom() | String.t(), String.t()) :: String.t() + def image_bundle_path(plugin, basename) do + Path.join(["assets", "plugin", to_string(plugin), basename]) + end + + @doc """ + Merges font basenames into an iOS `Info.plist` XML string under `UIAppFonts`, + creating the array if absent and de-duplicating existing entries. Pure string + transform over the plist's XML (the same approach as the plist-keys merge). + """ + @spec merge_ui_app_fonts(String.t(), [String.t()]) :: String.t() + def merge_ui_app_fonts(plist, []), do: plist + + def merge_ui_app_fonts(plist, font_basenames) do + existing = parse_ui_app_fonts(plist) + merged = Enum.uniq(existing ++ font_basenames) + array = render_ui_app_fonts_array(merged) + + cond do + has_ui_app_fonts?(plist) -> + replace_ui_app_fonts(plist, array) + + true -> + # Insert before the closing </dict></plist>. The replacement is a + # function (not a string) so that `\N` sequences in a font basename + # — e.g. a file literally named `x\1y.ttf` — are emitted verbatim + # rather than interpreted as regex backreferences, which would splice + # the captured closing tags into the middle of the array. + String.replace( + plist, + ~r{(\n\s*</dict>\s*</plist>\s*)$}, + fn closing -> "\n\t<key>UIAppFonts</key>\n#{array}#{closing}" end + ) + end + end + + @doc "Extracts the current `UIAppFonts` entries from an `Info.plist` (or `[]`)." + @spec parse_ui_app_fonts(String.t()) :: [String.t()] + def parse_ui_app_fonts(plist) do + case Regex.run(~r{<key>UIAppFonts</key>\s*<array>(.*?)</array>}s, plist) do + [_, body] -> Regex.scan(~r{<string>(.*?)</string>}s, body) |> Enum.map(fn [_, s] -> s end) + _ -> [] + end + end + + defp has_ui_app_fonts?(plist), do: String.contains?(plist, "<key>UIAppFonts</key>") + + defp replace_ui_app_fonts(plist, array) do + # Function replacement (not a string) so `\N` in a font basename is not + # interpreted as a backreference — see merge_ui_app_fonts/2 for details. + String.replace( + plist, + ~r{<key>UIAppFonts</key>\s*<array>.*?</array>}s, + fn _matched -> "<key>UIAppFonts</key>\n#{array}" end + ) + end + + defp render_ui_app_fonts_array(basenames) do + items = Enum.map_join(basenames, "\n", &"\t\t<string>#{&1}</string>") + "\t<array>\n#{items}\n\t</array>" + end +end diff --git a/lib/mob_dev/plugin/audit.ex b/lib/mob_dev/plugin/audit.ex new file mode 100644 index 0000000..97bb09b --- /dev/null +++ b/lib/mob_dev/plugin/audit.ex @@ -0,0 +1,474 @@ +defmodule MobDev.Plugin.Audit do + @moduledoc """ + Static-analysis pass over a plugin's source tree. + + Behind `mix mob.audit_plugins` (see `MOB_PLUGIN_SECURITY.md`). Walks the + plugin's Elixir sources (`lib/**/*.ex{,s}`) with an AST scanner and its C + NIF sources (`priv/native/**/*.{c,h}`) with a tighter regex pass, flagging + patterns Mob considers risky: + + - `Code.eval_string/1,2,3` and `Code.compile_string/1,2` + — the prime arbitrary-code-execution vector. Severity `:high`. + - `String.to_atom/1` with a non-literal argument — atom-exhaustion risk. + A literal `String.to_atom("foo")` is fine; flagged only when the argument + is a variable or expression. Severity `:medium`. + - `:erlang.binary_to_term/1` — unbounded deserialization. The arity-2 form + `:erlang.binary_to_term(bin, [:safe])` does not fire (caller has opted + into the bounded variant). Severity `:high`. + - `Application.put_env/3,4` targeting `:mob` — would let a plugin silently + retarget plugin activations, host_config keys, etc. `get_env` is fine. + Severity `:medium`. + - File / network I/O escape hatches outside the plugin's own `priv/`: + `File.write{,!}/1,2`, `File.rm_rf{,!}/1`, `File.cp{,!}/2`, `:os.cmd/1`, + `System.cmd/2,3`, `Path.expand/1` with a `~` literal. We don't try to + prove what they touch — flag and let the plugin author either remove or + justify. Severity `:medium`. + - In C NIF sources: calls to `system(3)`, `popen(3)`, `execve(2)`, and raw + `socket(2)` creation. Regex over comment-stripped text. Severities + `:medium` (`socket`) and `:high` (`system`/`popen`/`execve`). + + Swift / Kotlin sources are out of scope for this commit — the spec + (`MOB_PLUGIN_SECURITY.md`) calls for proper parsers there, and the task + surfaces a "not yet audited" summary line instead of guessing with regex. + + All checks are pure given file inputs; the only I/O is reading source + files off disk. + """ + + @type severity :: :high | :medium | :low + + @type finding :: %{ + severity: severity(), + rule: atom(), + plugin: atom() | nil, + file: String.t(), + line: pos_integer() | nil, + snippet: String.t(), + hint: String.t() + } + + @type report :: %{ + plugin: atom() | nil, + findings: [finding()], + summary: %{high: non_neg_integer(), medium: non_neg_integer(), low: non_neg_integer()}, + kotlin_or_swift_skipped: boolean() + } + + @elixir_glob "**/*.{ex,exs}" + @c_glob "**/*.{c,h}" + + @doc """ + Audits a single plugin checked out at `plugin_dir`. + + `manifest` may be `nil` (a tier-0 plugin); only `:name` is read from it for + attribution in the resulting findings. Returns a `t:report/0` map. + """ + @spec audit_plugin(Path.t(), map() | nil) :: report() + def audit_plugin(plugin_dir, manifest) do + name = (manifest && Map.get(manifest, :name)) || nil + + elixir_findings = + plugin_dir + |> elixir_sources() + |> Enum.flat_map(&audit_elixir_file(&1, plugin_dir, name)) + + c_findings = + plugin_dir + |> c_sources() + |> Enum.flat_map(&audit_c_file(&1, plugin_dir, name)) + + findings = + (elixir_findings ++ c_findings) + |> Enum.sort_by(fn f -> {severity_order(f.severity), f.file, f.line || 0} end) + + %{ + plugin: name, + findings: findings, + summary: tally(findings), + kotlin_or_swift_skipped: has_kotlin_or_swift?(plugin_dir) + } + end + + @doc """ + Audits a single Elixir source file in isolation. Public for testing. + """ + @spec audit_elixir_file(Path.t(), Path.t(), atom() | nil) :: [finding()] + def audit_elixir_file(path, plugin_dir, plugin_name) do + rel = relative_to(path, plugin_dir) + + case read_quoted(path) do + {:ok, ast} -> + scan_elixir_ast(ast, rel, plugin_name) + + {:error, reason} -> + [ + %{ + severity: :low, + rule: :unparseable_source, + plugin: plugin_name, + file: rel, + line: nil, + snippet: "", + hint: "could not parse as Elixir: #{reason}" + } + ] + end + end + + @doc """ + Audits a single C source file in isolation. Public for testing. + """ + @spec audit_c_file(Path.t(), Path.t(), atom() | nil) :: [finding()] + def audit_c_file(path, plugin_dir, plugin_name) do + rel = relative_to(path, plugin_dir) + + case File.read(path) do + {:ok, source} -> + source + |> strip_c_comments() + |> scan_c_source(rel, plugin_name) + + {:error, reason} -> + [ + %{ + severity: :low, + rule: :unreadable_source, + plugin: plugin_name, + file: rel, + line: nil, + snippet: "", + hint: "could not read: #{:file.format_error(reason)}" + } + ] + end + end + + @doc """ + Tally findings into `%{high: n, medium: n, low: n}`. Public for testing. + """ + @spec tally([finding()]) :: %{ + high: non_neg_integer(), + medium: non_neg_integer(), + low: non_neg_integer() + } + def tally(findings) do + Enum.reduce(findings, %{high: 0, medium: 0, low: 0}, fn f, acc -> + Map.update!(acc, f.severity, &(&1 + 1)) + end) + end + + @doc """ + Computes the exit code for one or more reports. + + * 0 — every finding is `:low` (or there are none). + * 1 — at least one `:medium`, no `:high`. + * 2 — at least one `:high`. + + When `accept_medium?` is true, mediums no longer count toward exit code 1 + (highs still produce 2). Public for testing. + """ + @spec exit_code([report()], boolean()) :: 0 | 1 | 2 + def exit_code(reports, accept_medium? \\ false) do + totals = + Enum.reduce(reports, %{high: 0, medium: 0, low: 0}, fn r, acc -> + Map.merge(acc, r.summary, fn _k, a, b -> a + b end) + end) + + cond do + totals.high > 0 -> 2 + not accept_medium? and totals.medium > 0 -> 1 + true -> 0 + end + end + + # ── source discovery ────────────────────────────────────────────────────── + + defp elixir_sources(plugin_dir) do + plugin_dir + |> Path.join("lib") + |> Path.join(@elixir_glob) + |> Path.wildcard() + end + + defp c_sources(plugin_dir) do + plugin_dir + |> Path.join("priv/native") + |> Path.join(@c_glob) + |> Path.wildcard() + end + + defp has_kotlin_or_swift?(plugin_dir) do + Path.wildcard(Path.join(plugin_dir, "priv/native/**/*.{kt,swift}")) != [] + end + + defp relative_to(path, base) do + path + |> Path.relative_to(base) + |> to_string() + end + + # ── Elixir AST scanner ──────────────────────────────────────────────────── + + defp read_quoted(path) do + case File.read(path) do + {:ok, source} -> + try do + {:ok, Code.string_to_quoted!(source, columns: true, file: path)} + rescue + e -> {:error, Exception.message(e)} + end + + {:error, reason} -> + {:error, :file.format_error(reason)} + end + end + + defp scan_elixir_ast(ast, file, plugin) do + {_, acc} = + Macro.prewalk(ast, [], fn node, acc -> + new = check_elixir_node(node, file, plugin) + {node, new ++ acc} + end) + + Enum.reverse(acc) + end + + # Code.eval_string/1,2,3 and Code.compile_string/1,2 — :high + defp check_elixir_node({{:., _, [{:__aliases__, _, [:Code]}, fun]}, meta, args}, file, plugin) + when fun in [:eval_string, :compile_string] and is_list(args) do + [ + finding( + :high, + :code_eval, + plugin, + file, + line(meta), + "Code.#{fun}(...)", + "Arbitrary code execution. Remove this call or justify it in the manifest." + ) + ] + end + + # :erlang.binary_to_term/1 — :high (arity-2 with [:safe] is fine) + defp check_elixir_node({{:., _, [:erlang, :binary_to_term]}, meta, args}, file, plugin) + when length(args) == 1 do + [ + finding( + :high, + :unsafe_deserialization, + plugin, + file, + line(meta), + ":erlang.binary_to_term/1", + "Use :erlang.binary_to_term(bin, [:safe]) to bound deserialization." + ) + ] + end + + # String.to_atom/1 with non-literal argument — :medium + defp check_elixir_node( + {{:., _, [{:__aliases__, _, [:String]}, :to_atom]}, meta, [arg]}, + file, + plugin + ) do + if literal_binary?(arg) do + [] + else + [ + finding( + :medium, + :unbounded_atom, + plugin, + file, + line(meta), + "String.to_atom(<non-literal>)", + "Use String.to_existing_atom/1 or an explicit whitelist to avoid atom-table exhaustion." + ) + ] + end + end + + # Application.put_env(:mob, ...) — :medium + defp check_elixir_node( + {{:., _, [{:__aliases__, _, [:Application]}, :put_env]}, meta, [app | _]} = _node, + file, + plugin + ) do + if app == :mob do + [ + finding( + :medium, + :mob_env_mutation, + plugin, + file, + line(meta), + "Application.put_env(:mob, ...)", + "A plugin should not mutate the :mob app's env; route changes through manifest declarations." + ) + ] + else + [] + end + end + + # File.write/write!/rm_rf/rm_rf!/cp/cp! — :medium + defp check_elixir_node({{:., _, [{:__aliases__, _, [:File]}, fun]}, meta, _args}, file, plugin) + when fun in [:write, :write!, :rm_rf, :rm_rf!, :cp, :cp!] do + [ + finding( + :medium, + :file_io, + plugin, + file, + line(meta), + "File.#{fun}(...)", + "File-system mutation outside the plugin's priv/. Remove or justify in the manifest." + ) + ] + end + + # :os.cmd/1 — :medium + defp check_elixir_node({{:., _, [:os, :cmd]}, meta, _args}, file, plugin) do + [ + finding( + :medium, + :process_spawn, + plugin, + file, + line(meta), + ":os.cmd(...)", + "Shell-out from a plugin is highly suspect. Remove or justify." + ) + ] + end + + # System.cmd/2,3 — :medium + defp check_elixir_node( + {{:., _, [{:__aliases__, _, [:System]}, :cmd]}, meta, _args}, + file, + plugin + ) do + [ + finding( + :medium, + :process_spawn, + plugin, + file, + line(meta), + "System.cmd(...)", + "Process spawn from a plugin is highly suspect. Remove or justify." + ) + ] + end + + # Path.expand("~"...) — :medium (home-directory escape) + defp check_elixir_node( + {{:., _, [{:__aliases__, _, [:Path]}, :expand]}, meta, [arg | _]}, + file, + plugin + ) do + if home_string?(arg) do + [ + finding( + :medium, + :home_escape, + plugin, + file, + line(meta), + "Path.expand(\"~...\")", + "Reaching outside the app sandbox via $HOME. Remove or justify." + ) + ] + else + [] + end + end + + defp check_elixir_node(_node, _file, _plugin), do: [] + + defp literal_binary?(arg) when is_binary(arg), do: true + defp literal_binary?(_), do: false + + defp home_string?(s) when is_binary(s), do: String.starts_with?(s, "~") + defp home_string?(_), do: false + + defp line(meta) when is_list(meta), do: Keyword.get(meta, :line) + defp line(_), do: nil + + # ── C source scanner ────────────────────────────────────────────────────── + + # Comment-strip + line-based scan. Regex on stripped source avoids matching + # the words inside `// system(...)` documentation comments — common in NIF + # stubs that explain why they *don't* call system(3). + defp scan_c_source(stripped, file, plugin) do + stripped + |> String.split("\n") + |> Enum.with_index(1) + |> Enum.flat_map(fn {line_text, ln} -> check_c_line(line_text, ln, file, plugin) end) + end + + @c_rules [ + {:high, :process_spawn, ~r/\bsystem\s*\(/, "system(3) call", + "Process spawn from a NIF is highly suspect."}, + {:high, :process_spawn, ~r/\bpopen\s*\(/, "popen(3) call", + "Process spawn from a NIF is highly suspect."}, + {:high, :process_spawn, ~r/\bexecve\s*\(/, "execve(2) call", + "Process spawn from a NIF is highly suspect."}, + {:medium, :raw_socket, ~r/\bsocket\s*\(/, "socket(2) call", + "Raw network access from a NIF should be declared and justified."} + ] + + defp check_c_line(line_text, ln, file, plugin) do + @c_rules + |> Enum.filter(fn {_sev, _rule, regex, _snippet, _hint} -> + Regex.match?(regex, line_text) + end) + |> Enum.map(fn {sev, rule, _regex, snippet, hint} -> + finding(sev, rule, plugin, file, ln, snippet, hint) + end) + end + + # Strip `/* ... */` and `// ...` comments. Keeps line counts intact by + # replacing the comment body with spaces and preserving newlines. + defp strip_c_comments(source) do + source + |> strip_block_comments() + |> strip_line_comments() + end + + defp strip_block_comments(source) do + Regex.replace(~r{/\*.*?\*/}s, source, fn match -> + match + |> String.graphemes() + |> Enum.map(fn + "\n" -> "\n" + _ -> " " + end) + |> Enum.join() + end) + end + + defp strip_line_comments(source) do + Regex.replace(~r{//[^\n]*}, source, fn match -> + String.duplicate(" ", String.length(match)) + end) + end + + # ── helpers ─────────────────────────────────────────────────────────────── + + defp finding(severity, rule, plugin, file, line, snippet, hint) do + %{ + severity: severity, + rule: rule, + plugin: plugin, + file: file, + line: line, + snippet: snippet, + hint: hint + } + end + + defp severity_order(:high), do: 0 + defp severity_order(:medium), do: 1 + defp severity_order(:low), do: 2 +end diff --git a/lib/mob_dev/plugin/cpp_archive.ex b/lib/mob_dev/plugin/cpp_archive.ex new file mode 100644 index 0000000..e2a1f44 --- /dev/null +++ b/lib/mob_dev/plugin/cpp_archive.ex @@ -0,0 +1,303 @@ +defmodule MobDev.Plugin.CppArchive do + @moduledoc """ + Cross-compiles a plugin's `lang: :cpp_archive` NIF — a set of C++ sources — + into `lib<module>.a` for one target ABI, and verifies the NIF-init symbol is + present. The archive is then static-linked into the app's single signed native + binary (same slot as `crypto.a` / `libnx_eigen.a`). + + This is the generic, manifest-driven generalization of `MobDev.NxEigenNif`: + the sources, include dirs, CXXFLAGS, and expected symbol all come from the + plugin's `Merge.static_archives/2` spec rather than being hardcoded. It exists + because the single-source `:c`/`:zig` plugin NIF path (compiled inline by + `build.zig`) can't express a C++ build with external headers (Eigen/Fine), + RTTI/exceptions, a per-arch hardening flag set, and an archive output. + + ## Why static-link (same as every on-device NIF) + + * **Android.** A separately-`dlopen`'d `.so` inherits `RTLD_LOCAL`, hiding + the BEAM's `enif_*` symbols → `on_load` fails. Static-linking lets the + BEAM resolve the init symbol via `dlsym(RTLD_DEFAULT)` on the app binary. + * **iOS.** The App Store forbids loading unsigned dylibs / `dlopen`; the NIF + must already be in the signed binary. + + ## What the builder forces vs. what the plugin controls + + The builder forces only `-fPIC` (a static lib linked into a shared object must + be position-independent) and the compile/output flags (`-c -o`). Everything + else — C++ standard, optimization, visibility, exceptions, the + `-DSTATIC_ERLANG_NIF_LIBNAME=…` that fixes the emitted init symbol, and any + Android hardening — is the plugin's via `:cxxflags` / `:cxxflags_android` / + `:cxxflags_ios`, so a plugin author keeps full control of its own ABI. + """ + + alias MobDev.NdkVersion + alias MobDev.Release.{Errors, Shell} + + @android_api 28 + @ios_min_version "17.0" + + @forced_cxxflags ["-fPIC"] + + @doc "All target ABIs a cpp_archive can be built for." + @spec targets() :: [atom()] + def targets, do: [:android_arm64, :android_arm32, :ios_sim, :ios_device] + + # ── Pure surface (unit-tested) ─────────────────────────────────────────── + + # Target-intrinsic ABI flags the builder always supplies (like -fPIC): the + # armeabi-v7a Android ABI mandates these for correct code-gen, so they belong + # to the target, not the plugin — a plugin author shouldn't have to know them. + defp target_abi_cxxflags(:android_arm32), + do: ["-march=armv7-a", "-mfloat-abi=softfp", "-mthumb"] + + defp target_abi_cxxflags(_), do: [] + + @doc """ + Assemble the full CXXFLAGS for one target: forced `-fPIC` + the target's + intrinsic ABI flags (e.g. armv7 flags for android_arm32), then the plugin's + base `:cxxflags`, then the target's platform-specific flags + (`:cxxflags_android` / `:cxxflags_ios`), then `-I` for each resolved include + dir (order preserved). Pure — silent flag drops are the regression class this + whole module guards against, so it's directly testable. + """ + @spec cxxflags(map(), atom(), [Path.t()]) :: [String.t()] + def cxxflags(spec, target_id, includes) when is_map(spec) and is_list(includes) do + platform_flags = + case platform_of(target_id) do + :android -> List.wrap(spec[:cxxflags_android]) + :ios -> List.wrap(spec[:cxxflags_ios]) + end + + @forced_cxxflags ++ + target_abi_cxxflags(target_id) ++ + List.wrap(spec[:cxxflags]) ++ + platform_flags ++ + Enum.map(includes, &"-I#{&1}") + end + + @doc """ + Resolve a spec's `:sources`/`:includes` (a mix of absolute strings and + `{:dep, name, subpath}` tokens left by `Merge.static_archives/2`) to absolute + paths, resolving dep tokens against `deps_path`. Pure. + """ + @spec resolve_deps([Path.t() | {:dep, atom(), String.t()}], Path.t()) :: [Path.t()] + def resolve_deps(entries, deps_path) when is_list(entries) do + for entry <- entries do + case entry do + {:dep, name, sub} -> Path.join([deps_path, Atom.to_string(name), sub]) + bin when is_binary(bin) -> bin + end + end + end + + @doc "Archive filename for a NIF module — `lib<module>.a`." + @spec archive_name(atom()) :: String.t() + def archive_name(module) when is_atom(module), do: "lib#{module}.a" + + @doc """ + Parse `nm` output and confirm the expected init symbol is exported (`T`). + Mirrors `MobDev.NxEigenNif.check_symbol_present/3`. Pure. + """ + @spec check_symbol_present(binary(), String.t(), Path.t()) :: :ok | Errors.t() + def check_symbol_present(nm_output, expected_symbol, archive) when is_binary(nm_output) do + pattern = ~r/^\s*[0-9a-f]+ T #{Regex.escape(expected_symbol)}\s*$/m + + if Regex.match?(pattern, nm_output) do + :ok + else + Errors.precondition( + "expected `T #{expected_symbol}` not found in #{archive} — the " <> + "cpp_archive plugin's sources didn't emit it. Check the source's " <> + "ERL_NIF_INIT / -DSTATIC_ERLANG_NIF_LIBNAME and :nm_symbol agree." + ) + end + end + + # ── Build entrypoint ────────────────────────────────────────────────────── + + @doc """ + Compile + archive + verify a cpp_archive `spec` (from `Merge.static_archives/2`) + for one `target_id`. Returns `{:ok, %{module:, archive:, objects:}}` or a + tagged error. + + Options: + * `:out_dir` — where the archive + per-arch objs land. **Required.** + * `:erts_include` — per-target `erts-VSN/include/` dir (carries `erl_nif.h`). + **Required.** + * `:deps_path` — for resolving `{:dep, …}` include tokens (defaults to + `Mix.Project.deps_path/0`). + * `:ndk_root` — Android NDK root (Android targets; default derived). + """ + @spec build(map(), atom(), keyword()) :: {:ok, map()} | Errors.t() + def build(spec, target_id, opts \\ []) + when is_map(spec) and target_id in [:android_arm64, :android_arm32, :ios_sim, :ios_device] do + shell = Shell.impl() + + with {:ok, out_dir} <- require_opt(opts, :out_dir), + {:ok, erts_inc} <- require_opt(opts, :erts_include), + {:ok, _} <- require_field(spec, :nm_symbol), + {:ok, _} <- require_sources(spec) do + deps_path = opts[:deps_path] || Mix.Project.deps_path() + + includes = + resolve_deps(spec.includes, deps_path) ++ [erts_inc, Path.join(erts_inc, "internal")] + + sources = resolve_deps(spec.sources, deps_path) + arch_dir = arch_dir(target_id) + obj_dir = Path.join([out_dir, "obj", arch_dir]) + archive = Path.join(out_dir, archive_name(spec.module)) + flags = cxxflags(spec, target_id, includes) + tools = tools(target_id, opts) + + with :ok <- precheck(sources, target_id, shell, opts), + :ok <- shell.mkdir_p(obj_dir), + :ok <- shell.mkdir_p(out_dir), + {:ok, objects} <- compile_sources(shell, tools, flags, sources, obj_dir), + :ok <- shell.rm_f(archive), + {:ok, _} <- shell.cmd(tools.ar ++ ["rcs", archive | objects], []), + {:ok, _} <- shell.cmd(tools.ranlib ++ [archive], []), + {:ok, nm_out} <- shell.cmd(tools.nm ++ [archive], []), + :ok <- check_symbol_present(nm_out, nm_symbol(target_id, spec.nm_symbol), archive) do + {:ok, %{module: spec.module, archive: archive, objects: objects}} + end + end + end + + # Mach-O nm prefixes symbols with an underscore; ELF (Android) doesn't. + defp nm_symbol(target_id, sym) do + case platform_of(target_id) do + :ios -> "_" <> sym + :android -> sym + end + end + + defp compile_sources(shell, tools, flags, sources, obj_dir) do + sources + |> Enum.reduce_while({:ok, []}, fn src, {:ok, acc} -> + obj = Path.join(obj_dir, Path.basename(src, Path.extname(src)) <> ".o") + + case shell.cmd(tools.cxx ++ flags ++ ["-c", "-o", obj, src], []) do + {:ok, _} -> {:cont, {:ok, [obj | acc]}} + err -> {:halt, err} + end + end) + |> case do + {:ok, objs} -> {:ok, Enum.reverse(objs)} + err -> err + end + end + + defp precheck(sources, target_id, shell, opts) do + missing = Enum.reject(sources, &shell.file?/1) + + cond do + missing != [] -> + Errors.precondition("cpp_archive sources missing: #{Enum.join(missing, ", ")}") + + platform_of(target_id) == :android -> + android_precheck(opts) + + true -> + :ok + end + end + + defp android_precheck(opts) do + ndk_root = opts[:ndk_root] || default_ndk_root() + version = NdkVersion.effective() + + cond do + not NdkVersion.installed?(version) -> + Errors.precondition( + "Android NDK #{version} not installed — #{NdkVersion.install_command()}" + ) + + not File.dir?(Path.join([ndk_root, "toolchains/llvm/prebuilt"])) -> + Errors.precondition("NDK toolchain not found at #{ndk_root}") + + true -> + :ok + end + end + + defp require_opt(opts, key) do + case Keyword.fetch(opts, key) do + {:ok, v} when is_binary(v) and v != "" -> {:ok, v} + _ -> Errors.precondition("MobDev.Plugin.CppArchive.build/3 requires #{inspect(key)}") + end + end + + defp require_field(spec, key) do + case Map.get(spec, key) do + v when is_binary(v) and v != "" -> {:ok, v} + _ -> Errors.precondition("cpp_archive spec requires #{inspect(key)}") + end + end + + defp require_sources(%{sources: [_ | _] = s}), do: {:ok, s} + defp require_sources(_), do: Errors.precondition("cpp_archive spec requires non-empty :sources") + + # ── Target plumbing (same toolchain layout as MobDev.NxEigenNif) ─────────── + + defp platform_of(:android_arm64), do: :android + defp platform_of(:android_arm32), do: :android + defp platform_of(:ios_sim), do: :ios + defp platform_of(:ios_device), do: :ios + + defp arch_dir(:android_arm64), do: "aarch64-unknown-linux-android" + defp arch_dir(:android_arm32), do: "arm-unknown-linux-androideabi" + defp arch_dir(:ios_sim), do: "aarch64-apple-iossimulator" + defp arch_dir(:ios_device), do: "aarch64-apple-ios" + + defp tools(target_id, opts) when target_id in [:android_arm64, :android_arm32] do + bin = android_toolchain_bin(opts) + + %{ + cxx: [Path.join(bin, android_cxx_name(target_id))], + ar: [Path.join(bin, "llvm-ar")], + ranlib: [Path.join(bin, "llvm-ranlib")], + nm: [Path.join(bin, "llvm-nm")] + } + end + + defp tools(target_id, _opts) when target_id in [:ios_sim, :ios_device] do + sdk = ios_sdk_name(target_id) + + %{ + cxx: [ + "xcrun", + "-sdk", + sdk, + "clang++", + "-arch", + "arm64", + ios_min_flag(target_id), + "-stdlib=libc++" + ], + ar: ["xcrun", "-sdk", sdk, "ar"], + ranlib: ["xcrun", "-sdk", sdk, "ranlib"], + nm: ["xcrun", "-sdk", sdk, "nm"] + } + end + + defp android_cxx_name(:android_arm64), do: "aarch64-linux-android#{@android_api}-clang++" + defp android_cxx_name(:android_arm32), do: "armv7a-linux-androideabi#{@android_api}-clang++" + + defp ios_sdk_name(:ios_sim), do: "iphonesimulator" + defp ios_sdk_name(:ios_device), do: "iphoneos" + + defp ios_min_flag(:ios_sim), do: "-mios-simulator-version-min=#{@ios_min_version}" + defp ios_min_flag(:ios_device), do: "-miphoneos-version-min=#{@ios_min_version}" + + # Honors ANDROID_HOME / ANDROID_SDK_ROOT via the shared NdkVersion helper — + # do NOT re-derive the SDK path here (MOB-89: this used to hardcode + # ~/Library/Android/sdk, breaking builds where the NDK lives elsewhere). + defp default_ndk_root, do: NdkVersion.root() + + defp android_toolchain_bin(opts) do + case opts[:ndk_root] do + nil -> NdkVersion.toolchain_bin() + root -> Path.join([root, "toolchains", "llvm", "prebuilt", NdkVersion.host(), "bin"]) + end + end +end diff --git a/lib/mob_dev/plugin/crypto.ex b/lib/mob_dev/plugin/crypto.ex new file mode 100644 index 0000000..f3df939 --- /dev/null +++ b/lib/mob_dev/plugin/crypto.ex @@ -0,0 +1,115 @@ +defmodule MobDev.Plugin.Crypto do + @moduledoc """ + Ed25519 sign/verify primitives + canonical-term encoding for plugin signing. + + The signing scheme (see `MOB_PLUGIN_SECURITY.md`, Phase 2): + + - Plugin authors generate a per-plugin Ed25519 keypair; the public key + ships in `priv/mob_plugin.pub` and the manifest+sources are signed + with the private key into `priv/mob_plugin.sig`. + - Hosts verify the signature against the public key at activation time + and refuse to build untrusted/unsigned plugins. + + This module is the single place the Ed25519 + canonical-encoding + decisions are encoded. Keep it crypto-only — workflow (sign/verify + orchestration, fingerprint storage) lives in `Sign`, `Verify`, and + `TrustStore`. + """ + + @typedoc "Raw 32-byte Ed25519 private key." + @type priv_key :: <<_::256>> + + @typedoc "Raw 32-byte Ed25519 public key." + @type pub_key :: <<_::256>> + + @typedoc "Raw 64-byte Ed25519 signature." + @type signature :: <<_::512>> + + @typedoc "Host-visible trust identifier; base64-encoded SHA-256 of the public key." + @type fingerprint :: String.t() + + @doc """ + Generates a fresh Ed25519 keypair. + + Returns `{priv_bin, pub_bin}` as raw 32-byte binaries. The format + matches what `:crypto.sign/4` and `:crypto.verify/5` accept directly + with the `:eddsa`/`:ed25519` options. + """ + @spec generate_keypair() :: {priv_key(), pub_key()} + def generate_keypair do + {pub, priv} = :crypto.generate_key(:eddsa, :ed25519) + {priv, pub} + end + + @doc """ + Signs `payload_term` with `priv_bin`. + + The term is canonically encoded via `canonical_encode/1` before signing, + so the signature is over the deterministic binary form — the same map + with the same contents produces the same signature regardless of map + insertion order. + """ + @spec sign(term(), priv_key()) :: signature() + def sign(payload_term, priv_bin) when is_binary(priv_bin) do + payload = canonical_encode(payload_term) + :crypto.sign(:eddsa, :sha512, payload, [priv_bin, :ed25519]) + end + + @doc """ + Verifies `signature_bin` against `payload_term` and `pub_bin`. + + Returns `:ok` on valid signature, `{:error, :invalid_signature}` + otherwise. Mirrors `sign/2` — the same canonical encoding is applied + before verifying. + + A wrong-*size* signature or public key (not a 64-byte sig / 32-byte + Ed25519 key) is treated as an invalid signature rather than crashing: + `:crypto.verify/5` raises `:badarg` from OpenSSL when handed an + ill-sized key, and these bytes originate from attacker-controlled + plugin files (`priv/mob_plugin.sig` / `priv/mob_plugin.pub`). The + documented contract (`:ok | {:error, :invalid_signature}`) must hold + for every binary input, so the raise is caught here. + """ + @spec verify(term(), signature(), pub_key()) :: :ok | {:error, :invalid_signature} + def verify(payload_term, signature_bin, pub_bin) + when is_binary(signature_bin) and is_binary(pub_bin) do + payload = canonical_encode(payload_term) + + if :crypto.verify(:eddsa, :sha512, payload, signature_bin, [pub_bin, :ed25519]) do + :ok + else + {:error, :invalid_signature} + end + rescue + ArgumentError -> {:error, :invalid_signature} + ErlangError -> {:error, :invalid_signature} + end + + @doc """ + Computes the host-visible trust identifier for a public key. + + Format: `"ed25519:<base64>"` where `<base64>` is the standard base64 + encoding (with `=` padding) of the SHA-256 digest of the raw 32-byte + public key. Suitable for storing in `mob.exs` under + `:trusted_plugins` and for comparing two keys for equality without + printing the key itself. + """ + @spec fingerprint(pub_key()) :: fingerprint() + def fingerprint(pub_bin) when is_binary(pub_bin) do + digest = :crypto.hash(:sha256, pub_bin) + "ed25519:" <> Base.encode64(digest) + end + + @doc """ + Canonical encoding of an arbitrary Erlang term to a binary. + + Uses `:erlang.term_to_binary/2` with `:deterministic` so the same logical + value produces the same bytes regardless of map iteration order. + Centralised here so the determinism flag is in one place: `sign/2`, + `verify/3`, and any future hash-of-payload helper all agree. + """ + @spec canonical_encode(term()) :: binary() + def canonical_encode(term) do + :erlang.term_to_binary(term, [:deterministic, minor_version: 2]) + end +end diff --git a/lib/mob_dev/plugin/ios_bootstrap.ex b/lib/mob_dev/plugin/ios_bootstrap.ex new file mode 100644 index 0000000..1cbbd10 --- /dev/null +++ b/lib/mob_dev/plugin/ios_bootstrap.ex @@ -0,0 +1,99 @@ +defmodule MobDev.Plugin.IOSBootstrap do + @moduledoc """ + Code-generates the iOS plugin bootstrap Swift source from the activated + plugins' `:ui_components` declarations. + + The generated file defines one C-callable function, `mob_register_plugins`, + that registers a factory closure with `MobNativeViewRegistry.shared` for + each component the host should expose. The host's `AppDelegate` calls + `mob_register_plugins()` once, before `mob_init_ui()`, and from that point + every component the BEAM mounts under a plugin's `view_module` key resolves + to the plugin's SwiftUI view. + + Plain function, no state — call `swift_source/1` with the same shape + `MobDev.Plugin.activated/0` returns (`[{plugin_dir, manifest}]`) and you + get back the Swift source string ready to write to disk. + + Pre-codegen, each plugin shipped its own `@objc class MobXxxPlugin { @objc + class func mob_register() }` and the host hand-wrote one + `[MobXxxPlugin mob_register];` line per plugin into AppDelegate.m. That's + the smoke-test pattern from the 2026-05-28 iOS plugin bring-up. This + module replaces both halves: plugins drop the @objc wrapper and just + ship the SwiftUI struct, and the host always calls a single generated + entry point regardless of how many plugins it has activated. + + The mapping from a `ui_components` entry to a `register` call comes from + two manifest fields: + + * `ui_components.ios.view_module` — the registry key (string match against + `MobNode.nativeViewModule`); the BEAM derives this from + `Mob.Component.module_name/1` so it must agree with the manifest. + * `ui_components.ios.swift_struct` — the Swift struct name to instantiate + (`StructName(props: props)`). Two plugins are free to register + different `view_module` keys that resolve to the same struct, and the + struct name need not be derivable from the registry key. + + A component without `:swift_struct` is silently skipped (the + `MobDev.Plugin.Validator.validate_plugin/3` step is where authors get told + to add the field); the validator's job is to surface this before build + time. The generator stays purely transform-shaped so the build pipeline + never has to think about missing manifest fields. + """ + + @doc """ + Returns the full Swift source for the bootstrap file. + + `plugins` is the activated-plugin list — `[{plugin_dir, manifest}]` — + same shape `MobDev.Plugin.activated/0` returns. Tier-0 (nil-manifest) + plugins contribute nothing. Components missing `:swift_struct` are + silently dropped: the validator is what nags about that. + + The output is stable: components are emitted in the order + `MobDev.Plugin.Merge.ui_components/1` lists them (i.e. activation order, + then declaration order within a manifest). Stable output keeps the build + cache hit-rate high. + """ + @spec swift_source([MobDev.Plugin.Merge.plugin()]) :: String.t() + def swift_source(plugins) do + components = MobDev.Plugin.Merge.ui_components(plugins) + body = components |> Enum.flat_map(®ister_lines/1) |> Enum.join("\n") + + header() <> + "@_cdecl(\"mob_register_plugins\")\n" <> + "public func mob_register_plugins() {\n" <> body <> "\n}\n" + end + + defp header do + """ + // Auto-generated by MobDev.Plugin.IOSBootstrap. + // + // Registers the activated plugins' SwiftUI views with + // MobNativeViewRegistry.shared, one entry per ui_components manifest + // declaration. AppDelegate.m calls mob_register_plugins() once before + // mob_init_ui() so the registry is populated by the time the BEAM + // mounts any plugin-contributed node. + // + // Regenerated on every `mix mob.deploy --native`. Do not edit by hand. + + import Foundation + import SwiftUI + + """ + end + + # One component maps to zero or one register lines: a missing :swift_struct + # means the component can't be registered, so we drop it. The validator + # surfaces this before the build runs. + defp register_lines(component) do + with view_module when is_binary(view_module) <- get_in(component, [:ios, :view_module]), + swift_struct when is_binary(swift_struct) <- get_in(component, [:ios, :swift_struct]) do + [ + " MobNativeViewRegistry.shared.register(\"#{view_module}\") { props, _send in", + " AnyView(#{swift_struct}(props: props))", + " }" + ] + else + _ -> [] + end + end +end diff --git a/lib/mob_dev/plugin/managed_block.ex b/lib/mob_dev/plugin/managed_block.ex new file mode 100644 index 0000000..01c735e --- /dev/null +++ b/lib/mob_dev/plugin/managed_block.ex @@ -0,0 +1,150 @@ +defmodule MobDev.Plugin.ManagedBlock do + @moduledoc """ + Reversible insertion of plugin-contributed fragments into host-owned build + files (`AndroidManifest.xml`, `build.gradle`). + + The problem: plugin permissions, AndroidManifest `<application>` components, + and Gradle dependencies are spliced into files the host also hand-edits. A + plain "append if absent" merge can't be undone — when a plugin is removed its + lines linger (a dangling `<service>` whose class is gone, an orphan permission + / dep). Unlike the copied artifacts (`bridge_kt`, `res_files`) there's no + ledger to prune, because the target file is shared and hand-authored. + + The fix: fence each managed region with begin/end marker comments and + **regenerate the whole region every build** from the current plugin set. A + removed plugin simply isn't in the fresh region, so its lines disappear; + anything the host wrote outside the fence is never touched. Re-running with an + unchanged plugin set is idempotent. + + `upsert/4` is pure: `(content, markers, body, place) -> content'`. `markers` + is `{begin_line, end_line}` (the exact comment lines, comment syntax chosen by + the caller so it works for both XML and Gradle). `place` is + `(stripped_content, region) -> content'` — it inserts the freshly built region + at the file-specific anchor (using `insert_before/3` or `insert_before_index/3` + so the region occupies whole lines and `strip/2` reverses it exactly). An + empty `body` removes the region entirely. + + Idempotence rests on `insert_before*` placing the region as complete lines at + a line boundary and `strip/2` removing exactly those lines — so + `strip(place(x)) == x`, and re-running with an unchanged plugin set is a fixed + point (verified in the tests). + """ + + @type markers :: {String.t(), String.t()} + + @doc """ + Replace (or remove) the managed region delimited by `markers` in `content`. + + Strips any existing region first (so the build is the single source of truth + for it), then — if `body` is non-empty — rebuilds it as + `begin <> "\\n" <> body <> "\\n" <> end` and hands it to `place` to insert. + An empty `body` leaves the content with the region stripped (the removal case). + """ + @spec upsert(String.t(), markers(), String.t(), (String.t(), String.t() -> String.t())) :: + String.t() + def upsert(content, {begin_line, end_line} = markers, body, place) + when is_binary(content) and is_binary(body) and is_function(place, 2) do + stripped = strip(content, markers) + + if String.trim(body) == "" do + stripped + else + region = begin_line <> "\n" <> body <> "\n" <> end_line + place.(stripped, region) + end + end + + @doc """ + Remove every managed region delimited by `markers` (the full lines the begin + and end markers sit on, inclusive), or return `content` unchanged when no + well-formed region is present. + + Each region is identified as **the last BEGIN before the first END**, never + "first BEGIN → first END". That distinction matters: with a stray/duplicate + BEGIN (an interrupted write, a hand-edit, a bad merge-conflict resolution), + "first BEGIN → first END" would delete everything from the orphan through the + real region's END — silently eating host-authored lines in between. Anchoring + on the last BEGIN before the first END guarantees the removed span contains no + other marker, so only the region itself is deleted. Loops until no well-formed + region remains, so duplicates are all cleared. + """ + @spec strip(String.t(), markers()) :: String.t() + def strip(content, markers) when is_binary(content) do + case strip_one(content, markers) do + ^content -> content + shorter -> strip(shorter, markers) + end + end + + defp strip_one(content, {begin_line, end_line}) do + with {es, el} <- match(content, end_line), + {bs, _} <- last_match_before(content, begin_line, es) do + region_start = line_start(content, bs) + region_stop = line_stop(content, es + el) + + binary_part(content, 0, region_start) <> + binary_part(content, region_stop, byte_size(content) - region_stop) + else + _ -> content + end + end + + @doc """ + Insert `region` as whole lines immediately before the line containing the + first occurrence of `anchor` (returns `content` unchanged if `anchor` is + absent). Pairs with `strip/2` for an exact round-trip. + """ + @spec insert_before(String.t(), String.t(), String.t()) :: String.t() + def insert_before(content, anchor, region) when is_binary(content) and is_binary(anchor) do + case match(content, anchor) do + {pos, _} -> insert_before_index(content, pos, region) + nil -> content + end + end + + @doc """ + Insert `region` as whole lines immediately before the line containing byte + index `idx`. + """ + @spec insert_before_index(String.t(), non_neg_integer(), String.t()) :: String.t() + def insert_before_index(content, idx, region) when is_binary(content) and is_integer(idx) do + ls = line_start(content, idx) + + binary_part(content, 0, ls) <> + region <> "\n" <> binary_part(content, ls, byte_size(content) - ls) + end + + # First occurrence of `needle` (or :nomatch as a with-friendly falsy). + defp match(hay, needle) do + case :binary.match(hay, needle) do + {s, l} -> {s, l} + :nomatch -> nil + end + end + + # Last occurrence of `needle` strictly before byte index `limit` (nil if none). + defp last_match_before(hay, needle, limit) do + case :binary.matches(binary_part(hay, 0, limit), needle) do + [] -> nil + list -> List.last(list) + end + end + + # Index of the start of the line containing `pos` (char after the previous \n). + defp line_start(content, pos) do + case :binary.matches(binary_part(content, 0, pos), "\n") do + [] -> 0 + list -> (List.last(list) |> elem(0)) + 1 + end + end + + # Index just past the newline that ends the line containing `pos` (or EOF). + defp line_stop(content, pos) do + rest = binary_part(content, pos, byte_size(content) - pos) + + case :binary.match(rest, "\n") do + {s, l} -> pos + s + l + :nomatch -> byte_size(content) + end + end +end diff --git a/lib/mob_dev/plugin/manifest.ex b/lib/mob_dev/plugin/manifest.ex new file mode 100644 index 0000000..b44ee75 --- /dev/null +++ b/lib/mob_dev/plugin/manifest.ex @@ -0,0 +1,736 @@ +defmodule MobDev.Plugin.Manifest do + @moduledoc """ + Reads, validates, and classifies a plugin's `priv/mob_plugin.exs` manifest. + + The manifest is data, not code (see `MOB_PLUGINS.md`): a plain Elixir map + describing what a plugin contributes. This module is the single place that + turns that map into a validated, classified description — tier, hot-push + status, activation — that `mix mob.plugins` reports and the compile-time + merge will later consume. + + A tier-0 plugin has no manifest at all; `load/1` returns `{:ok, nil}` for + that case, and `tier(nil)` is `0`. + """ + + @manifest_path "priv/mob_plugin.exs" + + # Spec versions this mob_dev understands. Bumped when MOB_PLUGINS.md makes a + # breaking schema change; old plugins keep validating against old specs. + @supported_spec_versions [1, 2] + + @native_sections [ + :nifs, + :nifs_generator, + :android, + :ios, + :permissions, + :ui_components, + :ui_components_generator + ] + @screen_sections [:screens, :screens_generator, :migrations, :assets] + @subapp_sections [:lifecycle, :settings, :notifications] + @visual_sections [:ui_components, :ui_components_generator] + @tier1_sections [:nifs, :nifs_generator, :android, :ios, :permissions] + # Sections whose Elixir half can still hot-push even when the plugin has + # native code (the partial case). + @pushable_sections [:screens, :screens_generator, :lifecycle, :migrations] + + @doc """ + Loads the manifest for a plugin checked out at `plugin_dir`. + + Returns `{:ok, nil}` when there is no `priv/mob_plugin.exs` (a tier-0 + plugin), `{:ok, map}` when one is present and evaluates to a map, or + `{:error, reason}` when the file is unreadable or doesn't yield a map. + Does not validate field contents — call `validate/1` for that. + """ + @spec load(Path.t()) :: {:ok, map() | nil} | {:error, String.t()} + def load(plugin_dir) do + path = Path.join(plugin_dir, @manifest_path) + + if File.exists?(path) do + eval(path) + else + {:ok, nil} + end + end + + defp eval(path) do + case Code.eval_file(path) do + {map, _bindings} when is_map(map) -> + {:ok, map} + + {other, _bindings} -> + {:error, "#{path} must evaluate to a map, got: #{inspect(other)}"} + end + rescue + e -> {:error, "#{path} failed to evaluate: #{Exception.message(e)}"} + end + + @doc """ + Validates a manifest map against the spec's required top-level fields. + + Returns `{:ok, manifest}` or `{:error, reasons}` with a list of every + problem found (validation never stops at the first error). `nil` (no + manifest) is valid — a tier-0 plugin has nothing to validate. + """ + @spec validate(map() | nil) :: {:ok, map() | nil} | {:error, [String.t()]} + def validate(nil), do: {:ok, nil} + + def validate(manifest) when is_map(manifest) do + errors = + [] + |> check_name(manifest) + |> check_mob_version(manifest) + |> check_spec_version(manifest) + |> check_permissions(manifest) + |> check_android_manifest_snippets(manifest) + |> check_android_res_files(manifest) + |> check_nifs(manifest) + |> check_screens(manifest) + |> check_screens_generator(manifest) + |> check_migrations(manifest) + |> check_assets(manifest) + |> check_lifecycle(manifest) + |> check_settings(manifest) + |> check_notifications(manifest) + |> check_ui_components(manifest) + |> check_host_requirements(manifest) + + case errors do + [] -> {:ok, manifest} + errs -> {:error, Enum.reverse(errs)} + end + end + + def validate(other), + do: + {:error, ["manifest must be a map (the priv/mob_plugin.exs data), got: #{inspect(other)}"]} + + defp check_name(errors, %{name: name}) when is_atom(name) and not is_nil(name), do: errors + defp check_name(errors, _), do: [":name is required and must be an atom" | errors] + + defp check_mob_version(errors, %{mob_version: req}) when is_binary(req) do + case Version.parse_requirement(req) do + {:ok, _} -> errors + :error -> [":mob_version #{inspect(req)} is not a valid version requirement" | errors] + end + end + + defp check_mob_version(errors, _), + do: [ + ":mob_version is required and must be a version requirement string (e.g. \"~> 0.7\")" + | errors + ] + + defp check_spec_version(errors, %{plugin_spec_version: v}) when is_integer(v) do + if v in @supported_spec_versions do + errors + else + [ + "plugin_spec_version #{v} is not supported (this mob_dev knows #{inspect(@supported_spec_versions)})" + | errors + ] + end + end + + defp check_spec_version(errors, _), + do: [":plugin_spec_version is required and must be an integer" | errors] + + # `:permissions` is optional. When present it must be a list of maps, each with + # a `:capability` atom (the value `Mob.Permissions.request/2` accepts) and an + # optional `:ios` map carrying a `:handler` string (the cdecl symbol the + # plugin self-registers). Android needs nothing here — its provider is + # auto-discovered by interface at bootstrap (see the permission-registry ADR). + defp check_permissions(errors, %{permissions: perms}) when is_list(perms) do + perms + |> Enum.with_index() + |> Enum.reduce(errors, fn {entry, i}, acc -> check_permission_entry(acc, entry, i) end) + end + + defp check_permissions(errors, %{permissions: other}), + do: ["permissions must be a list, got: #{inspect(other)}" | errors] + + defp check_permissions(errors, _), do: errors + + defp check_permission_entry(errors, %{capability: cap} = entry, _i) when is_atom(cap) do + case entry[:ios] do + nil -> + errors + + %{handler: h} when is_binary(h) -> + errors + + %{} -> + ["permissions entry #{inspect(cap)}: ios must declare a :handler string" | errors] + + other -> + ["permissions entry #{inspect(cap)}: :ios must be a map, got: #{inspect(other)}" | errors] + end + end + + defp check_permission_entry(errors, %{} = entry, i), + do: ["permissions entry ##{i} requires a :capability atom, got: #{inspect(entry)}" | errors] + + defp check_permission_entry(errors, other, i), + do: ["permissions entry ##{i} must be a map, got: #{inspect(other)}" | errors] + + # `android.manifest_application_snippets` (optional) is a list of XML strings + # spliced into the app manifest's `<application>` block (a `<service>`, + # `<receiver>`, …). Each must be a non-empty string; content is the plugin + # author's responsibility (the native build inserts verbatim). + defp check_android_manifest_snippets(errors, manifest) do + case get_in(manifest, [:android, :manifest_application_snippets]) do + nil -> + errors + + list when is_list(list) -> + if Enum.all?(list, &(is_binary(&1) and &1 != "")), + do: errors, + else: [ + "android.manifest_application_snippets must be a list of non-empty XML strings" + | errors + ] + + other -> + [ + "android.manifest_application_snippets must be a list of XML strings, got: #{inspect(other)}" + | errors + ] + end + end + + # `android.res_files` (optional) is a list of plugin-relative paths copied into + # the app's `res/` tree. Each must be a non-empty string containing a `res` + # path segment (the build derives the `res/<type>/<file>` destination from it) + # and must NOT contain a `..` segment — otherwise the derived destination could + # escape the app `res/` dir and `File.cp!` would write plugin bytes anywhere on + # the build host (path traversal). The native build enforces containment again + # at copy time as defense in depth. + defp check_android_res_files(errors, manifest) do + case get_in(manifest, [:android, :res_files]) do + nil -> + errors + + list when is_list(list) -> + cond do + not Enum.all?(list, &(is_binary(&1) and &1 != "")) -> + ["android.res_files must be a list of non-empty path strings" | errors] + + Enum.any?(list, &(".." in Path.split(&1))) -> + [ + "android.res_files paths must not contain a \"..\" segment " <> + "(path traversal — the copy destination must stay under the app res/ dir)" + | errors + ] + + not Enum.all?(list, &("res" in Path.split(&1))) -> + [ + "android.res_files paths must contain a \"res\" segment (e.g. " <> + "priv/native/android/res/xml/foo.xml) so the build can place them" + | errors + ] + + true -> + errors + end + + other -> + ["android.res_files must be a list of path strings, got: #{inspect(other)}" | errors] + end + end + + # `:nifs` is optional. When present, each entry's optional `:platform` (used by + # cross-platform plugins that ship a separate iOS + Android source for the same + # module) must be `:ios` or `:android`. For single-source C/ObjC/zig NIFs the + # rest of the fields are validated at build / link time. `lang: :cpp_archive` + # entries (a cross-compiled C++ static lib, e.g. an Nx backend) declare more — + # `:sources` + `:nm_symbol` — so those are checked structurally here. + defp check_nifs(errors, %{nifs: nifs}) when is_list(nifs) do + nifs + |> Enum.with_index() + |> Enum.reduce(errors, fn {nif, i}, acc -> check_nif_entry(acc, nif, i) end) + end + + defp check_nifs(errors, %{nifs: other}), + do: ["nifs must be a list, got: #{inspect(other)}" | errors] + + defp check_nifs(errors, _), do: errors + + defp check_nif_entry(errors, %{} = nif, i) do + errors + |> check_nif_platform(nif, i) + |> check_nif_cpp_archive(nif, i) + end + + defp check_nif_entry(errors, other, i), + do: ["nifs entry ##{i} must be a map, got: #{inspect(other)}" | errors] + + defp check_nif_platform(errors, %{platform: p}, i) when p not in [:ios, :android], + do: ["nifs entry ##{i}: :platform must be :ios or :android, got: #{inspect(p)}" | errors] + + defp check_nif_platform(errors, _nif, _i), do: errors + + # A `lang: :cpp_archive` entry is cross-compiled into a static `.a` and + # static-linked into the app (see `MobDev.Plugin.CppArchive`). It needs more + # than a single-source NIF: `:sources` (≥1 relative C++ paths) and + # `:nm_symbol` (the ERL_NIF_INIT symbol the driver table references). The + # toolchain-shaped fields (`:includes`, `:cxxflags*`) are optional and + # resolved at build time. + defp check_nif_cpp_archive(errors, %{lang: :cpp_archive} = nif, i) do + errors + |> check_archive_module(nif, i) + |> check_archive_sources(nif, i) + |> check_archive_symbol(nif, i) + |> check_archive_symbol_matches_module(nif, i) + end + + defp check_nif_cpp_archive(errors, _nif, _i), do: errors + + # The driver table builds the archive's libname and init symbol from + # `:module` (`lib<module>.a`, `<module>_nif_init`), and `Merge.static_archives` + # silently drops any entry whose `:module` isn't an atom. A missing/non-atom + # `:module` therefore fails *open*: validation passes but the build either + # drops the NIF (non-atom) or names it `libnil.a` (nil). Require a lowercase + # NIF `:module` atom up front — mirrors the `:screens` `:module` check, but + # also rejects aliased modules (`Foo.Bar`), which would yield a wrong-named + # `libElixir.Foo.Bar.a`. NIF modules are bare lowercase atoms (`:nx_eigen_nif`). + defp check_archive_module(errors, %{module: m}, _i) + when is_atom(m) and not is_nil(m) and m not in [true, false] do + if String.starts_with?(Atom.to_string(m), "Elixir."), + do: [ + "nifs cpp_archive :module must be a lowercase NIF atom (e.g. :nx_eigen_nif), " <> + "got an aliased module #{inspect(m)}" + | errors + ], + else: errors + end + + defp check_archive_module(errors, _nif, i), + do: ["nifs entry ##{i}: lang: :cpp_archive requires a lowercase :module atom" | errors] + + defp check_archive_sources(errors, %{sources: s}, _i) + when is_list(s) and s != [] do + if Enum.all?(s, &archive_path_entry?/1), + do: errors, + else: [ + "nifs cpp_archive sources must be path strings or {:dep, name, subpath} tokens" | errors + ] + end + + defp check_archive_sources(errors, _nif, i), + do: ["nifs entry ##{i}: lang: :cpp_archive requires a non-empty :sources list" | errors] + + # A cpp_archive :sources / :includes entry is either a plugin-relative path + # string or a `{:dep, name, subpath}` token referencing a dep's tree (e.g. + # NxEigen's NIF lives in the nx_eigen dep). Merge resolves both. + defp archive_path_entry?(s) when is_binary(s), do: true + defp archive_path_entry?({:dep, name, sub}) when is_atom(name) and is_binary(sub), do: true + defp archive_path_entry?(_), do: false + + defp check_archive_symbol(errors, %{nm_symbol: sym}, _i) when is_binary(sym) and sym != "", + do: errors + + defp check_archive_symbol(errors, _nif, i), + do: ["nifs entry ##{i}: lang: :cpp_archive requires an :nm_symbol string" | errors] + + # The static-NIF driver table derives a cpp_archive's init function as + # `<module>_nif_init` and resolves load_nif by the module name, so the + # archive's actual `:nm_symbol` MUST equal `<module>_nif_init`. Mismatch is a + # link-time "cannot locate symbol …_nif_init" that only shows up on-device + # after a full native build (it bit the first real consumer: module + # :nx_eigen_nif vs symbol nx_eigen_nif_init). Catch it at config time. + defp check_archive_symbol_matches_module(errors, %{module: m, nm_symbol: sym}, i) + when is_atom(m) and is_binary(sym) do + expected = "#{m}_nif_init" + + if sym == expected, + do: errors, + else: [ + "nifs entry ##{i}: cpp_archive :nm_symbol #{inspect(sym)} must be " <> + "#{inspect(expected)} (the driver table derives the init symbol as " <> + "<module>_nif_init; set the module so they agree, or fix " <> + "-DSTATIC_ERLANG_NIF_LIBNAME)" + | errors + ] + end + + defp check_archive_symbol_matches_module(errors, _nif, _i), do: errors + + # ── Tier 3: screens / migrations / assets ───────────────────────────────── + + # `:screens` (static, tier 3) is a list of `%{module: Mod, default_route: "/p"}`. + # Mutually exclusive with `:screens_generator` (enforced in check_screens_generator). + defp check_screens(errors, %{screens: screens}) when is_list(screens) do + screens + |> Enum.with_index() + |> Enum.reduce(errors, fn {entry, i}, acc -> check_screen_entry(acc, entry, i) end) + end + + defp check_screens(errors, %{screens: other}), + do: ["screens must be a list, got: #{inspect(other)}" | errors] + + defp check_screens(errors, _), do: errors + + defp check_screen_entry(errors, %{module: m, default_route: r}, _i) + when is_atom(m) and not is_nil(m) and is_binary(r), + do: errors + + defp check_screen_entry(errors, %{} = entry, i), + do: [ + "screens entry ##{i} requires a :module atom and a :default_route string, got: #{inspect(entry)}" + | errors + ] + + defp check_screen_entry(errors, other, i), + do: ["screens entry ##{i} must be a map, got: #{inspect(other)}" | errors] + + # `:screens_generator` (spec-v2) is an `{Module, :function, args}` MFA run at + # build time. Requires plugin_spec_version: 2 and is mutually exclusive with + # static `:screens`. + defp check_screens_generator(errors, %{screens_generator: gen} = m) do + errors + |> check_mfa(gen, :screens_generator) + |> require_spec_v2(m, :screens_generator) + |> reject_static_and_generated(m) + end + + defp check_screens_generator(errors, _), do: errors + + defp reject_static_and_generated(errors, m) do + if Map.has_key?(m, :screens) do + [":screens and :screens_generator are mutually exclusive — declare one, not both" | errors] + else + errors + end + end + + defp require_spec_v2(errors, %{plugin_spec_version: v}, _section) when v >= 2, do: errors + + defp require_spec_v2(errors, _m, section), + do: ["#{inspect(section)} requires plugin_spec_version: 2" | errors] + + # `:migrations` (tier 3) is `%{repo_namespace: "prefix_", migrations_dir: "path"}`. + # The namespace prefixes migration/table names so vendors don't collide. + defp check_migrations(errors, %{migrations: %{repo_namespace: ns, migrations_dir: dir}}) + when is_binary(ns) and is_binary(dir), + do: errors + + defp check_migrations(errors, %{migrations: other}), + do: [ + "migrations must be a map with :repo_namespace and :migrations_dir strings, got: #{inspect(other)}" + | errors + ] + + defp check_migrations(errors, _), do: errors + + # `:assets` (tier 3) is `%{fonts: [path], images: [path]}` (both keys optional). + defp check_assets(errors, %{assets: %{} = assets}) do + errors + |> check_asset_paths(assets, :fonts) + |> check_asset_paths(assets, :images) + end + + defp check_assets(errors, %{assets: other}), + do: [ + "assets must be a map with :fonts and/or :images path lists, got: #{inspect(other)}" + | errors + ] + + defp check_assets(errors, _), do: errors + + defp check_asset_paths(errors, assets, key) do + case Map.get(assets, key) do + nil -> + errors + + paths when is_list(paths) -> + if Enum.all?(paths, &is_binary/1), do: errors, else: bad_assets(errors, key) + + _ -> + bad_assets(errors, key) + end + end + + defp bad_assets(errors, key), + do: ["assets.#{key} must be a list of path strings" | errors] + + # ── Tier 4: lifecycle / settings / notifications ────────────────────────── + + # `:lifecycle` (tier 4) wires the plugin into the host's OTP lifecycle: + # `on_start`/`on_resume`/`on_background` MFAs (optional) and `supervised` + # child specs (optional). The host calls them; this only checks shape. + defp check_lifecycle(errors, %{lifecycle: %{} = lc}) do + errors + |> check_optional_mfa(lc, :on_start) + |> check_optional_mfa(lc, :on_resume) + |> check_optional_mfa(lc, :on_background) + |> check_supervised(lc) + end + + defp check_lifecycle(errors, %{lifecycle: other}), + do: ["lifecycle must be a map, got: #{inspect(other)}" | errors] + + defp check_lifecycle(errors, _), do: errors + + defp check_optional_mfa(errors, map, key) do + case Map.get(map, key) do + nil -> errors + mfa -> check_mfa(errors, mfa, :"lifecycle.#{key}") + end + end + + defp check_supervised(errors, %{supervised: children}) when is_list(children), do: errors + + defp check_supervised(errors, %{supervised: other}), + do: ["lifecycle.supervised must be a list of child specs, got: #{inspect(other)}" | errors] + + defp check_supervised(errors, _), do: errors + + # `:settings` (tier 4) is `%{schema: [%{key, type, default}], editor_screen: Mod}`. + # Persisted per-plugin via Mob.State; `type` drives runtime validation. + @setting_types [:boolean, :string, :integer] + + defp check_settings(errors, %{settings: %{} = settings}) do + errors + |> check_settings_schema(settings) + |> check_editor_screen(settings) + end + + defp check_settings(errors, %{settings: other}), + do: ["settings must be a map with a :schema list, got: #{inspect(other)}" | errors] + + defp check_settings(errors, _), do: errors + + defp check_settings_schema(errors, %{schema: schema}) when is_list(schema) do + schema + |> Enum.with_index() + |> Enum.reduce(errors, fn {entry, i}, acc -> check_setting_entry(acc, entry, i) end) + end + + defp check_settings_schema(errors, _), + do: ["settings.schema is required and must be a list of %{key, type, default} maps" | errors] + + defp check_setting_entry(errors, %{key: k, type: t} = entry, i) + when is_atom(k) and is_atom(t) do + cond do + t not in @setting_types -> + [ + "settings entry ##{i}: :type must be one of #{inspect(@setting_types)}, got: #{inspect(t)}" + | errors + ] + + not Map.has_key?(entry, :default) -> + ["settings entry ##{i} (#{inspect(k)}) requires a :default value" | errors] + + true -> + errors + end + end + + defp check_setting_entry(errors, other, i), + do: ["settings entry ##{i} requires :key and :type atoms, got: #{inspect(other)}" | errors] + + defp check_editor_screen(errors, %{editor_screen: m}) when is_atom(m) and not is_nil(m), + do: errors + + defp check_editor_screen(errors, %{editor_screen: other}), + do: [ + "settings.editor_screen must be a Mob.Screen module atom, got: #{inspect(other)}" | errors + ] + + defp check_editor_screen(errors, _), do: errors + + # `:notifications` (tier 4) is `%{handlers: [%{match, handler}]}`. `match` is a + # map (prefix-matched against the payload) or a 1-arity fun; `handler` is an MFA. + defp check_notifications(errors, %{notifications: %{handlers: handlers}}) + when is_list(handlers) do + handlers + |> Enum.with_index() + |> Enum.reduce(errors, fn {entry, i}, acc -> check_notification_handler(acc, entry, i) end) + end + + defp check_notifications(errors, %{notifications: other}), + do: ["notifications must be a map with a :handlers list, got: #{inspect(other)}" | errors] + + defp check_notifications(errors, _), do: errors + + # `:ui_components` entries are either NATIVE-backed (ios/android view + # registrations, tier 2's original form) or the pure-Elixir `expand:` form + # ({Module, :function} composite expander — MOB_PLUGINS.md, now honored). + # An entry must pick one; both/neither is an error. + defp check_ui_components(errors, %{ui_components: comps}) when is_list(comps) do + comps + |> Enum.with_index() + |> Enum.reduce(errors, fn {entry, i}, acc -> check_ui_component(acc, entry, i) end) + end + + defp check_ui_components(errors, %{ui_components: other}), + do: ["ui_components must be a list, got: #{inspect(other)}" | errors] + + defp check_ui_components(errors, _), do: errors + + defp check_ui_component(errors, %{tag: t, atom: a} = entry, i) + when is_binary(t) and is_atom(a) do + native? = Map.has_key?(entry, :ios) or Map.has_key?(entry, :android) + + case {Map.get(entry, :expand), native?} do + {nil, true} -> + errors + + {{m, f}, false} when is_atom(m) and is_atom(f) -> + errors + + {nil, false} -> + [ + "ui_components entry ##{i} needs native backing (:ios/:android) or an " <> + "expand: {Module, :function} composite expander" + | errors + ] + + {_, true} -> + [ + "ui_components entry ##{i} mixes expand: with native backing — pick one " <> + "(a composite that needs a native part should emit Mob.UI.native_view)" + | errors + ] + + {bad, false} -> + [ + "ui_components entry ##{i} expand: must be {Module, :function}, got: #{inspect(bad)}" + | errors + ] + end + end + + defp check_ui_component(errors, entry, i), + do: [ + "ui_components entry ##{i} requires a :tag string and an :atom, got: #{inspect(entry)}" + | errors + ] + + # `:host_requirements` is optional: human-readable host-app obligations the + # plugin system can't automate (e.g. AndroidManifest fragments). The native + # build prints them as warnings; here we only enforce the shape. + defp check_host_requirements(errors, %{host_requirements: reqs}) when is_list(reqs) do + Enum.reduce(reqs, errors, fn + req, acc when is_binary(req) and req != "" -> + acc + + req, acc -> + ["host_requirements entries must be non-empty strings, got: #{inspect(req)}" | acc] + end) + end + + defp check_host_requirements(errors, %{host_requirements: other}), + do: ["host_requirements must be a list of strings, got: #{inspect(other)}" | errors] + + defp check_host_requirements(errors, _), do: errors + + defp check_notification_handler(errors, %{match: match, handler: handler}, i) do + errors + |> check_match(match, i) + |> check_handler_ref(handler, i) + end + + defp check_notification_handler(errors, other, i), + do: [ + "notifications handler ##{i} requires :match and :handler, got: #{inspect(other)}" | errors + ] + + # A notification handler is invoked WITH the payload, so it's a + # `{Module, :function, arity}` reference (arity is an integer), not an + # args-MFA. The dispatcher calls `apply(m, f, [payload])`. + defp check_handler_ref(errors, {m, f, arity}, _i) + when is_atom(m) and is_atom(f) and is_integer(arity), + do: errors + + defp check_handler_ref(errors, other, i), + do: [ + "notifications handler ##{i}: :handler must be a {Module, :function, arity} tuple, got: #{inspect(other)}" + | errors + ] + + # `:match` is a map (prefix-matched against the payload) or a + # `{Module, :function, arity}` predicate reference. Anonymous functions are + # NOT allowed — the merged handler set is serialized into the host's runtime + # plugin manifest (a terms file), and closures don't survive that. + defp check_match(errors, match, _i) when is_map(match), do: errors + + defp check_match(errors, {m, f, arity}, _i) + when is_atom(m) and is_atom(f) and is_integer(arity), + do: errors + + defp check_match(errors, other, i), + do: [ + "notifications handler ##{i}: :match must be a map or a {Module, :function, arity} predicate, got: #{inspect(other)}" + | errors + ] + + # Shared: validate an `{Module, :function, args}` MFA tuple. + defp check_mfa(errors, {m, f, a}, _label) when is_atom(m) and is_atom(f) and is_list(a), + do: errors + + defp check_mfa(errors, other, label), + do: [ + "#{inspect(label)} must be an {Module, :function, args} tuple, got: #{inspect(other)}" + | errors + ] + + @doc """ + Classifies the plugin tier (0–4) from which capability sections are present. + + Highest matching section wins. `nil` (no manifest) is tier 0. A manifest + with required fields but no capability sections is the tier-1 floor (the + "minimum viable manifest"). + """ + @spec tier(map() | nil) :: 0..4 + def tier(nil), do: 0 + + def tier(m) when is_map(m) do + cond do + has_any?(m, @subapp_sections) -> 4 + has_any?(m, @screen_sections) -> 3 + has_any?(m, @visual_sections) -> 2 + has_any?(m, @tier1_sections) -> 1 + true -> 1 + end + end + + @doc """ + Whether the plugin's contributions can be hot-pushed without a native rebuild. + + Computed from populated sections, not from tier: `true` for pure-Elixir + plugins, `false` for native-only ones (NIFs / components), and `:partial` + when a plugin mixes native code with hot-pushable Elixir (screens, lifecycle). + """ + @spec hot_pushable(map() | nil) :: true | false | :partial + def hot_pushable(nil), do: true + + def hot_pushable(m) when is_map(m) do + cond do + not has_any_native?(m) -> true + has_any?(m, @pushable_sections) -> :partial + true -> false + end + end + + defp has_any?(map, keys), do: Enum.any?(keys, &Map.has_key?(map, &1)) + + # :ui_components counts as native ONLY when an entry actually carries native + # backing (:ios/:android). The expand: form (pure-Elixir composites) is + # hot-pushable like any screen module. + defp has_any_native?(m) do + Enum.any?(@native_sections, fn + :ui_components -> + m |> Map.get(:ui_components, []) |> Enum.any?(&native_component?/1) + + key -> + Map.has_key?(m, key) + end) + end + + defp native_component?(c) when is_map(c), + do: Map.has_key?(c, :ios) or Map.has_key?(c, :android) + + defp native_component?(_), do: false +end diff --git a/lib/mob_dev/plugin/merge.ex b/lib/mob_dev/plugin/merge.ex new file mode 100644 index 0000000..d0bde91 --- /dev/null +++ b/lib/mob_dev/plugin/merge.ex @@ -0,0 +1,447 @@ +defmodule MobDev.Plugin.Merge do + @moduledoc """ + Gathers the contributions of the activated plugins into combined lists the + build pipeline consumes. + + Pure: every function takes `plugins` — a list of `{plugin_dir, manifest}` for + the activated plugins — and returns the merged contribution for one build + concern (NIFs, permissions, gradle deps, frameworks, native sources, …). + Path-bearing declarations are resolved to absolute paths against each + plugin's own directory, so contributions from different plugins don't + collide or get misresolved. Tier-0 (nil-manifest) plugins contribute nothing. + + Discovery (deps → activated → load manifest) is the caller's job; this module + is the testable transform once the manifests are in hand. + """ + + @type plugin :: {Path.t(), map() | nil} + + @doc """ + Combined NIF entries across all plugins, with `:native_dir` resolved to an + absolute path. Shape matches `MobDev.StaticNifs` entries (`:module` plus the + plugin's native source dir), so the result can be fed straight into + `StaticNifs.resolve/1`. + """ + @spec nifs([plugin()]) :: [map()] + def nifs(plugins) do + for {dir, manifest} <- with_manifests(plugins), + nif <- Map.get(manifest, :nifs, []), + is_map(nif) do + case nif[:native_dir] do + nil -> nif + rel -> Map.put(nif, :native_dir, Path.join(dir, rel)) + end + end + end + + @doc "Unique Android permission strings declared across plugins." + @spec android_permissions([plugin()]) :: [String.t()] + def android_permissions(plugins), do: collect_uniq(plugins, [:android, :permissions]) + + @doc "Unique Android gradle dependency strings across plugins." + @spec gradle_deps([plugin()]) :: [String.t()] + def gradle_deps(plugins), do: collect_uniq(plugins, [:android, :gradle_deps]) + + @doc """ + AndroidManifest `<application>` snippets each plugin contributes (a `<service>`, + `<receiver>`, `<provider>`, …), tagged with `:plugin`. `native_build` splices + each into the app manifest's `<application>` block (idempotent on the + component's `android:name`). Ships the manifest half of a component whose class + rides in the plugin's `bridge_kt`; see also `android_res_files/1` for the + `res/` half (e.g. an `apduservice.xml`). + """ + @spec android_manifest_snippets([plugin()]) :: [%{plugin: atom(), snippet: String.t()}] + def android_manifest_snippets(plugins) do + for {_dir, manifest} <- with_manifests(plugins), + snippet <- List.wrap(get_in(manifest, [:android, :manifest_application_snippets])), + is_binary(snippet), + do: %{plugin: manifest[:name], snippet: snippet} + end + + @doc """ + Android `res/` files each plugin contributes, resolved to `{plugin, src, dest}`. + `src` is absolute (against the plugin dir); `dest` is the path under + `android/app/src/main/` derived from the declared path's last `res/` segment + (`priv/native/android/res/xml/foo.xml` → `res/xml/foo.xml`). `native_build` + copies each into the app res tree. Pairs with `android_manifest_snippets/1` + (a `<meta-data android:resource="@xml/foo"/>` needs its `res/xml/foo.xml`). + """ + @spec android_res_files([plugin()]) :: [%{plugin: atom(), src: String.t(), dest: String.t()}] + def android_res_files(plugins) do + for {dir, manifest} <- with_manifests(plugins), + rel <- List.wrap(get_in(manifest, [:android, :res_files])), + is_binary(rel) do + %{plugin: manifest[:name], src: Path.join(dir, rel), dest: res_dest(rel)} + end + end + + # Android res destination for a plugin-relative path: everything from the last + # `res` path segment onward, so it lands correctly typed under the app's + # `res/` tree. Falls back to `res/<basename>` when no `res` segment is present + # (the manifest validator rejects that case up front). + defp res_dest(rel) do + parts = Path.split(rel) + + case last_index(parts, "res") do + nil -> Path.join("res", Path.basename(rel)) + idx -> Path.join(Enum.drop(parts, idx)) + end + end + + defp last_index(list, value) do + list + |> Enum.with_index() + |> Enum.reduce(nil, fn {v, i}, acc -> if v == value, do: i, else: acc end) + end + + @doc "Unique iOS framework names across plugins." + @spec ios_frameworks([plugin()]) :: [String.t()] + def ios_frameworks(plugins), do: collect_uniq(plugins, [:ios, :frameworks]) + + @doc "Absolute paths of all plugin iOS Swift source files." + @spec swift_files([plugin()]) :: [String.t()] + def swift_files(plugins), do: collect_paths(plugins, [:ios, :swift_files]) + + @doc """ + Absolute paths of all plugin Android native sources (`bridge_kt`, + `jni_source`) plus NIF `native_dir`s — everything the Android build must + compile in. + """ + @spec android_sources([plugin()]) :: [String.t()] + def android_sources(plugins) do + bridge = collect_paths(plugins, [:android, :bridge_kt]) + jni = collect_paths(plugins, [:android, :jni_source]) + nif_dirs = for nif <- nifs(plugins), dir = nif[:native_dir], do: dir + + (bridge ++ jni ++ nif_dirs) |> Enum.uniq() + end + + @doc """ + Absolute paths of plugin Android `jni_source` files — plain JNI-thunk C + (e.g. `Java_<pkg>_<Class>_nativeDeliver*`) that the build compiles into the + app `.so` without a NIF-init libname (unlike `nif_sources/1`). Fed to the + build's `-Dplugin_jni_sources` arg. + """ + @spec jni_sources([plugin()]) :: [String.t()] + def jni_sources(plugins), do: collect_paths(plugins, [:android, :jni_source]) + + @doc """ + Absolute paths of plugin Android `bridge_kt` Kotlin sources. `native_build` + copies each into the app source tree (at its package-derived path) before + `gradle assembleDebug`, so the app's Kotlin sourceSet compiles it. + """ + @spec bridge_kt_sources([plugin()]) :: [String.t()] + def bridge_kt_sources(plugins), do: collect_paths(plugins, [:android, :bridge_kt]) + + @doc """ + Fully-qualified Kotlin class names (e.g. `"io.mob.bluetooth.MobBluetoothBridge"`) + each activated plugin wants registered at startup. `native_build` generates a + `MobPluginBootstrap.registerAll/0` that calls `<class>.register()` for each, so + the plugin's `nativeRegister` thunk can cache its own jclass + method IDs. + """ + @spec bridge_classes([plugin()]) :: [String.t()] + def bridge_classes(plugins), do: collect_uniq(plugins, [:android, :bridge_class]) + + @doc """ + Absolute paths of each plugin **C-family** NIF's primary source — entries with + no `:lang`, `lang: :c` (`<module>.c`), or `lang: :objc` (`<module>.m`, compiled + as Objective-C so iOS plugins can drive Apple frameworks like CoreLocation). + + Convention: for a manifest entry `%{module: :foo_nif, native_dir: "priv/jni"}` + the source is `<plugin_dir>/priv/jni/foo_nif.c` (or `.m` for `lang: :objc`). + This is the `<name>.c` pattern the build.zig templates already use for + project-level NIFs (`c_src/<name>.c`), extended to plugins. Returned paths feed + the build's `-Dplugin_c_nifs` arg; build.zig derives the NIF name from the + basename and applies `-DSTATIC_ERLANG_NIF_LIBNAME=<name>` so ERL_NIF_INIT emits + the static-init symbol the driver table references (and adds `-fobjc-arc` for + `.m` sources). + """ + @spec nif_sources([plugin()]) :: [String.t()] + def nif_sources(plugins), do: nif_sources(plugins, :all) + + @doc """ + Like `nif_sources/1` but restricted to NIFs the given platform compiles. + + A NIF entry with `platform: :ios | :android` is only compiled on that + platform; an entry with no `:platform` is compiled everywhere. Lets a + cross-platform plugin ship an iOS C/ObjC NIF and an Android NIF for the same + module without the iOS source (which may reference iOS-only symbols) ending up + in the Android build, and vice-versa. `:all` keeps every entry. + """ + @spec nif_sources([plugin()], :ios | :android | :all) :: [String.t()] + def nif_sources(plugins, platform) do + for {dir, manifest} <- with_manifests(plugins), + nif <- Map.get(manifest, :nifs, []), + is_map(nif), + name = nif[:module], + is_atom(name), + nif_lang(nif) in [:c, :objc], + nif_for_platform?(nif, platform) do + ext = if nif_lang(nif) == :objc, do: "m", else: "c" + native_dir = nif[:native_dir] || "priv/native/jni" + Path.join([dir, native_dir, "#{name}.#{ext}"]) + end + end + + @doc """ + Absolute paths of each plugin **zig** NIF's primary source (NIFs whose + manifest entry has `lang: :zig`). + + Same `<plugin_dir>/<native_dir>/<module>.zig` convention as the C path, but + fed to the build's `-Dplugin_zig_nifs` arg and compiled via `addZigObject`. + Unlike C, no `-DSTATIC_ERLANG_NIF_LIBNAME` is needed — the zig source names + its own `export fn <module>_nif_init()` directly. The plugin source reaches + mob-core bindings via the named imports `@import("erts")` / `@import("jni")` + that build.zig wires for plugin zig objects. + """ + @spec zig_nif_sources([plugin()]) :: [String.t()] + def zig_nif_sources(plugins), do: zig_nif_sources(plugins, :all) + + @doc "Like `zig_nif_sources/1` but restricted to the given platform (see `nif_sources/2`)." + @spec zig_nif_sources([plugin()], :ios | :android | :all) :: [String.t()] + def zig_nif_sources(plugins, platform), do: nif_sources_for_lang(plugins, :zig, "zig", platform) + + defp nif_sources_for_lang(plugins, lang, ext, platform) do + for {dir, manifest} <- with_manifests(plugins), + nif <- Map.get(manifest, :nifs, []), + is_map(nif), + name = nif[:module], + is_atom(name), + nif_lang(nif) == lang, + nif_for_platform?(nif, platform) do + native_dir = nif[:native_dir] || "priv/native/jni" + Path.join([dir, native_dir, "#{name}.#{ext}"]) + end + end + + # A NIF manifest entry defaults to C so existing (haptic) plugins are + # unaffected; `lang: :zig` opts into the zig compile path. + defp nif_lang(nif), do: nif[:lang] || :c + + # An entry with no `:platform` is compiled on every platform; one tagged + # `:ios`/`:android` only on that platform. `:all` keeps everything. + # + # `lang: :objc` is implicitly Apple-only: Objective-C has no Android runtime, + # so an objc NIF authored without an explicit `platform: :ios` must still be + # excluded from the Android build args + driver_tab (otherwise zig tries to + # compile a `.m` source Android cannot build). + defp nif_for_platform?(_nif, :all), do: true + + defp nif_for_platform?(nif, platform) do + if nif_lang(nif) == :objc and platform != :ios do + false + else + nif[:platform] in [nil, platform] + end + end + + @doc """ + Cross-compiled C++ static-archive contributions (`lang: :cpp_archive`) across + plugins, resolved for one platform. + + Unlike `nif_sources/2` (a single source `build.zig` compiles inline), a + `:cpp_archive` NIF is a set of C++ sources cross-compiled into a `libNAME.a` + by `MobDev.Plugin.CppArchive` and static-linked into the app — the path for + heavyweight NIFs like the Nx/Eigen CPU backend (Eigen headers, RTTI/exceptions, + per-arch CXXFLAGS) that don't fit the single-source model. + + Each returned spec has `:sources` (absolute) and `:includes` resolved: a + plugin-relative string becomes absolute against the plugin dir, while a + `{:dep, name, subpath}` token is passed through unchanged for the build to + resolve against `Mix.Project.deps_path/0` (Eigen/Fine live in the plugin's + *deps*, not its own tree, and this module stays Mix-free/pure). CXXFLAGS and + `:nm_symbol` pass through; the entry is tagged with `:plugin`. + """ + @spec static_archives([plugin()], :ios | :android | :all) :: [map()] + def static_archives(plugins, platform \\ :all) do + for {dir, manifest} <- with_manifests(plugins), + nif <- Map.get(manifest, :nifs, []), + is_map(nif), + nif_lang(nif) == :cpp_archive, + is_atom(nif[:module]), + nif_for_platform?(nif, platform) do + %{ + module: nif[:module], + sources: resolve_paths(dir, List.wrap(nif[:sources])), + includes: resolve_paths(dir, List.wrap(nif[:includes])), + cxxflags: List.wrap(nif[:cxxflags]), + cxxflags_android: List.wrap(nif[:cxxflags_android]), + cxxflags_ios: List.wrap(nif[:cxxflags_ios]), + nm_symbol: nif[:nm_symbol], + platform: nif[:platform], + plugin: manifest[:name] + } + end + end + + # Resolve a cpp_archive's `:sources`/`:includes` entries. A plugin-relative + # string resolves to absolute against the plugin dir; a `{:dep, name, subpath}` + # token passes through (resolved at build time against the deps path — keeps + # this module pure / Mix-free). The dep form lets a plugin reference sources or + # headers that live in one of its deps (e.g. nx_eigen's own c_src + Eigen + # headers) rather than vendoring a copy that can drift. + defp resolve_paths(dir, entries) do + for entry <- entries do + case entry do + bin when is_binary(bin) -> Path.join(dir, bin) + {:dep, name, sub} when is_atom(name) and is_binary(sub) -> {:dep, name, sub} + other -> other + end + end + end + + @doc "Merged iOS `plist_keys` across plugins (later plugins win on conflict)." + @spec plist_keys([plugin()]) :: map() + def plist_keys(plugins) do + for {_dir, manifest} <- with_manifests(plugins), + keys = get_in(manifest, [:ios, :plist_keys]), + is_map(keys), + reduce: %{} do + acc -> Map.merge(acc, keys) + end + end + + @doc "Combined `ui_components` entries across plugins." + @spec ui_components([plugin()]) :: [map()] + def ui_components(plugins) do + for {_dir, manifest} <- with_manifests(plugins), + c <- Map.get(manifest, :ui_components, []), + is_map(c), + do: Map.put(c, :plugin, manifest[:name]) + end + + # ── Tier 3/4: runtime-manifest contributions ────────────────────────────── + # + # Unlike the native gatherers above (which feed build args), these feed the + # host's generated runtime plugin manifest (priv/generated/mob_plugins.exs) — + # a terms file the on-device `Mob.Plugins` module reads at boot. Each entry is + # tagged with `:plugin` (the owning plugin name) so the runtime can namespace + # settings, order notification handlers, and attribute screens. Everything + # here must be serializable terms (atoms / tuples / maps / strings) — no + # closures (the manifest validator enforces that for notification matches). + + @doc """ + Static screen declarations across plugins, each tagged with `:plugin`. + + Generator-produced screens (spec-v2 `:screens_generator`) are folded in + separately by the runtime-manifest codegen; this is the static `:screens` half. + """ + @spec screens([plugin()]) :: [map()] + def screens(plugins) do + for {_dir, manifest} <- with_manifests(plugins), + s <- Map.get(manifest, :screens, []), + is_map(s), + do: Map.put(s, :plugin, manifest[:name]) + end + + @doc """ + Migration declarations across plugins, with `:migrations_dir` resolved to an + absolute path and tagged with `:plugin` + `:repo_namespace`. + """ + @spec migrations([plugin()]) :: [map()] + def migrations(plugins) do + for {dir, manifest} <- with_manifests(plugins), + m = Map.get(manifest, :migrations), + is_map(m), + is_binary(m[:migrations_dir]) do + %{ + plugin: manifest[:name], + repo_namespace: m[:repo_namespace], + migrations_dir: Path.join(dir, m[:migrations_dir]) + } + end + end + + @doc """ + Asset declarations across plugins, with font/image paths resolved to absolute + and tagged with `:plugin` (used by the `plugin://<name>/<file>` image resolver + and the native font-bundling merge). + """ + @spec assets([plugin()]) :: [map()] + def assets(plugins) do + for {dir, manifest} <- with_manifests(plugins), + a = Map.get(manifest, :assets), + is_map(a) do + %{ + plugin: manifest[:name], + fonts: abs_paths(dir, a[:fonts]), + images: abs_paths(dir, a[:images]) + } + end + end + + @doc "Lifecycle declarations across plugins, each tagged with `:plugin`." + @spec lifecycle([plugin()]) :: [map()] + def lifecycle(plugins) do + for {_dir, manifest} <- with_manifests(plugins), + lc = Map.get(manifest, :lifecycle), + is_map(lc), + do: Map.put(lc, :plugin, manifest[:name]) + end + + @doc "Settings declarations across plugins, each tagged with `:plugin`." + @spec settings([plugin()]) :: [map()] + def settings(plugins) do + for {_dir, manifest} <- with_manifests(plugins), + s = Map.get(manifest, :settings), + is_map(s), + do: Map.put(s, :plugin, manifest[:name]) + end + + @doc """ + Notification handlers across all plugins, flattened in plugin-then-declaration + order (first match wins at dispatch), each tagged with `:plugin`. + """ + @spec notification_handlers([plugin()]) :: [map()] + def notification_handlers(plugins) do + for {_dir, manifest} <- with_manifests(plugins), + notes = Map.get(manifest, :notifications), + is_map(notes), + h <- Map.get(notes, :handlers, []), + is_map(h), + do: Map.put(h, :plugin, manifest[:name]) + end + + @doc """ + Host-app obligations the plugin system can't automate (e.g. AndroidManifest + fragments — a typed foreground `<service>`, a `FileProvider`), tagged with + `:plugin`. The native build prints these so a host author can't activate a + plugin and only discover the missing manual step at first feature use. + """ + @spec host_requirements([plugin()]) :: [%{plugin: atom(), requirement: String.t()}] + def host_requirements(plugins) do + for {_dir, manifest} <- with_manifests(plugins), + req <- List.wrap(Map.get(manifest, :host_requirements)), + is_binary(req), + do: %{plugin: manifest[:name], requirement: req} + end + + defp abs_paths(dir, paths) when is_list(paths), + do: for(p <- paths, is_binary(p), do: Path.join(dir, p)) + + # A malformed (non-list) :fonts/:images declaration contributes no paths + # rather than crashing the merge — the manifest validator is the place that + # reports the shape error, but `activated/0` feeds Merge unvalidated maps. + defp abs_paths(_dir, _paths), do: [] + + # ── helpers ───────────────────────────────────────────────────────────── + + defp with_manifests(plugins) do + for {dir, manifest} <- plugins, is_map(manifest), do: {dir, manifest} + end + + defp collect_uniq(plugins, path) do + for {_dir, manifest} <- with_manifests(plugins), + value <- List.wrap(get_in(manifest, path)), + is_binary(value), + uniq: true, + do: value + end + + defp collect_paths(plugins, path) do + for {dir, manifest} <- with_manifests(plugins), + rel <- List.wrap(get_in(manifest, path)), + is_binary(rel), + do: Path.join(dir, rel) + end +end diff --git a/lib/mob_dev/plugin/private_key_store.ex b/lib/mob_dev/plugin/private_key_store.ex new file mode 100644 index 0000000..fb53a95 --- /dev/null +++ b/lib/mob_dev/plugin/private_key_store.ex @@ -0,0 +1,98 @@ +defmodule MobDev.Plugin.PrivateKeyStore do + @moduledoc """ + Author-side storage for the per-plugin Ed25519 private key. + + Keys live at `~/.mob/keys/<plugin_name>.priv` as a single line of + base64-encoded raw 32-byte key (with a trailing newline). The file + is chmod'd 0600. Plain text is intentional — plugin authors should + be able to inspect and back up the key with standard tools. + + This module is **author-only**; hosts never need it. The host-side + trust model (`TrustStore`) keys off the public key fingerprint + recorded in `mob.exs`. + """ + + alias MobDev.Plugin.Crypto + + @key_dir_relative ".mob/keys" + @key_extension ".priv" + @secure_mode 0o600 + + @typedoc "Errors `read_key/1` can return." + @type read_error :: :missing | :malformed + + @doc """ + Absolute path to the priv key file for `plugin_name`. + + Always under `~/.mob/keys/`. Pure (no I/O) and used by both + `read_key/1` and `write_key/2`. + """ + @spec key_path(atom() | String.t()) :: Path.t() + def key_path(plugin_name) do + name = to_string(plugin_name) + Path.join([key_dir(), name <> @key_extension]) + end + + @doc "Absolute path of the directory all priv keys live in." + @spec key_dir() :: Path.t() + def key_dir do + Path.join(home_dir(), @key_dir_relative) + end + + @doc """ + Reads the priv key for `plugin_name` and returns the raw 32-byte + binary. Returns `{:error, :missing}` if the file is absent or + `{:error, :malformed}` if the contents don't decode to a 32-byte key. + """ + @spec read_key(atom() | String.t()) :: {:ok, Crypto.priv_key()} | {:error, read_error()} + def read_key(plugin_name) do + path = key_path(plugin_name) + + case File.read(path) do + {:ok, contents} -> decode_priv_key(contents) + {:error, :enoent} -> {:error, :missing} + {:error, _} -> {:error, :malformed} + end + end + + defp decode_priv_key(contents) do + trimmed = String.trim(contents) + + case Base.decode64(trimmed) do + {:ok, priv} when byte_size(priv) == 32 -> {:ok, priv} + _ -> {:error, :malformed} + end + end + + @doc """ + Writes the priv key for `plugin_name` to disk with mode 0600. + + Creates the key directory if needed. Overwrites any existing file — + callers (`mix mob.plugin.keygen`) gate this on a confirmation / + `--force` flag before invoking. + """ + @spec write_key(atom() | String.t(), Crypto.priv_key()) :: :ok + def write_key(plugin_name, priv_bin) when is_binary(priv_bin) do + path = key_path(plugin_name) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, Base.encode64(priv_bin) <> "\n") + File.chmod!(path, @secure_mode) + :ok + end + + @doc "File mode applied to written keys (0o600 = owner read+write only)." + @spec secure_mode() :: integer() + def secure_mode, do: @secure_mode + + # Resolved per-call so tests can override via the `:mob_dev` Application + # env (`:plugin_key_home`). Falls back to `System.user_home!/0`, which + # is what end users hit. `HOME` env-var overrides are intentionally + # not honoured — Erlang caches the user home at OTP boot and `HOME` + # changes inside a running BEAM don't flow through. + defp home_dir do + case Application.get_env(:mob_dev, :plugin_key_home) do + nil -> System.user_home!() + override when is_binary(override) -> override + end + end +end diff --git a/lib/mob_dev/plugin/report.ex b/lib/mob_dev/plugin/report.ex new file mode 100644 index 0000000..39708f8 --- /dev/null +++ b/lib/mob_dev/plugin/report.ex @@ -0,0 +1,319 @@ +defmodule MobDev.Plugin.Report do + @moduledoc """ + Pure transforms behind `mix mob.plugins`: turn discovered deps + the + activation list into report rows, and render them as a table. + + Kept separate from the Mix task (which does the filesystem/config I/O) so + the classification and formatting are unit-testable without a project on disk. + """ + + alias MobDev.Plugin.Manifest + + @typedoc "An app and its loaded manifest (nil = no manifest / tier 0)." + @type dep :: {atom(), map() | nil} + + @typedoc "Vetting summary attached to a row by `with_vetting/2`." + @type vetting :: %{ + audit: %{high: non_neg_integer(), medium: non_neg_integer(), low: non_neg_integer()}, + capability_errors: non_neg_integer() + } + + @typedoc "One row of `mix mob.plugins` output." + @type row :: %{ + name: atom(), + tier: 0..4, + hot_pushable: true | false | :partial, + status: :activated | :installed, + manifest?: boolean(), + description: String.t() | nil, + vetting: vetting() | nil + } + + @doc """ + Builds sorted report rows from all deps and the activated-plugin list. + + A dep is a plugin row if it ships a manifest *or* is named in + `config :mob, :plugins`. A tier-0 plugin (no manifest) therefore only + appears once activated — otherwise it's indistinguishable from an ordinary + library dependency. Status is `:activated` when in the list, else + `:installed` (present in deps, not yet activated). + """ + @spec rows([dep()], [atom()]) :: [row()] + def rows(deps, activated) do + deps + |> Enum.filter(fn {name, manifest} -> manifest != nil or name in activated end) + |> Enum.map(fn {name, manifest} -> + %{ + name: name, + tier: Manifest.tier(manifest), + hot_pushable: Manifest.hot_pushable(manifest), + status: if(name in activated, do: :activated, else: :installed), + manifest?: manifest != nil, + description: manifest && Map.get(manifest, :description), + vetting: nil + } + end) + |> Enum.sort_by(& &1.name) + end + + @doc """ + Adds a `:vetting` summary to each activated row by running the static-analysis + audit (`MobDev.Plugin.Audit.audit_plugin/2`) and the capability checks + (`MobDev.Plugin.Validator.activated_capability_errors/1`) over the plugin's + source tree. + + `dep_dirs` is a map of `plugin_name => plugin_dir` (absolute path). Rows + whose plugin is not in `dep_dirs` (or which are `:installed` only — not + activated) get `vetting: nil` and render as a dash. This function performs + IO; pure rendering stays in `render/1`. + """ + @spec with_vetting([row()], %{atom() => Path.t()}) :: [row()] + def with_vetting(rows, dep_dirs) when is_map(dep_dirs) do + activated_with_manifests = + for row <- rows, + row.status == :activated, + row.manifest?, + dir = Map.get(dep_dirs, row.name), + is_binary(dir), + do: row.name + + capability_errors_by_plugin = + compute_capability_errors_by_plugin(rows, dep_dirs, activated_with_manifests) + + Enum.map(rows, fn row -> + case Map.get(dep_dirs, row.name) do + nil -> + row + + dir when row.status == :activated and row.manifest? -> + manifest = load_manifest(dir) + + audit = MobDev.Plugin.Audit.audit_plugin(dir, manifest).summary + + %{ + row + | vetting: %{ + audit: audit, + capability_errors: Map.get(capability_errors_by_plugin, row.name, 0) + } + } + + _ -> + row + end + end) + end + + defp compute_capability_errors_by_plugin(rows, dep_dirs, activated_with_manifests) do + for( + name <- activated_with_manifests, + dir = Map.fetch!(dep_dirs, name), + manifest = load_manifest(dir), + do: {dir, manifest, find_row_name(rows, name)} + ) + |> capability_errors_for() + end + + @doc false + # Pure kernel: counts capability errors per plugin from {dir, manifest, name} + # tuples. Extracted so the Validator arg order — `(manifest, dir)`, NOT + # `(dir, manifest)` — is regression-tested without the dep-dir/filesystem + # discovery above. Passing them swapped raises FunctionClauseError. + @spec capability_errors_for([{Path.t(), map(), term()}]) :: + %{optional(term()) => non_neg_integer()} + def capability_errors_for(plugins) do + Enum.into(plugins, %{}, fn {dir, manifest, name} -> + errors = + MobDev.Plugin.Validator.validate_swift_imports(manifest, dir) ++ + MobDev.Plugin.Validator.validate_android_permissions(manifest, dir) + + {name, length(errors)} + end) + end + + defp find_row_name(rows, name), do: Enum.find_value(rows, name, &(&1.name == name && name)) + + defp load_manifest(dir) do + case MobDev.Plugin.Manifest.load(dir) do + {:ok, manifest} -> manifest + _ -> %{} + end + end + + @doc """ + Renders rows as a human-readable table (or a friendly empty message). + """ + @spec render([row()]) :: String.t() + def render([]) do + "No mob plugins found.\n\n" <> + "A plugin appears here once it ships a priv/mob_plugin.exs manifest, or\n" <> + "is activated in mob.exs via `config :mob, :plugins, [...]`." + end + + def render(rows) do + with_vetting? = Enum.any?(rows, &(&1.vetting != nil)) + + header = + " " <> + pad("PLUGIN", 26) <> + pad("TIER", 8) <> + pad("HOT-PUSH", 10) <> + if(with_vetting?, do: pad("VETTING", 16), else: "") <> + "STATUS" + + rule_width = if with_vetting?, do: 74, else: 58 + lines = Enum.map(rows, &render_row(&1, with_vetting?)) + + ([header, " " <> String.duplicate("─", rule_width)] ++ lines ++ ["", legend(rows)]) + |> Enum.join("\n") + end + + defp render_row(row, with_vetting?) do + status = + case row.status do + :activated -> "activated" + :installed -> "installed (not activated)" + end + + note = if row.manifest?, do: "", else: " — no manifest (regular dep)" + + " " <> + pad(to_string(row.name), 26) <> + pad("tier #{row.tier}", 8) <> + pad(hot_push(row.hot_pushable), 10) <> + if(with_vetting?, do: pad(render_vetting(row.vetting), 16), else: "") <> + status <> note + end + + defp render_vetting(nil), do: "—" + + defp render_vetting(%{audit: %{high: 0, medium: 0, low: 0}, capability_errors: 0}), + do: "clean" + + defp render_vetting(%{audit: audit, capability_errors: caps}) do + audit_part = + [{audit.high, "H"}, {audit.medium, "M"}, {audit.low, "L"}] + |> Enum.filter(fn {n, _} -> n > 0 end) + |> Enum.map_join(" ", fn {n, code} -> "#{n}#{code}" end) + + case {audit_part, caps} do + {"", 0} -> "clean" + {"", n} -> "caps:#{n}" + {a, 0} -> a + {a, n} -> "caps:#{n} #{a}" + end + end + + defp hot_push(true), do: "yes" + defp hot_push(false), do: "no" + defp hot_push(:partial), do: "partial" + + defp legend(rows) do + not_activated = Enum.any?(rows, &(&1.status == :installed)) + + base = + "tier 0 = pure Elixir · 1 = NIF · 2 = component · 3 = screens · 4 = sub-app" + + if not_activated do + base <> + "\nInstalled-but-not-activated plugins contribute nothing until added to\n" <> + "`config :mob, :plugins` in mob.exs." + else + base + end + end + + defp pad(s, n), do: String.pad_trailing(s, n) + + # ── audit rendering ─────────────────────────────────────────────────────── + + @doc """ + Renders the output of `MobDev.Plugin.Audit.audit_plugin/2` (one or more) + for `mix mob.audit_plugins`. Pure. + + `reports` is a list of `MobDev.Plugin.Audit.report()` maps. Output is + ordered by plugin name; findings within a plugin are already sorted by + severity → file → line by `audit_plugin/2`. A trailing summary line tallies + every severity across all reports. + """ + @spec render_audit([map()]) :: String.t() + def render_audit([]) do + "No plugins audited.\n\n" <> + "A plugin appears here once activated in `config :mob, :plugins`." + end + + def render_audit(reports) do + sections = + reports + |> Enum.sort_by(fn r -> r.plugin || :"" end) + |> Enum.map(&render_audit_section/1) + + roll_up = render_audit_summary(reports) + + Enum.join(sections ++ ["", roll_up], "\n") + end + + defp render_audit_section(%{plugin: plugin, findings: [], kotlin_or_swift_skipped: skipped}) do + base = " #{plugin_label(plugin)} — no findings" + + if skipped do + base <> "\n (Kotlin/Swift sources present but not yet audited.)" + else + base + end + end + + defp render_audit_section(%{ + plugin: plugin, + findings: findings, + kotlin_or_swift_skipped: skipped + }) do + header = " #{plugin_label(plugin)}" + lines = Enum.map(findings, &render_finding/1) + + tail = + if skipped do + [" (Kotlin/Swift sources present but not yet audited.)"] + else + [] + end + + Enum.join([header] ++ lines ++ tail, "\n") + end + + defp render_finding(f) do + badge = + case f.severity do + :high -> "✗ HIGH " + :medium -> "⚠ MED " + :low -> "· LOW " + end + + loc = + case f.line do + nil -> f.file + ln -> "#{f.file}:#{ln}" + end + + snippet = if f.snippet == "", do: "", else: " — #{f.snippet}" + + " #{badge}[#{f.rule}] #{loc}#{snippet}\n #{f.hint}" + end + + defp render_audit_summary(reports) do + totals = + Enum.reduce(reports, %{high: 0, medium: 0, low: 0}, fn r, acc -> + Map.merge(acc, r.summary, fn _k, a, b -> a + b end) + end) + + count = length(reports) + plural = if count == 1, do: "plugin", else: "plugins" + + " ── Audit summary ─────────────────────────────────────────────────\n" <> + " #{count} #{plural} scanned · " <> + "#{totals.high} high · #{totals.medium} medium · #{totals.low} low" + end + + defp plugin_label(nil), do: "(unnamed plugin)" + defp plugin_label(name) when is_atom(name), do: to_string(name) +end diff --git a/lib/mob_dev/plugin/runtime_manifest.ex b/lib/mob_dev/plugin/runtime_manifest.ex new file mode 100644 index 0000000..673f480 --- /dev/null +++ b/lib/mob_dev/plugin/runtime_manifest.ex @@ -0,0 +1,166 @@ +defmodule MobDev.Plugin.RuntimeManifest do + @moduledoc """ + Builds the host's **runtime plugin manifest** — the device-readable record of + the activated plugins' tier-3/4 contributions. + + Tiers 3 and 4 are pure-Elixir and runtime-wired: the host needs to know, on + device, which screens exist, what lifecycle/settings/notification declarations + each plugin made — but `MobDev.Plugin.activated/0` is compile-time only. So at + build time this module gathers those sections (running any spec-v2 + `:screens_generator` under the host-config audit) and emits a terms file + (`priv/generated/mob_plugins.exs`) that the core `Mob.Plugins` module reads at + boot. It mirrors the `driver_tab` / `MobPluginBootstrap` codegen pattern, but + for serializable Elixir data instead of native symbols. + + Only the behavioral data lives here. Migration files and font/image assets are + physically copied into the host at build time (see `native_build`), not carried + in this manifest. + + Everything emitted must be serializable terms — atoms, tuples, maps, strings, + integers. The manifest validator forbids closures in plugin sections so this + always holds. + """ + + alias MobDev.Plugin.Merge + + @doc """ + Builds the merged runtime manifest map from `{plugin_dir, manifest}` pairs. + + Static `:screens` and spec-v2 `:screens_generator` output are combined (each + tagged with its plugin); generators run under `MobDev.Plugin.with_host_config_audit/3` + so an undeclared host-config read fails the build. + """ + @spec build([{Path.t(), map() | nil}]) :: map() + def build(plugins) do + # Styles ride the same runtime manifest (MOB_STYLES.md shares the plugin + # infrastructure): the activated token-only style packages + the + # configured default, applied by core at boot (Mob.Plugins.apply_default_style). + %{styles: styles, default_style: default_style} = MobDev.Style.runtime_entries!() + + %{ + screens: Merge.screens(plugins) ++ generated_screens(plugins), + lifecycle: Merge.lifecycle(plugins), + settings: Merge.settings(plugins), + notification_handlers: Merge.notification_handlers(plugins), + nifs: nif_modules(plugins), + composites: composites(plugins), + styles: styles, + default_style: default_style + } + end + + # Pure-Elixir composite components (the ui_components expand: form) — core + # registers each into Mob.Composite at boot. + defp composites(plugins) do + for c <- Merge.ui_components(plugins), + match?({m, f} when is_atom(m) and is_atom(f), c[:expand]) do + %{plugin: c[:plugin], atom: c[:atom], expand: c[:expand]} + end + end + + # The activated plugins' NIF module atoms (deduped, platform-agnostic — the + # same module name backs both the iOS and Android NIF). Core loads these at + # boot so an iOS plugin NIF's `load` callback fires eagerly, registering any + # permission handler it owns before a screen can request that permission. + # (Android registers permissions eagerly via MobPluginBootstrap, so this is + # the iOS counterpart; loading on both platforms is harmless and fail-fast.) + @spec nif_modules([{Path.t(), map() | nil}]) :: [atom()] + defp nif_modules(plugins) do + Merge.nifs(plugins) + |> Enum.map(& &1[:module]) + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + end + + defp generated_screens(plugins) do + for {_dir, manifest} <- plugins, + is_map(manifest), + gen = manifest[:screens_generator], + not is_nil(gen) do + {m, f, a} = gen + allowed = manifest[:host_config_keys] || [] + name = manifest[:name] + + {screens, _reads} = + MobDev.Plugin.with_host_config_audit(name, allowed, fn -> apply(m, f, a) end) + + screens + |> validate_generated_screens(name) + |> Enum.map(&Map.put(&1, :plugin, name)) + end + |> List.flatten() + end + + @doc """ + Validates a `:screens_generator`'s output, raising on malformed specs. + + A generator must return a list of `%{module: atom, default_route: binary}` + maps (a bare map is wrapped). This mirrors `MobDev.Plugin.Manifest`'s static + `:screens` validation so generator-produced screens are held to the same + shape contract — otherwise a typo'd or wrong-typed spec would be written into + `mob_plugins.exs` and silently dropped at boot (`Mob.Plugins.register_screens/0` + pattern-matches `%{module:, default_route:}` and skips non-matching maps), + making the plugin's screen vanish with no build-time error. + + Returns the validated screen list (without the `:plugin` tag, which the caller + adds). Public for testing. + """ + @spec validate_generated_screens(term(), atom() | nil) :: [map()] + def validate_generated_screens(screens, plugin_name) do + screens + |> List.wrap() + |> Enum.with_index() + |> Enum.map(fn {entry, i} -> validate_screen_entry(entry, i, plugin_name) end) + end + + defp validate_screen_entry(%{module: m, default_route: r} = entry, _i, _plugin) + when is_atom(m) and not is_nil(m) and is_binary(r), + do: entry + + defp validate_screen_entry(entry, i, plugin) do + raise ArgumentError, + "plugin #{inspect(plugin)} :screens_generator produced an invalid screen at " <> + "index #{i}: expected a %{module: atom, default_route: binary} map, got: " <> + "#{inspect(entry)}. Fix the generator to emit valid screen specs." + end + + @doc """ + Renders the manifest map to the contents of `priv/generated/mob_plugins.exs` — + a self-describing `.exs` that evaluates back to the map. + """ + @spec render(map()) :: String.t() + def render(manifest) do + rendered = + inspect(manifest, + limit: :infinity, + printable_limit: :infinity, + pretty: true, + custom_options: [sort_maps: true] + ) + + """ + # Generated by `mix mob.regen_plugin_manifest` — do not edit by hand. + # + # The activated plugins' tier-3/4 contributions, read at boot by Mob.Plugins. + # Regenerated whenever `config :mob, :plugins` changes (the deploy/regen hook). + #{rendered} + """ + end + + @doc """ + Writes the rendered manifest to `<host_root>/priv/generated/mob_plugins.exs` + and returns the path. + """ + @spec write(Path.t(), map()) :: Path.t() + def write(host_root, manifest) do + path = Path.join([host_root, "priv", "generated", "mob_plugins.exs"]) + rendered = render(manifest) + File.mkdir_p!(Path.dirname(path)) + + if File.read(path) != {:ok, rendered} do + File.write!(path, rendered) + end + + path + end +end diff --git a/lib/mob_dev/plugin/scaffold.ex b/lib/mob_dev/plugin/scaffold.ex new file mode 100644 index 0000000..facf5de --- /dev/null +++ b/lib/mob_dev/plugin/scaffold.ex @@ -0,0 +1,776 @@ +defmodule MobDev.Plugin.Scaffold do + @moduledoc """ + Pure templates + name conversions behind `mix mob.new_plugin`. + + Inputs are a snake_case plugin name (e.g. `"mob_demo_widget"`) and a tier + (0–4). Output is a list of `{relative_path, content_string}` pairs the Mix + task writes to disk. All conversions live here so the task stays thin and + the templates are unit-testable without filesystem I/O. + + Templates mirror the on-device-verified prototypes (`mob_palette_demo` t0, + `mob_demo_haptic_extras` t1, `mob_demo_signature_pad` t2, + `mob_demo_kv_browser` t3, `mob_demo_subapp` t4) so a freshly scaffolded plugin + compiles + activates by the same path the prototypes already prove. + """ + + @type tier :: 0 | 1 | 2 | 3 | 4 + @type file :: {Path.t(), String.t()} + + @supported_tiers [0, 1, 2, 3, 4] + + # Mob version requirement baked into a freshly scaffolded plugin when the + # installed mob can't be detected (e.g. scaffolding outside a host app). + # `detect_mob_requirement/0` prefers the real installed version; this is the + # floor. Keep it tracking the current published mob major.minor — a Scaffold + # test pins it so it can't silently lag a mob release (see issue #21). + @fallback_mob_requirement "~> 0.7" + + # Names that pass the snake_case regex but produce a broken or non-buildable + # plugin project. `nil`/`true`/`false` are the killers: the scaffold emits + # `app: :<name>` in mix.exs, and Mix treats `:nil`/`:false` as "no app name" + # (`mix compile` then dies with "Cannot access build without an application + # name"); `:true` builds a project whose `config :mob, :plugins, [:true]` + # entry is the boolean. The remaining entries are Elixir reserved words — + # rejected to mirror `mix new`'s `check_application_name!/2`, since a module + # or atom named after a keyword is a footgun for downstream `alias`/match. + @reserved_names ~w( + nil true false + when and or not in fn do end catch rescue after else + case cond if unless try receive with for + def defp defmodule defmacro defmacrop defprotocol defimpl + import alias require use quote unquote super + ) + + @doc """ + Validates a plugin name (must be a snake_case atom-friendly identifier). + """ + @spec validate_name(String.t()) :: :ok | {:error, String.t()} + def validate_name(name) when is_binary(name) do + cond do + name == "" -> + {:error, "plugin name is required"} + + not Regex.match?(~r/^[a-z][a-z0-9_]*$/, name) -> + {:error, + "plugin name #{inspect(name)} must be snake_case (lowercase ASCII letters, digits, underscores; starts with a letter)"} + + name in @reserved_names -> + {:error, + "plugin name #{inspect(name)} is a reserved word; choose another name — " <> + "it is used verbatim as the OTP app atom and module, so it would produce " <> + "a project that does not build (e.g. mix treats `app: :nil`/`:false` as no app name)"} + + true -> + :ok + end + end + + def validate_name(_), do: {:error, "plugin name must be a string"} + + @doc "Validates a tier (0 through 4)." + @spec validate_tier(integer()) :: :ok | {:error, String.t()} + def validate_tier(t) when t in @supported_tiers, do: :ok + + def validate_tier(t), + do: {:error, "tier #{inspect(t)} not supported; expected one of #{inspect(@supported_tiers)}"} + + @doc """ + Converts `"mob_demo_widget"` → `"MobDemoWidget"`. + """ + @spec module_name(String.t()) :: String.t() + def module_name(name) when is_binary(name) do + name + |> String.split("_", trim: true) + |> Enum.map(&String.capitalize/1) + |> Enum.join() + end + + @doc """ + Builds a `"~> MAJOR.MINOR"` mob version requirement from a concrete version. + + `nil` (mob not detectable) yields the compiled `@fallback_mob_requirement`. + Pure so the derivation is unit-testable independent of what's installed. + """ + @spec mob_requirement(String.t() | Version.t() | nil) :: String.t() + def mob_requirement(nil), do: @fallback_mob_requirement + def mob_requirement(%Version{major: major, minor: minor}), do: "~> #{major}.#{minor}" + + def mob_requirement(version) when is_binary(version), + do: mob_requirement(Version.parse!(version)) + + @doc """ + Resolves the mob version requirement for a freshly scaffolded plugin. + + Prefers the version of `:mob` actually resolved in the current project (so a + plugin scaffolded inside a mob 0.7.x app pins `"~> 0.7"`), falling back to + the compiled `@fallback_mob_requirement` when mob isn't loadable (scaffolding + standalone). Impure — the Mix task calls this and threads the result into + `files_for/3`; the templates themselves stay pure. + """ + @spec detect_mob_requirement() :: String.t() + def detect_mob_requirement do + _ = Application.load(:mob) + + case Application.spec(:mob, :vsn) do + nil -> mob_requirement(nil) + vsn -> mob_requirement(List.to_string(vsn)) + end + end + + @doc """ + Returns the file list for a given tier + name. Each entry is + `{relative_path, content}`. `relative_path` is relative to the plugin's + root directory. + + `mob_req` is the `mob` version requirement to embed in the generated + `mix.exs` and manifest; defaults to `@fallback_mob_requirement`. The Mix + task passes `detect_mob_requirement/0` so a scaffolded plugin pins the mob + it's being generated against. + """ + @spec files_for(tier(), String.t(), String.t()) :: [file()] + def files_for(tier, name, mob_req \\ @fallback_mob_requirement) + + def files_for(0, name, mob_req) do + [ + {"mix.exs", mix_exs(name, mob_req)}, + {"lib/#{name}.ex", tier0_lib(name)}, + {"test/test_helper.exs", test_helper()}, + {"test/#{name}_test.exs", tier0_test(name)} + ] + end + + def files_for(1, name, mob_req) do + nif_name = "#{name}_nif" + + [ + {"mix.exs", mix_exs(name, mob_req)}, + {"lib/#{name}.ex", tier1_lib(name, nif_name)}, + {"src/#{nif_name}.erl", tier1_erl_stub(nif_name)}, + {"priv/mob_plugin.exs", tier1_manifest(name, nif_name, mob_req)}, + {"priv/native/jni/#{nif_name}.c", tier1_c(nif_name)}, + {"test/test_helper.exs", test_helper()}, + {"test/#{name}_test.exs", plugin_test(name)} + ] + end + + def files_for(2, name, mob_req) do + mod = module_name(name) + registry_name = "#{mod}_View" + + [ + {"mix.exs", mix_exs(name, mob_req)}, + {"lib/#{name}.ex", tier2_lib(name, mod)}, + {"lib/#{name}/view.ex", tier2_view(mod)}, + {"priv/mob_plugin.exs", tier2_manifest(name, mod, registry_name, mob_req)}, + {"priv/native/android/#{mod}.kt", tier2_kt(mod, registry_name)}, + {"priv/native/ios/#{mod}View.swift", tier2_swift(mod)}, + {"test/test_helper.exs", test_helper()}, + {"test/#{name}_test.exs", plugin_test(name)} + ] + end + + def files_for(3, name, mob_req) do + mod = module_name(name) + + [ + {"mix.exs", mix_exs(name, mob_req)}, + {"lib/#{name}/list_screen.ex", tier3_list_screen(mod)}, + {"lib/#{name}/detail_screen.ex", tier3_detail_screen(mod)}, + {"priv/mob_plugin.exs", tier3_manifest(name, mod, mob_req)}, + {"priv/repo/migrations/20260101000000_create_#{name}_items.exs", tier3_migration(mod)}, + {"test/test_helper.exs", test_helper()}, + {"test/#{name}_test.exs", plugin_test(name)} + ] + end + + def files_for(4, name, mob_req) do + mod = module_name(name) + + [ + {"mix.exs", mix_exs(name, mob_req)}, + {"lib/#{name}.ex", tier4_lib(mod)}, + {"lib/#{name}/worker.ex", tier4_worker(mod)}, + {"lib/#{name}/notifications.ex", tier4_notifications(mod)}, + {"lib/#{name}/settings_screen.ex", tier4_settings_screen(mod)}, + {"priv/mob_plugin.exs", tier4_manifest(name, mod, mob_req)}, + {"test/test_helper.exs", test_helper()}, + {"test/#{name}_test.exs", plugin_test(name)} + ] + end + + # ── mix.exs (same for all tiers) ────────────────────────────────────────── + + defp mix_exs(name, mob_req) do + mod = module_name(name) + + """ + defmodule #{mod}.MixProject do + use Mix.Project + + def project do + [ + app: :#{name}, + version: "0.1.0", + elixir: "~> 1.17", + deps: deps() + ] + end + + def application do + [extra_applications: [:logger]] + end + + defp deps do + [ + {:mob, "#{mob_req}"} + ] + end + end + """ + end + + # ── Test scaffolding (all tiers) ────────────────────────────────────────── + # Stdlib-only on purpose: a scaffolded plugin can live anywhere, so it can't + # assume a path to mob_dev. The full validator still runs from a host app + # via `mix mob.validate_plugin`. + + defp test_helper, do: "ExUnit.start()\n" + + defp tier0_test(name) do + mod = module_name(name) + + """ + defmodule #{mod}Test do + use ExUnit.Case, async: true + + # Tier 0 ships no manifest — the contract is just "the module compiles + # against mob". Grow this suite alongside your plugin's pure logic. + test "the plugin module compiles" do + assert Code.ensure_loaded?(#{mod}) + end + end + """ + end + + defp plugin_test(name) do + mod = module_name(name) + + """ + defmodule #{mod}Test do + use ExUnit.Case, async: true + + # Structural checks that run with no extra deps. For the full pre-publish + # validation (path/NIF/permission rules + cross-plugin collisions) run + # `mix mob.validate_plugin` from a host app that has mob_dev. Grow this + # suite alongside your plugin's pure logic (option builders, parsers, …). + @plugin_dir Path.expand("..", __DIR__) + @manifest_path Path.join(@plugin_dir, "priv/mob_plugin.exs") + + test "manifest evaluates to a map with the required keys" do + assert {%{} = m, _} = Code.eval_file(@manifest_path) + assert m.name == :#{name} + assert is_binary(m.mob_version) + assert is_integer(m.plugin_spec_version) + end + + test "every NIF entry has a loadable stub module and an existing native_dir" do + {m, _} = Code.eval_file(@manifest_path) + + for %{module: nif_mod, native_dir: dir} <- Map.get(m, :nifs, []) do + assert Code.ensure_loaded?(nif_mod), "src/\#{nif_mod}.erl stub missing or broken" + assert File.dir?(Path.join(@plugin_dir, dir)), "\#{dir} missing" + end + end + + test "every screen module the manifest references compiles" do + {m, _} = Code.eval_file(@manifest_path) + + for %{module: screen_mod} <- Map.get(m, :screens, []) do + assert Code.ensure_loaded?(screen_mod) + end + end + end + """ + end + + # ── Tier 0 ──────────────────────────────────────────────────────────────── + + defp tier0_lib(name) do + mod = module_name(name) + + """ + defmodule #{mod} do + @moduledoc \"\"\" + Tier-0 mob plugin: pure-Elixir, no manifest, hot-pushable. + + A regular Hex package depending on `:mob`. mob_dev treats it as an + ordinary dependency; it shows in `mix mob.plugins` only once activated + in the host's `mob.exs`: + + config :mob, :plugins, [:#{name}] + + Replace `hello/0` with your plugin's API. + \"\"\" + + @doc "Example helper — replace with your plugin's real API." + def hello, do: :ok + end + """ + end + + # ── Tier 1 ──────────────────────────────────────────────────────────────── + + defp tier1_lib(name, nif_name) do + mod = module_name(name) + + """ + defmodule #{mod} do + @moduledoc \"\"\" + Tier-1 mob plugin: native NIF + Elixir wrapper. + + The NIF lives in `src/#{nif_name}.erl` (Erlang stub with tolerant + on_load) + `priv/native/jni/#{nif_name}.c` (the C side, ERL_NIF_INIT + under static linking). This Elixir wrapper delegates to it. + + Activate in your host's `mob.exs`: + + config :mob, :plugins, [:#{name}] + \"\"\" + + defdelegate ping, to: :#{nif_name} + end + """ + end + + defp tier1_erl_stub(nif_name) do + """ + %% #{nif_name} — Erlang NIF stub for the tier-1 plugin. + %% + %% The C side (priv/native/jni/#{nif_name}.c) registers functions under + %% this module name via ERL_NIF_INIT. On device the NIF is statically + %% linked into the host binary; on a host dev build it isn't linked, so + %% on_load tolerates the load failure (returning ok keeps the module + %% loadable) and ping/0 falls back to nif_error until the native merge + %% links it. + -module(#{nif_name}). + -export([ping/0]). + -on_load(init/0). + + init() -> + case erlang:load_nif("#{nif_name}", 0) of + ok -> ok; + {error, _} -> ok + end. + + ping() -> + erlang:nif_error(nif_not_loaded). + """ + end + + defp tier1_manifest(name, nif_name, mob_req) do + """ + %{ + name: :#{name}, + mob_version: "#{mob_req}", + plugin_spec_version: 1, + description: "TODO: describe your plugin", + nifs: [ + # :module is the C/Erlang NIF name (a valid C token), NOT an Elixir + # module — ERL_NIF_INIT uses it as both the registered module name + # and the static-init C symbol prefix. + %{module: :#{nif_name}, native_dir: "priv/native/jni"} + ] + } + """ + end + + defp tier1_c(nif_name) do + """ + /* #{nif_name} — tier-1 plugin NIF. + * + * The compile-time merge engine compiles this with + * -DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME=#{nif_name}, + * so ERL_NIF_INIT emits the static init symbol #{nif_name}_nif_init() + * that the driver_tab generated by `mix mob.regen_driver_tab` references. + */ + #include <erl_nif.h> + + static ERL_NIF_TERM ping(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + (void)argv; + return enif_make_atom(env, "ok"); + } + + static ErlNifFunc nif_funcs[] = { + {"ping", 0, ping}, + }; + + ERL_NIF_INIT(#{nif_name}, nif_funcs, NULL, NULL, NULL, NULL) + """ + end + + # ── Tier 2 ──────────────────────────────────────────────────────────────── + + defp tier2_lib(_name, mod) do + """ + defmodule #{mod} do + @moduledoc \"\"\" + Tier-2 mob plugin: a native UI component. + + Wraps `Mob.UI.native_view` so a host screen can write: + + use Mob.Sigil + + ~MOB\""" + <Column> + {#{mod}.widget(id: :w)} + </Column> + \""" + + The matching `#{mod}.View` (`use Mob.Component`) owns Elixir-side state. + The host's `MobBridge.kt` registers the Kotlin factory under + `"#{mod}_View"` (Elixir-module name stripped of `Elixir.` with dots → + underscores — the convention `Mob.Component` documents). + \"\"\" + + @doc \"\"\" + Returns a `Mob.UI.native_view` node for the component. `:id` is required + and must be unique on the screen. + \"\"\" + def widget(opts \\\\ []) do + {id, props} = Keyword.pop(opts, :id) + + unless is_atom(id) and not is_nil(id) do + raise ArgumentError, "#{mod}.widget/1 requires an :id atom" + end + + Mob.UI.native_view(#{mod}.View, [{:id, id} | props]) + end + end + """ + end + + defp tier2_view(mod) do + """ + defmodule #{mod}.View do + @moduledoc \"\"\" + `Mob.Component` for #{mod}. Native registration key is + `"#{mod}_View"` (the convention in `Mob.Component`'s docs). + \"\"\" + use Mob.Component + + @impl true + def mount(props, socket) do + {:ok, Mob.Socket.assign(socket, :label, props[:label] || "Hello from #{mod}")} + end + + @impl true + def update(props, socket) do + {:ok, Mob.Socket.assign(socket, :label, props[:label] || socket.assigns.label)} + end + + @impl true + def render(assigns) do + %{label: assigns.label} + end + end + """ + end + + defp tier2_manifest(name, mod, registry_name, mob_req) do + """ + %{ + name: :#{name}, + mob_version: "#{mob_req}", + plugin_spec_version: 1, + description: "TODO: describe your plugin", + + ui_components: [ + %{ + tag: "#{mod}", + atom: :#{name}, + props: [:label], + # Native registration name = `<Elixir module>`, stripped of `Elixir.` + # with dots → `_`. Matches what `Mob.Component` emits as the + # `:module` prop at render time, and what `MobNativeViewRegistry` + # looks up. + ios: %{view_module: "#{registry_name}"}, + android: %{composable: "#{registry_name}"} + } + ] + } + """ + end + + defp tier2_kt(mod, registry_name) do + """ + // #{mod} — tier-2 plugin Compose factory. + // + // Until the plugin merge engine wires plugin Kotlin into the build + // automatically, the host app developer copies this content into + // MobBridge.kt (alongside the MobNativeViewRegistry definition) and + // arranges #{mod}Plugin.register() to run at startup — the documented + // workflow for native components today. + + object #{mod}Plugin { + fun register() { + MobNativeViewRegistry.register("#{registry_name}") { props, _send -> + #{mod}Composable(props) + } + } + } + + @Composable + private fun #{mod}Composable(props: Map<String, Any?>) { + val label = (props["label"] as? String) ?: "#{mod}" + Text(label) + } + """ + end + + defp tier2_swift(mod) do + """ + // #{mod}View — tier-2 plugin SwiftUI view. + // Mirrors the Android Compose factory in priv/native/android/#{mod}.kt. + // Once the host iOS init wires plugin views into the native_view + // dispatch, this is registered under `"#{mod}_View"` (the Mob.Component + // module-name encoding). + import SwiftUI + + struct #{mod}View: View { + let props: [String: Any] + + var body: some View { + let label = props["label"] as? String ?? "#{mod}" + Text(label) + } + } + """ + end + + # ── Tier 3 — multi-screen + migration ───────────────────────────────────── + + defp tier3_list_screen(mod) do + """ + defmodule #{mod}.ListScreen do + @moduledoc \"\"\" + Tier-3 plugin screen. The host registers it as a navigable destination at + boot (by `default_route`); tapping a row pushes the detail screen. + \"\"\" + use Mob.Screen + + @items ["alpha", "beta", "gamma"] + + def mount(_params, _session, socket), do: {:ok, socket} + + def render(_assigns) do + ~MOB\""" + <Scroll background={:background}> + <Column background={:background} padding={:space_lg}> + <Text text="#{mod}" text_size={:xl} text_color={:on_surface} padding={:space_sm} /> + {for item <- @items, do: row(item)} + </Column> + </Scroll> + \""" + end + + def handle_event("open", %{"key" => key}, socket) do + {:noreply, Mob.Socket.push_screen(socket, #{mod}.DetailScreen, %{key: key})} + end + + defp row(item) do + ~MOB\""" + <Button text={item} background={:primary} text_color={:on_primary} + padding={:space_md} fill_width={true} on_tap={{self(), {:open, item}}} /> + \""" + end + end + """ + end + + defp tier3_detail_screen(mod) do + """ + defmodule #{mod}.DetailScreen do + @moduledoc "Tier-3 plugin detail screen, pushed from the list screen." + use Mob.Screen + + def mount(params, _session, socket) do + {:ok, Mob.Socket.assign(socket, :key, params[:key] || params["key"] || "?")} + end + + def render(assigns) do + ~MOB\""" + <Scroll background={:background}> + <Column background={:background} padding={:space_lg}> + <Text text={"key: " <> assigns.key} text_size={:lg} text_color={:on_surface} padding={4} /> + <Button text="Back" background={:primary} text_color={:on_primary} + padding={:space_md} on_tap={{self(), :back}} /> + </Column> + </Scroll> + \""" + end + + def handle_event("back", _params, socket), do: {:noreply, Mob.Socket.pop_screen(socket)} + end + """ + end + + defp tier3_manifest(name, mod, mob_req) do + """ + %{ + name: :#{name}, + mob_version: "#{mob_req}", + plugin_spec_version: 1, + description: "TODO: describe your plugin", + + # Whole screens the host can navigate to. Registered by default_route at + # boot; two distinct plugins may not claim the same route (cross-plugin + # validation rejects it — see MOB_PLUGINS.md "Cross-plugin conflict detection"). + screens: [ + %{module: #{mod}.ListScreen, default_route: "/#{name}/list"}, + %{module: #{mod}.DetailScreen, default_route: "/#{name}/detail"} + ], + + # Ecto migrations the plugin ships. mob_dev copies them into the host's + # migrations dir at `--native` build, prefixing each with repo_namespace + # (so vendors don't collide); the host's Ecto.Migrator runs them. The + # repo_namespace must be unique across activated plugins. + migrations: %{ + repo_namespace: "#{name}_", + migrations_dir: "priv/repo/migrations" + } + + # Optional tier-3 assets — add real files then uncomment: + # + # assets: %{ + # fonts: ["priv/fonts/MyFont.ttf"], # registered (iOS UIAppFonts / Android res/font) + # images: ["priv/assets/icon.png"] # addressable via plugin://#{name}/icon.png + # } + } + """ + end + + defp tier3_migration(mod) do + """ + defmodule #{mod}.Migrations.CreateItems do + # Rename this file with a real timestamp before publishing (the leading + # integer is the Ecto version). mob_dev namespaces the copied filename by + # the plugin's repo_namespace so it can't collide with other plugins'. + use Ecto.Migration + + def change do + create table(:#{Macro.underscore(mod)}_items) do + add(:name, :string, null: false) + end + end + end + """ + end + + # ── Tier 4 — embedded sub-app (lifecycle + settings + notifications) ─────── + + defp tier4_lib(mod) do + """ + defmodule #{mod} do + @moduledoc \"\"\" + Tier-4 sub-app plugin: lifecycle hooks + a supervised worker + settings + + a notification handler. The host runs `on_start` at boot (under the plugin + supervisor), starts the `supervised` children, and calls `on_resume` / + `on_background` on OS foreground/background transitions. + \"\"\" + + @doc "lifecycle.on_start — runs once at boot under the plugin supervisor." + def start, do: :ok + + @doc "lifecycle.on_resume — host came to the foreground." + def on_resume, do: :ok + + @doc "lifecycle.on_background — host went to the background." + def on_background, do: :ok + end + """ + end + + defp tier4_worker(mod) do + """ + defmodule #{mod}.Worker do + @moduledoc "Supervised background worker for the tier-4 plugin." + use GenServer + + def start_link(_arg), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + + @impl GenServer + def init(:ok), do: {:ok, %{}} + end + """ + end + + defp tier4_notifications(mod) do + """ + defmodule #{mod}.Notifications do + @moduledoc "Notification handler — invoked when an incoming payload matches." + + @doc "Handles a notification payload routed here by the host dispatcher." + def handle(_payload), do: :ok + end + """ + end + + defp tier4_settings_screen(mod) do + """ + defmodule #{mod}.SettingsScreen do + @moduledoc "Settings editor screen the host pushes for this plugin." + use Mob.Screen + + def mount(_params, _session, socket), do: {:ok, socket} + + def render(_assigns) do + ~MOB\""" + <Column background={:background} padding={:space_lg}> + <Text text="#{mod} settings" text_size={:xl} text_color={:on_surface} /> + </Column> + \""" + end + end + """ + end + + defp tier4_manifest(name, mod, mob_req) do + """ + %{ + name: :#{name}, + mob_version: "#{mob_req}", + plugin_spec_version: 1, + description: "TODO: describe your plugin", + + # Lifecycle hooks + supervised children. on_start/on_resume/on_background + # are {Module, fun, args} MFAs; supervised children join the host's plugin + # supervisor. A supervised worker's registered name must be unique across + # activated plugins. + lifecycle: %{ + on_start: {#{mod}, :start, []}, + on_resume: {#{mod}, :on_resume, []}, + on_background: {#{mod}, :on_background, []}, + supervised: [#{mod}.Worker] + }, + + # Typed, per-plugin-namespaced settings (read/written via Mob.Plugins + # get_setting/3 + put_setting/4, validated against :type). editor_screen + # is the screen the host pushes to let the user change them. + settings: %{ + schema: [%{key: :enabled, type: :boolean, default: true}], + editor_screen: #{mod}.SettingsScreen + }, + + # Notification handlers. `match` is a map prefix-matched against the + # payload (or a 1-arity predicate); the first matching handler across all + # plugins wins, so two plugins may not declare the identical match. + notifications: %{ + handlers: [ + %{match: %{type: "#{name}"}, handler: {#{mod}.Notifications, :handle, 1}} + ] + } + } + """ + end +end diff --git a/lib/mob_dev/plugin/sign.ex b/lib/mob_dev/plugin/sign.ex new file mode 100644 index 0000000..3438657 --- /dev/null +++ b/lib/mob_dev/plugin/sign.ex @@ -0,0 +1,230 @@ +defmodule MobDev.Plugin.Sign do + @moduledoc """ + Author-side signing workflow for mob plugins. + + Produces `priv/mob_plugin.sig` for a plugin directory by: + + 1. Loading the manifest (`priv/mob_plugin.exs`). + 2. Computing SHA-256 hashes for every file the manifest references + (Swift sources, Android bridge/JNI sources, NIF native_dir contents). + 3. Building the canonical payload (manifest + sorted file hashes). + 4. Signing the canonical encoding of the payload via `Crypto.sign/2`. + 5. Writing a versioned binary envelope to `priv/mob_plugin.sig`. + + Pure helpers are exposed for tests: `compute_file_hashes/2` and + `build_payload/2` are deterministic given their inputs. + """ + + alias MobDev.Plugin.{Crypto, Manifest} + + @legacy_envelope_version 1 + @envelope_version 2 + @supported_envelope_versions [@legacy_envelope_version, @envelope_version] + + @signature_file "priv/mob_plugin.sig" + @manifest_file "priv/mob_plugin.exs" + + # File extensions to include when a manifest entry points at a + # `:native_dir` (NIF C/C++/Objective-C/Objective-C++/Zig sources + headers). + # The set is fixed because the build pipeline only compiles these extensions. + @legacy_nif_extensions [".c", ".h", ".cpp", ".zig"] + @nif_extensions [".c", ".h", ".cpp", ".m", ".mm", ".zig"] + + @typedoc "A supported plugin-signature envelope and payload version." + @type signature_version :: 1 | 2 + + @typedoc "Relative path inside the plugin directory." + @type rel_path :: String.t() + + @typedoc "SHA-256 digest of a single file (raw 32-byte binary)." + @type file_hash :: binary() + + @typedoc "Sorted list of `{relative_path, sha256}` tuples." + @type file_hashes :: [{rel_path(), file_hash()}] + + @doc """ + Returns the relative-path-sorted list of `{relative_path, sha256}` + tuples for every file the manifest references. + + Pure given the plugin dir + manifest. The set covers: + + - `manifest.ios.swift_files` — single files (list of paths). + - `manifest.android.bridge_kt` and `manifest.android.jni_source` — + single paths each. + - `manifest.android.res_files` — the resource files copied verbatim + into the app `res/` tree (list of paths). + - `manifest.nifs[].native_dir` — recursive over `.c`, `.h`, `.cpp`, `.m`, + `.mm`, and `.zig` files inside. This is the only case where a directory + is expanded. + + Other manifest fields are either name-only (component atoms, + `swift_struct`) or pure data (plist keys, permission strings, + framework names) and are covered by the manifest term itself being + part of the signed payload. + + Missing files are skipped silently — `Validator.validate_plugin/3` + is responsible for refusing to publish a plugin with missing + declared paths, so the signing surface assumes paths that exist. + """ + @spec compute_file_hashes(Path.t(), map() | nil) :: file_hashes() + def compute_file_hashes(plugin_dir, manifest) do + compute_file_hashes(plugin_dir, manifest, @envelope_version) + end + + @doc false + @spec compute_file_hashes(Path.t(), map() | nil, signature_version()) :: file_hashes() + def compute_file_hashes(_plugin_dir, nil, version) + when version in @supported_envelope_versions, + do: [] + + def compute_file_hashes(plugin_dir, manifest, version) + when is_map(manifest) and version in @supported_envelope_versions do + manifest + |> referenced_files(plugin_dir, version) + |> Enum.uniq() + |> Enum.sort() + |> Enum.map(fn rel -> {rel, sha256!(Path.join(plugin_dir, rel))} end) + end + + @doc """ + Builds the canonical payload term that gets signed. + + Shape: + + %{ + manifest: <the loaded mob_plugin manifest>, + file_hashes: [{rel_path, sha256}, ...], + envelope_version: 2 + } + + The two-argument form always builds the current v2 payload. Verification + uses the versioned form to reconstruct the exact frozen v1 payload for + already-shipped signatures. The version is part of the signed payload, so + changing the envelope version without resigning fails cryptographically. + """ + @spec build_payload(map() | nil, file_hashes()) :: map() + def build_payload(manifest, file_hashes) do + build_payload(manifest, file_hashes, @envelope_version) + end + + @doc false + @spec build_payload(map() | nil, file_hashes(), signature_version()) :: map() + def build_payload(manifest, file_hashes, version) + when version in @supported_envelope_versions do + %{ + manifest: manifest, + file_hashes: file_hashes, + envelope_version: version + } + end + + # CAVEAT — atom keys in the signed terms (this payload + the sig envelope in + # `sign_plugin/2`) must also appear in `MobDev.Plugin.Verify`'s + # `@envelope_atoms`. Verify decodes the .sig with binary_to_term(_, [:safe]), + # which won't *create* atoms — any atom key it hasn't interned at load time + # makes a valid signature decode as :corrupt, intermittently (depends on what + # else loaded first). Adding a key here without updating @envelope_atoms + # reintroduces that bug. See decisions/2026-05-31-verify-safe-atom-intern.md. + + @doc """ + Signs `plugin_dir` and writes `priv/mob_plugin.sig`. + + Orchestrates the full author workflow: loads the manifest, computes + file hashes, builds the payload, signs it, wraps the signature in the + envelope binary, and writes the file. Returns `:ok` on success or + `{:error, reason}` if the manifest is missing/invalid. + """ + @spec sign_plugin(Path.t(), Crypto.priv_key()) :: :ok | {:error, term()} + def sign_plugin(plugin_dir, priv_key) when is_binary(priv_key) do + with {:ok, manifest} <- Manifest.load(plugin_dir), + :ok <- refuse_if_no_manifest(manifest, plugin_dir) do + file_hashes = compute_file_hashes(plugin_dir, manifest) + payload = build_payload(manifest, file_hashes) + signature = Crypto.sign(payload, priv_key) + + envelope = %{signature: signature, envelope_version: @envelope_version} + sig_path = Path.join(plugin_dir, @signature_file) + File.mkdir_p!(Path.dirname(sig_path)) + File.write!(sig_path, Crypto.canonical_encode(envelope)) + :ok + end + end + + @doc "Relative path inside a plugin dir where the signature lives." + @spec signature_path(Path.t()) :: Path.t() + def signature_path(plugin_dir), do: Path.join(plugin_dir, @signature_file) + + @doc "Relative path inside a plugin dir where the manifest lives." + @spec manifest_path(Path.t()) :: Path.t() + def manifest_path(plugin_dir), do: Path.join(plugin_dir, @manifest_file) + + @doc "Current signing envelope version." + @spec envelope_version() :: integer() + def envelope_version, do: @envelope_version + + defp refuse_if_no_manifest(nil, plugin_dir), + do: {:error, "no priv/mob_plugin.exs in #{plugin_dir}"} + + defp refuse_if_no_manifest(_manifest, _plugin_dir), do: :ok + + # ── referenced-file collection ──────────────────────────────────────────── + + defp referenced_files(manifest, plugin_dir, version) do + swift = list_of_strings(get_in(manifest, [:ios, :swift_files])) + + android = + [ + get_in(manifest, [:android, :bridge_kt]), + get_in(manifest, [:android, :jni_source]) + ] + |> Enum.filter(&is_binary/1) + + # res_files are copied verbatim into the app res/ tree, so their bytes must + # be tamper-evident too (same as bridge_kt / jni_source). + res = list_of_strings(get_in(manifest, [:android, :res_files])) + + nifs = nif_files(manifest, plugin_dir, version) + + swift ++ android ++ res ++ nifs + end + + defp nif_files(manifest, plugin_dir, version) do + for nif <- Map.get(manifest, :nifs, []) || [], + is_map(nif), + rel = nif[:native_dir], + is_binary(rel), + path <- expand_native_dir(plugin_dir, rel, version) do + path + end + end + + defp expand_native_dir(plugin_dir, rel_dir, version) do + abs_dir = Path.join(plugin_dir, rel_dir) + extensions = nif_extensions(version) + + if File.dir?(abs_dir) do + abs_dir + |> Path.join("**/*") + |> Path.wildcard() + |> Enum.filter(&File.regular?/1) + |> Enum.filter(fn p -> Path.extname(p) in extensions end) + |> Enum.map(&Path.relative_to(&1, plugin_dir)) + else + [] + end + end + + defp nif_extensions(@legacy_envelope_version), do: @legacy_nif_extensions + defp nif_extensions(@envelope_version), do: @nif_extensions + + defp list_of_strings(value) do + for s <- List.wrap(value), is_binary(s), do: s + end + + defp sha256!(path) do + case File.read(path) do + {:ok, bytes} -> :crypto.hash(:sha256, bytes) + {:error, _} -> :crypto.hash(:sha256, <<>>) + end + end +end diff --git a/lib/mob_dev/plugin/signature_gate.ex b/lib/mob_dev/plugin/signature_gate.ex new file mode 100644 index 0000000..fe4bacd --- /dev/null +++ b/lib/mob_dev/plugin/signature_gate.ex @@ -0,0 +1,230 @@ +defmodule MobDev.Plugin.SignatureGate do + @moduledoc """ + Host-side build-time gate: runs `Verify.verify_plugin/2` + the + `TrustStore` check across every activated plugin and refuses the + build on any failure. + + This is the Phase 2 cryptographic counterpart to the capability + drift check in `Validator`. Both run at the same hook point inside + `Validator.raise_on_capability_drift!/1` so the iOS-sim, iOS-device, + and Android paths all enforce them as a one-liner. + + Three distinct failure modes are surfaced (per `MOB_PLUGIN_SECURITY.md`, + Phase 2): + + - **Missing signature** — author hasn't run `mix mob.plugin.sign`. + Suppressible per-plugin via `config :mob, :acknowledge_unsafe_plugins` + with a persistent banner. + - **Invalid signature** — sig is present but doesn't verify; the + manifest or sources have been tampered with after signing. Not + suppressible. + - **Untrusted fingerprint** — signature verifies but the public key + isn't in `config :mob, :trusted_plugins` (or is a different key + from the trusted one, the key-rotation case). Not suppressible; + user must run `mix mob.plugin.trust <name>`. + """ + + alias MobDev.Plugin.{Crypto, TrustStore, Verify} + + @typedoc "Errors `check_plugin/2` can return." + @type gate_error :: + {:missing_signature, atom()} + | {:missing_pubkey, atom()} + | {:invalid_signature, atom()} + | {:untrusted, atom(), Crypto.fingerprint(), Crypto.fingerprint() | nil} + + @doc """ + Runs the signature + trust check across `plugins` (the + `MobDev.Plugin.activated/0` shape — `[{plugin_dir, manifest}]`). + + Returns `:ok` when every plugin verifies AND is trusted (or, for + missing signatures, is listed in `config :mob, :acknowledge_unsafe_plugins`). + Returns `{:error, errors}` otherwise — a list of `t:gate_error/0` tagged + by plugin name. + + Reads the trust map from `mob.exs` (via `TrustStore.load_trusted_plugins/0`) + and the acknowledgement list from `:mob`'s Application env or `mob.exs`. + Pass the trust_map + acknowledged list explicitly via + `check_activated/3` from tests that need isolation. + """ + @spec check_activated([{Path.t(), map() | nil}]) :: :ok | {:error, [gate_error()]} + def check_activated(plugins) do + check_activated(plugins, TrustStore.load_trusted_plugins(), acknowledged_unsafe()) + end + + @doc "Pure variant of `check_activated/1` for tests." + @spec check_activated([{Path.t(), map() | nil}], TrustStore.trust_map(), [atom()]) :: + :ok | {:error, [gate_error()]} + def check_activated(plugins, trust_map, acknowledged) do + errors = + for {dir, manifest} <- plugins, + is_map(manifest), + err = check_plugin(dir, manifest, trust_map, acknowledged), + err != :ok do + err + end + + case errors do + [] -> :ok + errs -> {:error, errs} + end + end + + @doc """ + Runs `check_activated/1` and raises a `Mix.raise/1` with a clear, + actionable message when any plugin fails. No-op on success. + """ + @spec raise_on_signature_drift!([{Path.t(), map() | nil}]) :: :ok + def raise_on_signature_drift!(plugins) do + case check_activated(plugins) do + :ok -> + :ok + + {:error, errors} -> + Mix.raise(format_errors(errors)) + end + end + + @doc """ + Prints a stderr banner when any activated plugin is allowed only via + `:acknowledge_unsafe_plugins`. Idempotent within a single Mix invocation + in spirit — the banner fires every time it's called, so callers should + invoke it once per build. + """ + @spec maybe_print_unsafe_banner([{Path.t(), map() | nil}]) :: :ok + def maybe_print_unsafe_banner(plugins) do + acknowledged = acknowledged_unsafe() + + unsafe = + for {dir, manifest} <- plugins, + is_map(manifest), + name = manifest[:name], + name in acknowledged, + {:error, :missing} <- [Verify.load_signature(dir)] do + name + end + + case unsafe do + [] -> + :ok + + names -> + names_str = Enum.map_join(names, ", ", &Atom.to_string/1) + + IO.puts( + :stderr, + [ + "\n", + IO.ANSI.yellow(), + "⚠ unsigned mob plugins enabled: ", + names_str, + "\n these are not cryptographically verified — disable for production\n", + IO.ANSI.reset() + ] + ) + + :ok + end + end + + @doc false + # Public for tests: checks a single plugin against the trust map and + # acknowledgement list. Returns `:ok` on pass, a gate_error otherwise. + @spec check_plugin(Path.t(), map(), TrustStore.trust_map(), [atom()]) :: :ok | gate_error() + def check_plugin(dir, manifest, trust_map, acknowledged) do + name = manifest[:name] + + case Verify.verify_plugin(dir, manifest) do + :ok -> + check_trust(dir, name, trust_map) + + {:error, :missing_signature} -> + if name in acknowledged, do: :ok, else: {:missing_signature, name} + + {:error, :missing_pubkey} -> + {:missing_pubkey, name} + + {:error, :invalid_signature} -> + {:invalid_signature, name} + end + end + + defp check_trust(dir, name, trust_map) do + case Verify.load_pubkey(dir) do + {:ok, pub} -> + actual_fp = Crypto.fingerprint(pub) + trusted_fp = Map.get(trust_map, name) + + if trusted_fp == actual_fp do + :ok + else + {:untrusted, name, actual_fp, trusted_fp} + end + + {:error, _} -> + {:missing_pubkey, name} + end + end + + defp acknowledged_unsafe do + Application.get_env(:mob, :acknowledge_unsafe_plugins, []) ++ + read_acknowledged_from_mob_exs() + end + + defp read_acknowledged_from_mob_exs do + config_file = Path.join(File.cwd!(), "mob.exs") + + if File.exists?(config_file) do + config_file + |> Config.Reader.read!() + |> Keyword.get(:mob, []) + |> Keyword.get(:acknowledge_unsafe_plugins, []) + else + [] + end + rescue + _ -> [] + end + + # ── error formatting ────────────────────────────────────────────────────── + + defp format_errors(errors) do + bullets = + errors + |> Enum.uniq() + |> Enum.map_join("\n\n", &format_error/1) + + "plugin signature check failed — refusing to build (see MOB_PLUGIN_SECURITY.md, Phase 2):\n\n" <> + bullets + end + + defp format_error({:missing_signature, name}) do + " - plugin #{inspect(name)} is not signed — author must run `mix mob.plugin.sign`.\n" <> + " To allow unsigned plugins during development, add\n" <> + " config :mob, :acknowledge_unsafe_plugins, [#{inspect(name)}]\n" <> + " to mob.exs (a persistent banner will print on every build)." + end + + defp format_error({:missing_pubkey, name}) do + " - plugin #{inspect(name)} is signed but ships no priv/mob_plugin.pub —\n" <> + " cannot verify. Re-run `mix mob.plugin.sign` after `mix mob.plugin.keygen`." + end + + defp format_error({:invalid_signature, name}) do + " - signature for plugin #{inspect(name)} is invalid — this can indicate\n" <> + " tampering with the plugin's manifest or source files." + end + + defp format_error({:untrusted, name, actual_fp, nil}) do + " - plugin #{inspect(name)} is signed with key\n" <> + " #{actual_fp}\n" <> + " but is not trusted. Run `mix mob.plugin.trust #{name}` after reviewing the plugin." + end + + defp format_error({:untrusted, name, actual_fp, trusted_fp}) do + " - plugin #{inspect(name)} key rotation detected:\n" <> + " trusted: #{trusted_fp}\n" <> + " signed with: #{actual_fp}\n" <> + " Run `mix mob.plugin.trust #{name}` again to accept the new key." + end +end diff --git a/lib/mob_dev/plugin/trust_store.ex b/lib/mob_dev/plugin/trust_store.ex new file mode 100644 index 0000000..315789f --- /dev/null +++ b/lib/mob_dev/plugin/trust_store.ex @@ -0,0 +1,180 @@ +defmodule MobDev.Plugin.TrustStore do + @moduledoc """ + Reads + writes the `config :mob, :trusted_plugins, %{...}` entry in `mob.exs`. + + Trust is per-host: each host project records which plugin fingerprints + it trusts in its own `mob.exs`. There is no central registry; the + initial trust is established once via `mix mob.plugin.trust <name>`. + + The on-disk shape is: + + config :mob, :trusted_plugins, %{ + mob_foo: "ed25519:abc...=", + mob_bar: "ed25519:def...=" + } + + Mutations (`add_trust/2`, `remove_trust/1`) work line-by-line over + the existing `mob.exs` so unrelated config and user comments are + preserved. `mob.exs` is the source of truth — `load_trusted_plugins/0` + reads it via `Config.Reader.read!` exactly like the activation + reader in `MobDev.Plugin.activated_names/0`. + """ + + alias MobDev.Plugin.Crypto + + @config_file "mob.exs" + + @typedoc "Map of plugin name to `\"ed25519:<base64>\"` fingerprint." + @type trust_map :: %{atom() => String.t()} + + @doc """ + Reads `config :mob, :trusted_plugins` from `mob.exs` in the cwd. + + Returns an empty map when the key is unset or the file is missing. + Pure: this function uses `Config.Reader.read!`, the same approach + used to read `config :mob, :plugins` (the activation list). + """ + @spec load_trusted_plugins() :: trust_map() + def load_trusted_plugins do + load_trusted_plugins(File.cwd!()) + end + + @doc "Variant of `load_trusted_plugins/0` that reads from `project_dir`." + @spec load_trusted_plugins(Path.t()) :: trust_map() + def load_trusted_plugins(project_dir) do + path = Path.join(project_dir, @config_file) + + if File.exists?(path) do + path + |> Config.Reader.read!() + |> Keyword.get(:mob, []) + |> Keyword.get(:trusted_plugins, %{}) + else + %{} + end + rescue + _ -> %{} + end + + @doc """ + Returns `true` if the stored fingerprint for `name` matches `pub_bin`. + + A plugin with no trust entry is not trusted. A plugin with a stored + fingerprint different from `Crypto.fingerprint(pub_bin)` is not + trusted (key rotation event — caller is expected to surface this + separately). + """ + @spec trusted?(atom(), Crypto.pub_key()) :: boolean() + def trusted?(name, pub_bin) when is_atom(name) and is_binary(pub_bin) do + trusted?(name, pub_bin, load_trusted_plugins()) + end + + @doc "Variant of `trusted?/2` that takes the trust map as input (pure)." + @spec trusted?(atom(), Crypto.pub_key(), trust_map()) :: boolean() + def trusted?(name, pub_bin, trust_map) + when is_atom(name) and is_binary(pub_bin) and is_map(trust_map) do + case Map.fetch(trust_map, name) do + {:ok, fingerprint} -> fingerprint == Crypto.fingerprint(pub_bin) + :error -> false + end + end + + @doc """ + Writes `name => Crypto.fingerprint(pub_bin)` into `mob.exs`. + + Preserves unrelated config and user comments by editing the file + line-by-line rather than regenerating it. Idempotent: writing the + same fingerprint a second time is a no-op. Replaces an existing + entry on key rotation. Adds the `config :mob, :trusted_plugins, …` + line at the end of the file when no entry exists yet. + """ + @spec add_trust(atom(), Crypto.pub_key()) :: :ok | {:error, term()} + def add_trust(name, pub_bin) when is_atom(name) and is_binary(pub_bin) do + add_trust(name, pub_bin, File.cwd!()) + end + + @doc "Variant of `add_trust/2` that targets `project_dir`." + @spec add_trust(atom(), Crypto.pub_key(), Path.t()) :: :ok | {:error, term()} + def add_trust(name, pub_bin, project_dir) + when is_atom(name) and is_binary(pub_bin) and is_binary(project_dir) do + fingerprint = Crypto.fingerprint(pub_bin) + update_trust_entry(project_dir, fn map -> Map.put(map, name, fingerprint) end) + end + + @doc "Removes the trust entry for `name` from `mob.exs`." + @spec remove_trust(atom()) :: :ok + def remove_trust(name) when is_atom(name) do + remove_trust(name, File.cwd!()) + end + + @doc "Variant of `remove_trust/1` that targets `project_dir`." + @spec remove_trust(atom(), Path.t()) :: :ok + def remove_trust(name, project_dir) when is_atom(name) and is_binary(project_dir) do + case update_trust_entry(project_dir, fn map -> Map.delete(map, name) end) do + :ok -> :ok + {:error, _} -> :ok + end + end + + # ── mob.exs editing ──────────────────────────────────────────────────────── + + defp update_trust_entry(project_dir, transform) do + path = Path.join(project_dir, @config_file) + + case File.read(path) do + {:ok, source} -> + current = load_trusted_plugins(project_dir) + new_map = transform.(current) + updated = replace_or_append_trust_line(source, new_map) + File.write!(path, updated) + :ok + + {:error, :enoent} -> + new_map = transform.(%{}) + + # No mob.exs yet — create the minimal one (header + use Config + the + # trusted_plugins entry). Same pattern used by mob_new templates. + contents = + "import Config\n\nconfig :mob, :trusted_plugins, " <> inspect_trust_map(new_map) <> "\n" + + File.mkdir_p!(Path.dirname(path)) + File.write!(path, contents) + :ok + + {:error, reason} -> + {:error, reason} + end + end + + # If the file already declares `config :mob, :trusted_plugins, ...` (any + # arity, possibly multi-line) we replace the whole stanza; otherwise we + # append a fresh line. The map shape is pretty-printed via `inspect/2` + # with sorted keys for stable diffs. + defp replace_or_append_trust_line(source, new_map) do + new_line = "config :mob, :trusted_plugins, " <> inspect_trust_map(new_map) + + cond do + Regex.match?(trust_stanza_pattern(), source) -> + Regex.replace(trust_stanza_pattern(), source, fn _ -> new_line end, global: false) + + String.ends_with?(source, "\n") -> + source <> new_line <> "\n" + + true -> + source <> "\n" <> new_line <> "\n" + end + end + + # Matches `config :mob, :trusted_plugins, <term>` where <term> can be a + # map literal possibly spanning multiple lines. We use a greedy match up + # to the closing `}` and rely on the writer always serialising the value + # as a `%{...}` literal (see inspect_trust_map/1) so this stays stable. + defp trust_stanza_pattern do + ~r/config\s+:mob\s*,\s*:trusted_plugins\s*,\s*%\{[^}]*\}/s + end + + defp inspect_trust_map(map) do + sorted = map |> Map.to_list() |> Enum.sort() + inspect(Map.new(sorted), pretty: false, limit: :infinity) + end +end diff --git a/lib/mob_dev/plugin/validator.ex b/lib/mob_dev/plugin/validator.ex new file mode 100644 index 0000000..c2eb3de --- /dev/null +++ b/lib/mob_dev/plugin/validator.ex @@ -0,0 +1,691 @@ +defmodule MobDev.Plugin.Validator do + @moduledoc """ + Validates plugin manifests, in two stages (see `MOB_PLUGINS.md`). + + **Single-plugin** (`validate_plugin/3`, behind `mix mob.validate_plugin`): a + plugin author's pre-publish check — required fields, referenced files exist, + `mob_version` satisfied by the installed mob, plus advisory warnings. + + **Cross-plugin** (`cross_validate/1`, run by mob_dev when activating): the + collision checks that only make sense across the *set* of activated plugins — + no two may claim the same component atom, screen route, or migration namespace. + + Every result is `%{errors: [...], warnings: [...]}`. Errors fail loud; + warnings are advisory. Both stages are pure given their inputs (the only I/O + is `File.exists?/1` for path checks, isolated in `validate_plugin/3`). + """ + + alias MobDev.Plugin.{Manifest, Merge} + + @type result :: %{errors: [String.t()], warnings: [String.t()]} + + @doc """ + Collects the file paths a manifest references, relative to the plugin root. + + Pure. Covers the concrete file declarations (`nifs.native_dir`, + `android.bridge_kt`, `android.jni_source`, `ios.swift_files`). Component + `view_module`/`composable` are type/function names, not paths, so they are + not included here. + """ + @spec referenced_paths(map() | nil) :: [String.t()] + def referenced_paths(nil), do: [] + + def referenced_paths(manifest) when is_map(manifest) do + nif_dirs = for n <- Map.get(manifest, :nifs, []), is_map(n), do: n[:native_dir] + android = Map.get(manifest, :android, %{}) + ios = Map.get(manifest, :ios, %{}) + + [ + nif_dirs, + [android[:bridge_kt], android[:jni_source]], + List.wrap(android[:res_files]), + List.wrap(ios[:swift_files]) + ] + |> List.flatten() + |> Enum.reject(&is_nil/1) + end + + @doc """ + Single-plugin validation, run from the plugin's own project directory. + + `installed_mob_version` is the version of `:mob` resolved in the plugin's + deps (a string), or `nil` to skip the compatibility check. + """ + @spec validate_plugin(map() | nil, Path.t(), String.t() | nil) :: result() + def validate_plugin(manifest, plugin_dir, installed_mob_version \\ nil) do + %{errors: structural_errors(manifest), warnings: []} + |> add_path_errors(manifest, plugin_dir) + |> add_mob_version_error(manifest, installed_mob_version) + |> add_nif_module_errors(manifest) + |> add_swift_struct_errors(manifest) + |> add_swift_import_errors(manifest, plugin_dir) + |> add_android_permission_errors(manifest, plugin_dir) + |> add_warnings(manifest) + end + + # iOS frameworks the host always links — a plugin doesn't have to redeclare + # these in its `ios.frameworks`. Mirrors the `frameworks_base` array in + # mob_new's iOS build.zig.eex (UIKit/Foundation/CoreGraphics/QuartzCore/SwiftUI). + # Accelerate is added when MLX is active, but plugin manifests don't know + # about the host's MLX configuration, so we treat it as always-allowed at + # validate time (an undeclared import will still surface in the host's link + # step when MLX isn't on — the failure mode the validator can't pre-empt). + @ios_base_frameworks ~w(UIKit Foundation CoreGraphics QuartzCore SwiftUI Accelerate) + + @doc """ + Verifies every `import X` in the plugin's Swift sources resolves to either + a base iOS framework or one declared in `manifest.ios.frameworks`. + + See `MOB_PLUGIN_SECURITY.md` (Layer 2 — capability enforcement at compile + time): the manifest is the contract; the plugin's source cannot reach for a + framework that isn't manifest-declared. Catches drift at validate time + rather than at link time, where the error points at the linker invocation + and not at the manifest that produced it. + + Returns a list of error strings (empty when the manifest passes). Skips + plugins with no `ios.swift_files`. Files referenced by the manifest but + missing on disk are flagged by `add_path_errors/3`, not here — this check + only opens files that exist. + """ + @spec validate_swift_imports(map() | nil, Path.t()) :: [String.t()] + def validate_swift_imports(nil, _plugin_dir), do: [] + + def validate_swift_imports(manifest, plugin_dir) when is_map(manifest) do + declared = MapSet.new(@ios_base_frameworks ++ declared_ios_frameworks(manifest)) + + for rel <- declared_swift_files(manifest), + abs = Path.join(plugin_dir, rel), + File.exists?(abs), + framework <- imported_frameworks(abs), + not MapSet.member?(declared, framework) do + ~s(iOS framework "#{framework}" imported by #{rel} but not declared in ) <> + "manifest.ios.frameworks — add it to the manifest" + end + end + + @doc """ + Activation-time capability check across every activated plugin. + + `plugins` is the `MobDev.Plugin.activated/0` shape — a list of + `{plugin_dir, manifest}` pairs. For each plugin, runs + `validate_swift_imports/2` and `validate_android_permissions/2` and + returns the flattened error list, each entry prefixed with the + plugin's `:name` so the user can tell which plugin tripped. + + Empty list means every activated plugin's source matches its manifest's + declared capability surface. The build hooks this into iOS + Android + builds via `raise_on_capability_drift!/1`, which raises a `Mix.raise/1` + with the full list when any drift is found. + """ + @spec activated_capability_errors([{Path.t(), map() | nil}]) :: [String.t()] + def activated_capability_errors(plugins) do + for {dir, manifest} <- plugins, + is_map(manifest), + err <- + validate_swift_imports(manifest, dir) ++ validate_android_permissions(manifest, dir) do + prefix_with_plugin_name(manifest, err) + end + end + + @doc """ + Runs `activated_capability_errors/1` and raises a `Mix.raise/1` (with a + user-readable bullet list) when any plugin's source references a + capability not in its manifest. No-op when every plugin is clean. + + Lives in the validator (not NativeBuild) so the iOS-sim and iOS-device + build paths can both call it as a one-liner, and so it stays unit-testable. + + Also runs the Phase 2 signature gate + (`MobDev.Plugin.SignatureGate.raise_on_signature_drift!/1`) at the top + — fails fast on a tampered or untrusted plugin before any capability + analysis runs. The unsigned-plugin banner is printed afterwards so it + surfaces on every successful invocation. + """ + @spec raise_on_capability_drift!([{Path.t(), map() | nil}]) :: :ok + def raise_on_capability_drift!(plugins) do + MobDev.Plugin.SignatureGate.raise_on_signature_drift!(plugins) + MobDev.Plugin.SignatureGate.maybe_print_unsafe_banner(plugins) + + case activated_capability_errors(plugins) do + [] -> + :ok + + errors -> + bullets = Enum.map_join(errors, "\n", &" - #{&1}") + + Mix.raise( + "plugin capability check failed — undeclared framework or permission " <> + "in an activated plugin (see MOB_PLUGIN_SECURITY.md, Layer 2):\n" <> bullets + ) + end + end + + defp prefix_with_plugin_name(manifest, err) do + case manifest[:name] do + name when is_atom(name) and not is_nil(name) -> "[#{name}] #{err}" + _ -> err + end + end + + @doc """ + Verifies every `<uses-permission android:name="X"/>` declared in + AndroidManifest.xml fragments under the plugin's tree appears in + `manifest.android.permissions`. + + Scope (deliberate): scans `priv/native/android/**/*.xml` for + `<uses-permission/>` entries — declarations the plugin author explicitly + wrote. Does not attempt to infer permissions from Kotlin/Java API usage + (the static-analysis rabbit hole `MOB_PLUGIN_SECURITY.md` warns against). + Returns `[]` when the plugin ships no AndroidManifest fragment. + """ + @spec validate_android_permissions(map() | nil, Path.t()) :: [String.t()] + def validate_android_permissions(nil, _plugin_dir), do: [] + + def validate_android_permissions(manifest, plugin_dir) when is_map(manifest) do + declared = MapSet.new(declared_android_permissions(manifest)) + + for {rel, found} <- android_manifest_permissions(plugin_dir), + permission <- found, + not MapSet.member?(declared, permission) do + ~s(Android permission "#{permission}" referenced in #{rel} but not declared in ) <> + "manifest.android.permissions — add it to the manifest" + end + end + + @doc """ + Cross-plugin collision validation across the activated set. + + `plugins` is a list of `{name, manifest}` for the activated plugins (tier-0 + no-manifest plugins, i.e. `manifest == nil`, contribute nothing and are + ignored). + """ + @spec cross_validate([{atom(), map() | nil}]) :: result() + def cross_validate(plugins) do + manifests = for {_name, m} <- plugins, is_map(m), do: m + + errors = + for {_gatherer, {:collision, checks}} <- conflict_surface(), + {label, extractor} <- checks, + error <- collisions(manifests, extractor, label), + do: error + + %{errors: errors, warnings: []} + end + + @doc """ + The cross-plugin **conflict surface**: every `MobDev.Plugin.Merge` gatherer + (each combines N plugins' manifest contributions into one space) classified by + what happens when two plugins clash. The map is keyed by the Merge gatherer + name, and `conflict_surface_test` asserts it covers **every** public gatherer — + so a new shared-resource field can't be added without classifying its conflict + behavior here (the systematic guarantee that multiples compose safely). + + Kinds: + * `{:collision, [{label, extractor}]}` — two plugins contributing the same + value is a build error. `extractor` returns the values one manifest claims. + * `{:namespaced, reason}` — per-plugin namespaced; no cross-plugin collision. + * `{:union, reason}` — set-union semantics; duplicates are harmless. + * `{:build_time, reason}` — collision is caught later in the native build + (e.g. font/migration planners in `MobDev.Plugin.Assets`), not here. + * `{:derived, reason}` — returns values derived from another classified field; + introduces no new namespace of its own. + """ + @spec conflict_surface() :: %{atom() => tuple()} + def conflict_surface do + %{ + screens: {:collision, [{"screen route (screens.default_route)", &screen_routes/1}]}, + ui_components: + {:collision, + [ + {"component atom (ui_components.atom)", &component_atoms/1}, + {"iOS native view key (ui_components.ios.view_module)", &component_view_modules/1}, + {"Android native view key (ui_components.android.composable)", + &component_composables/1} + ]}, + migrations: {:collision, [{"migration repo_namespace", &repo_namespaces/1}]}, + nifs: {:collision, [{"NIF module (nifs.module)", &nif_modules/1}]}, + static_archives: + {:collision, [{"cpp_archive init symbol (nifs.nm_symbol)", &archive_nm_symbols/1}]}, + swift_files: + {:collision, [{"iOS Swift source basename (ios.swift_files)", &swift_basenames/1}]}, + jni_sources: + {:collision, [{"Android JNI source basename (android.jni_source)", &jni_basenames/1}]}, + bridge_classes: + {:collision, [{"Android bridge class (android.bridge_class)", &bridge_class_names/1}]}, + android_manifest_snippets: + {:collision, + [ + {"AndroidManifest component (android.manifest_application_snippets android:name)", + &manifest_component_names/1} + ]}, + android_res_files: + {:collision, [{"Android res destination (android.res_files)", &res_file_dests/1}]}, + plist_keys: {:collision, [{"iOS Info.plist key (ios.plist_keys)", &plist_key_names/1}]}, + lifecycle: + {:collision, [{"supervised worker (lifecycle.supervised)", &supervised_workers/1}]}, + notification_handlers: + {:collision, + [{"notification match (notifications.handlers.match)", ¬ification_matches/1}]}, + # ── non-colliding (documented; no check needed) ─────────────────────── + settings: {:namespaced, "settings stored under a per-plugin key in Mob.State"}, + assets: + {:build_time, + "fonts: build-time collision planner (Assets.plan_*_font_*); images: per-plugin path"}, + android_permissions: {:union, "Android permissions are set-unioned"}, + host_requirements: + {:union, "informational host-app obligations, printed by the build; duplicates harmless"}, + gradle_deps: {:union, "Gradle deps are set-unioned"}, + ios_frameworks: {:union, "iOS frameworks are set-unioned"}, + bridge_kt_sources: {:derived, "Kotlin source paths; collision guarded via bridge_classes"}, + android_sources: {:derived, "bridge_kt + jni_source + nif dirs; guarded via their sources"}, + nif_sources: {:derived, "C/ObjC source paths derived from nifs (guarded via nifs.module)"}, + zig_nif_sources: {:derived, "zig source paths derived from nifs (guarded via nifs.module)"} + } + end + + # ── single-plugin checks ────────────────────────────────────────────────── + + defp structural_errors(manifest) do + case Manifest.validate(manifest) do + {:ok, _} -> [] + {:error, errs} -> errs + end + end + + defp add_path_errors(result, manifest, plugin_dir) do + missing = + manifest + |> referenced_paths() + |> Enum.reject(&File.exists?(Path.join(plugin_dir, &1))) + |> Enum.map(&"declared path does not exist: #{&1}") + + %{result | errors: result.errors ++ missing} + end + + defp add_mob_version_error(result, %{mob_version: req}, installed) + when is_binary(req) and is_binary(installed) do + case Version.parse_requirement(req) do + {:ok, _} -> + if Version.match?(installed, req) do + result + else + err = "installed :mob #{installed} does not satisfy mob_version #{inspect(req)}" + %{result | errors: result.errors ++ [err]} + end + + :error -> + # The bad-requirement string is already reported by structural validation. + result + end + end + + defp add_mob_version_error(result, _manifest, _installed), do: result + + # `nif :module` is the Erlang module name used by ERL_NIF_INIT both as the + # registered module atom and as the prefix of the static-init symbol + # (`<module>_nif_init`). It must therefore be a valid C token shape — a + # lowercase ASCII atom — not an Elixir module alias. Catch this at validate + # time rather than at link time. See MOB_PLUGINS.md (nifs section). + @nif_module_pattern ~r/^[a-z][a-z0-9_]*$/ + + # Core/runtime NIF module names baked into the static driver table by + # `MobDev.StaticNifs.default_nifs/0`. A plugin must not reuse one: the + # driver-tab generator de-duplicates by `:module` keeping the *last* entry + # (`StaticNifs.resolve/1`), so a plugin declaring e.g. `:crypto` silently + # overrides the core builtin row — dropping its `builtin: true` flag and + # repointing the `<module>_nif_init` symbol at the plugin's source. That + # breaks the core NIF at runtime/link time, far from the manifest that + # caused it. Catch the collision at validate time instead. + @reserved_nif_modules MapSet.new(MobDev.StaticNifs.default_nifs(), & &1.module) + + defp add_nif_module_errors(result, manifest) when is_map(manifest) do + errs = + for n <- Map.get(manifest, :nifs, []), + is_map(n), + Map.has_key?(n, :module), + err = nif_module_error(n[:module]), + do: err + + %{result | errors: result.errors ++ errs} + end + + defp add_nif_module_errors(result, _manifest), do: result + + defp nif_module_error(mod) when is_atom(mod) and not is_nil(mod) do + cond do + not Regex.match?(@nif_module_pattern, Atom.to_string(mod)) -> + bad_nif_module_message(mod) + + MapSet.member?(@reserved_nif_modules, mod) -> + reserved_nif_module_message(mod) + + true -> + nil + end + end + + defp nif_module_error(other), do: bad_nif_module_message(other) + + defp reserved_nif_module_message(mod) do + "nifs :module #{inspect(mod)} collides with a core/runtime NIF baked into " <> + "the static driver table — it would silently override the core entry " <> + "(dropping its builtin flag and repointing #{mod}_nif_init). Choose a " <> + "plugin-specific name (e.g. :my_plugin_nif)" + end + + defp bad_nif_module_message(value) do + "nifs :module #{inspect(value)} must be a C-token atom matching " <> + "/^[a-z][a-z0-9_]*$/ (e.g. :mob_bluetooth_nif), not an Elixir module — " <> + "ERL_NIF_INIT uses it as the static-init symbol prefix" + end + + # ui_components.ios.swift_struct names the SwiftUI struct the iOS bootstrap + # codegen instantiates (`StructName(props: props)`). It must therefore be a + # valid Swift identifier — the codegen pastes it straight into source. + # Catch a bad value at validate time rather than at swiftc time, where the + # error is far from the manifest that produced it. + @swift_identifier_pattern ~r/^[A-Za-z_][A-Za-z0-9_]*$/ + + defp add_swift_struct_errors(result, manifest) when is_map(manifest) do + errs = + for c <- Map.get(manifest, :ui_components, []), + is_map(c), + ios = c[:ios], + is_map(ios), + Map.has_key?(ios, :swift_struct), + err = swift_struct_error(ios[:swift_struct]), + do: err + + %{result | errors: result.errors ++ errs} + end + + defp add_swift_struct_errors(result, _manifest), do: result + + defp swift_struct_error(value) when is_binary(value) do + if Regex.match?(@swift_identifier_pattern, value), + do: nil, + else: bad_swift_struct_message(value) + end + + defp swift_struct_error(other), do: bad_swift_struct_message(other) + + defp bad_swift_struct_message(value) do + "ui_components.ios.swift_struct #{inspect(value)} must be a Swift " <> + "identifier matching /^[A-Za-z_][A-Za-z0-9_]*$/ (e.g. \"MobSignaturePadView\") — " <> + "the iOS bootstrap codegen instantiates it as `<StructName>(props: props)`" + end + + defp add_swift_import_errors(result, manifest, plugin_dir) do + %{result | errors: result.errors ++ validate_swift_imports(manifest, plugin_dir)} + end + + defp add_android_permission_errors(result, manifest, plugin_dir) do + %{result | errors: result.errors ++ validate_android_permissions(manifest, plugin_dir)} + end + + defp declared_ios_frameworks(manifest) do + for fw <- List.wrap(get_in(manifest, [:ios, :frameworks])), is_binary(fw), do: fw + end + + defp declared_swift_files(manifest) do + for f <- List.wrap(get_in(manifest, [:ios, :swift_files])), is_binary(f), do: f + end + + defp declared_android_permissions(manifest) do + for p <- List.wrap(get_in(manifest, [:android, :permissions])), is_binary(p), do: p + end + + # Matches `import Foundation`, `import SwiftUI`, `@testable import CoreLocation` + # — Swift's basic module-import forms — and captures the module name. The + # `[A-Z][A-Za-z0-9_]*` shape matches an iOS framework / Swift module name, + # which always begins with an uppercase letter. Submodule imports like + # `import struct Foundation.URL` capture `Foundation`, which is the + # framework-level granularity the manifest tracks. + @swift_import_pattern ~r/^[\t ]*(?:@testable[\t ]+)?import(?:[\t ]+(?:struct|class|enum|protocol|func|var|let|typealias))?[\t ]+([A-Z][A-Za-z0-9_]*)/m + + defp imported_frameworks(path) do + case File.read(path) do + {:ok, content} -> + @swift_import_pattern + |> Regex.scan(content, capture: :all_but_first) + |> Enum.map(fn [m] -> m end) + |> Enum.uniq() + + _ -> + [] + end + end + + # Matches `<uses-permission android:name="X"/>` (and its `</uses-permission>` + # form) and captures the permission string. Tolerates whitespace and arbitrary + # extra attributes on the element. + @android_uses_permission_pattern ~r/<uses-permission\b[^>]*android:name\s*=\s*"([^"]+)"/ + + # Scans `priv/native/android/**/*.xml` (plus a few historical paths) for + # AndroidManifest fragments contributed by the plugin and returns a list of + # `{rel_path, [permission_string]}` tuples — empty when the plugin ships no + # such fragment. + defp android_manifest_permissions(plugin_dir) do + candidates = + Path.wildcard(Path.join(plugin_dir, "priv/native/android/**/*.xml")) ++ + Path.wildcard(Path.join(plugin_dir, "priv/android/**/*.xml")) ++ + Path.wildcard(Path.join(plugin_dir, "android/**/AndroidManifest.xml")) + + for path <- Enum.uniq(candidates), + File.regular?(path), + perms = scan_android_permissions(path), + perms != [] do + {Path.relative_to(path, plugin_dir), perms} + end + end + + defp scan_android_permissions(path) do + case File.read(path) do + {:ok, content} -> + @android_uses_permission_pattern + |> Regex.scan(content, capture: :all_but_first) + |> Enum.map(fn [p] -> p end) + |> Enum.uniq() + + _ -> + [] + end + end + + defp add_warnings(result, manifest) do + %{result | warnings: result.warnings ++ warnings(manifest)} + end + + defp warnings(nil), do: [] + + defp warnings(manifest) do + single_platform_components(manifest) ++ + permission_review(manifest) ++ + plist_review(manifest) + end + + defp single_platform_components(manifest) do + for c <- Map.get(manifest, :ui_components, []), + is_map(c), + xor?(Map.has_key?(c, :ios), Map.has_key?(c, :android)) do + "ui_components #{inspect(c[:atom] || c[:tag])} declares only one platform — " <> + "the other platform will silently render nothing" + end + end + + defp permission_review(manifest) do + case get_in(manifest, [:android, :permissions]) do + [_ | _] = perms -> + [ + "declares Android permissions #{inspect(perms)} — review before publishing (opt-in via activation)" + ] + + _ -> + [] + end + end + + defp plist_review(manifest) do + case get_in(manifest, [:ios, :plist_keys]) do + m when is_map(m) and map_size(m) > 0 -> + ["declares iOS plist_keys #{inspect(Map.keys(m))} — review before publishing"] + + _ -> + [] + end + end + + defp xor?(a, b), do: a != b + + # ── cross-plugin collision detection ────────────────────────────────────── + + # Counts DISTINCT plugins contributing each value (uniq per manifest first), so + # a value a single plugin legitimately declares more than once — e.g. a + # cross-platform NIF with one iOS + one Android entry sharing a `:module` — is + # not mistaken for a cross-plugin collision. cross_validate is about CROSS-plugin + # clashes; within-plugin duplicates are a single-plugin concern. + defp collisions(manifests, extractor, label) do + manifests + |> Enum.flat_map(fn manifest -> manifest |> extractor.() |> Enum.uniq() end) + |> Enum.frequencies() + |> Enum.filter(fn {_value, count} -> count > 1 end) + |> Enum.map(fn {value, count} -> + "#{count} activated plugins declare the same #{label}: #{inspect(value)}" + end) + end + + defp component_atoms(manifest) do + for c <- Map.get(manifest, :ui_components, []), is_map(c), c[:atom], do: c[:atom] + end + + # Two plugins resolving to the same native view-registry key would silently + # shadow each other (last-write-wins) at build time, so flag the collision. + defp component_view_modules(manifest) do + for c <- Map.get(manifest, :ui_components, []), + is_map(c), + ios = c[:ios], + is_map(ios), + is_binary(ios[:view_module]), + do: ios[:view_module] + end + + defp component_composables(manifest) do + for c <- Map.get(manifest, :ui_components, []), + is_map(c), + android = c[:android], + is_map(android), + is_binary(android[:composable]), + do: android[:composable] + end + + defp screen_routes(manifest) do + for s <- Map.get(manifest, :screens, []), is_map(s), s[:default_route], do: s[:default_route] + end + + defp repo_namespaces(manifest) do + case get_in(manifest, [:migrations, :repo_namespace]) do + nil -> [] + ns -> [ns] + end + end + + # Two plugins declaring the same NIF :module both compile a `<module>.c/.zig` + # with `STATIC_ERLANG_NIF_LIBNAME=<module>`, producing a duplicate + # `<module>_nif_init` symbol → link/build failure. (Distinct from the + # plugin-vs-core check in validate_plugin/3, which guards reserved names.) + defp nif_modules(manifest) do + for n <- Map.get(manifest, :nifs, []), is_map(n), is_atom(n[:module]), do: n[:module] + end + + # cpp_archive NIFs static-link a libNAME.a into the app; two plugins emitting + # the same NIF-init symbol would be a duplicate-symbol link failure that the + # nifs.module guard doesn't catch (distinct modules can declare the same + # :nm_symbol). Collect the declared symbols so cross-validation flags a clash. + defp archive_nm_symbols(manifest) do + for n <- Map.get(manifest, :nifs, []), + is_map(n), + n[:lang] == :cpp_archive, + is_binary(n[:nm_symbol]), + do: n[:nm_symbol] + end + + # Plugin Swift sources are compiled into the one iOS app target; two plugins + # shipping a file with the same basename collide in the build. + defp swift_basenames(manifest) do + for p <- get_in(manifest, [:ios, :swift_files]) || [], is_binary(p), do: Path.basename(p) + end + + # Same for the single Android JNI-thunk C source. + defp jni_basenames(manifest) do + case get_in(manifest, [:android, :jni_source]) do + p when is_binary(p) -> [Path.basename(p)] + _ -> [] + end + end + + # Two plugins registering the same fully-qualified bridge class would have + # MobPluginBootstrap.registerAll call `<class>.register()` twice (and their + # bridge_kt land at the same package-path destination — last writer wins). + defp bridge_class_names(manifest) do + case get_in(manifest, [:android, :bridge_class]) do + c when is_binary(c) -> [c] + _ -> [] + end + end + + # Two plugins contributing a manifest component with the same android:name + # would duplicate it in the app's <application> (the merge is idempotent per + # name, so the second plugin's would be dropped — a silent loss). Key the + # collision on the component name. Reuses the Merge gatherer for the snippet + # set so the two can't drift. + defp manifest_component_names(manifest) do + Merge.android_manifest_snippets([{".", manifest}]) + |> Enum.flat_map(fn %{snippet: s} -> component_name(s) end) + end + + defp component_name(snippet) do + case Regex.run(~r/android:name="([^"]+)"/, snippet) do + [_, name] -> [name] + _ -> [] + end + end + + # Two plugins copying a res file to the same destination (e.g. both ship + # res/xml/apduservice.xml) would clobber each other. Key on the derived dest; + # reuses the Merge gatherer so the dest derivation stays single-sourced. + defp res_file_dests(manifest) do + Merge.android_res_files([{".", manifest}]) |> Enum.map(& &1.dest) + end + + # Two plugins setting the same Info.plist key silently last-write-wins in the + # merged plist (Map.merge in Merge.plist_keys/1). + defp plist_key_names(manifest) do + case get_in(manifest, [:ios, :plist_keys]) do + m when is_map(m) -> Map.keys(m) + _ -> [] + end + end + + # Two plugins supervising a worker with the same registered name collide at + # boot (`{:already_started, _}`); the second child fails to start. A child is + # a module, `{module, arg}`, or a child-spec map — key the collision on its id. + defp supervised_workers(manifest) do + for child <- get_in(manifest, [:lifecycle, :supervised]) || [], do: worker_id(child) + end + + defp worker_id(mod) when is_atom(mod), do: mod + defp worker_id({mod, _arg}) when is_atom(mod), do: mod + defp worker_id(%{id: id}), do: id + defp worker_id(other), do: other + + # Two plugins with the identical notification `:match` create ambiguous + # routing — dispatch walks handlers in order and the first-registered wins, so + # the other plugin silently never fires. (Equality catches the obvious clash; + # semantic overlap of predicate matches is undecidable and out of scope.) + defp notification_matches(manifest) do + for h <- get_in(manifest, [:notifications, :handlers]) || [], + is_map(h), + Map.has_key?(h, :match), + do: h.match + end +end diff --git a/lib/mob_dev/plugin/verify.ex b/lib/mob_dev/plugin/verify.ex new file mode 100644 index 0000000..0a88716 --- /dev/null +++ b/lib/mob_dev/plugin/verify.ex @@ -0,0 +1,216 @@ +defmodule MobDev.Plugin.Verify do + @moduledoc """ + Host-side signature verification for activated mob plugins. + + Given a plugin directory + its loaded manifest, this module: + + 1. Loads `priv/mob_plugin.sig` and validates its exact versioned envelope. + 2. Loads `priv/mob_plugin.pub` (the plugin author's public key). + 3. Recomputes the file-hash list using the policy bound to that version. + 4. Reconstructs the canonical payload and runs `Crypto.verify/3`. + + Failure modes are distinguished: + + - `:missing_signature` — no `priv/mob_plugin.sig`. + - `:missing_pubkey` — no `priv/mob_plugin.pub`. + - `:invalid_signature` — sig file present but the signature doesn't + verify against the canonical payload reconstructed from disk. This + is the failure mode for both manifest tampering and source-file + tampering: the recomputed `file_hashes` no longer match what was + signed, so the payload differs and the signature check fails. + + Trust (mapping a verified public key to "the host operator approved + it") lives in `TrustStore` and is layered on top of this module. + """ + + alias MobDev.Plugin.{Crypto, Sign} + + @signature_file "priv/mob_plugin.sig" + @pubkey_file "priv/mob_plugin.pub" + @supported_signature_versions [1, 2] + @max_signature_envelope_bytes 256 + + # Atom keys that appear in the signed envelope term (see `Sign.sign_plugin/2`). + # `load_signature/1` decodes the envelope with `binary_to_term(_, [:safe])`, + # which refuses to *create* atoms — every atom in the encoded term must + # already exist in the runtime atom table or the decode raises `badarg` and a + # valid signature is misreported as `:corrupt`. Naming the atoms in this + # module-level literal interns them at `Verify`-load (guaranteed before any + # decode), making the decode deterministic while keeping `:safe` (sig files + # are attacker-controlled). See + # decisions/2026-05-31-verify-safe-atom-intern.md. + @envelope_atoms [:signature, :envelope_version] + + @typedoc "Errors `load_signature/1` can return." + @type sig_error :: :missing | :corrupt + + @typedoc "A supported signature version; only the verify API authenticates it." + @type signature_version :: 1 | 2 + + @typedoc "The decoded version and raw Ed25519 signature." + @type versioned_signature :: {signature_version(), Crypto.signature()} + + @typedoc "Errors `load_pubkey/1` can return." + @type pubkey_error :: :missing | :malformed + + @typedoc "Errors `verify_plugin/2` can return." + @type verify_error :: :missing_signature | :missing_pubkey | :invalid_signature + + @doc """ + Loads the raw 64-byte signature from `priv/mob_plugin.sig`. + + This compatibility API validates the exact versioned envelope and then + discards the version. Call `load_signature_with_version/1` when the caller + needs the decoded version, or `verify_plugin_with_version/2` when it needs a + version that has also passed cryptographic verification. + """ + @spec load_signature(Path.t()) :: {:ok, Crypto.signature()} | {:error, sig_error()} + def load_signature(plugin_dir) do + case load_signature_with_version(plugin_dir) do + {:ok, {_version, signature}} -> {:ok, signature} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Loads and validates the exact two-key signature envelope. + + Returns `{version, raw_signature}` only for supported integer versions 1 and + 2 in the canonical uncompressed ETF map encoding. Missing, unknown, + non-integer, stripped, extra-key, compressed, oversized, and bare signature + forms fail closed as `:corrupt`. + """ + @spec load_signature_with_version(Path.t()) :: + {:ok, versioned_signature()} | {:error, sig_error()} + def load_signature_with_version(plugin_dir) do + path = Path.join(plugin_dir, @signature_file) + + case read_signature_envelope(path) do + {:ok, bytes} -> decode_signature_envelope(bytes) + {:error, :enoent} -> {:error, :missing} + {:error, _} -> {:error, :corrupt} + end + end + + defp read_signature_envelope(path) do + case File.open(path, [:read, :binary], fn io -> + IO.binread(io, @max_signature_envelope_bytes + 1) + end) do + {:ok, bytes} + when is_binary(bytes) and byte_size(bytes) <= @max_signature_envelope_bytes -> + {:ok, bytes} + + {:ok, _oversized_or_unreadable} -> + {:error, :corrupt} + + {:error, reason} -> + {:error, reason} + end + end + + defp decode_signature_envelope(<<131, 116, _::binary>> = bytes) + when byte_size(bytes) <= @max_signature_envelope_bytes do + # Touch the literal so the envelope atoms are guaranteed interned before the + # :safe decode runs (see @envelope_atoms above). + _ = @envelope_atoms + + case :erlang.binary_to_term(bytes, [:safe, :used]) do + {%{signature: signature, envelope_version: version} = envelope, bytes_used} + when bytes_used == byte_size(bytes) and map_size(envelope) == 2 and + is_binary(signature) and byte_size(signature) == 64 and + version in @supported_signature_versions -> + {:ok, {version, signature}} + + _ -> + {:error, :corrupt} + end + rescue + ArgumentError -> {:error, :corrupt} + ErlangError -> {:error, :corrupt} + end + + defp decode_signature_envelope(_bytes), do: {:error, :corrupt} + + @doc false + # Atoms the signed envelope can contain; exposed so the interning guarantee is + # regression-testable (see verify_test.exs). + @spec envelope_atoms() :: [atom()] + def envelope_atoms, do: @envelope_atoms + + @doc """ + Loads the raw 32-byte public key from `priv/mob_plugin.pub`. + + Format: a single line of base64 (with `=` padding) of the raw 32-byte + Ed25519 public key, optionally followed by a trailing newline. Plain + text so plugin authors can `cat` it or paste it into a release note. + """ + @spec load_pubkey(Path.t()) :: {:ok, Crypto.pub_key()} | {:error, pubkey_error()} + def load_pubkey(plugin_dir) do + path = Path.join(plugin_dir, @pubkey_file) + + case File.read(path) do + {:ok, contents} -> decode_pubkey(contents) + {:error, :enoent} -> {:error, :missing} + {:error, _} -> {:error, :malformed} + end + end + + defp decode_pubkey(contents) do + trimmed = String.trim(contents) + + case Base.decode64(trimmed) do + {:ok, pub} when byte_size(pub) == 32 -> {:ok, pub} + _ -> {:error, :malformed} + end + end + + @doc """ + Verifies that the plugin in `plugin_dir` has a valid signature for the + given `manifest` + the current file contents on disk. + + Returns `:ok` on success or one of the distinguished error reasons + (see `t:verify_error/0`). The caller is responsible for any trust + decision; this function only proves that the bytes on disk match + what the plugin author signed. + """ + @spec verify_plugin(Path.t(), map() | nil) :: :ok | {:error, verify_error()} + def verify_plugin(plugin_dir, manifest) do + case verify_plugin_with_version(plugin_dir, manifest) do + {:ok, _version} -> :ok + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Verifies a plugin and returns the signature version only after the signature + succeeds against that version's single payload and file-hash policy. + + Version 1 reconstructs the frozen legacy payload, which excludes Objective-C + `.m` and `.mm` files from `native_dir`. Version 2 includes them. Verification + never falls back between policies, so changing an envelope version without + resigning fails cryptographically. + """ + @spec verify_plugin_with_version(Path.t(), map() | nil) :: + {:ok, signature_version()} | {:error, verify_error()} + def verify_plugin_with_version(plugin_dir, manifest) do + with {:ok, {version, signature}} <- + need(load_signature_with_version(plugin_dir), :missing_signature), + {:ok, pub} <- need(load_pubkey(plugin_dir), :missing_pubkey), + file_hashes = Sign.compute_file_hashes(plugin_dir, manifest, version), + payload = Sign.build_payload(manifest, file_hashes, version), + :ok <- normalise_verify(Crypto.verify(payload, signature, pub)) do + {:ok, version} + end + end + + # Both load_signature and load_pubkey return :missing for a missing file; + # other errors (:corrupt, :malformed) collapse into :invalid_signature + # because they all mean "the bytes that should certify this plugin are + # not usable". + defp need({:ok, value}, _missing_reason), do: {:ok, value} + defp need({:error, :missing}, missing_reason), do: {:error, missing_reason} + defp need({:error, _}, _missing_reason), do: {:error, :invalid_signature} + + defp normalise_verify(:ok), do: :ok + defp normalise_verify({:error, :invalid_signature}), do: {:error, :invalid_signature} +end diff --git a/lib/mob_dev/python_android_support.ex b/lib/mob_dev/python_android_support.ex new file mode 100644 index 0000000..8d8a7f0 --- /dev/null +++ b/lib/mob_dev/python_android_support.ex @@ -0,0 +1,254 @@ +defmodule MobDev.PythonAndroidSupport do + @moduledoc """ + Downloads and caches a Chaquopy CPython distribution so Android builds + can embed Python. + + ## Why Chaquopy + + Chaquopy is currently the only actively-maintained source of pre-built + CPython binaries for Android. BeeWare's `Python-Android-support` + (the iOS sibling we already use) hasn't shipped a Python 3.11+ release. + Chaquopy is Apache 2.0 (since 2025), publishes to Maven Central, and + ships exactly the .so files we need: + + target-VSN-arm64-v8a.zip: + jniLibs/arm64-v8a/libpython3.13.so ← interpreter + jniLibs/arm64-v8a/libcrypto_python.so ← OpenSSL crypto + jniLibs/arm64-v8a/libssl_python.so ← OpenSSL SSL + jniLibs/arm64-v8a/libsqlite3_python.so ← bundled SQLite + lib-dynload/arm64-v8a/*.so ← C extensions + include/python3.13/ ← headers (NIF compile) + + target-VSN-stdlib.zip: + os.py, urllib/, email/, … ← shared pure-Python + + target-VSN-x86_64.zip: + Same as arm64-v8a but for x86_64 emulator slice. + + We only use these binaries — Chaquopy's Java<->Python bridge is bypassed. + Pythonx's NIF dlopens libpython3.13.so directly via the same path + contract as iOS, just with Android paths. + + ## Architectures + + * `arm64-v8a` — modern Android phones. Required. + * `x86_64` — Android emulators on Intel/AMD development machines. + Required for sim development. + * `armeabi-v7a` (32-bit) — NOT supported. Chaquopy dropped 32-bit + Android Python a few releases back. iOS-era 32-bit Android phones + (~2017 and earlier) cannot run Pythonx-enabled Mob apps. + + ## Mirrors PythonAppleSupport + + Same caching pattern: `~/.mob/cache/python-android-support-<version>/`, + `valid_dir?/1` for layout validation, downloads on-demand via + `MobDev.NativeBuild` when Pythonx is in the user's project. + """ + + @python_version "3.13" + # Chaquopy's target-VSN versioning is `<python>.<patch>-<chaquopy-rev>`. + # Bump together with manual end-to-end validation on emulator + device. + @chaquopy_target "3.13.9-0" + @release_tag @chaquopy_target + @base_url "https://repo1.maven.org/maven2/com/chaquo/python/target" + + @abis ~w(arm64-v8a x86_64) + + @doc """ + Ensures Chaquopy's Python distribution is cached and extracted. + Returns `{:ok, extracted_dir}` or `{:error, reason}`. + + Three artifacts get downloaded: per-abi binary zips for `arm64-v8a` + and `x86_64`, plus the shared stdlib zip. + """ + @spec ensure() :: {:ok, String.t()} | {:error, term()} + def ensure do + dir = extracted_dir() + + if valid_dir?(dir) do + {:ok, dir} + else + if File.dir?(dir), do: File.rm_rf!(dir) + File.mkdir_p!(dir) + download_and_extract(dir) + end + end + + @doc """ + Returns the cached extraction directory path. + """ + @spec extracted_dir() :: String.t() + def extracted_dir do + Path.join(version_dir(), "extracted") + end + + @doc """ + Validates the extracted bundle has the expected layout: per-abi + jniLibs/<abi>/libpython3.13.so, lib-dynload/<abi>/, headers, and + the shared stdlib. + + Public for testing (per AGENTS.md convention). + """ + @spec valid_dir?(String.t()) :: boolean() + def valid_dir?(dir) do + File.dir?(stdlib_dir(dir)) and + Enum.all?(@abis, fn abi -> + File.regular?(libpython_path(dir, abi)) and + File.dir?(lib_dynload_dir(dir, abi)) + end) + end + + @doc """ + Path to libpython3.13.so for a given ABI. + """ + @spec libpython_path(String.t(), String.t()) :: String.t() + def libpython_path(dir, abi) do + Path.join([jni_libs_dir(dir, abi), "libpython#{@python_version}.so"]) + end + + @doc """ + Per-abi `jniLibs/<abi>` subtree containing libpython.so + its + bundled OpenSSL/SQLite dependencies. + """ + @spec jni_libs_dir(String.t(), String.t()) :: String.t() + def jni_libs_dir(dir, abi) do + Path.join([dir, abi, "jniLibs", abi]) + end + + @doc """ + Per-abi `lib-dynload/<abi>` subtree with arch-specific Python C + extensions (_ssl, _ctypes, _hashlib, …). + """ + @spec lib_dynload_dir(String.t(), String.t()) :: String.t() + def lib_dynload_dir(dir, abi) do + Path.join([dir, abi, "lib-dynload", abi]) + end + + @doc """ + Shared (slice-independent) Python standard library directory. + """ + @spec stdlib_dir(String.t()) :: String.t() + def stdlib_dir(dir) do + Path.join(dir, "stdlib") + end + + @doc """ + Per-abi C headers for cross-compiling NIFs (pythonx, etc.) against + the bundled libpython. + """ + @spec headers_dir(String.t(), String.t()) :: String.t() + def headers_dir(dir, abi) do + Path.join([dir, abi, "include", "python#{@python_version}"]) + end + + @doc "URL the per-variant artifact is fetched from." + @spec download_url(String.t()) :: String.t() + def download_url(variant) do + "#{@base_url}/#{@chaquopy_target}/#{tarball_name(variant)}" + end + + @doc "Tarball file name for a given variant (`arm64-v8a` / `x86_64` / `stdlib`)." + @spec tarball_name(String.t()) :: String.t() + def tarball_name(variant), do: "target-#{@chaquopy_target}-#{variant}.zip" + + @doc "Pinned Chaquopy target version (`3.13.9-0`, …)." + @spec release_tag() :: String.t() + def release_tag, do: @release_tag + + @doc "Pinned Python version (`3.13`)." + @spec python_version() :: String.t() + def python_version, do: @python_version + + @doc "Supported ABIs (the per-arch artifacts we extract)." + @spec abis() :: [String.t()] + def abis, do: @abis + + # ── Private ───────────────────────────────────────────────────────────────── + + defp version_dir do + Path.join(cache_dir(), "python-android-support-#{@release_tag}") + end + + defp cache_dir do + System.get_env("MOB_CACHE_DIR") || + Path.join([System.get_env("HOME"), ".mob", "cache"]) + end + + defp download_and_extract(dest_dir) do + File.mkdir_p!(dest_dir) + + # Each abi extracts to <dest_dir>/<abi>/, stdlib to <dest_dir>/stdlib/. + with :ok <- download_and_extract_abi("arm64-v8a", dest_dir), + :ok <- download_and_extract_abi("x86_64", dest_dir), + :ok <- download_and_extract_stdlib(dest_dir), + :ok <- verify_layout(dest_dir) do + IO.puts(" Cached at #{dest_dir}") + {:ok, dest_dir} + else + {:error, reason} -> + File.rm_rf(dest_dir) + {:error, reason} + end + end + + defp download_and_extract_abi(abi, dest_dir) do + url = download_url(abi) + abi_dir = Path.join(dest_dir, abi) + File.mkdir_p!(abi_dir) + tmp_zip = Path.join(System.tmp_dir!(), tarball_name(abi)) + + IO.puts(" Downloading Chaquopy Python #{abi} (#{@release_tag})...") + IO.puts(" URL: #{url}") + + with :ok <- download(url, tmp_zip), + :ok <- extract_zip(tmp_zip, abi_dir) do + File.rm(tmp_zip) + :ok + else + err -> + File.rm(tmp_zip) + err + end + end + + defp download_and_extract_stdlib(dest_dir) do + url = download_url("stdlib") + stdlib = stdlib_dir(dest_dir) + File.mkdir_p!(stdlib) + tmp_zip = Path.join(System.tmp_dir!(), tarball_name("stdlib")) + + IO.puts(" Downloading Chaquopy Python stdlib (#{@release_tag})...") + + with :ok <- download(url, tmp_zip), + :ok <- extract_zip(tmp_zip, stdlib) do + File.rm(tmp_zip) + :ok + else + err -> + File.rm(tmp_zip) + err + end + end + + defp download(url, dest), do: MobDev.Download.curl(url, dest) + + defp extract_zip(zip, dest_dir) do + case System.cmd("unzip", ["-q", "-o", zip, "-d", dest_dir], stderr_to_stdout: true) do + {_, 0} -> :ok + {out, rc} -> {:error, "unzip failed (exit #{rc}): #{String.trim(out)}"} + end + end + + defp verify_layout(dir) do + if valid_dir?(dir) do + :ok + else + {:error, + "Chaquopy Python extraction at #{dir} is missing expected paths.\n" <> + " Expected jniLibs/<abi>/libpython#{@python_version}.so for arm64-v8a + x86_64,\n" <> + " lib-dynload/<abi>/, and shared stdlib/.\n" <> + " Maven artifact may have an unexpected layout — report at\n" <> + " https://github.com/GenericJam/mob_dev/issues"} + end + end +end diff --git a/lib/mob_dev/python_apple_support.ex b/lib/mob_dev/python_apple_support.ex new file mode 100644 index 0000000..348d578 --- /dev/null +++ b/lib/mob_dev/python_apple_support.ex @@ -0,0 +1,197 @@ +defmodule MobDev.PythonAppleSupport do + @moduledoc """ + Downloads and caches BeeWare's Python-Apple-support bundle so iOS + builds can embed CPython. + + Mirrors the `MobDev.OtpDownloader` pattern: hashed URL + cached download + at `~/.mob/cache/python-apple-support-<version>/`, validated against + the expected `Python.xcframework` layout. Reused across projects. + + Used by `MobDev.NativeBuild` whenever the user's project depends on + `:pythonx` — the build templates source `PYTHON_APPLE_SUPPORT` from + `extracted_dir/0` and bundle the framework + stdlib + lib-dynload + inside the `.app`. + + ## Scope + + Only the bare CPython runtime + standard library + standard arch-specific + C extensions ship via this module. Third-party wheels (cryptography, RNS, + numpy, …) are out of scope; users who need those should produce their + own wheels with BeeWare's `mobile-forge` and drop them into their + project. See `guides/python_embedding.md`. + """ + + # Pinned BeeWare release. Bump together with manual end-to-end validation + # on iOS sim + device — Python releases occasionally shift the lib-dynload + # layout or framework signing requirements. + @python_version "3.13" + @beeware_build "b13" + @release_tag "#{@python_version}-#{@beeware_build}" + @tarball_name "Python-#{@python_version}-iOS-support.#{@beeware_build}.tar.gz" + @base_url "https://github.com/beeware/Python-Apple-support/releases/download" + + @doc """ + Ensures the BeeWare Python-Apple-support bundle is cached and extracted. + Returns `{:ok, extracted_dir}` or `{:error, reason}`. + """ + @spec ensure() :: {:ok, String.t()} | {:error, term()} + def ensure do + dir = extracted_dir() + + if valid_dir?(dir) do + {:ok, dir} + else + # Stale or partial — clean and re-download. Same rationale as + # OtpDownloader: previous failed extraction or schema bump. + if File.dir?(dir), do: File.rm_rf!(dir) + download_and_extract(dir) + end + end + + @doc """ + Returns the cached extraction directory path. May not exist if `ensure/0` + hasn't been called. + """ + @spec extracted_dir() :: String.t() + def extracted_dir do + Path.join(version_dir(), "extracted") + end + + @doc """ + Validates that the extracted bundle has the `Python.xcframework` layout + this module expects (both device and simulator slices, plus shared stdlib). + + Public to enable testing (per AGENTS.md convention) and to let + `MobDev.NativeBuild` cheaply detect a partial cache. + """ + @spec valid_dir?(String.t()) :: boolean() + def valid_dir?(dir) do + File.dir?(Path.join([dir, "Python.xcframework", "ios-arm64", "Python.framework"])) and + File.dir?( + Path.join([dir, "Python.xcframework", "ios-arm64_x86_64-simulator", "Python.framework"]) + ) and + File.dir?(stdlib_path(dir)) + end + + @doc """ + Path to `Python.framework` inside the given extracted bundle for the + named platform slice. + + `slice` is one of `:ios_device` (`ios-arm64/`) or `:ios_simulator` + (`ios-arm64_x86_64-simulator/`). + """ + @spec framework_path(String.t(), :ios_device | :ios_simulator) :: String.t() + def framework_path(dir, :ios_device), + do: Path.join([dir, "Python.xcframework", "ios-arm64", "Python.framework"]) + + def framework_path(dir, :ios_simulator), + do: Path.join([dir, "Python.xcframework", "ios-arm64_x86_64-simulator", "Python.framework"]) + + @doc """ + Path to the shared (slice-independent) Python standard library directory. + This is the pure-Python `os.py`, `urllib/`, etc. layout that goes under + `PYTHONHOME/lib/python3.13/`. + """ + @spec stdlib_path(String.t()) :: String.t() + def stdlib_path(dir) do + Path.join([dir, "Python.xcframework", "lib", "python#{@python_version}"]) + end + + @doc """ + Path to the arch-specific C-extension dir (`_ctypes.so`, `_ssl.so`, …). + These live OUTSIDE the shared stdlib because they're per-slice. + """ + @spec lib_dynload_path(String.t(), :ios_device | :ios_simulator) :: String.t() + def lib_dynload_path(dir, :ios_device), + do: + Path.join([ + dir, + "Python.xcframework", + "ios-arm64", + "lib-arm64", + "python#{@python_version}", + "lib-dynload" + ]) + + def lib_dynload_path(dir, :ios_simulator), + do: + Path.join([ + dir, + "Python.xcframework", + "ios-arm64_x86_64-simulator", + "lib-arm64", + "python#{@python_version}", + "lib-dynload" + ]) + + @doc "URL the bundle is fetched from." + @spec download_url() :: String.t() + def download_url, do: "#{@base_url}/#{@release_tag}/#{@tarball_name}" + + @doc "Tarball file name (used for caching the downloaded artifact)." + @spec tarball_name() :: String.t() + def tarball_name, do: @tarball_name + + @doc "Pinned BeeWare release tag." + @spec release_tag() :: String.t() + def release_tag, do: @release_tag + + @doc "Pinned Python version (`3.13`, `3.14`, …)." + @spec python_version() :: String.t() + def python_version, do: @python_version + + # ── Private ───────────────────────────────────────────────────────────────── + + defp version_dir do + Path.join(cache_dir(), "python-apple-support-#{@release_tag}") + end + + defp cache_dir do + System.get_env("MOB_CACHE_DIR") || + Path.join([System.get_env("HOME"), ".mob", "cache"]) + end + + defp download_and_extract(dest_dir) do + tmp_file = Path.join(System.tmp_dir!(), @tarball_name) + + IO.puts(" Downloading BeeWare Python-Apple-support #{@release_tag}...") + IO.puts(" URL: #{download_url()}") + + File.mkdir_p!(Path.dirname(dest_dir)) + + with :ok <- download(download_url(), tmp_file), + :ok <- extract(tmp_file, dest_dir), + :ok <- verify_layout(dest_dir) do + File.rm(tmp_file) + IO.puts(" Cached at #{dest_dir}") + {:ok, dest_dir} + else + {:error, reason} -> + File.rm(tmp_file) + File.rm_rf(dest_dir) + {:error, reason} + end + end + + defp download(url, dest), do: MobDev.Download.curl(url, dest) + + defp extract(tarball, dest_dir) do + File.mkdir_p!(dest_dir) + # BeeWare's tarball doesn't have a single top-level directory — extract + # straight in. Verify happens afterward. + MobDev.Download.untar(tarball, dest_dir) + end + + defp verify_layout(dir) do + if valid_dir?(dir) do + :ok + else + {:error, + "Python-Apple-support extraction at #{dir} is missing expected paths.\n" <> + " Expected Python.xcframework with ios-arm64 + ios-arm64_x86_64-simulator\n" <> + " slices and shared stdlib at lib/python#{@python_version}/.\n" <> + " Tarball may have an unexpected layout — report at\n" <> + " https://github.com/GenericJam/mob_dev/issues"} + end + end +end diff --git a/lib/mob_dev/release.ex b/lib/mob_dev/release.ex new file mode 100644 index 0000000..00f4673 --- /dev/null +++ b/lib/mob_dev/release.ex @@ -0,0 +1,974 @@ +defmodule MobDev.Release do + @moduledoc """ + Build a signed, App-Store-ready iOS `.ipa` for the current Mob project. + + Mirrors `MobDev.NativeBuild`'s physical-device build pipeline but signs + with a distribution identity, embeds an App Store provisioning profile, + drops EPMD + the distribution-related BEAM args (the `MOB_RELEASE` flag), + and packages the `.app` as a `.ipa` instead of installing it. + + Output path: `_build/mob_release/<App>.ipa`. + + ## Required mob.exs keys + + config :mob_dev, + bundle_id: "com.example.app", + ios_team_id: "ABC123XYZ4", + # Distribution-only — falls back to auto-detect if absent: + ios_dist_sign_identity: "Apple Distribution: Your Name (ABC123XYZ4)", + ios_dist_profile_uuid: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + + Auto-detection looks for `Apple Distribution: ...` certificates in the + keychain and picks the first matching App Store provisioning profile + (one with no `ProvisionedDevices` and no `ProvisionsAllDevices`). + + ## Optional keys + + config :mob_dev, + # Ship mob's public-API `screenshot` NIF in the release build (default: false). + # Normally the whole iOS test harness is stripped from release because its + # synthetic-input NIFs use private selectors the App Store rejects; `screenshot` + # uses only public APIs, so this opts just it back in — letting an agent SEE a + # shipped app's screen to error-correct. It captures the app's own window with no + # OS prompt/indicator, so enabling it is a deliberate choice. tap/type stay stripped. + ios_release_screenshot: true + """ + + @doc """ + Build a signed `.ipa` for App Store / TestFlight distribution. + + Returns `{:ok, ipa_path}` or `{:error, reason}`. + """ + @spec build_ipa(keyword()) :: {:ok, String.t()} | {:error, String.t()} + def build_ipa(opts \\ []) do + cfg = MobDev.NativeBuild.__load_config__() + slim = Keyword.get(opts, :slim, true) + + with :ok <- check_macos(), + :ok <- check_xcrun(), + :ok <- check_driver_table(), + {:ok, cfg} <- resolve_distribution_signing(cfg), + {:ok, otp_root} <- MobDev.OtpDownloader.ensure_ios_device() do + script_path = "ios/release_device.sh" + File.write!(script_path, release_device_sh()) + File.chmod!(script_path, 0o755) + + env = release_env(cfg, otp_root) + output_dir = Path.expand("_build/mob_release") + File.mkdir_p!(output_dir) + + env = [ + {"MOB_RELEASE_OUTPUT_DIR", output_dir}, + {"MOB_SLIM", if(slim, do: "1", else: "0")} + | env + ] + + case System.cmd("bash", [script_path], + env: env, + stderr_to_stdout: true, + into: IO.stream() + ) do + {_, 0} -> + app_name = Mix.Project.config()[:app] |> to_string() |> Macro.camelize() + ipa_path = Path.join(output_dir, "#{app_name}.ipa") + {:ok, ipa_path} + + {_, _} -> + {:error, "release_device.sh failed — check output above"} + end + end + end + + # The iOS release links a per-app static-NIF driver table compiled from + # priv/generated/driver_tab_ios.c. The dev build uses Mob's built-in Zig table, + # so a project that has only ever done dev builds never generates the C file — + # and `mix mob.regen_driver_tab` defaults to Zig, so the table must be emitted + # with `--format c`. Without this preflight the build dies deep in release_device.sh + # with a cryptic `cc: no such file or directory: 'priv/generated/driver_tab_ios.c'`. + defp check_driver_table do + path = "priv/generated/driver_tab_ios.c" + + if File.exists?(path) do + :ok + else + {:error, + """ + #{path} not found. + + The iOS release links a per-app static-NIF driver table. Generate it once: + + mix mob.regen_driver_tab --format c + + then commit priv/generated/driver_tab_{ios,android}.c so release builds are + reproducible. (The dev build uses Mob's built-in Zig table, so this file is + only needed for release; `mob.regen_driver_tab` without --format c emits Zig, + which the release path does not compile.) + """} + end + end + + # ── Signing config ─────────────────────────────────────────────────────────── + + @doc false + @spec resolve_distribution_signing(keyword()) :: {:ok, keyword()} | {:error, String.t()} + def resolve_distribution_signing(cfg) do + bundle_id = cfg[:bundle_id] + + with {:ok, identity} <- resolve_dist_identity(cfg[:ios_dist_sign_identity]), + {:ok, {profile_uuid, team_id}} <- + resolve_dist_profile(cfg[:ios_dist_profile_uuid], bundle_id, cfg[:ios_team_id]) do + {:ok, + cfg + |> Keyword.put(:ios_dist_sign_identity, identity) + |> Keyword.put(:ios_dist_profile_uuid, profile_uuid) + |> Keyword.put(:ios_team_id, team_id)} + end + end + + defp resolve_dist_identity(identity) when is_binary(identity), do: {:ok, identity} + + defp resolve_dist_identity(_) do + case System.cmd("security", ["find-identity", "-v", "-p", "codesigning"], + stderr_to_stdout: true + ) do + {output, 0} -> + identities = + Regex.scan(Regex.compile!("\\d+\\) [0-9A-F]+ \"([^\"]+)\""), output) + |> Enum.map(fn [_, full] -> full end) + |> Enum.filter(&String.contains?(&1, "Apple Distribution")) + |> Enum.uniq() + + case identities do + [] -> + {:error, + """ + No Apple Distribution signing certificate found in the keychain. + + You need a paid Apple Developer Program account ($99/year) to get + a Distribution certificate. Once enrolled: + 1. Open Xcode → Settings → Accounts → your Apple ID + 2. Click "Manage Certificates" → "+" → "Apple Distribution" + 3. Close Xcode + + Then re-run `mix mob.release`. + + (For development-only builds to your own device, use + `mix mob.deploy --native` — that uses an Apple Development cert.) + """} + + [identity] -> + IO.puts( + " #{IO.ANSI.cyan()}Auto-detected distribution identity: #{identity}#{IO.ANSI.reset()}" + ) + + {:ok, identity} + + many -> + choices = Enum.map_join(many, "\n", &" #{&1}") + + {:error, + """ + Multiple distribution identities found — set ios_dist_sign_identity + in mob.exs: + + config :mob_dev, + ios_dist_sign_identity: "Apple Distribution: You (ABC123XYZ4)" + + Available: + #{choices} + """} + end + + {out, _} -> + {:error, "security find-identity failed: #{out}"} + end + end + + defp resolve_dist_profile(uuid, _bundle_id, team_id) + when is_binary(uuid) and is_binary(team_id), + do: {:ok, {uuid, team_id}} + + defp resolve_dist_profile(uuid, bundle_id, _team_id) do + profile_dirs = [ + Path.expand("~/Library/Developer/Xcode/UserData/Provisioning Profiles"), + Path.expand("~/Library/MobileDevice/Provisioning Profiles") + ] + + all_profiles = + Enum.flat_map(profile_dirs, &Path.wildcard(Path.join(&1, "*.mobileprovision"))) + |> Enum.flat_map(&parse_mobileprovision/1) + + case select_dist_profile(all_profiles, uuid, bundle_id) do + {:ok, %{uuid: u, team_id: t, app_id: aid}} -> + unless is_binary(uuid) do + IO.puts( + " #{IO.ANSI.cyan()}Auto-detected App Store profile: #{u} (team #{t})#{IO.ANSI.reset()}" + ) + + if String.ends_with?(aid, ".*") do + IO.puts( + " #{IO.ANSI.yellow()} using wildcard profile — run `mix mob.provision --distribution` to create a dedicated one for #{bundle_id}#{IO.ANSI.reset()}" + ) + end + end + + {:ok, {u, t}} + + :none -> + {:error, + """ + No App Store provisioning profile found for bundle ID '#{bundle_id}'. + + To create one: + 1. Enroll in the Apple Developer Program (paid, $99/yr) + 2. Run: mix mob.provision --distribution + + Or in Xcode: Settings → Accounts → Download Manual Profiles after + registering an App Store distribution profile in App Store Connect. + """} + + {:multiple, many} -> + choices = Enum.map_join(many, "\n", fn %{uuid: u, app_id: a} -> " #{u} (#{a})" end) + + {:error, + """ + Multiple App Store profiles match '#{bundle_id}' — set + ios_dist_profile_uuid in mob.exs: + + config :mob_dev, + ios_dist_profile_uuid: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + + Matching profiles: + #{choices} + """} + end + end + + @doc false + # Pure kernel of resolve_dist_profile/3: pick the App Store profile from an + # already-parsed list (the dir scan + parse + user-facing IO stay in the + # caller). App Store distribution profiles are the ones with NO + # `ProvisionedDevices` (development + ad-hoc have it) and NO + # `ProvisionsAllDevices` (enterprise has it). Matches by bundle id — exact + # `.<bundle_id>` or wildcard `.*`. With an explicit `uuid`, narrows to it; + # without one, prefers an exact-bundle profile over a wildcard. Returns the + # winning profile, `:none`, or `{:multiple, list}` for the caller to render. + @spec select_dist_profile([map()], String.t() | nil, String.t()) :: + {:ok, map()} | :none | {:multiple, [map()]} + def select_dist_profile(all_profiles, uuid, bundle_id) do + app_store_profiles = + Enum.filter(all_profiles, fn %{ + provisioned_devices?: pd, + provisions_all_devices?: pad + } -> + not pd and not pad + end) + + matches = + Enum.filter(app_store_profiles, fn %{app_id: aid} -> + String.ends_with?(aid, ".#{bundle_id}") or String.ends_with?(aid, ".*") + end) + + candidates = + if is_binary(uuid) do + Enum.filter(matches, &(&1.uuid == uuid)) + else + # Prefer exact bundle ID over wildcard. + exact = Enum.filter(matches, &String.ends_with?(&1.app_id, ".#{bundle_id}")) + if exact != [], do: exact, else: matches + end + + case candidates do + [] -> :none + [profile] -> {:ok, profile} + many -> {:multiple, many} + end + end + + @doc false + @spec parse_mobileprovision(String.t()) :: [ + %{ + uuid: String.t(), + app_id: String.t(), + team_id: String.t(), + provisioned_devices?: boolean(), + provisions_all_devices?: boolean() + } + ] + def parse_mobileprovision(path) do + with {:ok, data} <- File.read(path), + {s, _} <- :binary.match(data, "<?xml"), + {e, len} <- :binary.match(data, "</plist>") do + xml = binary_part(data, s, e - s + len) + uuid = capture(xml, Regex.compile!("<key>UUID<\\/key>\\s*<string>([^<]+)<\\/string>")) + + app_id = + capture( + xml, + Regex.compile!("<key>application-identifier<\\/key>\\s*<string>([^<]+)<\\/string>") + ) + + team = + capture( + xml, + Regex.compile!("<key>TeamIdentifier<\\/key>\\s*<array>\\s*<string>([^<]+)<\\/string>") + ) + + pd = String.contains?(xml, "<key>ProvisionedDevices</key>") + pad = String.contains?(xml, "<key>ProvisionsAllDevices</key>") + + case {uuid, app_id, team} do + {u, a, t} when is_binary(u) and is_binary(a) and is_binary(t) -> + [ + %{ + uuid: u, + app_id: a, + team_id: t, + provisioned_devices?: pd, + provisions_all_devices?: pad + } + ] + + _ -> + [] + end + else + _ -> [] + end + end + + defp capture(xml, regex) do + case Regex.run(regex, xml) do + [_, val] -> String.trim(val) + _ -> nil + end + end + + # ── Env for release_device.sh ──────────────────────────────────────────────── + + defp release_env(cfg, otp_root) do + app_atom = Mix.Project.config()[:app] + app_name = app_atom |> to_string() |> Macro.camelize() + app_module = to_string(app_atom) + elixir_lib = MobDev.NativeBuild.__resolve_elixir_lib__(cfg[:elixir_lib]) + epmd_src = cfg[:ios_epmd_build_src] || otp_root + + [ + {"MOB_DIR", Path.expand(cfg[:mob_dir])}, + {"MOB_ELIXIR_LIB", Path.expand(elixir_lib)}, + {"MOB_IOS_DEVICE_OTP_ROOT", otp_root}, + {"MOB_IOS_EPMD_BUILD_SRC", epmd_src}, + {"MOB_IOS_BUNDLE_ID", cfg[:bundle_id]}, + {"MOB_IOS_TEAM_ID", cfg[:ios_team_id]}, + {"MOB_IOS_SIGN_IDENTITY", cfg[:ios_dist_sign_identity]}, + {"MOB_IOS_PROFILE_UUID", cfg[:ios_dist_profile_uuid]}, + {"MOB_APP_NAME", app_name}, + {"MOB_APP_MODULE", app_module}, + screenshot_build_env(cfg) + ] ++ plugin_ios_build_env(MobDev.Plugin.activated()) + end + + @doc false + # Opt-in to shipping mob's public-API `screenshot` NIF in the release build (stripped + # by default with the rest of the test harness). Drives `-DMOB_ENABLE_SCREENSHOT` on + # the mob_nif.m compile in `release_device.sh`. Enabled only by + # `config :mob_dev, ios_release_screenshot: true` — an agent can then SEE a shipped + # app's screen to error-correct, but the private input-synthesis NIFs (tap/type) stay + # stripped regardless. Shipping a remotely-triggerable capture must be a conscious + # choice, so it defaults off. Pure so it's unit-tested. + @spec screenshot_build_env(keyword()) :: {String.t(), String.t()} + def screenshot_build_env(cfg), + do: {"MOB_ENABLE_SCREENSHOT", if(cfg[:ios_release_screenshot], do: "1", else: "")} + + @doc false + # Env vars that drive `release_device.sh`'s activated-plugin NIF compile + link + # step. Pure over the activated-plugin list (each `{plugin_dir, manifest}`, the + # shape `MobDev.Plugin.activated/0` returns) so it can be unit-tested without a + # real deps tree. + # + # - `MOB_PLUGIN_IOS_NIF_SOURCES` — space-joined absolute paths of each activated + # plugin's iOS C/ObjC NIF source (`priv/native/ios/<module>.m`). The generated + # `driver_tab_ios` references every activated plugin's `<module>_nif_init`, so + # the release link fails with "Undefined symbols: _<module>_nif_init" unless + # these are compiled in. Each source's basename is the NIF libname → the script + # compiles it with `-DSTATIC_ERLANG_NIF_LIBNAME=<basename>` so `ERL_NIF_INIT` + # emits `<basename>_nif_init`, matching the table. Mirrors the dev path's + # `build.zig -Dplugin_c_nifs` (`MobDev.Plugin.Merge.nif_sources/2`). + # - `MOB_PLUGIN_IOS_FRAMEWORKS` — space-joined union of the frameworks the + # activated plugins' iOS code drives. Belt-and-suspenders: the sources are + # compiled with `-fmodules` (Clang autolinks every imported framework), and + # these are also passed explicitly to the linker. + @spec plugin_ios_build_env([MobDev.Plugin.Merge.plugin()]) :: [{String.t(), String.t()}] + def plugin_ios_build_env(activated) do + sources = activated |> MobDev.Plugin.Merge.nif_sources(:ios) |> Enum.map(&Path.expand/1) + frameworks = MobDev.Plugin.Merge.ios_frameworks(activated) + + [ + {"MOB_PLUGIN_IOS_NIF_SOURCES", Enum.join(sources, " ")}, + {"MOB_PLUGIN_IOS_FRAMEWORKS", Enum.join(frameworks, " ")} + ] + end + + # ── Preflight ──────────────────────────────────────────────────────────────── + + defp check_macos do + case :os.type() do + {:unix, :darwin} -> :ok + _ -> {:error, "mix mob.release is only supported on macOS (Xcode is required)."} + end + end + + defp check_xcrun do + if System.find_executable("xcrun") do + :ok + else + {:error, "xcrun not found on PATH — install Xcode and run `xcode-select --install`."} + end + end + + # ── release_device.sh ──────────────────────────────────────────────────────── + + @doc false + # Public for testing — `mob_dev/test/mob_dev/release_script_test.exs` + # asserts the shape of the generated script (strip-from-bundle, full + # DT* set, ditto packaging, etc.) so accidental regressions of any of + # the App Store validator fixes get caught at `mix test` time rather + # than in a TestFlight upload round trip. + @spec release_device_sh() :: String.t() + def release_device_sh do + ~S""" + #!/bin/bash + # ios/release_device.sh — App Store / TestFlight build for Mob (generated + # by `mix mob.release`). Mirrors build_device.sh but with distribution + # signing, no EPMD, no distribution BEAM args, and IPA packaging. + set -e + cd "$(dirname "$0")/.." + + MOB_DIR="${MOB_DIR:?MOB_DIR not set}" + ELIXIR_LIB=$(elixir -e "IO.puts(Path.dirname(to_string(:code.lib_dir(:elixir))))" 2>/dev/null) + if [ -z "$ELIXIR_LIB" ] || [ ! -d "$ELIXIR_LIB/elixir/ebin" ]; then + ELIXIR_LIB="${MOB_ELIXIR_LIB:?MOB_ELIXIR_LIB not set}" + fi + OTP_ROOT="${MOB_IOS_DEVICE_OTP_ROOT:?MOB_IOS_DEVICE_OTP_ROOT not set}" + BUNDLE_ID="${MOB_IOS_BUNDLE_ID:?bundle_id not set}" + TEAM_ID="${MOB_IOS_TEAM_ID:?ios_team_id not set}" + SIGN_IDENTITY="${MOB_IOS_SIGN_IDENTITY:?distribution signing identity not set}" + PROFILE_UUID="${MOB_IOS_PROFILE_UUID:?App Store profile UUID not set}" + APP_NAME="${MOB_APP_NAME:?MOB_APP_NAME not set}" + APP_MODULE="${MOB_APP_MODULE:?MOB_APP_MODULE not set}" + OUTPUT_DIR="${MOB_RELEASE_OUTPUT_DIR:?MOB_RELEASE_OUTPUT_DIR not set}" + + ERTS_VSN=$(ls "$OTP_ROOT" | grep '^erts-' | sort -V | tail -1) + [ -z "$ERTS_VSN" ] && echo "ERROR: No erts-* in $OTP_ROOT" && exit 1 + OTP_RELEASE=$(ls "$OTP_ROOT/releases" 2>/dev/null | grep -E '^[0-9]+$' | sort -V | tail -1) + [ -z "$OTP_RELEASE" ] && echo "ERROR: No releases/<N>/ in $OTP_ROOT" && exit 1 + echo "=== RELEASE: ERTS=$ERTS_VSN OTP=$OTP_RELEASE App=$APP_NAME Bundle=$BUNDLE_ID ===" + + BEAMS_DIR="$OTP_ROOT/$APP_MODULE" + SDKROOT=$(xcrun -sdk iphoneos --show-sdk-path) + HOSTCC=$(xcrun -find cc) + CC="$HOSTCC -arch arm64 -miphoneos-version-min=17.0 -isysroot $SDKROOT" + + IFLAGS="-I$OTP_ROOT/$ERTS_VSN/include \ + -I$OTP_ROOT/$ERTS_VSN/include/internal \ + -I$MOB_DIR/ios" + + LIBS=" + $OTP_ROOT/$ERTS_VSN/lib/libbeam.a + $OTP_ROOT/$ERTS_VSN/lib/internal/liberts_internal_r.a + $OTP_ROOT/$ERTS_VSN/lib/internal/libethread.a + $OTP_ROOT/$ERTS_VSN/lib/libzstd.a + $OTP_ROOT/$ERTS_VSN/lib/libepcre.a + $OTP_ROOT/$ERTS_VSN/lib/libryu.a + $OTP_ROOT/$ERTS_VSN/lib/asn1rt_nif.a + $OTP_ROOT/$ERTS_VSN/lib/crypto.a + $OTP_ROOT/$ERTS_VSN/lib/libcrypto.a + " + + echo "=== Compiling Erlang/Elixir ===" + mix compile + + echo "=== Copying BEAM files to $BEAMS_DIR ===" + mkdir -p "$BEAMS_DIR" + for lib_dir in _build/dev/lib/*/ebin; do + cp "$lib_dir"/* "$BEAMS_DIR/" 2>/dev/null || true + done + + SQLITE_STATIC_LIB="" + if [ -d "_build/dev/lib/exqlite" ]; then + EXQLITE_VSN=$(grep '"exqlite"' mix.lock \ + | grep -o '"[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"' | head -1 | tr -d '"') + [ -z "$EXQLITE_VSN" ] && EXQLITE_VSN=$(grep -o '{vsn,"[^"]*"}' \ + _build/dev/lib/exqlite/ebin/exqlite.app | grep -o '"[^"]*"' | tr -d '"') + EXQLITE_LIB_DIR="$OTP_ROOT/lib/exqlite-${EXQLITE_VSN}" + rm -rf "$OTP_ROOT/lib/exqlite-"* + mkdir -p "$EXQLITE_LIB_DIR/ebin" "$EXQLITE_LIB_DIR/priv" + cp _build/dev/lib/exqlite/ebin/*.beam "$EXQLITE_LIB_DIR/ebin/" + cp _build/dev/lib/exqlite/ebin/exqlite.app "$EXQLITE_LIB_DIR/ebin/" + + EXQLITE_SRC="deps/exqlite/c_src" + BUILD_DIR_TMP=$(mktemp -d) + $CC -I "$EXQLITE_SRC" -I "$OTP_ROOT/$ERTS_VSN/include" \ + -I "$OTP_ROOT/$ERTS_VSN/include/internal" \ + -DSQLITE_THREADSAFE=1 -DSTATIC_ERLANG_NIF_LIBNAME=sqlite3_nif \ + -Wno-\#warnings \ + -c "$EXQLITE_SRC/sqlite3_nif.c" -o "$BUILD_DIR_TMP/sqlite3_nif.o" + $CC -I "$EXQLITE_SRC" -DSQLITE_THREADSAFE=1 -Wno-\#warnings \ + -c "$EXQLITE_SRC/sqlite3.c" -o "$BUILD_DIR_TMP/sqlite3.o" + $(xcrun -find ar) rcs "$EXQLITE_LIB_DIR/priv/sqlite3_nif.a" \ + "$BUILD_DIR_TMP/sqlite3_nif.o" "$BUILD_DIR_TMP/sqlite3.o" + SQLITE_STATIC_LIB="$EXQLITE_LIB_DIR/priv/sqlite3_nif.a" + rm -rf "$BUILD_DIR_TMP" + fi + + # Real crypto + ssl (no shims). The iOS OTP cache ships crypto-5.9 and + # ssl-11.7 (NOT in the slim-strip list below) and the crypto NIF is + # statically linked via crypto.a, so the real beams work on device. The + # old md5-only crypto shim + no-op ssl shim used to be compiled into + # BEAMS_DIR, where (being on the prepended -pa path) they SHADOWED the + # real beams in lib/{crypto,ssl}-*/ebin. That broke TLS: real ssl needs + # ciphers crypto can't provide, and the ssl shim didn't even export + # versions/0 — so Mint hit `:ssl.versions/0 undefined`, every HTTPS + # request crashed, and the orchestra SSE never connected on device. + # Removing the shims lets the real, NIF-backed crypto + ssl load. + + echo "=== Copying Elixir stdlib ===" + mkdir -p "$OTP_ROOT/lib/elixir/ebin" "$OTP_ROOT/lib/logger/ebin" + cp "$ELIXIR_LIB/elixir/ebin/"*.beam "$OTP_ROOT/lib/elixir/ebin/" + cp "$ELIXIR_LIB/elixir/ebin/elixir.app" "$OTP_ROOT/lib/elixir/ebin/" + cp "$ELIXIR_LIB/logger/ebin/"*.beam "$OTP_ROOT/lib/logger/ebin/" + cp "$ELIXIR_LIB/logger/ebin/logger.app" "$OTP_ROOT/lib/logger/ebin/" + cp "$ELIXIR_LIB/eex/ebin/"*.beam "$BEAMS_DIR/" 2>/dev/null || true + cp "$ELIXIR_LIB/eex/ebin/eex.app" "$BEAMS_DIR/" 2>/dev/null || true + + copy_otp_lib() { + local APP="$1" + local SRC + SRC=$(elixir -e "IO.puts(:code.lib_dir(:${APP}))" 2>/dev/null) + if [ -n "$SRC" ] && [ -d "$SRC/ebin" ]; then + local VSN + VSN=$(basename "$SRC") + mkdir -p "$OTP_ROOT/lib/$VSN/ebin" + cp "$SRC/ebin/"*.beam "$OTP_ROOT/lib/$VSN/ebin/" + cp "$SRC/ebin/${APP}.app" "$OTP_ROOT/lib/$VSN/ebin/" + fi + } + copy_otp_lib runtime_tools + copy_otp_lib asn1 + copy_otp_lib public_key + + echo "=== Copying priv (migrations, assets, bundled ebins, app priv) ===" + if [ -d "assets" ]; then + mix assets.build + fi + # Ship the WHOLE priv/ to the device, not just repo/migrations + static. + # Apps that bundle extra runtime assets under priv/ — e.g. :mix/:hex ebins + # for on-device Mix.install, or a vendored library's priv/static (Livebook) — + # need those on device too. Mirrors the Android deployer, which pushes all + # of priv/. (Previously only priv/repo/migrations and priv/static shipped, + # so priv/mix, priv/hex, priv/<lib>/... silently never reached the device.) + if [ -d "priv" ]; then + mkdir -p "$BEAMS_DIR/priv" + rsync -a "priv/" "$BEAMS_DIR/priv/" + fi + + APP_VSN=$(grep -o '{vsn,"[^"]*"}' "$BEAMS_DIR/${APP_MODULE}.app" | grep -o '"[^"]*"' | tr -d '"') + if [ -n "$APP_VSN" ]; then + APP_LIB_DIR="$OTP_ROOT/lib/${APP_MODULE}-${APP_VSN}" + rm -rf "$APP_LIB_DIR" + mkdir -p "$APP_LIB_DIR/ebin" + cp "$BEAMS_DIR/${APP_MODULE}.app" "$APP_LIB_DIR/ebin/" + if [ -d "$BEAMS_DIR/priv" ]; then + rsync -a "$BEAMS_DIR/priv/" "$APP_LIB_DIR/priv/" + fi + fi + + cp "$MOB_DIR/assets/logo/logo_dark.png" "$OTP_ROOT/mob_logo_dark.png" 2>/dev/null || true + cp "$MOB_DIR/assets/logo/logo_light.png" "$OTP_ROOT/mob_logo_light.png" 2>/dev/null || true + + echo "=== Compiling native sources (release: -DMOB_RELEASE, no EPMD) ===" + BUILD_DIR=$(mktemp -d) + SWIFT_BRIDGING="$MOB_DIR/ios/MobDemo-Bridging-Header.h" + + $CC -fobjc-arc -fmodules $IFLAGS \ + -c "$MOB_DIR/ios/MobNode.m" -o "$BUILD_DIR/MobNode.o" + + xcrun -sdk iphoneos swiftc \ + -target arm64-apple-ios17.0 \ + -module-name "$APP_NAME" \ + -emit-objc-header -emit-objc-header-path "$BUILD_DIR/MobApp-Swift.h" \ + -import-objc-header "$SWIFT_BRIDGING" \ + -I "$MOB_DIR/ios" \ + -parse-as-library -wmo \ + -O \ + "$MOB_DIR"/ios/*.swift \ + -c -o "$BUILD_DIR/swift_mob.o" + + # MOB_RELEASE on mob_nif.m strips the test harness (synthetic-input + # NIFs that use private UIKit selectors — App Store auto-rejects). + # MOB_ENABLE_SCREENSHOT (set when `ios_release_screenshot: true`) opts the + # public-API screenshot NIF back in — it stays stripped otherwise. `${VAR:+flag}` + # expands to the flag only when VAR is non-empty, so the default build is byte-identical. + $CC -fobjc-arc -fmodules $IFLAGS \ + -I "$BUILD_DIR" -DSTATIC_ERLANG_NIF -DMOB_RELEASE \ + ${MOB_ENABLE_SCREENSHOT:+-DMOB_ENABLE_SCREENSHOT} \ + -c "$MOB_DIR/ios/mob_nif.m" -o "$BUILD_DIR/mob_nif.o" + + # MOB_RELEASE on mob_beam.m drops -name/-setcookie/-kernel-dist BEAM + # args + EPMD thread (no Erlang distribution surface in shipped apps). + $CC -fobjc-arc -fmodules $IFLAGS \ + -DMOB_BUNDLE_OTP \ + -DMOB_RELEASE \ + -DERTS_VSN=\"$ERTS_VSN\" \ + -DOTP_RELEASE=\"$OTP_RELEASE\" \ + -c "$MOB_DIR/ios/mob_beam.m" -o "$BUILD_DIR/mob_beam.o" + + SQLITE_FLAG="" + [ -n "$SQLITE_STATIC_LIB" ] && SQLITE_FLAG="-DMOB_STATIC_SQLITE_NIF" + # driver_tab now lives in priv/generated (per-app, regenerated via + # `mix mob.regen_driver_tab --format c`), not $MOB_DIR/ios. + $CC $IFLAGS $SQLITE_FLAG \ + -c "priv/generated/driver_tab_ios.c" -o "$BUILD_DIR/driver_tab_ios.o" + + $CC -fobjc-arc -fmodules $IFLAGS \ + -I "$BUILD_DIR" \ + -c ios/AppDelegate.m -o "$BUILD_DIR/AppDelegate.o" + + $CC -fobjc-arc -fmodules $IFLAGS \ + -c ios/beam_main.m -o "$BUILD_DIR/beam_main.o" + + # erl_errno_id stub: BEAM's erl_posix_str.o references + # erl_errno_id_unknown but the bundled OTP doesn't define it. Weak so + # an OTP-internal definition wins if one ever appears. Written with + # printf (not a heredoc) to stay cleanly indentable inside this + # Elixir \""" string. NOTE the single backslash: this is a ~S (raw) heredoc, + # so '%s\\n' would reach bash verbatim and printf would emit a literal + # backslash-n into the C file (clang then rejects `}\n`). '%s\n' emits a real + # newline. + printf '%s\n' '__attribute__((weak)) const char *erl_errno_id_unknown(int error) { (void)error; return "unknown"; }' > "$BUILD_DIR/erl_errno_id_compat.c" + $CC $IFLAGS -c "$BUILD_DIR/erl_errno_id_compat.c" -o "$BUILD_DIR/erl_errno_id_compat.o" + + # ── Activated-plugin NIFs ───────────────────────────────────────────────── + # driver_tab_ios references each activated plugin's <module>_nif_init; those + # definitions live in the plugin's iOS NIF source (priv/native/ios/<module>.m, + # lang: :objc). The dev build compiles these via build.zig -Dplugin_c_nifs; the + # release build must do the same or the final link dies with "Undefined + # symbols: _<module>_nif_init". The source basename is the NIF libname → + # -DSTATIC_ERLANG_NIF_LIBNAME=<name> makes ERL_NIF_INIT emit <name>_nif_init. + # -fmodules lets Clang autolink every framework the source @imports (a plugin + # may import frameworks beyond its manifest's declared set, e.g. Accelerate). + PLUGIN_OBJS="" + for SRC in $MOB_PLUGIN_IOS_NIF_SOURCES; do + NAME=$(basename "$SRC"); NAME="${NAME%.*}" + case "$SRC" in + *.m) ARC="-fobjc-arc" ;; + *) ARC="" ;; + esac + echo " plugin NIF: $NAME ($SRC)" + $CC $ARC -fmodules $IFLAGS \ + -DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME="$NAME" \ + -c "$SRC" -o "$BUILD_DIR/$NAME.o" + PLUGIN_OBJS="$PLUGIN_OBJS $BUILD_DIR/$NAME.o" + done + + # Frameworks the activated plugins declare (explicit, alongside -fmodules + # autolink above): -framework <FW> for each unique name. + PLUGIN_FRAMEWORK_FLAGS="" + for FW in $MOB_PLUGIN_IOS_FRAMEWORKS; do + PLUGIN_FRAMEWORK_FLAGS="$PLUGIN_FRAMEWORK_FLAGS -Xlinker -framework -Xlinker $FW" + done + + echo "=== Linking $APP_NAME (release, no EPMD) ===" + xcrun -sdk iphoneos swiftc \ + -target arm64-apple-ios17.0 \ + "$BUILD_DIR/driver_tab_ios.o" \ + "$BUILD_DIR/MobNode.o" \ + "$BUILD_DIR/swift_mob.o" \ + "$BUILD_DIR/mob_nif.o" \ + "$BUILD_DIR/mob_beam.o" \ + "$BUILD_DIR/AppDelegate.o" \ + "$BUILD_DIR/beam_main.o" \ + "$BUILD_DIR/erl_errno_id_compat.o" \ + $PLUGIN_OBJS \ + $LIBS \ + "$SQLITE_STATIC_LIB" \ + -lz -lc++ -lpthread \ + -Xlinker -framework -Xlinker UIKit \ + -Xlinker -framework -Xlinker Foundation \ + -Xlinker -framework -Xlinker CoreGraphics \ + -Xlinker -framework -Xlinker QuartzCore \ + -Xlinker -framework -Xlinker SwiftUI \ + $PLUGIN_FRAMEWORK_FLAGS \ + -o "$BUILD_DIR/$APP_NAME" + + echo "=== Building .app bundle ===" + APP="$BUILD_DIR/$APP_NAME.app" + rm -rf "$APP" + mkdir -p "$APP" + cp "$BUILD_DIR/$APP_NAME" "$APP/" + + cp ios/Info.plist "$APP/" + /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$APP/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleExecutable $APP_NAME" "$APP/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$APP/Info.plist" + + # Apple's App Store validator requires MinimumOSVersion and DTPlatformName + # in the bundle Info.plist (codes 90065/90507/90530). Both are derived + # from the build target — set them defensively here so any app gets + # them right without needing to remember to add them by hand. + # `Add` errors if the key already exists; fall through to `Set` for the + # idempotent case. + /usr/libexec/PlistBuddy -c "Add :MinimumOSVersion string 17.0" "$APP/Info.plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Set :MinimumOSVersion 17.0" "$APP/Info.plist" + /usr/libexec/PlistBuddy -c "Add :DTPlatformName string iphoneos" "$APP/Info.plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Set :DTPlatformName iphoneos" "$APP/Info.plist" + + # The DT* keys ("Development Tools") record what built the bundle. + # App Store Connect's validator (error 90534) cross-references + # DTSDKBuild + DTXcodeBuild against an allow-list of accepted Xcode + # release versions. Without them the upload is rejected as "built + # with an unsupported SDK or Xcode version" even when Xcode is current. + SDK_VERSION=$(xcrun --sdk iphoneos --show-sdk-version) + SDK_BUILD=$(xcrun --sdk iphoneos --show-sdk-build-version) + XCODE_RAW=$(xcodebuild -version | head -1 | awk '{print $2}') + XCODE_BUILD=$(xcodebuild -version | sed -n '2p' | awk '{print $3}') + XCODE_MAJOR=$(echo "$XCODE_RAW" | cut -d. -f1) + XCODE_MINOR=$(echo "$XCODE_RAW" | cut -d. -f2) + [ -z "$XCODE_MINOR" ] && XCODE_MINOR=0 + XCODE_PATCH=$(echo "$XCODE_RAW" | cut -d. -f3) + [ -z "$XCODE_PATCH" ] && XCODE_PATCH=0 + # DTXcode encoding: e.g. "26.4" → "2640" (major × 1000 + minor × 10 + + # patch). Same scheme Xcode itself stamps into bundles. Computed via + # arithmetic so the result is always 4 digits regardless of how the + # version components were entered. + # Apple's encoding (per their IPA validator): Xcode 16.0 → 1600, + # 16.4 → 1640, 26.4 → 2640. Always 4 digits while major is 2-digit. + DTXCODE=$(( XCODE_MAJOR * 100 + XCODE_MINOR * 10 + XCODE_PATCH )) + + for kv in \ + "DTSDKName=iphoneos${SDK_VERSION}" \ + "DTSDKBuild=${SDK_BUILD}" \ + "DTPlatformVersion=${SDK_VERSION}" \ + "DTPlatformBuild=${SDK_BUILD}" \ + "DTXcode=${DTXCODE}" \ + "DTXcodeBuild=${XCODE_BUILD}" \ + "DTCompiler=com.apple.compilers.llvm.clang.1_0" \ + "BuildMachineOSBuild=$(sw_vers -buildVersion)"; do + K="${kv%%=*}" + V="${kv#*=}" + /usr/libexec/PlistBuddy -c "Add :$K string $V" "$APP/Info.plist" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Set :$K $V" "$APP/Info.plist" + done + # UIDeviceFamily is required when MinimumOSVersion >= 3.2 (always, in + # practice). 1 = iPhone, 2 = iPad. Default to iPhone-only; apps that + # want universal can set the array explicitly in their Info.plist + # before this script runs (the `Add` will fail and we won't overwrite). + /usr/libexec/PlistBuddy -c "Add :UIDeviceFamily array" "$APP/Info.plist" 2>/dev/null \ + && /usr/libexec/PlistBuddy -c "Add :UIDeviceFamily:0 integer 1" "$APP/Info.plist" + + # CFBundleSupportedPlatforms: array with one string identifying the + # platform the binary was built for. "iPhoneOS" for device builds, + # "iPhoneSimulator" for sim. Apple validator error 90562 if missing. + /usr/libexec/PlistBuddy -c "Add :CFBundleSupportedPlatforms array" "$APP/Info.plist" 2>/dev/null \ + && /usr/libexec/PlistBuddy -c "Add :CFBundleSupportedPlatforms:0 string iPhoneOS" "$APP/Info.plist" + + if [ -d "ios/Assets.xcassets/AppIcon.appiconset" ]; then + ACTOOL_PLIST=$(mktemp /tmp/actool_XXXXXX.plist) + xcrun actool ios/Assets.xcassets \ + --compile "$APP" --platform iphoneos \ + --minimum-deployment-target 17.0 \ + --app-icon AppIcon \ + --output-partial-info-plist "$ACTOOL_PLIST" 2>/dev/null || true + /usr/libexec/PlistBuddy -c "Merge $ACTOOL_PLIST" "$APP/Info.plist" 2>/dev/null || true + rm -f "$ACTOOL_PLIST" + fi + + echo "=== Bundling OTP runtime (no EPMD binary path) ===" + OTP_BUNDLE="$APP/otp" + mkdir -p "$OTP_BUNDLE" + rsync -a --delete "$OTP_ROOT/lib/" "$OTP_BUNDLE/lib/" + rsync -a --delete "$OTP_ROOT/releases/" "$OTP_BUNDLE/releases/" + rsync -a --delete "$OTP_ROOT/$APP_MODULE/" "$OTP_BUNDLE/$APP_MODULE/" + for f in "$OTP_ROOT"/*.png "$OTP_ROOT"/*.jpg; do + [ -f "$f" ] && cp "$f" "$OTP_BUNDLE/" + done + mkdir -p "$OTP_BUNDLE/$ERTS_VSN/bin" + + # ── App Store bundle policy: ONE Mach-O per .app, no .so/.a/standalone ── + # Apple's validator rejects the bundle if it contains any of: + # - dynamic loadable libraries (.so files for NIFs/drivers) + # - static archives (.a — these are linked into the main binary at + # build time, but copying them into the bundle is still rejected) + # - standalone executable files (erl_call, memsup, beam.smp, etc.) + # Strip them all from the bundled OTP tree. The static archives are + # already linked into $APP_NAME (the main Mach-O); the .so files + # belong to OTP libs the app doesn't actually use (megaco, + # runtime_tools, asn1's dynamic variant). + # ── Apple-policy strips (always on; not optional for App Store) ── + # Apple's validator rejects bundles containing .so/.a (frameworks must + # use .framework), priv/bin executables, or extra binaries in erts-*/bin. + # The BEAM is static-linked into the main Mach-O so these are + # unreachable from runtime anyway. NOT gated on MOB_SLIM — even + # `--no-slim` builds need to pass App Store validation. + echo "=== Stripping App-Store-disallowed binaries (always on) ===" + find "$OTP_BUNDLE" -type f \( -name "*.so" -o -name "*.a" \) -delete + find "$OTP_BUNDLE" -path "*/priv/bin/*" -type f -delete + find "$OTP_BUNDLE/$ERTS_VSN/bin" -type f -delete 2>/dev/null || true + # Standalone executables inside OTP libs (e.g. erl_interface/bin/erl_call) + # are also rejected by App Store validation (90171) and can't exec on iOS + # anyway. Remove every lib/*/bin/* executable while keeping the libs' + # .beam/.app — so a --no-slim full-OTP bundle (needed for runtime Mix.install) + # still passes Apple's "no standalone executables" rule. + find "$OTP_BUNDLE/lib" -path "*/bin/*" -type f -delete 2>/dev/null || true + + # ── Slim strips (gated; opt out with `mix mob.release --no-slim`) ── + # Each step echoes a tagged header AND the bundle size delta so a + # broken build can be traced to a specific step. The grep-friendly tag + # `[SLIM:<step>]` is what the docs walkthrough searches for. + if [ "${MOB_SLIM:-1}" = "1" ]; then + # Helper to log size delta around a step. Bash function so each + # step's size delta is visible in the build log without bespoke code. + slim_step() { + local label=$1 + local before=$(du -sk "$OTP_BUNDLE" 2>/dev/null | awk '{print $1}') + shift + "$@" + local after=$(du -sk "$OTP_BUNDLE" 2>/dev/null | awk '{print $1}') + local delta=$((before - after)) + printf "[SLIM:%s] %s KB → %s KB (-%s KB)\n" "$label" "$before" "$after" "$delta" + } + + echo "=== Slim strip pass ===" + + slim_step prefix_libs bash -c ' + # Note: compiler intentionally kept — Ecto.Migrator compiles + # .exs migration files at runtime via Code.compile_file, which + # requires the :compiler OTP app. Stripping it lands a + # `{:badmatch, {:error, :enoent, :"compiler.app"}}` deep in + # application_controller during app boot, so the BEAM never + # reaches the first screen. + for prefix in megaco runtime_tools erl_interface os_mon wx et eunit \ + observer debugger diameter edoc tools snmp dialyzer \ + syntax_tools parsetools xmerl reltool inets ftp tftp \ + common_test mnesia eldap odbc \ + ssh; do + rm -rf "'"$OTP_BUNDLE"'/lib/$prefix-"* + done + ' + + slim_step foreign_apps bash -c ' + for prefix in toy_ test_ mob_test scratch_; do + rm -rf "'"$OTP_BUNDLE"'/lib/$prefix"*-* + done + ' + + slim_step dedup_versions bash -c ' + set +e + cd "'"$OTP_BUNDLE"'/lib" + for name in $(ls -1 2>/dev/null | sed "s/-[0-9].*$//" | sort -u); do + versions=$(ls -1d "${name}"-[0-9]* 2>/dev/null | sort -V) + [ -z "$versions" ] && continue + count=$(printf "%s\n" "$versions" | wc -l | tr -d " ") + if [ "$count" -gt 1 ]; then + latest=$(printf "%s\n" "$versions" | tail -1) + for v in $versions; do + [ "$v" != "$latest" ] && rm -rf "$v" + done + fi + done + ' + + slim_step src_and_headers find "$OTP_BUNDLE" -type d \( -name src -o -name include \) -prune -exec rm -rf {} + + + slim_step beam_chunks erl -noinput -boot start_clean -eval " + case beam_lib:strip_release(\"$OTP_BUNDLE\") of + {ok, _} -> erlang:halt(0); + {error, beam_lib, R} -> + io:format(standard_error, \" strip_release error: ~p~n\", [R]), + erlang:halt(1) + end." + else + echo "[SLIM:skipped] MOB_SLIM=0 — keeping full OTP runtime" + fi + + echo " $(find "$OTP_BUNDLE" -type f | wc -l | tr -d ' ') files in bundle after strip" + + # Strip non-global symbols from the main Mach-O — slim only. + # MUST happen before codesigning since strip rewrites the file. + if [ "${MOB_SLIM:-1}" = "1" ]; then + echo "=== Stripping non-global symbols from main binary ===" + SIZE_BEFORE_STRIP=$(stat -f%z "$APP/$APP_NAME") + xcrun strip -x "$APP/$APP_NAME" + SIZE_AFTER_STRIP=$(stat -f%z "$APP/$APP_NAME") + echo " $APP_NAME: $((SIZE_BEFORE_STRIP / 1024)) KB → $((SIZE_AFTER_STRIP / 1024)) KB" + fi + + echo "=== Embedding App Store provisioning profile ===" + PROFILE_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" + PROFILE="$PROFILE_DIR/${PROFILE_UUID}.mobileprovision" + if [ ! -f "$PROFILE" ]; then + PROFILE="$HOME/Library/MobileDevice/Provisioning Profiles/${PROFILE_UUID}.mobileprovision" + fi + if [ ! -f "$PROFILE" ]; then + echo "ERROR: Provisioning profile $PROFILE_UUID not found." + exit 1 + fi + cp "$PROFILE" "$APP/embedded.mobileprovision" + + echo "=== Code signing (distribution, no get-task-allow) ===" + ENTITLEMENTS_FILE="$BUILD_DIR/mob_release.entitlements" + cat > "$ENTITLEMENTS_FILE" << ENTEOF + <?xml version="1.0" encoding="UTF-8"?> + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> + <plist version="1.0"> + <dict> + <key>application-identifier</key> + <string>${TEAM_ID}.${BUNDLE_ID}</string> + <key>com.apple.developer.team-identifier</key> + <string>${TEAM_ID}</string> + <key>beta-reports-active</key> + <true/> + </dict> + </plist> + ENTEOF + codesign --force --sign "$SIGN_IDENTITY" \ + --entitlements "$ENTITLEMENTS_FILE" \ + --timestamp \ + --options runtime \ + "$APP" + + echo "=== Verifying signature ===" + codesign --verify --deep --strict --verbose=2 "$APP" + + echo "=== Packaging IPA ===" + # `ditto -c -k --keepParent` (rather than plain `zip -r`) preserves + # symlinks and bundle structure that App Store Connect's validator + # checks (error code 90071: "CodeResources must be a symbolic link"). + # Skip --sequesterRsrc — that's for macOS resource forks, not iOS; + # adding it injects a __MACOSX/ sidecar tree that confuses the + # validator. + # cp -RP preserves symlinks (plain cp -R follows them and turns them + # into regular files, which would defeat the whole exercise). + IPA_STAGE=$(mktemp -d) + mkdir -p "$IPA_STAGE/Payload" + cp -RP "$APP" "$IPA_STAGE/Payload/" + # `dot_clean` removes the macOS AppleDouble (`._<file>`) sidecars + # that get created when `cp` preserves extended attributes across + # filesystems. Apple's validator can flag these. + dot_clean -m "$IPA_STAGE/Payload" 2>/dev/null || true + find "$IPA_STAGE/Payload" -name '._*' -delete 2>/dev/null || true + IPA_PATH="$OUTPUT_DIR/$APP_NAME.ipa" + rm -f "$IPA_PATH" + # --norsrc / --noextattr / --noqtn: don't preserve resource forks, + # extended attributes, or quarantine flags. Without these, ditto + # creates `._<file>` AppleDouble sidecars inside the IPA for any + # source file that happens to have an xattr (the OTP cross-build + # leaves a bunch of these on the cached output). Apple's validator + # generally tolerates them but the IPA is cleaner without. + (cd "$IPA_STAGE" && ditto -c -k --norsrc --noextattr --noqtn --keepParent Payload "$IPA_PATH") + rm -rf "$IPA_STAGE" + + echo "=== Done: $IPA_PATH ($(du -h "$IPA_PATH" | cut -f1)) ===" + """ + end +end diff --git a/lib/mob_dev/release/android_precheck.ex b/lib/mob_dev/release/android_precheck.ex new file mode 100644 index 0000000..43ea481 --- /dev/null +++ b/lib/mob_dev/release/android_precheck.ex @@ -0,0 +1,41 @@ +defmodule MobDev.Release.AndroidPrecheck do + @moduledoc false + + # Shared precondition checks for Android release builds. Both the OTP + # cross-compile and the OpenSSL/crypto NIF cross-compile need to verify + # that an NDK install exists at the expected path before running any + # of their own logic. This module factors that out so the two recipes + # don't drift. + + alias MobDev.NdkVersion + alias MobDev.Release.Errors + + @doc """ + Verify the Android NDK is installed at the path implied by + `opts[:ndk_root]` (or the default location). Returns :ok or raises + via Errors.precondition/1. + + Takes a `shell` module so callers can pass in a fake during tests. + """ + @spec verify_ndk(module(), keyword()) :: :ok | no_return() + def verify_ndk(shell, opts) do + ndk_root = opts[:ndk_root] || default_ndk_root(opts[:ndk_version]) + + if shell.dir?(ndk_root) do + :ok + else + Errors.precondition( + "Android NDK not at #{ndk_root} — install NDK #{NdkVersion.effective()}" + ) + end + end + + @doc """ + Canonical NDK install path: `~/Library/Android/sdk/ndk/<version>`. + Falls back to `NdkVersion.effective/0` when no version is supplied. + """ + @spec default_ndk_root(String.t() | nil) :: String.t() + def default_ndk_root(version \\ nil) do + Path.join([System.user_home!(), "Library/Android/sdk/ndk", version || NdkVersion.effective()]) + end +end diff --git a/lib/mob_dev/release/errors.ex b/lib/mob_dev/release/errors.ex new file mode 100644 index 0000000..87ff4e1 --- /dev/null +++ b/lib/mob_dev/release/errors.ex @@ -0,0 +1,174 @@ +defmodule MobDev.Release.Errors do + @moduledoc """ + Typed error tags used across the `MobDev.Release.*` modules. + + These exist for one specific reason: when the release pipeline fails in + production, the caller needs to instantly distinguish "it's our bug" + from "it's an external infra issue" from "the user's environment isn't + set up right." Shell scripts blob all three together as `exit 1`; this + module gives every release function a tagged-tuple shape so the + top-level Mix task can format an actionable message. + + ## Categories + + Each error is `{:error, {category, detail}}`. The category is one of: + + * `:precondition_failed` — a checked precondition didn't hold + *before* we did real work. Example: `OTP_SRC` not a git repo, + `OPENSSL_PREFIX` not yet built, `$ANDROID_NDK_ROOT` missing. + **Caller action:** fix the environment, retry. + + * `:cmd_failed` — an external command exited nonzero. Detail + includes the command, exit code, and captured output. + **Caller action:** read the output. If it looks like our code's + fault, file a bug; if it looks like the tool's fault, escalate. + + * `:parse_failed` — output we expected to parse didn't match. This + is almost always our bug — a tool's output format drifted or our + regex was wrong. + **Caller action:** file a bug. + + * `:fs_failed` — a filesystem operation failed. Detail carries the + `:file.posix` reason. + **Caller action:** check the path; if `eacces`/`enospc` etc., + it's environmental. + + * `:infra_unreachable` — a network/GH/etc. operation failed. Carries + the HTTP status or transport error. + **Caller action:** check status.github.com / status.hex.pm. Not + our bug. + + * `:auth_required` — credentials missing or expired. Carries a hint + about which credential needs renewal. + **Caller action:** run the auth refresh command we suggest. + + ## Convenience helpers + + Each category has a constructor and a guard. Use the constructors in + return values; pattern-match on the tag in the formatter. + + def foo do + with {:ok, hash} <- read_hash(), + {:ok, _} <- validate(hash) do + {:ok, hash} + end + end + + # In the Mix task: + case Release.full() do + {:ok, _} -> :ok + {:error, {:precondition_failed, msg}} -> Mix.raise(msg) + {:error, {:auth_required, hint}} -> Mix.raise("auth: " <> hint) + {:error, other} -> Mix.raise("release failed: " <> inspect(other)) + end + """ + + @type category :: + :precondition_failed + | :cmd_failed + | :parse_failed + | :fs_failed + | :infra_unreachable + | :auth_required + + @type detail :: term() + + @type t :: {:error, {category(), detail()}} + + # ── Constructors ───────────────────────────────────────────────────────── + + @doc "Build a precondition_failed error. `msg` should be human-readable." + @spec precondition(String.t()) :: t() + def precondition(msg) when is_binary(msg), do: {:error, {:precondition_failed, msg}} + + @doc """ + Build a cmd_failed error. Captures the command's argv, exit code, and + the head of its captured output (truncated to avoid pinning huge build + logs to memory). + """ + @spec cmd_failed([String.t()], non_neg_integer(), String.t()) :: t() + def cmd_failed(argv, exit_code, output) when is_list(argv) and is_integer(exit_code) do + {:error, {:cmd_failed, %{cmd: argv, exit: exit_code, output: truncate(output)}}} + end + + @doc "Build a parse_failed error. `expected` describes what we tried to parse." + @spec parse_failed(term(), String.t()) :: t() + def parse_failed(input, expected) when is_binary(expected) do + {:error, {:parse_failed, %{input: input, expected: expected}}} + end + + @doc "Build an fs_failed error. `reason` is the `:file` posix atom." + @spec fs_failed(Path.t(), atom()) :: t() + def fs_failed(path, reason) when is_atom(reason) do + {:error, {:fs_failed, %{path: path, reason: reason}}} + end + + @doc "Build an infra_unreachable error. Detail is opaque (HTTP status, transport error, etc.)." + @spec infra_unreachable(term()) :: t() + def infra_unreachable(detail), do: {:error, {:infra_unreachable, detail}} + + @doc "Build an auth_required error. `hint` should suggest the renewal command." + @spec auth_required(String.t()) :: t() + def auth_required(hint) when is_binary(hint), do: {:error, {:auth_required, hint}} + + # ── Formatter ──────────────────────────────────────────────────────────── + + @doc """ + Format a tagged error for end-user display. Returns a string suitable + for passing to `Mix.raise/1` or `IO.puts/1`. Uses the category to + produce an actionable message. + + iex> MobDev.Release.Errors.format({:error, {:precondition_failed, "OTP_SRC missing"}}) + "precondition failed — OTP_SRC missing" + """ + @spec format(t()) :: String.t() + def format({:error, {:precondition_failed, msg}}) do + "precondition failed — #{msg}" + end + + def format({:error, {:cmd_failed, %{cmd: cmd, exit: exit, output: out}}}) do + """ + command failed (exit #{exit}): + #{Enum.join(cmd, " ")} + output: + #{indent(out, " ")}\ + """ + end + + def format({:error, {:parse_failed, %{input: input, expected: expected}}}) do + "parse failed — expected #{expected}, got: #{inspect(input, limit: 50)}" + end + + def format({:error, {:fs_failed, %{path: path, reason: reason}}}) do + "filesystem error at #{path}: #{reason}" + end + + def format({:error, {:infra_unreachable, detail}}) do + "external infrastructure unreachable: #{inspect(detail)}" + end + + def format({:error, {:auth_required, hint}}) do + "authentication required — #{hint}" + end + + # ── Internals ──────────────────────────────────────────────────────────── + + @max_output_bytes 4_000 + + defp truncate(output) when is_binary(output) do + if byte_size(output) > @max_output_bytes do + head = binary_part(output, 0, @max_output_bytes) + head <> "\n... (truncated, full output was #{byte_size(output)} bytes)" + else + output + end + end + + defp truncate(other), do: inspect(other) + + defp indent(text, prefix) do + text + |> String.split("\n") + |> Enum.map_join("\n", &(prefix <> &1)) + end +end diff --git a/lib/mob_dev/release/helpers.ex b/lib/mob_dev/release/helpers.ex new file mode 100644 index 0000000..500d973 --- /dev/null +++ b/lib/mob_dev/release/helpers.ex @@ -0,0 +1,267 @@ +defmodule MobDev.Release.Helpers do + @moduledoc """ + Replaces the two `scripts/release/_lib.sh` files. Pure functions where + possible; side-effectful ones are narrow + testable via tmpdir fixtures. + + ## Why this lives in Elixir rather than shell + + The single big reason is **bugs surface in CI rather than in user + inboxes**. The shell version's failure path is "user runs release → + obscure error → reports issue → maintainer can't reproduce locally → + long discovery cycle." This module's failure path is "CI test fails + → fix → ship." See `MobDev.Release.Errors` for the typed error tags + that make distinguishing "our bug" from "user env" from "infra down" + cheap at the call site. + + ## Single source of truth conventions + + The shell `_lib.sh` mirrored constants from Elixir modules + (`MobDev.NdkVersion`, etc.). This module *is* the source — no + mirroring. When something downstream needs the recommended NDK + version, it calls `MobDev.NdkVersion.effective/0` directly. + """ + + alias MobDev.Release.Errors + + @typedoc "Absolute path to an OTP source checkout (e.g. ~/code/otp)." + @type otp_src :: Path.t() + + @hash_length 8 + + # ── HASH detection ─────────────────────────────────────────────────────── + + @doc """ + Detect the short git hash of the OTP source tree at `otp_src`. Used as + the release-asset tag suffix (e.g. `otp-android-<hash>.tar.gz`). + + Pinned to 8 characters so tarball filenames, GitHub release tags, and + the `@otp_hash` constant in `MobDev.OtpDownloader` all stay in + lockstep. Git's default `--short` length grows over time (collision + avoidance) so without pinning the names would silently drift. + + Returns `{:ok, "8hexchars"}` on success or a tagged error. + """ + @spec git_hash(otp_src()) :: {:ok, String.t()} | Errors.t() + def git_hash(otp_src) do + git_dir = Path.join(otp_src, ".git") + + if File.dir?(git_dir) or File.regular?(git_dir) do + run_git_hash(otp_src) + else + Errors.precondition("OTP_SRC (#{otp_src}) is not a git checkout — pass `hash:` explicitly") + end + end + + defp run_git_hash(otp_src) do + case System.cmd("git", ["-C", otp_src, "rev-parse", "--short=#{@hash_length}", "HEAD"], + stderr_to_stdout: true + ) do + {output, 0} -> + parse_git_hash(output) + + {output, exit_code} -> + Errors.cmd_failed( + ["git", "-C", otp_src, "rev-parse", "--short=#{@hash_length}", "HEAD"], + exit_code, + output + ) + end + end + + @doc """ + Parse a git short-hash from `git rev-parse` output. Trims whitespace + and validates that the result is exactly `@hash_length` hex chars. + + Public for testing — the regex/length contract is the surface we want + to lock down with examples, and a pure function is the cleanest way. + """ + @spec parse_git_hash(binary()) :: {:ok, String.t()} | Errors.t() + def parse_git_hash(output) when is_binary(output) do + trimmed = String.trim(output) + + if hex_chars?(trimmed) and String.length(trimmed) == @hash_length do + {:ok, trimmed} + else + Errors.parse_failed(output, "#{@hash_length}-char hex string from `git rev-parse --short`") + end + end + + defp hex_chars?(s), do: Regex.match?(~r/^[0-9a-f]+$/, s) + + # ── ERTS version detection ─────────────────────────────────────────────── + + @doc """ + Read the ERTS version (e.g. `"17.0"`) from `<otp_src>/erts/vsn.mk`. + The file format is one line `VSN = 17.0` plus comments. + + Returns `{:ok, "17.0"}` on success or a tagged error. + """ + @spec erts_version(otp_src()) :: {:ok, String.t()} | Errors.t() + def erts_version(otp_src) do + path = Path.join([otp_src, "erts", "vsn.mk"]) + + case File.read(path) do + {:ok, content} -> parse_erts_version(content) + {:error, reason} -> Errors.fs_failed(path, reason) + end + end + + @doc """ + Parse the `VSN = <version>` line out of an erts/vsn.mk-shaped file. + + Public for testing — version-string drift between OTP releases is + exactly the kind of regression that should fail loudly with a clear + message rather than silently producing tarballs named with a missing + version suffix. + """ + @spec parse_erts_version(binary()) :: {:ok, String.t()} | Errors.t() + def parse_erts_version(content) when is_binary(content) do + case Regex.run(~r/^\s*VSN\s*=\s*(\S+)\s*$/m, content, capture: :all_but_first) do + [vsn] -> {:ok, vsn} + _ -> Errors.parse_failed(content, "a line of the form `VSN = <version>`") + end + end + + # ── Elixir lib dir ─────────────────────────────────────────────────────── + + @doc """ + Return the host Elixir's lib dir — the parent directory containing + `elixir/`, `logger/`, `eex/` as sibling app dirs. Used to bundle the + Elixir stdlib into the release tarball. + + Shell equivalent: + + ELIXIR_LIB=$(elixir -e "IO.puts(:code.lib_dir(:elixir))" | xargs dirname) + + Here we just call `:code.lib_dir/1` directly — no subprocess hop. + """ + @spec elixir_lib_dir() :: {:ok, Path.t()} | Errors.t() + def elixir_lib_dir do + case :code.lib_dir(:elixir) do + {:error, :bad_name} -> + Errors.precondition("Elixir lib dir not found via :code.lib_dir(:elixir)") + + path when is_list(path) -> + {:ok, path |> List.to_string() |> Path.dirname()} + end + end + + # ── Elixir stdlib bundler ──────────────────────────────────────────────── + + @stdlib_apps ~w(elixir logger eex) + + @doc """ + Copy the Elixir stdlib apps (`elixir`, `logger`, `eex`) from the host's + Elixir installation into the release stage directory. Mirrors + `_lib.sh`'s `bundle_elixir_stdlib()` function. + + Bytecode is arch-independent, so the same source works for all + platform tarballs. Caller is responsible for creating the stage + directory. + + Returns `{:ok, [bundled_app_dirs]}` or a tagged error. + """ + @spec bundle_elixir_stdlib(Path.t(), Path.t()) :: {:ok, [Path.t()]} | Errors.t() + def bundle_elixir_stdlib(stage_dir, elixir_lib_dir) do + if not File.dir?(elixir_lib_dir) do + Errors.fs_failed(elixir_lib_dir, :enoent) + else + do_bundle_elixir_stdlib(stage_dir, elixir_lib_dir) + end + end + + defp do_bundle_elixir_stdlib(stage_dir, elixir_lib_dir) do + Enum.reduce_while(@stdlib_apps, {:ok, []}, fn app, {:ok, acc} -> + src = Path.join([elixir_lib_dir, app, "ebin"]) + dst = Path.join([stage_dir, "lib", app, "ebin"]) + + with true <- File.dir?(src) || {:fs, src, :enoent}, + :ok <- File.mkdir_p(dst), + {:ok, _} <- File.cp_r(src, dst) do + {:cont, {:ok, [dst | acc]}} + else + {:fs, path, reason} -> {:halt, Errors.fs_failed(path, reason)} + {:error, reason} when is_atom(reason) -> {:halt, Errors.fs_failed(dst, reason)} + {:error, {:enoent, posix}} -> {:halt, Errors.fs_failed(dst, posix)} + other -> {:halt, Errors.precondition("unexpected: #{inspect(other)}")} + end + end) + |> case do + {:ok, dirs} -> {:ok, Enum.reverse(dirs)} + err -> err + end + end + + # ── Output directory + defaults ────────────────────────────────────────── + + @doc """ + Default OTP source path (mirrors `_lib.sh`'s `${OTP_SRC:=$HOME/code/otp}`). + Resolved per-call rather than at module load so test setups can swap + `$HOME` via `System.put_env/2`. + """ + @spec default_otp_src() :: Path.t() + def default_otp_src do + case System.fetch_env("OTP_SRC") do + {:ok, path} -> path + :error -> Path.join(System.user_home!(), "code/otp") + end + end + + @doc """ + Default tarball output directory (mirrors `_lib.sh`'s `${OUT_DIR:=/tmp}`). + """ + @spec default_out_dir() :: Path.t() + def default_out_dir do + System.get_env("OUT_DIR", "/tmp") + end + + # ── One-shot resolver for the common case ──────────────────────────────── + + @doc """ + Collapse the per-piece resolvers into one struct-shaped return. + Convenience for callers that want all of `{otp_src, hash, erts_vsn, + elixir_lib, out_dir}` resolved in one go. + + Honours these env vars (in the same order `_lib.sh` did): + + * `OTP_SRC` — overrides default otp source path + * `OUT_DIR` — overrides default tarball output dir + * `HASH` — pre-set hash, skips git detection + * `ERTS_VSN` — pre-set erts version, skips vsn.mk parsing + + Returns `{:ok, %{otp_src: ..., hash: ..., erts_vsn: ..., elixir_lib: + ..., out_dir: ...}}` or the first error encountered. + """ + @spec resolve_release_env(keyword()) :: {:ok, map()} | Errors.t() + def resolve_release_env(opts \\ []) do + otp_src = Keyword.get(opts, :otp_src) || default_otp_src() + out_dir = Keyword.get(opts, :out_dir) || default_out_dir() + + with {:ok, hash} <- resolve_hash(opts, otp_src), + {:ok, erts_vsn} <- resolve_erts_vsn(opts, otp_src), + {:ok, elixir_lib} <- elixir_lib_dir() do + {:ok, + %{ + otp_src: otp_src, + out_dir: out_dir, + hash: hash, + erts_vsn: erts_vsn, + elixir_lib: elixir_lib + }} + end + end + + defp resolve_hash(opts, otp_src) do + case Keyword.get(opts, :hash) || System.get_env("HASH") do + nil -> git_hash(otp_src) + hash -> {:ok, hash} + end + end + + defp resolve_erts_vsn(opts, otp_src) do + case Keyword.get(opts, :erts_vsn) || System.get_env("ERTS_VSN") do + nil -> erts_version(otp_src) + vsn -> {:ok, vsn} + end + end +end diff --git a/lib/mob_dev/release/openssl.ex b/lib/mob_dev/release/openssl.ex new file mode 100644 index 0000000..d1ccb6e --- /dev/null +++ b/lib/mob_dev/release/openssl.ex @@ -0,0 +1,409 @@ +defmodule MobDev.Release.OpenSSL do + @moduledoc """ + Replaces `scripts/release/openssl/{android_arm64,android_arm32,ios_sim, + ios_device}.sh`. Cross-compiles OpenSSL 3.x for the four target + ABIs that Mob's bundled OTP tarballs link against, producing static + `libcrypto.a` + `libssl.a` + headers under a per-target `--prefix`. + + ## API + + iex> MobDev.Release.OpenSSL.build(:android_arm64) + {:ok, %{prefix: "/tmp/openssl-android-arm64", libcrypto: ".../libcrypto.a"}} + + iex> MobDev.Release.OpenSSL.build_all() + [{:android_arm64, {:ok, _}}, {:android_arm32, {:ok, _}}, ...] + + ## Why this exists + + The shell version compiled fine but had four classes of failure mode + that this module addresses: + + * **Drift between targets.** The arm64 script had `no-asm` + removed; the arm32 script needed it but the comment explaining + *why* lived only in arm32. New targets would copy from arm64 and + get bitten. Here the `Target` spec is data — the disable-`asm` + decision lives next to the target ID, visible to anyone reading. + + * **NDK version drift.** The shell mirrored + `MobDev.NdkVersion.@recommended` as a separate constant in + `openssl/_lib.sh`. This module calls `MobDev.NdkVersion.effective/0` + directly — no mirror, no drift. + + * **Silent precondition failures.** The shell version checked + `$ANDROID_NDK_ROOT` and `$OPENSSL_SRC` and bailed with `exit 1` + and a single-line `echo`. This module returns a tagged + `:precondition_failed` with an actionable hint, so the Mix task + layer can format it with the same shape as every other release + error. + + * **No tests.** Self-explanatory. + + ## Target spec — what's shared, what differs + + All four targets share: + * The `no-X` algorithm disable list (legacy/niche crypto we don't + ship) + * Size flags: `-Os -ffunction-sections -fdata-sections -fPIC` + * The `make distclean` / `Configure` / `make -j8` / `make install_sw` + sequence + * Output layout: `$PREFIX/lib/libcrypto.a`, `$PREFIX/include/openssl/*` + + Per-target differences are encoded in `Target` structs (see + `target_spec/1`): + * `configure_target` — `"android-arm64"`, `"android-arm"`, + `"iossimulator-xcrun"`, `"ios64-xcrun"` + * `default_prefix` — `/tmp/openssl-<target>` + * `env_fn` — function that returns the `:env` list for `Shell.cmd` + (Android targets set `ANDROID_NDK_ROOT` + prepend the NDK + toolchain to `PATH`; iOS targets set `CC`/`CXX`/`AR`/`RANLIB` + to xcrun-prefixed invocations) + * `extra_configure_args` — Android adds `-D__ANDROID_API__=24`; + arm32 adds `no-asm` (its hand-written ARM assembly emits non-PIC + relocations against `OPENSSL_armcap_P` that ld.lld rejects). + + ## Verifying outputs + + `build/2` doesn't ship — it returns a map naming the produced files. + Callers (or the integration test) can assert on the existence of + those files. We don't run `file` or `xcrun nm` here; the shell did + that as a final "did it produce something for the right arch" check. + In Elixir, that's a separate `verify/2` step (TODO — likely lands in + iter 4 alongside tarball verification). + """ + + alias MobDev.Release.{Errors, Shell} + alias MobDev.NdkVersion + + @android_api 24 + @ios_min_version "17.0" + @make_parallelism 8 + + # ── Target spec ────────────────────────────────────────────────────────── + + defmodule Target do + @moduledoc """ + One cross-compile target. `env_fn` is invoked at build time so it + can read live `:code.lib_dir(:elixir)` / NDK version / etc. + """ + + @enforce_keys [:id, :configure_target, :default_prefix, :env_fn, :extra_configure_args] + defstruct [:id, :configure_target, :default_prefix, :env_fn, :extra_configure_args] + + @type t :: %__MODULE__{ + id: :android_arm64 | :android_arm32 | :ios_sim | :ios_device, + configure_target: String.t(), + default_prefix: Path.t(), + env_fn: (keyword() -> [{String.t(), String.t()}]), + extra_configure_args: [String.t()] + } + end + + @doc "All known targets in canonical order." + @spec targets() :: [atom()] + def targets, do: [:android_arm64, :android_arm32, :android_x86_64, :ios_sim, :ios_device] + + @doc """ + Return the `Target` spec for an id. Public so tests can inspect specs + without having to build them. Raises on unknown id (programmer error). + """ + @spec target_spec(atom()) :: Target.t() + def target_spec(:android_arm64) do + %Target{ + id: :android_arm64, + configure_target: "android-arm64", + default_prefix: "/tmp/openssl-android-arm64", + env_fn: &android_env/1, + # arm64 hand-written assembly is PIC-safe — no `no-asm` needed. + extra_configure_args: ["-D__ANDROID_API__=#{@android_api}"] + } + end + + def target_spec(:android_x86_64) do + %Target{ + id: :android_x86_64, + configure_target: "android-x86_64", + default_prefix: "/tmp/openssl-android-x86_64", + env_fn: &android_env/1, + # x86_64 assembly is PIC-safe — no `no-asm` needed (like arm64). + extra_configure_args: ["-D__ANDROID_API__=#{@android_api}"] + } + end + + def target_spec(:android_arm32) do + %Target{ + id: :android_arm32, + configure_target: "android-arm", + default_prefix: "/tmp/openssl-android-arm32", + env_fn: &android_env/1, + # `no-asm` is required: arm32 hand-written ARM assembly emits + # non-PIC absolute relocations against OPENSSL_armcap_P, which + # ld.lld rejects when libcrypto.a is linked into the app's .so. + # Removing this would re-introduce the build failure the shell + # version's comment block documents. + extra_configure_args: ["-D__ANDROID_API__=#{@android_api}", "no-asm"] + } + end + + def target_spec(:ios_sim) do + %Target{ + id: :ios_sim, + configure_target: "iossimulator-xcrun", + default_prefix: "/tmp/openssl-ios-sim", + env_fn: &ios_env(&1, :ios_sim), + extra_configure_args: [] + } + end + + def target_spec(:ios_device) do + %Target{ + id: :ios_device, + configure_target: "ios64-xcrun", + default_prefix: "/tmp/openssl-ios-device", + env_fn: &ios_env(&1, :ios_device), + extra_configure_args: [] + } + end + + # ── Configure-args assembly ────────────────────────────────────────────── + + @doc """ + Assemble the full `./Configure` argv (excluding the program name) for + a given target + prefix. Public so tests can assert on the exact + list without running a build. + + Returns the args in canonical order: configure_target, size flags, + per-target extras, prefix flags, disable-algorithm flags. The order + is observable (OpenSSL's Configure is sensitive to placement of some + flags) — tests pin it. + """ + @spec configure_args(Target.t(), Path.t()) :: [String.t()] + def configure_args(%Target{} = target, prefix) do + [target.configure_target] ++ + size_flags() ++ + target.extra_configure_args ++ + ["--prefix=#{prefix}", "--openssldir=#{prefix}/ssl"] ++ + disabled_algorithms() + end + + @doc "Size + compile flags shared across all targets." + @spec size_flags() :: [String.t()] + def size_flags, do: ["-Os", "-ffunction-sections", "-fdata-sections", "-fPIC"] + + @doc """ + The full `no-X` list — legacy ciphers + protocols we don't ship. + Public so tests can pin the surface and any addition surfaces in + a code review rather than buried in a shell script. + + Every entry has a justification kept inline in `disabled_algorithms_doc/0`. + """ + @spec disabled_algorithms() :: [String.t()] + def disabled_algorithms do + [ + "no-shared", + "no-tests", + "no-apps", + "no-engine", + # Superseded by SHA-2 / BLAKE2 — no modern code uses them. + "no-md2", + "no-md4", + "no-mdc2", + "no-whirlpool", + "no-rmd160", + # Legacy ciphers — AES-GCM + ChaCha20-Poly1305 cover real cryptography. + "no-rc2", + "no-rc4", + "no-idea", + "no-cast", + "no-bf", + "no-blake2", + "no-seed", + "no-aria", + "no-camellia", + # Russian-only standards. + "no-gost", + # RC4, single-DES, NULL, EXPORT. + "no-weak-ssl-ciphers", + # Pre-TLS-1.2. Refused by every modern server. + "no-ssl3", + "no-tls1", + "no-tls1_1", + # Pre-shared password/key TLS variants — niche. + "no-srp", + "no-psk", + # Superseded by ALPN. + "no-nextprotoneg" + ] + end + + # ── Build entrypoints ──────────────────────────────────────────────────── + + @doc """ + Build OpenSSL for one target. Returns `{:ok, info}` where info names + the produced artifacts, or a tagged error. + + Options: + * `:openssl_src` — OpenSSL source checkout (default: `~/code/openssl` + or `$OPENSSL_SRC` env) + * `:prefix` — install dir (default: target's `default_prefix`) + * `:ndk_root` — Android NDK root override (Android targets only) + """ + @spec build(atom(), keyword()) :: {:ok, map()} | Errors.t() + def build(target_id, opts \\ []) + when target_id in [:android_arm64, :android_arm32, :android_x86_64, :ios_sim, :ios_device] do + target = target_spec(target_id) + shell = Shell.impl() + openssl_src = opts[:openssl_src] || default_openssl_src(shell) + prefix = opts[:prefix] || target.default_prefix + env = target.env_fn.(opts) + + with :ok <- precheck(target, shell, openssl_src, opts), + :ok <- maybe_distclean(shell, openssl_src, env), + args = configure_args(target, prefix), + {:ok, _} <- shell.cmd(["./Configure" | args], cd: openssl_src, env: env), + {:ok, _} <- shell.cmd(["make", "-j#{@make_parallelism}"], cd: openssl_src, env: env), + {:ok, _} <- shell.cmd(["make", "install_sw"], cd: openssl_src, env: env) do + {:ok, + %{ + target: target_id, + prefix: prefix, + libcrypto: Path.join([prefix, "lib", "libcrypto.a"]), + libssl: Path.join([prefix, "lib", "libssl.a"]), + include: Path.join(prefix, "include") + }} + end + end + + @doc """ + Build all four targets in sequence. Returns a list of + `{target_id, result}` pairs in canonical target order. Does NOT + short-circuit on first failure — callers can decide what to do with + partial results. + """ + @spec build_all(keyword()) :: [{atom(), {:ok, map()} | Errors.t()}] + def build_all(opts \\ []) do + for target_id <- targets() do + {target_id, build(target_id, opts)} + end + end + + # ── Preconditions ──────────────────────────────────────────────────────── + + defp precheck(target, shell, openssl_src, opts) do + cond do + not shell.dir?(openssl_src) -> + Errors.precondition( + "OPENSSL_SRC missing at #{openssl_src} — clone github.com/openssl/openssl and pass `openssl_src:`" + ) + + target.id in [:android_arm64, :android_arm32, :android_x86_64] -> + android_precheck(shell, opts) + + target.id in [:ios_sim, :ios_device] -> + ios_precheck(shell, target.id) + end + end + + defp android_precheck(shell, opts) do + ndk_root = opts[:ndk_root] || default_ndk_root() + + cond do + not shell.dir?(ndk_root) -> + Errors.precondition( + "Android NDK not at #{ndk_root} — install NDK #{NdkVersion.effective()} " <> + "via Android Studio SDK manager or set `ndk_root:` opt" + ) + + not shell.dir?(android_toolchain(ndk_root)) -> + Errors.precondition( + "NDK toolchain not at #{android_toolchain(ndk_root)} — verify install integrity" + ) + + true -> + :ok + end + end + + defp ios_precheck(shell, target_id) do + sdk = ios_sdk_name(target_id) + + case shell.cmd(["xcrun", "--sdk", sdk, "--show-sdk-path"], []) do + {:ok, _} -> + :ok + + {:error, _} -> + Errors.precondition( + "iOS #{sdk} SDK not available — install Xcode + run xcode-select --install" + ) + end + end + + defp ios_sdk_name(:ios_sim), do: "iphonesimulator" + defp ios_sdk_name(:ios_device), do: "iphoneos" + + # ── distclean (best-effort) ───────────────────────────────────────────── + + defp maybe_distclean(shell, openssl_src, env) do + # The shell script does `make distclean >/dev/null 2>&1 || true`. + # First-time builds have nothing to clean; subsequent builds for + # a different arch need the clean to drop the prior config. Either + # way, we don't care about the result — just don't let it stop us. + _ = shell.cmd(["make", "distclean"], cd: openssl_src, env: env) + :ok + end + + # ── Per-target env builders ────────────────────────────────────────────── + + defp android_env(opts) do + ndk_version = opts[:ndk_version] || NdkVersion.effective() + ndk_root = opts[:ndk_root] || default_ndk_root(ndk_version) + toolchain = android_toolchain(ndk_root) + current_path = System.get_env("PATH", "") + + [ + {"ANDROID_NDK_ROOT", ndk_root}, + {"PATH", "#{toolchain}/bin:#{current_path}"} + ] + end + + defp ios_env(_opts, target_id) do + sdk = ios_sdk_name(target_id) + min_flag = ios_min_version_flag(target_id) + + cc = "xcrun -sdk #{sdk} clang -arch arm64 #{min_flag}" + cxx = "xcrun -sdk #{sdk} clang++ -arch arm64 #{min_flag}" + ar = "xcrun -sdk #{sdk} ar" + ranlib = "xcrun -sdk #{sdk} ranlib" + + [ + {"CC", cc}, + {"CXX", cxx}, + {"AR", ar}, + {"RANLIB", ranlib} + ] + end + + defp ios_min_version_flag(:ios_sim), do: "-mios-simulator-version-min=#{@ios_min_version}" + defp ios_min_version_flag(:ios_device), do: "-miphoneos-version-min=#{@ios_min_version}" + + # ── Defaults ───────────────────────────────────────────────────────────── + + defp default_openssl_src(shell) do + case shell.fetch_env("OPENSSL_SRC") do + {:ok, path} -> path + :error -> Path.join(System.user_home!(), "code/openssl") + end + end + + defp default_ndk_root(ndk_version \\ nil) do + version = ndk_version || NdkVersion.effective() + Path.join([System.user_home!(), "Library/Android/sdk/ndk", version]) + end + + defp android_toolchain(ndk_root) do + # Host-OS detection: darwin-x86_64 covers Apple Silicon too (Apple + # ships Rosetta x86_64 toolchain — NDK r27 doesn't have an arm64 + # variant yet). When NDK ships native arm64-darwin we'd add a + # detection branch here. + Path.join([ndk_root, "toolchains/llvm/prebuilt", "darwin-x86_64"]) + end +end diff --git a/lib/mob_dev/release/openssl/crypto_nif.ex b/lib/mob_dev/release/openssl/crypto_nif.ex new file mode 100644 index 0000000..f626b22 --- /dev/null +++ b/lib/mob_dev/release/openssl/crypto_nif.ex @@ -0,0 +1,412 @@ +defmodule MobDev.Release.OpenSSL.CryptoNif do + @moduledoc """ + Replaces `scripts/release/openssl/build_crypto_static_*.sh` (×4). + Compiles OTP's crypto NIF C sources with `-DSTATIC_ERLANG_NIF` for one + target ABI and archives the result as `crypto.a`. Pairs with + `MobDev.Release.OpenSSL` (the OpenSSL build itself) to produce the + two `.a` files that get static-linked into the user app's main + native binary: + + $OPENSSL_PREFIX/lib/libcrypto.a ← from MobDev.Release.OpenSSL + $OTP_SRC/lib/crypto/priv/lib/<arch>/crypto.a ← from this module + + ## Why static-link the crypto NIF? + + Different reasons on each platform; the artifact is the same. + + * **Android.** dlopen'd children inherit `RTLD_LOCAL` by default, + which hides the parent's `enif_*` symbols from `crypto.so`. + `crypto.so`'s `on_load` then fails with "cannot locate symbol". + Static linking sidesteps that — the BEAM finds + `crypto_nif_init` via `dlsym(RTLD_DEFAULT)` instead of dlopen. + * **iOS.** App Store forbids loading unsigned dylib/dlopen — every + NIF must be present in the final signed binary. Same artifact. + + ## Per-target deltas (the spec) + + All four targets compile the same 30 source files (`@sources`) with a + shared base CFLAGS list (`@base_cflags`). The deltas are: + + | Target | Arch dir | Toolchain | Extra CFLAGS | nm symbol | + |---------------|---------------------------------|--------------------|-----------------------------------------------|-------------------| + | android_arm64 | aarch64-unknown-linux-android | NDK clang/llvm-ar | Android hardening: branch-protect, stack-clash, _GNU_SOURCE | `crypto_nif_init` | + | android_arm32 | arm-unknown-linux-androideabi | NDK clang/llvm-ar | Android hardening + `-march=armv7-a -mfloat-abi=softfp -mthumb` | `crypto_nif_init` | + | ios_sim | aarch64-apple-iossimulator | xcrun (sim SDK) | iOS minimal — no Android hardening | `_crypto_nif_init` | + | ios_device | aarch64-apple-ios | xcrun (device SDK) | iOS minimal — no Android hardening | `_crypto_nif_init` | + + ## Phases + + Each `build/2` call: + 1. Precheck — OTP source + OpenSSL prefix exist; Android/iOS + toolchain reachable. + 2. Compile — for each of @sources, run `<cc> <cflags> -c -o obj src`. + 3. Archive — `<ar> rcs crypto.a obj1 obj2 ...` then `<ranlib> crypto.a`. + 4. Verify — `<nm> crypto.a`, scan output for the expected symbol. + Symbol missing is a `:precondition_failed` (means our compile + didn't actually produce `crypto_nif_init` — usually because the + OTP source moved out from under us). + """ + + alias MobDev.Release.{Errors, Shell} + alias MobDev.NdkVersion + + @android_api 24 + @ios_min_version "17.0" + + # ── Source list — OTP's crypto NIF C files, minus otp_test_engine.c ───── + + @sources [ + "aead.c", + "aes.c", + "algorithms.c", + "api_ng.c", + "atoms.c", + "bn.c", + "cipher.c", + "cmac.c", + "common.c", + "crypto.c", + "crypto_callback.c", + "dh.c", + "digest.c", + "dss.c", + "ec.c", + "ecdh.c", + "eddsa.c", + "engine.c", + "evp.c", + "fips.c", + "hash.c", + "hash_equals.c", + "hmac.c", + "info.c", + "mac.c", + "math.c", + "pbkdf2_hmac.c", + "pkey.c", + "rand.c", + "rsa.c", + "srp.c" + ] + + @doc "Source files compiled for every target. Public so tests can pin the surface." + @spec sources() :: [String.t()] + def sources, do: @sources + + # ── Base CFLAGS — shared across all targets ───────────────────────────── + + @base_cflags [ + "-fno-strict-aliasing", + "-fno-delete-null-pointer-checks", + "-fno-strict-overflow", + "-fexceptions", + "-fstack-protector-strong", + "-U_FORTIFY_SOURCE", + "-D_FORTIFY_SOURCE=3", + "-fno-common", + "-g", + "-Os", + "-ffunction-sections", + "-fdata-sections", + "-fPIC", + "-DHAVE_OPENSSL_CRYPTO_MEMCMP", + "-DSTATIC_ERLANG_NIF", + "-DDISABLE_EVP_DH=0", + "-DDISABLE_EVP_HMAC=0", + "-Wno-deprecated-declarations" + ] + + @doc "Base CFLAGS shared across all targets. Public for testing." + @spec base_cflags() :: [String.t()] + def base_cflags, do: @base_cflags + + # Android targets add hardening flags + _GNU_SOURCE. iOS doesn't ship + # these — they're either non-applicable (no branch-protect on iOS + # arm64 — Apple's PAC is enabled differently) or Apple's SDK already + # defines its own equivalents. + @android_extra_cflags [ + "-fstrict-flex-arrays=3", + "-mbranch-protection=standard", + "-fstack-clash-protection", + "-D_GNU_SOURCE" + ] + + # arm32 additionally needs ABI flags: armv7-a target arch, softfp ABI + # (required for Android — Google's API contract pins this), and -mthumb + # to match NDK's default code-gen. + @arm32_extra_cflags ["-march=armv7-a", "-mfloat-abi=softfp", "-mthumb"] + + # ── Target spec ───────────────────────────────────────────────────────── + + defmodule Target do + @moduledoc "Per-target description: arch path layout, toolchain factory, extra CFLAGS, expected nm symbol." + + @enforce_keys [:id, :arch_dir, :tools_fn, :extra_cflags, :nm_symbol, :default_prefix] + defstruct [:id, :arch_dir, :tools_fn, :extra_cflags, :nm_symbol, :default_prefix] + + @type tools :: %{cc: [String.t()], ar: [String.t()], ranlib: [String.t()], nm: [String.t()]} + + @type t :: %__MODULE__{ + id: :android_arm64 | :android_arm32 | :ios_sim | :ios_device, + arch_dir: String.t(), + tools_fn: (keyword() -> tools()), + extra_cflags: [String.t()], + nm_symbol: String.t(), + default_prefix: Path.t() + } + end + + @doc "All known crypto-NIF targets. Same set as `MobDev.Release.OpenSSL.targets/0`." + @spec targets() :: [atom()] + def targets, do: [:android_arm64, :android_arm32, :ios_sim, :ios_device] + + @doc """ + Per-target spec. Public so tests can lock down the surface + (especially the `extra_cflags` lists — silent drops there would + silently weaken released binaries). + """ + @spec target_spec(atom()) :: Target.t() + def target_spec(:android_arm64) do + %Target{ + id: :android_arm64, + arch_dir: "aarch64-unknown-linux-android", + tools_fn: &android_tools(&1, :android_arm64), + extra_cflags: @android_extra_cflags, + nm_symbol: "crypto_nif_init", + default_prefix: "/tmp/openssl-android-arm64" + } + end + + def target_spec(:android_arm32) do + %Target{ + id: :android_arm32, + arch_dir: "arm-unknown-linux-androideabi", + tools_fn: &android_tools(&1, :android_arm32), + extra_cflags: @arm32_extra_cflags ++ @android_extra_cflags, + nm_symbol: "crypto_nif_init", + default_prefix: "/tmp/openssl-android-arm32" + } + end + + def target_spec(:ios_sim) do + %Target{ + id: :ios_sim, + arch_dir: "aarch64-apple-iossimulator", + tools_fn: &ios_tools(&1, :ios_sim), + extra_cflags: [], + # Mach-O symbols carry a leading underscore in the nm output. + nm_symbol: "_crypto_nif_init", + default_prefix: "/tmp/openssl-ios-sim" + } + end + + def target_spec(:ios_device) do + %Target{ + id: :ios_device, + arch_dir: "aarch64-apple-ios", + tools_fn: &ios_tools(&1, :ios_device), + extra_cflags: [], + nm_symbol: "_crypto_nif_init", + default_prefix: "/tmp/openssl-ios-device" + } + end + + # ── CFLAGS assembly (pure) ────────────────────────────────────────────── + + @doc """ + Assemble the full CFLAGS list for a target, given OpenSSL prefix + + OTP source path. Pure function for testability — silent flag drops + are the exact regression class this module exists to prevent. + + Order: base CFLAGS + per-target extras + include paths. Includes + carry the arch_dir suffix on OTP-internal headers because OTP + per-arch's `erl_int_sizes_config.h` lives there. + """ + @spec cflags(Target.t(), Path.t(), Path.t()) :: [String.t()] + def cflags(%Target{} = target, openssl_prefix, otp_src) do + @base_cflags ++ + target.extra_cflags ++ + [ + "-I#{openssl_prefix}/include", + "-I#{otp_src}/erts/emulator/beam", + "-I#{otp_src}/erts/include", + "-I#{otp_src}/erts/include/#{target.arch_dir}", + "-I#{otp_src}/erts/include/internal", + "-I#{otp_src}/erts/include/internal/#{target.arch_dir}", + "-I#{otp_src}/erts/emulator/sys/unix", + "-I#{otp_src}/erts/emulator/sys/common" + ] + end + + # ── Build entrypoint ──────────────────────────────────────────────────── + + @doc """ + Compile + archive + verify the crypto NIF for one target. Returns + `{:ok, info}` naming the produced archive, or a tagged error. + + Options: + * `:otp_src` — OTP source checkout (default: `$OTP_SRC` env or + `~/code/otp`) + * `:openssl_prefix` — OpenSSL install dir (default: target's + `default_prefix`) + * `:ndk_root` — Android NDK root (Android targets only) + """ + @spec build(atom(), keyword()) :: {:ok, map()} | Errors.t() + def build(target_id, opts \\ []) + when target_id in [:android_arm64, :android_arm32, :ios_sim, :ios_device] do + target = target_spec(target_id) + shell = Shell.impl() + otp_src = opts[:otp_src] || default_otp_src(shell) + openssl_prefix = opts[:openssl_prefix] || target.default_prefix + + paths = %{ + crypto_src: Path.join([otp_src, "lib/crypto/c_src"]), + obj_dir: Path.join([otp_src, "lib/crypto/priv/obj/#{target.arch_dir}_static_nif"]), + lib_dir: Path.join([otp_src, "lib/crypto/priv/lib/#{target.arch_dir}"]) + } + + with :ok <- precheck(target, shell, otp_src, openssl_prefix, opts), + tools = target.tools_fn.(opts), + flags = cflags(target, openssl_prefix, otp_src), + :ok <- shell.mkdir_p(paths.obj_dir), + :ok <- shell.mkdir_p(paths.lib_dir), + {:ok, objects} <- compile_sources(shell, tools, flags, paths), + archive = Path.join(paths.lib_dir, "crypto.a"), + :ok <- shell.rm_f(archive), + {:ok, _} <- shell.cmd(tools.ar ++ ["rcs", archive | objects], []), + {:ok, _} <- shell.cmd(tools.ranlib ++ [archive], []), + :ok <- verify_symbol(shell, tools.nm, archive, target.nm_symbol) do + {:ok, %{target: target_id, archive: archive, objects: objects}} + end + end + + defp compile_sources(shell, tools, flags, paths) do + Enum.reduce_while(@sources, {:ok, []}, fn src, {:ok, acc} -> + src_path = Path.join(paths.crypto_src, src) + obj_path = Path.join(paths.obj_dir, String.replace_suffix(src, ".c", ".o")) + + argv = tools.cc ++ flags ++ ["-c", "-o", obj_path, src_path] + + case shell.cmd(argv, []) do + {:ok, _} -> {:cont, {:ok, [obj_path | acc]}} + err -> {:halt, err} + end + end) + |> case do + {:ok, objs} -> {:ok, Enum.reverse(objs)} + err -> err + end + end + + defp verify_symbol(shell, nm_argv, archive, expected_symbol) do + case shell.cmd(nm_argv ++ [archive], []) do + {:ok, output} -> check_symbol_present(output, expected_symbol, archive) + err -> err + end + end + + @doc """ + Parse `nm` output and confirm the expected `crypto_nif_init` symbol + is exported (`T` flag in nm's output). Returns `:ok` or a tagged + precondition_failed. + + Public for testing — the shell version did `nm <archive> | grep -E + ' T crypto_nif_init$' | head -3` and silently let release ship if + the grep returned 0 lines. This module's behaviour: missing symbol + is a hard failure with a clear message. + """ + @spec check_symbol_present(binary(), String.t(), Path.t()) :: :ok | Errors.t() + def check_symbol_present(nm_output, expected_symbol, archive) when is_binary(nm_output) do + # nm output lines look like: + # 0000000000000000 T crypto_nif_init + # 0000000000000018 T _other_symbol + # We want " T <symbol>" with nothing after — exact match on the + # symbol name. Anchoring to end-of-line catches the case where some + # related symbol shares a prefix. + pattern = ~r/^\s*[0-9a-f]+ T #{Regex.escape(expected_symbol)}\s*$/m + + if Regex.match?(pattern, nm_output) do + :ok + else + Errors.precondition( + "expected `T #{expected_symbol}` not found in #{archive} — " <> + "did OTP's crypto source layout change? Did -DSTATIC_ERLANG_NIF get dropped?" + ) + end + end + + # ── Preconditions ────────────────────────────────────────────────────── + + defp precheck(target, shell, otp_src, openssl_prefix, opts) do + cond do + not shell.dir?(otp_src) -> + Errors.precondition("OTP_SRC missing at #{otp_src} — clone github.com/erlang/otp") + + not shell.dir?(openssl_prefix) -> + Errors.precondition( + "OPENSSL_PREFIX missing at #{openssl_prefix} — run MobDev.Release.OpenSSL.build(#{inspect(target.id)}) first" + ) + + target.id in [:android_arm64, :android_arm32] -> + android_precheck(shell, opts) + + target.id in [:ios_sim, :ios_device] -> + :ok + end + end + + defp android_precheck(shell, opts), do: MobDev.Release.AndroidPrecheck.verify_ndk(shell, opts) + + # ── Per-target toolchain factories ───────────────────────────────────── + + defp android_tools(opts, target_id) do + toolchain_bin = android_toolchain_bin(opts) + cc_name = android_cc_name(target_id) + + %{ + cc: [Path.join(toolchain_bin, cc_name)], + ar: [Path.join(toolchain_bin, "llvm-ar")], + ranlib: [Path.join(toolchain_bin, "llvm-ranlib")], + nm: [Path.join(toolchain_bin, "llvm-nm")] + } + end + + defp android_cc_name(:android_arm64), do: "aarch64-linux-android#{@android_api}-clang" + defp android_cc_name(:android_arm32), do: "armv7a-linux-androideabi#{@android_api}-clang" + + defp ios_tools(_opts, target_id) do + sdk = ios_sdk_name(target_id) + min_flag = ios_min_version_flag(target_id) + + %{ + cc: ["xcrun", "-sdk", sdk, "clang", "-arch", "arm64", min_flag], + ar: ["xcrun", "-sdk", sdk, "ar"], + ranlib: ["xcrun", "-sdk", sdk, "ranlib"], + nm: ["xcrun", "-sdk", sdk, "nm"] + } + end + + defp ios_sdk_name(:ios_sim), do: "iphonesimulator" + defp ios_sdk_name(:ios_device), do: "iphoneos" + + defp ios_min_version_flag(:ios_sim), do: "-mios-simulator-version-min=#{@ios_min_version}" + defp ios_min_version_flag(:ios_device), do: "-miphoneos-version-min=#{@ios_min_version}" + + # ── Defaults ─────────────────────────────────────────────────────────── + + defp default_otp_src(shell) do + case shell.fetch_env("OTP_SRC") do + {:ok, path} -> path + :error -> Path.join(System.user_home!(), "code/otp") + end + end + + defp default_ndk_root do + Path.join([System.user_home!(), "Library/Android/sdk/ndk", NdkVersion.effective()]) + end + + defp android_toolchain_bin(opts) do + ndk_root = opts[:ndk_root] || default_ndk_root() + Path.join([ndk_root, "toolchains/llvm/prebuilt/darwin-x86_64/bin"]) + end +end diff --git a/lib/mob_dev/release/otp.ex b/lib/mob_dev/release/otp.ex new file mode 100644 index 0000000..7ad4666 --- /dev/null +++ b/lib/mob_dev/release/otp.ex @@ -0,0 +1,429 @@ +defmodule MobDev.Release.OTP do + @moduledoc """ + Replaces `scripts/release/xcompile_*.sh` × 3 + the misplaced + `scripts/release/openssl/_build_otp_android_arm64.sh`. Cross-compiles + the Erlang OTP runtime for one of four target ABIs and stages the + install tree at a release root. + + iex> MobDev.Release.OTP.build(:android_arm64, + ...> openssl_prefix: "/tmp/openssl-android-arm64") + {:ok, %{release_root: "/tmp/otp-android", erts_vsn: "17.0", ...}} + + ## Phase 6c iter 13d deferral preserved + + The C compiler stays `xcrun cc` / NDK clang — iter 13d's research + established that swapping in `zig cc` here breaks on OTP's emulator + Makefile.in dep-generation pass. **This module is orchestration + only**: it wraps `./otp_build configure && make` with typed errors + + testable invocations, but the OTP build itself runs with the + toolchain it always has. + + ## Per-target deltas + + | Target | xcomp-conf | SSL strategy | Install | + |---------------|-----------------------------------------|----------------------------------------------|------------------------------------| + | android_arm64 | xcomp/erl-xcomp-arm64-android.conf | `--with-ssl=<prefix> --disable-dynamic-ssl-lib` | `./otp_build release -a <root>` | + | android_arm32 | xcomp/erl-xcomp-arm-android.conf | same | same | + | ios_sim | xcomp/erl-xcomp-arm64-iossimulator.conf | `--without-ssl` (the xcomp conf sets `--enable-static-nifs`; OTP doesn't propagate `--with-ssl` to beam.emu's link in that mode) | `make release RELEASE_ROOT=<root>` | + | ios_device | xcomp/erl-xcomp-arm64-ios.conf | same as ios_sim | same | + + ### Why the iOS targets diverge on install method + SSL flag + + Trial-and-error knowledge from the shell scripts, preserved here + inline so it's available to anyone reading the spec: + + * iOS xcomp confs set `--enable-static-nifs`, which static-links + crypto into beam.emu at OTP-build time. OTP's build system + doesn't propagate `--with-ssl=<prefix>` into beam.emu's link + line, so `--with-ssl` would break with undefined references to + `RAND_seed` / `OSSL_PROVIDER_load` / etc. We side-step by + building crypto separately via `MobDev.Release.OpenSSL.CryptoNif`, + then the tarball stage (iter 4) ships `crypto.a` + `libcrypto.a` + and the user's app links them at app-build time. + * `make release RELEASE_ROOT=` is the install incantation that + works for iOS xcomp; `./otp_build release -a` (used by Android) + hits a layout mismatch. + + Don't try to "unify" these without re-running the experiments — + the divergence is load-bearing. + + ## What "build" does end-to-end + + 1. `make distclean` (tolerant of failure — first-time builds + have nothing to clean) + 2. `./otp_build configure --xcomp-conf=<conf> <ssl-flags>` + 3. `./otp_build boot` (the long step, ~5-10 minutes) + 4. `rm -rf <release_root>` (idempotency — re-runs replace) + 5. Install: `./otp_build release -a` (Android) OR `make release + RELEASE_ROOT=` (iOS) + 6. Verify: per-target sanity checks (release tree exists, expected + arch-specific config.h / libs are produced, Android-only: + crypto/public_key/ssl apps are present in the install tree + — i.e. `--with-ssl` was wired correctly) + """ + + alias MobDev.Release.{Errors, Shell, Helpers} + alias MobDev.NdkVersion + + # ── Target spec ──────────────────────────────────────────────────────── + + defmodule Target do + @moduledoc "Per-target OTP cross-compile description." + + @enforce_keys [ + :id, + :arch_dir, + :xcomp_conf, + :default_release_root, + :ssl_strategy, + :install_method, + :env_fn + ] + defstruct [ + :id, + :arch_dir, + :xcomp_conf, + :default_release_root, + :ssl_strategy, + :install_method, + :env_fn + ] + + @type ssl_strategy :: :with_openssl | :without_ssl + @type install_method :: :otp_build_release | :make_release + + @type t :: %__MODULE__{ + id: :android_arm64 | :android_arm32 | :ios_sim | :ios_device, + arch_dir: String.t(), + xcomp_conf: String.t(), + default_release_root: Path.t(), + ssl_strategy: ssl_strategy(), + install_method: install_method(), + env_fn: (keyword() -> [{String.t(), String.t()}]) + } + end + + @doc "All cross-compile targets, in canonical order." + @spec targets() :: [atom()] + def targets, do: [:android_arm64, :android_arm32, :android_x86_64, :ios_sim, :ios_device] + + @doc "Per-target spec. Public for testing — surface lock-down." + @spec target_spec(atom()) :: Target.t() + def target_spec(:android_arm64) do + %Target{ + id: :android_arm64, + arch_dir: "aarch64-unknown-linux-android", + xcomp_conf: "xcomp/erl-xcomp-arm64-android.conf", + default_release_root: "/tmp/otp-android", + ssl_strategy: :with_openssl, + install_method: :otp_build_release, + env_fn: &android_env(&1, "android24") + } + end + + def target_spec(:android_x86_64) do + %Target{ + id: :android_x86_64, + arch_dir: "x86_64-pc-linux-android", + xcomp_conf: "xcomp/erl-xcomp-x86_64-android.conf", + default_release_root: "/tmp/otp-android-x86_64", + ssl_strategy: :with_openssl, + install_method: :otp_build_release, + env_fn: &android_env(&1, "android24") + } + end + + def target_spec(:android_arm32) do + %Target{ + id: :android_arm32, + arch_dir: "arm-unknown-linux-androideabi", + xcomp_conf: "xcomp/erl-xcomp-arm-android.conf", + default_release_root: "/tmp/otp-android-arm32", + ssl_strategy: :with_openssl, + install_method: :otp_build_release, + env_fn: &android_env(&1, "androideabi24") + } + end + + def target_spec(:ios_sim) do + %Target{ + id: :ios_sim, + arch_dir: "aarch64-apple-iossimulator", + xcomp_conf: "xcomp/erl-xcomp-arm64-iossimulator.conf", + default_release_root: "/tmp/otp-ios-sim", + ssl_strategy: :without_ssl, + install_method: :make_release, + env_fn: &ios_env/1 + } + end + + def target_spec(:ios_device) do + %Target{ + id: :ios_device, + arch_dir: "aarch64-apple-ios", + xcomp_conf: "xcomp/erl-xcomp-arm64-ios.conf", + default_release_root: "/tmp/otp-ios-device", + ssl_strategy: :without_ssl, + install_method: :make_release, + env_fn: &ios_env/1 + } + end + + # ── Argument assembly (pure) ─────────────────────────────────────────── + + @doc """ + Assemble the `./otp_build configure` argv (excluding the program) for + a target + opts. Pure for testability — silent flag drops here would + ship a runtime that can't load crypto / can't be cross-linked / etc. + """ + @spec configure_args(Target.t(), Path.t() | nil) :: [String.t()] + def configure_args(%Target{} = target, openssl_prefix) do + base = ["--xcomp-conf=./#{target.xcomp_conf}"] + + base ++ ssl_args(target, openssl_prefix) + end + + defp ssl_args(%Target{ssl_strategy: :with_openssl}, openssl_prefix) + when is_binary(openssl_prefix) do + ["--with-ssl=#{openssl_prefix}", "--disable-dynamic-ssl-lib"] + end + + defp ssl_args(%Target{ssl_strategy: :with_openssl}, nil) do + # Caller passed nil for openssl_prefix to a target that requires + # it. This is a precondition that build/2's precheck catches first; + # if we ever get here it's a programmer error, raise rather than + # silently producing a broken configure invocation. + raise ArgumentError, + "openssl_prefix is required for Android targets (ssl_strategy: :with_openssl)" + end + + defp ssl_args(%Target{ssl_strategy: :without_ssl}, _), do: ["--without-ssl"] + + @doc """ + Assemble the install-step argv. Returns one of: + * `["./otp_build", "release", "-a", release_root]` + * `["make", "release", "RELEASE_ROOT=" <> release_root]` + """ + @spec install_args(Target.t(), Path.t()) :: [String.t()] + def install_args(%Target{install_method: :otp_build_release}, release_root) do + ["./otp_build", "release", "-a", release_root] + end + + def install_args(%Target{install_method: :make_release}, release_root) do + ["make", "release", "RELEASE_ROOT=#{release_root}"] + end + + # ── Build entrypoint ─────────────────────────────────────────────────── + + @doc """ + Cross-compile OTP for one target. Returns `{:ok, info}` where info + names the produced release root + erts version, or a tagged error. + + Options: + * `:otp_src` — OTP source checkout (default: `$OTP_SRC` env or `~/code/otp`) + * `:openssl_prefix` — OpenSSL install (required for Android targets; + ignored for iOS) + * `:release_root` — where to install (default: target's `default_release_root`) + * `:ndk_root` — Android NDK override (Android targets only) + """ + @spec build(atom(), keyword()) :: {:ok, map()} | Errors.t() + def build(target_id, opts \\ []) + when target_id in [:android_arm64, :android_arm32, :android_x86_64, :ios_sim, :ios_device] do + target = target_spec(target_id) + shell = Shell.impl() + otp_src = opts[:otp_src] || default_otp_src(shell) + openssl_prefix = opts[:openssl_prefix] + release_root = opts[:release_root] || target.default_release_root + + with :ok <- precheck(target, shell, otp_src, openssl_prefix, opts), + {:ok, erts_vsn} <- Helpers.erts_version(otp_src), + env = target.env_fn.(opts), + _ = maybe_distclean(shell, otp_src, env), + cfg_argv = ["./otp_build", "configure" | configure_args(target, openssl_prefix)], + {:ok, _} <- shell.cmd(cfg_argv, cd: otp_src, env: env), + {:ok, _} <- shell.cmd(["./otp_build", "boot"], cd: otp_src, env: env), + :ok <- prepare_release_root(shell, release_root), + install_argv = install_args(target, release_root), + {:ok, _} <- shell.cmd(install_argv, cd: otp_src, env: env), + :ok <- verify_outputs(target, shell, otp_src, release_root, erts_vsn) do + {:ok, + %{ + target: target_id, + release_root: release_root, + erts_vsn: erts_vsn, + otp_src: otp_src + }} + end + end + + @doc """ + Build all four targets in sequence. Returns `[{target_id, result}, ...]` + in canonical order. Doesn't short-circuit on first failure — callers + can decide what to do with partial results. + + Per-target `:openssl_prefix` defaults from `MobDev.Release.OpenSSL`: + * `android_arm64` → `/tmp/openssl-android-arm64` + * `android_arm32` → `/tmp/openssl-android-arm32` + * iOS targets → not required (build uses `--without-ssl`) + """ + @spec build_all(keyword()) :: [{atom(), {:ok, map()} | Errors.t()}] + def build_all(opts \\ []) do + for target_id <- targets() do + target_opts = + case target_id do + :android_arm64 -> Keyword.put_new(opts, :openssl_prefix, "/tmp/openssl-android-arm64") + :android_arm32 -> Keyword.put_new(opts, :openssl_prefix, "/tmp/openssl-android-arm32") + :android_x86_64 -> Keyword.put_new(opts, :openssl_prefix, "/tmp/openssl-android-x86_64") + _ -> opts + end + + {target_id, build(target_id, target_opts)} + end + end + + # ── Preconditions ────────────────────────────────────────────────────── + + defp precheck(target, shell, otp_src, openssl_prefix, opts) do + cond do + not shell.dir?(otp_src) -> + Errors.precondition("OTP_SRC missing at #{otp_src} — clone github.com/erlang/otp") + + not shell.file?(Path.join(otp_src, "otp_build")) -> + Errors.precondition( + "otp_build script not found at #{otp_src}/otp_build — is this an OTP source checkout?" + ) + + target.ssl_strategy == :with_openssl and is_nil(openssl_prefix) -> + Errors.precondition( + "openssl_prefix required for #{target.id} — run MobDev.Release.OpenSSL.build/2 first " <> + "and pass `openssl_prefix: <path>`" + ) + + target.ssl_strategy == :with_openssl and not shell.dir?(openssl_prefix) -> + Errors.precondition( + "OPENSSL_PREFIX missing at #{openssl_prefix} — run MobDev.Release.OpenSSL.build(#{inspect(target.id)})" + ) + + target.id in [:android_arm64, :android_arm32, :android_x86_64] -> + android_precheck(shell, opts) + + true -> + :ok + end + end + + defp android_precheck(shell, opts), do: MobDev.Release.AndroidPrecheck.verify_ndk(shell, opts) + + # ── Distclean (tolerant) ─────────────────────────────────────────────── + + defp maybe_distclean(shell, otp_src, env) do + # Mirrors `make distclean >/dev/null 2>&1 || true` from the shell. + # First-time builds have nothing to clean; subsequent runs need it + # to drop the prior arch's config. + _ = shell.cmd(["make", "distclean"], cd: otp_src, env: env) + :ok + end + + # ── Release-root prep ────────────────────────────────────────────────── + + defp prepare_release_root(shell, release_root) do + # The shell scripts `rm -rf $RELEASE_ROOT` before installing. Mirror + # that — fresh install every time, no stale-file confusion. + case shell.cmd(["rm", "-rf", release_root], []) do + {:ok, _} -> :ok + err -> err + end + end + + # ── Output verification ──────────────────────────────────────────────── + + defp verify_outputs(target, shell, otp_src, release_root, erts_vsn) do + erts_dir = Path.join(release_root, "erts-#{erts_vsn}") + + cond do + not shell.dir?(erts_dir) -> + Errors.precondition( + "missing #{erts_dir} after install — 'make release' / 'otp_build release' didn't produce the expected layout" + ) + + target.id in [:android_arm64, :android_arm32, :android_x86_64] -> + verify_android_outputs(target, shell, release_root) + + target.id in [:ios_sim, :ios_device] -> + verify_ios_outputs(target, shell, otp_src) + end + end + + defp verify_android_outputs(_target, shell, release_root) do + # Android: --with-ssl wired correctly means crypto/public_key/ssl + # apps end up in the install tree. If they're missing, the runtime + # has no TLS — easy to miss until first SSL call. + lib_dir = Path.join(release_root, "lib") + + case shell.cmd(["ls", lib_dir], []) do + {:ok, output} -> + if Regex.match?(~r/^(crypto|public_key|ssl)-/m, output) do + :ok + else + Errors.precondition( + "crypto / public_key / ssl apps missing from #{lib_dir} — was --with-ssl wired correctly?" + ) + end + + err -> + err + end + end + + defp verify_ios_outputs(target, shell, otp_src) do + # iOS: arch-specific config.h is what downstream tarball steps copy. + config_h = Path.join([otp_src, "erts", target.arch_dir, "config.h"]) + + if shell.file?(config_h) do + :ok + else + Errors.precondition( + "missing #{config_h} after build — the arch-specific config didn't materialise" + ) + end + end + + # ── Per-target environment ───────────────────────────────────────────── + + defp android_env(opts, ndk_abi_plat) do + ndk_version = opts[:ndk_version] || NdkVersion.effective() + ndk_root = opts[:ndk_root] || default_ndk_root(ndk_version) + toolchain_bin = Path.join([ndk_root, "toolchains/llvm/prebuilt/darwin-x86_64/bin"]) + current_path = System.get_env("PATH", "") + + [ + {"NDK_ROOT", ndk_root}, + {"PATH", "#{toolchain_bin}:#{current_path}"}, + {"NDK_ABI_PLAT", ndk_abi_plat}, + # RELEASE_LIBBEAM=yes triggers OTP's release script to ship + # libbeam.a in the install tree — what our static-link path + # needs. + {"RELEASE_LIBBEAM", "yes"} + ] + end + + defp ios_env(_opts) do + # iOS xcomp configs handle the toolchain via xcrun internally; we + # just set RELEASE_LIBBEAM. No PATH manipulation needed. + [{"RELEASE_LIBBEAM", "yes"}] + end + + # ── Defaults ─────────────────────────────────────────────────────────── + + defp default_otp_src(shell) do + case shell.fetch_env("OTP_SRC") do + {:ok, path} -> path + :error -> Path.join(System.user_home!(), "code/otp") + end + end + + defp default_ndk_root(ndk_version) do + version = ndk_version || NdkVersion.effective() + Path.join([System.user_home!(), "Library/Android/sdk/ndk", version]) + end +end diff --git a/lib/mob_dev/release/publish.ex b/lib/mob_dev/release/publish.ex new file mode 100644 index 0000000..f48a06a --- /dev/null +++ b/lib/mob_dev/release/publish.ex @@ -0,0 +1,342 @@ +defmodule MobDev.Release.Publish do + @moduledoc """ + Replaces `scripts/release/publish.sh` — uploads built tarballs to a + GitHub release tagged `otp-<hash>`. The release is created if it + doesn't exist; existing assets with matching names are deleted before + upload (`gh release upload` won't replace by default). + + ## Why this module carries the most error categories + + Of the five `MobDev.Release.*` orchestrators this is the one most + likely to fail through no fault of ours — GitHub goes down, `gh` + auth expires, the local network drops. The shell version of this + step blobs all of those together as `exit 1`; the user can't tell + whether it's their problem or ours. This module classifies every + `gh` failure into one of: + + * `:auth_required` — gh CLI isn't authenticated, or token lacks + `repo` scope. Hint suggests + `gh auth login --scopes "repo,write:packages"`. + * `:infra_unreachable` — GitHub returned 5xx or the network's + down. Detail carries the first line of `gh`'s stderr so it's + obvious what to check on status.github.com. + * `:precondition_failed` — no tarballs in `out_dir` matching + `hash`. Hint points the user at `mix mob.release.tarball all`. + * `:cmd_failed` — fallback for unexpected `gh` failures (e.g. + malformed tag, write permission denied on a non-our repo). + + This is exactly the "is GitHub down or am I broken?" distinction the + build-system migration plan called for. Test coverage exercises each + category against a representative `gh` stderr snippet. + + ## Public API + + MobDev.Release.Publish.publish() # defaults + MobDev.Release.Publish.publish(repo: "fork/mob") # publish to fork + MobDev.Release.Publish.publish(assets: ["otp-android"]) # subset + + Returns `{:ok, %{tag, repo, assets}}` on success or a tagged error. + + ## What it does end-to-end + + 1. Resolve `repo`, `hash`, `out_dir` (env or opts). + 2. Discover which of the four canonical tarball basenames are + present in `out_dir` for this hash. At least one must exist. + 3. `gh release view otp-<hash>` — exists? + - If not (`release not found`), `gh release create`. + - If 401/403, `:auth_required`. + - If 5xx/network, `:infra_unreachable`. + 4. List the release's existing assets. For each one that overlaps + with what we're about to upload, `gh release delete-asset`. + 5. Single `gh release upload otp-<hash> <paths…>`. + 6. Verify by re-listing assets — returned in `info.assets`. + """ + + alias MobDev.Release.{Errors, Helpers, Shell} + + @default_repo "GenericJam/mob" + @candidate_basenames [ + "otp-android", + "otp-android-arm32", + "otp-ios-sim", + "otp-ios-device" + ] + + @typedoc "Successful publish result." + @type info :: %{tag: String.t(), repo: String.t(), assets: [String.t()]} + + @doc "Canonical tarball basenames the publisher knows how to upload." + @spec candidate_basenames() :: [String.t()] + def candidate_basenames, do: @candidate_basenames + + @doc "Default GitHub repo (mirrors publish.sh's `${REPO:=GenericJam/mob}`)." + @spec default_repo() :: String.t() + def default_repo, do: @default_repo + + @doc """ + Run the publish pipeline. See module doc for options. + + Recognized opts: + + * `:repo` — `owner/name` (default: `GenericJam/mob`) + * `:hash` — release hash. Default: detected from `OTP_SRC` git or `$HASH`. + * `:otp_src` — OTP checkout for hash detection. Default per `Helpers`. + * `:out_dir` — directory containing the built tarballs. Default per `Helpers`. + * `:assets` — list of tarball basenames to upload. Each may be + either the bare base (`"otp-android"`) — in which case + `-<hash>.tar.gz` is appended — or the full filename. Default: + auto-discover any of the four canonical names that exist. + """ + @spec publish(keyword()) :: {:ok, info()} | Errors.t() + def publish(opts \\ []) do + shell = Shell.impl() + repo = opts[:repo] || @default_repo + out_dir = opts[:out_dir] || Helpers.default_out_dir() + + with {:ok, hash} <- resolve_hash(opts), + tag = tag_for(hash), + {:ok, assets} <- resolve_assets(shell, opts, out_dir, hash), + :ok <- ensure_release(shell, repo, tag, hash), + {:ok, existing} <- list_assets(shell, repo, tag), + :ok <- delete_existing(shell, repo, tag, existing, assets), + :ok <- upload_assets(shell, repo, tag, out_dir, assets), + {:ok, uploaded} <- list_assets(shell, repo, tag) do + {:ok, %{tag: tag, repo: repo, assets: uploaded}} + end + end + + @doc """ + Asset basenames that exist in `out_dir` for the given `hash`. Returns + an ordered list (matching `candidate_basenames/0` order). + """ + @spec discover_assets(module(), Path.t(), String.t()) :: [String.t()] + def discover_assets(shell, out_dir, hash) when is_binary(hash) do + @candidate_basenames + |> Enum.map(&"#{&1}-#{hash}.tar.gz") + |> Enum.filter(&shell.file?(Path.join(out_dir, &1))) + end + + @doc """ + Classify a `gh` stderr/stdout line into one of `:not_found`, `:auth`, + `:infra`, or `:other`. Public for testing — the regexes are the + contract. + """ + @spec classify(String.t()) :: :not_found | :auth | :infra | :other + def classify(output) when is_binary(output) do + lower = String.downcase(output) + + cond do + not_found?(lower) -> :not_found + auth?(lower) -> :auth + infra?(lower) -> :infra + true -> :other + end + end + + @doc "Build the release tag for a hash (`otp-<hash>`)." + @spec tag_for(String.t()) :: String.t() + def tag_for(hash) when is_binary(hash), do: "otp-#{hash}" + + # ── Hash + asset resolution ──────────────────────────────────────────── + + defp resolve_hash(opts) do + case opts[:hash] || System.get_env("HASH") do + nil -> + otp_src = opts[:otp_src] || Helpers.default_otp_src() + Helpers.git_hash(otp_src) + + hash -> + {:ok, hash} + end + end + + defp resolve_assets(shell, opts, out_dir, hash) do + case opts[:assets] do + [_ | _] = explicit -> + explicit_assets(shell, explicit, out_dir, hash) + + _ -> + discovered_assets(shell, out_dir, hash) + end + end + + defp explicit_assets(shell, bases, out_dir, hash) do + filenames = Enum.map(bases, &normalize_basename(&1, hash)) + missing = Enum.reject(filenames, &shell.file?(Path.join(out_dir, &1))) + + if missing == [] do + {:ok, filenames} + else + Errors.precondition( + "missing tarballs in #{out_dir}: #{Enum.join(missing, ", ")} — " <> + "run `mix mob.release.tarball <target>` first" + ) + end + end + + defp discovered_assets(shell, out_dir, hash) do + case discover_assets(shell, out_dir, hash) do + [] -> + Errors.precondition( + "no tarballs found in #{out_dir} matching hash #{hash} — " <> + "run `mix mob.release.tarball all` first" + ) + + assets -> + {:ok, assets} + end + end + + defp normalize_basename(name, hash) do + if String.ends_with?(name, ".tar.gz"), do: name, else: "#{name}-#{hash}.tar.gz" + end + + # ── Release lifecycle ────────────────────────────────────────────────── + + defp ensure_release(shell, repo, tag, hash) do + case shell.cmd(["gh", "release", "view", tag, "--repo", repo], []) do + {:ok, _} -> + :ok + + {:error, {:cmd_failed, %{output: output}}} = err -> + case classify(output) do + :not_found -> create_release(shell, repo, tag, hash) + :auth -> Errors.auth_required(auth_hint()) + :infra -> Errors.infra_unreachable(infra_detail(output)) + :other -> err + end + + err -> + err + end + end + + defp create_release(shell, repo, tag, hash) do + title = "OTP pre-built runtime #{hash}" + + notes = + "Pre-built OTP for Android (aarch64 + arm32), iOS simulator " <> + "(aarch64-apple-iossimulator), and iOS device (aarch64-apple-ios). " <> + "OTP source commit: #{hash}." + + argv = [ + "gh", + "release", + "create", + tag, + "--repo", + repo, + "--title", + title, + "--notes", + notes + ] + + case shell.cmd(argv, []) do + {:ok, _} -> :ok + err -> reclassify(err) + end + end + + defp list_assets(shell, repo, tag) do + argv = [ + "gh", + "release", + "view", + tag, + "--repo", + repo, + "--json", + "assets", + "-q", + ".assets[].name" + ] + + case shell.cmd(argv, []) do + {:ok, output} -> + names = + output + |> String.split("\n", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + {:ok, names} + + err -> + reclassify(err) + end + end + + defp delete_existing(shell, repo, tag, existing, planned) do + to_delete = Enum.filter(planned, &(&1 in existing)) + + Enum.reduce_while(to_delete, :ok, fn name, :ok -> + argv = ["gh", "release", "delete-asset", tag, name, "--repo", repo, "--yes"] + + case shell.cmd(argv, []) do + {:ok, _} -> {:cont, :ok} + err -> {:halt, reclassify(err)} + end + end) + end + + defp upload_assets(shell, repo, tag, out_dir, assets) do + paths = Enum.map(assets, &Path.join(out_dir, &1)) + argv = ["gh", "release", "upload", tag, "--repo", repo] ++ paths + + case shell.cmd(argv, []) do + {:ok, _} -> :ok + err -> reclassify(err) + end + end + + # ── Error classification ─────────────────────────────────────────────── + + defp not_found?(s) do + String.contains?(s, "release not found") or + String.contains?(s, "no release found") or + String.contains?(s, "could not find release") or + String.contains?(s, "http 404") + end + + defp auth?(s) do + String.contains?(s, "http 401") or + String.contains?(s, "http 403") or + String.contains?(s, "bad credentials") or + String.contains?(s, "must authenticate") or + String.contains?(s, "you are not logged into") or + String.contains?(s, "to get started with github cli") or + String.contains?(s, "gh auth login") + end + + defp infra?(s) do + String.contains?(s, "http 5") or + String.contains?(s, "no such host") or + String.contains?(s, "connection refused") or + String.contains?(s, "i/o timeout") or + String.contains?(s, "dial tcp") or + String.contains?(s, "could not connect") or + String.contains?(s, "network is unreachable") + end + + defp reclassify({:error, {:cmd_failed, %{output: output}}} = err) do + case classify(output) do + :auth -> Errors.auth_required(auth_hint()) + :infra -> Errors.infra_unreachable(infra_detail(output)) + _ -> err + end + end + + defp reclassify(other), do: other + + defp auth_hint do + ~s(run `gh auth login --scopes "repo,write:packages"` and retry) + end + + defp infra_detail(output) do + case output |> String.split("\n", trim: true) |> List.first() do + nil -> "gh returned 5xx or network unreachable" + line -> String.trim(line) + end + end +end diff --git a/lib/mob_dev/release/shell.ex b/lib/mob_dev/release/shell.ex new file mode 100644 index 0000000..9b099ad --- /dev/null +++ b/lib/mob_dev/release/shell.ex @@ -0,0 +1,136 @@ +defmodule MobDev.Release.Shell do + @moduledoc """ + The I/O surface for `MobDev.Release.*` modules — every external command, + every env-var read, every filesystem inspection that crosses out of + Elixir's BEAM happens through a function declared here. + + ## Why this exists + + Release-script logic is mostly orchestration: "for each source file, + call clang with these flags; then call ar; then run xcrun nm to verify + the symbol exists." The actual *correctness* of the orchestration — + did we pass the right flags? did we name the output file correctly? — + is independent of whether clang itself runs. By routing every external + invocation through a behaviour, tests can substitute a `Mox` mock that + asserts on the exact argv we'd have invoked, without paying the + wall-clock cost of running the real tool or depending on the host + environment. + + Production code uses `MobDev.Release.Shell.System` (the default impl). + Tests use `MobDev.Release.ShellMock` (defined in `test/support/`). + Modules under `MobDev.Release.*` get the impl via application env: + + impl = Application.get_env(:mob_dev, :release_shell, MobDev.Release.Shell.System) + impl.cmd(["clang", "-c", "x.c"], cd: "/tmp") + + Tests flip the env via `setup` to install the Mox. + + ## Contract + + Every callback returns either `{:ok, value}` or a tagged-tuple error + from `MobDev.Release.Errors`. No callback raises on user-facing error + paths — they all return tagged tuples so the caller chains them with + `with`. + + Exception: programmer errors (bad argument types, etc.) may raise. + """ + + alias MobDev.Release.Errors + + @doc """ + Run an external command. Returns `{:ok, output}` on exit-0, + `{:error, {:cmd_failed, %{cmd, exit, output}}}` otherwise. + + `opts` accepts: + * `:cd` — working directory (default: cwd) + * `:env` — list of `{name, value}` env overrides (default: []) + * `:into` — passthrough to `System.cmd/3` (default: nil, output is + captured) + """ + @callback cmd([String.t()], keyword()) :: {:ok, String.t()} | Errors.t() + + @doc "Returns `true` if the path exists and is a directory." + @callback dir?(Path.t()) :: boolean() + + @doc "Returns `true` if the path exists and is a regular file." + @callback file?(Path.t()) :: boolean() + + @doc """ + Read an environment variable. Returns `{:ok, value}` if set, + `:error` if unset. Mirrors `System.fetch_env/1` but routed through + the behaviour so tests don't have to poke the global env. + """ + @callback fetch_env(String.t()) :: {:ok, String.t()} | :error + + @doc "Create a directory and any missing parents. Returns `:ok` or fs_failed." + @callback mkdir_p(Path.t()) :: :ok | Errors.t() + + @doc """ + Remove a file (best-effort). Returns `:ok` whether it existed or not, + errors only on permission issues. Mirrors `rm -f`. + """ + @callback rm_f(Path.t()) :: :ok | Errors.t() + + # ── Default impl resolver ───────────────────────────────────────────── + + @doc """ + Return the configured implementation module. Production: the real + `System` impl. Tests: whatever Mox/test setup installs. + """ + @spec impl() :: module() + def impl, do: Application.get_env(:mob_dev, :release_shell, MobDev.Release.Shell.System) +end + +defmodule MobDev.Release.Shell.System do + @moduledoc """ + Production implementation of `MobDev.Release.Shell` — uses the real + `System`, `File`, and OS to do work. Pure passthrough; the + intelligence is in the callers. + """ + + @behaviour MobDev.Release.Shell + + alias MobDev.Release.Errors + + @impl true + def cmd(argv, opts \\ []) when is_list(argv) and length(argv) >= 1 do + [exe | args] = argv + cmd_opts = Keyword.merge([stderr_to_stdout: true], Keyword.take(opts, [:cd, :env, :into])) + + case System.cmd(exe, args, cmd_opts) do + {output, 0} -> {:ok, output} + {output, exit_code} -> Errors.cmd_failed(argv, exit_code, output) + end + rescue + e in ErlangError -> + # Most common cause: executable not on $PATH. Translate to + # precondition_failed since it's almost always an env issue. + Errors.precondition("could not execute #{hd(argv)}: #{Exception.message(e)}") + end + + @impl true + def dir?(path), do: File.dir?(path) + + @impl true + def file?(path), do: File.regular?(path) + + @impl true + def fetch_env(name), do: System.fetch_env(name) + + @impl true + def mkdir_p(path) do + case File.mkdir_p(path) do + :ok -> :ok + {:error, reason} -> Errors.fs_failed(path, reason) + end + end + + @impl true + def rm_f(path) do + case File.rm(path) do + :ok -> :ok + {:error, :enoent} -> :ok + {:error, reason} -> Errors.fs_failed(path, reason) + end + end +end diff --git a/lib/mob_dev/release/tarball.ex b/lib/mob_dev/release/tarball.ex new file mode 100644 index 0000000..19c632e --- /dev/null +++ b/lib/mob_dev/release/tarball.ex @@ -0,0 +1,720 @@ +defmodule MobDev.Release.Tarball do + @moduledoc """ + Replaces `scripts/release/tarball_*.sh` × 4 — the staging + `tar czf` + step that produces the `otp-<target>-<hash>.tar.gz` archive + `MobDev.OtpDownloader` later fetches. + + iex> MobDev.Release.Tarball.build(:android_arm64, + ...> exqlite_build: "/path/to/_build/dev/lib/exqlite") + {:ok, %{tarball: "/tmp/otp-android-abc12345.tar.gz", target: :android_arm64}} + + ## Per-target variation (the richest of any iter so far) + + | Field | android_arm64 | android_arm32 | ios_sim | ios_device | + |--------------------------------|---------------------|----------------------------|-----------------|---------------------------| + | Tarball basename | `otp-android` | `otp-android-arm32` | `otp-ios-sim` | `otp-ios-device` | + | Borrow crypto/pk/ssl apps from | (built in-tree) | (built in-tree) | Android install | Android install | + | Bundle exqlite BEAMs | yes | yes | no | no | + | Bundle EPMD source | no | no | no | yes (for static-link) | + | Verify crypto.so present | yes | no | no | no | + | tar --exclude paths | (none) | (none) | test-app dirs | (none) | + + Note the asymmetry in `tarball_basename`: the Android arm64 tarball is + `otp-android-<hash>.tar.gz` (NOT `otp-android-arm64-...`), preserved + for backward compat with `MobDev.OtpDownloader.@otp_hash` — + changing this would break every existing cache entry. + + ## Why iOS targets "borrow" crypto/public_key/ssl apps from Android + + iOS OTP is cross-compiled `--without-ssl` (the iOS xcomp confs use + `--enable-static-nifs` and OTP's build system doesn't propagate + `--with-ssl` to beam.emu's link in that mode). The `crypto`, + `public_key`, and `ssl` Erlang apps therefore aren't produced for + iOS. BEAM bytecode is platform-neutral, so we copy these apps' + ebins from the Android arm64 install tree — they're the same + bytecode. The platform-specific NIF artifacts they ship in `priv/` + are obsolete on iOS anyway because we replace `crypto.so` with the + static `crypto.a` + `libcrypto.a` we just baked. + + ## What "build" does end-to-end + + 1. Stage — mktemp + `cp -r <otp_release>/.` (the cross-compiled + OTP install tree from `MobDev.Release.OTP`) + 2. Borrow apps — iOS targets only. cp from + `<android_otp_release>/lib/{crypto,public_key,ssl}-*`. + 3. Static libs — cp the four arch-specific `.a` files + (`libzstd.a`, `libepcre.a`, `libryu.a`, `asn1rt_nif.a`) into + `<stage>/erts-<vsn>/lib/`. + 4. Crypto archives — cp `crypto.a` (from + `MobDev.Release.OpenSSL.CryptoNif`) + `libcrypto.a` (from + `MobDev.Release.OpenSSL`) into the same place. + 5. ERTS headers — cp the 5 headers (`erl_nif.h`, + `erl_nif_api_funcs.h`, `erl_drv_nif.h`, + `erl_fixed_size_int_types.h`, arch-specific + `erl_int_sizes_config.h`) into `<stage>/erts-<vsn>/include/`. + 6. Elixir stdlib — `MobDev.Release.Helpers.bundle_elixir_stdlib/2` + (elixir, logger, eex ebins). + 7. exqlite BEAMs — Android targets only. Detect version from + mix.lock; cp ebins into `<stage>/lib/exqlite-<vsn>/`. + 8. EPMD source — iOS device only. cp the 3 `.c` files + headers + + the arch-specific config dir. + 9. tar czf the stage into `<out_dir>/<basename>-<hash>.tar.gz`, + honouring per-target `--exclude` flags. + 10. Verify — `tar tzf | grep <pattern>` for each per-target + required entry. Missing entries fail loudly rather than + ship a broken tarball. + + ## Why mix.lock parsing lives here + + Android tarballs ship exqlite BEAMs that the user app loads as a NIF + on device. The version of those BEAMs has to match the user app's + `_build/dev/lib/exqlite/` ebins so dialyzer behaviours and protocol + consolidation line up. Detecting the exqlite version means parsing + mix.lock — a small regex on a known shape. Pure function, tested. + """ + + alias MobDev.Release.{Errors, Shell, Helpers} + + # ── Target spec ──────────────────────────────────────────────────────── + + defmodule Target do + @moduledoc "Per-target tarball staging description." + + @enforce_keys [ + :id, + :arch_dir, + :tarball_basename, + :default_otp_release, + :openssl_prefix_default, + :include_exqlite, + :include_epmd_source, + :borrow_crypto_apps, + :additional_verifies, + :tar_excludes + ] + defstruct [ + :id, + :arch_dir, + :tarball_basename, + :default_otp_release, + :openssl_prefix_default, + :include_exqlite, + :include_epmd_source, + :borrow_crypto_apps, + :additional_verifies, + :tar_excludes + ] + + @type t :: %__MODULE__{ + id: :android_arm64 | :android_arm32 | :ios_sim | :ios_device, + arch_dir: String.t(), + tarball_basename: String.t(), + default_otp_release: Path.t(), + openssl_prefix_default: Path.t(), + include_exqlite: boolean(), + include_epmd_source: boolean(), + # Path of an Android install to copy crypto/public_key/ssl + # apps from, or nil (Android targets — they're built in-tree). + borrow_crypto_apps: Path.t() | nil, + # Additional `tar tzf | grep <pattern>` entries to verify + # beyond the universal ones. + additional_verifies: [String.t()], + # Paths (relative to the stage root) to exclude from the tarball. + tar_excludes: [String.t()] + } + end + + @doc "All tarball targets, in canonical order." + @spec targets() :: [atom()] + def targets, do: [:android_arm64, :android_arm32, :ios_sim, :ios_device] + + @doc """ + Per-target spec. Public for testing — surface lock-down for tarball + naming (changing the basename breaks the downloader's cache), the + iOS borrow-from-Android decision, and the per-target verify list. + """ + @spec target_spec(atom()) :: Target.t() + def target_spec(:android_arm64) do + %Target{ + id: :android_arm64, + arch_dir: "aarch64-unknown-linux-android", + # Asymmetric: this is `otp-android` not `otp-android-arm64`. + # MobDev.OtpDownloader's @otp_hash cache convention depends on + # this — DO NOT change without bumping every cached tarball. + tarball_basename: "otp-android", + default_otp_release: "/tmp/otp-android", + openssl_prefix_default: "/tmp/openssl-android-arm64", + include_exqlite: true, + include_epmd_source: false, + borrow_crypto_apps: nil, + # arm64 verifies the runtime crypto NIF + ssl/public_key ebins + # are present — proves --with-ssl was wired correctly in + # MobDev.Release.OTP. arm32 doesn't (the shell version didn't + # either; matches the historical asymmetry). + additional_verifies: [ + "lib/crypto-.*/priv/lib/crypto.so", + "lib/public_key-.*/ebin/public_key.beam", + "lib/ssl-.*/ebin/ssl.beam" + ], + tar_excludes: [] + } + end + + def target_spec(:android_arm32) do + %Target{ + id: :android_arm32, + arch_dir: "arm-unknown-linux-androideabi", + tarball_basename: "otp-android-arm32", + default_otp_release: "/tmp/otp-android-arm32", + openssl_prefix_default: "/tmp/openssl-android-arm32", + include_exqlite: true, + include_epmd_source: false, + borrow_crypto_apps: nil, + additional_verifies: [], + tar_excludes: [] + } + end + + def target_spec(:ios_sim) do + %Target{ + id: :ios_sim, + arch_dir: "aarch64-apple-iossimulator", + tarball_basename: "otp-ios-sim", + default_otp_release: "/tmp/otp-ios-sim", + openssl_prefix_default: "/tmp/openssl-ios-sim", + include_exqlite: false, + include_epmd_source: false, + # iOS borrows from the Android arm64 install (BEAM bytecode is + # arch-independent; iOS OTP was --without-ssl so doesn't produce + # these apps). + borrow_crypto_apps: "/tmp/otp-android", + additional_verifies: [], + # The iOS sim OTP_RELEASE often has stray test-app build dirs; + # excluding them keeps the tarball small + reproducible. + tar_excludes: ["beamhello", "test_app", "test_app0"] + } + end + + def target_spec(:ios_device) do + %Target{ + id: :ios_device, + arch_dir: "aarch64-apple-ios", + tarball_basename: "otp-ios-device", + default_otp_release: "/tmp/otp-ios-device", + openssl_prefix_default: "/tmp/openssl-ios-device", + include_exqlite: false, + include_epmd_source: true, + borrow_crypto_apps: "/tmp/otp-android", + # iOS device tarball ships EPMD source for static-link — the + # per-app build.zig compiles these into the .app's main native lib. + additional_verifies: [ + "erts/epmd/src/epmd.c", + "erts/epmd/src/epmd_srv.c", + "erts/epmd/src/epmd_cli.c", + "erts/epmd/src/epmd.h", + "erts/epmd/src/epmd_int.h", + "erts/aarch64-apple-ios/config.h" + ], + tar_excludes: [] + } + end + + # ── Tarball-name + path assembly (pure) ──────────────────────────────── + + @doc """ + Final tarball path: `<out_dir>/<basename>-<hash>.tar.gz`. Public for + testing — basename drift breaks the downloader's cache. + """ + @spec tarball_path(Target.t(), Path.t(), String.t()) :: Path.t() + def tarball_path(%Target{} = target, out_dir, hash) when is_binary(hash) do + Path.join(out_dir, "#{target.tarball_basename}-#{hash}.tar.gz") + end + + # ── exqlite version detection (pure) ─────────────────────────────────── + + @doc """ + Parse the `exqlite` package version out of a mix.lock string. + Returns `{:ok, version}` or a tagged error. + + Public for testing — silent parse failures here would silently bundle + no exqlite, then user apps die at runtime with `:undef`. + """ + @spec parse_exqlite_version_from_lock(binary()) :: {:ok, String.t()} | Errors.t() + def parse_exqlite_version_from_lock(content) when is_binary(content) do + # mix.lock entries look like: + # "exqlite": {:hex, :exqlite, "0.39.0", "...", ...} + # We want the version string. Match the package name explicitly to + # avoid false-matching another dep whose version field shares a + # prefix. + case Regex.run(~r/"exqlite":\s*\{:hex,\s*:exqlite,\s*"([^"]+)"/, content, + capture: :all_but_first + ) do + [vsn] -> {:ok, vsn} + _ -> Errors.parse_failed(content, "exqlite version line in mix.lock") + end + end + + @doc """ + Parse the `vsn` field out of an `exqlite.app` file's content. + Fallback for when mix.lock isn't available (rare, but the shell + version handled it). Returns `{:ok, version}` or a tagged error. + """ + @spec parse_exqlite_version_from_app_file(binary()) :: {:ok, String.t()} | Errors.t() + def parse_exqlite_version_from_app_file(content) when is_binary(content) do + # OTP .app files look like: + # {application, exqlite, [{vsn, "0.39.0"}, ...]} + case Regex.run(~r/\{vsn,\s*"([^"]+)"\}/, content, capture: :all_but_first) do + [vsn] -> {:ok, vsn} + _ -> Errors.parse_failed(content, "{vsn, \"...\"} entry in exqlite.app") + end + end + + @doc """ + Detect the exqlite version by reading mix.lock (preferred) or + `<exqlite_build>/ebin/exqlite.app` (fallback). Returns `{:ok, vsn}`. + + `exqlite_build` is the path to `_build/dev/lib/exqlite/` in any Mob + project that has run `mix deps.get && mix compile`. + + Project root lookup: mix.lock lives 4 levels up from + `_build/dev/lib/exqlite/` — that's the project root convention every + Hex umbrella uses. + """ + @spec detect_exqlite_version(Path.t()) :: {:ok, String.t()} | Errors.t() + def detect_exqlite_version(exqlite_build) do + # Prefer mix.lock — it's the authoritative source. + project_root = exqlite_build |> Path.join(["..", "..", "..", ".."]) |> Path.expand() + mix_lock = Path.join(project_root, "mix.lock") + + case File.read(mix_lock) do + {:ok, content} -> + case parse_exqlite_version_from_lock(content) do + {:ok, vsn} -> {:ok, vsn} + {:error, _} -> detect_from_app_file(exqlite_build) + end + + {:error, _} -> + detect_from_app_file(exqlite_build) + end + end + + defp detect_from_app_file(exqlite_build) do + app_file = Path.join([exqlite_build, "ebin", "exqlite.app"]) + + case File.read(app_file) do + {:ok, content} -> parse_exqlite_version_from_app_file(content) + {:error, reason} -> Errors.fs_failed(app_file, reason) + end + end + + # ── Build entrypoint ─────────────────────────────────────────────────── + + @doc """ + Stage + tar the per-target OTP runtime tarball. Returns `{:ok, info}` + naming the produced tarball + final size, or a tagged error. + + Options: + * `:otp_src` — OTP source checkout (default: `$OTP_SRC` env or `~/code/otp`) + * `:otp_release` — install tree from `MobDev.Release.OTP.build/2` + (default: target's `default_otp_release`) + * `:openssl_prefix` — OpenSSL install dir (default per-target) + * `:exqlite_build` — `_build/dev/lib/exqlite` in any project (required + for Android targets; ignored for iOS) + * `:android_otp_release` — used by iOS targets to borrow crypto + apps (default: `/tmp/otp-android`) + * `:asn1rt_nif_arm32` — pre-built arm32 asn1rt_nif.a (Android arm32 + only; default: `/tmp/asn1rt_nif_arm32.a`) + * `:out_dir` — tarball output (default: `/tmp`) + * `:hash` — release hash (default: detected from OTP source git) + """ + @spec build(atom(), keyword()) :: {:ok, map()} | Errors.t() + def build(target_id, opts \\ []) + when target_id in [:android_arm64, :android_arm32, :ios_sim, :ios_device] do + target = target_spec(target_id) + shell = Shell.impl() + otp_src = opts[:otp_src] || Helpers.default_otp_src() + + # Explicit otp_src dir check FIRST. Without this, resolve_release_env's + # implicit File.read on erts/vsn.mk fires first and returns fs_failed + # with a confusing path-with-vsn.mk-suffix rather than the actionable + # "OTP_SRC missing" precondition_failed. + if not shell.dir?(otp_src) do + Errors.precondition("OTP_SRC missing at #{otp_src} — clone github.com/erlang/otp") + else + with {:ok, env} <- + Helpers.resolve_release_env(Keyword.take(opts, [:otp_src, :hash, :out_dir])), + otp_release = opts[:otp_release] || target.default_otp_release, + openssl_prefix = opts[:openssl_prefix] || target.openssl_prefix_default, + :ok <- precheck(target, shell, env, otp_release, openssl_prefix, opts) do + do_build(target, shell, env, otp_release, openssl_prefix, opts) + end + end + end + + defp do_build(target, shell, env, otp_release, openssl_prefix, opts) do + stage = mktemp_stage(shell) + + try do + with :ok <- stage_otp_release(shell, otp_release, stage), + :ok <- maybe_borrow_crypto_apps(target, shell, opts, stage), + :ok <- stage_static_libs(target, shell, env, otp_release, opts, openssl_prefix, stage), + :ok <- stage_erts_headers(target, shell, env, stage), + {:ok, _} <- Helpers.bundle_elixir_stdlib(stage, env.elixir_lib), + :ok <- maybe_stage_exqlite(target, shell, opts, stage), + :ok <- maybe_stage_epmd(target, shell, env, stage), + tarball = tarball_path(target, env.out_dir, env.hash), + :ok <- tar_stage(target, shell, stage, tarball), + :ok <- verify_tarball(target, shell, env, tarball) do + {:ok, %{target: target.id, tarball: tarball, hash: env.hash, erts_vsn: env.erts_vsn}} + end + after + _ = shell.cmd(["rm", "-rf", stage], []) + end + end + + # ── Phases ───────────────────────────────────────────────────────────── + + defp mktemp_stage(shell) do + {:ok, output} = shell.cmd(["mktemp", "-d"], []) + String.trim(output) + end + + defp stage_otp_release(shell, otp_release, stage) do + # The trailing `/.` copies the directory CONTENTS, not the dir itself + # — same as the shell `cp -r $OTP_RELEASE/. $STAGE` idiom. + case shell.cmd(["cp", "-r", otp_release <> "/.", stage], []) do + {:ok, _} -> :ok + err -> err + end + end + + defp maybe_borrow_crypto_apps(%Target{borrow_crypto_apps: nil}, _shell, _opts, _stage), do: :ok + + defp maybe_borrow_crypto_apps(%Target{borrow_crypto_apps: default}, shell, opts, stage) do + src_root = opts[:android_otp_release] || default + + if not shell.dir?(src_root) do + Errors.precondition( + "android_otp_release missing at #{src_root} — needed to borrow crypto/public_key/ssl beams. " <> + "Run MobDev.Release.OTP.build(:android_arm64) first or pass `android_otp_release:` explicitly." + ) + else + Enum.reduce_while(~w(crypto public_key ssl), :ok, fn app, :ok -> + # The app directory is named like `<app>-<version>/`. Find the + # first match via `ls -d`. Failure here is a hard precondition — + # the Android install must have these apps. + case shell.cmd( + ["bash", "-c", "ls -d #{src_root}/lib/#{app}-*/ 2>/dev/null | head -1"], + [] + ) do + {:ok, output} -> + src = String.trim(output) + + if src == "" do + {:halt, Errors.precondition("no #{app}-*/ in #{src_root}/lib")} + else + case shell.cmd(["cp", "-r", src, Path.join(stage, "lib") <> "/"], []) do + {:ok, _} -> {:cont, :ok} + err -> {:halt, err} + end + end + + err -> + {:halt, err} + end + end) + end + end + + defp stage_static_libs(target, shell, env, _otp_release, opts, openssl_prefix, stage) do + erts_lib_dst = Path.join(stage, "erts-#{env.erts_vsn}/lib") <> "/" + + # Static libs from OTP source tree (arch-specific path). + base_libs = [ + "erts/emulator/zstd/obj/#{target.arch_dir}/opt/libzstd.a", + "erts/emulator/pcre/obj/#{target.arch_dir}/opt/libepcre.a", + "erts/emulator/ryu/obj/#{target.arch_dir}/opt/libryu.a" + ] + + # asn1rt_nif.a — arm32 has a special pre-built location; others come + # from the OTP source tree. + asn1_src = + case target.id do + :android_arm32 -> + opts[:asn1rt_nif_arm32] || "/tmp/asn1rt_nif_arm32.a" + + _ -> + Path.join(env.otp_src, "lib/asn1/priv/lib/#{target.arch_dir}/asn1rt_nif.a") + end + + # crypto.a from CryptoNif's output. + crypto_a = + Path.join(env.otp_src, "lib/crypto/priv/lib/#{target.arch_dir}/crypto.a") + + # libcrypto.a from OpenSSL build. + libcrypto_a = Path.join(openssl_prefix, "lib/libcrypto.a") + + sources = + Enum.map(base_libs, &Path.join(env.otp_src, &1)) ++ + [asn1_src, crypto_a, libcrypto_a] + + Enum.reduce_while(sources, :ok, fn src, :ok -> + case shell.cmd(["cp", src, erts_lib_dst], []) do + {:ok, _} -> {:cont, :ok} + err -> {:halt, err} + end + end) + end + + defp stage_erts_headers(target, shell, env, stage) do + erts_inc_dst = Path.join(stage, "erts-#{env.erts_vsn}/include") <> "/" + + with :ok <- shell.mkdir_p(erts_inc_dst) do + headers = [ + "erts/emulator/beam/erl_nif.h", + "erts/emulator/beam/erl_nif_api_funcs.h", + "erts/emulator/beam/erl_drv_nif.h", + "erts/include/erl_fixed_size_int_types.h", + # Arch-specific — the per-target size config. + "erts/include/#{target.arch_dir}/erl_int_sizes_config.h" + ] + + Enum.reduce_while(headers, :ok, fn header, :ok -> + src = Path.join(env.otp_src, header) + + case shell.cmd(["cp", src, erts_inc_dst], []) do + {:ok, _} -> {:cont, :ok} + err -> {:halt, err} + end + end) + end + end + + defp maybe_stage_exqlite(%Target{include_exqlite: false}, _shell, _opts, _stage), do: :ok + + defp maybe_stage_exqlite(_target, shell, opts, stage) do + case opts[:exqlite_build] do + nil -> + Errors.precondition( + "exqlite_build required for Android targets — pass `exqlite_build: <path>` pointing at " <> + "any project's _build/dev/lib/exqlite (run `mix deps.get && mix compile` in one of your projects)" + ) + + exqlite_build -> + with true <- + shell.dir?(Path.join(exqlite_build, "ebin")) || + {:fs, Path.join(exqlite_build, "ebin"), :enoent}, + {:ok, vsn} <- detect_exqlite_version(exqlite_build), + dst = Path.join(stage, "lib/exqlite-#{vsn}"), + :ok <- shell.mkdir_p(Path.join(dst, "ebin")), + :ok <- shell.mkdir_p(Path.join(dst, "priv")), + # Copy ebin/* — bytecode + .app file + {:ok, _} <- shell.cmd(["bash", "-c", "cp #{exqlite_build}/ebin/* #{dst}/ebin/"], []) do + :ok + else + {:fs, path, reason} -> Errors.fs_failed(path, reason) + err -> err + end + end + end + + defp maybe_stage_epmd(%Target{include_epmd_source: false}, _shell, _env, _stage), do: :ok + + defp maybe_stage_epmd(target, shell, env, stage) do + epmd_dst = Path.join(stage, "erts/epmd/src") + arch_dst = Path.join(stage, "erts/#{target.arch_dir}") + include_dst = Path.join(stage, "erts/include") + include_internal_dst = Path.join(stage, "erts/include/internal") + + with :ok <- shell.mkdir_p(epmd_dst), + # epmd .c sources + epmd_srcs = ~w(epmd.c epmd_srv.c epmd_cli.c), + :ok <- copy_each(shell, env.otp_src, "erts/epmd/src", epmd_srcs, epmd_dst <> "/"), + # all epmd .h files (cheap to include) + {:ok, _} <- + shell.cmd( + [ + "bash", + "-c", + "cp #{env.otp_src}/erts/epmd/src/*.h #{epmd_dst}/" + ], + [] + ), + # arch-specific configure output + :ok <- shell.mkdir_p(arch_dst), + {:ok, _} <- + shell.cmd( + [ + "bash", + "-c", + "cp -r #{env.otp_src}/erts/#{target.arch_dir}/* #{arch_dst}/" + ], + [] + ), + # erts/include + erts/include/internal mirrors + :ok <- shell.mkdir_p(include_dst), + :ok <- shell.mkdir_p(include_internal_dst), + {:ok, _} <- + shell.cmd( + [ + "bash", + "-c", + "cp -r #{env.otp_src}/erts/include/* #{include_dst}/" + ], + [] + ), + {:ok, _} <- + shell.cmd( + [ + "bash", + "-c", + "cp -r #{env.otp_src}/erts/include/internal/* #{include_internal_dst}/" + ], + [] + ) do + :ok + end + end + + defp copy_each(shell, otp_src, rel_dir, files, dst) do + Enum.reduce_while(files, :ok, fn file, :ok -> + src = Path.join([otp_src, rel_dir, file]) + + case shell.cmd(["cp", src, dst], []) do + {:ok, _} -> {:cont, :ok} + err -> {:halt, err} + end + end) + end + + defp tar_stage(target, shell, stage, tarball) do + base = Path.basename(stage) + parent = Path.dirname(stage) + + # Build the tar argv with per-target --exclude flags. + exclude_args = + Enum.flat_map(target.tar_excludes, fn exclude -> + ["--exclude=#{base}/#{exclude}"] + end) + + argv = ["tar", "czf", tarball] ++ exclude_args ++ ["-C", parent, base] + + case shell.cmd(argv, []) do + {:ok, _} -> :ok + err -> err + end + end + + # ── Verification ─────────────────────────────────────────────────────── + + @doc """ + Verify the produced tarball contains every required entry. Returns + `:ok` or a precondition_failed naming the missing entry. + + Universal entries (every target): + * `erts-<vsn>/` directory entry + * `lib/elixir/ebin/elixir.app` + * `erts-<vsn>/lib/crypto.a` + * `erts-<vsn>/lib/libcrypto.a` + + Per-target additions come from `target.additional_verifies`. + """ + @spec verify_tarball(Target.t(), MobDev.Release.Shell.t() | module(), map(), Path.t()) :: + :ok | Errors.t() + def verify_tarball(target, shell, env, tarball) do + with {:ok, listing} <- shell.cmd(["tar", "tzf", tarball], []) do + expected = required_entries(target, env) + check_entries(listing, expected, tarball) + end + end + + @doc """ + Per-target required entries. Public for testing — pinning the list + is the surface lock that prevents silent drops. + + ERTS version interpolation happens against `env.erts_vsn`. + """ + @spec required_entries(Target.t(), map()) :: [String.t()] + def required_entries(target, %{erts_vsn: vsn}) do + universal = [ + "erts-#{vsn}", + "lib/elixir/ebin/elixir.app", + "erts-#{vsn}/lib/crypto.a", + "erts-#{vsn}/lib/libcrypto.a" + ] + + universal ++ target.additional_verifies + end + + @doc """ + Scan a `tar tzf` listing and confirm every expected pattern matches + at least one entry. Patterns are treated as regex (matching the + shell's `grep -q` semantics). Public for tests. + """ + @spec check_entries(binary(), [String.t()], Path.t()) :: :ok | Errors.t() + def check_entries(listing, expected, tarball_path) + when is_binary(listing) and is_list(expected) do + missing = + Enum.reject(expected, fn pattern -> + # Compile as a regex — the shell version used grep -E, so .* + # etc. need to work. Anchor on neither end (grep doesn't either). + regex = Regex.compile!(pattern) + Regex.match?(regex, listing) + end) + + case missing do + [] -> + :ok + + [first | _] -> + Errors.precondition("verify failed — tarball #{tarball_path} missing #{inspect(first)}") + end + end + + # ── build_all/1 ──────────────────────────────────────────────────────── + + @doc """ + Build all four tarballs in sequence. Each target picks up its own + defaults. Returns `[{target_id, result}, ...]` in canonical order. + Doesn't short-circuit. + """ + @spec build_all(keyword()) :: [{atom(), {:ok, map()} | Errors.t()}] + def build_all(opts \\ []) do + for target_id <- targets() do + {target_id, build(target_id, opts)} + end + end + + # ── Preconditions ────────────────────────────────────────────────────── + + defp precheck(target, shell, env, otp_release, openssl_prefix, opts) do + cond do + not shell.dir?(env.otp_src) -> + Errors.precondition("OTP_SRC missing at #{env.otp_src}") + + not shell.dir?(otp_release) -> + Errors.precondition( + "otp_release missing at #{otp_release} — run MobDev.Release.OTP.build(#{inspect(target.id)}) first" + ) + + not shell.dir?(openssl_prefix) -> + Errors.precondition( + "openssl_prefix missing at #{openssl_prefix} — run MobDev.Release.OpenSSL.build(#{inspect(target.id)}) first" + ) + + target.include_exqlite and is_nil(opts[:exqlite_build]) -> + Errors.precondition( + "exqlite_build required for #{target.id} — pass `exqlite_build: <path>` " <> + "pointing at any project's _build/dev/lib/exqlite" + ) + + target.include_exqlite and not shell.dir?(Path.join(opts[:exqlite_build], "ebin")) -> + Errors.precondition( + "exqlite_build/ebin not found — did you `mix deps.get && mix compile` in the project?" + ) + + true -> + :ok + end + end +end diff --git a/lib/mob_dev/release_android.ex b/lib/mob_dev/release_android.ex new file mode 100644 index 0000000..00e7737 --- /dev/null +++ b/lib/mob_dev/release_android.ex @@ -0,0 +1,288 @@ +defmodule MobDev.ReleaseAndroid do + @moduledoc """ + Builds the signed release AAB for a Mob Android app. + + Called by `mix mob.release --android`. The pipeline: + + 1. Download the Android OTP runtime (arm64) if not already cached. + 2. Copy the OTP tree to a temp staging dir and add: + - App + dep BEAMs (flattened into `{app_name}/`) + - App `priv/` → `{app_name}/priv/` + - exqlite BEAMs → `lib/exqlite-{vsn}/ebin/` (OTP lib structure needed + for `:code.lib_dir(:exqlite)` to resolve correctly at runtime) + 3. Run `MobDev.OtpAssetBundle.build/2` — strips unused OTP libs and + optional BEAM chunks, then zips the tree to + `src/release/assets/otp.zip`. + 4. Run `./gradlew bundleRelease` — signs the AAB using the keystore + configured in `android/keystore.properties`. + + `MobBridge.extractOtpIfNeeded()` (Kotlin) extracts `otp.zip` into + `<filesDir>/otp/` on first launch. Without this zip the app crashes + immediately — the BEAM has no runtime or application BEAMs to load. + + The zip is written to the `release`-variant asset source set, not the + shared `main` one — Gradle only merges `src/release/assets/` into release + builds, so a leftover zip from a prior release build can never leak into + (and silently poison) a subsequent debug build. See + `decisions/2026-07-24-release-otp-zip-variant-scoped-assets.md`. + """ + + @app_assets "android/app/src/release/assets" + + @doc false + @spec otp_zip_path() :: String.t() + def otp_zip_path, do: Path.expand(Path.join(@app_assets, "otp.zip")) + + @doc """ + Runs the full Android release pipeline and returns `{:ok, aab_path}` or + `{:error, reason}`. + """ + @spec build_aab(keyword()) :: {:ok, Path.t()} | {:error, String.t()} + def build_aab(opts \\ []) do + app_name = Mix.Project.config()[:app] |> to_string() + slim = Keyword.get(opts, :slim, true) + + with :ok <- check_android_project(), + log("Ensuring Android OTP runtime..."), + {:ok, otp_arm64} <- MobDev.OtpDownloader.ensure_android("arm64-v8a"), + log("Staging OTP tree + app BEAMs..."), + {:ok, staging} <- stage_otp_tree(otp_arm64, app_name), + log("Building otp.zip (stripping unused OTP libs)..."), + {:ok, info} <- build_zip(staging, slim), + _ = File.rm_rf!(staging), + log( + " #{info.zipped_files} files, " <> + "#{div(info.original_size_kb, 1024)}MB → #{div(info.zip_size_kb, 1024)}MB" + ), + log("Running ./gradlew bundleRelease..."), + {:ok, aab} <- gradle_bundle_release() do + {:ok, aab} + end + end + + # ── Staging ────────────────────────────────────────────────────────────────── + + defp stage_otp_tree(otp_dir, app_name) do + staging = + Path.join(System.tmp_dir!(), "mob_android_release_#{:erlang.unique_integer([:positive])}") + + File.rm_rf!(staging) + + case System.cmd("cp", ["-R", otp_dir <> "/.", staging], stderr_to_stdout: true) do + {_, 0} -> + add_app_beams!(staging, app_name) + add_app_priv!(staging, app_name) + add_exqlite!(staging) + + # Only stub :crypto when the OTP runtime genuinely lacks the + # OpenSSL NIF. When crypto.a is present (the Android CMakeLists.txt + # statically links crypto.a + libcrypto.a and registers + # crypto_nif_init in the driver table), the real :crypto works — + # stubbing it replaces crypto.beam with one whose supports/1 + # returns [], making :ssl.versions/0 raise and breaking every + # HTTPS request (TLS handshake never starts). + if real_crypto_available?(otp_dir) do + log(" real crypto.a present — keeping OpenSSL crypto (no stub)") + else + patch_crypto_deps!(staging) + add_crypto_stub!(staging, app_name) + end + + {:ok, staging} + + {out, _} -> + {:error, "Failed to copy OTP tree: #{out}"} + end + end + + # True when the OTP runtime ships the real OpenSSL crypto NIF static + # archive (crypto.a). The Android native build links it into the app + # .so, so the BEAM has working :crypto and must not get the stub. + # Public for testing. + @doc false + @spec real_crypto_available?(Path.t()) :: boolean() + def real_crypto_available?(otp_dir) do + Path.wildcard(Path.join(otp_dir, "erts-*/lib/crypto.a")) != [] + end + + # Flatten all runtime BEAMs (app + deps) into {staging}/{app_name}/. + # This mirrors how the deployer stages BEAMs for adb push: + # all dirs are copied into one flat directory on the -pa code path. + defp add_app_beams!(staging, app_name) do + beam_dirs = collect_beam_dirs() + dest = Path.join(staging, app_name) + File.mkdir_p!(dest) + + Enum.each(beam_dirs, fn dir -> + System.cmd("cp", ["-r", "#{Path.expand(dir)}/.", dest], stderr_to_stdout: true) + end) + end + + defp add_app_priv!(staging, app_name) do + local_priv = Path.join(File.cwd!(), "priv") + + if File.dir?(local_priv) do + dest = Path.join([staging, app_name, "priv"]) + File.rm_rf!(dest) + System.cmd("cp", ["-R", local_priv, dest], stderr_to_stdout: true) + end + + :ok + end + + # exqlite BEAMs must live at lib/exqlite-VSN/ebin/ in the OTP root so that + # :code.lib_dir(:exqlite) resolves correctly. mob_beam.c creates the + # sqlite3_nif.so symlink at runtime from the APK's nativeLibraryDir. + defp add_exqlite!(staging) do + with vsn when is_binary(vsn) <- exqlite_version(), + [ebin | _] <- Path.wildcard("_build/dev/lib/exqlite/ebin") do + lib_dir = Path.join(staging, "lib/exqlite-#{vsn}") + File.mkdir_p!(Path.join(lib_dir, "ebin")) + File.mkdir_p!(Path.join(lib_dir, "priv")) + + System.cmd("cp", ["-r", "#{Path.expand(ebin)}/.", Path.join(lib_dir, "ebin")], + stderr_to_stdout: true + ) + else + _ -> :ok + end + end + + # Same runtime BEAM collection as deployer.collect_beam_dirs/0: app + deps + + # eex (Elixir stdlib, not in _build/) + ssl (Thousand Island dep, not in OTP tree). + defp collect_beam_dirs do + app_dirs = MobDev.HotPush.runtime_beam_dirs() + + eex_ebin = Path.join(to_string(:code.lib_dir(:eex)), "ebin") + eex = if File.dir?(eex_ebin), do: [eex_ebin], else: [] + + ssl_ebin = Path.join(to_string(:code.lib_dir(:ssl)), "ebin") + ssl = if File.dir?(ssl_ebin), do: [ssl_ebin], else: [] + + app_dirs ++ eex ++ ssl + end + + defp exqlite_version, do: MobDev.AppFile.dep_version(:exqlite) + + # The Android OTP release lacks :crypto (no cross-compiled OpenSSL NIF). + # Many deps (ecto, phoenix_pubsub, plug_crypto, …) declare it as a required + # application dependency, which causes Application.ensure_all_started to fail + # at startup. We patch every .app file in the staging tree to remove :crypto + # from the applications list, then inject a minimal crypto.beam stub + # (priv/android/crypto.erl) that implements strong_rand_bytes/1 via :rand. + defp patch_crypto_deps!(staging) do + staging + |> Path.join("**/*.app") + |> Path.wildcard() + |> Enum.each(&remove_crypto_from_app_file/1) + end + + defp remove_crypto_from_app_file(path) do + case :file.consult(String.to_charlist(path)) do + {:ok, [{:application, name, props}]} -> + apps = Keyword.get(props, :applications, []) + new_apps = Enum.reject(apps, &(&1 == :crypto)) + + if new_apps != apps do + new_props = Keyword.put(props, :applications, new_apps) + term_str = :io_lib.format("~p.~n", [{:application, name, new_props}]) + File.write!(path, IO.chardata_to_string(term_str)) + end + + _ -> + :ok + end + end + + defp add_crypto_stub!(staging, app_name) do + stub_src = + :code.priv_dir(:mob_dev) + |> to_string() + |> Path.join("android/crypto.erl") + + dest_dir = Path.join(staging, app_name) + tmp_dir = Path.join(System.tmp_dir!(), "mob_crypto_stub") + File.mkdir_p!(tmp_dir) + + case System.cmd("erlc", ["-o", tmp_dir, stub_src], stderr_to_stdout: true) do + {_, 0} -> + beam = Path.join(tmp_dir, "crypto.beam") + + if File.exists?(beam) do + File.cp!(beam, Path.join(dest_dir, "crypto.beam")) + File.rm!(beam) + end + + # Write crypto.app so the OTP application controller can load and + # start the :crypto application. Without this file, ensure_all_started + # fails with {error, {crypto, {"no such file or directory", "crypto.app"}}} + # even when crypto.beam is present — the app controller requires the + # .app spec to register the application before starting it. + # No {mod, ...} entry: starting :crypto just marks it as started, + # with no NIF initialization (our stub uses :rand instead). + crypto_app = """ + {application,crypto,[ + {description,"CRYPTO stub for Android"}, + {vsn,"5.5"}, + {modules,[crypto]}, + {registered,[]}, + {applications,[kernel,stdlib]}, + {env,[]} + ]}. + """ + + File.write!(Path.join(dest_dir, "crypto.app"), crypto_app) + + {out, _} -> + Mix.shell().info(" warning: could not compile crypto stub: #{out}") + end + end + + # ── otp.zip ────────────────────────────────────────────────────────────────── + + defp build_zip(staging, slim) do + zip_path = otp_zip_path() + File.mkdir_p!(Path.dirname(zip_path)) + MobDev.OtpAssetBundle.build(staging, zip_path, slim: slim) + end + + # ── Gradle ─────────────────────────────────────────────────────────────────── + + defp gradle_bundle_release do + gradlew = Path.expand("android/gradlew") + aab = Path.expand("android/app/build/outputs/bundle/release/app-release.aab") + + case System.cmd("bash", [gradlew, "bundleRelease", "--no-daemon"], + cd: Path.expand("android"), + stderr_to_stdout: true, + into: IO.stream() + ) do + {_, 0} -> + if File.exists?(aab) do + {:ok, aab} + else + {:error, "Gradle succeeded but AAB not found at #{aab}"} + end + + {_, rc} -> + {:error, "Gradle bundleRelease failed (exit #{rc}) — see output above."} + end + end + + # ── Helpers ────────────────────────────────────────────────────────────────── + + defp check_android_project do + cond do + not File.dir?("android") -> + {:error, "No android/ directory — run from the root of a Mob Android project."} + + not File.exists?("android/gradlew") -> + {:error, "android/gradlew not found."} + + true -> + :ok + end + end + + defp log(msg), do: Mix.shell().info(" #{msg}") +end diff --git a/lib/mob_dev/security_scan.ex b/lib/mob_dev/security_scan.ex new file mode 100644 index 0000000..8150fc8 --- /dev/null +++ b/lib/mob_dev/security_scan.ex @@ -0,0 +1,53 @@ +defmodule MobDev.SecurityScan do + @moduledoc """ + Top-level API for `mix mob.security_scan`. + + Runs every layer of the scan against the current project and + returns a `Report`. Layers cover: + + * Hex dependency CVEs (`mix_audit` + OSV) + * Android Gradle dependency CVEs (`osv-scanner`) + * iOS Swift Package dependency CVEs (`osv-scanner`) + * Bundled-runtime CVEs — OpenSSL/SQLite/OTP/Elixir baked into + the OTP tarballs (manifest + fingerprint verification + + OpenSSL/SQLite/Erlef advisory feeds) + * C source static analysis (semgrep, flawfinder) + * Kotlin static analysis (detekt) + * Swift static analysis (`xcodebuild analyze`) + + Each layer can be disabled with `--skip <name>`. Layers + never raise: a missing tool or unreadable file lands as a + `LayerResult` with status `:tool_missing` or `:error`, not + an exception. + """ + + alias MobDev.SecurityScan.{Report, Runner} + + @default_layers [ + MobDev.SecurityScan.Layers.HexDeps, + MobDev.SecurityScan.Layers.GradleDeps, + MobDev.SecurityScan.Layers.SwiftDeps, + MobDev.SecurityScan.Layers.BundledRuntime, + MobDev.SecurityScan.Layers.CSource, + MobDev.SecurityScan.Layers.KotlinSource, + MobDev.SecurityScan.Layers.SwiftSource + ] + + @doc """ + Run the scan. `opts` may include: + + * `:layers` — module list, defaults to `default_layers/0` + * `:skip` — list of layer-name atoms to skip + * `:project_root` — directory to scan; defaults to `File.cwd!/0` + * `:on_layer_start` / `:on_layer_done` — progress callbacks + """ + @spec run(keyword()) :: Report.t() + def run(opts \\ []) do + layers = Keyword.get(opts, :layers, default_layers()) + Runner.run(layers, opts) + end + + @doc "Default layer list. New layers register here as they are built." + @spec default_layers() :: [module()] + def default_layers, do: @default_layers +end diff --git a/lib/mob_dev/security_scan/bundled_runtime/fingerprint.ex b/lib/mob_dev/security_scan/bundled_runtime/fingerprint.ex new file mode 100644 index 0000000..ff66361 --- /dev/null +++ b/lib/mob_dev/security_scan/bundled_runtime/fingerprint.ex @@ -0,0 +1,226 @@ +defmodule MobDev.SecurityScan.BundledRuntime.Fingerprint do + @moduledoc """ + Extracts versions from `~/.mob/cache/otp-*-{hash}/` and from exqlite + C sources in a project's `deps/`. + + This module is the *receipt* side of the manifest-first design. + `BundledVersions` records what we *claim* shipped; `Fingerprint` + reads what's *actually* on disk. The bundled-runtime scan layer + asserts they agree. + + Pure functions only — no advisory feed lookups, no severity + judgements. The fingerprinter answers "what version is this binary?" + and nothing else. + """ + + @cache_dir Path.join([System.user_home() || "/", ".mob", "cache"]) + + @typedoc """ + One cached tarball found on disk. + `:platform` is decoded from the directory name; `:hash` is the trailing + OTP commit hash from `MobDev.OtpDownloader`. + """ + @type tarball :: %{ + platform: :android | :android_arm32 | :ios_sim | :ios_device, + hash: String.t(), + path: Path.t() + } + + @typedoc """ + Versions extracted from a single tarball. Any field may be `nil` if + fingerprinting failed for that artifact (e.g. libcrypto.a was stripped + in an unexpected way). The layer reports such cases as findings, not + silent failures. + """ + @type tarball_versions :: %{ + erts: String.t() | nil, + elixir: String.t() | nil, + openssl: String.t() | nil, + exqlite_beam: String.t() | nil + } + + @doc """ + Locate every cached OTP tarball under `~/.mob/cache/`. + + Returns a list of `tarball/0` entries, sorted by platform then hash + for stable output. Filters out anything that isn't a directory or + doesn't match the `otp-{platform}-{hash}` naming scheme. + + Pass `:cache_dir` to override the default path (used in tests). + """ + @spec locate_cached_tarballs(keyword()) :: [tarball()] + def locate_cached_tarballs(opts \\ []) do + cache_dir = Keyword.get(opts, :cache_dir, @cache_dir) + + case File.ls(cache_dir) do + {:ok, entries} -> + entries + |> Enum.map(&Path.join(cache_dir, &1)) + |> Enum.filter(&File.dir?/1) + |> Enum.flat_map(&decode_tarball_dir/1) + |> Enum.sort_by(&{&1.platform, &1.hash}) + + {:error, _} -> + [] + end + end + + defp decode_tarball_dir(path) do + # Regex.compile!/1 (not ~r/.../) to sidestep OTP 28.0's `:re.import/1` + # undefined-function bug on sigil-precompiled regexes loaded from beam + # files. See MobDev.NdkVersion.project_pinned/1 for the same workaround. + pattern = Regex.compile!("^otp-(android-arm32|android|ios-sim|ios-device)-([0-9a-f]+)$") + + case Regex.run(pattern, Path.basename(path)) do + [_, "android", hash] -> [%{platform: :android, hash: hash, path: path}] + [_, "android-arm32", hash] -> [%{platform: :android_arm32, hash: hash, path: path}] + [_, "ios-sim", hash] -> [%{platform: :ios_sim, hash: hash, path: path}] + [_, "ios-device", hash] -> [%{platform: :ios_device, hash: hash, path: path}] + _ -> [] + end + end + + @doc """ + Fingerprint a single tarball directory. Returns the versions + extracted from disk. + """ + @spec fingerprint_tarball(Path.t()) :: tarball_versions() + def fingerprint_tarball(tarball_path) do + %{ + erts: extract_erts_version(tarball_path), + elixir: extract_elixir_version(tarball_path), + openssl: extract_openssl_version(tarball_path), + exqlite_beam: extract_exqlite_version(tarball_path) + } + end + + @doc """ + Fingerprint the SQLite version compiled into exqlite from a + project's `deps/exqlite/c_src/sqlite3.c`. SQLite is bundled per-app + via the exqlite Hex package, not via the OTP tarball, so it's + scanned at the project level. + """ + @spec fingerprint_sqlite(Path.t()) :: {:ok, String.t()} | {:error, :not_found | :unparseable} + def fingerprint_sqlite(project_root) do + path = Path.join([project_root, "deps", "exqlite", "c_src", "sqlite3.c"]) + + cond do + not File.exists?(path) -> + {:error, :not_found} + + true -> + # SQLite source files are tens of MB; only read the head where + # the version macro lives. + case File.open(path, [:read, :utf8], &read_sqlite_version/1) do + {:ok, {:ok, version}} -> {:ok, version} + {:ok, {:error, reason}} -> {:error, reason} + {:error, _} -> {:error, :unparseable} + end + end + end + + defp read_sqlite_version(io) do + # Linear scan — the macro is in the first ~200 lines of sqlite3.c + # historically. Bail after 5000 lines as a safety net. + Enum.reduce_while(1..5000, {:error, :unparseable}, fn _, _acc -> + case IO.read(io, :line) do + :eof -> + {:halt, {:error, :unparseable}} + + {:error, _} = e -> + {:halt, e} + + line -> + case Regex.run(Regex.compile!(~S<^#define\s+SQLITE_VERSION\s+"([^"]+)">), line) do + [_, version] -> {:halt, {:ok, version}} + nil -> {:cont, {:error, :unparseable}} + end + end + end) + end + + defp extract_erts_version(tarball_path) do + tarball_path + |> Path.join("erts-*") + |> Path.wildcard() + |> List.first() + |> case do + nil -> nil + path -> path |> Path.basename() |> String.replace_prefix("erts-", "") + end + end + + defp extract_elixir_version(tarball_path) do + app_file = Path.join([tarball_path, "lib", "elixir", "ebin", "elixir.app"]) + + case File.read(app_file) do + {:ok, content} -> + case Regex.run(Regex.compile!(~S<\{vsn,\s*"([^"]+)"\}>), content) do + [_, version] -> version + nil -> nil + end + + {:error, _} -> + nil + end + end + + defp extract_exqlite_version(tarball_path) do + tarball_path + |> Path.join(["lib", "/", "exqlite-*"]) + |> Path.wildcard() + |> List.first() + |> case do + nil -> nil + path -> path |> Path.basename() |> String.replace_prefix("exqlite-", "") + end + end + + defp extract_openssl_version(tarball_path) do + libcrypto = + [tarball_path, "erts-*", "lib", "libcrypto.a"] + |> Path.join() + |> Path.wildcard() + |> List.first() + + cond do + libcrypto == nil -> nil + not File.exists?(libcrypto) -> nil + true -> scan_openssl_version_string(libcrypto) + end + end + + # OpenSSL embeds its version banner in libcrypto.a's .rodata as + # + # OpenSSL <version> <DD Mon YYYY>\0 + # + # e.g. "OpenSSL 3.4.0 22 Oct 2024". libcrypto also contains lots of + # other strings starting with "OpenSSL " ("OpenSSL default", "OpenSSL + # DH Method", etc.), so we have to scan every match and pick the one + # whose second token looks like a version. No `strings` binary needed. + defp scan_openssl_version_string(path) do + case File.read(path) do + {:ok, content} -> scan_for_version_banner(content) + _ -> nil + end + end + + defp scan_for_version_banner(content) do + content + |> :binary.matches("OpenSSL ") + |> Enum.find_value(&match_version_at(content, &1)) + end + + defp match_version_at(content, {pos, _len}) do + blob = + content + |> :binary.part(pos, min(64, byte_size(content) - pos)) + |> :binary.split(<<0>>) + |> List.first() + + case Regex.run(Regex.compile!(~S<^OpenSSL\s+(\d+\.\d+\.\d+[a-z]?)\b>), blob) do + [_, version] -> version + _ -> nil + end + end +end diff --git a/lib/mob_dev/security_scan/bundled_versions.ex b/lib/mob_dev/security_scan/bundled_versions.ex new file mode 100644 index 0000000..7baf3b0 --- /dev/null +++ b/lib/mob_dev/security_scan/bundled_versions.ex @@ -0,0 +1,117 @@ +defmodule MobDev.SecurityScan.BundledVersions do + @moduledoc """ + Loads `priv/security/bundled_versions.exs` — the source-of-truth + manifest of what versions ship inside the OTP tarballs that + `MobDev.OtpDownloader` distributes. + + See [`priv/security/bundled_versions.exs`](priv/security/bundled_versions.exs) + for the full schema and update procedure. + + ## Why a manifest, not a fingerprint-only approach + + Manifest first, fingerprint second. The manifest is a *claim* + reviewable in git — every PR that touches it is auditable. + Fingerprinting is the *receipt* that proves the claim. + + A fingerprint-only approach can silently fail when build flags + change and a version string is stripped or moves to a different + binary; the scanner just reports "version unknown" and you stop + noticing. A manifest-first approach forces a human to write down + what shipped — and the fingerprinter then catches drift. + """ + + @external_resource Path.join([ + __DIR__, + "..", + "..", + "..", + "priv", + "security", + "bundled_versions.exs" + ]) + + @manifest_path Path.join([ + :code.priv_dir(:mob_dev) |> to_string(), + "security", + "bundled_versions.exs" + ]) + + @doc "Path to the manifest .exs file." + @spec manifest_path() :: Path.t() + def manifest_path, do: @manifest_path + + @doc """ + Load the manifest from disk. Returns the parsed map. + + Raises if the file is missing or doesn't evaluate to a map with + the expected shape — the manifest is a hard requirement for the + bundled-runtime scan layer; a missing file is a real bug, not a + soft warning. + """ + @spec load() :: %{ + active_hash: String.t(), + bundles: %{String.t() => map()} + } + def load do + path = manifest_path() + + unless File.exists?(path) do + raise "bundled versions manifest missing at #{path}" + end + + {manifest, _bindings} = Code.eval_file(path) + validate!(manifest) + manifest + end + + @doc """ + Return the bundle entry for a given OTP tarball hash. + + Returns `{:ok, bundle}` when present, `{:error, :unknown_hash}` + otherwise. Useful for the fingerprinter when the hash on disk + doesn't match the manifest's `:active_hash` — the tarball might + be from an older or unpublished build. + """ + @spec for_hash(String.t()) :: {:ok, map()} | {:error, :unknown_hash} + def for_hash(hash) when is_binary(hash) do + case Map.fetch(load().bundles, hash) do + {:ok, bundle} -> {:ok, bundle} + :error -> {:error, :unknown_hash} + end + end + + @doc "Return the currently active bundle (the hash Mob is shipping today)." + @spec active() :: map() + def active do + manifest = load() + Map.fetch!(manifest.bundles, manifest.active_hash) + end + + defp validate!(%{active_hash: hash, bundles: bundles}) + when is_binary(hash) and is_map(bundles) do + unless Map.has_key?(bundles, hash) do + raise "bundled versions manifest: active_hash #{inspect(hash)} not found in :bundles" + end + + Enum.each(bundles, fn {h, bundle} -> validate_bundle!(h, bundle) end) + :ok + end + + defp validate!(other) do + raise "bundled versions manifest must be %{active_hash: ..., bundles: %{...}}; got #{inspect(other)}" + end + + @required_fields [:erts, :otp_release, :elixir, :openssl, :exqlite_beam] + + defp validate_bundle!(hash, bundle) when is_map(bundle) do + Enum.each(@required_fields, fn key -> + unless Map.has_key?(bundle, key) do + raise "bundled versions manifest: bundle #{inspect(hash)} missing required field #{inspect(key)}" + end + end) + end + + defp validate_bundle!(hash, other) do + raise "bundled versions manifest: bundle #{inspect(hash)} must be a map, got #{inspect(other)}" + end +end diff --git a/lib/mob_dev/security_scan/diff.ex b/lib/mob_dev/security_scan/diff.ex new file mode 100644 index 0000000..afe3c4c --- /dev/null +++ b/lib/mob_dev/security_scan/diff.ex @@ -0,0 +1,105 @@ +defmodule MobDev.SecurityScan.Diff do + @moduledoc """ + Computes the delta between the previous scan state and the + current report: + + * `new` — findings present now that were absent last run + * `resolved` — findings present last run that are absent now + * `still_present` — findings in both, with their `first_seen_at` + preserved from the prior state for patch-lag display + + The dedup key is `Finding.dedupe_key/1` (id, package, version). + Two findings reported by different sources for the same advisory + on the same package@version are considered the same finding — + resolution is based on the underlying vulnerability, not the + scanner that surfaced it. + """ + + alias MobDev.SecurityScan.{Finding, Report} + alias MobDev.SecurityScan.StateFile + + @type t :: %__MODULE__{ + new: [Finding.t()], + resolved: [StateFile.entry()], + still_present: [Finding.t()], + first_seen: %{StateFile.key() => DateTime.t()} + } + + defstruct new: [], resolved: [], still_present: [], first_seen: %{} + + @doc """ + Compute the diff between a previous state map (typically loaded + from the state file) and the current report. + + Both sides are keyed by the string form of `Finding.dedupe_key/1` + (`"id|package|version"`) so we can compare across the JSON state + file boundary. + + `now` is injectable so tests can pin timestamps. + """ + @spec compute(StateFile.state(), Report.t(), DateTime.t()) :: t() + def compute(%{} = prev_state, %Report{} = report, %DateTime{} = now) do + prev_findings = Map.get(prev_state, :findings, []) + prev_by_key = Map.new(prev_findings, &{&1.key, &1}) + prev_keys = MapSet.new(Map.keys(prev_by_key)) + + current_findings = Report.all_findings(report) + current_by_key = Map.new(current_findings, &{string_key(&1), &1}) + current_keys = MapSet.new(Map.keys(current_by_key)) + + new_keys = MapSet.difference(current_keys, prev_keys) + resolved_keys = MapSet.difference(prev_keys, current_keys) + still_keys = MapSet.intersection(current_keys, prev_keys) + + first_seen = build_first_seen(current_by_key, prev_by_key, now) + + # Map back to tuple-keyed first_seen for downstream consumers + # (HistoryFormatter calls `Finding.dedupe_key/1` directly). + first_seen_tuple_keyed = + Map.new(first_seen, fn {string_key, ts} -> + {string_to_tuple(string_key), ts} + end) + + %__MODULE__{ + new: Enum.map(new_keys, &Map.fetch!(current_by_key, &1)), + resolved: Enum.map(resolved_keys, &Map.fetch!(prev_by_key, &1)), + still_present: Enum.map(still_keys, &Map.fetch!(current_by_key, &1)), + first_seen: first_seen_tuple_keyed + } + end + + @doc "String form of `Finding.dedupe_key/1` — matches StateFile entry keys." + @spec string_key(Finding.t()) :: String.t() + def string_key(%Finding{} = f) do + {id, package, version} = Finding.dedupe_key(f) + "#{id || ""}|#{package || ""}|#{version || ""}" + end + + defp string_to_tuple(string) do + [id, package, version] = String.split(string, "|", parts: 3) + {nil_if_empty(id), nil_if_empty(package), nil_if_empty(version)} + end + + defp nil_if_empty(""), do: nil + defp nil_if_empty(s), do: s + + defp build_first_seen(current_by_key, prev_by_key, now) do + Map.new(current_by_key, fn {key, _finding} -> + first = + case Map.get(prev_by_key, key) do + %{first_seen_at: %DateTime{} = ts} -> ts + %{first_seen_at: ts} when is_binary(ts) -> parse_or_now(ts, now) + _ -> now + end + + {key, first} + end) + end + + defp parse_or_now(string, fallback) do + case DateTime.from_iso8601(string) do + {:ok, dt, _} -> dt + _ -> fallback + end + end +end diff --git a/lib/mob_dev/security_scan/finding.ex b/lib/mob_dev/security_scan/finding.ex new file mode 100644 index 0000000..cfa6fb5 --- /dev/null +++ b/lib/mob_dev/security_scan/finding.ex @@ -0,0 +1,67 @@ +defmodule MobDev.SecurityScan.Finding do + @moduledoc """ + A single normalized security finding. + + Findings come from many sources — Hex `mix_audit`, `osv-scanner`, + the OpenSSL/SQLite/Erlef advisory feeds, semgrep, etc. — and are + normalized into this struct so the report and rubric treat them + uniformly. `source` records which scanner produced it; `layer` + records which surface area it covers (`:hex_deps`, `:bundled_runtime`, + `:c_source`, ...). + + `severity` is one of `:critical`, `:high`, `:medium`, `:low`, + `:unknown`. Scanners report severity differently (CVSS scores, + GHSA ratings, vendor scales); upstream callers normalize before + building a Finding. + """ + + @type severity :: :critical | :high | :medium | :low | :unknown + + @type t :: %__MODULE__{ + id: String.t() | nil, + severity: severity(), + package: String.t() | nil, + version: String.t() | nil, + fixed_in: String.t() | nil, + title: String.t() | nil, + description: String.t() | nil, + url: String.t() | nil, + source: atom(), + layer: atom() + } + + @derive Jason.Encoder + defstruct id: nil, + severity: :unknown, + package: nil, + version: nil, + fixed_in: nil, + title: nil, + description: nil, + url: nil, + source: nil, + layer: nil + + @severity_order %{critical: 0, high: 1, medium: 2, low: 3, unknown: 4} + + @doc """ + Sort order helper: severities ranked critical → unknown. + + Use as `Enum.sort_by(findings, &Finding.sort_key/1)`. + """ + @spec sort_key(t()) :: {non_neg_integer(), String.t()} + def sort_key(%__MODULE__{severity: sev, id: id}) do + {Map.get(@severity_order, sev, 99), id || ""} + end + + @doc """ + Deduplication key. Two findings dedupe to the same key when they + describe the same advisory against the same package+version, even + if they came from different sources (e.g. mix_audit and osv-scanner + both reporting GHSA-XXXX against `:plug` 1.10). + """ + @spec dedupe_key(t()) :: {String.t() | nil, String.t() | nil, String.t() | nil} + def dedupe_key(%__MODULE__{id: id, package: package, version: version}) do + {id, package, version} + end +end diff --git a/lib/mob_dev/security_scan/formatter.ex b/lib/mob_dev/security_scan/formatter.ex new file mode 100644 index 0000000..d318bda --- /dev/null +++ b/lib/mob_dev/security_scan/formatter.ex @@ -0,0 +1,259 @@ +defmodule MobDev.SecurityScan.Formatter do + @moduledoc """ + Render a `Report` for human or machine consumers. + + * `terminal/1` — pretty ANSI-coloured output for `mix mob.security_scan` + * `json/1` — machine-readable for `--json` + * `markdown/1` — for `--write-report SECURITY_SCAN.md` + + The formatter never raises; missing optional fields render as + blank, not crashes. Callers control output destination (IO, + File.write/2, etc.). + """ + + alias MobDev.SecurityScan.{Finding, Report} + + @severity_colors %{ + critical: IO.ANSI.red() <> IO.ANSI.bright(), + high: IO.ANSI.red(), + medium: IO.ANSI.yellow(), + low: IO.ANSI.cyan(), + unknown: IO.ANSI.faint() + } + + @severity_icon %{ + critical: "▲", + high: "▲", + medium: "▲", + low: "•", + unknown: "?" + } + + @doc "Render the report as ANSI-coloured terminal text." + @spec terminal(Report.t()) :: String.t() + def terminal(%Report{} = report) do + sections = [ + header(report), + layer_sections(report), + summary(report) + ] + + Enum.join(sections, "\n") <> "\n" + end + + @doc "Render the report as JSON-encodable data." + @spec json(Report.t()) :: String.t() + def json(%Report{} = report) do + Jason.encode!(report, pretty: true) + end + + @doc """ + Render the report as a Markdown document for `--write-report PATH`. + Designed to be checked into a repo and diffed across runs as + patch-lag evidence — fewer findings over time = receipts. + """ + @spec markdown(Report.t()) :: String.t() + def markdown(%Report{} = report) do + counts = Report.severity_counts(report) + duration = Report.duration_ms(report) + + [ + "# Mob Security Scan", + "", + "_Generated by `mix mob.security_scan` on #{report.started_at}._", + "", + "**Project:** `#{report.project_root}` ", + "**Duration:** #{duration || "?"}ms ", + "**Total findings:** #{Enum.sum(Map.values(counts))}", + "", + "## Severity counts", + "", + "| Critical | High | Medium | Low | Unknown |", + "| -------: | ---: | -----: | --: | ------: |", + "| #{counts.critical} | #{counts.high} | #{counts.medium} | #{counts.low} | #{counts.unknown} |", + "", + "## Layers", + "", + Enum.map_join(report.layers, "\n", &markdown_layer/1), + "", + "## Coverage", + "", + "This scan covers every surface a Mob app actually ships:", + "", + "- Hex dependency CVEs (`mix_audit` + `osv-scanner` over `mix.lock`)", + "- Android Gradle dependency CVEs (`osv-scanner`)", + "- iOS Swift Package Manager / CocoaPods dependency CVEs (`osv-scanner`)", + "- Bundled-runtime versions (OpenSSL, ERTS, Elixir, exqlite, SQLite) with", + " manifest-vs-binary drift detection", + "- C source static analysis (`semgrep`, `flawfinder`)", + "- Kotlin/Java static analysis (`detekt`)", + "- Swift static analysis (`swiftlint`)", + "", + "Layers reporting `tool missing` indicate an external scanner", + "isn't installed — coverage gap, not a clean bill of health.", + "" + ] + |> Enum.join("\n") + end + + defp markdown_layer(layer) do + duration = if layer.duration_ms, do: " (#{layer.duration_ms}ms)", else: "" + status = layer.status |> Atom.to_string() |> String.replace("_", " ") + + body = [ + "### `#{layer.name}` — #{status}#{duration}", + "" + ] + + body = + body ++ + if layer.tools_used != [] do + ["**Tools:** #{Enum.join(layer.tools_used, ", ")}", ""] + else + [] + end + + body = + body ++ + if layer.notes != [] do + Enum.map(layer.notes, &"- #{&1}") ++ [""] + else + [] + end + + body = + body ++ + if layer.error do + ["**Error:** #{layer.error}", ""] + else + [] + end + + body = body ++ markdown_findings(layer.findings) + Enum.join(body, "\n") + end + + defp markdown_findings([]), do: [] + + defp markdown_findings(findings) do + sorted = Enum.sort_by(findings, &Finding.sort_key/1) + + [ + "**Findings**", + "", + "| Severity | ID | Package | Version | Fixed in | Title |", + "| -------- | -- | ------- | ------- | -------- | ----- |" + ] ++ + Enum.map(sorted, &markdown_finding_row/1) ++ [""] + end + + defp markdown_finding_row(%Finding{} = f) do + sev = f.severity |> Atom.to_string() |> String.upcase() + + "| #{sev} | #{f.id || ""} | #{f.package || ""} | #{f.version || ""} | #{f.fixed_in || ""} | #{escape_md(f.title) || ""} |" + end + + defp escape_md(nil), do: nil + defp escape_md(s) when is_binary(s), do: String.replace(s, "|", "\\|") + + defp header(%Report{started_at: started, project_root: root}) do + h = IO.ANSI.bright() + r = IO.ANSI.reset() + "#{h}=== mob security scan ===#{r}\n started: #{started}\n root: #{root}\n" + end + + defp layer_sections(%Report{layers: layers}) do + layers + |> Enum.map(&layer_section/1) + |> Enum.join("\n") + end + + defp layer_section(layer) do + h = IO.ANSI.bright() + r = IO.ANSI.reset() + dim = IO.ANSI.faint() + + duration = if layer.duration_ms, do: " (#{layer.duration_ms}ms)", else: "" + status_tag = status_tag(layer.status) + + head = "#{h}── #{layer.name}#{r} #{status_tag}#{dim}#{duration}#{r}" + + tools = + if layer.tools_used != [], do: "\n tools: #{Enum.join(layer.tools_used, ", ")}", else: "" + + notes = render_notes(layer.notes) + error = if layer.error, do: "\n #{IO.ANSI.red()}error: #{layer.error}#{r}", else: "" + findings = render_findings(layer.findings) + + head <> tools <> notes <> error <> findings <> "\n" + end + + defp status_tag(:ok), do: "#{IO.ANSI.green()}ok#{IO.ANSI.reset()}" + defp status_tag(:tool_missing), do: "#{IO.ANSI.yellow()}tool missing#{IO.ANSI.reset()}" + defp status_tag(:not_applicable), do: "#{IO.ANSI.faint()}n/a#{IO.ANSI.reset()}" + defp status_tag(:skipped), do: "#{IO.ANSI.faint()}skipped#{IO.ANSI.reset()}" + defp status_tag(:error), do: "#{IO.ANSI.red()}error#{IO.ANSI.reset()}" + + defp render_notes([]), do: "" + + defp render_notes(notes) do + "\n" <> Enum.map_join(notes, "\n", &" · #{&1}") + end + + defp render_findings([]), do: "" + + defp render_findings(findings) do + "\n" <> + (findings + |> Enum.sort_by(&Finding.sort_key/1) + |> Enum.map_join("\n", &render_finding/1)) + end + + defp render_finding(%Finding{} = f) do + color = Map.get(@severity_colors, f.severity, "") + icon = Map.get(@severity_icon, f.severity, "?") + reset = IO.ANSI.reset() + sev = f.severity |> Atom.to_string() |> String.upcase() |> String.pad_trailing(8) + + pkg = if f.package, do: " #{f.package}", else: "" + ver = if f.version, do: "@#{f.version}", else: "" + fixed = if f.fixed_in, do: " → fixed in #{f.fixed_in}", else: "" + title = if f.title, do: "\n #{f.title}", else: "" + id = if f.id, do: " [#{f.id}]", else: "" + + " #{color}#{icon} #{sev}#{reset}#{pkg}#{ver}#{id}#{fixed}#{title}" + end + + defp summary(%Report{} = report) do + counts = Report.severity_counts(report) + total = Enum.sum(Map.values(counts)) + h = IO.ANSI.bright() + r = IO.ANSI.reset() + duration = Report.duration_ms(report) + + duration_line = + if duration, do: " total time: #{duration}ms\n", else: "" + + counts_line = + " #{color_count(:critical, counts.critical)} critical " <> + "#{color_count(:high, counts.high)} high " <> + "#{color_count(:medium, counts.medium)} medium " <> + "#{color_count(:low, counts.low)} low " <> + "#{color_count(:unknown, counts.unknown)} unknown" + + "#{h}=== Summary ===#{r}\n" <> + duration_line <> + " total findings: #{total}\n" <> + counts_line <> "\n" + end + + defp color_count(severity, count) do + color = + cond do + count == 0 -> IO.ANSI.faint() + true -> Map.get(@severity_colors, severity, "") + end + + "#{color}#{count}#{IO.ANSI.reset()}" + end +end diff --git a/lib/mob_dev/security_scan/history_formatter.ex b/lib/mob_dev/security_scan/history_formatter.ex new file mode 100644 index 0000000..1332f9d --- /dev/null +++ b/lib/mob_dev/security_scan/history_formatter.ex @@ -0,0 +1,169 @@ +defmodule MobDev.SecurityScan.HistoryFormatter do + @moduledoc """ + Render a single changelog entry for `SECURITY_HISTORY.md`. + + Each entry is one Markdown section with the timestamp as the + heading, severity counts as the lede, and three lists: New + since last scan / Resolved / Still present. Designed to be + appended to the head of `SECURITY_HISTORY.md` so the latest run + is the first thing a reader sees. + """ + + alias MobDev.SecurityScan.{Diff, Finding, Report} + + @doc """ + Build a single Markdown changelog entry for the given report+diff. + Returns a string with a trailing blank line so successive entries + separate cleanly. + """ + @spec entry(Report.t(), Diff.t(), DateTime.t()) :: String.t() + def entry(%Report{} = report, %Diff{} = diff, %DateTime{} = now) do + counts = Report.severity_counts(report) + duration = Report.duration_ms(report) + + [ + "## #{DateTime.to_iso8601(now)}", + "", + "**Project:** `#{report.project_root}` ", + "**Duration:** #{duration || "?"}ms ", + "**Total findings:** #{Enum.sum(Map.values(counts))} " <> + "(#{counts.critical} critical, #{counts.high} high, #{counts.medium} medium, " <> + "#{counts.low} low, #{counts.unknown} unknown)", + "", + section_new(diff), + section_resolved(diff), + section_still_present(diff, now), + "" + ] + |> Enum.join("\n") + end + + @doc """ + Append `entry` to the top of `path` (after the file's header, if + any). Creates the file with a default header if it doesn't exist. + """ + @spec prepend_to_file(Path.t(), String.t()) :: :ok + def prepend_to_file(path, entry) do + File.mkdir_p!(Path.dirname(path)) + + existing = + case File.read(path) do + {:ok, body} -> strip_header(body) + {:error, _} -> "" + end + + contents = header() <> entry <> existing + File.write!(path, contents) + :ok + end + + defp header do + """ + # Security scan history + + Append-only changelog generated by `mix mob.security_scan.log`. + Newest entries on top. + + """ + end + + defp strip_header(body) do + case String.split(body, "\n\n", parts: 2) do + ["# Security scan history" <> _, rest] -> "\n\n" <> strip_old_intro(rest) + _ -> body + end + end + + # The header is "# Security scan history\n\n<intro>\n\n<first-entry>". + # After splitting on the first "\n\n" we still have "<intro>\n\n<entries>"; + # drop the intro paragraph if it's the auto-generated one. + defp strip_old_intro(rest) do + case String.split(rest, "\n\n", parts: 2) do + ["Append-only changelog" <> _, entries] -> entries + _ -> rest + end + end + + defp section_new(%Diff{new: []}), do: "### New since last scan _(none)_\n" + + defp section_new(%Diff{new: findings}) do + sorted = Enum.sort_by(findings, &Finding.sort_key/1) + + "### New since last scan (#{length(findings)})\n\n" <> + Enum.map_join(sorted, "\n", &("- " <> render_finding(&1))) <> "\n" + end + + defp section_resolved(%Diff{resolved: []}), do: "### Resolved since last scan _(none)_\n" + + defp section_resolved(%Diff{resolved: entries}) do + sorted = + Enum.sort_by(entries, fn entry -> + {severity_rank(entry.severity), entry.id || ""} + end) + + "### Resolved since last scan (#{length(entries)}) ✓\n\n" <> + Enum.map_join(sorted, "\n", &("- " <> render_entry(&1))) <> "\n" + end + + defp section_still_present(%Diff{still_present: []}, _now), + do: "### Still present from last scan _(none)_\n" + + defp section_still_present(%Diff{still_present: findings, first_seen: first_seen}, now) do + sorted = Enum.sort_by(findings, &Finding.sort_key/1) + + "### Still present from last scan (#{length(findings)})\n\n" <> + Enum.map_join(sorted, "\n", fn f -> + first = Map.get(first_seen, Finding.dedupe_key(f)) + "- " <> render_finding(f) <> age_suffix(first, now) + end) <> "\n" + end + + defp render_finding(%Finding{} = f) do + sev = f.severity |> Atom.to_string() |> String.upcase() + pkg = if f.package, do: " `#{f.package}#{ver_suffix(f.version)}`", else: "" + id = if f.id, do: " #{link_id(f.id, f.url)}", else: "" + fixed = if f.fixed_in, do: " — fixed in #{f.fixed_in}", else: "" + title = if f.title, do: " — #{escape_md(f.title)}", else: "" + + "**#{sev}**#{pkg}#{id}#{fixed}#{title}" + end + + defp render_entry(%{} = e) do + sev = e.severity |> to_string() |> String.upcase() + pkg = if e.package, do: " `#{e.package}#{ver_suffix(e.version)}`", else: "" + id = if e.id, do: " #{link_id(e.id, e.url)}", else: "" + title = if e.title, do: " — #{escape_md(e.title)}", else: "" + + "**#{sev}**#{pkg}#{id}#{title}" + end + + defp ver_suffix(nil), do: "" + defp ver_suffix(""), do: "" + defp ver_suffix(v), do: "@#{v}" + + defp link_id(id, nil), do: "[#{id}]" + defp link_id(id, ""), do: "[#{id}]" + defp link_id(id, url), do: "[`#{id}`](#{url})" + + defp age_suffix(nil, _now), do: "" + + defp age_suffix(%DateTime{} = first, %DateTime{} = now) do + days = DateTime.diff(now, first, :second) |> div(86_400) + + cond do + days <= 0 -> "" + days == 1 -> " _(first seen 1 day ago)_" + true -> " _(first seen #{days} days ago)_" + end + end + + defp age_suffix(_, _), do: "" + + defp severity_rank(:critical), do: 0 + defp severity_rank(:high), do: 1 + defp severity_rank(:medium), do: 2 + defp severity_rank(:low), do: 3 + defp severity_rank(_), do: 4 + + defp escape_md(s) when is_binary(s), do: String.replace(s, "|", "\\|") +end diff --git a/lib/mob_dev/security_scan/layer.ex b/lib/mob_dev/security_scan/layer.ex new file mode 100644 index 0000000..f605709 --- /dev/null +++ b/lib/mob_dev/security_scan/layer.ex @@ -0,0 +1,23 @@ +defmodule MobDev.SecurityScan.Layer do + @moduledoc """ + Behaviour every scan layer implements. + + A layer's job is to produce a `LayerResult` for one slice of the + attack surface (Hex deps, Gradle deps, bundled OpenSSL, ...). + Layers must never raise; failures are reported as + `%LayerResult{status: :error, error: "..."}` so the rest of the + scan continues. + + A layer is responsible for deciding whether its surface area + exists in the current project (e.g. the Gradle layer returns + `:not_applicable` when there's no `android/` directory). The + runner does not gate layers on project shape. + """ + + alias MobDev.SecurityScan.LayerResult + + @type opts :: keyword() + + @callback name() :: atom() + @callback run(opts()) :: LayerResult.t() +end diff --git a/lib/mob_dev/security_scan/layer_result.ex b/lib/mob_dev/security_scan/layer_result.ex new file mode 100644 index 0000000..fb06daa --- /dev/null +++ b/lib/mob_dev/security_scan/layer_result.ex @@ -0,0 +1,43 @@ +defmodule MobDev.SecurityScan.LayerResult do + @moduledoc """ + Result of running a single scan layer (Hex deps, Gradle deps, + bundled runtime, C source, etc). + + `status` is one of: + + * `:ok` — layer ran successfully (findings may still be empty) + * `:tool_missing` — a required external scanner isn't installed; + the layer was skipped with a warning + * `:not_applicable` — the surface area doesn't exist in this project + (e.g. no `android/` directory) + * `:skipped` — the user passed `--skip <name>` + * `:error` — the layer failed unexpectedly; see `:error` field + + Layers always return a LayerResult; they never raise. Callers + decide how to surface tool-missing or error states based on + whether the scan is in `--strict` mode. + """ + + alias MobDev.SecurityScan.Finding + + @type status :: :ok | :tool_missing | :not_applicable | :skipped | :error + + @type t :: %__MODULE__{ + name: atom(), + status: status(), + findings: [Finding.t()], + tools_used: [String.t()], + duration_ms: non_neg_integer() | nil, + error: String.t() | nil, + notes: [String.t()] + } + + @derive Jason.Encoder + defstruct name: nil, + status: :ok, + findings: [], + tools_used: [], + duration_ms: nil, + error: nil, + notes: [] +end diff --git a/lib/mob_dev/security_scan/layers/bundled_runtime.ex b/lib/mob_dev/security_scan/layers/bundled_runtime.ex new file mode 100644 index 0000000..09f2bf2 --- /dev/null +++ b/lib/mob_dev/security_scan/layers/bundled_runtime.ex @@ -0,0 +1,238 @@ +defmodule MobDev.SecurityScan.Layers.BundledRuntime do + @moduledoc """ + Audits the OpenSSL, ERTS, Elixir, exqlite, and SQLite versions + baked into Mob's pre-built OTP tarballs and into the project's + `deps/exqlite/c_src/sqlite3.c`. + + ## Why this layer exists + + Generic dependency scanners (`mix_audit`, `osv-scanner`, + Aqua/Snyk/Trivy) all assume *dynamic* linking — they look at lockfiles + and don't see versions baked into static archives. Mob ships + `libcrypto.a` (OpenSSL), `libbeam.a` (ERTS), the entire SQLite + amalgamation, and a frozen Elixir stdlib *inside* the OTP tarball + that gets copied into every app binary. Nothing in your `mix.lock` + reveals these versions. + + This layer looks inside. + + ## What it does + + 1. **Fingerprint** — locate cached tarballs at `~/.mob/cache/otp-*-{hash}/`, + extract real versions of OpenSSL, ERTS, Elixir, and the bundled + exqlite BEAMs. + + 2. **Drift detection** — compare fingerprints against the + `BundledVersions` manifest. Any mismatch is a `:high` finding — + it means the manifest is lying about what shipped, which is the + exact failure mode the manifest exists to prevent. + + 3. **SQLite fingerprint** — read `deps/exqlite/c_src/sqlite3.c` from + the scanned project and extract `SQLITE_VERSION`. SQLite isn't + in the OTP tarball; it's compiled per-app via exqlite. + + 4. **Version transparency** — emit informational notes documenting + every detected version with a pointer to its upstream advisory + page. We don't pretend to do live CVE lookups for OpenSSL/SQLite + — there's no reliable machine-readable feed at this writing + (OpenSSL retired its JSON feed; OSV.dev doesn't cover native libs). + The notes give the user everything they need to verify manually. + + Hex-ecosystem CVEs (including exqlite the BEAM package) are handled + by `hex_deps`, not duplicated here. + """ + + @behaviour MobDev.SecurityScan.Layer + + alias MobDev.SecurityScan.{BundledVersions, Finding, LayerResult} + alias MobDev.SecurityScan.BundledRuntime.Fingerprint + + @impl true + def name, do: :bundled_runtime + + @impl true + def run(opts) do + project_root = Keyword.get(opts, :project_root, File.cwd!()) + cache_dir_opts = if dir = Keyword.get(opts, :cache_dir), do: [cache_dir: dir], else: [] + + tarballs = Fingerprint.locate_cached_tarballs(cache_dir_opts) + manifest = safe_load_manifest() + + {drift_findings, version_notes, status, error} = + analyze(tarballs, manifest, project_root) + + %LayerResult{ + name: :bundled_runtime, + status: status, + findings: drift_findings, + tools_used: ["BundledVersions manifest", "fingerprint"], + notes: version_notes, + error: error + } + end + + defp safe_load_manifest do + {:ok, BundledVersions.load()} + rescue + e -> {:error, Exception.message(e)} + end + + defp analyze([], _manifest, project_root) do + sqlite_notes = sqlite_notes(project_root) + + notes = + [ + "no cached OTP tarballs found at ~/.mob/cache/", + "run `mix mob.deploy --native` from a Mob app to populate the cache" + ] ++ sqlite_notes + + {[], notes, :not_applicable, nil} + end + + defp analyze(_tarballs, {:error, reason}, _project_root) do + {[], [], :error, "bundled-versions manifest failed to load: #{reason}"} + end + + defp analyze(tarballs, {:ok, manifest}, project_root) do + {drift_findings, per_tarball_notes} = check_tarballs(tarballs, manifest) + sqlite_notes = sqlite_notes(project_root) + upstream_notes = upstream_pointer_notes(manifest) + + notes = + ["scanned #{length(tarballs)} cached OTP tarball(s)"] ++ + per_tarball_notes ++ upstream_notes ++ sqlite_notes + + {drift_findings, notes, :ok, nil} + end + + defp check_tarballs(tarballs, manifest) do + Enum.reduce(tarballs, {[], []}, fn tb, {findings_acc, notes_acc} -> + versions = Fingerprint.fingerprint_tarball(tb.path) + {findings, note} = compare_to_manifest(tb, versions, manifest) + {findings_acc ++ findings, notes_acc ++ [note]} + end) + end + + defp compare_to_manifest(tb, versions, manifest) do + case Map.fetch(manifest.bundles, tb.hash) do + {:ok, bundle} -> + check_bundle(tb, versions, bundle) + + :error -> + # Tarball on disk has an unknown hash — could be from an + # older Mob release. Inform but don't error. + {[], + " · #{tb.platform} (#{tb.hash}): hash not in manifest — older or unpublished tarball"} + end + end + + defp expected_for(bundle, platform, key) do + overrides = bundle |> Map.get(:per_platform, %{}) |> Map.get(platform, %{}) + + if Map.has_key?(overrides, key) do + Map.fetch!(overrides, key) + else + Map.get(bundle, key) + end + end + + defp check_bundle(tb, versions, bundle) do + fields = [ + {:erts, "ERTS"}, + {:elixir, "Elixir"}, + {:openssl, "OpenSSL"}, + {:exqlite_beam, "exqlite (BEAM)"} + ] + + drifts = + Enum.flat_map(fields, fn {key, label} -> + actual = Map.get(versions, key) + expected = expected_for(bundle, tb.platform, key) + compare_field(tb, label, key, expected, actual) + end) + + summary = + " · #{tb.platform} (#{tb.hash}): " <> + "ERTS #{versions.erts || "?"}, " <> + "Elixir #{versions.elixir || "?"}, " <> + "OpenSSL #{versions.openssl || "?"}, " <> + "exqlite #{versions.exqlite_beam || "n/a"}" <> + if drifts == [], do: " ✓", else: " ✗ DRIFT (#{length(drifts)})" + + {drifts, summary} + end + + # Both expected and actual nil → manifest declares "this platform + # doesn't ship this artifact" and the binary agrees. Not drift. + defp compare_field(_tb, _label, _key, nil, nil), do: [] + defp compare_field(_tb, _label, _key, expected, actual) when expected == actual, do: [] + + defp compare_field(tb, label, key, expected, nil) do + [ + %Finding{ + id: "MOB-DRIFT-#{tb.platform}-#{key}", + severity: :high, + package: "mob/otp-tarball", + version: "#{tb.platform}@#{tb.hash}", + title: "Manifest lists #{label} #{expected} but binary has no detectable version", + description: + "Fingerprinting the tarball at #{tb.path} could not extract a #{label} version. " <> + "Either the binary was built without the expected metadata, or fingerprinting needs to be updated.", + url: "https://github.com/genericjam/mob_dev/blob/main/priv/security/bundled_versions.exs", + source: :bundled_runtime, + layer: :bundled_runtime + } + ] + end + + defp compare_field(tb, label, key, expected, actual) do + [ + %Finding{ + id: "MOB-DRIFT-#{tb.platform}-#{key}", + severity: :high, + package: "mob/otp-tarball", + version: "#{tb.platform}@#{tb.hash}", + fixed_in: nil, + title: "Bundled-versions drift: #{label} manifest=#{expected} binary=#{actual}", + description: + "Manifest at priv/security/bundled_versions.exs claims #{label} #{expected} " <> + "but the actual binary contains #{actual}. " <> + "Update the manifest to match the binary, or rebuild the tarball.", + url: "https://github.com/genericjam/mob_dev/blob/main/priv/security/bundled_versions.exs", + source: :bundled_runtime, + layer: :bundled_runtime + } + ] + end + + defp upstream_pointer_notes(manifest) do + bundle = Map.get(manifest.bundles, manifest.active_hash, %{}) + + [ + "── version pointers (verify advisories upstream) ──", + " OpenSSL #{bundle[:openssl] || "?"} (#{bundle[:openssl_release_date] || "?"}) — https://openssl-library.org/news/vulnerabilities/", + " Erlang/OTP #{bundle[:otp_release] || "?"} (ERTS #{bundle[:erts] || "?"}) — https://github.com/erlef/security-wg/tree/main/advisories", + " Elixir #{bundle[:elixir] || "?"} — https://github.com/elixir-lang/elixir/security/advisories", + " exqlite (BEAM) #{bundle[:exqlite_beam] || "?"} — covered by :hex_deps layer" + ] + end + + defp sqlite_notes(project_root) do + case Fingerprint.fingerprint_sqlite(project_root) do + {:ok, version} -> + [ + " SQLite #{version} (from #{Path.relative_to_cwd(Path.join([project_root, "deps/exqlite/c_src/sqlite3.c"]))}) — https://www.sqlite.org/cves.html" + ] + + {:error, :not_found} -> + [ + " SQLite: no deps/exqlite/c_src/sqlite3.c — exqlite not in this project's deps tree" + ] + + {:error, :unparseable} -> + [ + " SQLite: deps/exqlite/c_src/sqlite3.c present but version macro could not be parsed" + ] + end + end +end diff --git a/lib/mob_dev/security_scan/layers/c_source.ex b/lib/mob_dev/security_scan/layers/c_source.ex new file mode 100644 index 0000000..43e6cee --- /dev/null +++ b/lib/mob_dev/security_scan/layers/c_source.ex @@ -0,0 +1,276 @@ +defmodule MobDev.SecurityScan.Layers.CSource do + @moduledoc """ + Static analysis of every C source file Mob actually compiles into + the app: Mob's own NIF shims (`mob/android/jni/`, `mob/ios/`), the + exqlite NIF wrapper (`deps/exqlite/c_src/sqlite3_nif.c`), and any + C the project itself ships. + + Two tools, run in parallel: + + * [`semgrep`](https://semgrep.dev/) with the community `p/c` + ruleset — catches unsafe API use, format-string bugs, + memory-safety patterns, and a few CVE-derived rules. + + * [`flawfinder`](https://dwheeler.com/flawfinder/) — pattern-based + audit with a long history; catches things semgrep doesn't + (banned APIs, risky `gets`/`strcpy` use). + + ## What's deliberately excluded + + `deps/exqlite/c_src/sqlite3.c` — SQLite's amalgamated source is + ~9MB and ~250k LOC. It's battle-tested, ships in millions of + apps, and would generate thousands of low-value findings if scanned + with general C rules. SQLite-specific CVE coverage lives in the + `:bundled_runtime` layer (which fingerprints the version). + + ## Soft-degradation + + If either scanner is missing, the layer reports `:tool_missing` + rather than failing. Install with `brew install semgrep flawfinder` + on macOS. + """ + + @behaviour MobDev.SecurityScan.Layer + + alias MobDev.SecurityScan.{Finding, LayerResult} + + @semgrep_binary "semgrep" + @flawfinder_binary "flawfinder" + + @impl true + def name, do: :c_source + + @impl true + def run(opts) do + project_root = Keyword.get(opts, :project_root, File.cwd!()) + targets = c_targets(project_root) + + if targets == [] do + %LayerResult{ + name: :c_source, + status: :not_applicable, + notes: ["no C source under project — nothing to scan"] + } + else + run_scanners(targets, opts) + end + end + + defp c_targets(project_root) do + # Only include directories that have at least one .c/.h/.m file. + candidates = + [ + Path.join([project_root, "deps", "mob", "android", "jni"]), + Path.join([project_root, "deps", "mob", "ios"]), + Path.join([project_root, "android", "app", "src", "main", "jni"]), + Path.join([project_root, "ios"]), + # Project-local C, if any + Path.join([project_root, "c_src"]), + # exqlite NIF wrapper only (sqlite3.c amalgamation excluded — see moduledoc) + Path.join([project_root, "deps", "exqlite", "c_src", "sqlite3_nif.c"]) + ] + + candidates + |> Enum.filter(&File.exists?/1) + |> Enum.filter(&has_c_source?/1) + end + + defp has_c_source?(path) do + cond do + File.regular?(path) -> Path.extname(path) in [".c", ".h", ".m"] + File.dir?(path) -> Path.wildcard(Path.join(path, "**/*.{c,h,m}")) != [] + true -> false + end + end + + defp run_scanners(targets, opts) do + semgrep_runner = Keyword.get(opts, :semgrep_runner, &default_semgrep_runner/1) + flawfinder_runner = Keyword.get(opts, :flawfinder_runner, &default_flawfinder_runner/1) + + {semgrep_findings, semgrep_notes, semgrep_tools} = run_semgrep(targets, semgrep_runner) + + {flawfinder_findings, flawfinder_notes, flawfinder_tools} = + run_flawfinder(targets, flawfinder_runner) + + findings = semgrep_findings ++ flawfinder_findings + + %LayerResult{ + name: :c_source, + status: :ok, + findings: findings, + tools_used: semgrep_tools ++ flawfinder_tools, + notes: ["scanned #{length(targets)} target(s)"] ++ semgrep_notes ++ flawfinder_notes + } + end + + ## ── semgrep ──────────────────────────────────────────────────────────────── + + defp run_semgrep(targets, runner) do + case runner.(targets) do + {:ok, json} -> + findings = parse_semgrep(json) + {findings, ["semgrep: #{length(findings)} finding(s)"], ["semgrep"]} + + {:error, :not_installed} -> + {[], ["semgrep not installed (skipped); install: brew install semgrep"], []} + + {:error, reason} -> + {[], ["semgrep failed: #{reason}"], ["semgrep"]} + end + end + + defp default_semgrep_runner(targets) do + if System.find_executable(@semgrep_binary) == nil do + {:error, :not_installed} + else + args = ["--config=p/c", "--json", "--quiet"] ++ targets + + case System.cmd(@semgrep_binary, args, stderr_to_stdout: false) do + # 0 = no findings, 1 = findings present + {output, code} when code in [0, 1] -> {:ok, output} + {output, code} -> {:error, "exit #{code}: #{trim(output)}"} + end + end + rescue + e -> {:error, Exception.message(e)} + end + + @doc false + @spec parse_semgrep(String.t()) :: [Finding.t()] + def parse_semgrep(json) when is_binary(json) do + case Jason.decode(json) do + {:ok, %{"results" => results}} when is_list(results) -> + Enum.map(results, &semgrep_to_finding/1) + + _ -> + [] + end + end + + defp semgrep_to_finding(r) do + severity = semgrep_severity(get_in(r, ["extra", "severity"])) + rule = r["check_id"] || "semgrep" + + %Finding{ + id: rule, + severity: severity, + package: r["path"], + version: location_string(r), + title: get_in(r, ["extra", "message"]) |> truncate(120), + description: get_in(r, ["extra", "message"]), + url: get_in(r, ["extra", "metadata", "source"]) || "https://semgrep.dev/r/#{rule}", + source: :semgrep, + layer: :c_source + } + end + + defp location_string(%{"start" => %{"line" => line}}), do: "line #{line}" + defp location_string(_), do: nil + + defp semgrep_severity(nil), do: :unknown + + defp semgrep_severity(s) when is_binary(s) do + case String.upcase(s) do + "ERROR" -> :high + "CRITICAL" -> :critical + "WARNING" -> :medium + "INFO" -> :low + _ -> :unknown + end + end + + defp semgrep_severity(_), do: :unknown + + ## ── flawfinder ───────────────────────────────────────────────────────────── + + defp run_flawfinder(targets, runner) do + case runner.(targets) do + {:ok, csv} -> + findings = parse_flawfinder(csv) + {findings, ["flawfinder: #{length(findings)} finding(s)"], ["flawfinder"]} + + {:error, :not_installed} -> + {[], ["flawfinder not installed (skipped); install: brew install flawfinder"], []} + + {:error, reason} -> + {[], ["flawfinder failed: #{reason}"], ["flawfinder"]} + end + end + + defp default_flawfinder_runner(targets) do + if System.find_executable(@flawfinder_binary) == nil do + {:error, :not_installed} + else + args = ["--csv", "--quiet"] ++ targets + + case System.cmd(@flawfinder_binary, args, stderr_to_stdout: false) do + {output, 0} -> {:ok, output} + {output, code} -> {:error, "exit #{code}: #{trim(output)}"} + end + end + rescue + e -> {:error, Exception.message(e)} + end + + @doc false + @spec parse_flawfinder(String.t()) :: [Finding.t()] + def parse_flawfinder(csv) when is_binary(csv) do + csv + |> String.split("\n", trim: true) + |> Enum.drop(1) + |> Enum.flat_map(&flawfinder_row_to_finding/1) + end + + defp flawfinder_row_to_finding(line) do + case String.split(line, ",") do + [file, line_no, _col, level, _category, name, _warning, _suggestion | rest] -> + # rest holds the description, possibly containing commas + message = + rest |> Enum.join(",") |> String.trim_leading("\"") |> String.trim_trailing("\"") + + level_int = parse_int(level) + + [ + %Finding{ + id: "flawfinder/#{name}", + severity: flawfinder_severity(level_int), + package: file, + version: "line #{line_no}", + title: "#{name}: #{truncate(message, 120)}", + description: message, + url: "https://dwheeler.com/flawfinder/", + source: :flawfinder, + layer: :c_source + } + ] + + _ -> + [] + end + end + + defp parse_int(s) when is_binary(s) do + case Integer.parse(s) do + {n, _} -> n + :error -> 0 + end + end + + # Flawfinder levels 0–5; 4–5 are "very risky", 3 is risky, 1–2 is medium-low. + defp flawfinder_severity(level) when level >= 5, do: :critical + defp flawfinder_severity(4), do: :high + defp flawfinder_severity(3), do: :medium + defp flawfinder_severity(level) when level >= 1, do: :low + defp flawfinder_severity(_), do: :unknown + + ## ── helpers ──────────────────────────────────────────────────────────────── + + defp truncate(nil, _), do: nil + + defp truncate(s, n) when is_binary(s) do + if String.length(s) > n, do: String.slice(s, 0, n) <> "…", else: s + end + + defp trim(s) when is_binary(s), do: s |> String.trim() |> String.slice(0, 200) + defp trim(s), do: inspect(s) +end diff --git a/lib/mob_dev/security_scan/layers/gradle_deps.ex b/lib/mob_dev/security_scan/layers/gradle_deps.ex new file mode 100644 index 0000000..72a6342 --- /dev/null +++ b/lib/mob_dev/security_scan/layers/gradle_deps.ex @@ -0,0 +1,127 @@ +defmodule MobDev.SecurityScan.Layers.GradleDeps do + @moduledoc """ + Audits Android dependencies via `osv-scanner` recursively over + the `android/` directory. + + ## What gets scanned + + `osv-scanner` understands these Android-relevant manifests: + + * `gradle.lockfile` — the result of Gradle's [dependency locking][1]. + Captures the exact transitive dep tree. + * `buildscript-gradle.lockfile` — same idea, for buildscript classpath. + * `pom.xml` — Maven, occasionally appears in Gradle projects. + + Mob's Android template does NOT enable dependency locking by default, + so a fresh `mix mob.new` app will report `:not_applicable` for this + layer until the user opts in. The layer's notes spell out the + remediation. + + ## Enabling Gradle dependency locking + + // android/build.gradle + allprojects { + configurations.all { + resolutionStrategy.activateDependencyLocking() + } + } + + // android/app/build.gradle + dependencyLocking { + lockAllConfigurations() + } + + Then `cd android && ./gradlew :app:dependencies --write-locks` + creates `gradle.lockfile`. + + [1]: https://docs.gradle.org/current/userguide/dependency_locking.html + """ + + @behaviour MobDev.SecurityScan.Layer + + alias MobDev.SecurityScan.{LayerResult, OsvScanner} + + @impl true + def name, do: :gradle_deps + + @impl true + def run(opts) do + project_root = Keyword.get(opts, :project_root, File.cwd!()) + android_dir = Path.join(project_root, "android") + + if File.dir?(android_dir) do + run_scan(android_dir, opts) + else + %LayerResult{ + name: :gradle_deps, + status: :not_applicable, + notes: ["no android/ directory at #{android_dir}"] + } + end + end + + defp run_scan(android_dir, opts) do + osv_scan = Keyword.get(opts, :osv_scan_fn, &OsvScanner.scan/3) + + case osv_scan.({:directory, android_dir}, :gradle_deps, []) do + {:ok, findings} when findings == [] -> + %LayerResult{ + name: :gradle_deps, + status: :ok, + findings: [], + tools_used: ["osv-scanner"], + notes: notes_with_lockfile_guidance(android_dir) + } + + {:ok, findings} -> + %LayerResult{ + name: :gradle_deps, + status: :ok, + findings: findings, + tools_used: ["osv-scanner"], + notes: ["osv-scanner: #{length(findings)} finding(s) under #{android_dir}"] + } + + {:error, :not_installed} -> + %LayerResult{ + name: :gradle_deps, + status: :tool_missing, + notes: [ + "osv-scanner not installed — install: brew install osv-scanner", + "without it Android Gradle deps are not audited" + ] + } + + {:error, {:scan_failed, reason}} -> + %LayerResult{ + name: :gradle_deps, + status: :error, + tools_used: ["osv-scanner"], + error: "osv-scanner failed: #{reason}" + } + + {:error, {:not_found, path}} -> + %LayerResult{ + name: :gradle_deps, + status: :not_applicable, + notes: ["target path missing: #{path}"] + } + end + end + + defp notes_with_lockfile_guidance(android_dir) do + lockfile = Path.join([android_dir, "app", "gradle.lockfile"]) + + base = "osv-scanner ran cleanly under #{android_dir} (0 findings)" + + if File.exists?(lockfile) do + [base, "scanned manifests including gradle.lockfile"] + else + [ + base, + "NOTE: no gradle.lockfile present — only declared deps in build.gradle were checked", + "for transitive coverage, enable Gradle dependency locking and run `./gradlew :app:dependencies --write-locks`" + ] + end + end +end diff --git a/lib/mob_dev/security_scan/layers/hex_deps.ex b/lib/mob_dev/security_scan/layers/hex_deps.ex new file mode 100644 index 0000000..3edf850 --- /dev/null +++ b/lib/mob_dev/security_scan/layers/hex_deps.ex @@ -0,0 +1,157 @@ +defmodule MobDev.SecurityScan.Layers.HexDeps do + @moduledoc """ + Audits Hex dependencies in `mix.lock` against two complementary + advisory sources: + + 1. [`mix_audit`](https://hexdocs.pm/mix_audit/) — Mirego's curated + `elixir-security-advisories` repo, cloned into `~/.local/share/`. + Hex-ecosystem-only, hand-reviewed entries. + + 2. [`osv-scanner`](https://google.github.io/osv-scanner/) — Google's + OSV.dev aggregator, which pulls the Erlef CNA feed alongside many + other ecosystems. Tends to surface CVE-numbered advisories that + Mirego hasn't ingested yet. + + Running both is deliberate. They miss different things, and the + delta between them is what catches advisories the curated database + hasn't picked up. Findings dedupe on `(advisory_id, package, version)` + with osv-scanner winning on ties (CVSS-derived severity is the more + standard signal). + + If `osv-scanner` isn't installed the layer still runs successfully + on `mix_audit` alone — the note records that the second source was + unavailable so the report is honest about coverage. + """ + + @behaviour MobDev.SecurityScan.Layer + + alias MobDev.SecurityScan.{Finding, LayerResult, OsvScanner} + + @impl true + def name, do: :hex_deps + + @impl true + def run(opts) do + path = Keyword.get(opts, :project_root, File.cwd!()) + lockfile = Path.join(path, "mix.lock") + + if File.exists?(lockfile) do + run_audit(path, lockfile, opts) + else + %LayerResult{ + name: :hex_deps, + status: :not_applicable, + notes: ["no mix.lock at #{lockfile}"] + } + end + end + + defp run_audit(path, lockfile, opts) do + deps = MixAudit.Project.dependencies(path) + {audit_findings, audit_notes, audit_status} = run_mix_audit(deps, opts) + {osv_findings, osv_notes, osv_tools} = run_osv(lockfile, opts) + + findings = dedupe(osv_findings ++ audit_findings) + base_note = "audited #{length(deps)} hex deps from #{lockfile}" + + %LayerResult{ + name: :hex_deps, + status: audit_status, + findings: findings, + tools_used: ["mix_audit"] ++ osv_tools, + notes: [base_note] ++ audit_notes ++ osv_notes + } + end + + defp run_mix_audit(deps, opts) do + advisories_fn = Keyword.get(opts, :advisories_fn, &MixAudit.Repo.advisories/0) + + case fetch_advisories(advisories_fn) do + {:ok, advisories} -> + grouped = Enum.group_by(advisories, & &1.package) + report = MixAudit.Audit.report(deps, grouped) + findings = Enum.map(report.vulnerabilities, &to_finding/1) + {findings, ["mix_audit: #{length(findings)} finding(s)"], :ok} + + {:error, reason} -> + {[], + [ + "mix_audit advisory db unavailable: #{reason}", + "first run clones github.com/mirego/elixir-security-advisories" + ], :tool_missing} + end + end + + defp run_osv(lockfile, opts) do + osv_scan = Keyword.get(opts, :osv_scan_fn, &OsvScanner.scan/3) + + case osv_scan.({:lockfile, lockfile}, :hex_deps, []) do + {:ok, findings} -> + {findings, ["osv-scanner: #{length(findings)} finding(s)"], ["osv-scanner"]} + + {:error, :not_installed} -> + {[], ["osv-scanner not installed (skipped); install: brew install osv-scanner"], []} + + {:error, {:not_found, _}} -> + # mix.lock missing was already screened above; if osv says not_found, + # treat as transient and skip without panic. + {[], ["osv-scanner: target unavailable"], []} + + {:error, {:scan_failed, reason}} -> + {[], ["osv-scanner failed: #{reason}"], ["osv-scanner"]} + end + end + + defp dedupe(findings) do + Enum.uniq_by(findings, &Finding.dedupe_key/1) + end + + defp fetch_advisories(advisories_fn) do + {:ok, advisories_fn.()} + rescue + e -> {:error, Exception.message(e)} + catch + kind, reason -> {:error, "#{kind}: #{inspect(reason)}"} + end + + defp to_finding(%MixAudit.Vulnerability{advisory: advisory, dependency: dep}) do + %Finding{ + id: advisory.id, + severity: normalize_severity(advisory.severity), + package: dep.package, + version: dep.version, + fixed_in: first_patched(advisory.first_patched_versions), + title: advisory.title, + description: advisory.description, + url: advisory.url, + source: :mix_audit, + layer: :hex_deps + } + end + + defp first_patched(nil), do: nil + defp first_patched([]), do: nil + defp first_patched([first | _]) when is_binary(first), do: first + defp first_patched(other) when is_binary(other), do: other + defp first_patched(_), do: nil + + # Mirego advisory severities are free-form strings ("critical", "high", + # "moderate", etc.) and many entries simply omit the field. Normalize + # to our atom scale. + defp normalize_severity(nil), do: :unknown + defp normalize_severity(""), do: :unknown + + defp normalize_severity(severity) when is_binary(severity) do + case severity |> String.trim() |> String.downcase() do + "critical" -> :critical + "high" -> :high + "important" -> :high + "medium" -> :medium + "moderate" -> :medium + "low" -> :low + _ -> :unknown + end + end + + defp normalize_severity(_), do: :unknown +end diff --git a/lib/mob_dev/security_scan/layers/kotlin_source.ex b/lib/mob_dev/security_scan/layers/kotlin_source.ex new file mode 100644 index 0000000..7576ec9 --- /dev/null +++ b/lib/mob_dev/security_scan/layers/kotlin_source.ex @@ -0,0 +1,229 @@ +defmodule MobDev.SecurityScan.Layers.KotlinSource do + @moduledoc """ + Static analysis of Kotlin/Java source under `android/app/src/main/` + using [detekt](https://detekt.dev/). + + Detekt is the de-facto Kotlin static analyzer. We invoke its CLI + with `--report json:<out>` and parse the SARIF-like output. + + Coverage notes: + + * The default detekt ruleset emphasizes code quality more than + security per se — but several built-in rules do cover concrete + vulnerability classes (`HardCodedDispatcher`, unsafe-call + patterns, regex DoS). + * For deeper security coverage, projects can configure a + `detekt-security.yml` and pass it via `MOB_DETEKT_CONFIG=path` + (read by `default_runner/1`). + + Soft-degrades to `:tool_missing` when detekt isn't installed. + Install on macOS with `brew install detekt`. + """ + + @behaviour MobDev.SecurityScan.Layer + + alias MobDev.SecurityScan.{Finding, LayerResult} + + @binary "detekt" + + @impl true + def name, do: :kotlin_source + + @impl true + def run(opts) do + project_root = Keyword.get(opts, :project_root, File.cwd!()) + target = locate_kotlin_target(project_root) + + cond do + target == nil -> + %LayerResult{ + name: :kotlin_source, + status: :not_applicable, + notes: ["no Kotlin/Java source under android/app/src/main/"] + } + + true -> + runner = Keyword.get(opts, :runner, &default_runner/1) + run_scan(target, runner) + end + end + + defp locate_kotlin_target(project_root) do + candidates = [ + Path.join([project_root, "android", "app", "src", "main", "java"]), + Path.join([project_root, "android", "app", "src", "main", "kotlin"]) + ] + + Enum.find(candidates, &has_jvm_source?/1) + end + + defp has_jvm_source?(path) do + File.dir?(path) and Path.wildcard(Path.join(path, "**/*.{kt,java}")) != [] + end + + defp run_scan(target, runner) do + case runner.(target) do + {:ok, json} -> + findings = parse(json) + + %LayerResult{ + name: :kotlin_source, + status: :ok, + findings: findings, + tools_used: ["detekt"], + notes: ["scanned #{target}", "detekt: #{length(findings)} finding(s)"] + } + + {:error, :not_installed} -> + %LayerResult{ + name: :kotlin_source, + status: :tool_missing, + notes: [ + "detekt not installed; install: brew install detekt", + "without it Kotlin/Java code is not statically analyzed" + ] + } + + {:error, reason} -> + %LayerResult{ + name: :kotlin_source, + status: :error, + tools_used: ["detekt"], + error: "detekt failed: #{reason}" + } + end + end + + defp default_runner(target) do + if System.find_executable(@binary) == nil do + {:error, :not_installed} + else + report_path = + Path.join( + System.tmp_dir!(), + "mob_security_scan_detekt_#{System.unique_integer([:positive])}.json" + ) + + args = ["--input", target, "--report", "json:#{report_path}"] + args = maybe_add_config(args) + + result = System.cmd(@binary, args, stderr_to_stdout: true) + + finally = + case result do + # 0 = no findings, 1 or 2 = findings/build issues but JSON written + {_output, code} when code in [0, 1, 2] -> read_report(report_path) + {output, code} -> {:error, "exit #{code}: #{trim(output)}"} + end + + File.rm(report_path) + finally + end + rescue + e -> {:error, Exception.message(e)} + end + + defp maybe_add_config(args) do + case System.get_env("MOB_DETEKT_CONFIG") do + nil -> args + "" -> args + path -> args ++ ["--config", path] + end + end + + defp read_report(path) do + case File.read(path) do + {:ok, body} -> {:ok, body} + {:error, _} -> {:ok, "{}"} + end + end + + @doc false + @spec parse(String.t()) :: [Finding.t()] + def parse(json) when is_binary(json) do + case Jason.decode(json) do + {:ok, %{"runs" => runs}} when is_list(runs) -> + Enum.flat_map(runs, &parse_run/1) + + _ -> + [] + end + end + + defp parse_run(%{"results" => results}) when is_list(results) do + Enum.map(results, &result_to_finding/1) + end + + defp parse_run(_), do: [] + + defp result_to_finding(r) do + rule = r["ruleId"] || "detekt" + + location = + r + |> Map.get("locations", []) + |> List.first() + |> get_in([ + Access.key("physicalLocation", %{}), + Access.key("artifactLocation", %{}), + Access.key("uri") + ]) + + line = + r + |> Map.get("locations", []) + |> List.first() + |> get_in([ + Access.key("physicalLocation", %{}), + Access.key("region", %{}), + Access.key("startLine") + ]) + + severity = detekt_severity(r["level"]) + message = get_in(r, ["message", "text"]) + + %Finding{ + id: rule, + severity: severity, + package: location, + version: line && "line #{line}", + title: truncate(message, 120), + description: message, + url: "https://detekt.dev/docs/rules/" <> rule_doc_path(rule), + source: :detekt, + layer: :kotlin_source + } + end + + defp rule_doc_path(rule) do + rule + |> String.split(".", parts: 2) + |> case do + [_only_one] -> rule |> String.downcase() + [category, _name] -> String.downcase(category) <> "/" <> rule + end + end + + defp detekt_severity(nil), do: :unknown + + defp detekt_severity(level) when is_binary(level) do + case String.downcase(level) do + "error" -> :high + "warning" -> :medium + "note" -> :low + "info" -> :low + _ -> :unknown + end + end + + defp detekt_severity(_), do: :unknown + + defp truncate(nil, _), do: nil + + defp truncate(s, n) when is_binary(s) do + if String.length(s) > n, do: String.slice(s, 0, n) <> "…", else: s + end + + defp trim(s) when is_binary(s), do: s |> String.trim() |> String.slice(0, 200) + defp trim(s), do: inspect(s) +end diff --git a/lib/mob_dev/security_scan/layers/swift_deps.ex b/lib/mob_dev/security_scan/layers/swift_deps.ex new file mode 100644 index 0000000..48aa71e --- /dev/null +++ b/lib/mob_dev/security_scan/layers/swift_deps.ex @@ -0,0 +1,106 @@ +defmodule MobDev.SecurityScan.Layers.SwiftDeps do + @moduledoc """ + Audits iOS dependencies via `osv-scanner` recursively over the + `ios/` directory. + + ## What gets scanned + + `osv-scanner` understands: + + * `Package.resolved` — Swift Package Manager (when SwiftPM is used) + * `Podfile.lock` — CocoaPods + + Mob's iOS template does not depend on either by default — the iOS + bridge is built with raw `.m` / `.swift` files plus the bundled OTP + static libs (libcrypto.a, libbeam.a, etc.). Those static libs are + audited by the `:bundled_runtime` layer; this layer only covers + *application-level* iOS dependencies. + + In a stock Mob app this layer typically reports `:not_applicable`, + which is the correct signal — there's no iOS dependency manifest + to audit because the app pulls nothing from CocoaPods/SwiftPM. + """ + + @behaviour MobDev.SecurityScan.Layer + + alias MobDev.SecurityScan.{LayerResult, OsvScanner} + + @impl true + def name, do: :swift_deps + + @impl true + def run(opts) do + project_root = Keyword.get(opts, :project_root, File.cwd!()) + ios_dir = Path.join(project_root, "ios") + + cond do + not File.dir?(ios_dir) -> + %LayerResult{ + name: :swift_deps, + status: :not_applicable, + notes: ["no ios/ directory at #{ios_dir}"] + } + + not has_swift_manifest?(ios_dir) -> + %LayerResult{ + name: :swift_deps, + status: :not_applicable, + notes: [ + "no Package.resolved or Podfile.lock under #{ios_dir}", + "Mob iOS apps typically have neither — bundled OpenSSL/SQLite are audited by :bundled_runtime" + ] + } + + true -> + run_scan(ios_dir, opts) + end + end + + defp has_swift_manifest?(ios_dir) do + paths = [ + Path.join([ios_dir, "Package.resolved"]), + Path.join([ios_dir, "**/Package.resolved"]), + Path.join([ios_dir, "Podfile.lock"]), + Path.join([ios_dir, "**/Podfile.lock"]) + ] + + Enum.any?(paths, &(Path.wildcard(&1) != [])) + end + + defp run_scan(ios_dir, opts) do + osv_scan = Keyword.get(opts, :osv_scan_fn, &OsvScanner.scan/3) + + case osv_scan.({:directory, ios_dir}, :swift_deps, []) do + {:ok, findings} -> + %LayerResult{ + name: :swift_deps, + status: :ok, + findings: findings, + tools_used: ["osv-scanner"], + notes: ["osv-scanner: #{length(findings)} finding(s) under #{ios_dir}"] + } + + {:error, :not_installed} -> + %LayerResult{ + name: :swift_deps, + status: :tool_missing, + notes: ["osv-scanner not installed — install: brew install osv-scanner"] + } + + {:error, {:scan_failed, reason}} -> + %LayerResult{ + name: :swift_deps, + status: :error, + tools_used: ["osv-scanner"], + error: "osv-scanner failed: #{reason}" + } + + {:error, {:not_found, path}} -> + %LayerResult{ + name: :swift_deps, + status: :not_applicable, + notes: ["target path missing: #{path}"] + } + end + end +end diff --git a/lib/mob_dev/security_scan/layers/swift_source.ex b/lib/mob_dev/security_scan/layers/swift_source.ex new file mode 100644 index 0000000..3879afc --- /dev/null +++ b/lib/mob_dev/security_scan/layers/swift_source.ex @@ -0,0 +1,164 @@ +defmodule MobDev.SecurityScan.Layers.SwiftSource do + @moduledoc """ + Static analysis of Swift source under `ios/` using + [swiftlint](https://github.com/realm/SwiftLint). + + ## Why swiftlint, not `xcodebuild analyze`? + + The Clang Static Analyzer (run via `xcodebuild analyze`) is the gold + standard for Objective-C and Swift correctness checks but requires + a buildable Xcode project — i.e. a working signing identity, the + right SDK, and a `.xcodeproj` or `.xcworkspace`. That's a heavy + prerequisite for a security scan to "just work" out of the box. + + swiftlint operates directly on `.swift` files without compilation, + produces JSON output, and ships several security-relevant rules + (`force_cast`, `force_try`, `force_unwrapping`, `implicitly_unwrapped_optional`) + that flag crash-by-design patterns. It's the pragmatic Swift + counterpart to detekt. + + ## What this doesn't cover + + Mob's iOS bridge is mostly Objective-C (`.m` / `.c` files), not + Swift. swiftlint ignores those. ObjC code is covered by the + `:c_source` layer instead, which runs semgrep+flawfinder over `.m` + files alongside `.c`/`.h`. The split is unfortunate but follows + tool boundaries. + + Soft-degrades to `:tool_missing` when swiftlint isn't installed. + Install on macOS with `brew install swiftlint`. + """ + + @behaviour MobDev.SecurityScan.Layer + + alias MobDev.SecurityScan.{Finding, LayerResult} + + @binary "swiftlint" + + @impl true + def name, do: :swift_source + + @impl true + def run(opts) do + project_root = Keyword.get(opts, :project_root, File.cwd!()) + target = locate_swift_target(project_root) + + cond do + target == nil -> + %LayerResult{ + name: :swift_source, + status: :not_applicable, + notes: [ + "no .swift files under ios/ — Mob's iOS bridge is .m/.c which is covered by :c_source" + ] + } + + true -> + runner = Keyword.get(opts, :runner, &default_runner/1) + run_scan(target, runner) + end + end + + defp locate_swift_target(project_root) do + ios = Path.join(project_root, "ios") + + if File.dir?(ios) and Path.wildcard(Path.join(ios, "**/*.swift")) != [] do + ios + end + end + + defp run_scan(target, runner) do + case runner.(target) do + {:ok, json} -> + findings = parse(json) + + %LayerResult{ + name: :swift_source, + status: :ok, + findings: findings, + tools_used: ["swiftlint"], + notes: ["scanned #{target}", "swiftlint: #{length(findings)} finding(s)"] + } + + {:error, :not_installed} -> + %LayerResult{ + name: :swift_source, + status: :tool_missing, + notes: [ + "swiftlint not installed; install: brew install swiftlint", + "without it Swift code is not statically analyzed" + ] + } + + {:error, reason} -> + %LayerResult{ + name: :swift_source, + status: :error, + tools_used: ["swiftlint"], + error: "swiftlint failed: #{reason}" + } + end + end + + defp default_runner(target) do + if System.find_executable(@binary) == nil do + {:error, :not_installed} + else + args = ["lint", "--quiet", "--reporter", "json", target] + + case System.cmd(@binary, args, stderr_to_stdout: false) do + # swiftlint exits non-zero when violations exist; treat both as success. + {output, code} when code in [0, 2] -> {:ok, output} + {output, code} -> {:error, "exit #{code}: #{trim(output)}"} + end + end + rescue + e -> {:error, Exception.message(e)} + end + + @doc false + @spec parse(String.t()) :: [Finding.t()] + def parse(json) when is_binary(json) do + case Jason.decode(json) do + {:ok, results} when is_list(results) -> Enum.map(results, &result_to_finding/1) + _ -> [] + end + end + + defp result_to_finding(r) do + rule = r["rule_id"] || r["type"] || "swiftlint" + + %Finding{ + id: rule, + severity: swiftlint_severity(r["severity"]), + package: r["file"], + version: r["line"] && "line #{r["line"]}", + title: truncate(r["reason"], 120), + description: r["reason"], + url: "https://realm.github.io/SwiftLint/" <> String.replace(rule, "_", "-") <> ".html", + source: :swiftlint, + layer: :swift_source + } + end + + defp swiftlint_severity(nil), do: :unknown + + defp swiftlint_severity(s) when is_binary(s) do + case String.downcase(s) do + "error" -> :high + "warning" -> :medium + _ -> :unknown + end + end + + defp swiftlint_severity(_), do: :unknown + + defp truncate(nil, _), do: nil + + defp truncate(s, n) when is_binary(s) do + if String.length(s) > n, do: String.slice(s, 0, n) <> "…", else: s + end + + defp trim(s) when is_binary(s), do: s |> String.trim() |> String.slice(0, 200) + defp trim(s), do: inspect(s) +end diff --git a/lib/mob_dev/security_scan/osv_scanner.ex b/lib/mob_dev/security_scan/osv_scanner.ex new file mode 100644 index 0000000..02f4e20 --- /dev/null +++ b/lib/mob_dev/security_scan/osv_scanner.ex @@ -0,0 +1,105 @@ +defmodule MobDev.SecurityScan.OsvScanner do + @moduledoc """ + Wrapper around the `osv-scanner` CLI (https://google.github.io/osv-scanner/). + + `osv-scanner` queries the [OSV.dev](https://osv.dev) database, which + aggregates advisories from many ecosystems (Hex, Maven/Gradle, Swift + PM, npm, PyPI, RubyGems, ...) into a single feed. Several Mob scan + layers (`hex_deps`, `gradle_deps`, `swift_deps`) call this helper so + the binary integration lives in one place. + + All public functions are pure orchestration — no parsing logic, no + finding shape. `Parser` does the actual JSON → `Finding` translation, + which keeps the network/process side easy to mock and the parser + trivially testable with fixture JSON. + """ + + alias MobDev.SecurityScan.Finding + alias MobDev.SecurityScan.OsvScanner.Parser + + @binary "osv-scanner" + + @typedoc """ + What to scan. `{:lockfile, path}` for a single lockfile, `{:directory, path}` + for a recursive scan that finds every supported manifest under the tree. + """ + @type target :: {:lockfile, Path.t()} | {:directory, Path.t()} + + @doc "True if `osv-scanner` is on PATH." + @spec installed?() :: boolean() + def installed? do + System.find_executable(@binary) != nil + end + + @doc """ + Scan a target and return findings tagged with the given `layer`. + + Returns: + + * `{:ok, findings}` — scan completed (findings list may be empty) + * `{:error, :not_installed}` — binary not on PATH + * `{:error, {:not_found, path}}` — target path doesn't exist + * `{:error, {:scan_failed, reason}}` — binary exited non-zero or + produced unparseable output + + `osv-scanner` exits with code 1 when *findings* are present and 0 + when clean — this function treats both as success and only signals + `:scan_failed` for true errors (code 127, malformed JSON, etc.). + """ + @spec scan(target(), atom(), keyword()) :: + {:ok, [Finding.t()]} + | {:error, :not_installed | {:not_found, Path.t()} | {:scan_failed, String.t()}} + def scan(target, layer, opts \\ []) when is_atom(layer) do + runner = Keyword.get(opts, :runner, &default_runner/1) + + cond do + not target_exists?(target) -> + {:error, {:not_found, elem(target, 1)}} + + not installed?() and runner == (&default_runner/1) -> + {:error, :not_installed} + + true -> + target |> build_args() |> runner.() |> handle_output(layer) + end + end + + defp target_exists?({:lockfile, path}), do: File.exists?(path) + defp target_exists?({:directory, path}), do: File.dir?(path) + + defp build_args({:lockfile, path}) do + ["scan", "source", "--format=json", "--lockfile=#{path}"] + end + + defp build_args({:directory, path}) do + ["scan", "source", "--format=json", "--recursive", path] + end + + # osv-scanner exit codes (https://google.github.io/osv-scanner/output/#exit-codes): + # 0 clean (no vulns) + # 1 success, vulns found + # 128 no scannable lockfiles found in target + # 127 tool error (bad args, internal failure, etc.) + defp default_runner(args) do + # stderr stays separate so the progress chatter ("Scanning dir ...", + # "End status: ...") doesn't end up mixed into stdout's JSON. + case System.cmd(@binary, args, stderr_to_stdout: false) do + {output, code} when code in [0, 1] -> {:ok, output} + {_output, 128} -> {:ok, ~s({"results":[]})} + {output, code} -> {:error, "exit #{code}: #{output}"} + end + rescue + e -> {:error, Exception.message(e)} + end + + defp handle_output({:ok, output}, layer) do + case Jason.decode(output) do + {:ok, %{} = json} -> {:ok, Parser.findings(json, layer)} + {:error, decode_error} -> {:error, {:scan_failed, "json decode: #{inspect(decode_error)}"}} + end + end + + defp handle_output({:error, reason}, _layer) do + {:error, {:scan_failed, reason}} + end +end diff --git a/lib/mob_dev/security_scan/osv_scanner/parser.ex b/lib/mob_dev/security_scan/osv_scanner/parser.ex new file mode 100644 index 0000000..6aca6f4 --- /dev/null +++ b/lib/mob_dev/security_scan/osv_scanner/parser.ex @@ -0,0 +1,142 @@ +defmodule MobDev.SecurityScan.OsvScanner.Parser do + @moduledoc """ + Pure parser: `osv-scanner` JSON → `[Finding.t()]`. + + The osv-scanner output schema (as of 2.x): + + { + "results": [ + { + "source": {"path": "...", "type": "lockfile"}, + "packages": [ + { + "package": {"name": "...", "version": "...", "ecosystem": "..."}, + "groups": [{"ids": [...], "max_severity": "8.2"}], + "vulnerabilities": [ + { + "id": "GHSA-XXX", + "summary": "...", + "details": "...", + "aliases": ["CVE-...", "GHSA-..."], + "affected": [{"ranges": [{"events": [{"fixed": "1.11.0"}]}]}], + "references": [{"url": "..."}] + } + ] + } + ] + } + ] + } + + Severity comes from the package's `groups[].max_severity` field, + which is a CVSS 3.x base score as a string. We normalize using + the standard CVSS severity bands (NVD qualitative ratings). + """ + + alias MobDev.SecurityScan.Finding + + @doc "Walk an osv-scanner JSON map and return findings tagged with `layer`." + @spec findings(map(), atom()) :: [Finding.t()] + def findings(%{} = json, layer) do + json + |> Map.get("results", []) + |> Enum.flat_map(&package_findings(&1, layer)) + end + + defp package_findings(%{"packages" => packages}, layer) when is_list(packages) do + Enum.flat_map(packages, &one_package(&1, layer)) + end + + defp package_findings(_, _), do: [] + + defp one_package(%{"package" => pkg, "vulnerabilities" => vulns} = entry, layer) + when is_list(vulns) do + severity_map = build_severity_map(entry) + + Enum.map(vulns, &one_vulnerability(&1, pkg, severity_map, layer)) + end + + defp one_package(_, _), do: [] + + defp build_severity_map(%{"groups" => groups}) when is_list(groups) do + # groups[].ids gives the alias set; max_severity applies to all of them. + # Build a per-id lookup so each vulnerability can look up its severity + # without scanning all groups. + Enum.reduce(groups, %{}, fn group, acc -> + score = parse_cvss(group["max_severity"]) + + group + |> Map.get("ids", []) + |> Enum.reduce(acc, &Map.put(&2, &1, score)) + end) + end + + defp build_severity_map(_), do: %{} + + defp one_vulnerability(vuln, pkg, severity_map, layer) do + id = vuln["id"] + severity = lookup_severity(vuln, severity_map) + + %Finding{ + id: id, + severity: severity, + package: pkg["name"], + version: pkg["version"], + fixed_in: first_fixed_version(vuln), + title: vuln["summary"] || vuln["details"], + description: vuln["details"] || vuln["summary"], + url: primary_url(vuln), + source: :osv_scanner, + layer: layer + } + end + + defp lookup_severity(vuln, severity_map) do + aliases = (vuln["aliases"] || []) ++ [vuln["id"]] + + aliases + |> Enum.reject(&is_nil/1) + |> Enum.find_value(:unknown, &Map.get(severity_map, &1)) + end + + defp first_fixed_version(vuln) do + vuln + |> Map.get("affected", []) + |> Enum.flat_map(fn affected -> Map.get(affected, "ranges", []) end) + |> Enum.flat_map(fn range -> Map.get(range, "events", []) end) + |> Enum.find_value(fn + %{"fixed" => v} when is_binary(v) -> v + _ -> nil + end) + end + + defp primary_url(vuln) do + vuln + |> Map.get("references", []) + |> Enum.find_value(fn + %{"url" => url} when is_binary(url) -> url + _ -> nil + end) + end + + # CVSS 3.x base score → NVD qualitative severity bands. + # Spec: https://www.first.org/cvss/specification-document + defp parse_cvss(nil), do: :unknown + defp parse_cvss(""), do: :unknown + + defp parse_cvss(score) when is_binary(score) do + case Float.parse(score) do + {n, _} -> cvss_band(n) + :error -> :unknown + end + end + + defp parse_cvss(score) when is_number(score), do: cvss_band(score * 1.0) + defp parse_cvss(_), do: :unknown + + defp cvss_band(n) when n >= 9.0, do: :critical + defp cvss_band(n) when n >= 7.0, do: :high + defp cvss_band(n) when n >= 4.0, do: :medium + defp cvss_band(n) when n > 0.0, do: :low + defp cvss_band(_), do: :unknown +end diff --git a/lib/mob_dev/security_scan/report.ex b/lib/mob_dev/security_scan/report.ex new file mode 100644 index 0000000..b6c962b --- /dev/null +++ b/lib/mob_dev/security_scan/report.ex @@ -0,0 +1,96 @@ +defmodule MobDev.SecurityScan.Report do + @moduledoc """ + Aggregate result of a security scan: every layer's `LayerResult` + plus run metadata. The report is the single object handed to + formatters (terminal, JSON, markdown) and the value returned + from `MobDev.SecurityScan.run/1`. + + Severity rollup helpers (`severity_counts/1`, `worst_severity/1`) + are colocated here so formatters and the `--strict` exit-code + logic agree on the math. + """ + + alias MobDev.SecurityScan.{Finding, LayerResult} + + @type t :: %__MODULE__{ + started_at: DateTime.t(), + finished_at: DateTime.t() | nil, + project_root: String.t(), + layers: [LayerResult.t()] + } + + @derive Jason.Encoder + defstruct started_at: nil, + finished_at: nil, + project_root: nil, + layers: [] + + @doc "Flatten findings across all layers." + @spec all_findings(t()) :: [Finding.t()] + def all_findings(%__MODULE__{layers: layers}) do + Enum.flat_map(layers, & &1.findings) + end + + @doc """ + Count findings by severity across the report. + Returns a map keyed by `:critical`, `:high`, `:medium`, `:low`, + `:unknown` — every key is present (zero if no findings at that level). + """ + @spec severity_counts(t()) :: %{Finding.severity() => non_neg_integer()} + def severity_counts(%__MODULE__{} = report) do + base = %{critical: 0, high: 0, medium: 0, low: 0, unknown: 0} + + report + |> all_findings() + |> Enum.reduce(base, fn %Finding{severity: sev}, acc -> + Map.update(acc, sev, 1, &(&1 + 1)) + end) + end + + @doc """ + Worst severity present in the report. Returns `:none` when the + report has zero findings. + """ + @spec worst_severity(t()) :: Finding.severity() | :none + def worst_severity(%__MODULE__{} = report) do + counts = severity_counts(report) + + cond do + counts.critical > 0 -> :critical + counts.high > 0 -> :high + counts.medium > 0 -> :medium + counts.low > 0 -> :low + counts.unknown > 0 -> :unknown + true -> :none + end + end + + @doc "Total wall-clock duration of the scan in milliseconds, or nil if not yet finished." + @spec duration_ms(t()) :: non_neg_integer() | nil + def duration_ms(%__MODULE__{started_at: nil}), do: nil + def duration_ms(%__MODULE__{finished_at: nil}), do: nil + + def duration_ms(%__MODULE__{started_at: s, finished_at: f}) do + DateTime.diff(f, s, :millisecond) + end + + @doc """ + If `strict?` is true and the report contains medium-or-worse findings, + print a message to stderr and `exit({:shutdown, 1})`. Otherwise returns + `:ok`. Shared between `mix mob.security_scan` and its `.log` sibling. + """ + @spec maybe_exit_strict(t(), boolean() | nil) :: :ok + def maybe_exit_strict(_report, nil), do: :ok + def maybe_exit_strict(_report, false), do: :ok + + def maybe_exit_strict(report, true) do + case worst_severity(report) do + sev when sev in [:critical, :high, :medium] -> + Mix.shell().error("--strict: #{sev} finding(s) present") + exit({:shutdown, 1}) + + _ -> + :ok + end + end +end diff --git a/lib/mob_dev/security_scan/runner.ex b/lib/mob_dev/security_scan/runner.ex new file mode 100644 index 0000000..d1a242a --- /dev/null +++ b/lib/mob_dev/security_scan/runner.ex @@ -0,0 +1,81 @@ +defmodule MobDev.SecurityScan.Runner do + @moduledoc """ + Orchestrates the scan: invokes each enabled layer in turn, + collects `LayerResult`s, and returns a `Report`. + + Layers are run sequentially (not concurrently) so that terminal + output stays readable in the streaming formatter and so that + resource-heavy scanners like semgrep don't dogpile a laptop. + + Layers never raise — failures land as `%LayerResult{status: :error}`. + The runner additionally guards every callback with a `try`, so a + bug in one layer can't take the whole scan down. + """ + + alias MobDev.SecurityScan.{LayerResult, Report} + + @doc """ + Runs the listed layers in order. `opts` is forwarded to each + layer's `run/1` callback. The optional `:on_layer_start` and + `:on_layer_done` callbacks let the formatter stream progress + to the terminal as layers complete. + """ + @spec run([module()], keyword()) :: Report.t() + def run(layers, opts \\ []) do + project_root = Keyword.get(opts, :project_root, File.cwd!()) + skip = Keyword.get(opts, :skip, []) + on_start = Keyword.get(opts, :on_layer_start, fn _ -> :ok end) + on_done = Keyword.get(opts, :on_layer_done, fn _ -> :ok end) + + started_at = DateTime.utc_now() + + layer_results = + Enum.map(layers, fn layer -> + on_start.(layer.name()) + result = run_layer(layer, skip, opts) + on_done.(result) + result + end) + + %Report{ + started_at: started_at, + finished_at: DateTime.utc_now(), + project_root: project_root, + layers: layer_results + } + end + + defp run_layer(layer, skip, opts) do + name = layer.name() + + if name in skip do + %LayerResult{ + name: name, + status: :skipped, + notes: ["skipped via --skip flag"] + } + else + execute(layer, opts) + end + end + + defp execute(layer, opts) do + started = System.monotonic_time(:millisecond) + + try do + %LayerResult{} = result = layer.run(opts) + duration = System.monotonic_time(:millisecond) - started + %{result | duration_ms: duration} + rescue + e -> + duration = System.monotonic_time(:millisecond) - started + + %LayerResult{ + name: layer.name(), + status: :error, + error: Exception.message(e), + duration_ms: duration + } + end + end +end diff --git a/lib/mob_dev/security_scan/state_file.ex b/lib/mob_dev/security_scan/state_file.ex new file mode 100644 index 0000000..c540ef2 --- /dev/null +++ b/lib/mob_dev/security_scan/state_file.ex @@ -0,0 +1,208 @@ +defmodule MobDev.SecurityScan.StateFile do + @moduledoc """ + Read/write the state sidecar for `mix mob.security_scan.log`. + + The state file is a small JSON document that records the + last-known set of findings and when each was first seen. Diff + computation between runs depends on it. + + Default location: `.security_scan/state.json` at the project root. + Should be **checked into git** so a fresh CI run knows what the + prior baseline was — without it, every scheduled run reports every + finding as 'new' and the changelog becomes useless. + + ## Schema + + { + "version": 1, + "last_run_at": "2026-05-07T05:30:00Z", + "findings": [ + { + "key": "EEF-CVE-2026-32689|phoenix|1.8.5", + "id": "EEF-CVE-2026-32689", + "severity": "high", + "package": "phoenix", + "version": "1.8.5", + "fixed_in": "1.7.22", + "title": "...", + "url": "...", + "source": "osv_scanner", + "layer": "hex_deps", + "first_seen_at": "2026-05-07T05:30:00Z" + } + ] + } + """ + + alias MobDev.SecurityScan.{Diff, Finding, Report} + + @schema_version 1 + + @typedoc "Finding-as-stored: same fields as Finding plus key + first_seen_at." + @type entry :: %{ + required(:key) => key(), + required(:id) => String.t() | nil, + required(:severity) => atom(), + required(:package) => String.t() | nil, + required(:version) => String.t() | nil, + required(:fixed_in) => String.t() | nil, + required(:title) => String.t() | nil, + required(:url) => String.t() | nil, + required(:source) => atom() | nil, + required(:layer) => atom() | nil, + required(:first_seen_at) => DateTime.t() | String.t() + } + + @typedoc "Dedup key derived from id|package|version." + @type key :: String.t() + + @typedoc "Loaded state map." + @type state :: %{ + required(:version) => integer(), + required(:last_run_at) => DateTime.t() | nil, + required(:findings) => [entry()] + } + + @doc "Empty initial state for first-time scans." + @spec empty() :: state() + def empty do + %{version: @schema_version, last_run_at: nil, findings: []} + end + + @doc "Load state from a JSON file. Returns `empty/0` if the file is missing." + @spec load(Path.t()) :: state() + def load(path) do + case File.read(path) do + {:ok, body} -> decode(body) + {:error, _} -> empty() + end + end + + @doc "Encode + write the given state to disk. Creates parent dirs as needed." + @spec save(Path.t(), state()) :: :ok + def save(path, %{} = state) do + File.mkdir_p!(Path.dirname(path)) + File.write!(path, Jason.encode!(serialize(state), pretty: true) <> "\n") + :ok + end + + @doc """ + Build the next state from the current report and a `Diff` (which + carries `first_seen_at` for findings that already existed). + """ + @spec from_report(Report.t(), Diff.t(), DateTime.t()) :: state() + def from_report(%Report{} = report, %Diff{} = diff, %DateTime{} = now) do + findings = + report + |> Report.all_findings() + |> Enum.map(&finding_to_entry(&1, diff.first_seen, now)) + + %{version: @schema_version, last_run_at: now, findings: findings} + end + + ## ── encoding ────────────────────────────────────────────────────────────── + + defp serialize(state) do + %{ + "version" => state.version, + "last_run_at" => format_dt(state.last_run_at), + "findings" => + state.findings + |> Enum.sort_by(& &1.key) + |> Enum.map(&serialize_entry/1) + } + end + + defp serialize_entry(entry) do + %{ + "key" => entry.key, + "id" => entry.id, + "severity" => to_string(entry.severity), + "package" => entry.package, + "version" => entry.version, + "fixed_in" => entry.fixed_in, + "title" => entry.title, + "url" => entry.url, + "source" => entry.source && to_string(entry.source), + "layer" => entry.layer && to_string(entry.layer), + "first_seen_at" => format_dt(entry.first_seen_at) + } + end + + defp format_dt(nil), do: nil + defp format_dt(%DateTime{} = dt), do: DateTime.to_iso8601(dt) + defp format_dt(s) when is_binary(s), do: s + + defp decode(body) do + case Jason.decode(body) do + {:ok, %{"findings" => findings} = map} -> + %{ + version: map["version"] || @schema_version, + last_run_at: parse_dt(map["last_run_at"]), + findings: Enum.map(findings, &decode_entry/1) + } + + _ -> + empty() + end + end + + defp decode_entry(map) do + %{ + key: map["key"], + id: map["id"], + severity: parse_atom(map["severity"]), + package: map["package"], + version: map["version"], + fixed_in: map["fixed_in"], + title: map["title"], + url: map["url"], + source: parse_atom(map["source"]), + layer: parse_atom(map["layer"]), + first_seen_at: parse_dt(map["first_seen_at"]) + } + end + + defp parse_atom(nil), do: nil + defp parse_atom(""), do: nil + + defp parse_atom(s) when is_binary(s) do + String.to_atom(s) + rescue + _ -> nil + end + + defp parse_dt(nil), do: nil + + defp parse_dt(s) when is_binary(s) do + case DateTime.from_iso8601(s) do + {:ok, dt, _} -> dt + _ -> nil + end + end + + defp parse_dt(%DateTime{} = dt), do: dt + + defp finding_to_entry(%Finding{} = f, first_seen, now) do + key = Finding.dedupe_key(f) |> key_to_string() + first = Map.get(first_seen, Finding.dedupe_key(f), now) + + %{ + key: key, + id: f.id, + severity: f.severity, + package: f.package, + version: f.version, + fixed_in: f.fixed_in, + title: f.title, + url: f.url, + source: f.source, + layer: f.layer, + first_seen_at: first + } + end + + defp key_to_string({id, package, version}) do + "#{id || ""}|#{package || ""}|#{version || ""}" + end +end diff --git a/lib/mob_dev/server/device_poller.ex b/lib/mob_dev/server/device_poller.ex index 2ad8809..533aa99 100644 --- a/lib/mob_dev/server/device_poller.ex +++ b/lib/mob_dev/server/device_poller.ex @@ -53,25 +53,27 @@ defmodule MobDev.Server.DevicePoller do defp schedule_poll, do: Process.send_after(self(), :poll, @poll_ms) defp poll_devices do - android = try do - Android.list_devices() - |> Enum.reject(&(&1.status == :unauthorized)) - |> Enum.map(&enrich_android/1) - rescue - _ -> [] - end - - ios = if macos?() do + android = try do - IOS.list_simulators() - |> Enum.filter(&(&1.status == :booted)) - |> Enum.map(&enrich_ios/1) + Android.list_devices() + |> Enum.reject(&(&1.status == :unauthorized)) + |> Enum.map(&enrich_android/1) rescue _ -> [] end - else - [] - end + + ios = + if macos?() do + try do + IOS.list_simulators() + |> Enum.filter(&(&1.status == :booted)) + |> Enum.map(&enrich_ios/1) + rescue + _ -> [] + end + else + [] + end android ++ ios end @@ -87,23 +89,26 @@ defmodule MobDev.Server.DevicePoller do end defp read_android_battery(serial) do - case System.cmd("adb", ["-s", serial, "shell", "dumpsys battery"], - stderr_to_stdout: true) do + case System.cmd("adb", ["-s", serial, "shell", "dumpsys battery"], stderr_to_stdout: true) do {out, 0} -> - case Regex.run(~r/level:\s*(\d+)/, out) do + case Regex.run(Regex.compile!("level:\\s*(\\d+)"), out) do [_, pct] -> String.to_integer(pct) nil -> nil end - _ -> nil + + _ -> + nil end end defp beam_running_android?(serial) do # Use pm list packages to find the installed third-party app rather than # hardcoding a bundle ID — works for any app name. - case System.cmd("adb", ["-s", serial, "shell", - "pidof $(pm list packages -3 | head -1 | cut -d: -f2)"], - stderr_to_stdout: true) do + case System.cmd( + "adb", + ["-s", serial, "shell", "pidof $(pm list packages -3 | head -1 | cut -d: -f2)"], + stderr_to_stdout: true + ) do {out, 0} -> String.trim(out) != "" _ -> false end diff --git a/lib/mob_dev/server/elixir_log_buffer.ex b/lib/mob_dev/server/elixir_log_buffer.ex index 258e621..6bcff27 100644 --- a/lib/mob_dev/server/elixir_log_buffer.ex +++ b/lib/mob_dev/server/elixir_log_buffer.ex @@ -1,36 +1,7 @@ defmodule MobDev.Server.ElixirLogBuffer do @moduledoc """ - Holds the last N server-side Elixir log lines in memory so the dashboard + Holds the last 200 server-side Elixir log lines in memory so the dashboard can restore them on reconnect. Fed by `MobDev.Server.ElixirLogger`. """ - use GenServer - - @limit 200 - - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - GenServer.start_link(__MODULE__, opts, name: __MODULE__) - end - - @spec get() :: [map()] - def get, do: GenServer.call(__MODULE__, :get) - - @spec push(map()) :: :ok - def push(line), do: GenServer.cast(__MODULE__, {:push, line}) - - @spec clear() :: :ok - def clear, do: GenServer.cast(__MODULE__, :clear) - - @impl GenServer - def init(_), do: {:ok, []} - - @impl GenServer - def handle_call(:get, _from, lines), do: {:reply, lines, lines} - - @impl GenServer - def handle_cast({:push, line}, lines) do - {:noreply, Enum.take([line | lines], @limit)} - end - - def handle_cast(:clear, _lines), do: {:noreply, []} + use MobDev.Server.RingLog, limit: 200 end diff --git a/lib/mob_dev/server/elixir_logger.ex b/lib/mob_dev/server/elixir_logger.ex index c6e9ee9..734ca39 100644 --- a/lib/mob_dev/server/elixir_logger.ex +++ b/lib/mob_dev/server/elixir_logger.ex @@ -15,38 +15,47 @@ defmodule MobDev.Server.ElixirLogger do @topic "elixir_logs" @doc "Attach the handler to OTP's logger. Call after the server supervisor starts." + @spec attach() :: :ok | {:error, term()} def attach do :logger.add_handler(@handler_id, __MODULE__, %{}) end @doc "Detach the handler." + @spec detach() :: :ok | {:error, term()} def detach do :logger.remove_handler(@handler_id) end # ── OTP logger callbacks ────────────────────────────────────────────────── + @spec adding_handler(map()) :: {:ok, map()} def adding_handler(config), do: {:ok, config} + + @spec removing_handler(map()) :: :ok def removing_handler(_config), do: :ok + @spec log(map(), map()) :: :ok def log(%{level: level, msg: msg, meta: meta} = _event, _config) do # Only capture Elixir Logger events (domain: [:elixir]) if elixir_domain?(meta) do line = %{ - id: System.unique_integer([:positive, :monotonic]), - level: level_char(level), + id: System.unique_integer([:positive, :monotonic]), + level: level_char(level), message: format_msg(msg), - ts: format_time(meta[:time]), - module: meta[:module], + ts: format_time(meta[:time]), + module: meta[:module] } + # Guard: if the buffer GenServer isn't up, skip silently if Process.whereis(MobDev.Server.ElixirLogBuffer) do MobDev.Server.ElixirLogBuffer.push(line) end + if Process.whereis(MobDev.PubSub) do Phoenix.PubSub.broadcast(MobDev.PubSub, @topic, {:elixir_log_line, line}) end end + :ok end @@ -56,28 +65,31 @@ defmodule MobDev.Server.ElixirLogger do defp elixir_domain?(_), do: false defp level_char(:emergency), do: "E" - defp level_char(:alert), do: "E" - defp level_char(:critical), do: "E" - defp level_char(:error), do: "E" - defp level_char(:warning), do: "W" - defp level_char(:notice), do: "I" - defp level_char(:info), do: "I" - defp level_char(:debug), do: "D" - defp level_char(_), do: "D" - - defp format_msg({:string, text}), do: IO.iodata_to_binary(text) - defp format_msg({:report, map}), do: inspect(map, pretty: false, limit: 50) + defp level_char(:alert), do: "E" + defp level_char(:critical), do: "E" + defp level_char(:error), do: "E" + defp level_char(:warning), do: "W" + defp level_char(:notice), do: "I" + defp level_char(:info), do: "I" + defp level_char(:debug), do: "D" + defp level_char(_), do: "D" + + defp format_msg({:string, text}), do: IO.iodata_to_binary(text) + defp format_msg({:report, map}), do: inspect(map, pretty: false, limit: 50) + defp format_msg({:format, fmt, args}) do :io_lib.format(fmt, args) |> IO.iodata_to_binary() rescue _ -> inspect({fmt, args}) end + defp format_msg(other), do: inspect(other) defp format_time(nil), do: "" + defp format_time(microseconds) do - ms = div(microseconds, 1_000) - dt = DateTime.from_unix!(ms, :millisecond) + ms = div(microseconds, 1_000) + dt = DateTime.from_unix!(ms, :millisecond) frac = String.pad_leading("#{rem(ms, 1000)}", 3, "0") Calendar.strftime(dt, "%H:%M:%S.") <> frac end diff --git a/lib/mob_dev/server/endpoint.ex b/lib/mob_dev/server/endpoint.ex index cc7714c..d1f33f3 100644 --- a/lib/mob_dev/server/endpoint.ex +++ b/lib/mob_dev/server/endpoint.ex @@ -1,31 +1,39 @@ defmodule MobDev.Server.Endpoint do use Phoenix.Endpoint, otp_app: :mob_dev - socket "/live", Phoenix.LiveView.Socket, - websocket: [connect_info: [session: [store: :cookie, key: "_mob_dev_session", signing_salt: "mob_dev"]]] + socket("/live", Phoenix.LiveView.Socket, + websocket: [ + connect_info: [session: [store: :cookie, key: "_mob_dev_session", signing_salt: "mob_dev"]] + ] + ) # Serve phoenix.js and phoenix_live_view.js directly from package priv/static. # No npm/esbuild needed — these are pre-built files from the hex packages. - plug Plug.Static, + plug(Plug.Static, at: "/assets/phoenix", from: {:phoenix, "priv/static"}, gzip: false + ) - plug Plug.Static, + plug(Plug.Static, at: "/assets/plv", from: {:phoenix_live_view, "priv/static"}, gzip: false + ) - plug Plug.Static, + plug(Plug.Static, at: "/", from: {:mob_dev, "priv/static"}, gzip: false + ) - plug Plug.RequestId - plug Plug.Session, + plug(Plug.RequestId) + + plug(Plug.Session, store: :cookie, key: "_mob_dev_session", signing_salt: "mob_dev" + ) - plug MobDev.Server.Router + plug(MobDev.Server.Router) end diff --git a/lib/mob_dev/server/live/dashboard_live.ex b/lib/mob_dev/server/live/dashboard_live.ex index 32504ee..44c1e6a 100644 --- a/lib/mob_dev/server/live/dashboard_live.ex +++ b/lib/mob_dev/server/live/dashboard_live.ex @@ -3,12 +3,12 @@ defmodule MobDev.Server.DashboardLive do alias MobDev.Server.{LogFilter, WatchWorker} - @log_limit 500 - @elixir_limit 200 - @log_topic "logs" - @elixir_topic "elixir_logs" - @device_topic "devices" - @watch_topic "watch" + @log_limit 500 + @elixir_limit 200 + @log_topic "logs" + @elixir_topic "elixir_logs" + @device_topic "devices" + @watch_topic "watch" @impl Phoenix.LiveView @spec mount(map(), map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()} @@ -20,10 +20,12 @@ defmodule MobDev.Server.DashboardLive do Phoenix.PubSub.subscribe(MobDev.PubSub, @watch_topic) end - devices = MobDev.Server.DevicePoller.get_devices() - all_lines = MobDev.Server.LogBuffer.get() # newest-first list - elixir_lines = MobDev.Server.ElixirLogBuffer.get() - lan_url = Application.get_env(:mob_dev, :dashboard_lan_url) + devices = MobDev.Server.DevicePoller.get_devices() + # newest-first list + all_lines = MobDev.Server.LogBuffer.get() + elixir_lines = MobDev.Server.ElixirLogBuffer.get() + lan_url = Application.get_env(:mob_dev, :dashboard_lan_url) + {qr_small, qr_large} = if lan_url do encoded = EQRCode.encode(lan_url) @@ -37,22 +39,24 @@ defmodule MobDev.Server.DashboardLive do socket = socket |> assign( - devices: devices, - all_log_lines: all_lines, - log_filter: :app, - text_filter: "", - deploying: %{}, # serial => :update | :first_deploy - deploy_output: %{}, # serial => [line, ...] - lan_url: lan_url, - qr_small: qr_small, - qr_large: qr_large, - watch_active: watch.watching, - watch_nodes: watch.nodes, - watch_last_push: watch.last_push, - all_elixir_lines: elixir_lines, + devices: devices, + all_log_lines: all_lines, + log_filter: :app, + text_filter: "", + # serial => :update | :first_deploy + deploying: %{}, + # serial => [line, ...] + deploy_output: %{}, + lan_url: lan_url, + qr_small: qr_small, + qr_large: qr_large, + watch_active: watch.watching, + watch_nodes: watch.nodes, + watch_last_push: watch.last_push, + all_elixir_lines: elixir_lines, elixir_text_filter: "" ) - |> stream(:log_lines, LogFilter.apply(all_lines, :app, "") |> Enum.reverse()) + |> stream(:log_lines, LogFilter.apply(all_lines, :app, "") |> Enum.reverse()) |> stream(:elixir_lines, Enum.reverse(elixir_lines)) {:ok, socket} @@ -61,7 +65,8 @@ defmodule MobDev.Server.DashboardLive do # ── PubSub handlers ────────────────────────────────────────────────────────── @impl Phoenix.LiveView - @spec handle_info(term(), Phoenix.LiveView.Socket.t()) :: {:noreply, Phoenix.LiveView.Socket.t()} + @spec handle_info(term(), Phoenix.LiveView.Socket.t()) :: + {:noreply, Phoenix.LiveView.Socket.t()} def handle_info({:devices_updated, devices}, socket) do {:noreply, assign(socket, :devices, devices)} end @@ -69,24 +74,28 @@ defmodule MobDev.Server.DashboardLive do def handle_info({:log_line, _serial, line}, socket) do all_lines = [line | socket.assigns.all_log_lines] |> Enum.take(@log_limit) socket = assign(socket, :all_log_lines, all_lines) + socket = if LogFilter.matches?(line, socket.assigns.log_filter, socket.assigns.text_filter) do stream_insert(socket, :log_lines, line, at: -1, limit: @log_limit) else socket end + {:noreply, socket} end def handle_info({:elixir_log_line, line}, socket) do all = [line | socket.assigns.all_elixir_lines] |> Enum.take(@elixir_limit) socket = assign(socket, :all_elixir_lines, all) + socket = if elixir_matches?(line, socket.assigns.elixir_text_filter) do stream_insert(socket, :elixir_lines, line, at: -1, limit: @elixir_limit) else socket end + {:noreply, socket} end @@ -100,7 +109,13 @@ defmodule MobDev.Server.DashboardLive do def handle_info({:deploy_line, serial, line}, socket) do output = Map.get(socket.assigns.deploy_output, serial, []) - {:noreply, assign(socket, :deploy_output, Map.put(socket.assigns.deploy_output, serial, [line | output]))} + + {:noreply, + assign( + socket, + :deploy_output, + Map.put(socket.assigns.deploy_output, serial, [line | output]) + )} end def handle_info({:deploy_done, serial}, socket) do @@ -111,14 +126,18 @@ defmodule MobDev.Server.DashboardLive do # ── Events ─────────────────────────────────────────────────────────────────── @impl Phoenix.LiveView - @spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) :: {:noreply, Phoenix.LiveView.Socket.t()} + @spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) :: + {:noreply, Phoenix.LiveView.Socket.t()} def handle_event("deploy", %{"serial" => serial, "mode" => mode}, socket) do deploying = Map.put(socket.assigns.deploying, serial, String.to_atom(mode)) device = Enum.find(socket.assigns.devices, &(&1.serial == serial)) - socket = assign(socket, - deploying: deploying, - deploy_output: Map.put(socket.assigns.deploy_output, serial, []) - ) + + socket = + assign(socket, + deploying: deploying, + deploy_output: Map.put(socket.assigns.deploy_output, serial, []) + ) + spawn_deploy(serial, String.to_atom(mode), device.platform, self()) {:noreply, socket} end @@ -129,57 +148,76 @@ defmodule MobDev.Server.DashboardLive do else WatchWorker.start_watching() end + {:noreply, socket} end def handle_event("set_log_filter", %{"filter" => raw_filter}, socket) do - filter = case raw_filter do - "all" -> :all - "app" -> :app - serial -> serial - end - filtered = LogFilter.apply(socket.assigns.all_log_lines, filter, socket.assigns.text_filter) |> Enum.reverse() + filter = + case raw_filter do + "all" -> :all + "app" -> :app + serial -> serial + end + + filtered = + LogFilter.apply(socket.assigns.all_log_lines, filter, socket.assigns.text_filter) + |> Enum.reverse() + socket = socket |> assign(:log_filter, filter) |> stream(:log_lines, filtered, reset: true) + {:noreply, socket} end # phx-change on a <form> sends {name => value} pairs; the input is named "text_filter". def handle_event("set_text_filter", %{"text_filter" => text}, socket) do - filtered = LogFilter.apply(socket.assigns.all_log_lines, socket.assigns.log_filter, text) |> Enum.reverse() + filtered = + LogFilter.apply(socket.assigns.all_log_lines, socket.assigns.log_filter, text) + |> Enum.reverse() + socket = socket |> assign(:text_filter, text) |> stream(:log_lines, filtered, reset: true) + {:noreply, socket} end def handle_event("clear_text_filter", _, socket) do - filtered = LogFilter.apply(socket.assigns.all_log_lines, socket.assigns.log_filter, "") |> Enum.reverse() + filtered = + LogFilter.apply(socket.assigns.all_log_lines, socket.assigns.log_filter, "") + |> Enum.reverse() + socket = socket |> assign(:text_filter, "") |> stream(:log_lines, filtered, reset: true) + {:noreply, socket} end def handle_event("clear_logs", _, socket) do MobDev.Server.LogBuffer.clear() + socket = socket |> assign(:all_log_lines, []) |> stream(:log_lines, [], reset: true) + {:noreply, socket} end def handle_event("set_elixir_text_filter", %{"elixir_text_filter" => text}, socket) do filtered = apply_elixir_filter(socket.assigns.all_elixir_lines, text) + socket = socket |> assign(:elixir_text_filter, text) |> stream(:elixir_lines, filtered, reset: true) + {:noreply, socket} end @@ -188,15 +226,18 @@ defmodule MobDev.Server.DashboardLive do socket |> assign(:elixir_text_filter, "") |> stream(:elixir_lines, Enum.reverse(socket.assigns.all_elixir_lines), reset: true) + {:noreply, socket} end def handle_event("clear_elixir_logs", _, socket) do MobDev.Server.ElixirLogBuffer.clear() + socket = socket |> assign(:all_elixir_lines, []) |> stream(:elixir_lines, [], reset: true) + {:noreply, socket} end @@ -205,19 +246,29 @@ defmodule MobDev.Server.DashboardLive do defp spawn_deploy(serial, mode, platform, lv_pid) do mix = System.find_executable("mix") || "mix" platform_flag = if platform == :ios, do: "--ios", else: "--android" - args = case mode do - :first_deploy -> ["mob.deploy", "--native", platform_flag] - :update -> ["mob.deploy", platform_flag] - end + + args = + case mode do + :first_deploy -> ["mob.deploy", "--native", platform_flag] + :update -> ["mob.deploy", platform_flag] + end + _ = serial Task.start(fn -> # Stream output line by line via a Port so the UI updates in real time - port = Port.open({:spawn_executable, mix}, - [:binary, :exit_status, :stderr_to_stdout, - {:args, args}, - {:line, 2048}, - {:cd, File.cwd!()}]) + port = + Port.open( + {:spawn_executable, mix}, + [ + :binary, + :exit_status, + :stderr_to_stdout, + {:args, args}, + {:line, 2048}, + {:cd, File.cwd!()} + ] + ) stream_port(port, serial, lv_pid) send(lv_pid, {:deploy_done, serial}) @@ -229,6 +280,7 @@ defmodule MobDev.Server.DashboardLive do {^port, {:data, {:eol, line}}} -> send(lv_pid, {:deploy_line, serial, line}) stream_port(port, serial, lv_pid) + {^port, {:exit_status, _}} -> :done after @@ -238,12 +290,12 @@ defmodule MobDev.Server.DashboardLive do # ── Helpers ────────────────────────────────────────────────────────────────── - # Elixir log filter — matches message or module name; comma separates OR terms defp elixir_matches?(_line, ""), do: true + defp elixir_matches?(line, filter) do terms = filter |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) - text = [line.message, inspect(line.module)] |> Enum.join(" ") |> String.downcase() + text = [line.message, inspect(line.module)] |> Enum.join(" ") |> String.downcase() Enum.any?(terms, &String.contains?(text, String.downcase(&1))) end @@ -256,16 +308,18 @@ defmodule MobDev.Server.DashboardLive do defp level_class("E"), do: "log-E" defp level_class("W"), do: "log-W" defp level_class("I"), do: "log-I" - defp level_class(_), do: "log-D" + defp level_class(_), do: "log-D" defp platform_badge(:android), do: {"Android", "bg-green-900 text-green-300"} - defp platform_badge(:ios), do: {"iOS", "bg-blue-900 text-blue-300"} - defp platform_badge(_), do: {"?", "bg-zinc-700 text-zinc-300"} + defp platform_badge(:ios), do: {"iOS", "bg-blue-900 text-blue-300"} + defp platform_badge(_), do: {"?", "bg-zinc-700 text-zinc-300"} defp short_serial(serial) do if String.contains?(serial, ":"), - do: serial, # IP:port — show as-is - else: String.slice(serial, -8, 8) # USB serial — last 8 chars is enough + # IP:port — show as-is + do: serial, + # USB serial — last 8 chars is enough + else: String.slice(serial, -8, 8) end # ── Template ───────────────────────────────────────────────────────────────── diff --git a/lib/mob_dev/server/log_buffer.ex b/lib/mob_dev/server/log_buffer.ex index 9487778..a2726bb 100644 --- a/lib/mob_dev/server/log_buffer.ex +++ b/lib/mob_dev/server/log_buffer.ex @@ -1,39 +1,7 @@ defmodule MobDev.Server.LogBuffer do @moduledoc """ - Holds the last N log lines in memory so the LiveView can restore them on + Holds the last 500 log lines in memory so the LiveView can restore them on reconnect without losing context from before a crash or page refresh. """ - use GenServer - - @limit 500 - - @spec start_link(keyword()) :: GenServer.on_start() - def start_link(opts \\ []) do - GenServer.start_link(__MODULE__, opts, name: __MODULE__) - end - - @spec get() :: [map()] - def get, do: GenServer.call(__MODULE__, :get) - - @spec push(map()) :: :ok - def push(line), do: GenServer.cast(__MODULE__, {:push, line}) - - @spec clear() :: :ok - def clear, do: GenServer.cast(__MODULE__, :clear) - - @impl GenServer - @spec init(term()) :: {:ok, [map()]} - def init(_), do: {:ok, []} - - @impl GenServer - @spec handle_call(:get, GenServer.from(), [map()]) :: {:reply, [map()], [map()]} - def handle_call(:get, _from, lines), do: {:reply, lines, lines} - - @impl GenServer - @spec handle_cast({:push, map()} | :clear, [map()]) :: {:noreply, [map()]} - def handle_cast({:push, line}, lines) do - {:noreply, Enum.take([line | lines], @limit)} - end - - def handle_cast(:clear, _lines), do: {:noreply, []} + use MobDev.Server.RingLog, limit: 500 end diff --git a/lib/mob_dev/server/log_filter.ex b/lib/mob_dev/server/log_filter.ex index c5a702f..4e17ba4 100644 --- a/lib/mob_dev/server/log_filter.ex +++ b/lib/mob_dev/server/log_filter.ex @@ -30,28 +30,32 @@ defmodule MobDev.Server.LogFilter do # ── Device filter ───────────────────────────────────────────────────────────── @spec by_device([line()], filter()) :: [line()] - def by_device(lines, :all), do: lines - def by_device(lines, :app), do: Enum.filter(lines, & &1.mob) - def by_device(lines, serial), do: Enum.filter(lines, &(&1.serial == serial)) + def by_device(lines, :all), do: lines + def by_device(lines, :app), do: Enum.filter(lines, & &1.mob) + def by_device(lines, serial), do: Enum.filter(lines, &(&1.serial == serial)) @spec by_device?(line(), filter()) :: boolean() - def by_device?(_, :all), do: true - def by_device?(line, :app), do: line.mob + def by_device?(_, :all), do: true + def by_device?(line, :app), do: line.mob def by_device?(line, serial), do: line.serial == serial # ── Text filter ─────────────────────────────────────────────────────────────── @spec by_text([line()], String.t()) :: [line()] - def by_text(lines, ""), do: lines + def by_text(lines, ""), do: lines def by_text(lines, text), do: Enum.filter(lines, &by_text?(&1, text)) @spec by_text?(line(), String.t()) :: boolean() def by_text?(_, ""), do: true + def by_text?(line, text) do terms = text |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) + case terms do - [] -> true - _ -> + [] -> + true + + _ -> haystack = String.downcase((line.message || "") <> " " <> (line.raw || "")) Enum.any?(terms, &String.contains?(haystack, String.downcase(&1))) end diff --git a/lib/mob_dev/server/log_streamer.ex b/lib/mob_dev/server/log_streamer.ex index 3a048dd..d121c77 100644 --- a/lib/mob_dev/server/log_streamer.ex +++ b/lib/mob_dev/server/log_streamer.ex @@ -10,7 +10,8 @@ defmodule MobDev.Server.LogStreamer do @topic "logs" - defstruct ports: %{} # serial => port + # serial => port + defstruct ports: %{} @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts \\ []) do @@ -34,20 +35,23 @@ defmodule MobDev.Server.LogStreamer do @spec handle_info(term(), %__MODULE__{}) :: {:noreply, %__MODULE__{}} def handle_info({:devices_updated, devices}, state) do current_serials = MapSet.new(Map.keys(state.ports)) - new_serials = MapSet.new(Enum.map(devices, & &1.serial)) + new_serials = MapSet.new(Enum.map(devices, & &1.serial)) # Stop ports for disconnected devices removed = MapSet.difference(current_serials, new_serials) - ports = Enum.reduce(removed, state.ports, fn serial, acc -> - if port = acc[serial] do - Port.close(port) - end - Map.delete(acc, serial) - end) + + ports = + Enum.reduce(removed, state.ports, fn serial, acc -> + if port = acc[serial] do + Port.close(port) + end + + Map.delete(acc, serial) + end) # Open ports for newly connected devices added = MapSet.difference(new_serials, current_serials) - new_devices = Enum.filter(devices, &(MapSet.member?(added, &1.serial))) + new_devices = Enum.filter(devices, &MapSet.member?(added, &1.serial)) ports = Enum.reduce(new_devices, ports, &open_port_for/2) {:noreply, %{state | ports: ports}} @@ -81,8 +85,11 @@ defmodule MobDev.Server.LogStreamer do {:noreply, state} else devices = MobDev.Server.DevicePoller.get_devices() + case Enum.find(devices, &(&1.serial == serial)) do - nil -> {:noreply, state} + nil -> + {:noreply, state} + device -> broadcast_restart(serial) ports = open_port_for(device, state.ports) @@ -95,6 +102,7 @@ defmodule MobDev.Server.LogStreamer do defp broadcast_line(port, line, state) do serial = Enum.find_value(state.ports, fn {s, p} -> if p == port, do: s end) + if serial do parsed = parse_line(line, serial) MobDev.Server.LogBuffer.push(parsed) @@ -104,16 +112,17 @@ defmodule MobDev.Server.LogStreamer do defp broadcast_restart(serial) do line = %{ - id: unique_id(), - serial: serial, - level: "I", - tag: nil, + id: unique_id(), + serial: serial, + level: "I", + tag: nil, message: "── Restart ──", - raw: "", - mob: true, + raw: "", + mob: true, restart: true, - ts: time_string() + ts: time_string() } + MobDev.Server.LogBuffer.push(line) Phoenix.PubSub.broadcast(MobDev.PubSub, @topic, {:log_line, serial, line}) end @@ -129,9 +138,18 @@ defmodule MobDev.Server.LogStreamer do defp open_port_for(%{platform: :ios, serial: udid}, ports) do # Stream iOS simulator log, filter to mob-relevant output. # Process name is the binary name ("MobDemo"), not the bundle ID. - args = ["simctl", "spawn", udid, "log", "stream", - "--predicate", "process == 'MobDemo'", - "--style", "syslog"] + args = [ + "simctl", + "spawn", + udid, + "log", + "stream", + "--predicate", + "process == 'MobDemo'", + "--style", + "syslog" + ] + port = open_port("xcrun", args) Map.put(ports, udid, port) end @@ -140,8 +158,11 @@ defmodule MobDev.Server.LogStreamer do defp open_port(cmd, args) do executable = System.find_executable(cmd) || cmd - Port.open({:spawn_executable, executable}, - [:binary, :exit_status, {:args, args}, {:line, 4096}]) + + Port.open( + {:spawn_executable, executable}, + [:binary, :exit_status, {:args, args}, {:line, 4096}] + ) end # ── Log line parsing ───────────────────────────────────────────────────────── @@ -154,42 +175,48 @@ defmodule MobDev.Server.LogStreamer do @spec parse_line(String.t(), String.t()) :: map() def parse_line(raw, serial) do # Android logcat brief: "I/MobBeam( 1234): message text" - case Regex.run(~r/^([EWIDVF])\/([^\(]+)\(\s*\d+\):\s*(.*)$/, String.trim(raw)) do + case Regex.run( + Regex.compile!("^([EWIDVF])/([^\\(]+)\\(\\s*\\d+\\):\\s*(.*)$"), + String.trim(raw) + ) do [_, level, tag, message] -> %{ - id: unique_id(), - serial: serial, - level: level, - tag: String.trim(tag), + id: unique_id(), + serial: serial, + level: level, + tag: String.trim(tag), message: message, - raw: raw, - mob: mob_tag?(tag), - ts: time_string() + raw: raw, + mob: mob_tag?(tag), + ts: time_string() } + nil -> # iOS syslog or unparsed line %{ - id: unique_id(), - serial: serial, - level: "I", - tag: nil, + id: unique_id(), + serial: serial, + level: "I", + tag: nil, message: String.trim(raw), - raw: raw, - mob: mob_line?(raw), - ts: time_string() + raw: raw, + mob: mob_line?(raw), + ts: time_string() } end end defp mob_tag?(tag) do tag = String.trim(tag) + tag in ["MobBeam", "MobNif", "MobDist", "MobBridge", "Elixir"] or String.starts_with?(tag, "Mob") end defp mob_line?(line) do - app = Mix.Project.config()[:app] |> to_string() + app = Mix.Project.config()[:app] |> to_string() app_camel = app |> Macro.camelize() + String.contains?(line, "MobBeam") or String.contains?(line, "MobNIF") or String.contains?(line, "MobBridge") or diff --git a/lib/mob_dev/server/ring_log.ex b/lib/mob_dev/server/ring_log.ex new file mode 100644 index 0000000..9616f49 --- /dev/null +++ b/lib/mob_dev/server/ring_log.ex @@ -0,0 +1,42 @@ +defmodule MobDev.Server.RingLog do + @moduledoc false + + # Shared GenServer behaviour for in-memory ring-buffer log holders. + # `LogBuffer` and `ElixirLogBuffer` differ only in their limit; both + # `use MobDev.Server.RingLog, limit: N` to get the standard + # `get/0`, `push/1`, `clear/0` API plus the GenServer callbacks. + + defmacro __using__(opts) do + limit = Keyword.fetch!(opts, :limit) + + quote bind_quoted: [limit: limit] do + use GenServer + + @limit limit + + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @spec get() :: [map()] + def get, do: GenServer.call(__MODULE__, :get) + + @spec push(map()) :: :ok + def push(line), do: GenServer.cast(__MODULE__, {:push, line}) + + @spec clear() :: :ok + def clear, do: GenServer.cast(__MODULE__, :clear) + + @impl GenServer + def init(_), do: {:ok, []} + + @impl GenServer + def handle_call(:get, _from, lines), do: {:reply, lines, lines} + + @impl GenServer + def handle_cast({:push, line}, lines), do: {:noreply, Enum.take([line | lines], @limit)} + def handle_cast(:clear, _lines), do: {:noreply, []} + end + end +end diff --git a/lib/mob_dev/server/router.ex b/lib/mob_dev/server/router.ex index c1cbd53..7a338e5 100644 --- a/lib/mob_dev/server/router.ex +++ b/lib/mob_dev/server/router.ex @@ -3,16 +3,16 @@ defmodule MobDev.Server.Router do import Phoenix.LiveView.Router pipeline :browser do - plug :accepts, ["html"] - plug :fetch_session - plug :fetch_live_flash - plug :protect_from_forgery - plug :put_secure_browser_headers - plug :put_root_layout, html: {MobDev.Server.Layouts, :root} + plug(:accepts, ["html"]) + plug(:fetch_session) + plug(:fetch_live_flash) + plug(:protect_from_forgery) + plug(:put_secure_browser_headers) + plug(:put_root_layout, html: {MobDev.Server.Layouts, :root}) end scope "/" do - pipe_through :browser - live "/", MobDev.Server.DashboardLive + pipe_through(:browser) + live("/", MobDev.Server.DashboardLive) end end diff --git a/lib/mob_dev/server/watch_worker.ex b/lib/mob_dev/server/watch_worker.ex index 105d3f9..b53ef81 100644 --- a/lib/mob_dev/server/watch_worker.ex +++ b/lib/mob_dev/server/watch_worker.ex @@ -17,26 +17,33 @@ defmodule MobDev.Server.WatchWorker do alias MobDev.HotPush - @pubsub MobDev.PubSub - @topic "watch" - @cookie :mob_secret - @interval 500 # ms between source polls - @debounce 300 # ms to wait after first change before compiling + @pubsub MobDev.PubSub + @topic "watch" + @cookie :mob_secret + # ms between source polls + @interval 500 + # ms to wait after first change before compiling + @debounce 300 # ── Public API ────────────────────────────────────────────────────────────── + @spec start_link(term()) :: GenServer.on_start() def start_link(_), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__) @doc "Start watching. Idempotent — safe to call when already watching." + @spec start_watching() :: term() def start_watching, do: GenServer.call(__MODULE__, :start) @doc "Stop watching." + @spec stop_watching() :: term() def stop_watching, do: GenServer.call(__MODULE__, :stop) @doc "Returns %{watching: bool, nodes: [node()], last_push: map | nil}." + @spec status() :: map() def status, do: GenServer.call(__MODULE__, :status) @doc "Subscribe the calling process to watch PubSub events." + @spec subscribe() :: :ok | {:error, term()} def subscribe, do: Phoenix.PubSub.subscribe(@pubsub, @topic) # ── GenServer ──────────────────────────────────────────────────────────────── @@ -52,10 +59,10 @@ defmodule MobDev.Server.WatchWorker do end def handle_call(:start, _from, state) do - nodes = connect_nodes() - sources = snapshot_sources() - timer = schedule_tick() - state = %{state | watching: true, sources: sources, nodes: nodes, timer: timer} + nodes = connect_nodes() + sources = MobDev.SourceWatch.snapshot() + timer = schedule_tick() + state = %{state | watching: true, sources: sources, nodes: nodes, timer: timer} broadcast({:watch_status, :watching}) Logger.info("WatchWorker: started, #{length(nodes)} node(s) connected") {:reply, :ok, state} @@ -76,8 +83,8 @@ defmodule MobDev.Server.WatchWorker do def handle_info(:tick, %{watching: false} = state), do: {:noreply, state} def handle_info(:tick, state) do - current = snapshot_sources() - changed = changed_files(state.sources, current) + current = MobDev.SourceWatch.snapshot() + changed = MobDev.SourceWatch.diff(state.sources, current) state = if changed == [] do @@ -85,7 +92,7 @@ defmodule MobDev.Server.WatchWorker do else # Debounce — let format-on-save and multi-file saves settle. Process.sleep(@debounce) - current2 = snapshot_sources() + current2 = MobDev.SourceWatch.snapshot() nodes = reconnect(state.nodes) @@ -100,9 +107,9 @@ defmodule MobDev.Server.WatchWorker do push_info = %{ pushed: pushed, failed: failed, - nodes: nodes, - files: Enum.map(changed, &Path.relative_to_cwd/1), - at: DateTime.utc_now() + nodes: nodes, + files: Enum.map(changed, &Path.relative_to_cwd/1), + at: DateTime.utc_now() } if pushed > 0 or failed != [] do @@ -131,7 +138,7 @@ defmodule MobDev.Server.WatchWorker do defp reconnect(nodes) do alive = Enum.filter(nodes, &(Node.connect(&1) == true)) - new = connect_nodes() + new = connect_nodes() Enum.uniq(alive ++ new) end @@ -140,22 +147,5 @@ defmodule MobDev.Server.WatchWorker do System.cmd(mix, ["compile"], cd: File.cwd!(), stderr_to_stdout: true) end - defp snapshot_sources do - Path.wildcard("lib/**/*.ex") - |> Map.new(fn path -> - mtime = case File.stat(path, time: :posix) do - {:ok, %{mtime: t}} -> t - _ -> 0 - end - {path, mtime} - end) - end - - defp changed_files(old, current) do - Enum.flat_map(current, fn {path, mtime} -> - if Map.get(old, path) != mtime, do: [path], else: [] - end) - end - defp broadcast(msg), do: Phoenix.PubSub.broadcast(@pubsub, @topic, msg) end diff --git a/lib/mob_dev/source_watch.ex b/lib/mob_dev/source_watch.ex new file mode 100644 index 0000000..9b5c1c5 --- /dev/null +++ b/lib/mob_dev/source_watch.ex @@ -0,0 +1,34 @@ +defmodule MobDev.SourceWatch do + @moduledoc false + + # Helpers shared by `mix mob.watch` and `MobDev.Server.WatchWorker` — + # both need a snapshot of `lib/**/*.ex` mtimes plus a diff against a + # previous snapshot. Each had its own copy until ex_dna flagged them. + + @doc """ + Take a snapshot of every `lib/**/*.ex` file's mtime (posix seconds). + Files that fail to stat get an mtime of 0. + """ + @spec snapshot() :: %{Path.t() => integer()} + def snapshot do + Map.new(Path.wildcard("lib/**/*.ex"), fn path -> + mtime = + case File.stat(path, time: :posix) do + {:ok, %{mtime: t}} -> t + _ -> 0 + end + + {path, mtime} + end) + end + + @doc """ + Return the file paths whose mtime in `current` differs from `old`. + """ + @spec diff(map(), map()) :: [Path.t()] + def diff(old, current) do + current + |> Enum.filter(fn {path, mtime} -> Map.get(old, path) != mtime end) + |> Enum.map(&elem(&1, 0)) + end +end diff --git a/lib/mob_dev/static_nifs.ex b/lib/mob_dev/static_nifs.ex new file mode 100644 index 0000000..b310215 --- /dev/null +++ b/lib/mob_dev/static_nifs.ex @@ -0,0 +1,712 @@ +defmodule MobDev.StaticNifs do + @moduledoc """ + Schema, defaults, and C-source generation for the static NIF table. + + The static NIF table lives in two C files inside an app's project: + + priv/generated/driver_tab_ios.c + priv/generated/driver_tab_android.c + + Both are linked **before** `libbeam.a` so they override BEAM's empty + built-in `erts_static_nif_tab[]`. With these in place, `load_nif/2` + resolves to the in-binary init function instead of falling back to + `dlopen`, which fails on iOS (App Store rejects bundled `.dylibs`) and + on Android (RTLD_LOCAL hides the parent's `enif_*` symbols from + child libraries). + + ## Declaring NIFs + + An app's `mob.exs` may add to or override the defaults via the + `:static_nifs` key: + + config :mob_dev, + static_nifs: [ + %{module: :my_native, archs: [:all]} + ] + + Each entry is a map with these fields: + + | Field | Type | Default | Meaning | + |------------|----------|-----------|----------------------------------------| + | `:module` | atom | required | Erlang module name | + | `:init` | string | derived | Init fn name. Defaults to `<mod>_nif_init` | + | `:builtin` | boolean | `false` | True for OTP-shipped libs | + | `:archs` | [atom] | `[:all]` | Where this NIF should appear | + | `:guard` | string | none | Preprocessor macro that gates the entry | + | `:extra_static_libs` | map | none | Per-ABI archives to link with this NIF | + + Valid `:archs` values: `:all`, `:ios`, `:android`, `:ios_sim`, + `:ios_device`, `:android_arm64`, `:android_arm32`. + + When `:archs` is a strict subset of a target platform's archs (e.g. + `[:ios_device]` for iOS), set `:guard` to a preprocessor macro that the + build defines only on those archs. The generated C file wraps both the + forward declaration and the table row in `#ifdef <guard>`. + + ## Defaults + + See `default_nifs/0` for the baked-in NIF set. It mirrors the hand-edited + `driver_tab_ios.c` / `driver_tab_android.c` files mob shipped through + v0.5.18 — `regen/1` against an empty user list produces byte-equivalent + output to those files. + """ + + # Top-level platforms cover both arches; per-arch "platforms" are + # accepted by `on_platform?/2` so cross-compile callers (e.g. + # `NativeBuild.project_nif_zig_args/1`) can filter entries against a + # specific ABI. + @type platform :: :ios | :android | arch() + @type arch :: + :all + | :ios + | :android + | :ios_sim + | :ios_device + | :android_arm64 + | :android_arm32 + + @type extra_static_lib_arch :: + :ios_sim + | :ios_device + | :android_arm64 + | :android_arm32 + | :android_x86_64 + + @type nif_entry :: %{ + required(:module) => atom(), + optional(:init) => String.t(), + optional(:builtin) => boolean(), + optional(:archs) => [arch()], + optional(:guard) => String.t(), + optional(:extra_static_libs) => %{optional(extra_static_lib_arch()) => Path.t()} + } + + @valid_archs [ + :all, + :ios, + :android, + :ios_sim, + :ios_device, + :android_arm64, + :android_arm32 + ] + + @valid_extra_static_lib_archs [ + :ios_sim, + :ios_device, + :android_arm64, + :android_arm32, + :android_x86_64 + ] + + @doc """ + Returns the baked-in NIF set used by every Mob app. + + These match the hand-edited `driver_tab_*.c` files in mob ≤ 0.5.18. + Users append to this list via `:static_nifs` in `mob.exs`. + """ + @spec default_nifs() :: [nif_entry()] + def default_nifs do + [ + %{module: :prim_tty}, + %{module: :erl_tracer}, + %{module: :prim_buffer}, + %{module: :prim_file}, + %{module: :zlib}, + %{module: :zstd}, + %{module: :prim_socket}, + %{module: :prim_net}, + %{module: :asn1rt_nif, builtin: true}, + %{module: :crypto, builtin: true}, + %{module: :mob_nif}, + %{ + module: :sqlite3_nif, + archs: [:ios_device], + guard: "MOB_STATIC_SQLITE_NIF" + }, + # EMLX NIF — statically linked when the project enables MLX via + # `mix mob.enable mlx`. The guard means the entry only fires when + # the build defines MOB_STATIC_EMLX_NIF, so apps that don't use Nx + # pay zero size cost. See MobDev.MLXDownloader for tarball fetching. + %{ + module: :emlx_nif, + archs: [:ios_device, :ios_sim], + guard: "MOB_STATIC_EMLX_NIF" + }, + # NxEigen NIF (Eigen-backed Nx backend) — statically linked when the + # project enables it via `mix mob.enable nxeigen`. Available on iOS + # AND Android (Eigen is header-only C++; mob_dev cross-compiles + # libnx_eigen.a per arch). Guard `MOB_STATIC_NX_EIGEN_NIF` keeps the + # entry zero-cost for apps that don't use Nx. See MobDev.NxEigenNif + # for the cross-compile. + %{ + module: :nx_eigen, + archs: [:all], + guard: "MOB_STATIC_NX_EIGEN_NIF" + }, + # TFLite NIF (TensorFlow Lite Nx backend) — statically linked when + # the project enables it via `mix mob.enable tflite`. Cross-platform: + # NNAPI on Android (vendor GPU/NPU HAL), CoreML on iOS (Apple + # Neural Engine). The TFLite runtime itself + # (`libtensorflowlite_jni.so` on Android, + # `TensorFlowLiteC.framework` on iOS) ships as a separate dynamic + # library bundled with the .apk / .app — only the NIF init has to be + # static. See `MobDev.TfliteNif` for the per-arch cross-compile and + # `MobDev.TfliteDownloader` for the runtime fetch/cache. + %{ + module: :tflite_nif, + archs: [:all], + guard: "MOB_STATIC_TFLITE_NIF" + } + ] + end + + @doc """ + Combines the defaults with a user list (typically from + `Application.get_env(:mob_dev, :static_nifs, [])`). + + Later entries with the same `:module` override earlier ones. This lets + users replace a default entry — e.g. drop `:sqlite3_nif` by setting + `archs: []` — without forking the default list. + """ + @spec resolve(user_nifs :: [nif_entry()]) :: [nif_entry()] + def resolve(user_nifs) when is_list(user_nifs) do + (default_nifs() ++ user_nifs) + |> Enum.reverse() + |> Enum.uniq_by(& &1.module) + |> Enum.reverse() + |> Enum.reject(&(Map.get(&1, :archs, [:all]) == [])) + end + + @doc """ + Validates a single entry, returning `:ok` or `{:error, reason}`. + """ + @spec validate_entry(nif_entry()) :: :ok | {:error, String.t()} + def validate_entry(%{module: module} = entry) when is_atom(module) do + cond do + Map.has_key?(entry, :init) and not is_binary(entry.init) -> + {:error, ":init must be a string, got #{inspect(entry.init)}"} + + Map.has_key?(entry, :builtin) and not is_boolean(entry.builtin) -> + {:error, ":builtin must be a boolean, got #{inspect(entry.builtin)}"} + + Map.has_key?(entry, :guard) and not is_binary(entry.guard) -> + {:error, ":guard must be a string, got #{inspect(entry.guard)}"} + + Map.has_key?(entry, :extra_static_libs) and + not valid_extra_static_libs?(entry.extra_static_libs) -> + {:error, + ":extra_static_libs must be a non-empty map of concrete arch => path string, got " <> + inspect(entry.extra_static_libs)} + + true -> + validate_archs(Map.get(entry, :archs, [:all])) + end + end + + def validate_entry(other), do: {:error, "expected a map with :module, got #{inspect(other)}"} + + # Per-ABI external static archives to add to the app link for this NIF. This + # lets a project NIF declare `extern` symbols and resolve them against an + # archive that is only valid for the current target ABI. + defp valid_extra_static_libs?(%{} = libs) when map_size(libs) > 0 do + Enum.all?(libs, fn {arch, path} -> + arch in @valid_extra_static_lib_archs and is_binary(path) + end) + end + + defp valid_extra_static_libs?(_), do: false + + defp validate_archs(archs) when is_list(archs) do + case Enum.reject(archs, &(&1 in @valid_archs)) do + [] -> :ok + bad -> {:error, "unknown archs: #{inspect(bad)}; valid: #{inspect(@valid_archs)}"} + end + end + + defp validate_archs(other), do: {:error, ":archs must be a list, got #{inspect(other)}"} + + @doc """ + Returns the init function name for an entry — either the explicit `:init` + value or the conventional `<module>_nif_init`. + """ + @spec init_fn(nif_entry()) :: String.t() + def init_fn(%{init: init}) when is_binary(init), do: init + def init_fn(%{module: module}), do: "#{module}_nif_init" + + @doc """ + Returns true if the entry should appear in the generated file for this + platform (i.e. its archs intersect the platform's archs). + """ + @spec on_platform?(nif_entry(), platform()) :: boolean() + def on_platform?(entry, platform) do + entry_archs = Map.get(entry, :archs, [:all]) |> expand_archs() |> MapSet.new() + platform_archs = platform_archs(platform) |> MapSet.new() + not MapSet.disjoint?(entry_archs, platform_archs) + end + + @doc """ + Returns true if the entry's archs are a *strict* subset of the platform's + archs (i.e. it's present on this platform but not all of its arches). When + true, the generated entry must be wrapped in `#ifdef <guard>`. + """ + @spec needs_guard?(nif_entry(), platform()) :: boolean() + def needs_guard?(entry, platform) do + entry_archs = Map.get(entry, :archs, [:all]) |> expand_archs() |> MapSet.new() + platform_archs = platform_archs(platform) |> MapSet.new() + on_platform?(entry, platform) and not MapSet.subset?(platform_archs, entry_archs) + end + + defp platform_archs(:ios), do: [:ios_sim, :ios_device] + defp platform_archs(:android), do: [:android_arm64, :android_arm32] + # Per-arch "platforms" — used by `NativeBuild.project_nif_zig_args/1` + # to filter user NIF entries against a specific ABI when the iOS or + # Android build path needs to cross-compile per-ABI (e.g. Android's + # arm64 + armv7 .sos each get their own static-NIF set). Each is a + # singleton list so `on_platform?/2`'s intersection check still + # behaves the way the broader `:ios`/`:android` callers expect. + defp platform_archs(:ios_device), do: [:ios_device] + defp platform_archs(:ios_sim), do: [:ios_sim] + defp platform_archs(:android_arm64), do: [:android_arm64] + defp platform_archs(:android_arm32), do: [:android_arm32] + + defp expand_archs(archs) do + Enum.flat_map(archs, fn + :all -> [:ios_sim, :ios_device, :android_arm64, :android_arm32] + :ios -> [:ios_sim, :ios_device] + :android -> [:android_arm64, :android_arm32] + other -> [other] + end) + end + + @doc """ + Generates the driver_tab source for one platform. + + Format is `:c` by default (produces `driver_tab_<platform>.c` matching + the hand-edited reference files byte-for-byte). Pass `format: :zig` + for the Phase 6a Zig output — same semantics, structured as + comptime-friendly Zig (`extern struct` ABI types, `export` for the + C-callable symbols, `if (sqlite_static) ... else ...` in place of + `#ifdef`). + + Pure function — given the same nif list it always produces the same + bytes. + """ + @spec generate(platform(), [nif_entry()]) :: iodata() + @spec generate(platform(), [nif_entry()], keyword()) :: iodata() + def generate(platform, nifs, opts \\ []) when platform in [:ios, :android] do + case Keyword.get(opts, :format, :c) do + :c -> generate_c(platform, nifs) + :zig -> generate_zig(platform, nifs) + end + end + + defp generate_c(platform, nifs) do + applicable = Enum.filter(nifs, &on_platform?(&1, platform)) + + [ + header(platform), + driver_tab_block(), + forward_decls(applicable, platform), + "\n", + static_nif_tab(applicable, platform) + ] + end + + defp generate_zig(platform, nifs) do + applicable = Enum.filter(nifs, &on_platform?(&1, platform)) + + [ + zig_header(platform), + zig_extern_decls(applicable, platform), + "\n", + zig_driver_tab_block(), + zig_static_nif_tab(applicable, platform) + ] + end + + # ── Zig output ──────────────────────────────────────────────────────────── + + defp zig_header(:ios) do + """ + //! driver_tab_ios.zig — Static NIF table generated by mix mob.regen_driver_tab. + //! DO NOT EDIT. Regenerate via `mix mob.regen_driver_tab` after changing + //! :static_nifs in mob.exs. + //! + //! Linked BEFORE libbeam.a so it overrides BEAM's empty built-in driver_tab. + + 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; + + extern var inet_driver_entry: ErlDrvEntryStub; + extern var ram_file_driver_entry: ErlDrvEntryStub; + + """ + end + + defp zig_header(:android) do + """ + //! driver_tab_android.zig — Static NIF table generated by mix mob.regen_driver_tab. + //! DO NOT EDIT. Regenerate via `mix mob.regen_driver_tab` after changing + //! :static_nifs in mob.exs. + //! + //! Linked BEFORE libbeam.a so it overrides BEAM's empty built-in driver_tab. + + 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; + + extern var inet_driver_entry: ErlDrvEntryStub; + extern var ram_file_driver_entry: ErlDrvEntryStub; + + """ + end + + defp zig_extern_decls(nifs, platform) do + # iOS: each guarded NIF (e.g. :sqlite3_nif on device only, :emlx_nif + # when EMLX is enabled) gets its own comptime flag, threaded in via + # `b.addOptions` in build_device.zig. Flag name is derived from the + # `:guard` value — see guard_flag_name/1. + plain = + nifs + |> Enum.reject(&needs_guard_in_zig?(&1, platform)) + |> Enum.map(fn nif -> + "extern fn #{init_fn(nif)}() callconv(.c) ?*anyopaque;\n" + end) + + guarded = Enum.filter(nifs, &needs_guard_in_zig?(&1, platform)) + + guard_imports = + case guarded do + [] -> + [] + + _ -> + flag_decls = + guarded + |> Enum.map(& &1.guard) + |> Enum.uniq() + |> Enum.map(fn guard -> + "const #{guard_flag_name(guard)} = build_options.#{guard_flag_name(guard)};\n" + end) + + [ + "\n", + "// Comptime flags threaded from build.zig via b.addOptions().\n", + "// Each per-feature flag defaults to false; the build sets it to true\n", + "// when the project opts into the corresponding statically-linked NIF.\n", + "const build_options = @import(\"build_options\");\n", + flag_decls, + "\n", + Enum.map(guarded, fn nif -> + "extern fn #{init_fn(nif)}() callconv(.c) ?*anyopaque;\n" + end) + ] + end + + [plain, guard_imports] + end + + defp needs_guard_in_zig?(nif, platform) do + guarded?(nif) and on_platform?(nif, platform) + end + + @doc """ + True when the entry carries a `:guard` key. The guard is the user's + explicit opt-in (e.g. `MOB_STATIC_EMLX_NIF`) and gets emitted as a + preprocessor `#ifdef` in C output or a comptime const in Zig output, + independent of whether the entry's archs narrow the platform. + """ + @spec guarded?(nif_entry()) :: boolean() + def guarded?(nif), do: Map.has_key?(nif, :guard) + + # Convention: MOB_STATIC_SQLITE_NIF → sqlite_static, MOB_STATIC_EMLX_NIF → + # emlx_static. Lowercased, stripped of the `MOB_STATIC_` prefix and the + # `_NIF` suffix. Future guards follow the same convention. + defp guard_flag_name(guard) when is_binary(guard) do + guard + |> String.replace_prefix("MOB_STATIC_", "") + |> String.replace_suffix("_NIF", "") + |> String.downcase() + |> Kernel.<>("_static") + end + + defp zig_driver_tab_block do + """ + 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 {} + + """ + end + + defp zig_static_nif_tab(nifs, platform) do + plain_nifs = Enum.reject(nifs, &needs_guard_in_zig?(&1, platform)) + guarded_nifs = Enum.filter(nifs, &needs_guard_in_zig?(&1, platform)) + + base_rows = Enum.map(plain_nifs, &zig_nif_row/1) + + sentinel_row = + " .{ .nif_init = null, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null },\n" + + case guarded_nifs do + [] -> + [ + "export var erts_static_nif_tab = [_]ErtsStaticNif{\n", + base_rows, + sentinel_row, + "};\n" + ] + + [_ | _] -> + # Build the table comptime — base rows + per-flag conditionally-added + # guarded rows + sentinel. Each guarded NIF gets its own ErtsStaticNif + # const, and the final array is selected via a branching block. + per_nif_consts = + Enum.map(guarded_nifs, fn nif -> + init = init_fn(nif) + builtin = if Map.get(nif, :builtin, false), do: "1", else: "0" + + "const #{nif_const_name(nif)} = ErtsStaticNif{ " <> + ".nif_init = #{init}, .is_builtin = #{builtin}, " <> + ".nif_mod = THE_NON_VALUE, .entry = null };\n" + end) + + [ + "const base_nifs = [_]ErtsStaticNif{\n", + base_rows, + "};\n\n", + per_nif_consts, + "\n", + "const sentinel = ErtsStaticNif{ .nif_init = null, .is_builtin = 0, .nif_mod = THE_NON_VALUE, .entry = null };\n\n", + zig_branching_table(guarded_nifs) + ] + end + end + + # Builds the `export var erts_static_nif_tab = blk: { ... }` chain. + # For N guarded NIFs we emit 2^N branches enumerating every subset of + # active guards. N=1 → 2 branches (current sqlite-only behavior). N=2 → + # 4 branches (sqlite + emlx). Higher N is theoretical for now. + defp zig_branching_table(guarded_nifs) do + n = length(guarded_nifs) + subsets = subsets(guarded_nifs) + + branches = + subsets + # Most-specific subsets first (all flags true) so `if (a and b)` shadows + # `if (a)` correctly. + |> Enum.sort_by(&(-length(&1))) + |> Enum.with_index() + |> Enum.map(fn {active, idx} -> + condition = zig_branch_condition(active, guarded_nifs) + rows_expr = zig_branch_rows(active) + keyword = if idx == 0, do: "if", else: "} else if" + + if length(active) == 0 do + "} else {\n break :blk #{rows_expr};\n" + else + "#{keyword} (#{condition}) {\n break :blk #{rows_expr};\n" + end + end) + + case n do + 1 -> + # Simpler shape for the common N=1 case — matches the legacy output. + [nif] = guarded_nifs + + [ + "export var erts_static_nif_tab = blk: {\n", + " if (#{guard_flag_name(nif.guard)}) {\n", + " break :blk base_nifs ++ [_]ErtsStaticNif{ #{nif_const_name(nif)}, sentinel };\n", + " } else {\n", + " break :blk base_nifs ++ [_]ErtsStaticNif{sentinel};\n", + " }\n", + "};\n" + ] + + _ -> + [ + "export var erts_static_nif_tab = blk: {\n ", + Enum.intersperse(branches, " "), + " }\n};\n" + ] + end + end + + defp subsets([]), do: [[]] + + defp subsets([h | t]) do + rest = subsets(t) + rest ++ Enum.map(rest, &[h | &1]) + end + + defp zig_branch_condition([], _all), do: "true" + + defp zig_branch_condition(active, _all) do + active + |> Enum.map(&guard_flag_name(&1.guard)) + |> Enum.join(" and ") + end + + defp zig_branch_rows([]) do + "base_nifs ++ [_]ErtsStaticNif{sentinel}" + end + + defp zig_branch_rows(active) do + extras = + active + |> Enum.map(&nif_const_name/1) + |> Enum.join(", ") + + "base_nifs ++ [_]ErtsStaticNif{ #{extras}, sentinel }" + end + + defp nif_const_name(%{module: module}), do: "#{module}_const" + + defp zig_nif_row(nif) do + init = init_fn(nif) + builtin = if Map.get(nif, :builtin, false), do: "1", else: "0" + + " .{ .nif_init = #{init}, .is_builtin = #{builtin}, .nif_mod = THE_NON_VALUE, .entry = null },\n" + end + + defp header(:ios) do + """ + // driver_tab_ios.c — Static NIF table generated by mix mob.regen_driver_tab. + // DO NOT EDIT. Regenerate via `mix mob.regen_driver_tab` after changing + // :static_nifs in mob.exs. + // + // Linked BEFORE libbeam.a so it overrides BEAM's empty 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; + + """ + end + + defp header(:android) do + """ + // driver_tab_android.c — Static NIF table generated by mix mob.regen_driver_tab. + // DO NOT EDIT. Regenerate via `mix mob.regen_driver_tab` after changing + // :static_nifs in mob.exs. + // + // Linked BEFORE libbeam.a so it overrides BEAM's empty 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; + + """ + end + + defp driver_tab_block do + """ + ErtsStaticDriver driver_tab[] = { + {&inet_driver_entry, 0}, + {&ram_file_driver_entry, 0}, + {NULL, 0} + }; + + void erts_init_static_drivers(void) {} + + """ + end + + defp forward_decls(nifs, platform) do + Enum.map(nifs, fn nif -> + decl = "void *#{init_fn(nif)}(void);\n" + + if guarded?(nif) and on_platform?(nif, platform) do + "#ifdef #{nif.guard}\n#{decl}#endif\n" + else + decl + end + end) + end + + defp static_nif_tab(nifs, platform) do + rows = + Enum.map(nifs, fn nif -> + row = format_row(nif) + + if guarded?(nif) and on_platform?(nif, platform) do + "#ifdef #{nif.guard}\n #{row}\n#endif\n" + else + " #{row}\n" + end + end) + + [ + "ErtsStaticNif erts_static_nif_tab[] = {\n", + rows, + " {NULL, 0, THE_NON_VALUE, NULL}\n};\n" + ] + end + + defp format_row(nif) do + init = init_fn(nif) + builtin = if Map.get(nif, :builtin, false), do: "1", else: "0" + # Pad the init name to 22 chars so columns align like the hand-edited file. + padded = String.pad_trailing("#{init},", 23) + "{#{padded}#{builtin}, THE_NON_VALUE, NULL}," + end +end diff --git a/lib/mob_dev/style.ex b/lib/mob_dev/style.ex new file mode 100644 index 0000000..aeed727 --- /dev/null +++ b/lib/mob_dev/style.ex @@ -0,0 +1,177 @@ +defmodule MobDev.Style do + @moduledoc """ + The styles lane (MOB_STYLES.md), minimum-viable slice: token-only style + packages. A style package ships `priv/mob_style.exs` declaring a theme + module; activation is `config :mob, :styles, [...]` plus + `config :mob, :default_style, :name` in `mob.exs`, and the runtime manifest + carries the resolved set so core can apply the default style's theme at boot. + + Tokens-only is the doc's smallest tier ("repalette-only styles"); the + per-component native-override tier (cascade, `_style` dispatch — the mob_m3 + case) is NOT implemented yet. + """ + + @manifest_path "priv/mob_style.exs" + @supported_spec_versions [1] + + @doc """ + Loads a style manifest from `style_dir`. `{:ok, map}`, or `{:error, reason}` + when the file is missing/unreadable/not a map (styles REQUIRE a manifest — + there is no tier-0 equivalent). + """ + @spec load(Path.t()) :: {:ok, map()} | {:error, String.t()} + def load(style_dir) do + path = Path.join(style_dir, @manifest_path) + + if File.exists?(path) do + case Code.eval_file(path) do + {map, _} when is_map(map) -> {:ok, map} + {other, _} -> {:error, "#{@manifest_path} must evaluate to a map, got: #{inspect(other)}"} + end + else + {:error, "missing #{@manifest_path}"} + end + rescue + e -> {:error, "could not evaluate #{@manifest_path}: #{Exception.message(e)}"} + end + + @doc """ + Validates the four required fields (MOB_STYLES.md "Minimum viable + manifest"): `:name` atom, `:mob_version` requirement string, + `:style_spec_version` integer, `:theme` module atom. + """ + @spec validate(map()) :: {:ok, map()} | {:error, [String.t()]} + def validate(manifest) when is_map(manifest) do + errors = + [] + |> check( + manifest, + :name, + &(is_atom(&1) and not is_nil(&1)), + ":name is required and must be an atom" + ) + |> check_version(manifest) + |> check_spec_version(manifest) + |> check( + manifest, + :theme, + &(is_atom(&1) and not is_nil(&1)), + ":theme is required and must be a theme module atom" + ) + + case errors do + [] -> {:ok, manifest} + errs -> {:error, Enum.reverse(errs)} + end + end + + def validate(other), + do: + {:error, + ["style manifest must be a map (the priv/mob_style.exs data), got: #{inspect(other)}"]} + + @doc """ + Activated style names from `mob.exs`'s `config :mob, :styles` (Application + env fallback, mirroring `MobDev.Plugin.activated_names/0`). + """ + @spec activated_names() :: [atom()] + def activated_names, do: read_mob_config(:styles, []) + + @doc "The configured `:default_style` name (or nil)." + @spec default_style() :: atom() | nil + def default_style, do: read_mob_config(:default_style, nil) + + @doc """ + The activated styles as `{style_dir, manifest}` pairs. Names that don't + resolve to a dep or whose manifest fails to load are skipped (surfaced by + validation at build). + """ + @spec activated() :: [{Path.t(), map()}] + def activated do + deps = Mix.Project.deps_paths() + + for name <- activated_names(), dir = deps[name], not is_nil(dir) do + case load(dir) do + {:ok, manifest} -> {dir, manifest} + {:error, _} -> nil + end + end + |> Enum.reject(&is_nil/1) + end + + @doc """ + The runtime-manifest entries for the activated styles: + `[%{name, theme}]`. Raises at build when an activated style's manifest is + invalid or the configured `:default_style` isn't among the activated styles + — a misconfigured style must fail the BUILD, not silently render baseline. + """ + @spec runtime_entries!() :: %{styles: [map()], default_style: atom() | nil} + def runtime_entries! do + entries = + for {dir, manifest} <- activated() do + case validate(manifest) do + {:ok, m} -> + %{name: m.name, theme: m.theme} + + {:error, errs} -> + raise ArgumentError, + "invalid style manifest in #{dir}:\n " <> Enum.join(errs, "\n ") + end + end + + default = default_style() + + if default != nil and not Enum.any?(entries, &(&1.name == default)) do + raise ArgumentError, + ":default_style #{inspect(default)} is not among the activated styles " <> + "#{inspect(Enum.map(entries, & &1.name))} — add it to config :mob, :styles" + end + + %{styles: entries, default_style: default} + end + + # ── helpers ──────────────────────────────────────────────────────────────── + + defp check(errors, manifest, key, pred, msg) do + if pred.(Map.get(manifest, key)), do: errors, else: [msg | errors] + end + + defp check_version(errors, %{mob_version: req}) when is_binary(req) do + case Version.parse_requirement(req) do + {:ok, _} -> errors + :error -> [":mob_version #{inspect(req)} is not a valid version requirement" | errors] + end + end + + defp check_version(errors, _), + do: [":mob_version is required and must be a version requirement string" | errors] + + defp check_spec_version(errors, %{style_spec_version: v}) when is_integer(v) do + if v in @supported_spec_versions do + errors + else + [ + "style_spec_version #{v} is not supported (this mob_dev knows #{inspect(@supported_spec_versions)})" + | errors + ] + end + end + + defp check_spec_version(errors, _), + do: [":style_spec_version is required and must be an integer" | errors] + + defp read_mob_config(key, default) do + config_file = Path.join(File.cwd!(), "mob.exs") + + if File.exists?(config_file) do + config_file + |> Config.Reader.read!() + |> Keyword.get(:mob, []) + |> Keyword.get(key, default) + else + Application.get_env(:mob, key, default) + end + rescue + _ -> Application.get_env(:mob, key, default) + end +end diff --git a/lib/mob_dev/support_matrix.ex b/lib/mob_dev/support_matrix.ex new file mode 100644 index 0000000..9fde6b6 --- /dev/null +++ b/lib/mob_dev/support_matrix.ex @@ -0,0 +1,294 @@ +defmodule MobDev.SupportMatrix do + @moduledoc """ + Per-feature device requirements + the validation that runs before + `mix mob.deploy` builds anything. + + The instinct is to ship the latest-arch path because that's where the + upstream toolchains are easiest to integrate. The cost of that + instinct is silent failure: a user with an older / cheaper / 32-bit + device buys hardware, runs `mix mob.deploy`, sees a vague gradle + error, and walks away assuming Mob is broken. They never find out + the device was below our floor. + + This module makes the floors explicit, declarable, and enforced: + + * `feature_requirements/1` — the data. What ABIs / SDK levels does + a feature need? Where does the constraint come from upstream + (Chaquopy, BeeWare, Apple)? + * `enabled_features/1` — what features does *this* project use? + Inferred from the project's mix.exs / generated files, not from + a flag the user has to remember to set. + * `check_device/2` — given a device's discovered properties + a + list of enabled features, return `:ok` or + `{:error, [%{feature, reason, device}]}`. + + `mix mob.deploy` calls these before invoking `MobDev.NativeBuild` so + the message a user sees on an unsupported device is: + + ✗ Moto e (armeabi-v7a, Android 10) cannot run this project: + - pythonx requires Android arm64-v8a or x86_64. Chaquopy + (the upstream Python distribution we bundle) dropped + 32-bit Android support several releases ago. + + To target this device, either disable Pythonx or use an arm64 + device. See guides/support_matrix.md for the full floor. + + …rather than a build that silently produces an APK the device can't + load. + + ## Adding a new feature + + When you add a feature that has device requirements distinct from + the base Mob floor, add a clause to `feature_requirements/1` and a + detection clause to `enabled_features/1`. Don't bury the constraint + in build-time code — that's how silent failures happen. + """ + + alias MobDev.Device + + @typedoc "Requirement spec for a single feature on a single platform." + @type platform_req :: %{ + required(:abis) => [String.t()], + required(:min_sdk) => non_neg_integer(), + optional(:reason) => String.t() + } + + @typedoc """ + Full requirement spec for a feature. No feature currently models + `:unsupported` (a feature outright incompatible with a platform) — + if one needs to, add `| :unsupported` to the platform value type + here AND re-add the matching case clause in `check_against/2`. + """ + @type feature_req :: %{ + required(:android) => platform_req(), + required(:ios) => platform_req() + } + + @typedoc "An incompatibility found by check_device/2." + @type incompatibility :: %{ + device: Device.t(), + feature: atom(), + reason: String.t() + } + + # ── Base Mob floor ───────────────────────────────────────────────────────── + # + # Every Mob app needs at least these. Features that just inherit the + # floor don't need a separate clause in feature_requirements/1 — + # they're covered by `:base`. + + # Empirically corrected after a 32-bit Moto e deploy: + # - The BEAM, libpigeon.so, and erts helper binaries all build + # and run on armeabi-v7a (`mob.install` already fetches an + # `otp-android-arm32-*` cache and the gradle build's + # abiFilters include armeabi-v7a). A vanilla Mob app boots + # through all 5 launcher steps on a 2018-vintage Moto e. + # - The 32-bit failure mode lives in `:pythonx`, not the base. + # Chaquopy stopped shipping armv7 CPython, so `:pythonx` apps + # fall through PythonPaths.detect → `:desktop`, then crash on + # `Pythonx.Uv.fetch` because uv has no + # `arm-unknown-linux-androideabi` build either. + @base %{ + android: %{ + abis: ["arm64-v8a", "x86_64", "armeabi-v7a"], + min_sdk: 28, + reason: + "Mob's BEAM/erts is built for arm64-v8a, x86_64 (emulator), " <> + "and armeabi-v7a. Older 32-bit Android phones (Moto e, " <> + "low-end devices through ~2018) can run vanilla Mob apps " <> + "— the per-feature constraints below are what cut deeper." + }, + ios: %{ + abis: ["arm64"], + min_sdk: 13, + reason: + "Mob targets iOS 13+ and bundles arm64 OTP. The simulator slice is " <> + "arm64 on Apple Silicon Macs, x86_64 on Intel Macs." + } + } + + @doc """ + Returns the base device requirements every Mob app inherits. + """ + @spec base_requirements() :: feature_req() + def base_requirements, do: @base + + # ── Per-feature requirements ─────────────────────────────────────────────── + + @doc """ + Returns the device requirements for a specific feature. + + Returns `nil` for unknown features (caller should treat as base-only). + """ + @spec feature_requirements(atom()) :: feature_req() | nil + def feature_requirements(:base), do: @base + + def feature_requirements(:pythonx) do + %{ + android: %{ + abis: ["arm64-v8a", "x86_64"], + min_sdk: 28, + reason: + "Pythonx on Android bundles Chaquopy's prebuilt CPython distribution. " <> + "Chaquopy ships arm64-v8a and x86_64 only — they dropped armeabi-v7a " <> + "(32-bit ARM) several releases back. On a 32-bit phone the asset " <> + "extraction yields no usable lib-dynload, MOB_PYTHON_DL stays unset, " <> + "PythonPaths.detect/1 falls through to :desktop, and Pythonx.Uv.fetch " <> + "then crashes with \"uv is not available for architecture: " <> + "arm-unknown-linux-androideabi\" — there's no uv build for armv7 " <> + "Android either. Verified empirically on a Moto e (Android 10)." + }, + ios: %{ + abis: ["arm64"], + min_sdk: 13, + reason: + "Pythonx on iOS bundles BeeWare's Python-Apple-support framework " <> + "(arm64 device + arm64/x86_64 simulator). iOS 13 is the BeeWare floor." + } + } + end + + def feature_requirements(_), do: nil + + # ── Enabled-features detection ───────────────────────────────────────────── + + @doc """ + Returns the list of features enabled in `project_dir` that have + non-base device requirements. + + Inferred from project artifacts (deps in `mix.exs`, generated source + files), not from a flag — so a user can't accidentally bypass the + validation by forgetting an option. + """ + @spec enabled_features(Path.t()) :: [atom()] + def enabled_features(project_dir) do + [ + {:pythonx, &pythonx_enabled?/1} + ] + |> Enum.filter(fn {_feature, detect} -> detect.(project_dir) end) + |> Enum.map(&elem(&1, 0)) + end + + defp pythonx_enabled?(project_dir) do + mix_exs = Path.join(project_dir, "mix.exs") + + case File.read(mix_exs) do + {:ok, content} -> String.contains?(content, ":pythonx") + _ -> false + end + end + + # ── Validation ───────────────────────────────────────────────────────────── + + @doc """ + Checks that `device` can run the given list of enabled features + (plus the base Mob floor). + + Returns `:ok` if every requirement is satisfied, or + `{:error, [incompatibility]}` listing every reason the device is + unsupported. We collect every reason rather than short-circuiting so + the user sees the full picture, not a one-at-a-time game of + whack-a-mole. + """ + @spec check_device(Device.t(), [atom()]) :: :ok | {:error, [incompatibility()]} + def check_device(%Device{} = device, features) when is_list(features) do + issues = + [:base | features] + |> Enum.flat_map(&check_against(&1, device)) + + case issues do + [] -> :ok + _ -> {:error, issues} + end + end + + defp check_against(feature, %Device{platform: platform} = device) do + case feature_requirements(feature) do + nil -> + [] + + reqs -> + case Map.get(reqs, platform) do + %{} = req -> + check_platform(feature, device, req) + + nil -> + [] + end + end + end + + defp check_platform(feature, %Device{} = device, %{abis: abis, min_sdk: min_sdk} = req) do + abi_issue = + cond do + # Discovery may not populate abi (older mob_dev installs, or + # iOS where we don't query it). When unknown, we skip the + # check rather than guess — better silent passthrough than a + # false positive that blocks a valid device. + device.abi == nil -> nil + device.abi in abis -> nil + true -> abi_message(feature, device, abis, req) + end + + sdk_issue = + cond do + device.sdk_level == nil -> nil + device.sdk_level >= min_sdk -> nil + true -> sdk_message(feature, device, min_sdk, req) + end + + [abi_issue, sdk_issue] + |> Enum.reject(&is_nil/1) + |> Enum.map(fn reason -> %{device: device, feature: feature, reason: reason} end) + end + + defp abi_message(feature, device, abis, req) do + base = + "#{label(feature)} requires #{device.platform} #{Enum.join(abis, " or ")}; " <> + "this device is #{device.abi}." + + case Map.get(req, :reason) do + nil -> base + detail -> base <> " " <> detail + end + end + + defp sdk_message(feature, device, min_sdk, req) do + base = + "#{label(feature)} requires #{device.platform} SDK/version " <> + ">= #{min_sdk}; this device is at #{device.sdk_level}." + + case Map.get(req, :reason) do + nil -> base + detail -> base <> " " <> detail + end + end + + defp label(:base), do: "Mob" + defp label(feature), do: to_string(feature) + + # ── Pretty error block ───────────────────────────────────────────────────── + + @doc """ + Renders an `{:error, [incompatibility]}` result as the human-readable + block printed by `mix mob.deploy` before exiting. + + Groups by device so multi-feature failures on one device collapse + into a single block, then re-displays the device summary line. + """ + @spec format_error([incompatibility()]) :: String.t() + def format_error(issues) when is_list(issues) do + issues + |> Enum.group_by(& &1.device) + |> Enum.map_join("\n", fn {device, group} -> + header = " ✗ #{Device.summary(device)}" + + reasons = + group + |> Enum.map(fn %{reason: reason} -> " - #{reason}" end) + |> Enum.join("\n") + + header <> "\n" <> reasons + end) + end +end diff --git a/lib/mob_dev/task_help.ex b/lib/mob_dev/task_help.ex new file mode 100644 index 0000000..f5d1179 --- /dev/null +++ b/lib/mob_dev/task_help.ex @@ -0,0 +1,62 @@ +defmodule MobDev.TaskHelp do + @moduledoc """ + Helpers for `--help` / `-h` handling in mob_dev Mix tasks. + + Mix has `mix help <task>` built in (reads the task module's + `@moduledoc`), but users from outside the Elixir ecosystem expect + `<command> --help` to work too. This module gives each task a + one-line opt-in: + + def run(args) do + if MobDev.TaskHelp.help_requested?(args) do + MobDev.TaskHelp.print_module_help(__MODULE__) + else + # normal flow… + end + end + + Public API is intentionally tiny — two predicates and a printer — + so a future change to where `@moduledoc` is stored (or how it gets + rendered) only ripples through one file. + """ + + @doc """ + True when `argv` contains `--help` or `-h` as a standalone arg. + + iex> MobDev.TaskHelp.help_requested?(["--help"]) + true + + iex> MobDev.TaskHelp.help_requested?(["-h"]) + true + + iex> MobDev.TaskHelp.help_requested?(["--device", "foo"]) + false + + iex> MobDev.TaskHelp.help_requested?([]) + false + """ + @spec help_requested?([String.t()]) :: boolean() + def help_requested?(argv) when is_list(argv) do + "--help" in argv or "-h" in argv + end + + @doc """ + Prints `task_module`'s `@moduledoc` to stdout. + + Falls back to a small "no docs available" message if the module + has no `@moduledoc` (which shouldn't happen for any mob_dev task + but the fallback keeps `print_module_help/1` total). + """ + @spec print_module_help(module()) :: :ok + def print_module_help(task_module) when is_atom(task_module) do + case Code.fetch_docs(task_module) do + {:docs_v1, _, _, _, %{"en" => doc}, _, _} when is_binary(doc) -> + IO.puts(doc) + + _ -> + IO.puts("(no documentation found for #{inspect(task_module)} — `mix help` may have more)") + end + + :ok + end +end diff --git a/lib/mob_dev/tflite_downloader.ex b/lib/mob_dev/tflite_downloader.ex new file mode 100644 index 0000000..dd71695 --- /dev/null +++ b/lib/mob_dev/tflite_downloader.ex @@ -0,0 +1,307 @@ +defmodule MobDev.TfliteDownloader do + @moduledoc """ + Downloads and caches pre-built TensorFlow Lite native libraries for + Android and iOS so Mob apps can ship the TFLite Nx backend without + building TFLite from source (Bazel). + + Two upstream sources: + + * **Android** — `tensorflow-lite-2.16.1.aar` from Maven Central + (the last release with a packed `.aar`; 2.17.0 ships `.jar` only). + Extracted to `jni/arm64-v8a/libtensorflowlite_jni.so` + + `headers/tensorflow/lite/c/c_api.h` etc. + * **iOS** — `TensorFlowLiteC-2.17.0.tar.gz` from `dl.google.com` + (CocoaPods upstream). Contains three xcframeworks (Core, CoreML, + Metal) each with `ios-arm64` device + `ios-arm64_x86_64-simulator` + slices. + + The C API is binary-compatible between these versions — TFLite's + `c_api.h` surface has been stable since 2.13. Different version pins + per platform reflect upstream packaging differences, not API drift. + + Mirrors the `MobDev.MLXDownloader` pattern: hashed URL + cached + extraction at `~/.mob/cache/tflite-<version>-<target>/`, validated + against the expected layout. Reused across projects. + + Used by `MobDev.NativeBuild` when the project enables TFLite via + `mix mob.enable tflite`. The build template sources `TFLITE_DIR` from + `dir/1` and links the `tflite_nif.c` NIF against the headers and + framework / .so it provides. + + ## Local-build override + + Set `MOB_TFLITE_LOCAL_TARBALL_DIR=/path/to/dir` to bypass the upstream + download and use locally-fetched tarballs (named exactly as + `tarball_name/1` returns). Useful when iterating offline or against + a custom TFLite build. + """ + + @android_version "2.16.1" + @ios_version "2.17.0" + + @android_aar_url "https://repo1.maven.org/maven2/org/tensorflow/tensorflow-lite/#{@android_version}/tensorflow-lite-#{@android_version}.aar" + + # iOS tarball URL is content-addressed by the CocoaPods publishing + # pipeline — the `0c10b3543e01f547` segment is a build hash that + # changes per upstream release. Captured here so the download is + # deterministic without needing to scrape the podspec at build time. + @ios_tarball_url "https://dl.google.com/tflite-release/ios/prod/tensorflow/lite/release/ios/release/32/20240729-115310/TensorFlowLiteC/#{@ios_version}/0c10b3543e01f547/TensorFlowLiteC-#{@ios_version}.tar.gz" + + @typedoc "Target slice this downloader supports." + @type target :: :android_arm64 | :android_arm32 | :ios_device | :ios_sim + + @android_targets [:android_arm64, :android_arm32] + @ios_targets [:ios_device, :ios_sim] + + # ── Public API ────────────────────────────────────────────────────────────── + + @doc """ + Ensure the TFLite bundle for `target` is cached and extracted. + + Returns `{:ok, path}` where `path` is the unpacked root containing the + expected layout (see `valid_dir?/2`). + """ + @spec ensure(target()) :: {:ok, String.t()} | {:error, term()} + def ensure(target) when target in @android_targets do + dest = dir(target) + + if valid_dir?(target, dest) do + {:ok, dest} + else + if File.dir?(dest), do: File.rm_rf!(dest) + download_and_extract_android(target, dest) + end + end + + def ensure(target) when target in @ios_targets do + dest = dir(target) + + if valid_dir?(target, dest) do + {:ok, dest} + else + if File.dir?(dest), do: File.rm_rf!(dest) + download_and_extract_ios(target, dest) + end + end + + @doc """ + Cached TFLite root directory for `target`. May not exist if `ensure/1` + hasn't been called. + """ + @spec dir(target()) :: String.t() + def dir(target), do: Path.join(cache_dir(), name(target)) + + @doc """ + Returns true if the cache directory has the expected layout for the + given target. Public for tests and `NativeBuild` probing. + """ + @spec valid_dir?(target(), String.t()) :: boolean() + def valid_dir?(target, dir) when target == :android_arm64 do + File.regular?(Path.join([dir, "jni", "arm64-v8a", "libtensorflowlite_jni.so"])) and + File.regular?(Path.join([dir, "headers", "tensorflow", "lite", "c", "c_api.h"])) + end + + def valid_dir?(target, dir) when target == :android_arm32 do + File.regular?(Path.join([dir, "jni", "armeabi-v7a", "libtensorflowlite_jni.so"])) and + File.regular?(Path.join([dir, "headers", "tensorflow", "lite", "c", "c_api.h"])) + end + + def valid_dir?(:ios_device, dir) do + fw = Path.join([dir, "Frameworks", "TensorFlowLiteC.xcframework", "ios-arm64"]) + + File.regular?(Path.join([fw, "TensorFlowLiteC.framework", "TensorFlowLiteC"])) and + File.regular?(Path.join([fw, "TensorFlowLiteC.framework", "Headers", "c_api.h"])) + end + + def valid_dir?(:ios_sim, dir) do + fw = + Path.join([dir, "Frameworks", "TensorFlowLiteC.xcframework", "ios-arm64_x86_64-simulator"]) + + File.regular?(Path.join([fw, "TensorFlowLiteC.framework", "TensorFlowLiteC"])) and + File.regular?(Path.join([fw, "TensorFlowLiteC.framework", "Headers", "c_api.h"])) + end + + @doc "Android TFLite version pin." + @spec android_version() :: String.t() + def android_version, do: @android_version + + @doc "iOS TFLite version pin." + @spec ios_version() :: String.t() + def ios_version, do: @ios_version + + # ── Internals ─────────────────────────────────────────────────────────────── + + defp name(:android_arm64), do: "tflite-#{@android_version}-android_arm64" + defp name(:android_arm32), do: "tflite-#{@android_version}-android_arm32" + defp name(:ios_device), do: "tflite-#{@ios_version}-ios_device" + defp name(:ios_sim), do: "tflite-#{@ios_version}-ios_sim" + + # Public for testability — tests redirect to /tmp via `MOB_CACHE_DIR`. + @doc false + @spec cache_dir() :: String.t() + def cache_dir do + case System.get_env("MOB_CACHE_DIR") do + nil -> + System.user_home!() + |> Path.join(".mob") + |> Path.join("cache") + + "" -> + System.user_home!() + |> Path.join(".mob") + |> Path.join("cache") + + path -> + path + end + end + + defp download_and_extract_android(target, dest) do + aar_dir = Path.join(cache_dir(), "tflite-#{@android_version}-android-aar") + + with {:ok, aar_path} <- fetch_android_aar(aar_dir), + :ok <- unpack_aar(aar_path, dest, target) do + {:ok, dest} + end + end + + defp fetch_android_aar(aar_dir) do + File.mkdir_p!(aar_dir) + aar_path = Path.join(aar_dir, "tensorflow-lite-#{@android_version}.aar") + + cond do + File.regular?(aar_path) -> + {:ok, aar_path} + + local = local_tarball_dir() -> + local_aar = Path.join(local, "tensorflow-lite-#{@android_version}.aar") + + if File.regular?(local_aar) do + File.cp!(local_aar, aar_path) + {:ok, aar_path} + else + {:error, {:local_missing, local_aar}} + end + + true -> + case http_get(@android_aar_url, aar_path) do + :ok -> {:ok, aar_path} + err -> err + end + end + end + + # AAR is a zip; extract jni/<abi>/libtensorflowlite_jni.so + headers/. + # The AAR ships headers/ as a peer of jni/ — both root entries. + defp unpack_aar(aar_path, dest, target) do + File.mkdir_p!(dest) + + case System.cmd("unzip", ["-q", "-o", aar_path, "-d", dest], stderr_to_stdout: true) do + {_, 0} -> + # Patch in two upstream-missing headers (AAR ships an incomplete + # tensorflow/lite/core tree); they are needed by c_api_experimental.h + # at build time. Same workaround the standalone bench Makefile uses. + patch_missing_android_headers(dest) + + if valid_dir?(target, dest) do + :ok + else + {:error, {:invalid_aar_layout, dest}} + end + + {out, code} -> + {:error, {:unzip_failed, code, out}} + end + end + + defp patch_missing_android_headers(dest) do + headers_root = Path.join(dest, "headers") + + [ + "tensorflow/lite/core/c/registration_external.h", + "tensorflow/lite/core/async/c/types.h" + ] + |> Enum.each(fn rel -> + out = Path.join(headers_root, rel) + + unless File.regular?(out) do + File.mkdir_p!(Path.dirname(out)) + + url = + "https://raw.githubusercontent.com/tensorflow/tensorflow/v#{@android_version}/#{rel}" + + _ = http_get(url, out) + end + end) + end + + defp download_and_extract_ios(target, dest) do + tar_dir = Path.join(cache_dir(), "tflite-#{@ios_version}-ios-tarball") + + with {:ok, tar_path} <- fetch_ios_tarball(tar_dir), + :ok <- unpack_ios(tar_path, dest, target) do + {:ok, dest} + end + end + + defp fetch_ios_tarball(tar_dir) do + File.mkdir_p!(tar_dir) + tar_path = Path.join(tar_dir, "TensorFlowLiteC-#{@ios_version}.tar.gz") + + cond do + File.regular?(tar_path) -> + {:ok, tar_path} + + local = local_tarball_dir() -> + local_tar = Path.join(local, "TensorFlowLiteC-#{@ios_version}.tar.gz") + + if File.regular?(local_tar) do + File.cp!(local_tar, tar_path) + {:ok, tar_path} + else + {:error, {:local_missing, local_tar}} + end + + true -> + case http_get(@ios_tarball_url, tar_path) do + :ok -> {:ok, tar_path} + err -> err + end + end + end + + defp unpack_ios(tar_path, dest, target) do + File.mkdir_p!(dest) + + case System.cmd("tar", ["xzf", tar_path, "-C", dest, "--strip-components=1"], + stderr_to_stdout: true + ) do + {_, 0} -> + if valid_dir?(target, dest) do + :ok + else + {:error, {:invalid_ios_layout, dest}} + end + + {out, code} -> + {:error, {:tar_failed, code, out}} + end + end + + defp http_get(url, dest) do + # Use curl rather than :httpc to inherit system certs without + # juggling ssl options; matches MLXDownloader's approach. + case System.cmd("curl", ["-fsSL", "-o", dest, url], stderr_to_stdout: true) do + {_, 0} -> :ok + {out, code} -> {:error, {:http_get_failed, code, out}} + end + end + + defp local_tarball_dir do + case System.get_env("MOB_TFLITE_LOCAL_TARBALL_DIR") do + nil -> nil + "" -> nil + path -> path + end + end +end diff --git a/lib/mob_dev/tflite_nif.ex b/lib/mob_dev/tflite_nif.ex new file mode 100644 index 0000000..921ef11 --- /dev/null +++ b/lib/mob_dev/tflite_nif.ex @@ -0,0 +1,365 @@ +defmodule MobDev.TfliteNif do + @moduledoc """ + Cross-compiles `tflite_nif.c` (the NIF wrapping TensorFlow Lite's C API) + for one Android or iOS target ABI and archives the result as + `libtflite_nif.a`. The archive gets static-linked into the user app's + main native binary alongside `crypto.a`, `libemlx.a`, and any other + static NIFs. + + ## Companion bits + + * **Native libs** — `MobDev.TfliteDownloader` fetches the TFLite native + libraries (AAR for Android, xcframework tarball for iOS) and supplies + headers + the runtime library that gets linked into the launcher + binary alongside this archive. + * **C source** — `tflite_nif.c` ships in the `:nx_tflite_mob` Hex dep + (the user adds `{:nx_tflite_mob, ...}` to their `mix.exs` when they + run `mix mob.enable tflite`). This module looks it up via + `:code.lib_dir(:nx_tflite_mob)`. + * **Static-NIF table entry** — `:tflite_nif` is registered in + `MobDev.StaticNifs.default_nifs/0` with guard `MOB_STATIC_TFLITE_NIF`. + The guard threads into `build_device.zig` as `tflite_static` and is + set to `true` when the project enables this feature. + + ## Why static-link this NIF? + + Same constraints as every other NIF mob ships on phones: + + * **Android.** `dlopen`'d children inherit `RTLD_LOCAL`, hiding the + parent's `enif_*` symbols from a separately-loaded `libtflite_nif.so`. + `on_load` then fails with "cannot locate symbol". Static linking + sidesteps that — BEAM finds `tflite_nif_nif_init` via + `dlsym(RTLD_DEFAULT)` against the main app binary. + * **iOS.** App Store forbids loading unsigned dylibs / `dlopen`; every + NIF must already be present in the signed binary. + + The TFLite runtime itself (`libtensorflowlite_jni.so` on Android, + `TensorFlowLiteC.framework` on iOS) IS allowed to load via the normal + dynamic-linker path: it's code-signed (iOS) or part of the standard + jniLibs/ contract (Android). Only the NIF init has to be static. + + ## Per-target deltas + + | Target | Source | Compiler | nm symbol | + |---------------|---------------------|----------------------|----------------------| + | android_arm64 | NDK aarch64 clang | aarch64 cross | `tflite_nif_nif_init` | + | android_arm32 | NDK arm clang | arm cross (armv7-a) | `tflite_nif_nif_init` | + | ios_sim | xcrun iphonesimulator | arm64-apple-ios-simulator | `_tflite_nif_nif_init` | + | ios_device | xcrun iphoneos | arm64-apple-ios | `_tflite_nif_nif_init` | + + ## STATIC_ERLANG_NIF_LIBNAME + + We pass `-DSTATIC_ERLANG_NIF_LIBNAME=tflite_nif` to make `ERL_NIF_INIT` + emit symbol `tflite_nif_nif_init`. That matches the convention used by + `MobDev.NxEigenNif` (`nx_eigen` → `nx_eigen_nif_init`) and what + `MobDev.StaticNifs.init_fn/1` derives for the `:tflite_nif` module + entry. + """ + + alias MobDev.NdkVersion + alias MobDev.Release.{Errors, Shell} + + @android_api 28 + @ios_min_version "15.0" + + @doc "All known TFLite NIF targets." + @spec targets() :: [atom()] + def targets, do: [:android_arm64, :android_arm32, :ios_sim, :ios_device] + + # ── Target spec ───────────────────────────────────────────────────────── + + defmodule Target do + @moduledoc false + @enforce_keys [:id, :tools_fn, :extra_cflags, :nm_symbol] + defstruct [:id, :tools_fn, :extra_cflags, :nm_symbol] + end + + @android_extra_cflags [ + "-fstrict-flex-arrays=3", + "-mbranch-protection=standard", + "-fstack-clash-protection", + "-D_GNU_SOURCE", + "-D__ANDROID__" + ] + + @arm32_extra_cflags ["-march=armv7-a", "-mfloat-abi=softfp", "-mthumb"] + + @doc "Per-target spec. Public for testing." + @spec target_spec(atom()) :: %Target{} + def target_spec(:android_arm64) do + %Target{ + id: :android_arm64, + tools_fn: &android_tools(&1, :android_arm64), + extra_cflags: @android_extra_cflags, + nm_symbol: "tflite_nif_nif_init" + } + end + + def target_spec(:android_arm32) do + %Target{ + id: :android_arm32, + tools_fn: &android_tools(&1, :android_arm32), + extra_cflags: @arm32_extra_cflags ++ @android_extra_cflags, + nm_symbol: "tflite_nif_nif_init" + } + end + + def target_spec(:ios_sim) do + %Target{ + id: :ios_sim, + tools_fn: &ios_tools(&1, :ios_sim), + extra_cflags: [], + nm_symbol: "_tflite_nif_nif_init" + } + end + + def target_spec(:ios_device) do + %Target{ + id: :ios_device, + tools_fn: &ios_tools(&1, :ios_device), + extra_cflags: [], + nm_symbol: "_tflite_nif_nif_init" + } + end + + # ── CFLAGS assembly ───────────────────────────────────────────────────── + + @base_cflags [ + "-fPIC", + "-O2", + "-Wall", + "-std=c99", + "-DSTATIC_ERLANG_NIF_LIBNAME=tflite_nif" + ] + + @doc "Base CFLAGS shared across all targets. Public for testing." + @spec base_cflags() :: [String.t()] + def base_cflags, do: @base_cflags + + @doc """ + Assemble full CFLAGS for a target plus include / framework search paths. + + `includes` are `-I`-prefixed; `frameworks` are `-F`-prefixed (iOS only — + ignored on Android targets). Order is preserved. + """ + @spec cflags(%Target{}, [Path.t()], [Path.t()]) :: [String.t()] + def cflags(%Target{} = target, includes, frameworks \\ []) + when is_list(includes) and is_list(frameworks) do + @base_cflags ++ + target.extra_cflags ++ + Enum.map(includes, &"-I#{&1}") ++ + Enum.map(frameworks, &"-F#{&1}") + end + + # ── Build entrypoint ──────────────────────────────────────────────────── + + @doc """ + Compile + archive + verify `libtflite_nif.a` for one target. + + Options: + * `:nx_tflite_mob_dir` — path to the `:nx_tflite_mob` Hex dep (the + dir containing `c_src/tflite_nif.c`). **Required.** + * `:tflite_dir` — path returned by `MobDev.TfliteDownloader.ensure/1` + for this target. **Required.** + * `:erts_include` — per-target `erts-VSN/include/` dir. **Required.** + * `:out_dir` — where the archive + object subdir get written. + **Required.** + * `:ndk_root` — Android NDK root (Android targets only; defaults to + `~/Library/Android/sdk/ndk/<NdkVersion.effective()>`). + """ + @spec build(atom(), keyword()) :: {:ok, map()} | Errors.t() + def build(target_id, opts \\ []) + when target_id in [:android_arm64, :android_arm32, :ios_sim, :ios_device] do + target = target_spec(target_id) + shell = Shell.impl() + + with {:ok, src_root} <- require_opt(opts, :nx_tflite_mob_dir), + {:ok, tflite_dir} <- require_opt(opts, :tflite_dir), + {:ok, erts_inc} <- require_opt(opts, :erts_include), + {:ok, out_dir} <- require_opt(opts, :out_dir) do + src = Path.join([src_root, "c_src", "tflite_nif.c"]) + + {includes, frameworks} = include_paths(target_id, tflite_dir, erts_inc) + + paths = %{ + obj_dir: Path.join([out_dir, "obj", arch_dir(target_id)]), + lib_dir: out_dir, + src: src + } + + with :ok <- precheck(target, shell, paths, tflite_dir, erts_inc, opts), + tools = target.tools_fn.(opts), + flags = cflags(target, includes, frameworks), + :ok <- shell.mkdir_p(paths.obj_dir), + :ok <- shell.mkdir_p(paths.lib_dir), + obj = Path.join(paths.obj_dir, "tflite_nif.o"), + {:ok, _} <- shell.cmd(tools.cc ++ flags ++ ["-c", "-o", obj, src], []), + archive = Path.join(paths.lib_dir, "libtflite_nif.a"), + :ok <- shell.rm_f(archive), + {:ok, _} <- shell.cmd(tools.ar ++ ["rcs", archive, obj], []), + {:ok, _} <- shell.cmd(tools.ranlib ++ [archive], []), + :ok <- verify_symbol(shell, tools.nm, archive, target.nm_symbol) do + {:ok, %{target: target_id, archive: archive, object: obj}} + end + end + end + + # ── Per-target tool / path helpers ────────────────────────────────────── + + defp arch_dir(:android_arm64), do: "aarch64-unknown-linux-android" + defp arch_dir(:android_arm32), do: "arm-unknown-linux-androideabi" + defp arch_dir(:ios_sim), do: "aarch64-apple-iossimulator" + defp arch_dir(:ios_device), do: "aarch64-apple-ios" + + defp include_paths(target_id, tflite_dir, erts_inc) + when target_id in [:android_arm64, :android_arm32] do + {[ + Path.join(tflite_dir, "headers"), + erts_inc, + Path.join(erts_inc, "internal") + ], []} + end + + defp include_paths(:ios_device, tflite_dir, erts_inc) do + fw_root = Path.join(tflite_dir, "Frameworks") + + {[erts_inc, Path.join(erts_inc, "internal")], + [ + Path.join([fw_root, "TensorFlowLiteC.xcframework", "ios-arm64"]), + Path.join([fw_root, "TensorFlowLiteCCoreML.xcframework", "ios-arm64"]) + ]} + end + + defp include_paths(:ios_sim, tflite_dir, erts_inc) do + fw_root = Path.join(tflite_dir, "Frameworks") + slice = "ios-arm64_x86_64-simulator" + + {[erts_inc, Path.join(erts_inc, "internal")], + [ + Path.join([fw_root, "TensorFlowLiteC.xcframework", slice]), + Path.join([fw_root, "TensorFlowLiteCCoreML.xcframework", slice]) + ]} + end + + defp android_tools(opts, arch) do + ndk_root = + opts[:ndk_root] || + Path.join([System.user_home!(), "Library/Android/sdk/ndk", NdkVersion.effective()]) + + host = ndk_host() + bin = Path.join([ndk_root, "toolchains/llvm/prebuilt/#{host}/bin"]) + + cc_name = + case arch do + :android_arm64 -> "aarch64-linux-android#{@android_api}-clang" + :android_arm32 -> "armv7a-linux-androideabi#{@android_api}-clang" + end + + %{ + cc: [Path.join(bin, cc_name)], + ar: [Path.join(bin, "llvm-ar")], + ranlib: [Path.join(bin, "llvm-ranlib")], + nm: [Path.join(bin, "llvm-nm")] + } + end + + defp ios_tools(_opts, target) do + sdk = + case target do + :ios_device -> "iphoneos" + :ios_sim -> "iphonesimulator" + end + + sdk_path = String.trim(System.cmd("xcrun", ["--sdk", sdk, "--show-sdk-path"]) |> elem(0)) + + target_triple = + case target do + :ios_device -> "arm64-apple-ios#{@ios_min_version}" + :ios_sim -> "arm64-apple-ios#{@ios_min_version}-simulator" + end + + cc_path = String.trim(System.cmd("xcrun", ["--find", "clang"]) |> elem(0)) + ar_path = String.trim(System.cmd("xcrun", ["--find", "ar"]) |> elem(0)) + ranlib_path = String.trim(System.cmd("xcrun", ["--find", "ranlib"]) |> elem(0)) + nm_path = String.trim(System.cmd("xcrun", ["--find", "nm"]) |> elem(0)) + + %{ + cc: [cc_path, "-arch", "arm64", "-target", target_triple, "-isysroot", sdk_path], + ar: [ar_path], + ranlib: [ranlib_path], + nm: [nm_path] + } + end + + defp ndk_host do + case :os.type() do + {:unix, :darwin} -> "darwin-x86_64" + {:unix, :linux} -> "linux-x86_64" + _ -> "darwin-x86_64" + end + end + + # ── Precondition + verification ───────────────────────────────────────── + + defp precheck(target, shell, paths, tflite_dir, erts_inc, opts) do + cond do + not shell.file?(paths.src) -> + Errors.precondition( + "nx_tflite_mob c_src/tflite_nif.c not found at #{paths.src} — is :nx_tflite_mob in mix deps?" + ) + + not shell.dir?(tflite_dir) -> + Errors.precondition( + "TFLite bundle missing at #{tflite_dir} — TfliteDownloader.ensure/1 not run for #{target.id}?" + ) + + not shell.dir?(erts_inc) -> + Errors.precondition("erts include dir missing at #{erts_inc}") + + true -> + ensure_toolchain(target, opts) + end + end + + defp ensure_toolchain(%Target{id: id}, opts) when id in [:android_arm64, :android_arm32] do + ndk_root = + opts[:ndk_root] || + Path.join([System.user_home!(), "Library/Android/sdk/ndk", NdkVersion.effective()]) + + if File.dir?(ndk_root) do + :ok + else + Errors.precondition("Android NDK not found at #{ndk_root}") + end + end + + defp ensure_toolchain(%Target{id: id}, _opts) when id in [:ios_sim, :ios_device] do + case System.cmd("xcrun", ["--find", "clang"], stderr_to_stdout: true) do + {_, 0} -> :ok + _ -> Errors.precondition("xcrun / Xcode command-line tools missing") + end + end + + defp verify_symbol(shell, nm_cmd, archive, expected) do + case shell.cmd(nm_cmd ++ [archive], []) do + {:ok, out} -> + if String.contains?(out, expected) do + :ok + else + Errors.precondition( + "compile produced libtflite_nif.a without `#{expected}` symbol — STATIC_ERLANG_NIF_LIBNAME=tflite_nif may have been overridden by something upstream" + ) + end + + err -> + err + end + end + + defp require_opt(opts, key) do + case Keyword.fetch(opts, key) do + {:ok, v} -> {:ok, v} + :error -> Errors.precondition("required option missing: #{inspect(key)}") + end + end +end diff --git a/lib/mob_dev/tunnel.ex b/lib/mob_dev/tunnel.ex index 9a68f8a..f8f9160 100644 --- a/lib/mob_dev/tunnel.ex +++ b/lib/mob_dev/tunnel.ex @@ -1,10 +1,31 @@ defmodule MobDev.Tunnel do @moduledoc """ - Manages adb port tunnels for Android devices. + Manages port tunnels for Android and physical iOS devices. - For each device: - adb reverse tcp:4369 tcp:4369 — Android BEAM registers in Mac's EPMD - adb forward tcp:<dist> tcp:9100 — Mac reaches device's dist port + Android (adb): + adb reverse tcp:4369 tcp:4369 — Android BEAM registers in Mac's EPMD + adb forward tcp:<dist> tcp:<dist> — Mac reaches the device's dist port (1:1) + + Physical iOS (direct networking — USB preferred, WiFi/LAN fallback): + mob_beam.m finds the device's own IP via getifaddrs() and starts the BEAM + as mob_qa_ios@<device-ip>. The in-process EPMD binds 0.0.0.0:4369 so Mac + can query it at <device-ip>:4369. The dist port is directly reachable. + + iOS simulator: + Shares Mac network stack — no tunnels needed. + + ## Dist ports are keyed by device serial, not run index + + The Mac runs ONE EPMD (port 4369) that every device — across every project + and every `mix mob.connect` run — registers into. Assigning dist ports by + per-run index (`9100 + index`) meant project A's device-0 and project B's + device-0 both claimed 9100: two nodes at the same port in the shared EPMD, + but `adb forward tcp:9100` can only point at one device → the other resolved + to the wrong phone or nothing (silent timeout). Now the port is derived from + the device serial (`serial_base_port/1`, a crc32 hash into 9100..9899), so a + given phone always gets the same unique port regardless of project/run, and + `assign_dist_port/2` bumps past any port another live node/forward already + holds (cross-project or hash collision). """ alias MobDev.Device @@ -12,52 +33,261 @@ defmodule MobDev.Tunnel do # EPMD port — shared across all devices (same Mac EPMD). @epmd_port 4369 - # Base dist port — each device gets an offset so they don't collide. + # Dist port window. crc32(serial) spreads phones across [9100, 9100+span). @base_dist_port 9100 + @port_span 800 @doc """ - Assigns a dist port and sets up adb tunnels for a device. - Returns {:ok, %Device{}} with dist_port filled in, or {:error, reason}. - """ - @spec setup(Device.t(), non_neg_integer()) :: {:ok, Device.t()} | {:error, String.t()} - def setup(device, index \\ 0) + Assigns a serial-derived dist port and sets up tunnels for a device. - def setup(%Device{platform: :android, serial: serial} = device, index) do - dist_port = @base_dist_port + index + Cleans the device's own stale forwards first, then picks a port that no other + live node/forward on this Mac is using. Returns `{:ok, %Device{}}` with + `dist_port` (and `host_ip` for USB iOS) filled in, or `{:error, reason}`. + """ + @spec setup(Device.t()) :: {:ok, Device.t()} | {:error, String.t()} + def setup(%Device{platform: :android, serial: serial} = device) do + clean_android_forwards(serial) + port = assign_dist_port(serial, ports_in_use(device.node)) with :ok <- reverse(serial, @epmd_port, @epmd_port), - :ok <- forward(serial, dist_port, @base_dist_port) do - {:ok, %{device | dist_port: dist_port, status: :tunneled}} + :ok <- forward(serial, port, port) do + {:ok, %{device | dist_port: port, status: :tunneled}} end end - def setup(%Device{platform: :ios} = device, index) do - # iOS simulator shares Mac network stack — no adb tunnels needed. - # Port is offset by index so iOS and Android don't share the same dist port. - # (Android's adb forward also binds that port on Mac's loopback, causing conflict.) - # Physical iOS via iproxy is a future addition. - dist_port = @base_dist_port + index - {:ok, %{device | dist_port: dist_port, status: :tunneled}} + def setup(%Device{platform: :ios, type: :physical, host_ip: ip} = device) + when not is_nil(ip) do + # IP already known from WiFi/LAN discovery — no ARP lookup needed. + # dist_port was set during discovery (parsed from EPMD). Node name already set. + {:ok, %{device | status: :tunneled}} + end + + def setup(%Device{platform: :ios, type: :physical, serial: udid} = device) do + # IP not yet known — device was discovered via USB. Find the USB link-local IP. + port = assign_dist_port(udid, ports_in_use(device.node)) + + case device_usb_ip() do + {:ok, device_ip} -> + d = %{device | dist_port: port, host_ip: device_ip, status: :tunneled} + {:ok, %{d | node: Device.node_name(d)}} + + {:error, reason} -> + {:error, "device usb ip: #{reason}"} + end + end + + def setup(%Device{platform: :ios, serial: udid} = device) do + # iOS simulator shares Mac network stack — no tunnels needed, but it still + # needs a unique dist port (multiple sims / Android share the Mac EPMD). + port = assign_dist_port(udid, ports_in_use(device.node)) + {:ok, %{device | dist_port: port, status: :tunneled}} + end + + @doc """ + Stable, deterministic dist port for a device serial — a crc32 hash into + `[9100, 9100 + 800)`. Same serial → same port across runs and projects, so + the port a device is *deployed* to listen on matches what `mix mob.connect` + later forwards to. + """ + @spec serial_base_port(String.t()) :: pos_integer() + def serial_base_port(serial) when is_binary(serial) do + @base_dist_port + rem(:erlang.crc32(serial), @port_span) end - @doc "Returns the dist port for a given device index (same formula used in setup/2)." - @spec dist_port(non_neg_integer()) :: non_neg_integer() - def dist_port(index), do: @base_dist_port + index + @doc """ + The serial's base port, bumped to the next free slot if `in_use` already + claims it (a cross-project collision or a crc32 hash collision between two + serials). Walks the window from the base; falls back to the base if the whole + window is somehow taken. Pure — `in_use` is gathered by the caller. + """ + @spec assign_dist_port(String.t(), MapSet.t()) :: pos_integer() + def assign_dist_port(serial, in_use \\ MapSet.new()) do + base_off = rem(:erlang.crc32(serial), @port_span) - @doc "Tears down adb tunnels for a device." + Enum.find_value(0..(@port_span - 1), serial_base_port(serial), fn off -> + port = @base_dist_port + rem(base_off + off, @port_span) + if MapSet.member?(in_use, port), do: false, else: port + end) + end + + @doc "Tears down tunnels for a device." @spec teardown(Device.t()) :: :ok def teardown(%Device{platform: :android, serial: serial, dist_port: dist_port}) do run_adb(["-s", serial, "reverse", "--remove", "tcp:#{@epmd_port}"]) - run_adb(["-s", serial, "forward", "--remove", "tcp:#{dist_port}"]) + if dist_port, do: run_adb(["-s", serial, "forward", "--remove", "tcp:#{dist_port}"]) + :ok + end + + def teardown(%Device{platform: :ios, type: :physical, dist_port: dist_port}) + when not is_nil(dist_port) do + kill_iproxy(dist_port) :ok end def teardown(%Device{platform: :ios}), do: :ok + # ── port bookkeeping ────────────────────────────────────────────────────────── + + @doc false + # Host-side ports already claimed on this Mac — by another node in the shared + # EPMD or by an existing adb forward — so a device's serial-derived port can + # dodge a cross-project collision. `exclude_node` drops this device's own + # registration so re-running reclaims its port. + @spec ports_in_use(atom() | nil) :: MapSet.t() + def ports_in_use(exclude_node \\ nil) do + MapSet.union(epmd_ports(exclude_node), forward_host_ports()) + end + + defp epmd_ports(exclude_node) do + exclude = exclude_node && exclude_node |> Atom.to_string() |> String.split("@") |> hd() + + case System.cmd("epmd", ["-names"], stderr_to_stdout: true) do + {out, 0} -> + ~r/name (\S+) at port (\d+)/ + |> Regex.scan(out) + |> Enum.reject(fn [_, name, _] -> name == exclude end) + |> Enum.map(fn [_, _, port] -> String.to_integer(port) end) + |> MapSet.new() + + _ -> + MapSet.new() + end + end + + defp forward_host_ports do + case run_adb(["forward", "--list"]) do + {:ok, out} -> + ~r/tcp:(\d+) tcp:\d+/ + |> Regex.scan(out) + |> Enum.map(fn [_, port] -> String.to_integer(port) end) + |> MapSet.new() + + _ -> + MapSet.new() + end + end + + # Remove this device's own stale forwards (old per-run ports) so its + # serial-derived port is free to reclaim and we don't accumulate duplicates. + # Scoped to this serial — never touches other devices' forwards. + defp clean_android_forwards(serial) do + case run_adb(["forward", "--list"]) do + {:ok, out} -> + out + |> String.split("\n") + |> Enum.each(fn line -> + case String.split(line) do + [^serial, "tcp:" <> host_port | _] -> + run_adb(["-s", serial, "forward", "--remove", "tcp:#{host_port}"]) + + _ -> + :ok + end + end) + + _ -> + :ok + end + + :ok + end + + # ── iproxy cleanup ──────────────────────────────────────────────────────────── + + # Kill any stale iproxy process on a given port. Called from teardown to clean + # up any lingering iproxy from previous sessions (before the direct USB approach). + defp kill_iproxy(port) do + System.cmd("sh", ["-c", "lsof -ti tcp:#{port} | xargs kill -9 2>/dev/null; true"], + stderr_to_stdout: true + ) + + :ok + end + + # Find the physical iOS device's own USB link-local (169.254.x.x) IP. + # + # When an iOS device is connected via USB, macOS creates a USB Ethernet + # interface (e.g. en11). The device has its own 169.254.x.x address on that + # interface; macOS discovers it via mDNS and caches it in the ARP table as + # "<device-name>.local (169.254.x.x) at <mac>". + # + # ARP entries start as "(incomplete)" until traffic triggers MAC resolution. + # We ping any incomplete 169.254 entries first, then re-read the ARP table. + # The device's own EPMD binds 0.0.0.0:4369, making it directly reachable + # from Mac at that IP — no iproxy needed. + defp device_usb_ip do + case read_resolved_usb_ip() do + {:ok, _} = ok -> + ok + + {:error, _} -> + ping_incomplete_usb_ips() + + case read_resolved_usb_ip() do + {:ok, _} = ok -> + ok + + {:error, _} -> + {:error, "no device USB IP in ARP — is the device connected via USB?"} + end + end + end + + defp read_resolved_usb_ip do + case System.cmd("arp", ["-a"], stderr_to_stdout: true) do + {out, 0} -> + ip = + out + |> String.split("\n") + |> Enum.find_value(fn line -> + # Match resolved entries: kevins-iphone.local (169.254.x.x) at aa:bb:cc... on enN + case Regex.run( + Regex.compile!("\\((169\\.254\\.\\d+\\.\\d+)\\) at [0-9a-f]{2}:[0-9a-f]{2}"), + line + ) do + [_, found_ip] -> found_ip + _ -> nil + end + end) + + case ip do + nil -> {:error, :not_found} + ip -> {:ok, ip} + end + + _ -> + {:error, :arp_failed} + end + end + + defp ping_incomplete_usb_ips do + case System.cmd("arp", ["-a"], stderr_to_stdout: true) do + {out, 0} -> + out + |> String.split("\n") + |> Enum.each(fn line -> + case Regex.run( + Regex.compile!("\\((169\\.254\\.\\d+\\.\\d+)\\) at \\(incomplete\\)"), + line + ) do + [_, ip] -> + System.cmd("ping", ["-c", "1", "-t", "2", ip], stderr_to_stdout: true) + + _ -> + :ok + end + end) + + _ -> + :ok + end + end + + # ── adb helpers ─────────────────────────────────────────────────────────────── + # adb reverse tcp:remote tcp:local (device→Mac) defp reverse(serial, device_port, local_port) do - case run_adb(["-s", serial, "reverse", - "tcp:#{device_port}", "tcp:#{local_port}"]) do + case run_adb(["-s", serial, "reverse", "tcp:#{device_port}", "tcp:#{local_port}"]) do {:ok, _} -> :ok {:error, reason} -> {:error, "reverse #{device_port}: #{reason}"} end @@ -65,17 +295,37 @@ defmodule MobDev.Tunnel do # adb forward tcp:local tcp:remote (Mac→device) defp forward(serial, local_port, device_port) do - case run_adb(["-s", serial, "forward", - "tcp:#{local_port}", "tcp:#{device_port}"]) do + case run_adb(["-s", serial, "forward", "tcp:#{local_port}", "tcp:#{device_port}"]) do {:ok, _} -> :ok {:error, reason} -> {:error, "forward #{local_port}→#{device_port}: #{reason}"} end end + # Pure-Elixir timeout via Task — avoids depending on the GNU `timeout` + # binary, which doesn't ship with macOS or BSD by default. Calls adb + # directly via System.cmd/3 (no shell, no quoting concerns). + # + # Resolves `adb` up front via System.find_executable/1: an iOS-only Mac + # has no Android platform-tools, and `System.cmd("adb", ...)` *raises* + # `:enoent` for a missing binary (it does not return a non-zero exit). That + # raise inside the linked Task would propagate an exit to the caller and + # crash the whole `mix mob.connect`. Returning `{:error, ...}` instead lets + # every caller's existing error branch degrade gracefully (no forwards → + # empty port set, no-op cleanup), so iOS-only setups never touch adb. defp run_adb(args) do - case System.cmd("adb", args, stderr_to_stdout: true) do - {output, 0} -> {:ok, String.trim(output)} - {output, _} -> {:error, String.trim(output)} + case System.find_executable("adb") do + nil -> + {:error, "adb not found on PATH"} + + adb -> + task = Task.async(fn -> System.cmd(adb, args, stderr_to_stdout: true) end) + + case Task.yield(task, 8_000) || Task.shutdown(task, :brutal_kill) do + {:ok, {output, 0}} -> {:ok, String.trim(output)} + {:ok, {output, _rc}} -> {:error, String.trim(output)} + nil -> {:error, "adb timed out"} + {:exit, reason} -> {:error, "adb crashed: #{inspect(reason)}"} + end end end end diff --git a/lib/mob_dev/uninstaller.ex b/lib/mob_dev/uninstaller.ex new file mode 100644 index 0000000..0bb573b --- /dev/null +++ b/lib/mob_dev/uninstaller.ex @@ -0,0 +1,535 @@ +defmodule MobDev.Uninstaller do + @moduledoc """ + Uninstall a Mob app (or every Mob-prefixed app) from one or more + connected devices. + + The user-facing surface is `mix mob.uninstall`. This module owns the + matrix math + per-platform uninstall mechanics so the Mix task stays + thin and the testable invariants live here. + + ## Scope dimensions + + Two orthogonal axes: + + * **Devices** — one auto-detected (when exactly one is connected), + a list of named devices (`--device foo --device bar`), or every + connected device (`--all-devices`). + * **Apps** — the current project's bundle id by default, every + installed package matching the user's `bundle_prefix` + (`--all-apps`), or an explicit override (`--bundle-id`). + + The (devices × apps) matrix gets flattened into per-pair uninstall + attempts and the results bucket into `{uninstalled, failed, skipped}` + — same shape as `MobDev.Deployer.deploy_all/1` so the report + rendering can borrow the same idiom. + + ## Per-platform mechanics + + * **Android (adb)** — `adb -s <serial> uninstall <pkg>`. + stderr "Unknown package" → `:skipped` (not installed). All + other non-zero exits → `:error`. + + * **iOS simulator (xcrun simctl)** — `xcrun simctl uninstall + <udid> <bundle>`. simctl returns exit 0 whether the app was + installed or not, so we probe with `xcrun simctl listapps` + first to distinguish skip-vs-uninstall. + + * **iOS physical device (devicectl)** — `xcrun devicectl device + uninstall app --device <udid> <bundle>`. Exit 0 → uninstalled. + `ContainerLookupErrorDomain` in stderr → `:skipped` + (app not installed on this device). + """ + + alias MobDev.Discovery.{Android, IOS} + alias MobDev.Device + + @type outcome :: :uninstalled | :skipped | :error + @type result :: %{ + device: Device.t(), + bundle_id: String.t(), + outcome: outcome(), + reason: String.t() | nil + } + + @typedoc """ + An uninstall plan — list of (device, [bundle_id]) pairs. + Built from `plan/1`; executed by `execute_plan/1`. + """ + @type plan :: [{Device.t(), [String.t()]}] + + @typedoc "Why `plan/1` couldn't build a matrix." + @type plan_error :: + :no_devices + | :ambiguous_devices + | :no_matching_devices + | :no_dev_devices + | :no_physical_devices + + @doc """ + Build the (devices × apps) plan without executing it. + + The Mix task uses this to render a "what's about to happen" preview + and prompt for confirmation before any destructive work runs. + + Recognized opts: same as `uninstall_all/1`. + + Returns: + * `{:ok, plan}` — plan ready to execute. Each pair has at least + one device and one bundle id. + * `{:error, :no_devices, %{detected: 0}}` — no connected devices. + * `{:error, :ambiguous_devices, %{detected: N}}` — multiple + devices connected, no `--device` or `--all-devices` flag. + * `{:error, :no_matching_devices, %{requested: [...]}}` — the + user passed `--device` IDs but none matched. + """ + @spec plan(keyword()) :: {:ok, plan()} | {:error, plan_error(), map()} + def plan(opts \\ []) do + all = list_all_devices(opts) + device_ids = opts[:device_ids] || [] + selected = select_devices(all, device_ids, opts) + + cond do + all == [] -> + {:error, :no_devices, %{detected: 0}} + + device_ids != [] and selected == [] -> + {:error, :no_matching_devices, %{requested: device_ids, detected: length(all)}} + + selected == [] -> + # Could mean: --all-devices was set but no emulators/sims + # connected (only physical), OR no flags + multiple devices + # connected, OR no flags + zero non-physical devices. Use + # the available counts to pick the right error. + ambiguous_or_only_physical_error(all, opts) + + true -> + {:ok, build_plan(selected, opts)} + end + end + + @doc """ + Pick the target device set from `all` connected devices given the + user-supplied opts. + + Precedence: + 1. `:device_ids` non-empty — match by id, regardless of type + (the user typed the id, that's explicit consent). + 2. Both `:all_devices` and `:all_physical` — every connected + device. + 3. `:all_devices` only — emulators/simulators (NEVER physical). + The destructive sweep is safe by default; touching a physical + device requires explicit `--all-physical` or `--device <id>`. + 4. `:all_physical` only — physical devices only. + 5. Auto-detect: exactly one NON-physical device → target it. + Physical devices are never the auto-target. + + Public for testing — the precedence ladder is the safety contract. + """ + @spec select_devices([Device.t()], [String.t()], keyword()) :: [Device.t()] + def select_devices(all, device_ids, opts) do + # Coerce to explicit booleans — opts[:foo] is nil when the flag + # wasn't passed, and `nil and X` crashes under Elixir 1.20. + all_devices? = Keyword.get(opts, :all_devices, false) == true + all_physical? = Keyword.get(opts, :all_physical, false) == true + + cond do + device_ids != [] -> + filter_devices_by_id(all, device_ids) + + all_devices? and all_physical? -> + all + + all_devices? -> + Enum.reject(all, &Device.physical?/1) + + all_physical? -> + Enum.filter(all, &Device.physical?/1) + + true -> + non_physical = Enum.reject(all, &Device.physical?/1) + if length(non_physical) == 1, do: non_physical, else: [] + end + end + + defp ambiguous_or_only_physical_error(all, opts) do + physical = Enum.filter(all, &Device.physical?/1) + non_physical = Enum.reject(all, &Device.physical?/1) + # Same boolean-coercion guard as in select_devices/3. + all_devices? = Keyword.get(opts, :all_devices, false) == true + all_physical? = Keyword.get(opts, :all_physical, false) == true + + cond do + all_devices? and non_physical == [] -> + # --all-devices targets non-physical; user has only phones. + {:error, :no_dev_devices, + %{ + physical_count: length(physical), + hint: + "Only physical devices connected. `--all-devices` targets " <> + "emulators/simulators only — use `--all-physical` to also " <> + "uninstall on physical devices, or `--device <id>` to " <> + "target one explicitly." + }} + + all_physical? and physical == [] -> + {:error, :no_physical_devices, %{detected: length(all)}} + + true -> + # No flags + ambiguous (>1 non-physical) OR no non-physical at all. + {:error, :ambiguous_devices, + %{ + detected: length(all), + non_physical: length(non_physical), + physical: length(physical) + }} + end + end + + @doc """ + Execute a `plan/0` against the connected devices. Returns + `{uninstalled, failed, skipped}` lists of `result/0` maps. + """ + @spec execute_plan(plan()) :: {[result()], [result()], [result()]} + def execute_plan(plan) do + plan + |> Enum.flat_map(fn {device, bundles} -> + Enum.map(bundles, &uninstall_one(device, &1)) + end) + |> categorize_results() + end + + @doc """ + Top-level orchestration: discover devices, resolve target apps, + run the uninstall matrix. Equivalent to `plan/1 |> execute_plan/1` + but raises on the plan-error cases instead of returning a tuple — + useful for tests that just want results without going through the + Mix-task layer. + + Recognized opts: see `plan/1`. + """ + @spec uninstall_all(keyword()) :: {[result()], [result()], [result()]} + def uninstall_all(opts \\ []) do + case plan(opts) do + {:ok, p} -> + execute_plan(p) + + {:error, reason, ctx} -> + raise ArgumentError, + "Uninstaller.uninstall_all/1 — plan failed (#{reason}): #{inspect(ctx)}" + end + end + + defp build_plan(devices, opts) do + bundle_prefix = opts[:bundle_prefix] || MobDev.Config.bundle_prefix() + + Enum.map(devices, fn d -> + {d, resolve_apps_for_device(d, opts, bundle_prefix)} + end) + end + + # ── Pure helpers (the testable surface) ──────────────────────────────── + + @doc """ + Bucket `results` into `{uninstalled, failed, skipped}`. + Mirrors `MobDev.Deployer.categorize_results/1`. + """ + @spec categorize_results([result()]) :: {[result()], [result()], [result()]} + def categorize_results(results) do + uninstalled = for %{outcome: :uninstalled} = r <- results, do: r + failed = for %{outcome: :error} = r <- results, do: r + skipped = for %{outcome: :skipped} = r <- results, do: r + {uninstalled, failed, skipped} + end + + @doc """ + Filter a list of `%Device{}` by `device_ids`. When `device_ids` is + empty, return all. When non-empty, match by serial OR name (case- + sensitive prefix on serial, exact on either name). Devices not + found are dropped silently — the caller is expected to validate + user intent. + + Used by `resolve_devices/1` when the user passes `--device foo`. + """ + @spec filter_devices_by_id([Device.t()], [String.t()]) :: [Device.t()] + def filter_devices_by_id(devices, []), do: devices + + def filter_devices_by_id(devices, ids) do + Enum.filter(devices, fn d -> + Enum.any?(ids, &Device.match_id?(d, &1)) + end) + end + + @doc """ + Parse `adb uninstall` output into an outcome. + + Adb's reporting is informal: + + * `"Success"` (sometimes followed by other lines) → `:uninstalled` + * stderr contains `"Failure"` with `"DELETE_FAILED_INTERNAL_ERROR"` + and `"Unknown package"` → `:skipped` (not installed) + * any other non-zero exit → `:error` + + `output` is the combined stdout+stderr; `exit_code` is the process + exit status. Returns `{outcome, reason}` where `reason` is a + short user-facing string (or nil for the success case). + """ + @spec interpret_adb_uninstall(String.t(), non_neg_integer()) :: + {outcome(), String.t() | nil} + def interpret_adb_uninstall(output, exit_code) do + cond do + exit_code == 0 and String.contains?(output, "Success") -> + {:uninstalled, nil} + + String.contains?(output, "Unknown package") -> + {:skipped, "not installed"} + + exit_code != 0 -> + {:error, String.trim(output)} + + true -> + # adb sometimes returns exit 0 with no Success marker — be + # conservative and treat unclear output as an error rather + # than claiming success. + {:error, String.trim(output)} + end + end + + @doc """ + Parse `xcrun devicectl device uninstall app` output into an outcome. + + devicectl is more structured than adb but its error reporting still + varies by Xcode version. Patterns: + + * exit 0 → app actually uninstalled (or wasn't there — devicectl + doesn't always distinguish; check the listapps probe if + precision matters). + * `ContainerLookupErrorDomain` → bundle id not installed → `:skipped`. + * `MissingProfileError` / `NotPaired` → device-pairing problem, + real error. + * Anything else exit != 0 → `:error` with trimmed output. + + Output is the combined stdout+stderr; exit_code is the process exit + status. Returns `{outcome, reason}`. + + Public for regression-testing without a paired physical device. + """ + @spec interpret_devicectl_uninstall(String.t(), non_neg_integer()) :: + {outcome(), String.t() | nil} + def interpret_devicectl_uninstall(output, exit_code) do + cond do + exit_code == 0 -> + {:uninstalled, nil} + + String.contains?(output, "ContainerLookupErrorDomain") or + String.contains?(output, "not installed") -> + {:skipped, "not installed"} + + true -> + {:error, String.trim(output)} + end + end + + @doc """ + Parse `adb shell pm list packages <prefix>` output into a list of + package names. Returns names without the `package:` prefix, sorted + for deterministic ordering. + + iex> MobDev.Uninstaller.parse_package_list("package:com.example.a\\npackage:com.example.b\\n") + ["com.example.a", "com.example.b"] + + iex> MobDev.Uninstaller.parse_package_list("") + [] + + iex> MobDev.Uninstaller.parse_package_list("garbage\\n") + [] + """ + @spec parse_package_list(String.t()) :: [String.t()] + def parse_package_list(output) when is_binary(output) do + output + |> String.split("\n", trim: true) + |> Enum.flat_map(fn line -> + case String.split(String.trim(line), "package:", parts: 2) do + ["", pkg] -> [pkg] + _ -> [] + end + end) + |> Enum.sort() + end + + @doc """ + Build a human-readable preview of the uninstall matrix. + + Used by the Mix task to show "what's about to happen" before the + destructive step. Lines are colored ANSI (faint/cyan/dim) but the + ANSI codes can be stripped for assertion-friendly tests. + """ + @spec preview_lines([{Device.t(), [String.t()]}]) :: [String.t()] + def preview_lines([]), do: ["(no apps to uninstall — nothing to do)"] + + def preview_lines(pairs) do + header = "About to uninstall:" + + rows = + Enum.flat_map(pairs, fn {device, bundles} -> + [ + " #{IO.ANSI.cyan()}#{device.name || device.serial}#{IO.ANSI.reset()} (#{device.platform}):" + | Enum.map(bundles, &" - #{&1}") + ] + end) + + [header | rows] + end + + # ── Device discovery ─────────────────────────────────────────────────── + + defp list_all_devices(opts) do + platforms = opts[:platforms] || [:android, :ios] + + android = + if :android in platforms, + do: Android.list_devices() |> Enum.reject(&(&1.status == :unauthorized)), + else: [] + + ios = if :ios in platforms, do: IOS.list_devices(), else: [] + + android ++ ios + end + + # ── App resolution per device ────────────────────────────────────────── + + defp resolve_apps_for_device(device, opts, bundle_prefix) do + cond do + opts[:bundle_id] -> + [opts[:bundle_id]] + + opts[:all_apps] -> + list_matching_packages(device, bundle_prefix) + + true -> + [opts[:project_bundle_id] || MobDev.Config.bundle_id()] + end + end + + defp list_matching_packages(%Device{platform: :android, serial: serial}, prefix) do + {output, _} = + System.cmd("adb", ["-s", serial, "shell", "pm", "list", "packages", prefix], + stderr_to_stdout: true + ) + + parse_package_list(output) + end + + defp list_matching_packages(%Device{platform: :ios, serial: udid}, prefix) do + # simctl returns a hashmap-ish plist of installed apps. Quick + # path: dump as JSON, walk top-level keys, filter by prefix. + case System.cmd("xcrun", ["simctl", "listapps", udid], stderr_to_stdout: true) do + {output, 0} -> simctl_listapps_with_prefix(output, prefix) + _ -> [] + end + end + + defp list_matching_packages(_device, _prefix), do: [] + + @doc false + @spec simctl_listapps_with_prefix(String.t(), String.t()) :: [String.t()] + def simctl_listapps_with_prefix(output, prefix) when is_binary(output) do + # simctl listapps output is roughly: + # "com.example.foo" = { ... }; + # "com.example.bar" = { ... }; + # Pull the quoted keys via regex; cheap, no plist parser needed + # for this use case. + Regex.scan(~r/"([^"]+)"\s*=\s*\{/, output) + |> Enum.map(fn [_, name] -> name end) + |> Enum.filter(&String.starts_with?(&1, prefix)) + |> Enum.sort() + end + + # ── Per-device uninstall ─────────────────────────────────────────────── + + defp uninstall_one(%Device{platform: :android, serial: serial} = d, bundle_id) do + {output, exit_code} = + System.cmd("adb", ["-s", serial, "uninstall", bundle_id], stderr_to_stdout: true) + + {outcome, reason} = interpret_adb_uninstall(output, exit_code) + %{device: d, bundle_id: bundle_id, outcome: outcome, reason: reason} + end + + defp uninstall_one(%Device{platform: :ios, type: :physical, serial: udid} = d, bundle_id) do + # iOS physical device: devicectl. Distinct from simctl — + # devicectl returns non-zero with "ContainerLookupErrorDomain" + # when the bundle id isn't installed on the device, vs simctl + # which returns 0 either way. + args = [ + "devicectl", + "device", + "uninstall", + "app", + "--device", + udid, + bundle_id + ] + + {output, exit_code} = System.cmd("xcrun", args, stderr_to_stdout: true) + {outcome, reason} = interpret_devicectl_uninstall(output, exit_code) + %{device: d, bundle_id: bundle_id, outcome: outcome, reason: reason} + end + + defp uninstall_one(%Device{platform: :ios, serial: udid} = d, bundle_id) do + # iOS simulators: simctl uninstall returns 0 either way. Probe + # the app-list first so the outcome distinguishes + # "actually-uninstalled" from "wasn't there to begin with". + case System.cmd("xcrun", ["simctl", "uninstall", udid, bundle_id], stderr_to_stdout: true) do + {_, 0} -> + # Was it installed? Check now that simctl has supposedly + # processed the uninstall. If simctl listapps still shows it, + # the uninstall didn't take. + if package_listed_on_sim?(udid, bundle_id) do + %{ + device: d, + bundle_id: bundle_id, + outcome: :error, + reason: "still installed after uninstall" + } + else + %{device: d, bundle_id: bundle_id, outcome: :uninstalled, reason: nil} + end + + {output, _} -> + case classify_simctl_error(output) do + :not_installed -> + %{device: d, bundle_id: bundle_id, outcome: :skipped, reason: "not installed"} + + :error -> + %{device: d, bundle_id: bundle_id, outcome: :error, reason: String.trim(output)} + end + end + end + + defp uninstall_one(%Device{platform: platform} = d, bundle_id) do + %{ + device: d, + bundle_id: bundle_id, + outcome: :skipped, + reason: + "platform #{inspect(platform)} not supported by `mix mob.uninstall` (use Xcode/Android Studio)" + } + end + + defp package_listed_on_sim?(udid, bundle_id) do + case System.cmd("xcrun", ["simctl", "listapps", udid], stderr_to_stdout: true) do + {output, 0} -> String.contains?(output, ~s("#{bundle_id}")) + _ -> false + end + end + + @doc false + @spec classify_simctl_error(String.t()) :: :not_installed | :error + def classify_simctl_error(output) when is_binary(output) do + if String.contains?(output, "No such application") or + String.contains?(output, "not installed") do + :not_installed + else + :error + end + end +end diff --git a/mix.exs b/mix.exs index 1a8ffde..c69a203 100644 --- a/mix.exs +++ b/mix.exs @@ -4,34 +4,128 @@ defmodule MobDev.MixProject do def project do [ app: :mob_dev, - version: "0.2.11", - elixir: "~> 1.17", + version: "0.6.23", + elixir: "~> 1.19", description: "Development tooling for the Mob mobile framework", source_url: "https://github.com/genericjam/mob_dev", + compilers: compilers(Mix.env()) ++ Mix.compilers(), + elixirc_paths: elixirc_paths(Mix.env()), deps: deps(), + aliases: aliases(), package: package(), - docs: docs() + docs: docs(), + unused: [ + ignore: [ + # GenServer / behaviour callbacks (mix_unused can't see callbacks). + {:_, :init, 1}, + {:_, :handle_call, 3}, + {:_, :handle_cast, 2}, + {:_, :handle_info, 2}, + {:_, :handle_continue, 2}, + {:_, :terminate, 2}, + {:_, :code_change, 3}, + {:_, :format_status, 1}, + # Mix task entry points are dispatched by name. + {~r/^Mix\.Tasks\..+$/, :run, 1}, + # Public API surface intended for downstream apps + IEx exploration — + # these are documented entry points, even when no internal caller + # references them. + {~r/^MobDev\.GooglePlay\..+$/, :_, :_}, + {~r/^MobDev\.Server\..+$/, :_, :_}, + # Test helpers exposed via @doc false for diagnosis. + {~r/^MobDev\..+/, :__test_only__, :_} + ] + ] ] end + # `:unused` only runs in dev. Adding it to test or prod compile would + # spam the test output and slow CI for no benefit (test fixtures + # legitimately have unused public functions). + # mix_unused 0.4.1 uses :re.import/1 which was removed in OTP 28, so + # skip it there until a compatible release is available. + defp compilers(:dev) do + if String.to_integer(System.otp_release()) >= 28, do: [], else: [:unused] + end + + defp compilers(_), do: [] + + # Include test/support/ in test compile so Mox definitions etc. are + # available without manually requiring them. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + def application do [extra_applications: [:logger]] 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 [ {:eqrcode, "~> 0.2"}, {:jason, "~> 1.4"}, - {:avatarz, "~> 0.2"}, - {:image, "~> 0.54"}, + {:mix_audit, "~> 2.1", runtime: false}, + {:avatarz, "~> 0.2", optional: true}, + # Pulls in sweet_xml ~> 0.7 transitively (via vix). sweet_xml + # surfaces a 1.20-rc.4 type-checker warning at lib/sweet_xml.ex:246 + # (`%SweetXpath{xpath | ...}` without a preceding pattern match + # against `%SweetXpath{}`). Cosmetic, in their code, doesn't + # affect runtime. Remove this note when sweet_xml ships a fix + # OR when image stops depending on it. + {:image, "~> 0.54", optional: true}, # Dev server {:phoenix_live_view, "~> 1.0"}, {:bandit, "~> 1.0"}, {:phoenix_pubsub, "~> 2.0"}, {:plug_crypto, "~> 2.0"}, + # Igniter — AST-aware code generation. Used by `mix mob.add_nif` and + # (planned) the LV generator to manipulate user mix.exs / mob.exs / + # generated modules without regex-patching Elixir source. Phase 3 of + # the build-system migration. + {:igniter, "~> 0.8"}, {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, - {:jump_credo_checks, "~> 0.1.0", only: [:dev, :test], runtime: false} + {:jump_credo_checks, "~> 0.1.0", only: [:dev, :test], runtime: false}, + # ex_slop — Credo plugin that catches AI-generated Elixir + # patterns (blanket rescue, narrator-style docs, redundant + # Enum chains, etc). Plugged into the existing Credo run. + {:ex_slop, "~> 0.4", only: [:dev, :test], runtime: false}, + # ex_dna — semantic code duplication detector. Catches Type I/II/III + # clones (exact, renamed-var, near-miss). Runs as a Credo check or + # standalone Mix task. Useful for keeping parallel-agent work from + # drifting into duplicate implementations of the same idea. + {:ex_dna, "~> 1.5", only: [:dev, :test], runtime: false}, + # reach — program dependence graph + architecture analysis. Mix + # tasks `reach.map`, `reach.inspect`, `reach.trace`, `reach.check`, + # `reach.otp` plus an HTML report. Useful for validating the + # cross-package layering (mob_dev tasks shouldn't be reachable + # from runtime code, etc). + {:reach, "~> 2.3", only: [:dev, :test], runtime: false}, + # Mox — behaviour-based mocks for the MobDev.Release.* test suite. + # Lets us test "given inputs, clang is invoked with these args" + # without actually running clang. + {:mox, "~> 1.2", only: :test}, + {:erlfmt, "~> 1.8", only: :dev, runtime: false}, + # Dev-only dead-code detector. Wires in via the `:unused` compiler + # tracer; ignore list maintained inline below since this codebase has + # legitimate dynamic dispatch (NIF on_load stubs, GenServer + # callbacks, behaviour implementations). + # + # Known Elixir 1.20-rc.4 warnings from this dep (cosmetic, dev-only): + # - lib/mix_unused/filter.ex:61 — `_.._ inside match is deprecated` + # (range without explicit step, deprecated in 1.20). + # - 0.4.1 uses :re.import/1 which was removed in OTP 28 (see the + # compilers/1 gate below — we skip the :unused tracer on OTP + # ≥ 28 to dodge that runtime crash). + # When mix_unused ships a release covering both, bump this version + # and remove the compilers/1 OTP gate. + {:mix_unused, "~> 0.4", only: :dev, runtime: false} ] end @@ -39,13 +133,26 @@ defmodule MobDev.MixProject do [ main: "readme", source_url: "https://github.com/genericjam/mob_dev", - source_url_pattern: "https://github.com/genericjam/mob_dev/blob/main/%{path}#L%{line}", - extras: ["README.md": [title: "mob_dev"]], + source_url_pattern: "https://github.com/genericjam/mob_dev/blob/master/%{path}#L%{line}", + extras: [ + "README.md": [title: "mob_dev"], + "CHANGELOG.md": [title: "Changelog"], + "build_release.md": [title: "Building OTP Release Tarballs"], + "guides/nifs.md": [title: "Static NIFs (C, Rust, Zig, Python)"], + "guides/security_scan.md": [title: "Security scanning"], + "guides/slim_release.md": [title: "Slim Release (bundle size)"], + "guides/publishing_to_testflight.md": [title: "Publishing to TestFlight (iOS)"], + "guides/publishing_to_google_play.md": [title: "Publishing to Google Play (Android)"], + "guides/python_embedding.md": [title: "Embedded CPython (iOS)"] + ], + groups_for_extras: [ + Guides: ~r/guides\/.*/ + ], groups_for_modules: [ "Mix Tasks": ~r/Mix\.Tasks\./, - "Server": ~r/MobDev\.Server/, - "Internals": ~r/MobDev/, - ], + Server: ~r/MobDev\.Server/, + Internals: ~r/MobDev/ + ] ] end diff --git a/mix.lock b/mix.lock index edb3dbe..4590193 100644 --- a/mix.lock +++ b/mix.lock @@ -1,34 +1,58 @@ %{ "avatarz": {:hex, :avatarz, "0.2.0", "84fcd8a173b74a4a9d1730b181c0c44dd703cb661de23d39874f5b2cc2a761d1", [:mix], [{:image, "~> 0.55.2", [hex: :image, repo: "hexpm", optional: false]}], "hexpm", "77e31e38896d5a86f3ff1d7c91f47bae09dd4fd6a29985eea820a79fad6075f9"}, - "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"}, + "bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"}, "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"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, - "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, "eqrcode": {:hex, :eqrcode, "0.2.1", "d12838813e8fc87b8940cc05f9baadb189031f6009facdc56ff074375ec73b6e", [:mix], [], "hexpm", "d5828a222b904c68360e7dc2a40c3ef33a1328b7c074583898040f389f928025"}, - "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"}, + "erlfmt": {:hex, :erlfmt, "1.8.0", "6df9379029a09f60b5c07d631c376f31d32dbf36a59f021b4a56f0b8825db468", [:rebar3], [], "hexpm", "f783ca8a8367c92f96ec75c8fee2c636efd0f39ac45ff57d8d825a71b4b957d3"}, + "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"}, + "ex_dna": {:hex, :ex_dna, "1.5.2", "486aff9dea0790df17041665abdf9b36f444bcf764fb0a268ea4101cfee58db9", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: true]}, {:gen_lsp, "~> 0.11", [hex: :gen_lsp, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: true]}], "hexpm", "29f9935fd27b12a33411168ca9a38b5cc5ef7e8ef91061654731486a1b85f170"}, + "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"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, + "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "igniter": {:hex, :igniter, "0.8.1", "3c6ea47f3a6031015e29da8b4ba5c685f0a2e409facf63041fd83e982ca3aa89", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "d99472e6daf3bfc3675d699c6c7ace9196f377207aab83e09d7b95e9d90e8ae8"}, "image": {:hex, :image, "0.55.2", "f21b5341ee05dfe2e0f649c34c6335cbce44be55e3ce3ced404ac008bef6c335", [:mix], [{:bumblebee, "~> 0.3", [hex: :bumblebee, repo: "hexpm", optional: true]}, {:evision, "~> 0.1.33 or ~> 0.2", [hex: :evision, repo: "hexpm", optional: true]}, {:exla, "~> 0.5", [hex: :exla, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:kino, "~> 0.13", [hex: :kino, repo: "hexpm", optional: true]}, {:nx, "~> 0.7", [hex: :nx, repo: "hexpm", optional: true]}, {:nx_image, "~> 0.1", [hex: :nx_image, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.1 or ~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:rustler, "> 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:scholar, "~> 0.3", [hex: :scholar, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:vix, "~> 0.23", [hex: :vix, repo: "hexpm", optional: false]}], "hexpm", "aa126e45b514810d1af89eded505ed3e523acefbb005f6220f8fbc1955904607"}, - "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"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"}, + "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"}, + "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, + "phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.31", "c45c85df509dd79c917bc530e26c71299e3920850f65ea52ab6a19ccee66875a", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2f53cc6a9e149f30449341c2775990819d97e3b22338fe719c4d30342e6f9638"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, - "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "reach": {:hex, :reach, "2.7.1", "5f9df784c4919b1e48e4e7aa5d777ee6566f219983fc295f275c79619eff419d", [: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]}, {:jason, "~> 1.0", [hex: :jason, 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", "c7d64e6c703885b067faa276d265bd6516aa877abeafb1189ff53d2463d7bec9"}, + "req": {:hex, :req, "0.6.2", "b9b2024f35bcf60a92cc8cad2eaaf9d4e7aace463ff74be1afe5986830184413", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cc9cd30a2ddd04989929b887178e1610c940456d962c6c3a52df6146d2eef9bf"}, + "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, + "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, + "spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, - "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, - "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "vix": {:hex, :vix, "0.38.0", "77529ee4f6ced339c3d5f90a9eacf306f5b7109d3d1b5e3ef391a984ad404f75", [:make, :mix], [{:cc_precompiler, "~> 0.1.4 or ~> 0.2", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7.3 or ~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:kino, "~> 0.7", [hex: :kino, repo: "hexpm", optional: true]}], "hexpm", "dca58f654922fa678d5df8e028317483d9c0f8acb2e2714076a8468695687aa7"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, + "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/priv/android/crypto.erl b/priv/android/crypto.erl new file mode 100644 index 0000000..89bf209 --- /dev/null +++ b/priv/android/crypto.erl @@ -0,0 +1,17 @@ +-module(crypto). +-export([strong_rand_bytes/1]). + +%% Minimal crypto stub for Android builds. +%% +%% The Mob pre-built Android OTP release omits the crypto OTP application +%% because it requires a cross-compiled OpenSSL NIF. Ecto and other deps +%% declare :crypto as a required application dependency even though the only +%% function called at runtime is strong_rand_bytes/1 (for UUID generation). +%% +%% This stub satisfies the dependency using the BEAM's built-in :rand module, +%% which is seeded from os:timestamp() at BEAM start. UUID generation works +%% correctly; cryptographic strength is reduced, which is acceptable for a +%% local-only mobile app without TLS or encryption use cases. + +strong_rand_bytes(N) -> + list_to_binary([rand:uniform(256) - 1 || _ <- lists:seq(1, N)]). diff --git a/priv/cpp_nif/nx_eigen_fft_eigen.cpp b/priv/cpp_nif/nx_eigen_fft_eigen.cpp new file mode 100644 index 0000000..595a139 --- /dev/null +++ b/priv/cpp_nif/nx_eigen_fft_eigen.cpp @@ -0,0 +1,97 @@ +// nx_eigen_fft_eigen.cpp — Eigen-backed FFT implementation of the +// nx_eigen_fft.h C contract. +// +// We ship this in mob_dev (not in upstream nx_eigen) so the on-device +// path through `mix mob.enable nxeigen` gets `Nx.fft` working without +// a separate FFTW cross-compile. Eigen's FFT module ships kissfft as +// the default backend — header-only, embedded in the Eigen tarball at +// unsupported/Eigen/FFT — so this file plus the Eigen headers gives +// us a complete pluggable FFT with no external library dep. +// +// ## Contract delta vs FFTW +// +// nx_eigen_fft.h spec: +// * Forward unnormalised: X[k] = Σ x[n] · exp(-2πi nk/N) +// * Inverse unnormalised: X̃[k] = Σ x[n] · exp(+2πi nk/N) +// * Caller divides the IDFT by n. +// +// Eigen's `FFT<>` defaults match this for `fwd` (kissfft is unscaled) +// but its `inv` divides by N unless `Unscaled` flag is set. We set +// the flag on every transform so the contract holds exactly. +// +// ## Layout +// +// The C contract is interleaved real/imag floats: +// [re_0, im_0, re_1, im_1, ..., re_{n-1}, im_{n-1}] +// `std::complex<float>` and `std::complex<double>` are guaranteed by +// C++ to have this exact layout (sizeof = 2 × sizeof(scalar), no +// padding, real then imag). Bitwise-compatible reinterpret_cast. +// +// ## Performance note +// +// Eigen's default kissfft backend is roughly 2× slower than FFTW for +// large transforms but typically microseconds for audio-sized buffers +// (≤8192). If a future workload measures a real bottleneck, swap to +// FFTW via a parallel `nx_eigen_fft_fftw.cpp` cross-compile — the +// pluggable contract was designed for exactly that. + +#include "nx_eigen_fft.h" + +#include <complex> +#include <unsupported/Eigen/FFT> + +namespace { + +template <typename Scalar> +int fft_forward(const Scalar *in, Scalar *out, int n) { + if (n <= 0) { + return 1; + } + Eigen::FFT<Scalar> fft; + fft.SetFlag(Eigen::FFT<Scalar>::Unscaled); + + const auto *src = reinterpret_cast<const std::complex<Scalar> *>(in); + auto *dst = reinterpret_cast<std::complex<Scalar> *>(out); + + // Eigen's `fwd(dst, src, n)` accepts overlapping buffers (kissfft + // copies internally), so the contract's in-place allowance holds. + fft.fwd(dst, src, static_cast<Eigen::Index>(n)); + return 0; +} + +template <typename Scalar> +int fft_inverse(const Scalar *in, Scalar *out, int n) { + if (n <= 0) { + return 1; + } + Eigen::FFT<Scalar> fft; + fft.SetFlag(Eigen::FFT<Scalar>::Unscaled); + + const auto *src = reinterpret_cast<const std::complex<Scalar> *>(in); + auto *dst = reinterpret_cast<std::complex<Scalar> *>(out); + + fft.inv(dst, src, static_cast<Eigen::Index>(n)); + return 0; +} + +} // namespace + +extern "C" { + +int nx_eigen_fft_forward_f32(const float *in, float *out, int n) { + return fft_forward<float>(in, out, n); +} + +int nx_eigen_fft_forward_f64(const double *in, double *out, int n) { + return fft_forward<double>(in, out, n); +} + +int nx_eigen_fft_inverse_f32(const float *in, float *out, int n) { + return fft_inverse<float>(in, out, n); +} + +int nx_eigen_fft_inverse_f64(const double *in, double *out, int n) { + return fft_inverse<double>(in, out, n); +} + +} // extern "C" diff --git a/priv/mob_logo.svg b/priv/mob_logo.svg new file mode 100644 index 0000000..d12a224 --- /dev/null +++ b/priv/mob_logo.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1740" height="1840"><path fill="#04040a" d="M1079 2h57l1 3 2.55.08 3.33.17 3.3.14L1149 6l2 4h9l1 3 1.68.08c2.18.17 4.2.38 6.32.92 1.5 2.06 1.5 2.06 2 4l1.69-.87A64 64 0 0 1 1180 14l2 1v3h-6c1 3 1 3 2.86 3.96L1181 23c1.7 1.73 1.7 1.73 3.25 3.63l1.58 1.9L1187 30c4.68-.62 4.68-.62 6.31-2.56L1194 26l4 1v3h-5c2.6 5.09 6.46 8.76 10.5 12.75l2.05 2.07 1.98 1.98 1.8 1.79C1211 50 1211 50 1214 51v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.9 2.27-.9 2.27-1 5 1.77 2.42 1.77 2.42 4.25 4.75l2.45 2.36C1223 66 1223 66 1226 67v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.94 2.25-.94 2.25-1 5 2.14 3.17 4.38 4.8 8 6v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.94 2.25-.94 2.25-1 5 2.14 3.17 4.38 4.8 8 6v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l1.94.69c2.06 1.31 2.06 1.31 2.81 3.94l.25 2.37h2l1-6h3l.25 3.38c.26 1.93.26 1.93.75 3.62 2.06 1.44 2.06 1.44 4 2-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v-12h3l1 11-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v-12h3l1 11-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v-12h3l1 11-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v-12h3l1 11-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v-12h3l1 11-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l1.96.72c2.04 1.28 2.04 1.28 2.54 3.37l.06 2.41c.07 2.4.07 2.4.44 4.5q1.49 1.03 3 2c.4 2.39.14 4.56 0 7l4 2v-10h3l1 9-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69C1355 336 1355 336 1354 338.3c0 3.03.25 3.36 2.25 5.44 2 1.85 3.04 2.35 5.75 3.25v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.94 2.25-.94 2.25-1 5 2.14 3.17 4.38 4.8 8 6v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.84 2.27-.84 2.27-1 5a22.4 22.4 0 0 0 9 9c5.14 1.2 10.07 1.06 15.32.88l4.4-.04q5.74-.06 11.5-.22 5.87-.14 11.76-.18 11.51-.15 23.02-.44l1 4h34l1 3 24 1 2 4h22l2 4h17l1 3 3.43.08 4.44.17 2.27.04 2.16.1 2 .06 1.7.55 2 4h10l2 4h14l2 4h10l2 4h10l2 4h10l2 4 5.27-.3 1.73.3.95 1.48L1640 421c2.32.41 4.55.44 6.9.5 2.1.5 2.1.5 3.38 2.54l.72 1.96c2.38-.31 2.38-.31 5-1l2-3 11 1v3h-12l2 4h10l2 4c4.68-.62 4.68-.62 6.31-2.56l.69-1.44 7 1v3h-8l2 4c4.68-.62 4.68-.62 6.31-2.56l.69-1.44 4 1v3h-5c1.88 3.48 3.48 6.08 7 8 2.13-.12 2.13-.12 4-1 1.25-1.56 1.25-1.56 2-3l4 1v3h-5c1.88 3.48 3.48 6.08 7 8 2.13-.12 2.13-.12 4-1 1.25-1.56 1.25-1.56 2-3l4 1v3h-5c4.2 7.4 4.2 7.4 9 9v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.94 2.25-.94 2.25-1 5 2.14 3.17 4.38 4.8 8 6v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v24l4 1q.12 2.69.19 5.38l.1 3.02-.29 2.6q-.74.45-1.5.94c-1.5 1.06-1.5 1.06-1.87 2.72v1.97l-.05 2.14-.02 4.46-.06 2.14v1.97l-.5 1.66-4 2v10l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 4h-3v-5c-3.48 1.88-6.08 3.48-8 7 .13 2.13.13 2.13 1 4a13 13 0 0 0 3 2l-1 4h-3v-5c-4.33 2.32-7.44 5.2-10.81 8.69L1682 615c1.07 2.92 1.78 4.78 4 7-.37 2.13-.37 2.13-1 4h-3v-5h-4l-.69 1.67c-1.67 2.97-3.9 5.17-6.32 7.53l-1.47 1.47-4.64 4.58-3.16 3.13L1654 647c1.07 2.92 1.78 4.78 4 7-.37 2.13-.37 2.13-1 4h-3l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.1-1.06-2.1-1.06-5-1-2.81 1.95-5.07 4.42-7.42 6.9l-2.11 2.16a2303.17 2303.17 0 0 1-13.34 13.75l-4.15 4.29-1.9 1.95-1.66 1.73C1611 690 1611 690 1609 690l-2 4h-2v3l5 1v3c-3.53 1.22-3.53 1.22-5.75.19L1603 700v-2c-5.32.34-7.76 3.23-11.25 6.88l-1.64 1.64c-3.97 4.06-3.97 4.06-5.11 7.48h-7l-1 3c-1.72 1.62-3.55 3.04-5.4 4.5-1.6 1.5-1.6 1.5-2.6 4.5h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.27-.9-2.27-.9-5-1-2.42 1.77-2.42 1.77-4.75 4.25l-2.36 2.45C1554 735 1554 735 1553 738h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.27-.9-2.27-.9-5-1-2.42 1.77-2.42 1.77-4.75 4.25l-2.36 2.45C1538 747 1538 747 1537 750h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.25-.94-2.25-.94-5-1-3.17 2.14-4.8 4.38-6 8h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.25-.94-2.25-.94-5-1-3.17 2.14-4.8 4.38-6 8h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.25-.94-2.25-.94-5-1-3.17 2.14-4.8 4.38-6 8h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.25-.94-2.25-.94-5-1-3.17 2.14-4.8 4.38-6 8h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.26-.88-2.26-.88-5-1-2.46 1.66-2.46 1.66-4.81 4.06l-2.4 2.35c-2.15 3.11-2.15 4.88-1.79 8.59l-4 1v39l-4 2v29l-4 1 1 37c4.1-2.05 6.6-4.16 9.81-7.31l1.56-1.44c1.4-1.36 2.59-2.6 3.63-4.25-.08-2.77-.08-2.77-1-5-1.56-1.31-1.56-1.31-3-2l1-4h3v5c3.48-1.88 6.08-3.48 8-7-.12-2.12-.12-2.12-1-4-1.56-1.25-1.56-1.25-3-2l1-7h3v8l4-2c-.31-2.37-.31-2.37-1-5l-1.52-.95L1486 866c-.3-2.69-.4-5.14-.36-7.82v-2.37q0-2.47.02-4.96.03-3.76.02-7.54l.02-4.82v-2.26c.06-4.76.8-8.7 2.3-13.23h2v44l4-2-1-43h8l.25 2.38c.75 2.62.75 2.62 2.5 3.62l2.25 1q1.32 1.24 2.56 2.56C1511 834 1511 834 1514 835v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.94 2.25-.94 2.25-1 5 2.14 3.17 4.38 4.8 8 6v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v10l4 2 1 22 3 1q.13 4.19.19 8.38l.07 2.4.08 4.43-.34 1.79-1.5.9c-1.5 1.1-1.5 1.1-1.9 2.7l-.03 1.93-.06 2.19-.02 2.35-.06 2.41-.12 7.64-.1 5.18q-.13 6.35-.21 12.7l-4 2v23l4 1v14l4 2-.14 2.08-.11 2.73-.14 2.71c.39 2.48.39 2.48 2.34 4.12l2.05 1.36 1 3 3 1v-6h3c.93 3.01 1.04 3.87 0 7-2.06.69-2.06.69-4 1 2.4 4.5 5.48 7.83 9.02 11.43l1.77 1.8 3.7 3.74a706 706 0 0 1 5.62 5.76l3.6 3.64 1.68 1.76a27 27 0 0 0 4.61 3.87c2.81-.05 2.81-.05 5-1 1.31-1.56 1.31-1.56 2-3l4 1v3h-5c2.3 4.26 5.09 7.4 8.5 10.75l1.47 1.5c2.33 2.31 3.87 3.7 7.03 4.75v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.9 2.27-.9 2.27-1 5 1.77 2.42 1.77 2.42 4.25 4.75l2.45 2.36c2.3 1.89 2.3 1.89 5.3 2.89v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v10l4 2v42l-4 2v10l-4 2v9l-4 1-.31 1.81c-.69 2.19-.69 2.19-2.32 3.69l-1.37 1.5c.13 2.06.13 2.06 1 4a13 13 0 0 0 3 2l-1 4h-3v-5a34 34 0 0 0-12 10c-.12 2.81-.12 2.81 1 5a10 10 0 0 0 3 2l-1 4h-3v-5c-2.24 1-3.88 1.88-5.62 3.63L1579 1190h-2v3l5 1-1 4c-3.69-.5-5.6-1.1-8-4a24 24 0 0 0-7.69 5.44l-1.82 1.8C1562 1203 1562 1203 1561 1206h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.25-.94-2.25-.94-5-1-3.17 2.14-4.8 4.38-6 8h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.56-.62-2.56-.62-5-1l-2 4h8v3l-7 1-.69-1.44c-1.83-2.18-3.56-2.2-6.31-2.56l-2 4h-10l-2 4h-15v-7l2.38-.25 2.62-.75q1.05-1.99 2-4c1.44-1.57 1.44-1.57 3.07-3.06l1.78-1.67 3.71-3.4a33 33 0 0 0 4.44-4.87c0-2.83 0-2.83-1-5-1.56-1.31-1.56-1.31-3-2l1-4h3v5c3.5-1.9 6.12-3.43 8-7 .24-2.4.14-4.58 0-7l-3-1c-1.03-5.11-1.18-10.06-1.2-15.27l-.13-22-.04-2.56.01-2.37v-2.08c.36-1.72.36-1.72 1.84-2.8l1.52-.92c.69-2.62.69-2.62 1-5l-4-2v8h-3l-1-7 1.44-.69c1.56-1.31 1.56-1.31 2.37-3.63.19-2.68.19-2.68-1.18-4.82l-2.08-2.1q-1.12-1.13-2.26-2.3-1.2-1.14-2.41-2.34l-2.35-2.4q-1.11-1.13-2.29-2.27l-2.1-2.08c-2.14-1.37-2.14-1.37-4.82-1.18-2.32.81-2.32.81-3.63 2.37l-.69 1.44-4-1v-3h5a34 34 0 0 0-10-12c-2.81-.12-2.81-.12-5 1-1.31 1.56-1.31 1.56-2 3l-7-1v-3h8l-2-4c-2.92 1.07-4.78 1.78-7 4-2.12-.37-2.12-.37-4-1v-3h5v-4l-1.81-.31c-2.19-.69-2.19-.69-3.69-2.32l-1.5-1.37c-2.06.13-2.06.13-4 1-1.25 1.56-1.25 1.56-2 3l-6-1-1-3h8l-2-4c-3.7.64-3.7.64-5.31 2.63-1.69 1.37-1.69 1.37-3.43 1.34a91 91 0 0 1-5.26-.97c-2.43-6.29-2.43-6.29-1-10l-4-2v8l-4 1v47l4 1 .08 38.3a5507 5507 0 0 1 .03 22.44l.01 1.88c0 4.27 0 4.27-1.12 5.38-3.88.15-7.27.2-11-1v2l1.88.94 4.12 2.06c2.52-.32 3.64-.55 5.25-2.56l.75-1.44 7 1v3h-8l2 4c4.68-.62 4.68-.62 6.31-2.56l.69-1.44 4 1v3h-5c2.39 4.63 5.69 9.01 10 12 2.69-.26 3.66-.63 5.5-2.62l1.5-1.38c2.19.31 2.19.31 4 1v3l-5 1v3l1.8.27c2.76.91 3.69 2.1 5.51 4.36a143 143 0 0 0 9.11 10.02c1.58 1.35 1.58 1.35 4.58 2.35v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.94 2.25-.94 2.25-1 5 2.14 3.17 4.38 4.8 8 6v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v12l4 1v45l-4 2v14l-4 2v10l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 4h-3v-5c-4.87 2.53-8.39 6.1-12.23 9.97l-6.34 6.34q-3.27 3.28-6.57 6.55-2.07 2.1-4.16 4.17l-1.99 1.98-1.83 1.85-1.62 1.62C1466 1423 1466 1423 1465 1426h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.25-.94-2.25-.94-5-1-3.17 2.14-4.8 4.38-6 8h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.56-.62-2.56-.62-5-1l-2 4h8v3l-7 1-.69-1.44c-1.83-2.18-3.56-2.2-6.31-2.56l-2 4h-10c-1.12 2.24-1.12 3.4-1.12 5.88v24.62l.01 14.86.02 21.49a54719 54719 0 0 0 .04 70.82v10.47l.05 86.86-4 1v23l-4 2v10l-4 2 .1 2.55.09 3.33.1 3.3-.29 2.82c-1.48 1.13-1.48 1.13-3 2-.37 1.88-.37 1.88-.44 4-.18 2.16-.18 2.16-.56 4-2.06 1.5-2.06 1.5-4 2 .62 4.68.62 4.68 2.56 6.31l1.44.69-1 4h-3v-5c-3.48 1.88-6.08 3.48-8 7 .13 2.13.13 2.13 1 4a13 13 0 0 0 3 2l-1 4h-3v-5a34 34 0 0 0-12 10c-.12 2.81-.12 2.81 1 5a10 10 0 0 0 3 2l-1 4h-3v-5c-4.26 2.3-7.4 5.09-10.75 8.5l-1.5 1.47c-2.31 2.33-3.7 3.87-4.75 7.03h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.27-.9-2.27-.9-5-1-2.42 1.77-2.42 1.77-4.75 4.25l-2.36 2.45c-1.89 2.3-1.89 2.3-2.89 5.3h-7l-1 3-3 2-1 3h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.56-.62-2.56-.62-5-1l-2 4h8v3l-7 1-.69-1.44c-1.83-2.18-3.56-2.2-6.31-2.56l-2 4h-8l-1 4h-13l-2 4h-10l-2 4-24 1-1 3H511l-2-4h-30l-2-4h-10l-2-4h-10l-2-4h-10l-2-4c-4.68.62-4.68.62-6.31 2.56L434 1818l-7-1v-3h8l-2-4c-4.68.62-4.68.62-6.31 2.56L426 1814l-4-1v-3h5c-1.88-3.48-3.48-6.08-7-8-2.12.13-2.12.13-4 1-1.25 1.56-1.25 1.56-2 3l-4-1v-3h5c-2.65-5.24-6.71-9-10.84-13.1l-2.32-2.33-6.1-6.08-3.72-3.74-7.18-7.17-2.24-2.26-2.1-2.07-1.83-1.83C377 1762 377 1762 374 1761v5l-4-1c.38-1.94.38-1.94 1-4l2-1c.63-2.56.63-2.56 1-5l-4-2v8h-3l-1-7 1.44-.69C369 1752 369 1752 370 1749.7c0-3.03-.25-3.36-2.25-5.44-2-1.85-3.04-2.35-5.75-3.25v5l-4-1c.38-1.94.38-1.94 1-4l2-1c.63-2.56.63-2.56 1-5l-4-2v-10l-4-2v12h-3l-1-11 3-2c.69-2.62.69-2.62 1-5l-4-2v-14l-4-2-1-30-3-1v-54l3-1 .08-21.96.02-8.02.01-2.5c0-4.02-.31-7.58-1.11-11.52l1-3q.14-2.13.14-4.27l.04-8.57.02-10 .02-6.93.02-12.7q0-9.33.05-18.66l.03-16.2.03-6.87v-9.62q.02-1.42.03-2.87c-.03-5.34-.62-8.72-3.38-13.31-.39-2.05-.39-2.05-.4-3.87l-.01-2.05.04-2.08-.04-2.08.01-2.05.01-1.82c.39-2.05.39-2.05 1.89-4.76 1.68-3.31 1.92-6.37 1.88-10.05l.02-1.99-.01-6.54.01-4.7q.02-6.39 0-12.76v-35.79q-.04-12.96 0-25.92.01-11.13 0-22.27v-13.3q.02-6.24 0-12.49v-4.58q0-3.14-.02-6.26l.02-1.83c-.06-4.1-1.13-6.46-3.4-9.81-.39-2.14-.39-2.14-.4-4.25l-.01-2.34.04-2.41-.04-2.4.01-2.35.01-2.1c.39-2.15.39-2.15 1.89-4.28 1.97-3.78 1.93-6.75 1.9-10.95l.02-2.45q.02-4.05.01-8.12l.03-5.83q.04-7.9.05-15.8 0-8.27.06-16.53l.08-27.73a18626 18626 0 0 1 .13-39.18q.14-36.64.22-73.28c-2.24-1.12-3.43-1.12-5.91-1.1l-2.3.01-2.42.03-2.42.01-5.95.05-2-4h-26v-4h-20l-2-4-1.71.05q-3.84.08-7.67.14l-2.69.07-4.96.08-1.97-.34a107 107 0 0 1-2-3c-1.79-.43-1.79-.43-3.91-.51l-2.3-.1-2.41-.08-2.43-.1-5.95-.21-2-4h-14l-2-4h-14l-2-4h-10l-2-4h-13l-1-3-2.55-.08-3.32-.17-3.31-.14-2.82-.61-2-4h-10l-2-4h-10l-2-4c-2.37.31-2.37.31-5 1l-2 3-11-1v-3h12l-2-4h-10l-2-4c-4.68.62-4.68.62-6.31 2.56L114 994l-7-1v-3h8l-2-4c-2.37.31-2.37.31-5 1l-2 3-11-1v-3h12l-2-4H95l-2-4c-4.68.62-4.68.62-6.31 2.56L86 982l-7-1v-3h8l-2-4c-4.68.62-4.68.62-6.31 2.56L78 978l-4-1v-3h5c-1.88-3.48-3.48-6.08-7-8-2.12.13-2.12.13-4 1-1.25 1.56-1.25 1.56-2 3l-7-1v-3h8l-2-4c-4.68.62-4.68.62-6.31 2.56L58 966l-4-1v-3h5c-1.88-3.48-3.48-6.08-7-8-2.12.13-2.12.13-4 1-1.25 1.56-1.25 1.56-2 3l-4-1v-3h5c-2.4-4.52-5.5-7.79-9.1-11.36l-1.78-1.79q-1.84-1.86-3.72-3.7l-5.69-5.7q-1.8-1.82-3.62-3.62l-1.71-1.73-1.6-1.59-1.41-1.4C17 922 17 922 14 921v5l-4-1c.38-1.94.38-1.94 1-4l2-1c.94-2.25.94-2.25 1-5-2.14-3.17-4.38-4.8-8-6v5l-4-1c.38-1.94.38-1.94 1-4l2-1c.63-2.56.63-2.56 1-5l-4-2 .07-2.12.06-2.75c0-.46 0-.46.07-2.75C2 891 2 891 0 889c-.25-1.97-.25-1.97-.26-4.4l-.02-2.76.01-3v-9.48q.01-4.92 0-9.84v-9.22l.01-2.77v-2.44c.28-2.3.94-3.24 2.26-5.09.2-2.92.2-2.92.13-6.19l-.06-3.29L2 828l3-1 .08-1.68c.17-2.18.38-4.2.92-6.32 2.06-1.5 2.06-1.5 4-2-.62-4.68-.62-4.68-2.56-6.31L6 810l1-7h3v8l4-2c-.62-4.68-.62-4.68-2.56-6.31L10 802l1-7h3v8l4-2c-.62-4.68-.62-4.68-2.56-6.31L14 794l1-4h3v5a34 34 0 0 0 12-10c.13-2.81.13-2.81-1-5-1.56-1.31-1.56-1.31-3-2l1-4h3v5a41 41 0 0 0 9.64-7.45l1.26-1.26 3.91-3.98 2.68-2.7L54 757c-1.07-2.92-1.78-4.78-4-7 .38-2.12.38-2.12 1-4h3v5h4l.68-1.68c1.68-2.95 3.94-5.08 6.38-7.38 4.8-4.54 4.8-4.54 5.94-7.94h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.25.9 2.25.9 5 1a32 32 0 0 0 5.05-4.4l1.38-1.39q1.44-1.46 2.86-2.94 2.19-2.26 4.4-4.5l4.12-4.2c2.11-2.21 3.2-3.62 4.19-6.57h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.27.9 2.27.9 5 1 2.42-1.77 2.42-1.77 4.75-4.25l2.36-2.45C114 701 114 701 115 698h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.14 1.02 2.14 1.02 5 1 2.58-1.76 4.62-3.97 6.75-6.25l1.79-1.82c4.3-4.46 4.3-4.46 5.46-7.93h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.25.94 2.25.94 5 1 3.17-2.14 4.8-4.38 6-8h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.25.94 2.25.94 5 1 3.17-2.14 4.8-4.38 6-8h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.25.94 2.25.94 5 1 3.17-2.14 4.8-4.38 6-8h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.56.63 2.56.63 5 1l2-4h-8v-3l7-1 .69 1.44C180 653 180 653 182.3 654c3.03 0 3.36-.25 5.44-2.25 1.85-2 2.35-3.04 3.25-5.75h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.56.63 2.56.63 5 1l2-4h-8v-3l7-1 .69 1.44C200 641 200 641 202.3 642c3.03 0 3.36-.25 5.44-2.25 1.85-2 2.35-3.04 3.25-5.75h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.56.63 2.56.63 5 1l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h7l1-3 3-2 1-3h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.56.63 2.56.63 5 1l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.31 1.56 1.31 1.56 3.69 2.5 2.62.06 2.62.06 4.8-1.88L281 598h2l1-4h10l1-4h6l2-4h-9v-3l8-1 2 3c2.63.69 2.63.69 5 1l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.31 1.56 1.31 1.56 3.6 2.4 2.71.16 2.71.16 5.17-1.36 6.26-6.1 6.26-6.1 7.12-10.3A44 44 0 0 0 334 559l4-2-.19-3.37c-.1-1.9-.1-1.9.19-3.63l1.47-.81C341 548 341 548 341.5 544.96q.1-1.8.18-3.59l.1-1.85q.12-2.25.21-4.52l4-1-.05-1.9q-.09-4.23-.14-8.48l-.07-2.98-.03-2.85-.05-2.64.34-2.15 1.49-.96L349 511c.43-2.43.43-2.43.51-5.45l.1-3.26.08-3.42.1-3.44q.12-4.2.21-8.43l4-2v-22l4-2v-26l4-2v-26l4-2v-29l4-1v-24l4-2v-22l4-2v-22l4-2-.05-1.71q-.09-3.84-.14-7.67l-.07-2.69-.08-4.96.34-1.97q1.49-1.02 3-2c.43-1.79.43-1.79.51-3.91l.1-2.3.08-2.42.1-2.42.21-5.95 4-2v-18l4-2v-14l4-2v-10l4-2v-10l4-2v-10l4-2v-10l4-2c-.62-4.68-.62-4.68-2.56-6.31L410 170l1-7h3v8l4-2c-.62-4.68-.62-4.68-2.56-6.31L414 162l1-7h3v8l4-2c-.62-4.68-.62-4.68-2.56-6.31L418 154l1-7h3v8l4-2c-.62-4.68-.62-4.68-2.56-6.31L422 146l1-4h3v5c3.48-1.88 6.08-3.48 8-7-.12-2.12-.12-2.12-1-4-1.56-1.25-1.56-1.25-3-2l1-4h3v5c5.04-2.62 8.84-6.36 12.88-10.31l2.12-1.98a36 36 0 0 0 5-5.71c-.05-3.48-.05-3.48-1-6 1.75-1.06 1.75-1.06 4-2l1.94.6c2.06.4 2.06.4 3.7-.63l1.56-1.61 1.72-1.75 1.77-1.86 1.8-1.82c4.36-4.49 4.36-4.49 5.51-7.93h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.56.63 2.56.63 5 1l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h10l2-4h14l2-4h22l2-4h77l1 4h40l2 4h30l2 4h37l1 4 1.85-.02q8.6-.08 17.21-.14 4.42-.01 8.85-.07l10.18-.06 3.21-.05h2.97l2.63-.02c2.1.36 2.1.36 3.19 1.85L800 85c2.62.34 5 .49 7.63.51l2.28.06q3.6.07 7.22.12l4.88.1q6 .13 11.99.21l1-4h18l2-4h14l2-4h10l2-4h10l2-4h-12v-3l11-1 2 3c2.63.69 2.63.69 5 1l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h10l2-4h-12v-3l11-1 2 3c2.63.69 2.63.69 5 1l.72-1.96C931 50 931 50 933.09 49.5l2.41-.06c2.4-.07 2.4-.07 4.5-.44q1.03-1.48 2-3c1.73-.3 1.73-.3 3.63-.19L949 46l2-4h-10v-3l9-1 2 3c2.63.69 2.63.69 5 1l2-4h10l2-4h-12v-3l11-1 2 3c2.63.69 2.63.69 5 1l2-4h10l2-4h10l2-4h10l2-4h10l2-4h10l2-4h14l2-4h22zm174 112 1 4 4 1v-6c-1.72 0-3.34.57-5 1m397 539-1 5h5v-5zm-191 252c-1 1-1 1-1.1 2.85l.06 4.44.04 1.71 2 1 2-1v-8zm-17 152v2h2v-2zm19 1v2h2v-2zm-5 12-1 4h8l1-2-1-2zm-14.18 52.11c-1.2 2.76-1.01 5.1-.96 8.1l.03 1.9.13 6.18.08 4.3q.1 5.64.23 11.28l.22 11.53q.21 11.3.45 22.6h4v-67c-3 0-3 0-4.18 1.11M1534 1133v48l4-2a4314 4314 0 0 0 .72-25.05q.15-4.57.26-9.13l.1-2.88.06-2.67.06-2.36c-.2-1.91-.2-1.91-2.2-3.91z"/><path fill="#2e2d33" d="M1397 426q2.91.15 5.81.44c4.3.38 8.5.6 12.82.5 6.11-.07 12.08.6 18.15 1.28q9.24 1.02 18.53 1.53c10.53.66 21 1.96 31.46 3.23l3.34.4 3 .36c2.98.27 5.9.28 8.89.26 1.92.37 1.92.37 3.75.88 4.7 1.14 9.39 1.59 14.19 2.06 5.86.58 11.17 1.47 16.76 3.4 3.56 1.02 7.15 1.16 10.84 1.41 3.01.3 5.6 1.26 8.46 2.25q2.15.26 4.31.44c4.05.38 7.47 1.42 11.27 2.8 4.37 1.38 8.9 2.04 13.42 2.76v2l1.62.11c6.1.54 11.57 2.01 17.38 3.89a219 219 0 0 0 11 3v2h6v2h9l-1 2 10 3-2 1v2h6l1 3q2.17 1.5 4.38 2.9c2.55 1.73 4.5 3.86 6.62 6.1l1.81 1.69C1655 486 1655 486 1655 488h2c4.77 4.77 5.07 14.56 5.09 21.02-.6 13.52-5.44 25.4-12.09 36.98l-1.5 2.69a182 182 0 0 1-5.57 8.98c-9.77 15.14-20.31 30.1-34.16 41.81a115 115 0 0 0-8.34 8.16l-1.62 1.67-1.48 1.55C1596 612 1596 612 1594 612l-.62 1.69a14 14 0 0 1-4.13 4.62c-4.77 3.79-8.68 8.15-11.25 13.69l-5-2 1-2-2-1-1 3q-2.48 1.05-5 2l-1.62 1.63L1563 635h-3v3l-1.94.88C1556 640 1556 640 1555 642a89 89 0 0 0 9.56-3.56A32 32 0 0 1 1573 636c-.5 4.2-1.5 5.94-4.75 8.63l-2.2 1.81c-2.05 1.56-2.05 1.56-4.67 3.12a20 20 0 0 0-5 4.25c-3.4 3.57-7.43 5.44-11.76 7.68-3 1.73-5.06 3.67-7.36 6.2-1.83 1.9-3.95 3.04-6.26 4.31q-2.25 1.46-4.46 2.97-1.52 1.02-3.08 2a33 33 0 0 0-6.96 5.65c-2.9 2.76-4.7 3.4-8.5 4.38v2l-1.58.81c-7.01 3.74-14.44 8.13-20.03 13.83a13 13 0 0 1-4.7 2.92l-1.85.75q-2.55.94-5.15 1.75c-2.69.94-2.69.94-4.69 2.94q-2.1.71-4.21 1.36c-2.82 1-5.41 2.43-8.06 3.84-1.73.8-1.73.8-3.73.8l-1 3c-2.15 1.4-2.15 1.4-4.94 2.81-3.76 1.96-7.4 3.99-11 6.25a69 69 0 0 1-11 5.38c-6.86 2.74-13.87 6.2-19.02 11.63-2.4 2.28-4.51 3.14-7.6 4.3a86 86 0 0 0-12.19 5.88c-3.94 2.23-6.7 3.11-11.15 3.35-3.88.74-7.11 2.96-10.5 4.92a65 65 0 0 1-7.93 3.77c-1.9.8-3.71 1.72-5.55 2.65q-5.34 2.68-10.85 5.06-2.22.98-4.42 2.04a61 61 0 0 1-12.66 4.4c-10.94 2.6-20.76 7.73-30.72 12.79l-1.85.94q-1.95 1.01-3.9 2.07c-1.72.76-1.72.76-4.72.76l-1 3c-6.92 4.48-13.9 6.75-22 8-2.44.4-3.75.82-6 2-1.13 2.36-1.52 4.4-2 7l-1-2c-7.73.97-14.64 3.64-21.89 6.4a125 125 0 0 1-16.41 4.93c-7.03 1.75-13.67 4.6-20.25 7.6-3.55 1.55-7.16 2.9-10.78 4.28a145 145 0 0 0-8.48 3.48 58 58 0 0 1-9.06 2.78c-2.39.6-4.72 1.29-7.07 2.03l-2.27.72c-1.79.78-1.79.78-2.79 2.78-2.58.34-2.58.34-5.81.5-5.76.28-5.76.28-8.1 1.46a22 22 0 0 1-6.65 1.95c-5.67 1.06-11.03 2.77-16.5 4.59a161 161 0 0 1-17.89 5.02 66 66 0 0 0-8.99 3.04c-5 1.9-9.4 2.84-14.77 3.14-3.32.44-5.96 1.86-8.95 3.32-3.43 1.43-6.9 1.9-10.56 2.48a77 77 0 0 0-10.59 2.91c-2.17.58-3.96.7-6.19.59v2l-1.58.3c-6.4 1.3-12.33 3.39-18.42 5.7-4.74 1.78-9.43 2.64-14.4 3.5-2.98.57-5.73 1.52-8.6 2.5q-2.12.43-4.25.81a199 199 0 0 0-29.4 8.17 100 100 0 0 1-21.57 4.94c-6.65.89-12.59 2.9-18.87 5.21-3.51 1.05-6.9 1.4-10.53 1.75a71 71 0 0 0-17.74 4.24c-3.55 1.19-6.92 1.53-10.64 1.88l-1 1c-2.72.66-5.5 1.09-8.25 1.56l-2.32.42c-3.85.68-7.5 1.2-11.43 1.02v2l-7.69 1.38-2.35.42q-6.71 1.18-13.46 2.14c-7.2 1.08-14.22 2.8-21.29 4.52q-5.51 1.34-11.02 2.6a528 528 0 0 0-13.94 3.44c-7.3 1.85-14.7 3.16-22.1 4.54q-5.34.99-10.67 2.02l-2.46.47C751 951 751 951 749 952q-1.86.29-3.74.46l-2.23.24-4.65.46A40 40 0 0 0 727 956c-3.17.63-6.36 1.06-9.56 1.5l-7.7 1.1q-5.85.88-11.7 1.9-4.45.73-8.9 1.43l-2.96.47-6.36.99c-5.03.8-10.04 1.62-15.03 2.69-13.8 2.9-27.43 3.73-41.49 4.32l-2.4.1q-2.23.1-4.48.18c-3.04.13-5.51.35-8.42 1.32-8.32 1.07-16.63 1.12-25 1v2a1079 1079 0 0 1-52.6 2.66c-23.86.57-23.86.57-27.4 2.34-1.66.13-1.66.13-3.67.13h-52.26c-64.18.06-64.18.06-87.94-2.43l-5.97-.6c-10.31-1.01-10.31-1.01-14.16-2.1v-2l-3.1-.14a298 298 0 0 1-29.37-2.9q-3.34-.5-6.69-.97a252 252 0 0 1-20.17-3.6 101 101 0 0 0-15.74-2.13c-5.06-.45-9.97-1.55-14.93-2.63l-3.2-.7q-4.4-.95-8.8-1.93l-1.94-.43c-7.68-1.73-15.2-4.09-22.72-6.38a217 217 0 0 0-13.15-3.63c-13.82-3.36-27.63-7.1-40.17-13.93a147 147 0 0 0-11.72-5.49c-2.34-1.16-4.21-2.57-6.3-4.14-1.86-.8-1.86-.8-3.75-1.44a66 66 0 0 1-15.44-7.68C119 913 119 913 116.2 911.94L114 911l-1-3-5-1-1-3a84 84 0 0 0-4-2c-1.66-1.43-1.66-1.43-3.28-3.08l-1.8-1.83-1.86-1.9-1.84-1.87-3.56-3.64q-2.24-2.26-4.53-4.49A20 20 0 0 1 82 879l-2-1c-7.32-11.61-8.06-24.6-7-38l1-3a219 219 0 0 0 .7-9.17L75 823l3-1q.52-2 1-4 1.5-1.01 3-2 1.53-2.48 3-5 1.45-1.96 2.94-3.87c2.3-3.01 4.22-6 5.9-9.4 1.36-2.03 3-3.64 4.75-5.34q2.19-2.18 4.24-4.48c4.91-5.41 9.76-10.67 15.38-15.37a84 84 0 0 0 5.29-5.04c7.96-8.05 16.93-14.99 25.74-22.08l4.44-3.63c2.32-1.79 2.32-1.79 4.79-3.26a62 62 0 0 0 8.03-6.03c5.75-4.88 11.96-8.33 18.6-11.8a90 90 0 0 0 8.17-4.86c2.73-1.84 2.73-1.84 5.04-2.9C200 712 200 712 201 709l3-1 1.81-1.87c2.53-2.46 5.02-3.6 8.19-5.13 3-1.86 5.88-3.92 8.79-5.92a97 97 0 0 1 9.59-5.46 67 67 0 0 0 10.93-6.87 30 30 0 0 1 9.62-5.05 93 93 0 0 0 6.63-2.58l2.07-.86a43 43 0 0 0 5.65-3.64c3.7-2.7 7.59-4.7 11.72-6.68 4.57-2.22 9.05-4.47 13.4-7.1 2.22-1.16 4.49-1.73 6.9-2.35 3.64-1.05 6.87-2.34 9.14-5.49 2-2.57 4.04-3.25 7.04-4.39C317 644 317 644 318 643c3-.12 3-.12 6 0l1 1c-.48 6.48-.48 6.48-2.45 8.44-5.9 5.94-6.28 19.5-7.55 27.56h-2c.08 12.92.17 24.3 6 36l1.44 3.75c1.58 3.65 3.87 6.81 6.16 10.04 1.4 2.21 1.4 2.21 2.3 4.72 1.37 3.1 3.08 4.56 5.66 6.74l4.1 3.54c2.9 2.6 5.57 5.43 8.3 8.21a58 58 0 0 0 5.54 4.75c2.73 2.46 3.3 3.64 3.5 7.25h2v2l2.19.75c3.07 1.36 5.2 3.16 7.81 5.25a41 41 0 0 0 5 2v2l10 2v2l2.52.79c3.86 1.29 7.53 2.94 11.23 4.65l2.04.9 1.94.9 1.76.8c1.51.96 1.51.96 2.53 2.5 1.7 2.52 4.74 2.83 7.54 3.59 6.34 1.77 6.34 1.77 7.44 2.87q2.28.41 4.59.72l2.82.39 2.96.39 5.78.78 2.6.35C441 801 441 801 444 802v2l1.54-.07c4.65-.12 8.14.34 12.46 2.07v2l1.75.3c19 3.34 19 3.34 24.75 5.7 23.6 9.43 53.05 9.75 78.18 10.54l1.92.07a1605 1605 0 0 0 50.9.7c22.85.05 45.58-.53 68.38-2.12l1.84-.13c12.23-.84 24.4-1.78 36.46-4.05 6.01-1.14 11.97-1.5 18.07-1.7 2.78-.16 5.35-.63 8.05-1.27 5.93-1.4 11.91-1.78 17.97-2.25 4.7-.4 9.06-1.06 13.6-2.3 4.46-1.03 9.03-1.38 13.57-1.87 17.89-2.03 36.08-4.38 53.62-8.56 5.41-1.24 10.9-2.07 16.38-2.92a79 79 0 0 0 10.23-2.17c4.12-1.2 8.22-1.8 12.46-2.4 2.84-.56 5.2-1.49 7.87-2.57q3.24-.78 6.5-1.44l3.72-.77 1.9-.4q4.42-.93 8.82-1.95l3.02-.7c2.17-.53 4.28-1.1 6.42-1.74 4.49-1.3 9.06-2.05 13.66-2.86 3.83-.68 7.52-1.54 11.25-2.65 2.87-.82 5.79-1.4 8.71-1.99 6.1-1.28 11.95-2.67 17.75-5 4.15-1.6 8.34-2.3 12.72-3.04 2.98-.54 5.7-1.41 8.53-2.46a246 246 0 0 1 25.13-7.5c2.27-.6 4.34-1.5 6.5-2.44a60 60 0 0 1 12.59-3.5c3.4-.68 6.7-1.6 10.03-2.56l1.75-.5a268 268 0 0 0 13.24-4.28c4.6-1.6 9.12-2.8 13.91-3.7C1082 741 1082 741 1084 739q2.4-.83 4.88-1.5c3.27-.9 6.1-1.98 9.12-3.5q1.95-.55 3.94-1a56 56 0 0 0 9.06-3q2.93-.98 5.88-1.94c3.9-1.29 7.67-2.66 11.42-4.35a76 76 0 0 1 6.01-2.15c4.18-1.35 8.23-2.94 12.3-4.6a211 211 0 0 1 6.27-2.4c4.4-1.64 8.74-3.47 13.08-5.3q5.46-2.24 10.98-4.37c2.06-.89 2.06-.89 3.46-1.88 2.37-1.5 4.94-2.16 7.6-3.01a320 320 0 0 0 28.2-12.62c2.9-1.43 5.75-2.36 8.8-3.38a269 269 0 0 0 19-9l3.31-1.62c2.69-1.38 2.69-1.38 5.13-2.88a57 57 0 0 1 7.62-3.66c5.53-2.4 10.76-5.37 16-8.34l2.1-1.18a430 430 0 0 0 15.3-9.07 408 408 0 0 1 25.91-15.06c6.4-3.45 12.15-7.71 18-12q3.46-2.5 7-4.94a285 285 0 0 0 10.65-7.8 100 100 0 0 1 4.73-3.24c3.85-2.55 7.38-5.47 10.94-8.4l4.26-3.5 2.1-1.71q2.55-2.1 5.12-4.14c6.01-4.8 11.49-9.93 16.83-15.46l2.56-2.5c2.92-3 5.7-6.1 8.46-9.24a250 250 0 0 1 3.65-4.08c3.92-4.34 6.67-8.98 9.33-14.18l.87-1.7q1.08-2.13 2.13-4.3l1.44-2.69q.8-1.65 1.56-3.31l1.08-2.2c5.2-11.99 4.13-25.26-.01-37.4-1.07-2.4-1.07-2.4-3.07-4.4q-1.01-3-2-6-.79-1.6-1.62-3.19C1422 474 1422 474 1422 471h-2l-.8-1.64-1.08-2.17-1.05-2.15c-1.07-2.04-1.07-2.04-2.2-3.61-.87-1.43-.87-1.43-.87-4.43h-2l-2-3.37-1.12-1.9C1408 450 1408 450 1408 448h-2l-1.02-2.08-1.36-2.73-1.33-2.7c-1.29-2.49-1.29-2.49-2.45-4.13-.84-1.36-.84-1.36-.84-4.36h-2a88 88 0 0 1-1-5z"/><path fill="#38373c" d="M1058.81 80.69h3.27c2.86.3 4.54.74 6.92 2.31 1.82 3.63 1.8 7.05 1 11a52 52 0 0 1-15 17l-2.78 2.14c-7.76 5.86-7.76 5.86-11.22 5.86l-1 3a50 50 0 0 1-9 4l-.42 1.88c-.8 2.96-2.24 3.54-4.83 5.12-2.65 1.66-5 3.2-7.37 5.25-2.48 1.82-4.56 2.4-7.5 3.14-2.87.93-5.17 2.55-7.67 4.21-2.4 1.52-4.86 2.75-7.4 4.03-6.53 3.5-11.85 8.23-17.37 13.12-2.1 1.82-4.27 3.53-6.44 5.25-3.92 3.25-7.69 6.45-10.87 10.44L959 181h-2l-.62 2.06c-3.63 7.74-10.88 13.62-17.2 19.19-4.1 3.63-7.76 7.5-11.3 11.7-1.88 2.05-1.88 2.05-4.35 4a43 43 0 0 0-6.72 6.99l-2.04 2.52A32 32 0 0 0 911 235a43 43 0 0 1-5.5 6.38c-1.93 2.09-3.17 4.4-4.54 6.9-1.85 3.3-1.85 3.3-2.96 4.72h-2l-.72 2c-1.94 4.54-4.5 8.7-7 12.94A49 49 0 0 0 883 281c-2.17 5.31-5.1 10.75-9 15h-2l-.48 1.77c-2.2 7.75-4.72 15.8-9.05 22.65a30 30 0 0 0-2.97 7.7c-.89 3.32-2.03 6.56-3.12 9.82l-.7 2.07L854 345h-2l-.35 2.1c-.59 2.62-1.4 5.01-2.36 7.5-2.99 7.71-4.58 15.37-5.93 23.5a76 76 0 0 1-1.41 5.83c-2.75 10.7-1.96 21.9 2.66 31.88 1.4 2.2 2.14 2.99 4.39 4.19 1.82.26 1.82.26 3.84.27h2.27l2.39-.02 2.46-.02A76 76 0 0 0 878 418l2.54-.62a71 71 0 0 0 14.7-5.93q3.04-1.6 6.15-3.08c6.41-3.08 12.34-6.32 18.1-10.54 2.89-2.1 5.84-4.1 8.82-6.08l1.5-1a231 231 0 0 1 10.82-6.7c3.33-2.08 5.55-4.32 7.95-7.4a47 47 0 0 1 5.54-5.21c2.51-2.11 3.93-3.52 5.63-6.38L961 363c2.08-.13 2.08-.13 4 0l-.02 2.38q-.08 11.23-.14 22.45-.01 5.78-.07 11.54c-.29 33.46-.29 33.46 3.23 48.63l.68 3q.84 3.3 1.88 6.56l.63 2.15c.92 3.02 1.54 5.02 3.81 7.29.44 2.94.44 2.94 0 6-4.22 3.33-9.2 4.27-14.31 5.5A128 128 0 0 0 943 484c-4.44 1.78-8.29 3.1-13.09 3.62-3.32.66-6.35 2.14-9.46 3.46-3.19 1.2-6.07 1.63-9.45 1.92v2l-2.76.48c-14.46 2.58-29.02 5.3-42.71 10.71-6.5 2.4-13.31 3.27-20.12 4.36a114 114 0 0 0-18.68 4.57 74 74 0 0 1-10.51 2.39c-2.22.49-2.22.49-4.53 1.52-3.45 1.24-6.56 1.3-10.19 1.4a87 87 0 0 0-18.57 2.94A41 41 0 0 1 771 524v2c-7 2.06-13.9 3.6-21.1 4.7l-6.05.97a992 992 0 0 1-33.16 4.83l-2.54.32c-18.17 2.27-36.38 3.3-54.66 4.11l-5.17.24q-3.17.16-6.34.27l-2.88.14-2.48.1c-2.62.32-2.62.32-5.77 1.33-4.8 1.44-9.6 1.27-14.57 1.25h-3.2q-4.32.02-8.64 0l-9.09.01h-15.27q-8.79-.03-17.58 0a5356 5356 0 0 1-24.18 0l-10.08-.01-2.97.01a87 87 0 0 1-16.95-1.8 68 68 0 0 0-7.88-1.07l-2.73-.24-2.71-.22q-2.75-.23-5.5-.48l-2.4-.2C485 540 485 540 482 539q-1.95-.27-3.91-.43l-2.3-.22-2.42-.23c-6.5-.66-12.25-1.73-18.37-4.12-7.8-2.37-15.99-3.54-24-5v-2l-2.12-.26c-5.89-.87-10.15-2.1-15.02-5.5-2.79-1.86-5.77-2.98-8.86-4.24-1.45-1.45-1.2-2.46-1.25-4.5.05-4.8.8-9.41 1.63-14.12l.38-2.17c.61-3.34 1.31-6.6 2.27-9.86a85 85 0 0 0 2.1-9.29l.5-2.9.37-2.16h2l-.1-2.24c-.17-6.34.4-11.56 2.24-17.6 2.86-10.5 3.97-21.53 5.16-32.33.41-3.64.91-7.19 1.7-10.77 1.17-5.35 1.68-10.76 2.21-16.2.42-4.08.99-8.07 1.75-12.1 1.2-6.34 1.9-12.72 2.6-19.13l1.08-9.85c.34-2.63.8-5.19 1.36-7.78h2l-.09-2.64c.09-3.28.6-5.9 1.46-9.05 1.28-4.82 2.29-9.62 3.1-14.54.56-2.92 1.27-5.76 2-8.63 1.09-4.38 2.03-8.8 2.99-13.21.76-3.5 1.53-6.97 2.43-10.43 1.7-6.66 2.53-13 2.73-19.86.17-4.51 1-8.34 2.38-12.64h2l.08-1.4c.66-8.08 2.86-15.8 4.92-23.6h2l.08-1.8c.47-6.93 2.3-12.79 4.92-19.2h2l.59-2.08.79-2.73q.37-1.34.77-2.7a20 20 0 0 1 2.85-5.49q.46-1.39.94-2.81c2.31-6.95 6.27-12.96 10.06-19.19l1.42-2.47A76 76 0 0 1 501 150l5.63-4.69 1.38-1.15c10.52-8.66 25.19-15.6 38.64-17.7 2.35-.46 2.35-.46 4.92-1.42 4.13-1.39 8.31-1.76 12.63-2.16l2.75-.25c11.94-1 23.9-.85 35.86-.75l7.09.02q8.55.03 17.1.1v2h19l-1 4c-1.57.8-1.57.8-3.56 1.31-5.21 1.7-8.56 4.93-12.44 8.69l-2.31 2.13c-4.2 4.66-5.12 8.12-4.88 14.18l.04 1.96.15 4.73 2 1 .81 2.25c1.81 4.19 4.2 6.82 8.3 8.73q2.92 1.1 5.89 2.02v2l1.54.32 7.02 1.5 2.43.5c4.3.93 8.2 2 12.13 4 5.34 1.92 11.13 1.83 16.74 1.81h9.32l2.46-.01c17.12-.05 33.78-.17 50.58-3.76 2.73-.55 5.47-.95 8.22-1.36 4.33-.7 8.26-1.81 12.38-3.29 3-.98 6-1.53 9.1-2.11 4.14-.8 8.27-1.7 12.4-2.6l2.35-.5A70 70 0 0 0 797 168q3.15-.64 6.31-1.12l3.24-.51L809 166v-2q7.5-2.52 15.02-4.94l6.36-2.06 3.26-1.04 3.13-1.02 2.83-.91c2.46-1.05 3.58-2.13 5.4-4.03 2.38-.66 4.64-1.2 7.06-1.62 6.76-1.29 6.76-1.29 8.94-2.38q2.12-.22 4.25-.37c4.19-.43 7.02-1.61 10.75-3.63 8.57-3.57 17.95-5.08 27-7v-2l1.9-.37 2.48-.5 2.46-.5C912 131 912 131 914 129q2.71-.73 5.45-1.37 4.64-1.17 9.24-2.57l3.26-.96A40 40 0 0 0 940 120c2.96-1.42 5.98-2.71 9-4l2.63-1.12a115 115 0 0 1 13.3-3.82 75 75 0 0 0 9.71-3.2 98 98 0 0 1 5.72-1.87l1.79-.53 3.55-1.01q2.17-.66 4.3-1.45l1-2a62 62 0 0 1 19.36-6.45c4-.83 7.78-2.22 11.64-3.55l5.16-1.72 2.66-.89 10.64-3.54a232 232 0 0 0 4.96-1.73c4.65-1.65 8.45-2.51 13.4-2.43"/><path fill="#383742" d="M835 1049c3.47 2.21 5.25 5.37 7 9l1.44.94c1.56 1.06 1.56 1.06 2.93 3.5 1.63 2.56 1.63 2.56 3.88 3.88 3.26 2 5.67 4.34 8.31 7.06l1.42 1.42a70 70 0 0 1 6.05 6.86c2.15 2.55 4.1 3.7 6.97 5.34q1.59 1.24 3.13 2.52c6.55 5.45 12.36 9.68 20.44 12.58 2.1.78 3.73 1.44 5.43 2.9v2l1.69.3c3.97.78 7.49 1.64 11.06 3.58 3.94 2.12 7.97 3.31 12.26 4.55 3.7 1.1 7.09 2.46 10.55 4.13 4.76 2.27 9.42 3.53 14.56 4.7 2.7.7 5.03 1.6 7.57 2.74 6.57 2.81 13.65 3.63 20.68 4.67 1.63.33 1.63.33 3.63 1.33v2l3.31.4q4.82.6 9.63 1.35c16.73 2.34 33.42 2.56 50.28 2.56l5.69.01a1558 1558 0 0 0 14.13-.02c5.77-.05 11.1-.52 16.72-1.83 4.03-.85 8.14-1 12.24-1.28a114 114 0 0 0 34-8.19q1.8-.51 3.63-.94l2.07-.5 2.3-.56c6.88-1.7 13.8-3.44 20-7l1-2c2.29-.63 2.29-.63 5.06-1.12l2.79-.51 2.15-.37 1-3a83 83 0 0 1 7.06-2.31c5.53-1.69 9.78-3.5 14.24-7.28 2.2-1.83 4.56-3.12 7.06-4.48a84 84 0 0 0 6.02-3.85c2.62-1.75 5.3-3.38 7.98-5.03 1.64-1.05 1.64-1.05 2.64-2.05q2.5-.06 5 0a10712 10712 0 0 1 .15 41.09 3190 3190 0 0 1 .06 17.36q.03 3.36.02 6.71l.02 2.02c-.02 4.6-.02 4.6-2.25 6.82-2.4.23-4.7.35-7.1.38l-2.18.05q-3.45.08-6.9.13l-8.99.2-2.23.04a397 397 0 0 0-17 .78c-2.47.17-4.36.3-6.6 1.42q-3.26.2-6.53.25l-4.06.1q-1.06 0-2.16.04c-32.6.73-32.6.73-41.25 3.61q-3.84.52-7.69.94l-4.58.52-2.37.27a1499 1499 0 0 0-13.27 1.57 40 40 0 0 0-8.09 1.7q-3.53.47-7.05.87l-11.04 1.25c-8.49.94-16.74 2.1-25.03 4.2-4.04.96-8.12 1.56-12.22 2.15a40 40 0 0 0-7.66 2.53c-3 .8-6.03 1.5-9.06 2.22-2.94.78-2.94.78-5.3 1.74-3.56 1.4-7.17 2.24-10.89 3.1l-2.16.53c-3.7.87-7.29 1.52-11.07 1.85-4.77.48-9.02 1.83-13.52 3.43l-4.5 1.56-2.24.79q-3.47 1.19-6.98 2.32a76 76 0 0 0-9.78 4.02 47 47 0 0 1-12.53 3.98c-1.97.46-1.97.46-3.95 1.5-2.31 1.1-3.8 1.1-6.33 1.09-7.2.54-12.92 4.46-19.05 7.98A55 55 0 0 1 890 1223a82 82 0 0 0-3.31 1.94 51 51 0 0 1-10 4.35c-1.69.71-1.69.71-3.69 2.71-1.66.75-1.66.75-3.62 1.44l-2.16.78-4.31 1.5c-5.79 2.05-10.31 4.54-14.6 8.97C846 1247 846 1247 843 1248q-1.03 1.99-2 4-1.27 1.32-2.55 2.62c-3.18 3.75-3.04 7.02-2.76 11.76l.07 2.23q.08 2.7.24 5.39h2v2l1.76.85c2.13 1.1 4.1 2.3 6.12 3.59a67 67 0 0 0 7.43 4.18c2.69 1.38 2.69 1.38 4.06 2.87 2.05 1.9 3.75 2.32 6.44 3.01A60 60 0 0 1 875 1295q2.4.75 4.81 1.44a78 78 0 0 1 11.67 4.47c2.4 1.04 4.84 1.9 7.32 2.75 8.83 2.98 16.96 7 25.2 11.34l2.36 1.24q7.34 3.85 14.64 7.76v2l2 1v2c-1.98 1.45-1.98 1.45-4.75 3.13l-3.03 1.87-1.6.98q-4 2.54-7.93 5.2l-2.87 1.93L920 1344l-1.6 1.04c-2.58 1.68-4.96 3.28-7.02 5.59-2.16 2.15-3.73 3.04-6.38 4.37l-1 3c-2.07.95-2.07.95-4.56 1.69l-2.5.76-1.94.55v2l-5 1-1 3c-1.7 1.13-1.7 1.13-3.81 2.13l-2.08 1c-2.11.87-2.11.87-4.3 1.3l-1.81.57-1 3h-3l-.62 1.75c-1.87 3.06-4.4 4.43-7.38 6.25l-5.25 3.38q-6.74 4.28-13.66 8.26c-2 1.3-3.43 2.67-5.09 4.36-1.47.92-1.47.92-3.06 1.75-4.9 2.7-9.37 6.02-13.94 9.25-4.97 3.5-9.22 6.07-15 8a150 150 0 0 0-6.76 4.48 80 80 0 0 1-6.05 3.7 44 44 0 0 0-5.19 3.32 45 45 0 0 1-5.44 3.44 40 40 0 0 0-6.37 4.31C777 1439 777 1439 775 1439l-.56 1.81c-1.84 2.8-3.53 3.24-6.58 4.41a33 33 0 0 0-6.4 3.75c-1.47 1.04-3 2-4.52 2.97a31 31 0 0 0-5.44 4.56c-2.56 2.67-5.39 4.53-8.5 6.5l-2.87 2a59 59 0 0 1-7.13 4c-4.7 2.3-8.9 4.76-12.56 8.56-2.39 2.39-4.53 3.77-7.44 5.44q-1.57 1.15-3.12 2.31c-3.95 2.88-8.2 5.1-12.52 7.36C695 1494 695 1494 693 1496l-2.75.88c-4.34 1.5-7.63 3.88-11.23 6.64l-2.08 1.6-1.91 1.51c-2.26 1.53-4.5 2.37-7.03 3.37q-3.09 1.74-6.12 3.56c-2.92 1.76-5.63 3.36-8.88 4.44v2c-1.25 1.11-1.25 1.11-3 2.31l-1.88 1.32-2 1.37-3.86 2.69-1.75 1.2C639 1530 639 1530 637 1532l-5 2-2 2-2.19.81c-4.49 1.9-8.3 4.68-11.75 8.13-4.76 4.76-9.54 8.22-16.06 10.06-4.52 1.92-7.7 4.33-11.34 7.59-2.07 1.76-4.33 3.03-6.66 4.41q-1.51 1.26-3 2.56c-1.93 1.67-3.05 2.46-5.5 3.32-2.74 1.23-3.55 2.24-5.37 4.56-2.54 3.13-4.58 4.54-8.34 5.88a34 34 0 0 0-5.41 2.74 41 41 0 0 1-6.07 3c-2.31.94-2.31.94-4.43 2.57C542 1593 542 1593 539 1593l-.56 1.81c-2.11 3.21-4.94 3.9-8.44 5.19l-2 1c-11.3 1.5-24.36 2.94-35-2-2.43-2.1-4.16-4.4-6-7l-1.58-1.43c-1.82-2-2.1-3.61-2.6-6.26l-.49-2.45-.33-1.86h-2v-403l5-1q2.55 1.92 5 4c2.35 1.1 4.78 1.9 7.25 2.69 6.65 2.21 6.65 2.21 7.75 3.31q3.78.48 7.56.88c5.2.59 9.84 1.3 14.68 3.4 3.45 1.4 7.12 1.98 10.76 2.72l3.16.72c14.82 3.37 29.22 4.66 44.4 4.6l2.52-.01c9.97-.03 19.84-.23 29.73-1.56l2.5-.33c4.99-.8 9.15-2.39 13.7-4.54a23 23 0 0 1 7.05-1.76c5.01-.76 9.38-2.33 14.07-4.18 5.61-2.23 10.75-3.9 16.87-3.94v-2l2.67-1.02 3.52-1.36 1.75-.67 7.92-3.05c2.14-.9 2.14-.9 3.14-1.9q3-.06 6 0l.63-1.69c2.84-4.78 7.63-7.58 12.37-10.31 4.46-2.66 7.29-5.39 10.56-9.4 1.88-2.08 4.01-3.23 6.44-4.6a277 277 0 0 0 5-5l3.34-3.3 3.6-3.58 1.8-1.77c3.48-3.46 6.75-7 9.84-10.81 2.02-2.19 4.37-3.7 6.82-5.36 3.92-2.89 6.17-7.06 8.6-11.18q1.5-2.32 3-4.62l1.5-2.3c1.5-2.08 1.5-2.08 3.12-3.66 2.06-2.12 2.5-4.65 3.38-7.42l2-2c.56-1.8.95-3.6 1.34-5.44l.66-1.56 3-1c.66-1.82.66-1.82 1.06-4.12a90 90 0 0 1 2-8.7c.94-3.18.94-3.18 1.38-5.5.75-2.25 1.44-2.6 3.56-3.68 2.7-.45 2.7-.45 5.92-.75l3.55-.35 5.56-.5c12.56-1.15 12.56-1.15 18.3-3.75a16 16 0 0 1 9.67-.65"/><path fill="#41404a" d="M424.15 1059.98h2.18l1.67.02.34 2.56 1.8 13.72c.85 6.46 1.69 12.9 2.83 19.32 1.62 9.47 2.56 19.04 3.6 28.59l.33 3.13c1.39 12.91 2.74 25.76 2.96 38.75a984 984 0 0 0 .32 12.66c1.2 38.27.92 76.55.86 114.83q-.02 15.41-.03 30.82l-.04 54.97v3.3l-.05 36.6v3.34l-.01 6.68v3.33l-.01 3.33-.05 54.95q0 16.89-.04 33.78a13741 13741 0 0 0-.03 36.36 2971 2971 0 0 0-.02 16.56v9.33c.26 3.3 1.12 5.98 2.24 9.09.13 2.37.13 2.37 0 4l1.88.62c2.97 1.93 3.25 4.05 4.12 7.38h2l3 5c1.6 1.75 3.18 3.42 4.88 5.06l1.27 1.28c4.24 4.18 8.19 7.6 13.85 9.66 5.74 2.8 5.74 2.8 7.66 3.79 8.98 4.34 18.91 4.5 28.7 4.46l3.64.01h68.12q25.25.03 50.5.01h59.07l111.68-.01h99.2q55.72.02 111.45.01h137.34a8978 8978 0 0 0 29.49 0q4.88 0 9.75-.02 2.55 0 5.11.02c17.53-.09 29.38-4.41 43.29-15.27l2.37-1.82c3.84-3.13 6.34-5.7 7.98-10.4 1.15-3.13 2.94-5.93 4.65-8.78h2a702 702 0 0 0 1.26-39.68l.08-9.94.19-24.6a15572 15572 0 0 1 .28-36.7l.01-2.76.03-2.5.01-2.17c.14-1.65.14-1.65 1.14-2.65 6.81-.4 13.38.67 20.1 1.7q4.88.74 9.78 1.42l3.55.51 2.57.37v2l1.85.14q4.17.31 8.34.67l2.9.22 5.41.45c3.13.65 4.97 1.55 7.5 3.52.62 2.78.62 2.78.6 6.22l.02 1.92q.01 3.18-.04 6.37l.01 4.57q0 6.22-.05 12.43-.04 6.53-.03 13.04-.02 10.96-.08 21.93-.07 15.4-.1 30.8a15788 15788 0 0 1-.14 45.82c-.03 7.34-.16 14.65-.6 21.97-.03.3-.03.3-.11 1.8-.37 5.28-1.49 10.2-2.8 15.32a49 49 0 0 0-1.18 6.75 21 21 0 0 1-2.63 7.37c-1 1.96-1.77 3.95-2.56 6-7.91 20.02-17.71 35.63-34.31 49.69l-1.72 1.5a65 65 0 0 1-9.28 6.5q-1.37 1.2-2.7 2.42c-3.27 2.74-6.49 4.09-10.5 5.4l-2.08.7q-2.16.74-4.34 1.45-3.23 1.07-6.45 2.19a112 112 0 0 1-26.52 5.5c-3.41.34-3.41.34-6.41 1.34q-1.8.16-3.62.2l-2.22.06-2.44.05-2.6.06-8.73.18-3.12.07c-27.52.55-55.04.52-82.56.51h-23.7a88482 88482 0 0 1-86.87 0H514.85A86 86 0 0 1 500 1770v-2l-2.94-.04c-13-.4-24.4-3.54-36.12-9.13-2-.85-3.89-1.36-6-1.83a38 38 0 0 1-14.94-7v-2a55 55 0 0 0-5.72-2.65c-7.04-2.87-11.84-8.54-16.96-13.97a96 96 0 0 0-3.62-3.54c-1.76-1.9-2.22-2.84-2.64-5.34-.93-3.89-2.85-5.13-6.06-7.5a54 54 0 0 1-4-6l-1.46-2.23a53 53 0 0 1-4.1-10.08l-.66-2.04a142 142 0 0 1-5.32-23.7c-.46-2.95-.46-2.95-.98-4.69-.67-3.13-.62-6.23-.6-9.42l-.01-2.2v-385.81c-.02-35.95-.02-35.95.58-53.3l.11-3.4a373 373 0 0 1 1.98-25.6q.46-4.18.67-8.37c.44-9.23 1.5-20.07 5.98-28.37 1.06-2.35.51-3.35-.19-5.79q.1-2.76.38-5.5l.27-2.84c.35-2.66.35-2.66.95-4.88.4-1.78.4-1.78-.6-3.78.05-1.68.05-1.68.3-3.66q.14-1.06.27-2.15l.3-2.25.28-2.26c.72-5.55.72-5.55 1.85-6.68q.27-3.03.44-6.06c.58-8.65 2.85-16.02 5.66-24.2a57 57 0 0 0 2.21-8.62c1.28-3.96 3.77-7.2 6.69-10.12 2.72-1.05 5.27-1.04 8.15-1.02"/><path fill="#5c3798" d="m1003 513 .81 2.98c1.2 4.2 2.7 8.29 4.19 12.4 1.9 5.25 3.54 10.36 4.57 15.85.43 1.77.43 1.77 1.93 4.7 1.7 3.46 2.23 6.57 2.9 10.34.87 3.95 2.31 7.62 3.8 11.38.8 2.35.8 2.35.8 5.35h2c1.85 5.8 3.4 11.55 4.53 17.53.73 3.84 1.6 7.65 2.47 11.47h2c2.34 4.48 4.2 9 5.94 13.75 1.68 4.57 3.39 9.12 5.25 13.63a96 96 0 0 1 4 12.5c.81 2.12.81 2.12 2.3 3.55 2.11 2.2 2.37 4.24 3.01 7.2 1.05 4.63 2.33 9 4.05 13.43.58 2.48.6 3.65-.55 5.94-2.58 1.27-2.58 1.27-5.81 2.25l-3.21 1.02c-2.5.61-4.43.9-6.98.73l-1 3c-1.75.8-1.75.8-4 1.44-6 1.83-11.57 4.32-17.16 7.16-8.85 4.42-17.74 7.27-27.27 9.86a416 416 0 0 0-19.85 6.1c-8.85 2.86-17.66 5.33-26.83 6.91a165 165 0 0 0-26.86 7.24c-7.12 2.5-14.38 3.93-21.77 5.4-7.64 1.58-7.64 1.58-11.36 3.45-4.1 2.03-8.15 2.43-12.65 3l-5.38.75-2.57.36c-4.14.6-8.22 1.43-12.3 2.33l-2.41.5c-3.15.66-6.05 1.27-9.02 2.54-3.52 1.32-6.83 1.35-10.56 1.52-2.54.37-4.26.87-6.6 1.81-5.41 2.14-11.02 3.15-16.72 4.25l-3.23.66a139 139 0 0 1-18.13 2.45c-2.26.26-4 .77-6.08 1.64-9.83 4.08-20.96 4.98-31.46 5.9-4.65.42-9.06 1.2-13.6 2.26a87 87 0 0 1-10.5 1.4l-2.5.25c-10.62 1-21.26 1.84-31.9 2.71-7.3.6-14.59 1.2-21.87 1.96-19.16 1.9-38.3 2.31-57.55 2.32l-36.6.05-19.42.02c-19.55.07-19.55.07-29.17-1.78a65 65 0 0 0-8.87-.77c-7.32-.44-14.44-1.8-21.62-3.19l-1.8-.34c-10.2-1.97-10.2-1.97-11.39-3.16q-2.74-.52-5.5-.94c-14.8-2.37-14.8-2.37-16.5-4.06a60 60 0 0 0-3.5-.81q-7.2-1.56-14.37-3.25l-2.28-.53-4.1-.96c-4.48-1.15-4.48-1.15-5.99-2.83-2.61-2.4-5.65-3.33-8.95-4.56l-6.03-2.35a69 69 0 0 1-14.06-7.63c-3.73-2.34-7.76-4.17-11.72-6.08v-2l-1.77-.8a28 28 0 0 1-5.86-4.26l-2.07-1.87L376 714l-2.67-2.32-2.7-2.37-2.53-2.2c-5.3-5.33-5.23-11.9-5.24-19.05l.01-2.5v-2.48c0-4.8.33-9.35 1.13-14.08h2v-3h2l.15-2.78c.35-5.95.7-11.62 2.39-17.36.53-2.13.72-4.18.9-6.36a34 34 0 0 1 2.77-10.3c.82-2.27 1.3-4.43 1.73-6.8 1.02-5.33 2.29-10.58 3.62-15.84l.67-2.68c.84-3.33 1.68-6.61 2.77-9.88q.3-4.4.43-8.82c.2-4.1 1.03-6.04 3.57-9.18.63-2.48.63-2.48 1-5.19a76 76 0 0 1 4-14.81h6l1 2c2.25.94 4.45 1.78 6.75 2.56l1.88.67c2.77.97 5.37 1.77 8.26 2.3l2.11.47 1 2c2.48.78 4.92 1.45 7.44 2.06l4.12 1.04 1.85.45C434 570 434 570 436 571q2.56.2 5.13.31c8.83.66 17.48 2.9 25.87 5.69q2.52.16 5.06.21l3.07.08 3.3.09c7.77.24 15.3.71 22.94 2.06 35.6 5.88 72.67 3.89 108.63 3.56l2.74-.02c19.36-.17 38.65-1.43 57.95-2.73l5.71-.37c23.94-1.57 23.94-1.57 35.63-4.22a95 95 0 0 1 12.4-1.72c4.26-.38 8.34-.82 12.47-1.93 4.71-1.26 9.44-1.66 14.29-2.07 11.74-1.05 11.74-1.05 14.81-1.94l1-2 2.06-.04q4.69-.13 9.38-.27l3.24-.07c5.9-.2 10.86-.85 16.4-2.94 2.57-.9 5.01-1.12 7.73-1.24 4.8-.23 4.8-.23 6.98-1.38 3.44-1.65 7.08-1.95 10.84-2.5 5.85-.9 11.38-2 17-3.87 3.75-1.1 7.56-1.61 11.41-2.16 2.8-.5 5.42-1.22 8.15-2.03 3.02-.84 6.1-1.37 9.18-1.94 4.1-.78 8.07-1.58 12-3 8.91-3.2 18.46-5.17 27.82-6.5 5.8-.88 11.03-2.76 16.49-4.84 6.66-2.5 13.6-4.07 20.5-5.72l2.82-.68a161 161 0 0 1 17.63-3.27c2.37-.55 2.37-.55 4.67-1.97a35 35 0 0 1 8.26-3.4l3.17-.93 3.27-.94 3.27-.97c8.8-2.54 8.8-2.54 13.73-2.34"/><path fill="#121219" d="m862 1237-3.56 2.69-2.22 1.67q-2 1.5-4.1 2.87c-5.54 3.64-10 8.4-12.12 14.77h-2a520 520 0 0 0 .59 8.1c.53 3.71 1.02 6.84 4.1 9.2l1.87.89c3.35 1.63 3.35 1.63 4.44 3.81 2.48 1 2.48 1 5.63 2l3.1 1q3.3 1 6.62 1.94a215 215 0 0 1 12.46 4.06l2.31.79 2.21.77 2 .7c1.67.74 1.67.74 3.67 2.74q2.39.9 4.83 1.62l2.92.9 6.09 1.83 2.93.9 2.67.8c2.8 1.04 5.14 2.2 7.56 3.95v3l2.44.75a92 92 0 0 1 15.71 7l5.03 2.82c4 2.18 7.66 3.16 12.16 3.91l1.66.52a22 22 0 0 1 2 5c1.49 1.14 3 2.03 4.66 2.91 1.34 1.09 1.34 1.09 1.74 3.02.87 2.99 2.54 3.63 5.16 5.2q2.27 1.35 4.44 2.87v2l2.31.31c2.96.76 3.65 1.56 5.69 3.69 2.19.69 2.19.69 4 1v3l1.81.44q2.63.69 5.19 1.56l1 2 3 1v2l2.88.94a28 28 0 0 1 5.12 2.06v2l2.88.31c3.12.69 3.12.69 4.12 2.07l1 1.62q2.09 1.11 4.21 2.1c2.79 1.4 5.35 3.12 7.96 4.81 1.83 1.09 1.83 1.09 4.83 2.09v2l5 1 1 3 2.63.3c3.6.75 6.08 1.95 9.24 3.76l2.93 1.66 2.2 1.28v2l2.81.31c3.48.75 3.84 1.31 6.19 3.69 1.8.84 1.8.84 3.75 1.5 3.83 1.34 7.53 2.87 11.25 4.5v2l1.64.4c.36.1.36.1 2.17.54l2.15.52q2.54.69 5.04 1.54v2l2.63.44 3.37.56 3.19.38c3.05.67 3.82 1.34 5.81 3.62l3 1v2h3v2h4v2h6v2l1.83.37 4.81 1c2.33.62 4.25 1.48 6.36 2.63v2l2.69.19c3.88.54 7.42 1.85 11.06 3.25l1.72.66a37 37 0 0 1 7.53 3.9c2.19.13 2.19.13 4 0v2l2.14.41q4.34.9 8.61 2.03l2.92.74c2.33.82 2.33.82 3.33 2.82 2.07.63 2.07.63 4.56 1.13l2.5.5 1.94.37v2l2.01.37c4.57.86 8.68 1.86 12.99 3.63 2.31.13 2.31.13 4 0v2l13 2v2l7 1c1.2 5.65 1.16 11.22 1.18 16.99l.06 14.18.01 3.76.04 17.65q.02 10.16.08 20.3.04 7.88.05 15.75 0 4.7.03 9.38.04 5.25.02 10.49l.04 3.09c-.06 8.42-1.95 16.73-8.13 22.85-4.31 4-8.71 5.4-14.38 6.56l-2.17.57c-3.13.74-5.93.13-9.09-.33-6.78-.96-12.96-1.4-19.8-.43-2.42.16-4.15.06-6.5-.29-7.74-.9-15.54-.66-23.32-.65h-91.27a30978 30978 0 0 0-55.43 0h-14.59c-4.1.01-8.05.34-12.1.93-2.31.27-4.44.07-6.74-.23-7.26-.77-14.48-.71-21.77-.7H895c-7.92-.02-15.83.07-23.74.36-29.77 1.06-59.54.85-89.33.78l-40.67-.06h-2.62l-29.37-.04h-2.7l-43.54-.09-44.94-.06q-13.8-.01-27.6-.05l-18.87-.03q-5.44 0-10.87-.02-4.98-.03-9.96-.01-2.63 0-5.26-.03c-6.9.03-13 .96-19.64 2.7-4.17.99-8.18 1.14-12.44 1.06l-2.45-.04c-15.91-.43-15.91-.43-21.01-5.25-2-2.58-3.53-5.19-4.98-8.09 2.8 1.1 3.9 1.85 5.56 4.44 3.18 4.17 8.54 4.76 13.44 5.56 4.03.3 7.97.21 12 0l3.25-.13c5.56-.32 9.02-.77 13.75-3.87l2.75-1.06C538 1595 538 1595 539 1593a169 169 0 0 1 21.34-11.03c4.64-2.07 7.17-4.46 10.3-8.44 1.84-2.07 3.79-2.59 6.36-3.53 1.84-1.45 3.56-3 5.3-4.55 2.09-1.78 4.35-3.06 6.7-4.45q1.49-1.23 2.94-2.5a29 29 0 0 1 12.56-6.37c5.14-1.37 8.4-4.97 12-8.7 3.55-3.45 7.3-5.9 11.75-8.06C631 1534 631 1534 633 1532l2.13-.75c5.18-2.25 9.42-6.04 13.75-9.62C651 1520 651 1520 653 1520v-2c5.42-4.32 11.51-7.45 17.81-10.25 3.74-1.85 6.84-4.53 10.09-7.1 4.2-3.29 8.08-4.98 13.1-6.65q2.55-1.43 5-3l3.25-1.75c3.86-2.18 7.22-4.9 10.68-7.65 2.07-1.6 2.07-1.6 4.82-3.22 2.34-1.44 3.98-3.14 5.87-5.11 3.1-2.84 6.89-4.73 10.82-6.14 3.27-1.44 5.81-3.5 8.63-5.67 3.18-2.4 6.16-4.2 9.93-5.46l1-3a56 56 0 0 1 16.56-10.44c3.23-1.34 5.03-3 7.44-5.56 3.52-2.89 7.06-5.67 11.19-7.62a28 28 0 0 0 6.18-4.2c3.18-2.3 6.74-4 10.2-5.81C808 1418 808 1418 810 1416l2.7-.86c3.82-1.32 6.82-3.16 10.17-5.39l1.86-1.21q5.99-3.95 11.68-8.34c2.1-1.58 4.33-2.87 6.59-4.2l3.13-2.31a76 76 0 0 1 9.43-5.69 57 57 0 0 0 11.44-8l5-3 1-2 3-1 1-2c2.47-1.33 4.98-2.52 7.52-3.7a25 25 0 0 0 7.48-5.3h3v-2l1.8-.7c4.32-1.78 7.73-3.35 10.92-6.9 5.32-5.82 11.76-10.27 18.34-14.53l6.8-4.45c3.1-2.06 6.13-4.23 9.14-6.42-.34-1.94-.34-1.94-1-4-2.49-.95-2.49-.95-4.13-1.4-3.9-1.26-7.45-3.46-11.06-5.41-9.54-5.1-18.97-9.63-29.19-13.22-2.46-.9-4.8-1.93-7.18-3.03a74 74 0 0 0-12.47-4.23C875 1296 875 1296 874 1294a87 87 0 0 0-5-1q-3.04-.85-6.06-1.81l-3.1-.96c-3.06-1.32-4.6-2.8-6.84-5.23q-2.1-1.08-4.2-2.08A65 65 0 0 1 838 1276v-2h-2c-1.77-5.32-1.86-10.5-1-16 3.67-6.9 9.93-12.2 16-17l1.92-1.57c4.96-3.86 4.96-3.86 9.08-2.43"/><path fill="#2a1552" d="M1306 349c2.2 4.6 4.12 9.14 5.5 14.06 1.55 5.45 3.44 10.88 7.5 14.94q1.02 3 2 6l1.13 1.69c.87 1.31.87 1.31.87 4.31h2c3.98 5.06 6.48 10.98 8.74 16.96A78 78 0 0 0 1336 412l3 1a80 80 0 0 1 2.5 7.31c1.33 4.52 2.51 7.97 5.5 11.69.88 2.15.88 2.15 1.56 4.38l.76 2.36q.62 2.07 1.2 4.14c.48 1.62.48 1.62 1.48 4.12l3 1c.74 1.4.74 1.4 1.31 3.25 1.3 3.82 2.9 7.43 4.63 11.06 9.62 20.48 9.62 20.48 8.06 29.69-1.7 4.65-3.61 9.2-6.64 13.13-1.36 1.87-1.36 1.87-1.86 5.06-1.13 4.52-4.68 6.71-8.2 9.4a45 45 0 0 0-7.8 7.41c-3.05 3.67-6.5 6.61-10.23 9.56a120 120 0 0 0-8.25 7.17c-4.33 4.1-7.28 5.95-13.02 7.27-1.06 1.81-1.06 1.81-2 4-3.27 3.27-7 5.7-11 8l-4.34 2.9q-7.2 4.75-14.73 9-3.63 2.08-7.24 4.23l-2.55 1.5C1269 582 1269 582 1267 584q-3.26 1.2-6.56 2.25a51 51 0 0 0-14.28 7.27 69 69 0 0 1-14.68 7.43 34 34 0 0 0-6.86 4.05c-3.86 2.66-7.1 3.87-11.62 5l-1 2c-1.67.96-1.67.96-3.75 1.94a57 57 0 0 0-6.75 3.68 20 20 0 0 1-6.25 2.25c-5.28 1.2-10.86 3.65-14.66 7.6-6.07 5.85-15.47 8.49-23.59 9.53v2l-12 3v2l-2.85.95-3.78 1.3-1.87.62a34 34 0 0 0-8.63 4.17 32 32 0 0 1-8.18 4.02l-2.91 1.01q-3.5 1.17-7.03 2.3c-4.16 1.37-8.2 2.99-12.25 4.63-14.66 5.91-14.66 5.91-20.56 6.44-3.78.37-6.5 1.66-9.81 3.45a72 72 0 0 1-7.07 3.11 245 245 0 0 0-11.76 4.97L1046 682l-3.16 1.42c-4.45 1.9-9.04 3.37-13.65 4.83l-2.6.85-2.48.8-2.25.71c-1.86.39-1.86.39-3.86-.61l2.52-1.02 3.3-1.36 1.65-.67A41 41 0 0 0 1033 683c1.81-.57 3.63-1 5.48-1.44C1040 681 1040 681 1041 679c1.58-.69 1.58-.69 3.6-1.29l2.18-.66 2.28-.67 2.3-.7L1057 674a122 122 0 0 0-4.28-15.86 93 93 0 0 1-1.78-6.08C1050 649 1050 649 1048 647c-.68-1.66-.68-1.66-1.3-3.64l-.7-2.23-.75-2.38a185 185 0 0 0-7.2-18.95A98 98 0 0 1 1033 605h-2a139 139 0 0 1-7-29h-2c-3.27-8.2-6.28-15.97-7.57-24.75-.4-2.1-.92-3.58-1.93-5.44-1.5-2.83-2.13-5.18-2.83-8.27-1.08-4.1-2.5-8.09-3.9-12.08A59 59 0 0 1 1003 514c-7.17.45-13.58 2.2-20.37 4.44l-2.99.94c-7.42 2.4-7.42 2.4-10.64 4.62-2.32.5-2.32.5-5.12.88-8.07 1.22-15.95 3.19-23.88 5.12v-2c.34-.1.34-.1 2.09-.55a235 235 0 0 0 15.3-4.58c4-1.33 8.01-2.57 12.03-3.81q5.78-1.8 11.52-3.68l2.52-.82q2.4-.78 4.8-1.6c4.6-1.48 8.91-2.35 13.74-2.7 2.7-.35 4.88-1.35 7.33-2.5 2.78-1.26 5.73-1.97 8.67-2.76 5.2-1.46 10.25-3.05 15.25-5.12A37 37 0 0 1 1044 497v-2l2.08-.77 2.73-1.04 2.7-1.02c2.49-1.17 2.49-1.17 3.99-2.72 2.05-1.99 3.92-2.26 6.69-2.89A57 57 0 0 0 1076 481c6.52-3.35 12.92-6.55 20.02-8.46q3.73-1.03 7.42-2.17l2.62-.78q.96-.3 1.94-.59v-2l1.94-.77c7.91-3.15 7.91-3.15 11.62-4.8a75 75 0 0 1 8-2.87c4.06-1.3 6.99-3.07 10.44-5.56 5.36-3.21 11.9-4.08 18-5l1-3c1.56-.88 1.56-.88 3.44-1.56 2.55-.94 3.57-1.45 5.56-3.44l2.44-.94c2.56-1.06 2.56-1.06 4.06-2.56 1.95-1.95 3.95-2.49 6.5-3.5l4.32-2.92c2.78-1.78 5.72-3.2 8.68-4.64l1.79-.88q.87-.44 1.77-.88l1.74-.85q1.68-.82 3.37-1.6A23 23 0 0 0 1210 416c1.83-1 1.83-1 3.81-1.87a29 29 0 0 0 7.32-4.75c2.74-2.44 5.34-3.33 8.87-4.38 5.46-2.95 10.64-6.62 15.7-10.2 2.4-1.7 4.4-2.83 7.24-3.68 3.63-1.33 5.76-3.25 8.54-5.88C1263 384 1263 384 1265 384l.63-1.75c1.52-2.5 3.15-3.48 5.6-5 2.9-2.05 5.24-4.65 7.72-7.2a94 94 0 0 1 5.8-5.3 94 94 0 0 0 7.25-6.86l1.6-1.66q1.57-1.65 3.1-3.32c3.02-3.15 4.68-4.66 9.3-3.91"/><path fill="#969697" d="M309 654c-1.94 5.82-7.63 9.44-12.37 13.06l-1.64 1.28c-5.67 4.35-11.73 7.62-18.2 10.63-1.94 1.12-3.24 2.42-4.79 4.03a394 394 0 0 1-5 3c-3.47 2.3-6.74 4.61-9.62 7.63A27 27 0 0 1 250 699h-2l-1 3a119 119 0 0 1-6 4q-1.83 1.2-3.62 2.44l-1.67 1.12C234 711 234 711 232.08 713.42a38 38 0 0 1-6.7 6.4q-1.31 1-2.64 2.05L220 724l-2.77 2.26-2.67 2.18-2.43 2C210 732 210 732 207 733l-.86 2.29c-1.43 3.39-3.59 5.01-6.45 7.15A58 58 0 0 0 189 753l-3.63 3.59c-2.39 2.46-4.51 5.14-6.68 7.78a87 87 0 0 1-12.18 12.2c-1.98 1.87-2.64 3.88-3.51 6.43q-1.98 1.05-4 2a73 73 0 0 0-2 4l-2.87 5.13a104 104 0 0 0-6.27 13.68C147 810 147 810 145.93 812c-1.06 2.28-1.3 4.13-1.5 6.63-.33 4.18-.33 4.18-1.43 6.37-.3 3.85-.28 7.7-.31 11.56l-.09 3.22c-.06 7.09 1.1 12 4.4 18.22q.8 1.95 1.56 3.94c1.46 3.1 1.8 3.39 4.44 5.06a113 113 0 0 1 3 5c4.26 7.2 9.97 14.16 18 17 2.62 1.65 5.13 3.36 7.62 5.2 5.43 3.98 11.1 7.5 17.38 9.99 2 .81 2 .81 3 2.81l3.31.25c4.55.44 7.92 2.25 11.91 4.38 4.7 2.46 9.52 3.98 14.63 5.43 2.96.88 5.78 1.93 8.65 3.07 5.12 1.92 10.15 2.69 15.55 3.28 2.95.59 2.95.59 5.31 2.04 3.54 2.08 6.95 2.58 10.95 3.24l2.25.39q2.71.48 5.44.92v2l3-.12c2.69 0 4.94.29 7.52 1.03 5.32 1.39 10.72 2 16.17 2.65l6.07.76 2.69.32q2.55.36 5.1.87c2.35.47 4.64.75 7.03.95l10.44.94 2.32.2c2.22.33 4.22.8 6.38 1.4 4.57 1.18 9.01 1.4 13.71 1.56l2.66.11q4.17.18 8.35.33 5.5.2 11 .44l2.5.08 2.4.1 2.08.08c2.33.27 4.47.74 6.76 1.29 4.87 1.11 9.6 1.43 14.59 1.6l2.86.11c12.03.44 24.06.64 36.1.82l6.13.1c32.8.49 65.6.57 98.41.57q9.6 0 19.18.02a8630 8630 0 0 0 30.09.03c17.93.05 35.6-.54 53.46-2.24l2.36-.22q5.92-.55 11.83-1.22l3.16-.33c6.41-.7 12.65-1.8 18.95-3.1a218 218 0 0 1 18.31-2.78 895 895 0 0 0 37.38-5.22c11.71-1.78 11.71-1.78 15.87-3.72 3.84-1.72 7.23-2.07 11.39-2.41a88 88 0 0 0 17.1-3.29 71 71 0 0 1 8.77-1.65c5.44-.77 10.73-1.86 16.07-3.12l2.25-.53c5.29-1.27 5.29-1.27 7.56-2.41q2.33-.27 4.64-.5c8.06-.78 15.67-2.5 23.49-4.56 8.7-2.3 16.85-3.9 25.87-3.94v-2a124 124 0 0 1 17-2.66c2-.34 2-.34 3.81-1.3 2.44-1.16 4.54-1.57 7.2-1.98a219 219 0 0 0 17.87-4l9.35-2.37q6.57-1.63 13.18-3.05c2.59-.64 2.59-.64 5.56-1.66 3-.97 5.78-1.46 8.9-1.86 13.03-1.85 25.61-5.76 38.18-9.57 5.21-1.56 10.42-2.9 15.75-3.98a198 198 0 0 0 20.37-5.53l5.77-1.81c5.09-1.6 10.11-3.23 15.06-5.23q2-.45 4-.81c4.38-.93 8.4-2.43 12.56-4.09 4.86-1.92 9.79-3.63 14.75-5.29l2.25-.75q6.2-2.06 12.54-3.59c2.92-.72 5.78-1.59 8.65-2.47l3.3-1c2.95-1 2.95-1 5-2.04 2.43-1.2 4.8-1.7 7.45-2.27a71 71 0 0 0 12.44-4.19c6.4-2.8 13.98-5.5 21.06-5.5v-2l3.4-.77 4.41-1.04 2.24-.5c6.43-1.55 6.43-1.55 8.95-4.69 3.75-1.6 7.42-2.26 11.44-2.94 3.9-.67 7.17-1.57 10.74-3.26 4.65-2.04 9.73-3.8 14.82-3.8v-2l9.58-3q7.29-2.32 14.7-4.23l1.72-.77 1-3c1.44-.84 1.44-.84 3.28-1.5l2.04-.74 2.18-.76 2.22-.8c4.32-1.55 8.66-3 13.01-4.43 6.86-2.33 13.66-4.84 20.08-8.2 5.02-2.63 10.24-4.54 15.57-6.43A56 56 0 0 0 1316 768c2.11-.67 2.11-.67 4.31-1.19 6.45-1.8 12.22-4.91 18.15-7.97l1.9-.98a100 100 0 0 0 3.68-2.04c2.33-.97 4.59-1.15 7.07-1.46 3.38-.65 6.33-2.2 9.39-3.74l3.78-1.83 1.86-.9c8.14-3.89 8.14-3.89 11.86-3.89l.5-1.81c2.34-3.42 5.67-3.92 9.5-5.19 4.98-1.84 9.7-3.75 14.25-6.5 4.4-2.56 8.89-3.85 13.79-5.07A32 32 0 0 0 1426 721c5.53-2.63 10.92-3.6 17-4l1-3 2.38-.31c2.62-.69 2.62-.69 3.53-2.22 1.09-1.47 1.09-1.47 3.04-1.76l2.17.1 2.2.08 1.68.11.13-1.75c1.08-2.78 2.62-3.66 5.12-5.18a72 72 0 0 0 5.31-3.7A33 33 0 0 1 1478 695q3.48-1.98 6.81-4.2a53 53 0 0 1 12.18-5.98c2.96-1.2 5.52-2.93 8.18-4.69 2.18-1.34 4.46-2.2 6.83-3.13l1-2a11.7 11.7 0 0 1 8-2c-1.1 3.31-1.42 3.55-4.25 5.23l-2.02 1.24-2.17 1.28-6.54 3.99a166 166 0 0 0-10.2 7.08C1494 693 1494 693 1492 693l-.77 1.84c-1.4 2.47-2.54 3.26-4.98 4.66-2.5 1.47-4.94 2.95-7.31 4.63a49 49 0 0 1-8.1 4.1c-6.13 2.56-11.8 5.87-17.45 9.32a182 182 0 0 1-6.2 3.58 40 40 0 0 0-5.75 3.93c-4.6 3.65-10.08 5.67-15.44 7.94l-4.81 2.06-2.27.98c-1.92.96-1.92.96-3.92 2.96l-2.12.81a40 40 0 0 0-6.76 3.63 56 56 0 0 1-9.59 4.93c-1.53.63-1.53.63-3.78 1.82-1.75.81-1.75.81-4.75.81l-1 3a45 45 0 0 1-10.95 6.09c-2.6 1.16-5.06 2.53-7.55 3.91a100 100 0 0 1-10.83 5.3c-1.9.8-3.71 1.7-5.55 2.64q-5.34 2.68-10.85 5.06-2.22.98-4.42 2.04a61 61 0 0 1-12.66 4.4c-10.94 2.6-20.76 7.73-30.72 12.79l-1.85.94q-1.95 1.01-3.9 2.07c-1.72.76-1.72.76-4.72.76l-1 3c-6.92 4.48-13.9 6.75-22 8-2.44.4-3.75.82-6 2-1.13 2.36-1.52 4.4-2 7l-1-2c-7.73.97-14.64 3.64-21.89 6.4a125 125 0 0 1-16.41 4.93c-7.03 1.75-13.67 4.6-20.25 7.6-3.55 1.55-7.16 2.9-10.78 4.28a145 145 0 0 0-8.48 3.48 58 58 0 0 1-9.06 2.78c-2.39.6-4.72 1.29-7.07 2.03l-2.27.72c-1.79.78-1.79.78-2.79 2.78-2.58.34-2.58.34-5.81.5-5.76.28-5.76.28-8.1 1.46a22 22 0 0 1-6.65 1.95c-5.67 1.06-11.03 2.77-16.5 4.59a161 161 0 0 1-17.89 5.02 66 66 0 0 0-8.99 3.04c-5 1.9-9.4 2.84-14.77 3.14-3.32.44-5.96 1.86-8.95 3.32-3.43 1.43-6.9 1.9-10.56 2.48a77 77 0 0 0-10.59 2.91c-2.17.58-3.96.7-6.19.59v2l-1.58.3c-6.4 1.3-12.33 3.39-18.42 5.7-4.74 1.78-9.43 2.64-14.4 3.5-2.98.57-5.73 1.52-8.6 2.5q-2.12.43-4.25.81a199 199 0 0 0-29.4 8.17 100 100 0 0 1-21.57 4.94c-6.65.89-12.59 2.9-18.87 5.21-3.51 1.05-6.9 1.4-10.53 1.75a71 71 0 0 0-17.74 4.24c-3.55 1.19-6.92 1.53-10.64 1.88l-1 1c-2.72.66-5.5 1.09-8.25 1.56l-2.32.42c-3.85.68-7.5 1.2-11.43 1.02v2l-7.69 1.38-2.35.42q-6.71 1.18-13.46 2.14c-7.2 1.08-14.22 2.8-21.29 4.52q-5.51 1.34-11.02 2.6a528 528 0 0 0-13.94 3.44c-7.3 1.85-14.7 3.16-22.1 4.54q-5.34.99-10.67 2.02l-2.46.47C751 951 751 951 749 952q-1.86.29-3.74.46l-2.23.24-4.65.46A40 40 0 0 0 727 956c-3.17.63-6.36 1.06-9.56 1.5l-7.7 1.1q-5.85.88-11.7 1.9-4.45.73-8.9 1.43l-2.96.47-6.36.99c-5.03.8-10.04 1.62-15.03 2.69-13.8 2.9-27.43 3.73-41.49 4.32l-2.4.1q-2.23.1-4.48.18c-3.04.13-5.51.35-8.42 1.32-8.32 1.07-16.63 1.12-25 1v2a1079 1079 0 0 1-52.6 2.66c-23.86.57-23.86.57-27.4 2.34-1.66.13-1.66.13-3.67.13h-52.26c-64.18.06-64.18.06-87.94-2.43l-5.97-.6c-10.31-1.01-10.31-1.01-14.16-2.1v-2l-3.1-.14a298 298 0 0 1-29.37-2.9q-3.35-.5-6.69-.97a252 252 0 0 1-20.17-3.6 101 101 0 0 0-15.74-2.13c-5.06-.45-9.97-1.55-14.93-2.63l-3.2-.7q-4.4-.95-8.8-1.93l-1.94-.43c-7.68-1.73-15.2-4.09-22.72-6.38a217 217 0 0 0-13.15-3.63c-13.82-3.36-27.63-7.1-40.17-13.93a147 147 0 0 0-11.72-5.49c-2.34-1.16-4.21-2.57-6.3-4.14-1.86-.8-1.86-.8-3.75-1.44a66 66 0 0 1-15.44-7.68C119 913 119 913 116.2 911.94L114 911l-1-3-5-1-1-3a84 84 0 0 0-4-2c-1.66-1.43-1.66-1.43-3.28-3.08l-1.8-1.83-1.86-1.9-1.84-1.87-3.56-3.64q-2.24-2.26-4.53-4.49A20 20 0 0 1 82 879l-2-1c-7.32-11.61-8.06-24.6-7-38l1-3a219 219 0 0 0 .7-9.17L75 823l3-1q.52-2 1-4 1.5-1.01 3-2 1.53-2.48 3-5 1.45-1.96 2.94-3.87c2.3-3.01 4.22-6 5.9-9.4 1.36-2.03 3-3.64 4.75-5.34q2.19-2.18 4.24-4.48c4.91-5.41 9.76-10.67 15.38-15.37a84 84 0 0 0 5.29-5.04c7.96-8.05 16.93-14.99 25.74-22.08l4.44-3.63c2.32-1.79 2.32-1.79 4.79-3.26a62 62 0 0 0 8.03-6.03c5.75-4.88 11.96-8.33 18.6-11.8a90 90 0 0 0 8.17-4.86c2.73-1.84 2.73-1.84 5.04-2.9C200 712 200 712 201 709l3-1 1.81-1.87c2.53-2.46 5.02-3.6 8.19-5.13 2.97-1.84 5.82-3.87 8.69-5.87a95 95 0 0 1 23.1-11.27C248 683 248 683 250 681l2.56-.5 2.44-.5 1-2c1.47-.73 1.47-.73 3.34-1.4l2.03-.76 4.26-1.54 2.03-.76 1.87-.68C271 672 271 672 272 669c1.85-.73 1.85-.73 4.06-1.19l2.23-.48L280 667v-2l8-2v-2q4.13-1.76 8.25-3.5l2.36-1.01 2.28-.96 2.1-.89c2.12-.67 3.8-.76 6.01-.64"/><path fill="#6a6972" d="M424.15 1059.98h2.18l1.67.02.34 2.56 1.8 13.72c.85 6.46 1.69 12.9 2.83 19.32 1.62 9.47 2.56 19.04 3.6 28.59l.33 3.13c1.39 12.91 2.74 25.76 2.96 38.75a984 984 0 0 0 .32 12.66c1.2 38.27.92 76.55.86 114.83q-.02 15.41-.03 30.82l-.04 54.97v3.3l-.05 36.6v3.34l-.01 6.68v3.33l-.01 3.33-.05 54.95q0 16.89-.04 33.78a13741 13741 0 0 0-.03 36.36 2971 2971 0 0 0-.02 16.56v9.33c.26 3.3 1.12 5.98 2.24 9.09.13 2.37.13 2.37 0 4l1.88.62c2.97 1.93 3.25 4.05 4.12 7.38h2l3 5c1.6 1.75 3.18 3.42 4.88 5.06l1.27 1.28c4.24 4.18 8.19 7.6 13.85 9.66 5.74 2.8 5.74 2.8 7.66 3.79 8.98 4.34 18.91 4.5 28.7 4.46l3.64.01h68.12q25.25.03 50.5.01h59.07l111.68-.01h99.2q55.72.02 111.45.01h137.34a8978 8978 0 0 0 29.49 0q4.88 0 9.75-.02 2.55 0 5.11.02c17.53-.09 29.38-4.41 43.29-15.27l2.37-1.82c3.84-3.13 6.34-5.7 7.98-10.4 1.15-3.13 2.94-5.93 4.65-8.78h2l.15-2.05.23-2.83.2-2.74c.34-2.73.83-5.27 1.43-7.96 1.05-5.22 1.3-10.23 1.3-15.55l.04-2.9.06-9.38.06-6.55q.08-8.58.13-17.16l.14-17.53q.14-17.17.26-34.35h7c1.14 3.42 1.14 6.46 1.13 10.02v13.17c.01 6.54-.09 13.02-.63 19.55-.69 8.6-.65 17.19-.63 25.8v8.45c0 9.12.08 18.17.96 27.25.13 1.58.16 3.17.17 4.76l-2 2-1-4c-2.65 2.65-2.32 4.18-2.5 7.87-.2 3.96-.48 6.71-2.5 10.13l-1.12 2.16c-3.33 5.91-7.25 10.62-11.9 15.5a47 47 0 0 0-7.88 10.82c-1.42 1.97-2.9 2.52-5.1 3.52q-1.62 1.23-3.19 2.5c-3.4 2.48-6.39 3.3-10.49 4.04a62 62 0 0 0-8.89 2.68l-2.43.78-3.15 1.03c-6.83 1.68-13.96 1.24-20.95 1.22h-33.36l-29.09-.01-32.74-.01q-33.03 0-66.04-.03-28.56-.01-57.1-.01h-16.13a80800 80800 0 0 1-364.94-.9 3771 3771 0 0 1-71.93-.89l-2.33-.03A45 45 0 0 1 498 1647c-2.17-.32-2.17-.32-3.96-.41l-1.99-.12-1.99-.1-2.07-.11q-2.5-.14-4.99-.26v-2l-1.64-.33c-6.87-1.52-6.87-1.52-9.36-4.67l-1.69-1.06-1.31-.94v-2l-1.62-.19c-4-1.36-7.45-4.06-9.44-7.81l-.94-2q-1.82-1.89-3.7-3.7a41 41 0 0 1-3.49-4.05C448 1615 448 1615 446 1614c-.62-2.56-.62-2.56-1-5l-3-1v-6h-3c-2.52-7.61-3.6-15.02-4.35-22.98l-.22-2.27-.18-2.05c-.25-1.7-.25-1.7-1.25-3.7a361 361 0 0 0-1.32 25.82l-.21 13.3-.1 6.04q-.45 29-.5 58.03v3.34a2829 2829 0 0 0 0 16.7c.13 1.77.13 1.77 1.13 3.77q.32 2.5.6 5c1.1 10.1 3.4 18.18 10.03 26.11 1.37 1.89 1.37 1.89 2.25 4.34 1.42 3.24 3.3 4.82 6 7.11 7.02 6.05 7.02 6.05 9.12 8.44v3l1.88.44c2.12.56 2.12.56 4.12 1.56v2l3 1v1c-5.3.7-8.38-1.08-13-3.57-4.17-1.99-8.49-3.33-12.91-4.64-3.56-1.1-5.54-2.16-8.09-4.79q-1.64-.7-3.3-1.32c-4.2-1.7-6.83-4.35-9.89-7.62l-1.53-1.59q-1.48-1.53-2.94-3.08a97 97 0 0 0-3.64-3.55c-1.76-1.9-2.22-2.84-2.64-5.34-.93-3.89-2.85-5.13-6.06-7.5a54 54 0 0 1-4-6l-1.46-2.23a53 53 0 0 1-4.1-10.08l-.66-2.04a142 142 0 0 1-5.32-23.7c-.46-2.95-.46-2.95-.98-4.69-.67-3.13-.62-6.23-.6-9.42l-.01-2.2v-385.81c-.02-35.95-.02-35.95.58-53.3l.11-3.4a373 373 0 0 1 1.98-25.6q.46-4.18.67-8.37c.44-9.23 1.5-20.07 5.98-28.37 1.06-2.35.51-3.35-.19-5.79q.1-2.76.38-5.5l.27-2.84c.35-2.66.35-2.66.95-4.88.4-1.78.4-1.78-.6-3.78.05-1.68.05-1.68.3-3.66q.14-1.06.27-2.15l.3-2.25.28-2.26c.72-5.55.72-5.55 1.85-6.68q.27-3.03.44-6.06c.58-8.65 2.85-16.02 5.66-24.2a57 57 0 0 0 2.21-8.62c1.28-3.96 3.77-7.2 6.69-10.12 2.72-1.05 5.27-1.04 8.15-1.02"/><path fill="#aab4bc" d="m1492.06 772.94 1.94.06c-.31 1.88-.31 1.88-1 4q-1.47 1.05-3 2l-1 3h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.26-.88-2.26-.88-5-1-2.46 1.66-2.46 1.66-4.81 4.06l-2.4 2.35c-2.15 3.11-2.15 4.88-1.79 8.59l-4 1v39l-4 2v29l-4 1 1 37c4.1-2.05 6.6-4.16 9.81-7.31l1.56-1.44c1.4-1.36 2.59-2.6 3.63-4.25-.08-2.77-.08-2.77-1-5-1.56-1.31-1.56-1.31-3-2l1-4h3v5c3.48-1.88 6.08-3.48 8-7-.12-2.12-.12-2.12-1-4-1.56-1.25-1.56-1.25-3-2l1-7h3v8l4-2c-.31-2.37-.31-2.37-1-5l-1.52-.95L1486 866c-.3-2.69-.4-5.14-.36-7.82v-2.37q0-2.47.02-4.96.03-3.76.02-7.54l.02-4.82v-2.26c.06-4.76.8-8.7 2.3-13.23h2v44l4-2-1-43h8l.25 2.38c.75 2.62.75 2.62 2.5 3.62l2.25 1q1.32 1.24 2.56 2.56C1511 834 1511 834 1514 835v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.94 2.25-.94 2.25-1 5 2.14 3.17 4.38 4.8 8 6v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v10l4 2 1 22 3 1q.13 4.19.19 8.38l.07 2.4.08 4.43-.34 1.79-1.5.9c-1.5 1.1-1.5 1.1-1.9 2.7l-.03 1.93-.06 2.19-.02 2.35-.06 2.41-.12 7.64-.1 5.18q-.13 6.35-.21 12.7l-4 2v23l4 1v14l4 2-.14 2.08-.11 2.73-.14 2.71c.39 2.48.39 2.48 2.34 4.12l2.05 1.36 1 3 3 1v-6h3c.93 3.01 1.04 3.87 0 7-2.06.69-2.06.69-4 1 2.4 4.5 5.48 7.83 9.02 11.43l5.47 5.54a706 706 0 0 1 5.62 5.76l3.6 3.64 1.68 1.76a27 27 0 0 0 4.61 3.87c2.81-.05 2.81-.05 5-1 1.31-1.56 1.31-1.56 2-3l4 1v3h-5c2.3 4.26 5.09 7.4 8.5 10.75l1.47 1.5c2.33 2.31 3.87 3.7 7.03 4.75v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.9 2.27-.9 2.27-1 5 1.77 2.42 1.77 2.42 4.25 4.75l2.45 2.36c2.3 1.89 2.3 1.89 5.3 2.89v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v10l4 2v42l-4 2v10l-4 2v9l-4 1-.31 1.81c-.69 2.19-.69 2.19-2.32 3.69l-1.37 1.5c.13 2.06.13 2.06 1 4a13 13 0 0 0 3 2l-1 4h-3v-5a34 34 0 0 0-12 10c-.12 2.81-.12 2.81 1 5a10 10 0 0 0 3 2l-1 4h-3v-5c-2.24 1-3.88 1.88-5.62 3.63L1579 1190h-2v3l5 1-1 4c-3.69-.5-5.6-1.1-8-4a24 24 0 0 0-7.69 5.44l-1.82 1.8C1562 1203 1562 1203 1561 1206h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.25-.94-2.25-.94-5-1-3.17 2.14-4.8 4.38-6 8h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.56-.62-2.56-.62-5-1l-2 4h8v3l-7 1-.69-1.44c-1.83-2.18-3.56-2.2-6.31-2.56l-2 4h-10l-2 4h-15v-7l2.38-.25 2.62-.75q1.05-1.99 2-4c1.44-1.57 1.44-1.57 3.07-3.06l1.78-1.67 3.71-3.4a33 33 0 0 0 4.44-4.87c0-2.83 0-2.83-1-5-1.56-1.31-1.56-1.31-3-2l1-4h3v5c3.5-1.9 6.12-3.43 8-7 .24-2.4.14-4.58 0-7l-3-1c-1.03-5.11-1.18-10.06-1.2-15.27l-.13-22-.04-2.56.01-2.37v-2.08c.36-1.72.36-1.72 1.84-2.8l1.52-.92c.69-2.62.69-2.62 1-5l-4-2v8h-3l-1-7 1.44-.69c1.56-1.31 1.56-1.31 2.37-3.63.19-2.68.19-2.68-1.18-4.82l-2.08-2.1q-1.12-1.13-2.26-2.3-1.2-1.14-2.41-2.34l-2.35-2.4q-1.12-1.13-2.29-2.27l-2.1-2.08c-2.14-1.37-2.14-1.37-4.82-1.18-2.32.81-2.32.81-3.63 2.37l-.69 1.44-4-1v-3h5a34 34 0 0 0-10-12c-2.81-.12-2.81-.12-5 1-1.31 1.56-1.31 1.56-2 3l-7-1v-3h8l-2-4c-2.92 1.07-4.78 1.78-7 4-2.12-.37-2.12-.37-4-1v-3h5v-4l-1.81-.31c-2.19-.69-2.19-.69-3.69-2.32l-1.5-1.37c-2.06.13-2.06.13-4 1-1.25 1.56-1.25 1.56-2 3l-6-1-1-3h8l-2-4c-3.7.64-3.7.64-5.31 2.63-1.69 1.37-1.69 1.37-3.43 1.34a91 91 0 0 1-5.26-.97c-2.43-6.29-2.43-6.29-1-10l-4-2v8l-4 1v47l-5 1c-2.38-2.38-2.24-3.54-2.28-6.8l.01-1.97v-6.39q.01-3.26 0-6.5c-.01-8.35.59-16.15 2.27-24.34.82-4.12 1.35-6.94 0-11-3.01-3.95-6.77-7.2-10.42-10.55A48 48 0 0 1 1420 1040v-2l-1.84-.68c-2.85-1.74-3.43-3.4-4.72-6.44l-1.28-3.01-1.16-2.87-.91-2.12c-5.73-13.94-6.36-32.4-2.09-46.88h2l.33-1.72c2.28-11.07 7.22-20.57 14.34-29.36 1.33-1.92 1.33-1.92 2.17-4.28 1.55-3.52 3.83-5.84 6.47-8.58l2.87-3.02 1.42-1.5q2.1-2.33 4.05-4.78c1.7-2.08 3.3-3.58 5.6-5.01 2.15-1.68 2.69-2.46 3.28-5.18q.21-2.62.27-5.27c.2-2.3.2-2.3 1.2-3.73 1.74-2.72 1.36-5.94 1.41-9.08l.06-2.2.15-6.98.12-4.73q.14-5.79.26-11.58l-2-1 5-1 .04-2.63q.1-4.82.22-9.65l.09-4.18q.05-3 .14-6l.02-1.9c.06-1.75.06-1.75.49-4.64l1.5-.94c1.5-1.06 1.5-1.06 1.9-2.78l.03-2.09.06-2.36.02-2.56.06-2.61.12-8.28.1-5.61q.13-6.9.21-13.77l-2-1 5-1 .19-3.19c.62-4.4 2.32-6.13 5.81-8.81 2.31-.31 2.31-.31 4 0l.13-1.75c1.3-3.34 3.73-4.8 6.87-6.25 2.35-.54 4.6-.76 7-1 1-1 1-1 3.06-1.06M1459 905c-1 1-1 1-1.1 2.85l.06 4.44.04 1.71 2 1 2-1v-8zm-17 152v2h2v-2zm19 1v2h2v-2zm-5 12-1 4h8l1-2-1-2zm78 63v48l4-2a4314 4314 0 0 0 .72-25.05q.15-4.56.26-9.13l.1-2.88.06-2.67.06-2.36c-.2-1.91-.2-1.91-2.2-3.91z"/><path fill="#b39fce" d="m911.69 540.94 3 .02 2.31.04c-1.32 2.63-2.27 2.92-5 4q-1.8.3-3.62.5c-3.38.5-3.38.5-5.38 2.5-3.12.13-3.12.13-6 0v2a490 490 0 0 1-49.96 17.36C845 568 845 568 843 569q-2.71.24-5.44.38a68 68 0 0 0-15.28 3C820 573 820 573 817 573v2l-13 2v2l-2.58.45c-7.64 1.39-15 3.24-22.42 5.55a187 187 0 0 1-23.13 5.66q-3.32.61-6.63 1.3a448 448 0 0 1-15.99 2.85l-6.32 1.05-3.38.55a3714 3714 0 0 0-21.04 3.53c-12.07 2.04-24.12 3.94-36.32 5-6.21.58-12.31 1.35-18.44 2.5-20.29 3.78-40.19 5.04-60.76 5.17q-7.73.07-15.44.16l-5.3.04c-6.15.06-12.15.38-18.25 1.19-1.99 20.17-2.15 40.4-2.1 60.66v9.47l.03 17.82.02 20.33L546 764l-14.9-.56c-6.23-.24-12.12-.74-18.23-1.97-4.43-.73-8.88-.92-13.35-1.13a52 52 0 0 1-7.99-.84 53 53 0 0 0-8.4-.8c-20.2-.77-20.2-.77-24.13-4.7a60 60 0 0 0-3.5-.81q-7.2-1.56-14.37-3.25l-2.28-.53-4.1-.96c-4.48-1.15-4.48-1.15-5.99-2.83-2.61-2.4-5.65-3.33-8.95-4.56l-6.03-2.35a69 69 0 0 1-14.06-7.63c-3.73-2.34-7.76-4.17-11.72-6.08v-2l-1.77-.8a28 28 0 0 1-5.86-4.26l-2.07-1.87L376 714l-2.67-2.32-2.7-2.37-2.53-2.2c-5.3-5.33-5.23-11.9-5.24-19.05l.01-2.5v-2.48c0-4.8.33-9.35 1.13-14.08h2v-3h2l.15-2.78c.35-5.95.7-11.62 2.39-17.36.53-2.13.72-4.18.9-6.36a34 34 0 0 1 2.77-10.3c.82-2.27 1.3-4.43 1.73-6.8 1.02-5.33 2.29-10.58 3.62-15.84l.67-2.68c.84-3.33 1.68-6.61 2.77-9.88q.3-4.4.43-8.82c.2-4.1 1.03-6.04 3.57-9.18.63-2.48.63-2.48 1-5.19a76 76 0 0 1 4-14.81h6l1 2c2.25.94 4.45 1.78 6.75 2.56l1.88.67c2.77.97 5.37 1.77 8.26 2.3q1.05.22 2.11.47l1 2c2.48.78 4.92 1.45 7.44 2.06l4.12 1.04 1.85.45C434 570 434 570 436 571q2.56.2 5.13.31c5.79.43 11.14 1.6 16.73 3.14 5.98 1.63 12.02 2.6 18.14 3.55l3.28.52a1998 1998 0 0 0 13.57 2.07A272 272 0 0 0 516 583v2a50150 50150 0 0 0 63.5.08l26.84.03h10.36l3.13.01h5.31C627 585 627 585 628 584q4.08-.18 8.15-.21l5.2-.08 2.76-.04c16.2-.25 32-1.1 48.05-3.3 4.75-.62 9.51-1.02 14.28-1.43a157 157 0 0 0 17.62-2.45c3.17-.53 6.3-.81 9.5-1.09a258 258 0 0 0 27.5-3.96l1.74-.34c9.48-1.86 9.48-1.86 13.2-3.1q2.21-.18 4.42-.25l2.61-.1 5.4-.17A54 54 0 0 0 803 565c2.34-.41 4.7-.67 7.06-.94a178 178 0 0 0 27.69-5.68 64 64 0 0 1 12.75-2.13c4.22-.42 8.35-1.34 12.5-2.25v-2l2.67-.37 3.52-.5 1.75-.24c3.62-.54 6.7-1.48 10.06-2.89q2.55-.35 5.11-.66c2.78-.5 5.29-1.54 7.9-2.58 2.95-1.13 5.95-1.93 8.99-2.76 3.15-1.05 5.39-1.1 8.69-1.06"/><path fill="#868688" d="M996 112c.2 1.84.2 1.84 0 4-1.36 1.41-1.36 1.41-3.19 2.63l-1.79 1.22C989 121 989 121 986.56 121.96a43 43 0 0 0-7.31 3.98 40 40 0 0 1-10.19 4.69c-3.65 1.08-5 2.08-7.06 5.37-5.44 3.84-11.82 6.3-18.12 8.38-13.65 4.51-13.65 4.51-18.3 9.25-2.61 2.26-5.75 3.14-8.96 4.3-3.7 1.5-6.93 3.59-10.3 5.7a52 52 0 0 1-7.67 3.66 57 57 0 0 0-6.88 3.75c-1.77.96-1.77.96-4.18 1.9-2.7 1.1-5 2.45-7.47 4a70 70 0 0 1-10.53 5.42 64 64 0 0 0-9.93 5.1c-3.5 2.02-7.12 3.2-10.94 4.48A46 46 0 0 0 841 196q-2.48 1.08-4.98 2.1c-2.4 1.06-4.66 2.3-6.96 3.53a27 27 0 0 1-11.38 3.19c-2.18.23-3.78 1.12-5.68 2.18h-3v2c-4.32 1.5-8.6 2.95-13.06 3.94a27 27 0 0 0-7.38 2.87c-10.73 5.62-23.3 7.73-35.15 9.61C750 226 750 226 747 227q-1.84.28-3.7.46l-2.17.24-4.44.46c-3.68.4-7.15.94-10.73 1.9-6.26 1.45-12.63 1.24-19.02 1.2q-3.26-.01-6.5.01c-11.58.02-22.67-.66-33.44-5.27a39 39 0 0 0-9.56-2.44c-3.6-.59-5.39-1.65-8.44-3.56a67 67 0 0 0-3.44-.94 21 21 0 0 1-8-4.06c-1.56-1-1.56-1-3.9-1.77-3.54-1.64-5.51-3.95-8.03-6.85l-1.46-1.64L620 200l-2.02-2.29q-3.41-3.9-6.78-7.8l-1.83-2.1-1.6-1.85c-10.46-11.55-20.68-22.01-36.85-23.1-9.16-.44-16.65-.2-24.92 4.14l-2.47 1.03c-5.94 2.57-9.98 5.97-14.3 10.67A103 103 0 0 1 526 182h-2l-.82 1.78a29 29 0 0 1-4.12 5.78l-1.52 1.76c-3.1 3.38-3.1 3.38-4.54 4.68h-2l-.3 2.14c-.81 3.33-2.17 5.69-3.95 8.61l-1.9 3.19-.96 1.57c-3.27 5.45-6.17 10.78-8.27 16.8C495 230 495 230 493.94 232c-.94 2-.94 2-1.69 5.31a58 58 0 0 1-3.48 10c-2.79 6.51-4.3 11.95-4.56 19.11-.25 3.08-1.15 5.22-2.52 7.95-1.04 2.46-1.13 4.91-1.38 7.55a31 31 0 0 1-1.75 6.27c-1.8 5.14-2.54 10.3-3.27 15.68q-.53 3.83-1.08 7.63L472 327h-2l-.15 1.99c-.74 9.42-1.79 18.8-2.85 28.2L464 384h-2l-.15 1.92c-.6 7.08-1.52 14.1-2.52 21.13a513 513 0 0 0-3.9 39.44A605 605 0 0 1 453 473h-2l.07 2.41.06 3.21.07 3.17c-.21 3.43-1.1 5.96-2.2 9.21a180 180 0 0 0-1.56 10l-.4 2.8c-1 7.39-1.68 14.75-2.04 22.2-5.7-.7-11.03-1.76-16.34-3.98a62 62 0 0 0-8.54-2.46A65 65 0 0 1 407 515a180 180 0 0 1 4-43h2l-.1-2.24c-.17-6.34.4-11.56 2.24-17.6 2.86-10.5 3.97-21.53 5.16-32.33.41-3.64.91-7.19 1.7-10.77 1.17-5.35 1.68-10.76 2.21-16.2.42-4.08.99-8.07 1.75-12.1 1.2-6.34 1.9-12.72 2.6-19.13l1.08-9.85c.34-2.63.8-5.19 1.36-7.78h2l-.09-2.64c.09-3.28.6-5.9 1.46-9.05 1.28-4.82 2.29-9.62 3.1-14.54.56-2.92 1.27-5.76 2-8.63 1.09-4.38 2.03-8.8 2.99-13.21.76-3.5 1.53-6.97 2.43-10.43 1.7-6.66 2.53-13 2.73-19.86.17-4.51 1-8.34 2.38-12.64h2l.08-1.4c.66-8.08 2.86-15.8 4.92-23.6h2l.08-1.8c.47-6.93 2.3-12.79 4.92-19.2h2l.59-2.08.79-2.73q.37-1.34.77-2.7a20 20 0 0 1 2.85-5.49q.46-1.39.94-2.81c2.31-6.95 6.27-12.96 10.06-19.19l1.42-2.47A76 76 0 0 1 501 150l5.63-4.69 1.38-1.15c10.52-8.66 25.19-15.6 38.64-17.7 2.35-.46 2.35-.46 4.92-1.42 4.13-1.39 8.31-1.76 12.63-2.16l2.75-.25c11.94-1 23.9-.85 35.86-.75l7.09.02q8.55.03 17.1.1v2h19l-1 4c-1.57.8-1.57.8-3.56 1.31-5.21 1.7-8.56 4.93-12.44 8.69l-2.31 2.13c-4.2 4.66-5.12 8.12-4.88 14.18l.04 1.96.15 4.73 2 1 .81 2.25c1.81 4.19 4.2 6.82 8.3 8.73q2.92 1.1 5.89 2.02v2l1.54.32 7.02 1.5 2.43.5c4.3.93 8.2 2 12.13 4 5.34 1.92 11.13 1.83 16.74 1.81h9.32l2.46-.01c17.12-.05 33.78-.17 50.58-3.76 2.73-.55 5.47-.95 8.22-1.36 4.33-.7 8.26-1.81 12.38-3.29 3-.98 6-1.53 9.1-2.11 4.14-.8 8.27-1.7 12.4-2.6l2.35-.5A70 70 0 0 0 797 168q3.15-.64 6.31-1.12l3.24-.51L809 166v-2l2.77-.62 3.6-.82 1.83-.4c2.4-.55 4.59-1.05 6.8-2.16a234 234 0 0 1 5.63-.87c4.38-.7 8.2-1.66 12.27-3.38 2.2-.79 4.29-1.2 6.6-1.6 6-1.16 11.67-3.05 17.44-5.03l3.05-1.02q7.4-2.5 14.7-5.22c4.68-1.72 9.33-2.97 14.18-4 7.55-1.64 7.55-1.64 10.55-2.88 4.66-1.8 9.64-2.5 14.54-3.41A44 44 0 0 0 936 128a78 78 0 0 1 21-4v-2c4.02-1.87 8.03-2.67 12.35-3.5 3.9-.74 7.78-1.62 11.65-2.5v-2a39 39 0 0 1 15-2"/><path fill="#553620" d="M1188.1 1203.68q4.57 0 9.15-.02c21.78-.12 43.22.95 64.9 3.15l8.25.81a1942 1942 0 0 1 20.45 2.1L1303 1211l1 5-1.4 1c-10.6 7.72-18.72 16.17-26.52 26.63-1.08 1.37-1.08 1.37-3.08 3.37q-1.04 2.38-1.94 4.81a43 43 0 0 1-3.75 7.88c-1.44 2.54-1.7 4.69-2 7.57-.4 2.24-1.4 4.03-2.49 6.01-1.38 2.9-2.13 5.98-2.98 9.07a44 44 0 0 1-2.37 5.88c-1.56 3.62-1.9 6.95-1.83 10.87v1.98q0 3.15.05 6.3l.03 4.35c.1 9.87.69 19.53 2.28 29.28l1 7h2l.3 1.84c1.47 8.27 3.24 16.39 9.7 22.16v3l1.87.88c2.13 1.12 2.13 1.12 4.13 3.12.12 2.13.12 2.13 0 4-8.16-.3-16.04-1.42-24.06-2.94l-2.78-.51-2.63-.51-2.35-.46c-2.26-.6-4.11-1.5-6.18-2.58q-2.58-.6-5.19-1.06c-4.7-.84-4.7-.84-5.81-1.94q-2.1-.28-4.2-.43l-2.6-.22-5.44-.42c-5.04-.44-9.33-1.23-13.98-3.26a21 21 0 0 0-5.84-1.17c-3.3-.32-5.81-1.26-8.83-2.68-4.84-1.89-10.05-2.74-15.11-3.82q-4.5-.97-9-2l-3.31-.75c-4.5-1.3-8.6-3.64-12.73-5.81a62 62 0 0 0-8.96-3.44v-2l-2.69-.37c-3.31-.63-3.31-.63-5.65-1.58a58 58 0 0 0-8.54-2.49A64 64 0 0 1 1108 1338a49 49 0 0 0-13.38-4.19c-7.81-1.73-12.67-5.45-17.14-12.05a95 95 0 0 1-3.48-6.76l-2.19-4.31-1.81-3.69-1.04-1.78c-1.33-3.09-1.67-6.15-2.09-9.47l-.26-1.97-.61-4.78-3-1c-3.92-8.81-3.67-19.34-3.94-28.8a345 345 0 0 0-.22-5.8c-.16-5.43.21-9.5 2.44-14.46.84-2.25 1-4.24 1.16-6.63.37-4.28 1.65-6.08 4.56-9.31q1.9-3.37 3.66-6.82c2-3.27 3.5-5.66 7.37-6.76 2.65-.48 5.29-.81 7.97-1.1l3.07-.38c8.93-1 17.86-1.36 26.84-1.58 7.68-.2 15.3-.63 22.96-1.23 16.4-1.25 32.78-1.47 49.23-1.45"/><path fill="#d3d2d2" d="M1238 910v7h2c.42 25.17.42 25.17-2 36l-.5 2.38q-.88 4.04-1.81 8.06-.3 1.27-.57 2.58c-1.36 5.53-3.03 8.98-7.12 12.98h-2l-.75 2.69c-.95 3.39-3.02 5.63-5.25 8.31l-1.86 2.4a131 131 0 0 1-19.43 20.16q-1.75 1.47-3.46 3c-9.42 8.4-9.42 8.4-16 8.82-3.76.28-6.1 1.55-9.31 3.5-6.1 3.54-12.05 4.68-18.92 5.75-2.02.37-3.96.88-5.95 1.4-5.97 1.32-11.91 1.28-18 1.27l-3.74.01h-7.8q-3.95 0-7.89.03c-17.45.12-34.46-.07-51.2-5.65-4.08-1.15-8.26-1.63-12.46-2.16a34 34 0 0 1-7.98-2.53l-3.19-.87c-6.16-1.72-12.31-3.47-16.81-8.13-1.58-.77-1.58-.77-3.25-1.37-3.4-1.36-6.1-3.2-8.98-5.45-1.77-1.18-1.77-1.18-4.16-2.11-2.9-1.2-5.14-2.72-7.67-4.57-2.9-2.11-5.68-4-8.94-5.5v-2l-2.31-.87A26 26 0 0 1 975 992l1-5 2.2-.3c5.4-.8 10.46-1.8 15.61-3.64a84 84 0 0 1 13.13-3.43c7.33-1.34 14.42-3.35 21.56-5.47 4.08-1.2 8.13-2.33 12.29-3.22 3.37-.72 6.49-1.7 9.71-2.94 5.77-2 11.37-2.76 17.45-3.23a32 32 0 0 0 11.45-3.05c2.24-1 4.52-1.51 6.91-2.03a191 191 0 0 0 16.19-4.75l2.36-.78c5.62-1.9 5.62-1.9 8.22-3.2 2.4-1.2 4.75-1.73 7.36-2.34 7.49-1.8 15.05-3.79 22.2-6.69 2.12-.83 4-1.33 6.23-1.74 10.07-2.15 19.8-5.84 29.15-10.08a47 47 0 0 1 7.22-2.04c5.02-1.05 9.72-2.7 14.51-4.5l2.5-.92a55 55 0 0 0 9.5-4.3c2.54-1.52 4.65-2.03 7.56-2.54l2.68-.48 2.01-.33v-2c9.41-4.78 9.41-4.78 14-3"/><path fill="#e44a08" d="M1373.87 1224.68c7.44.11 14.16 1.63 21.26 3.76q2.7.8 5.44 1.54A69 69 0 0 1 1419 1238l2.05 1.25q2 1.24 3.98 2.52 1.96 1.23 4 2.34c5.64 3.17 9.63 7.32 12.97 12.89v3l-2.13.02c-10.92.85-18.08 7.4-25.12 15.4a33 33 0 0 0-4.75 7.58h-2l-.63 2.88c-.9 3.3-2.33 5.58-4.3 8.37-8.41 13.52-7.64 34.8-4.78 49.98a69 69 0 0 0 3.9 10.8c.81 1.97.81 1.97.81 3.97h2c1.79 2.01 2.9 3.7 3.75 6.27 1.92 4.2 5.12 7.03 8.35 10.27 2.93 3.04 4.57 5.06 4.9 9.46l-4 1v2c-4.9 3.76-10.9 4.64-16.79 6.02-4.02.99-7.41 2-10.98 4.12-5.51 2.93-11.47 2.32-17.54 2.17q-1.8 0-3.57-.03c-8.61-.11-8.61-.11-12.12-1.28q-3.09-.33-6.19-.56l-3.29-.26-2.52-.18-1 2-.08-2.28c-.92-2.72-.92-2.72-3.87-4.56q-1.86-.86-3.74-1.66c-4.94-2.17-9.63-4.43-13.31-8.5v-2l-1.69-.81c-9.51-4.89-16.67-11.99-20.31-22.19l-1.38-3.75q-.37-1.05-.77-2.1c-.85-2.15-.85-2.15-1.83-4.1-5.4-10.8-6.28-22.53-6.33-34.42l-.02-2.74c.07-6.18.97-11.88 2.33-17.89l.64-3.43a55 55 0 0 1 6.96-18.15 38 38 0 0 0 2.84-6.04c1.92-4.74 5.35-8.47 8.56-12.38l1.25-1.7c2.9-3.85 6.35-6.21 10.41-8.79 2.28-1.47 4.43-3.05 6.59-4.7a81 81 0 0 1 8.75-5.81l1-1q2.11-.49 4.25-.87 6.43-1.24 12.81-2.7l3.35-.75c4.69-1.23 8.32-1.98 13.46-2"/><path fill="#45454e" d="M437 1140h1c1.43 10.27 1.6 20.58 1.93 30.93l.14 4.04c1.33 39.52 1.03 79.06.97 118.59q-.02 15.41-.03 30.82l-.04 54.97v3.3l-.05 36.6v3.34l-.01 6.68v3.33l-.01 3.33-.05 54.95q0 16.89-.04 33.78a13741 13741 0 0 0-.04 36.36 2971 2971 0 0 0-.01 16.56 774 774 0 0 0 0 9.33c.26 3.3 1.12 5.98 2.24 9.09.13 2.38.13 2.38 0 4l1.88.63c2.97 1.92 3.25 4.04 4.12 7.37h2l3 5c1.6 1.75 3.18 3.42 4.88 5.06l1.27 1.28c4.24 4.18 8.19 7.6 13.85 9.66 5.74 2.8 5.74 2.8 7.66 3.79 8.97 4.34 18.91 4.5 28.7 4.46l3.64.01h68.12q25.25.03 50.5.01h59.07l111.68-.01h225.49l44.17.01h78.33a8910 8910 0 0 0 29.49 0q4.88 0 9.75-.02 2.55 0 5.11.02c17.53-.09 29.38-4.41 43.29-15.27l2.37-1.82c3.84-3.13 6.34-5.7 7.98-10.4 1.15-3.13 2.94-5.93 4.65-8.78h2l.15-2.05.22-2.83.22-2.74c.33-2.73.82-5.27 1.42-7.96 1.05-5.22 1.3-10.23 1.3-15.55l.04-2.9.06-9.38.06-6.55q.08-8.58.13-17.16l.14-17.53q.14-17.17.26-34.35h7c1.14 3.42 1.14 6.46 1.13 10.02v13.17c.01 6.54-.09 13.02-.63 19.55-.69 8.6-.65 17.19-.63 25.8v8.45c0 9.12.08 18.17.96 27.25q.18 2.37.17 4.76l-2 2-1-4c-2.65 2.65-2.32 4.18-2.5 7.88-.2 3.95-.48 6.7-2.5 10.12l-1.12 2.16c-3.33 5.91-7.25 10.62-11.9 15.5a47 47 0 0 0-7.88 10.82c-1.42 1.97-2.9 2.52-5.1 3.52a129 129 0 0 0-3.19 2.5c-3.4 2.48-6.39 3.3-10.49 4.04a62 62 0 0 0-8.89 2.68l-2.43.78-3.15 1.03c-6.83 1.68-13.96 1.24-20.95 1.22h-33.36l-29.09-.01-32.74-.01q-33.03 0-66.04-.03-28.56-.01-57.1-.01h-16.13a80825 80825 0 0 1-364.94-.9 3771 3771 0 0 1-71.93-.89l-2.33-.03A45 45 0 0 1 498 1647c-2.17-.32-2.17-.32-3.96-.41l-1.99-.12-1.99-.1-2.07-.11q-2.5-.14-4.99-.26v-2l-1.64-.33c-6.87-1.52-6.87-1.52-9.36-4.67l-1.69-1.06-1.31-.94v-2l-1.62-.19c-4-1.36-7.45-4.06-9.44-7.81l-.94-2q-1.82-1.89-3.7-3.7a41 41 0 0 1-3.49-4.05C448 1615 448 1615 446 1614c-.62-2.56-.62-2.56-1-5l-3-1v-6h-3c-2.52-7.61-3.6-15.02-4.35-22.98l-.22-2.27-.18-2.05c-.25-1.7-.25-1.7-1.25-3.7a361 361 0 0 0-1.32 25.82l-.21 13.3-.1 6.04a3998 3998 0 0 0-.5 73.4v4.67c.13 1.77.13 1.77 1.13 3.77q.65 4.65 1.16 9.33A38 38 0 0 0 436 1718c-.37 2.31-.37 2.31-1 4l-1-5h-2c-3.97-11.09-4.56-21.25-4.49-32.95l-.01-5.66q-.01-7.74.02-15.47.02-8.36 0-16.71 0-14.46.03-28.92a16562 16562 0 0 0 .03-39.9q0-34.9.06-69.79.06-32.94.06-65.9v-12.29l.01-2.04v-4.08a134586 134586 0 0 1 .16-168.1v-2.2c.02-4.88.02-4.88 1.13-5.99q.59-3.02 1.06-6.06l.54-3.35.4-2.59h1l1 52-.02-11.87-.06-62.4a18028 18028 0 0 1-.03-41.66v-3.74c-.03-7.18.22-14.2 1.11-21.33h1l1 5z"/><path fill="#8c8d8e" d="M1521 673c-1.1 3.31-1.42 3.55-4.25 5.23l-2.02 1.24-2.17 1.28-6.54 3.99a166 166 0 0 0-10.2 7.08C1494 693 1494 693 1492 693l-.77 1.84c-1.4 2.47-2.54 3.26-4.98 4.66-2.5 1.47-4.94 2.95-7.31 4.63a49 49 0 0 1-8.1 4.1c-6.13 2.56-11.8 5.87-17.45 9.32a182 182 0 0 1-6.2 3.58 40 40 0 0 0-5.75 3.93c-4.6 3.65-10.08 5.67-15.44 7.94l-4.81 2.06-2.27.98c-1.92.96-1.92.96-3.92 2.96l-2.12.81a40 40 0 0 0-6.76 3.63 56 56 0 0 1-9.59 4.93c-1.53.63-1.53.63-3.78 1.82-1.75.81-1.75.81-4.75.81l-1 3a45 45 0 0 1-10.95 6.09c-2.6 1.16-5.06 2.53-7.55 3.91a100 100 0 0 1-10.83 5.3c-1.9.8-3.71 1.7-5.55 2.64q-5.34 2.68-10.85 5.06-2.22.98-4.42 2.04a61 61 0 0 1-12.66 4.4c-10.94 2.6-20.76 7.73-30.72 12.79l-1.85.94q-1.95 1.01-3.9 2.07c-1.72.76-1.72.76-4.72.76l-1 3c-6.92 4.48-13.9 6.75-22 8-2.44.4-3.75.82-6 2-1.13 2.36-1.52 4.4-2 7l-1-2c-7.73.97-14.64 3.64-21.89 6.4a125 125 0 0 1-16.41 4.93c-7.03 1.75-13.67 4.6-20.25 7.6-3.55 1.55-7.16 2.9-10.78 4.28a145 145 0 0 0-8.48 3.48 58 58 0 0 1-9.06 2.78c-2.39.6-4.72 1.29-7.07 2.03l-2.27.72c-1.79.78-1.79.78-2.79 2.78-2.58.34-2.58.34-5.81.5-5.76.28-5.76.28-8.1 1.46a22 22 0 0 1-6.65 1.95c-5.67 1.06-11.03 2.77-16.5 4.59a161 161 0 0 1-17.89 5.02 66 66 0 0 0-8.99 3.04c-5 1.9-9.4 2.84-14.77 3.14-3.32.44-5.96 1.86-8.95 3.32-3.43 1.43-6.9 1.9-10.56 2.48a77 77 0 0 0-10.59 2.91c-2.17.58-3.96.7-6.19.59v2l-1.58.3c-6.4 1.3-12.33 3.39-18.42 5.7-4.74 1.78-9.43 2.64-14.4 3.5-2.98.57-5.73 1.52-8.6 2.5q-2.12.43-4.25.81a199 199 0 0 0-29.4 8.17 100 100 0 0 1-21.57 4.94c-6.65.89-12.59 2.9-18.87 5.21-3.51 1.05-6.9 1.4-10.53 1.75a71 71 0 0 0-17.74 4.24c-3.55 1.19-6.92 1.53-10.64 1.88l-1 1c-2.72.66-5.5 1.09-8.25 1.56l-2.32.42c-3.85.68-7.5 1.2-11.43 1.02v2l-7.69 1.38-2.35.42q-6.71 1.18-13.46 2.14c-7.2 1.08-14.22 2.8-21.29 4.52q-5.51 1.34-11.02 2.6a528 528 0 0 0-13.94 3.44c-7.3 1.85-14.7 3.16-22.1 4.54q-5.34.99-10.67 2.02l-2.46.47C751 951 751 951 749 952q-1.86.29-3.74.46l-2.23.24-4.65.46A40 40 0 0 0 727 956c-3.17.63-6.36 1.06-9.56 1.5l-5.07.72-2.63.37q-5.85.89-11.7 1.9-4.45.74-8.9 1.44a933 933 0 0 0-28.08 4.73c-16.83 3.17-32.96 3.48-50.06 3.34v-1a193 193 0 0 1 18.37-2.6c10.73-.98 21.13-2.96 31.66-5.18a326 326 0 0 1 15.26-2.77c8.8-1.46 8.8-1.46 11.71-2.45l1-2h-21l1-2c6.57-2.14 13.88-2.54 20.73-3.3 5.17-.6 5.17-.6 6.27-1.7q-2.48-.09-4.94-.12l-2.77-.08c-2.71.17-4.85.53-7.45 1.18-5.42 1.27-10.74 1.4-16.28 1.52-7.05.16-13.96.62-20.96 1.45a428 428 0 0 1-28.24 2.29c-24.85 1.29-49.75 1.06-74.62 1.02h-14.26c-51.88.09-51.88.09-77.02-1.21l-2.52-.13-8.85-.5c-3.44-.23-6.72-.7-10.09-1.42q-3.09-.2-6.18-.28l-1.76-.06-5.5-.16-7.24-.22-3.24-.1a83 83 0 0 1-9.08-1.18q-2.31-.17-4.62-.25l-2.56-.1-2.7-.09c-6.9-.28-13.67-.78-20.5-1.76-4.53-.62-9.06-1-13.62-1.3l-1.9-.13a1171 1171 0 0 0-12.95-.8l-3.15-.2c-3.2-.4-5.96-1.3-9-2.37a92 92 0 0 0-5.75-.44 53 53 0 0 1-13.5-2.74c-3.36-1-6.74-1.67-10.19-2.32-4.44-.84-8.78-1.79-13.12-3.06-4.2-1.21-8.3-1.9-12.63-2.44-4.53-.58-8.67-1.36-12.87-3.22-2.3-.93-4.66-1.56-7.05-2.23-3.1-.9-6.15-1.97-9.2-3.05l-1.88-.64c-2.82-1-4.67-1.72-6.81-3.86a45 45 0 0 1 10.06 2.44c4.9 1.72 9.79 2.34 14.92 2.93 3.02.63 3.02.63 5.4 2.09 3.52 2.07 6.93 2.57 10.93 3.23l2.25.39q2.71.48 5.44.92v2l3-.12c2.69 0 4.94.29 7.52 1.03 5.32 1.39 10.72 2 16.17 2.65l8.76 1.08q2.55.36 5.1.87c2.35.47 4.64.75 7.03.95l10.44.94 2.32.2c2.22.33 4.22.8 6.38 1.4 4.57 1.18 9.01 1.4 13.71 1.56l2.66.11q4.17.18 8.35.33 5.5.2 11 .44l2.5.08 2.4.1 2.07.08c2.34.27 4.48.74 6.77 1.29 4.87 1.11 9.6 1.43 14.59 1.6l2.86.11c12.03.44 24.06.64 36.1.82l6.13.1c32.8.49 65.6.57 98.41.57q9.6 0 19.18.02a8630 8630 0 0 0 30.09.03c17.93.05 35.6-.54 53.46-2.24l2.36-.22q5.92-.55 11.83-1.22l3.16-.33c6.41-.7 12.65-1.8 18.95-3.1a218 218 0 0 1 18.31-2.78 895 895 0 0 0 37.38-5.22c11.71-1.78 11.71-1.78 15.87-3.72 3.84-1.72 7.23-2.07 11.39-2.41a88 88 0 0 0 17.1-3.29 71 71 0 0 1 8.77-1.65c5.44-.77 10.73-1.86 16.07-3.12l2.25-.53c5.29-1.27 5.29-1.27 7.56-2.41q2.33-.27 4.64-.5c8.06-.78 15.67-2.5 23.49-4.56 8.7-2.3 16.85-3.9 25.87-3.94v-2a124 124 0 0 1 17-2.66c2-.34 2-.34 3.81-1.3 2.44-1.16 4.54-1.57 7.2-1.98a219 219 0 0 0 17.87-4l9.35-2.37q6.57-1.63 13.18-3.05c2.59-.64 2.59-.64 5.56-1.66 3-.97 5.78-1.46 8.9-1.86 13.03-1.85 25.61-5.76 38.18-9.57 5.21-1.56 10.42-2.9 15.75-3.98a198 198 0 0 0 20.37-5.53l5.77-1.81c5.09-1.6 10.11-3.23 15.06-5.23q2-.45 4-.81c4.38-.93 8.4-2.43 12.56-4.09 4.86-1.92 9.79-3.63 14.75-5.29l2.25-.75q6.2-2.06 12.54-3.59c2.92-.72 5.78-1.59 8.65-2.47l3.3-1c2.95-1 2.95-1 5-2.04 2.43-1.2 4.8-1.7 7.45-2.27a71 71 0 0 0 12.44-4.19c6.4-2.8 13.98-5.5 21.06-5.5v-2l3.4-.77 4.41-1.04 2.24-.5c6.43-1.55 6.43-1.55 8.95-4.69 3.75-1.6 7.42-2.26 11.44-2.94 3.9-.67 7.17-1.57 10.74-3.26 4.65-2.04 9.73-3.8 14.82-3.8v-2l9.58-3q7.29-2.32 14.7-4.23l1.72-.77 1-3c1.44-.84 1.44-.84 3.28-1.5l2.04-.74 2.18-.76 2.22-.8c4.32-1.55 8.66-3 13.01-4.43 6.86-2.33 13.66-4.84 20.08-8.2 5.02-2.63 10.24-4.54 15.57-6.43A56 56 0 0 0 1316 768c2.11-.67 2.11-.67 4.31-1.19 6.45-1.8 12.22-4.91 18.15-7.97l1.9-.98a100 100 0 0 0 3.68-2.04c2.33-.97 4.59-1.15 7.07-1.46 3.38-.65 6.33-2.2 9.39-3.74l3.78-1.83 1.86-.9c8.14-3.89 8.14-3.89 11.86-3.89l.5-1.81c2.34-3.42 5.67-3.92 9.5-5.19 4.98-1.84 9.7-3.75 14.25-6.5 4.4-2.56 8.89-3.85 13.79-5.07A32 32 0 0 0 1426 721c5.53-2.63 10.92-3.6 17-4l1-3 2.38-.31c2.62-.69 2.62-.69 3.53-2.22 1.09-1.47 1.09-1.47 3.04-1.76l2.17.1 2.2.08 1.68.11.13-1.75c1.08-2.78 2.62-3.66 5.12-5.18a72 72 0 0 0 5.31-3.7A33 33 0 0 1 1478 695q3.48-1.98 6.81-4.2a53 53 0 0 1 12.18-5.98c2.96-1.2 5.52-2.93 8.18-4.69 2.18-1.34 4.46-2.2 6.83-3.13l1-2a11.7 11.7 0 0 1 8-2"/><path fill="#8f6d52" d="M1188.1 1203.68q4.57 0 9.15-.02c21.78-.12 43.22.95 64.9 3.15l8.25.81a1942 1942 0 0 1 20.45 2.1L1303 1211l1 5-1.4 1c-10.6 7.72-18.72 16.17-26.52 26.63-1.08 1.37-1.08 1.37-3.08 3.37q-1.04 2.38-1.94 4.81a43 43 0 0 1-3.75 7.88c-1.44 2.54-1.7 4.69-2 7.57-.4 2.23-1.4 4.02-2.48 6-1.45 3.05-2.27 6.32-3.18 9.57-.65 2.17-.65 2.17-1.65 4.17h-2l-2 6a388 388 0 0 1-34.9-4.73q-8.5-1.53-17.01-2.95l-5.4-.9q-4.03-.68-8.05-1.35A650 650 0 0 1 1152 1276l-2.18-.43-1.82-.57-1-2c-2.17-.45-4.25-.8-6.44-1.06-6.34-.83-6.34-.83-8.56-1.94q-2-.29-4-.46l-2.42-.24-2.58-.24a159 159 0 0 1-20-3.06l-3.21-.65a226 226 0 0 1-19.72-4.93 110 110 0 0 0-14.06-3.03c-2.01-.39-2.01-.39-3.01-1.39-.68-11.35.7-21.44 5-32l.94-2.58 1-2.67.9-2.45c1.47-2.91 2.5-4.37 5.16-6.3 3.3-.94 6.6-1.31 10-1.69l3.07-.37c8.93-1 17.86-1.36 26.84-1.58 7.68-.2 15.3-.63 22.96-1.23 16.4-1.25 32.78-1.47 49.23-1.45"/><path fill="#595662" d="M1318 892h1l.08 14.48c.06 8.57-.3 17-1.08 25.52l-3-3-.65 27.99-.23 10.2-.08 3.23-.06 2.97-.06 2.63c.08 1.98.08 1.98 1.08 2.98l1-5h1a447 447 0 0 1 1.46 6.74c1.67 10.14 1.74 20.33 1.86 30.58l.06 4.88.25 21.43c.5 43.46.47 86.91.37 130.37-7.43 1.14-7.43 1.14-11.02 1.05l-2.13-.04-2.23-.06-21.74-.55c-4.08-.13-7.12-.68-10.88-2.4-2.76-.31-5.5-.44-8.28-.56-3.47-.56-5.06-1.08-7.72-3.44-.78-3.31-.66-6.6-.62-9.98l-.01-3.12q-.01-4.22.02-8.45.02-4.45.02-8.9 0-7.5.05-14.99.04-8.6.04-17.19a3576 3576 0 0 1 .03-23.66c.01-21.01.01-21.01 1.75-31.35l.33-1.98c.8-4.07 2.23-7.25 4.42-10.76 1.33-2.22 2.27-4.54 3.25-6.92 3.3-7.8 3.3-7.8 5.72-10.7h2l.11-1.72.2-2.34.18-2.29a22 22 0 0 1 1.94-5.85c1.63-3.71 2.45-7.34 3.14-11.32l.38-2.14q.4-2.22.78-4.46.6-3.33 1.2-6.64a182 182 0 0 0 2.77-24.62 70 70 0 0 1 2.92-16.79l.38-1.83-1-1q-.31-3.09-.5-6.19c-.28-4.34-.73-8.53-1.5-12.81-.44-3.12-.44-3.12 0-6 2.07-1.8 3.6-2.82 6-4l1.95-.96c4.54-2.1 9.2-3.92 13.86-5.73l1.67-.65a49 49 0 0 1 6.84-2.18l1.68-.48z"/><path fill="#a980dd" d="m459 608 2.4.91c4.33 1.44 8.77 1.95 13.29 2.53l2.85.38c9.13 1.19 18.29 2.2 27.45 3.18l3.3.35 2.9.3q3.07.4 6.1.95l2.71.4 1-1c5.64-.33 11.5-.45 17 1l1 2 5-1 1-4 2 1-.1 2.17a1515 1515 0 0 0-1 62.2q0 11.65.03 23.3l.02 20.12L546 764l-11.6-.44-3.3-.12c-6.24-.24-12.15-.75-18.28-1.98-3.5-.57-6.96-.73-10.5-.9-8.6-.42-16.9-1.89-25.32-3.56l-2.86-.54c-3.4-.68-5.99-1.38-9.08-3.02-3.71-1.75-6.82-1.84-10.89-1.94-2.17-.5-2.17-.5-3.45-2.05-1-3.43-1.02-6.72-.97-10.26l.03-2.4c.12-6.85.48-13.67.95-20.51l.68-10.17.14-2.2q.8-12.26 1.51-24.5l.96-15.78.8-13.47 1.1-18.34c.96-16.5.96-16.5 3.08-23.82"/><path fill="#05050a" d="M806.22 1667.8q1.83 0 3.65-.02l11.9-.05 4.12-.02 17.14-.05q12.24-.03 24.46-.1 8.63-.07 17.27-.07 5.13 0 10.28-.05 4.83-.05 9.68-.02 2.6-.01 5.2-.05c9.64.07 17.27 3.3 24.41 9.82 7.7 8.36 9.86 14.63 9.98 25.69l.09 2.68c.06 5.9-.72 8.83-4.4 13.44l-.81 2.88A26.3 26.3 0 0 1 926 1736a310 310 0 0 1-5 2l-1 1q-2.5.14-5 .15l-3.27.02-7.43.03-16.83.05-20.26.04q-11.69.02-23.36.08-9.04.04-18.07.05-5.4 0-10.78.03-5.1.04-10.17.02-1.86 0-3.71.02c-8.4.09-16.02-.5-22.3-6.61L777 1731q-3-1.05-6-2c-1.87-1.5-1.87-1.5-3-3v-2l-3-1c-4.62-6.95-2.82-19.08-2-27 2.4-10.81 11.78-20.27 20.81-26.19 6.98-2.59 15.09-1.98 22.41-2"/><path fill="#2b1655" d="M1306 349c2.2 4.6 4.12 9.14 5.5 14.06 1.55 5.45 3.44 10.88 7.5 14.94q1.02 3 2 6l1.13 1.69c.87 1.31.87 1.31.87 4.31h2c3.98 5.06 6.48 10.98 8.74 16.96A78 78 0 0 0 1336 412l3 1a80 80 0 0 1 2.5 7.31c1.33 4.52 2.51 7.97 5.5 11.69.88 2.15.88 2.15 1.56 4.38l.76 2.36q.62 2.07 1.2 4.14c.48 1.62.48 1.62 1.48 4.12l3 1c.74 1.4.74 1.4 1.31 3.25 1.3 3.82 2.9 7.43 4.63 11.06 10.6 22.55 10.6 22.55 7.12 32.34-2.35 5.37-4.92 11.1-9.06 15.35h-2l-.5 2.06c-1.86 3.65-4.24 5.47-7.36 8.05a46 46 0 0 0-5.52 5.95c-3.28 3.9-6.93 6.2-11.39 8.62-2.23 1.32-2.23 1.32-3.84 2.99C1327 539 1327 539 1324 539v-6l-2 6h-3l-1 4-3-1 2-1 1-3 3-1c-4.03-27.67-4.03-27.67-8-38l-1.16-3.25-2.46-6.73a23 23 0 0 0-3.54-5.79C1304 481 1304 481 1304 479h-2c-1.83-1.93-2-2.99-2.12-5.69l.12-2.31h-2c-1.9-2.14-2.53-4.62-3.4-7.3-1.03-2.94-2.29-5.78-3.54-8.64l-1.52-3.52-2.38-5.45c-1.3-3-2.52-5.87-3.16-9.09h-2c-1.25-1.2-1.25-1.2-2.56-2.81l-1.32-1.58C1277 431 1277 431 1276 428l-1.87-1.75c-2.24-2.37-3.18-4.28-4.32-7.27-1.12-2.73-2.5-5.34-3.81-7.98l-14 1-1 4-4 1v2l-1.83.77c-4.1 1.77-7.61 3.5-11.17 6.23q-1.7.75-3.44 1.44a15 15 0 0 0-5.75 3.62c-5.6 3.86-13.14 4.09-19.81 3.94 1.26-3.77 2.79-4.73 6-7l5-2q2.55-1.96 5-4l1.54-.83c2.7-1.46 4.15-2.41 5.33-5.32a75 75 0 0 0 1.2-4.73c1.3-2.96 3.13-3.56 5.93-5.12 1.92-1.58 3.7-3.29 5.5-5a50 50 0 0 1 10.5-8 82 82 0 0 0 10.7-8.05c1.3-.95 1.3-.95 3.3-.95l.63-1.75c1.52-2.5 3.15-3.48 5.6-5 2.9-2.05 5.24-4.65 7.72-7.2a94 94 0 0 1 5.8-5.3 94 94 0 0 0 7.25-6.86l1.6-1.66q1.57-1.65 3.1-3.32c3.02-3.15 4.68-4.66 9.3-3.91"/><path fill="#7a7782" d="M1280.56 1483.75c5.64.04 10.87.98 16.37 2.11a96 96 0 0 0 16.3 1.73c3.36.5 5.1 1.3 7.77 3.41.62 2.78.62 2.78.6 6.22l.02 1.92q.01 3.18-.04 6.37l.01 4.57q0 6.22-.05 12.43-.04 6.53-.03 13.04-.02 10.96-.08 21.93-.07 15.4-.1 30.8a15789 15789 0 0 1-.14 45.82c-.03 7.34-.16 14.65-.6 21.97-.03.3-.03.3-.11 1.8-.37 5.28-1.49 10.2-2.8 15.32a49 49 0 0 0-1.18 6.75 21 21 0 0 1-2.63 7.37c-1 1.96-1.77 3.95-2.56 6-6.37 16.12-12.97 29.31-26.31 40.69q-3.55 3.27-7.09 6.57C1276 1742 1276 1742 1273 1742l2.22-14.02.8-5.12.5-3.11c.48-2.75.48-2.75 1-4.6.56-2.53.61-4.85.63-7.45l.02-6.66.02-11.03q0-4.76.03-9.52v-7.36l.03-5.2v-6.07c-.25-2.86-.25-2.86-1.23-5.94-1.61-5.3-1.5-10.79-1.64-16.29l-.11-3.71c-.41-14.26-.42-28.53-.4-42.8v-52.66a17140 17140 0 0 1 0-41.07v-12.18c.23-3.98 2.15-3.4 5.7-3.46"/><path fill="#b3b2b3" d="M1238 910v7h2c.42 25.17.42 25.17-2 36l-.5 2.38q-.88 4.04-1.81 8.06-.3 1.27-.57 2.58c-1.36 5.53-3.03 8.98-7.12 12.98h-2l-.75 2.69c-.95 3.39-3.02 5.63-5.25 8.31l-1.86 2.4a131 131 0 0 1-19.43 20.16q-1.75 1.47-3.46 3c-3.7 3.3-7.57 6.65-12.25 8.44l-2-1c5.03-4.8 9.85-8.65 16.02-11.87a22 22 0 0 0 5.62-4.64 71 71 0 0 1 3.38-3.42c7.28-6.97 13.34-14.59 18.04-23.54 1.04-1.7 2.27-2.89 3.69-4.28 3.19-3.57 2.64-7.5 2.43-12.05l-.18-2.2 3-1 2-12h2v-28l-2-1-1 8-5 1-.25 1.63c-1.05 3.31-2.66 5.58-4.75 8.37h-2l-.75 2c-2.65 6.35-5.03 11.42-10.25 16l-2.19 2.19c-3.4 2.19-6.25 2.6-10.19 3.25-3.44.74-6.37 2.12-9.54 3.62a44 44 0 0 1-7.3 2.45c-1.98.55-3.9 1.23-5.84 1.93-5.17 1.75-10.46 2.82-15.8 3.91-3.64.75-7.1 1.63-10.62 2.8-10.58 3.48-20.4 4.7-31.52 4.85v2a87 87 0 0 1-11.62 2.81c-5.67.98-11.23 2.33-16.82 3.69-6.6 1.6-13.14 3.02-19.86 4.04-4.97.85-9.82 2.2-14.7 3.46a88 88 0 0 1-13.56 2.44c-4.37.42-10.2 1.32-13.44 4.56-2.34.14-4.66.04-7 0l.63 1.88.37 2.12c-2 2-2 2-5.12 2.13l-2.88-.13v-2l-4 1 2.59 1.24 3.35 1.63 1.7.82c4.25 2.08 4.25 2.08 5.36 4.31l2.88.94c3.12 1.06 3.12 1.06 4.51 2.53 2.04 1.93 3.72 2.33 6.42 3.03a49 49 0 0 1 11.19 4.5c-3.15 1.04-4.78 1-7.81-.31a59 59 0 0 0-7.57-2.63c-6.1-1.69-12.17-3.45-16.62-8.06-1.58-.77-1.58-.77-3.25-1.37-3.4-1.36-6.1-3.2-8.98-5.45-1.77-1.18-1.77-1.18-4.16-2.11-2.9-1.2-5.14-2.72-7.67-4.57-2.9-2.11-5.68-4-8.94-5.5v-2l-2.31-.87A26 26 0 0 1 975 992l1-5 2.2-.3c5.4-.8 10.46-1.8 15.61-3.64a84 84 0 0 1 13.13-3.43c7.33-1.34 14.42-3.35 21.56-5.47 4.08-1.2 8.13-2.33 12.29-3.22 3.37-.72 6.49-1.7 9.71-2.94 5.77-2 11.37-2.76 17.45-3.23a32 32 0 0 0 11.45-3.05c2.24-1 4.52-1.51 6.91-2.03a191 191 0 0 0 16.19-4.75l2.36-.78c5.62-1.9 5.62-1.9 8.22-3.2 2.4-1.2 4.75-1.73 7.36-2.34 7.49-1.8 15.05-3.79 22.2-6.69 2.12-.83 4-1.33 6.23-1.74 10.07-2.15 19.8-5.84 29.15-10.08a47 47 0 0 1 7.22-2.04c5.02-1.05 9.72-2.7 14.51-4.5l2.5-.92a55 55 0 0 0 9.5-4.3c2.54-1.52 4.65-2.03 7.56-2.54l2.68-.48 2.01-.33v-2c9.41-4.78 9.41-4.78 14-3"/><path fill="#cbc3b9" d="M1373.87 1224.68c7.44.11 14.16 1.63 21.26 3.76q2.7.8 5.44 1.54A71 71 0 0 1 1419 1238l3.06 1.63 1.94 1.37v3c-5.88-.87-5.88-.87-7-2-10.43-1.77-19.54-1.69-29.5 2.13-2.5.87-2.5.87-5.5.87v2c-3.43 2.62-6.89 4.41-10.81 6.19-7.57 3.7-13.06 9.54-17.19 16.81-3.66 5.56-3.66 5.56-5.41 7.86-4.6 6.27-6.8 12.86-9 20.22q-.75 2.39-1.64 4.72c-1.54 5.18-1.28 10.4-1.27 15.76q0 2.96-.04 5.93A86 86 0 0 0 1340 1349l.7 2.64c1.39 4.96 3.6 8.5 6.9 12.44 1.4 1.92 1.4 1.92 2.32 4.35 2.1 4.99 6.4 8.68 10.08 12.57l1.9 2.1c5.66 6.04 11.04 7.8 19.01 9.43 2.49.56 4.77 1.42 7.09 2.47-8.85 6.04-22.53 3.14-32.61 1.42A58 58 0 0 1 1338 1390q-1.78-.82-3.56-1.62c-2.46-1.39-3.01-2.1-4.44-4.38l-2.25-1.31a24 24 0 0 1-5.63-4.63c-2.09-2.19-4-3.67-6.68-5.06-5.6-3-8.2-7.11-10.43-12.98-1.32-2.64-3.1-4.72-4.95-7.02-8.4-10.8-9.24-25.62-9.25-38.81l-.03-2.83c-.02-6.37.8-12.17 2.22-18.36l.64-3.43a55 55 0 0 1 6.96-18.15 38 38 0 0 0 2.84-6.04c1.92-4.74 5.35-8.47 8.56-12.38l1.25-1.7c2.9-3.85 6.35-6.21 10.41-8.79 2.28-1.47 4.43-3.05 6.59-4.7a81 81 0 0 1 8.75-5.81l1-1q2.11-.49 4.25-.87 6.43-1.24 12.81-2.7l3.35-.75c4.69-1.23 8.32-1.98 13.46-2"/><path fill="#7b7b7d" d="M1521 673c-1.1 3.31-1.42 3.55-4.25 5.23l-2.02 1.24-2.17 1.28-6.54 3.99a166 166 0 0 0-10.2 7.08C1494 693 1494 693 1492 693l-.77 1.84c-1.4 2.47-2.54 3.26-4.98 4.66-2.5 1.47-4.94 2.95-7.31 4.63a49 49 0 0 1-8.1 4.1c-6.13 2.56-11.8 5.87-17.45 9.32a182 182 0 0 1-6.2 3.58 40 40 0 0 0-5.75 3.93c-4.6 3.65-10.08 5.67-15.44 7.94l-4.81 2.06-2.27.98c-1.92.96-1.92.96-3.92 2.96l-2.12.81a40 40 0 0 0-6.76 3.63 56 56 0 0 1-9.59 4.93c-1.53.63-1.53.63-3.78 1.82-1.75.81-1.75.81-4.75.81l-1 3a45 45 0 0 1-10.95 6.09c-2.6 1.16-5.06 2.53-7.55 3.91a100 100 0 0 1-10.83 5.3c-1.9.8-3.71 1.7-5.55 2.64q-5.34 2.68-10.85 5.06-2.22.98-4.42 2.04a61 61 0 0 1-12.66 4.4c-10.94 2.6-20.76 7.73-30.72 12.79l-1.85.94q-1.95 1.01-3.9 2.07c-1.72.76-1.72.76-4.72.76l-1 3c-6.92 4.48-13.9 6.75-22 8-2.44.4-3.75.82-6 2-1.13 2.36-1.52 4.4-2 7l-1-2c-7.73.97-14.64 3.64-21.89 6.4a125 125 0 0 1-16.41 4.93c-7.03 1.75-13.67 4.6-20.25 7.6-3.55 1.55-7.16 2.9-10.78 4.28a145 145 0 0 0-8.48 3.48 58 58 0 0 1-9.06 2.78c-2.39.6-4.72 1.29-7.07 2.03l-2.27.72c-1.79.78-1.79.78-2.79 2.78-2.58.34-2.58.34-5.81.5-5.76.28-5.76.28-8.1 1.46a22 22 0 0 1-6.65 1.95c-5.67 1.06-11.03 2.77-16.5 4.59a161 161 0 0 1-17.89 5.02 66 66 0 0 0-8.99 3.04c-5 1.9-9.4 2.84-14.77 3.14-3.32.44-5.96 1.86-8.95 3.32-3.43 1.43-6.9 1.9-10.56 2.48a77 77 0 0 0-10.59 2.91c-2.17.58-3.96.7-6.19.59v2l-1.58.3c-6.4 1.3-12.33 3.39-18.42 5.7-4.74 1.78-9.41 2.64-14.39 3.5-2.61.5-2.61.5-5.92 1.63-3.02.98-5.53 1.02-8.69.87v-1l2.63-.77 3.5-1.04 1.72-.5a41 41 0 0 0 7.99-3.21c4.11-1.93 8.14-2.58 12.6-3.36a140 140 0 0 0 22.5-6.07c2.69-.92 5.26-1.57 8.06-2.05l1-3h-10v-2l2.15-.37 2.79-.5 2.77-.5 2.29-.63 1-2c-11.1 1.69-11.1 1.69-15.25 3.13-3.63 1.15-6.97 1.04-10.75.87 2.82-2.23 6.07-3.08 9.46-4.1l1.9-.58 6.02-1.82 9.7-2.95c3.39-1.03 6.65-2.22 9.92-3.55q2-.45 4-.81c4.38-.93 8.4-2.43 12.56-4.09 4.86-1.92 9.79-3.63 14.75-5.29l2.25-.75q6.2-2.06 12.54-3.59c2.92-.72 5.78-1.59 8.65-2.47l3.3-1c2.95-1 2.95-1 5-2.04 2.43-1.2 4.8-1.7 7.45-2.27a71 71 0 0 0 12.44-4.19c6.4-2.8 13.98-5.5 21.06-5.5v-2l3.4-.77 4.41-1.04 2.24-.5c6.43-1.55 6.43-1.55 8.95-4.69 3.75-1.6 7.42-2.26 11.44-2.94 3.9-.67 7.17-1.57 10.74-3.26 4.65-2.04 9.73-3.8 14.82-3.8v-2l9.58-3q7.29-2.32 14.7-4.23l1.72-.77 1-3c1.44-.84 1.44-.84 3.28-1.5l2.04-.74 2.18-.76 2.22-.8c4.32-1.55 8.66-3 13.01-4.43 6.86-2.33 13.66-4.84 20.08-8.2 5.02-2.63 10.24-4.54 15.57-6.43A56 56 0 0 0 1316 768c2.11-.67 2.11-.67 4.31-1.19 6.45-1.8 12.22-4.91 18.15-7.97l1.9-.98a100 100 0 0 0 3.68-2.04c2.33-.97 4.59-1.15 7.07-1.46 3.38-.65 6.33-2.2 9.39-3.74l3.78-1.83 1.86-.9c8.14-3.89 8.14-3.89 11.86-3.89l.5-1.81c2.34-3.42 5.67-3.92 9.5-5.19 4.98-1.84 9.7-3.75 14.25-6.5 4.4-2.56 8.89-3.85 13.79-5.07A32 32 0 0 0 1426 721c5.53-2.63 10.92-3.6 17-4l1-3 2.38-.31c2.62-.69 2.62-.69 3.53-2.22 1.09-1.47 1.09-1.47 3.04-1.76l2.17.1 2.2.08 1.68.11.13-1.75c1.08-2.78 2.62-3.66 5.12-5.18a72 72 0 0 0 5.31-3.7A33 33 0 0 1 1478 695q3.48-1.98 6.81-4.2a53 53 0 0 1 12.18-5.98c2.96-1.2 5.52-2.93 8.18-4.69 2.18-1.34 4.46-2.2 6.83-3.13l1-2a11.7 11.7 0 0 1 8-2"/><path fill="#7f45c7" d="M412 594c3.73.53 7.14 1.68 10.69 2.94A77 77 0 0 0 434 600c4.91.89 9.5 2.42 14.21 4.05 3.24 1.1 6.5 2.05 9.79 2.95.98 3.8.76 6.2 0 10q-.32 3.71-.54 7.42l-.14 2.22-.44 7.26-1.15 18.72-.99 16.12-.8 13.16q-.74 12.11-1.54 24.22l-.14 2.16-.66 9.83c-1.7 25.57-1.7 25.57.4 31.89l1.87.08c5.62.37 9.84 1.46 14.88 3.93 3.61 1.59 7.38 2.3 11.25 2.99v1c-7.03.4-13.28-.92-20-3l-1-1a60 60 0 0 0-3.5-.81q-7.2-1.56-14.37-3.25l-2.28-.53-4.1-.96c-4.48-1.15-4.48-1.15-5.99-2.83-2.61-2.4-5.65-3.33-8.95-4.56l-6.03-2.35a69 69 0 0 1-14.06-7.63c-3.73-2.34-7.76-4.17-11.72-6.08v-2l-4-2c1.88-.62 1.88-.62 4-1l2 2 2 .44c2 .56 2 .56 2.94 2.06 1.06 1.5 1.06 1.5 3.63 2.1l2.43.4-.01-1.44c-.21-23.28-.32-46.38 2.01-69.56l.18-1.85q.66-6.68 1.38-13.34l.2-1.8.56-4.99.32-2.87C406 629 406 629 407 628q.32-3.21.5-6.44c.37-6.3.37-6.3 1.5-8.56q.3-2.04.5-4.1l.25-2.43.75-7.5.22-2.24C411 595 411 595 412 594"/><path fill="#e6e7e7" d="m1492.06 772.94 1.94.06c-.31 1.88-.31 1.88-1 4q-1.47 1.05-3 2l-1 3h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.26-.88-2.26-.88-5-1-2.46 1.66-2.46 1.66-4.81 4.06l-2.4 2.35c-2.15 3.11-2.15 4.88-1.79 8.59l-4 1v39l-4 2v29l-4 1 1 37c4.5-2.25 4.5-2.25 6.58-4.38q.65-.68 1.33-1.35l1.34-1.4 1.4-1.41L1477 894c.6 1.82.6 1.82 1 4a55 55 0 0 1-6.19 8.56c-1.91 2.58-2.45 4.29-2.81 7.44l-5 1-.19 3.19c-.5 3.54-1.7 4.93-4.41 7.31-6.07 6.5-8.9 17.06-10.86 25.57a34 34 0 0 1-2.1 5.43c-3.43 7.7-2.64 16.73-2.63 25v1.87c.04 24.39.04 24.39 4.19 32.63q.48 2.8.81 5.63c.96 6.2 3.08 10.96 6.19 16.37l1.14 2.14c2.56 4.71 2.56 4.71 4.86 5.86v8h3v3h3l.56 3.13c1.16 4.14 3.45 5.9 6.77 8.46 2.15 1.82 3.24 4 4.67 6.41 3.54 2.68 6.62 3.5 11 4l1 3c1.63.73 1.63.73 3.56 1.19l1.94.48 1.5.33v4l2.75-.25c3.25.25 3.25.25 5.56 2.06 1.69 2.19 1.69 2.19 2.69 5.19a24 24 0 0 0 4 2l-1 5-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-4-1v-3h5a34 34 0 0 0-10-12c-2.81-.12-2.81-.12-5 1-1.31 1.56-1.31 1.56-2 3l-7-1v-3h8l-2-4c-2.92 1.07-4.78 1.78-7 4-2.12-.37-2.12-.37-4-1v-3h5v-4l-1.81-.31c-2.19-.69-2.19-.69-3.69-2.32l-1.5-1.37c-2.06.13-2.06.13-4 1-1.25 1.56-1.25 1.56-2 3l-6-1-1-3h8l-2-4c-3.7.64-3.7.64-5.31 2.63-1.69 1.37-1.69 1.37-3.43 1.34a91 91 0 0 1-5.26-.97c-2.43-6.29-2.43-6.29-1-10l-4-2v8l-4 1v47l-5 1c-2.38-2.38-2.24-3.54-2.28-6.8l.01-1.97v-6.39q.01-3.26 0-6.5c-.01-8.35.59-16.15 2.27-24.34.82-4.12 1.35-6.94 0-11-3.01-3.95-6.77-7.2-10.42-10.55A48 48 0 0 1 1420 1040v-2l-1.84-.68c-2.85-1.74-3.43-3.4-4.72-6.44l-1.28-3.01-1.16-2.87-.91-2.12c-5.73-13.94-6.36-32.4-2.09-46.88h2l.33-1.72c2.28-11.07 7.22-20.57 14.34-29.36 1.33-1.92 1.33-1.92 2.17-4.28 1.55-3.52 3.83-5.84 6.47-8.58l2.87-3.02 1.42-1.5q2.1-2.33 4.05-4.78c1.7-2.08 3.3-3.58 5.6-5.01 2.15-1.68 2.69-2.46 3.28-5.18q.21-2.62.27-5.27c.2-2.3.2-2.3 1.2-3.73 1.74-2.72 1.36-5.94 1.41-9.08l.06-2.2.15-6.98.12-4.73q.14-5.79.26-11.58l-2-1 5-1 .04-2.63q.1-4.82.22-9.65l.09-4.18q.05-3 .14-6l.02-1.9c.06-1.75.06-1.75.49-4.64l1.5-.94c1.5-1.06 1.5-1.06 1.9-2.78l.03-2.09.06-2.36.02-2.56.06-2.61.12-8.28.1-5.61q.13-6.9.21-13.77l-2-1 5-1 .19-3.19c.62-4.4 2.32-6.13 5.81-8.81 2.31-.31 2.31-.31 4 0l.13-1.75c1.3-3.34 3.73-4.8 6.87-6.25 2.35-.54 4.6-.76 7-1 1-1 1-1 3.06-1.06M1459 905c-1 1-1 1-1.1 2.85l.06 4.44.04 1.71 2 1 2-1v-8zm-17 152v2h2v-2zm19 1v2h2v-2zm-5 12-1 4h8l1-2-1-2z"/><path fill="#573620" d="m1049.5 1214.81 2.6.08 1.9.11c.9 2.4 1.34 4.2.32 6.62l-1 1.82c-2.02 3.66-3.24 7.36-4.32 11.37-4.54 16.73-4.54 16.73-7 19.19-.26 3.45-.19 6.92-.19 10.38v3.04c.04 6.04.4 11.61 1.74 17.5.6 2.77.6 5.27.45 8.08h2l.77 3.14 1.04 4.11.5 2.07c1 3.9 2.1 6.51 4.69 9.68.19 2.75.19 2.75 0 5-5.11-.63-9.32-2.94-13.87-5.19a255 255 0 0 0-20.23-8.87c-9.67-3.7-9.67-3.7-11.9-5.94-2.03-.28-4.05-.45-6.09-.62C999 1296 999 1296 997 1294a68 68 0 0 0-4.44-1.5c-5.35-1.62-5.35-1.62-7.56-3.5v-2l-2.12-.31c-4.25-1.02-7.78-2.59-10.88-5.69q-2.05-.66-4.1-1.3c-2.35-.87-4.5-1.99-6.71-3.14A81 81 0 0 0 947 1271v-3l-6-2v-2l-1.8-.11c-4.85-.4-8.14-1-12.2-3.89q-2-1.02-4-2c-.06-2.25-.06-2.25 1-5a46 46 0 0 1 6.72-3.1c2.61-1.03 4.88-2.43 7.28-3.9 3-1.46 5.47-2.07 8.75-2.31 5.8-.6 11.15-3.6 15.25-7.69q2.91-.85 5.88-1.5A41 41 0 0 0 978 1231q2.45-.72 4.94-1.31l3.23-.78 1.83-.43 6.58-1.6a596 596 0 0 1 32.9-7.1c5.18-.98 9.9-2.2 14.8-4.18 2.44-.85 4.65-.9 7.22-.79"/><path fill="#710e02" d="m1435 1248 3.81 2.81 2.15 1.58c3.26 2.57 5.19 4.59 6.79 8.42 1.87 3.95 4.17 6.01 7.57 8.68 2.64 2.37 4.33 5.24 5.68 8.51v2h2a43 43 0 0 1 3.06 10.56c.88 5.21.88 5.21 1.46 7.31.53 2.34.61 4.44.61 6.83v8.33c-.01 13.94-.01 13.94-2.5 20.03l-.8 2q-.8 1.88-1.66 3.72c-1.46 3.16-2.28 5.66-2.48 9.16-.48 5.13-3.45 8.25-6.69 12.06l-2.06 2.81c-1.94 2.19-1.94 2.19-4.25 3.5-2.61 1.64-3.98 3.05-5.94 5.38-7.53 8.84-16.87 17.45-28.75 19.31 1.38-1.5 1.38-1.5 3-3h2v-2l4-1c-1.32-3.91-2.87-6.19-5.87-9-5.53-5.34-8.67-9.63-11.13-17h-2c-9.37-17.51-9.43-40.05-4-59a53 53 0 0 1 5.38-9.55c1.62-2.45 1.62-2.45 2.71-5.2.91-2.25.91-2.25 2.91-3.25l1.19-2.62c3.64-7.58 11.95-13.86 18.81-18.38 4.06-1.14 7.82-1.08 12-1a29 29 0 0 0-7-10z"/><path fill="#030308" d="M1487 571c4.3 3.33 6.63 7.04 7.63 12.4.33 3.89.24 7.76.12 11.66l-.06 4.17c-.23 9.92-.93 20.83-6.73 29.29-.96 1.48-.96 1.48-1.9 4.32-3.48 10.38-10.6 18.64-17.75 26.68a224 224 0 0 0-3.48 4.05c-6.84 8.05-14.73 13.6-23.88 18.75-2.6 1.48-5.11 3.06-7.64 4.68-8.99 5.43-19.19 9.27-29.31 12l-2.08.63c-5.82 1.66-12.36 2.83-18.11.47a47 47 0 0 1-4.81-3.1v-3h-2c-.8-5.21.16-7.73 3.19-12.06q1.88-2.48 3.81-4.94l1.3-1.76c2.8-3.67 6.14-6.22 9.93-8.85A14 14 0 0 0 1399 662h-3l8-7 2 1-2 3q4-3.49 8-7l1.31-1.15 4-3.54 2.5-2.2c2.19-2.11 2.19-2.11 3.86-4.38L1425 639h2l.66-1.63c1.82-3.21 4.23-5.6 6.78-8.25a210 210 0 0 0 28.99-39.31c12.5-21.34 12.5-21.34 23.57-18.81"/><path fill="#402a6f" d="M1283 376c2.8.3 4.5.5 6.52 2.54 2.39 3.96 3.33 8.34 4.5 12.8 1.53 5.37 1.53 5.37 4.07 7l1.91.66a38 38 0 0 1 2 5 40 40 0 0 0 4.56 6.81c1.44 2.19 1.8 3.49 2.32 6 .86 3.89 2.44 6.3 4.83 9.44 5.22 7.08 9.29 17.9 9.29 26.75h2l.55 1.76a68 68 0 0 0 4.01 10.3c1.5 3.05 2.23 5.84 2.97 9.13.47 1.81.47 1.81 1.6 3.87 1.34 2.99 1 5.69.87 8.94l4 1c1.95 8.92 3 20.45-.13 29.13-1.47 2.2-3 4-4.87 5.87l-3.37 4.06q-1.3 1.49-2.63 2.94c-1.88-.24-1.88-.24-4-1-1.05-1.76-1.05-1.76-1.87-4.12l-1-2.74L1320 519l-1.48-3.61a143 143 0 0 1-6.8-19.95c-1.6-5.45-4.28-9.97-7.72-14.44v-2h-2c-1.83-1.93-2-2.99-2.12-5.69l.12-2.31h-2c-1.9-2.14-2.53-4.62-3.4-7.3-1.03-2.94-2.29-5.78-3.54-8.64l-1.52-3.52-2.38-5.45c-1.3-3-2.52-5.87-3.16-9.09h-2c-1.25-1.2-1.25-1.2-2.56-2.81l-1.32-1.58C1277 431 1277 431 1276 428l-1.87-1.75c-2.24-2.37-3.18-4.28-4.32-7.27-1.12-2.73-2.5-5.34-3.81-7.98l-14 1-1 3-8 1v2c-14.63 7.49-14.63 7.49-20 7l-1 3-3-1c2.2-3.49 4.05-4.81 8-6h3l.13-1.75c1.1-2.85 2.77-3.64 5.33-5.18 1.54-1.07 1.54-1.07 3.54-3.63 2.4-2.93 4.56-3.93 8-5.44 5.68-2.68 5.68-2.68 8-5 .63-2.12.63-2.12 1-4h-2c1.15-2.47 2.05-4.05 4-6 2.63-.12 2.63-.12 5 0l1-4 5-1v2h4v-2c1.68-1.4 1.68-1.4 3.88-2.94 2.6-1.82 4.54-3.28 6.12-6.06"/><path fill="#dcdcdd" d="M515.94 1827.87h83.55a118321 118321 0 0 1 101.17 0h560.11c2.23.13 2.23.13 4.23 1.13l-1 5H511l-1-5c1.67-1.67 3.65-1.13 5.94-1.13"/><path fill="#47454b" d="M319 644h4c-.5 6.4-.5 6.4-2.87 9.25-2.6 3.36-3.66 6.54-4.88 10.56l-.67 2.1L313 671h-1v-8l-1 2h-2v-3l-1.14.99A66 66 0 0 1 298 670l-2 2 2 2c-3.03 2.6-5.16 4.07-9 5l-.94 1.44C287 682 287 682 284.5 682.87c-2.5 1.13-2.5 1.13-3.5 3.7l-1 2.43c-2.31.63-2.31.63-5 1-2.44 1.31-2.44 1.31-5 3l-2.66 1.7c-4.77 3.18-9.1 6.95-13.5 10.61A78 78 0 0 1 243 713a172 172 0 0 0-5.25 3.94l-2.64 2.02c-2.26 2.18-2.51 3.09-3.11 6.04-1.3 1.27-1.3 1.27-2.87 2.25l-1.56 1.02C226 729 226 729 223 729c-3.14 1.47-5.6 2.97-6.87 6.25L216 737h-3l-1.13 2.62c-1.92 3.84-4.66 6.5-7.75 9.44l-4.64 4.5q-2.14 2.1-4.22 4.24c-2.08 2.03-3.72 2.93-6.26 4.2l-1 3-1.77.4c-3.16.85-4.77 2.91-6.85 5.29l-2.36 2.57c-2.13 2.89-3.07 5.3-4.02 8.74l-1 3h-3c-1.5 1.8-1.5 1.8-3 4.19l-1.58 2.5L163 794l-1.69 2.69a96 96 0 0 0-2.87 5.5C157 805 157 805 155 807c-.56 1.66-.56 1.66-1 3.5-.65 2.73-.92 3.42-3 5.5a98 98 0 0 0-.23 7.9v2.37l.02 4.99q.02 3.8.02 7.6l.02 4.85V846c.04 3.8.25 7.29 1.17 11h2v6h2v2h2l.73 1.46c3.33 6.27 3.33 6.27 7.27 7.54v3l2.44.88C171 879 171 879 172 881l2-1v2h2l1 3q2.48 1.05 5 2l1 2 2-1v2l4 1v3h2l1-2v2h5v4h8l1 5 2-1q3-.06 6 0l.38 2.44.62 2.56 2 1v-2h8l1 4h10v6l1.24-1c2.15-1.22 3.49-1.2 5.95-1.12l2.17.05 1.64.07 1 4h13l1 5 2 1v-2h14v5l2 1v-2h20v4h21l1 3 28 1c-3.42 2.28-4.68 2.22-8.69 2.13l-3-.06L336 936v2l3.31-.12c5.35-.02 10.47 1.04 15.69 2.12l1-2h37c-2.9 1.94-3.56 2.26-6.8 2.41l-2.15.12-2.24.1-2.27.11q-2.78.14-5.54.26a3145 3145 0 0 0 15.78 1.72c8.12.9 16.03 1.45 24.22 1.28l-10-1v-1h155v1l-25.62.44-1.97.03q-33.04.57-66.1 1.1l-2.1.03-25.21.4q18.63.55 37.25 1l2.9.07c37.48.92 74.93 1.1 112.41 1.05h1.99q25.22-.03 50.45-.12v1c-8.55.85-16.98 1.14-25.57 1.13H552.7a6135 6135 0 0 1-104.99-.63c-12.31-.19-24.62-.43-36.93-.91l-2.62-.09a68 68 0 0 1-14.08-2.05c-3.77-.82-7.58-.86-11.43-.98l-5.32-.21-8.24-.29c-9.94-.34-19.43-.84-29.1-3.3-3.22-.72-6.31-1-9.6-1.17-8.43-.46-16.69-1.82-25.02-3.14q-5.02-.76-10.05-1.42A150 150 0 0 1 280 932v-2l-2.63.08a47 47 0 0 1-18.87-4.64c-3.01-1.42-5.9-1.9-9.19-2.38-6.4-.96-12.22-2.9-18.31-5.06l-4.81-1.5c-3.5-1.16-6.5-2.7-9.65-4.59-4.2-2.49-7.97-3.18-12.77-3.47-2.3-.57-2.53-1.49-3.77-3.44-1.63-.74-1.63-.74-3.56-1.31-6.09-2.1-11.23-5.83-16.19-9.88-2.98-2.4-5.9-4.37-9.55-5.56-3.49-1.61-5.68-3.83-8.32-6.56l-1.45-1.44c-3.41-3.46-5.9-7-7.93-11.44-1-1.81-1-1.81-2.44-2.7-1.92-1.37-2.54-2.6-3.56-4.74q-.46-.94-.94-1.9L145 857l-.78-1.62c-2.72-6.19-2.6-12.09-2.47-18.75l.02-3.31c.07-8.01.07-8.01 1.23-10.32q.44-2.77.81-5.56c.93-5.7 2.5-10.35 5.19-15.44l1.2-2.27q1.85-3.4 3.8-6.73l1.29-2.24c2.25-3.78 4.13-6.16 7.71-8.76l.82-2.32c1.42-3.24 3.09-4.63 5.8-6.87 5.18-4.42 9.27-9.39 13.38-14.81a49 49 0 0 1 9-9l3.6-3.65c2.13-2.06 4.44-3.88 6.75-5.73C204 738 204 738 205.3 735.65c2.22-3.45 4.94-5.46 8.2-7.89l3.78-2.9 1.9-1.45a351 351 0 0 0 7.88-6.27l2.54-2.06c1.75-1.5 3.35-3 4.97-4.64a49 49 0 0 1 6.3-5.37 100 100 0 0 0 7.75-6.25c2.92-2.55 5.91-4.08 9.38-5.81 1.26-1.2 2.53-2.4 3.72-3.67 3.66-3.8 7.73-6.7 12.28-9.33l2-1.56c2.25-1.62 4.43-2.42 7-3.44a89 89 0 0 0 13.56-8.81l1.71-1.32A58 58 0 0 0 309 654l-1.39.53-6.3 2.4-2.18.85c-3.7 1.4-7.21 2.6-11.13 3.22v2c-2.9 1.26-4.8 2-8 2v2c-2.9 1.26-4.8 2-8 2l-.13 1.83c-.87 2.17-.87 2.17-3.49 3.65l-3.25 1.27-3.25 1.3C259 678 259 678 256 678l-1 3c-1.38.5-2.76 1-4.18 1.4-2.86.95-5.44 2.44-8.09 3.9l-1.73.7-2-1c4.43-3.64 8.48-6.46 13.93-8.3a93 93 0 0 0 6.63-2.58l2.07-.86a43 43 0 0 0 5.65-3.64c3.7-2.7 7.59-4.7 11.72-6.68 4.58-2.22 9.05-4.48 13.4-7.1a22 22 0 0 1 5.1-1.84 23 23 0 0 0 6.81-2.94c3.68-2.26 7.48-3.24 11.65-4.25L318 647z"/><path fill="#4b2a86" d="m1003 513 .81 2.98c1.2 4.2 2.7 8.29 4.19 12.4 1.9 5.25 3.54 10.36 4.57 15.85.43 1.77.43 1.77 1.93 4.7 1.7 3.46 2.23 6.57 2.9 10.34.87 3.95 2.31 7.62 3.8 11.38.8 2.35.8 2.35.8 5.35h2c1.85 5.8 3.4 11.55 4.53 17.53.73 3.84 1.6 7.65 2.47 11.47h2c2.34 4.48 4.2 9 5.94 13.75 1.68 4.57 3.39 9.12 5.25 13.63a96 96 0 0 1 4 12.5c.81 2.12.81 2.12 2.3 3.55 2.11 2.2 2.37 4.24 3.01 7.2 1.05 4.63 2.33 9 4.05 13.43.58 2.48.6 3.65-.55 5.94-2.58 1.27-2.58 1.27-5.81 2.25l-3.21 1.02c-2.5.61-4.43.9-6.98.73l-1 3c-1.75.8-1.75.8-4 1.44-6 1.83-11.57 4.32-17.16 7.16-8.85 4.42-17.74 7.27-27.27 9.86a416 416 0 0 0-19.85 6.1c-8.85 2.86-17.66 5.33-26.83 6.91a165 165 0 0 0-26.86 7.24c-7.12 2.5-14.38 3.93-21.77 5.4-7.64 1.58-7.64 1.58-11.34 3.44-4.17 2.07-8.34 2.48-12.92 3.08l-5.25.72-2.48.33q-4.15.6-8.27 1.32c6.31-6.31 19.75-7.15 28.18-8.5 3.38-.6 6.53-1.5 9.82-2.5q3.58-.7 7.18-1.34L903 722l1-3h-13v-1l3-.11 3.88-.2 1.97-.07c1.9-.1 1.9-.1 5.15-.62l1-1.51 1-1.49c3.02-.38 5.91-.48 8.95-.53 1.86-.06 1.86-.06 5.05-.47l1-1.52 1-1.48c2.47-.22 4.67-.28 7.13-.19l2 .04 4.87.15 1-4 15-1v-3l11-1 1-3 13-1v-3l2.34-.11 3.03-.2 3.03-.18 2.6-.51.97-1.5L991 690c2.82-.51 2.82-.51 6.13-.69l3.32-.2 2.55-.11 1-3 2.55-.37 3.33-.5 3.3-.5C1016 684 1016 684 1018 682c2.38-.2 2.38-.2 5.13-.12l2.75.05 2.12.07 2-4h10l1-4 3.38-.31c3.44-.47 3.44-.47 4.93-2.25l.69-1.44h2a112 112 0 0 0-6.06-21.12l-.78-1.94A45 45 0 0 0 1041 639c-.53-1.73-.53-1.73-.94-3.62-1.99-8.6-5.32-16.74-8.63-24.9-1.66-4.18-2.9-8-3.43-12.48h-2l-.75-3.31a105 105 0 0 0-4.45-13.17 16 16 0 0 1-.8-6.52h-2l-.31-3.19a13.5 13.5 0 0 0-2.13-6.18c-1.85-3.12-2.39-5.91-3.03-9.45-.53-2.18-.53-2.18-1.55-4.2a16 16 0 0 1-1.6-6.23l-.23-2.14-.15-1.61h-2l-.52-1.98A306 306 0 0 0 1000 519l-1 3-2.52.11-3.3.2-3.26.18c-3.26.57-3.73 1.23-5.92 3.51-2.56.44-5.03.5-7.62.6-2.84.48-3.42 1.4-5.38 3.4-2.12.4-2.12.4-4.44.5-3.8.29-5.66.93-8.56 3.5h-3l-2 4-6-1-.44 1.94C946 541 946 541 945 542c-1.78.13-1.78.13-3.94.13h-2.15C937 542 937 542 935 541l-.25 1.88c-.75 2.12-.75 2.12-2.5 3.3-2.25.82-2.25.82-4.87 1.32-2.38.5-2.38.5-4.38 2.5-2.83.34-5.63.44-8.47.56L912 551q-1.02 1.5-2 3c-2.38.51-2.38.51-5.12.69l-2.76.2-2.12.11-1 3-2.23.08c-6.13.35-9.01.93-13.77 4.92-2.1.17-4.12.06-6.23-.05-2.48.07-3.09 1.3-4.77 3.05-2.03.35-4 .5-6.06.6-1.94.4-1.94.4-3.38 1.9-2.13 2.05-3.64 1.93-6.56 2.06-4.7.23-4.7.23-6.49 1.46-2.22 1.44-4.09 1.17-6.7 1.1l-2.73-.05L838 573a14.7 14.7 0 0 1 6.9-4.6l2.26-.8 2.34-.79 2.34-.82c5.74-1.99 5.74-1.99 9.16-1.99v-2l1.43-.47c8.75-2.9 17.5-5.8 26.15-8.97A68 68 0 0 1 897 550v-2l1.5-.4 1.94-.54 1.93-.52C904 546 904 546 905 545q2.55-.35 5.11-.62c2.2-.44 3.93-1.3 5.89-2.38q-2.37-.08-4.75-.12l-2.67-.08c-2.78.22-4.14.93-6.58 2.2q-3.33.86-6.69 1.56c-6.18 1.31-6.18 1.31-7.31 2.44q-2.46.55-4.94 1c-4.85.9-4.85.9-7.06 2q-3.3.35-6.62.56l-1.86.13-4.52.31v2a84 84 0 0 1-15.75 3.88c-6.14.85-12.1 2.2-18.12 3.62a183 183 0 0 1-22.94 4.04c-2.4.35-4.28.76-6.54 1.5-4.18 1.25-8.25 1.2-12.59 1.27-10.06.31-19.55 2.2-29.34 4.44a224 224 0 0 1-30.23 4.46c-4.25.4-8.38.93-12.56 1.75-7.5 1.43-15.08 1.97-22.68 2.6l-10.7.92c-3 .26-5.94.58-8.92 1.05-4.4.65-8.8.68-13.25.75l-22.56.44-2.64.04-2.41.05-2.14.04C628 585 628 585 627 586c-2.3.1-4.56.14-6.86.13h-38.67c-21.84.03-43.64-.27-65.47-1.13v-2l-2.34.07c-8.52.14-16.57-.7-24.97-2.07l-3.73-.58q-4.48-.7-8.96-1.42v-1c8.45-.32 16.47-.07 24.81 1.38 35.84 5.84 73 3.95 109.19 3.62l2.74-.02c19.36-.17 38.65-1.43 57.95-2.73l5.71-.37c23.94-1.57 23.94-1.57 35.63-4.22a95 95 0 0 1 12.4-1.72c4.26-.38 8.34-.82 12.47-1.93 4.71-1.26 9.44-1.66 14.29-2.07 11.74-1.05 11.74-1.05 14.81-1.94l1-2 2.06-.04q4.69-.13 9.38-.27l3.24-.07c5.9-.2 10.86-.85 16.4-2.94 2.57-.9 5.01-1.12 7.73-1.24 4.8-.23 4.8-.23 6.98-1.38 3.44-1.65 7.08-1.95 10.84-2.5 5.85-.9 11.38-2 17-3.87 3.75-1.1 7.56-1.61 11.41-2.16 2.8-.5 5.42-1.22 8.15-2.03 3.02-.84 6.1-1.37 9.18-1.94 4.1-.78 8.07-1.58 12-3 8.91-3.2 18.46-5.17 27.82-6.5 5.8-.88 11.03-2.76 16.49-4.84 6.66-2.5 13.6-4.07 20.5-5.72l2.82-.68a161 161 0 0 1 17.63-3.27c2.37-.55 2.37-.55 4.67-1.97a35 35 0 0 1 8.26-3.4l3.17-.93 3.27-.94 3.27-.97c8.8-2.54 8.8-2.54 13.73-2.34"/><path fill="#1c1c24" d="M835 1049c3.47 2.21 5.25 5.37 7 9l1.44.94c1.56 1.06 1.56 1.06 2.93 3.5 1.63 2.56 1.63 2.56 3.88 3.88 3.26 2 5.67 4.34 8.31 7.06l1.42 1.42a70 70 0 0 1 6.05 6.86c2.15 2.55 4.1 3.7 6.97 5.34q1.59 1.24 3.13 2.52c6.55 5.45 12.36 9.68 20.44 12.58 2.1.78 3.73 1.44 5.43 2.9v2l1.69.3c3.97.78 7.49 1.64 11.06 3.58 3.94 2.12 7.97 3.31 12.26 4.55 3.7 1.1 7.09 2.46 10.55 4.13 4.76 2.27 9.42 3.53 14.56 4.7 2.7.7 5.03 1.6 7.57 2.74 6.57 2.81 13.65 3.63 20.68 4.67 1.63.33 1.63.33 3.63 1.33v2l3.31.4q4.82.6 9.63 1.35c16.73 2.34 33.42 2.56 50.28 2.56l5.69.01q5.42.01 10.85-.02h3.28c5.77-.05 11.1-.52 16.72-1.83 4.03-.85 8.14-1 12.24-1.28a114 114 0 0 0 34-8.19q1.8-.51 3.63-.94l2.07-.5 2.3-.56c6.88-1.7 13.8-3.44 20-7l1-2c2.29-.63 2.29-.63 5.06-1.12l2.79-.51 2.15-.37 1-3a83 83 0 0 1 7.06-2.31c5.53-1.69 9.78-3.5 14.24-7.28 2.2-1.83 4.56-3.12 7.06-4.48a84 84 0 0 0 6.02-3.85c2.62-1.75 5.3-3.38 7.98-5.03 1.64-1.05 1.64-1.05 2.64-2.05q2.5-.06 5 0a10712 10712 0 0 1 .15 41.09 3190 3190 0 0 1 .06 17.36q.03 3.36.02 6.71l.02 2.02c-.02 4.6-.02 4.6-2.25 6.82-2.4.23-4.7.35-7.1.38l-2.18.05q-3.45.08-6.9.13l-8.99.2-2.23.04a397 397 0 0 0-17 .78c-2.47.17-4.36.3-6.6 1.42q-3.26.2-6.53.25l-4.06.1q-1.06 0-2.16.04c-32.6.73-32.6.73-41.25 3.61q-3.84.52-7.69.94l-4.58.52-2.37.27a1499 1499 0 0 0-13.27 1.57 40 40 0 0 0-8.09 1.7q-3.53.47-7.05.87l-11.04 1.25c-8.49.94-16.74 2.1-25.03 4.2-4.04.96-8.12 1.56-12.22 2.15a40 40 0 0 0-7.66 2.53c-3 .8-6.03 1.5-9.06 2.22-2.94.78-2.94.78-5.3 1.74-3.56 1.4-7.17 2.24-10.89 3.1l-2.16.53c-3.7.87-7.29 1.52-11.07 1.85-4.77.48-9.02 1.83-13.52 3.43l-4.5 1.56-2.24.79q-3.47 1.19-6.98 2.32a76 76 0 0 0-9.78 4.02 47 47 0 0 1-12.53 3.98c-1.97.46-1.97.46-3.95 1.5-2.31 1.1-3.8 1.1-6.33 1.09-7.2.54-12.92 4.46-19.05 7.98A55 55 0 0 1 890 1223a82 82 0 0 0-3.31 1.94 51 51 0 0 1-10 4.35c-1.69.71-1.69.71-3.69 2.71-1.84.82-1.84.82-3.94 1.56l-2.09.76q-2.48.86-4.97 1.68v-2c5.51-4.16 12.1-7.14 18.25-10.25l2.04-1.03A75 75 0 0 1 894 1218v-2h-3v-2h5l1 2 1.81-1c2.19-1 2.19-1 5.19-1v-2h-4v-2l5.27-1.37C907 1208 907 1208 909 1206q2.67-.35 5.34-.65c1.66-.35 1.66-.35 3.16-1.85 2.1-2.1 4-1.9 6.88-2.18 1.62-.32 1.62-.32 3.06-1.88 2.39-2.2 4.79-1.66 7.92-1.55l1.64.11v2l6.25-1.87 1.78-.53c3.15-.96 6.08-2.01 8.97-3.6v-4h7l-1 3 2.13-.74a297 297 0 0 1 22.04-6.55l5.38-1.42 3.48-.9 3.11-.81c2.72-.55 5.1-.7 7.86-.58l1-2q2.8-.42 5.64-.72c2.38-.28 4.68-.72 7.03-1.23 16.62-3.55 33.35-6.17 50.16-8.73l2.91-.44 5.47-.83c3.18-.48 6.33-.96 9.48-1.6 2.9-.56 5.77-.85 8.7-1.13l1.86-.18 6-.58 2.09-.2c18.1-1.72 36.17-3.32 54.35-3.96 8.1-.3 16.16-.86 24.25-1.46 10.37-.77 20.66-1.21 31.06-.94v-3l1.48-1.4c2.03-2.13 1.88-3.3 1.85-6.2l-.02-2.88-.06-3.14-.03-3.26c-.2-14.71-.7-29.42-1.22-44.12-1.87-.62-1.87-.62-4-1l-2 2 2 2a42 42 0 0 1-6 5c-2.25-.19-2.25-.19-4-1-3.26 0-5.72 2.03-8.44 3.69l-1.77 1.03c-3.79 2.3-5.63 4.32-7.79 8.28h-2l-1-2-1.28.94c-2.27 1.4-4.52 1.88-7.1 2.5-1.62.56-1.62.56-3.62 2.56-1.6.71-1.6.71-3.5 1.38q-4.67 1.67-9.25 3.56c-4.76 1.92-9.6 3.55-14.49 5.13q-3.93 1.34-7.76 2.93l3 1v2h-6v-3l-2.82 1.34c-4.85 2.07-9.86 3.15-15 4.29l-2.82.65a112 112 0 0 1-16.3 2.49c-2.59.29-4.68 1.2-7.06 2.23-21.68 3.8-44.85 2.44-66.74 1.3l-4.58-.22c-8.72-.42-16.88-1.3-25.33-3.56L988 1139l-1 1 2 2h-9l4-3-1.98-.49c-44.3-10.92-44.3-10.92-47.03-15.03-1.37-2.05-2.8-2.38-5.12-3.17l-2.19-.76-1.68-.55v-2l-2.16-.11c-5.98-.41-10.47-1.26-15.84-3.89h-6v-3c-2.5-.69-4.38-1-7-1l-1.22-2.2c-2.41-3.79-5.37-5.32-9.22-7.49l-2.02-1.18c-2.6-1.5-5.18-2.95-7.88-4.26-2.1-1.1-3.48-2.67-5.09-4.37-2.7-2.59-5.76-4.73-8.78-6.95L859 1081v-2l-1.75-.74c-3.4-1.9-5.65-4.73-8.25-7.58-2-1.68-2-1.68-4.25-1.92l-1.75.24-.12-2.06c-1.25-4.17-3.82-6.92-6.88-9.94l-2-1v2c-3-1-3-1-4-2-5-.32-9.34.43-14.22 1.49-4.89.9-9.83 1.19-14.78 1.51v2l2.05-.29 2.7-.34 2.67-.35c2.8-.02 4.21.53 6.58 1.98l-1.51.04q-3.4.13-6.8.27l-2.38.07-2.3.1-2.1.09c-2.46.55-3.37 1.48-4.91 3.43-.69 2.69-.69 2.69-1 5l-3-3c-2.92 5.6-2.92 5.6-3.37 8-.86 2.75-2.55 4.43-4.47 6.54C782 1084 782 1084 781 1087l-2 1c-.71 1.45-.71 1.45-1.37 3.25-1.38 3.43-3.29 5.88-5.63 8.75-1.25 2.81-1.25 2.81-2 5l3 1-1.2 1.57-1.61 2.12-1.58 2.07c-1.61 2.24-1.61 2.24-3.04 4.7a47 47 0 0 1-5.82 7.35l-2.4 2.63L755 1129q-1.88 2.12-3.75 4.25l-1.7 1.94a88 88 0 0 0-2.85 3.5c-3.03 3.93-6.25 6.13-10.7 8.31l-1-2-5 1-1 3-5 3a174 174 0 0 0-3.94 3.44 67 67 0 0 1-12.68 9c-2.76 1.81-4.36 3.98-6.38 6.56h-2v2l-2.81.88c-3.19 1.12-3.19 1.12-4.47 2.07-2.62 1.6-5.53 2.17-8.47 2.92-4.68 1.27-8.66 2.64-12.77 5.27-2 1.16-4.11 1.68-6.35 2.23a72 72 0 0 0-6.7 2.45 73 73 0 0 1-17.29 4.82c-2.14.36-2.14.36-4.14 1.36q-2.06.1-4.12.06l-2.2-.02-1.68-.04c3.58-3.58 8.04-4.02 12.88-4.75 5.04-.85 9.51-2.44 14.25-4.31 5.61-2.23 10.75-3.9 16.87-3.94v-2l2.67-1.02 3.52-1.36 1.75-.67 7.92-3.05c2.14-.9 2.14-.9 3.14-1.9q3-.06 6 0l.63-1.69c2.84-4.78 7.63-7.58 12.37-10.31 4.46-2.66 7.29-5.39 10.56-9.4 1.88-2.08 4.01-3.23 6.44-4.6a277 277 0 0 0 5-5l3.34-3.3 3.6-3.58 1.8-1.77c3.48-3.46 6.75-7 9.84-10.81 2.02-2.19 4.37-3.7 6.82-5.36 3.92-2.89 6.17-7.06 8.6-11.18q1.5-2.32 3-4.62l1.5-2.3c1.5-2.08 1.5-2.08 3.12-3.66 2.06-2.12 2.5-4.65 3.38-7.42l2-2c.56-1.8.95-3.6 1.34-5.44l.66-1.56 3-1c.66-1.82.66-1.82 1.06-4.12a90 90 0 0 1 2-8.7c.94-3.18.94-3.18 1.38-5.5.75-2.25 1.44-2.6 3.56-3.68 2.7-.45 2.7-.45 5.92-.75l3.55-.35 5.56-.5c12.56-1.15 12.56-1.15 18.3-3.75a16 16 0 0 1 9.67-.65"/><path fill="#8f8f95" d="m392 1260 2 1c.41 2.6.41 2.6.63 6l.13 1.82.37 5.74.26 3.9.61 9.54 1-18h1c.76 4.7 1.14 9.24 1.12 14v13.67l-.02 13.42v14.48l-.02 25.08-.03 36.29q0 29.46-.03 58.91v1.78l-.14 210.79c-.04 15.84-.04 15.84 1.12 21.58l2 1c2.23 2.92 3.17 4.89 2.94 8.5-.22 3.97 1.58 5.47 4.06 8.5.25 3.31.25 3.31 0 6h3l1 7h3l.33 1.5.48 1.94.46 1.93.73 1.63 3 1c.69 2.06.69 2.06 1 4l2-1 3 3c.08-3.34-.04-6.46-.62-9.75L426 1712l2-2 1-3v2h2l1 8h2l.25 2.38.75 2.62q1.99 1.02 4 2c2.2 2.93 3.8 5.57 5.31 8.91 1.63 3.29 3.83 5.34 6.63 7.71 6.95 5.99 6.95 5.99 9.06 8.38v3l1.88.44c2.12.56 2.12.56 4.12 1.56v2l3 1v1c-5.3.7-8.38-1.08-13-3.57-4.17-1.99-8.49-3.33-12.91-4.64-3.56-1.1-5.54-2.16-8.09-4.79a74 74 0 0 0-3.3-1.32c-4.2-1.7-6.83-4.35-9.89-7.62l-1.53-1.59q-1.49-1.53-2.94-3.08a98 98 0 0 0-3.63-3.54 9.5 9.5 0 0 1-2.83-5.47C410 1719 410 1719 408 1717l-.81-2.06a59 59 0 0 0-3.69-7.38 38 38 0 0 1-3.69-8.06l-.81-2.5-2-1c-2.82-5.98-3.65-12.35-4.15-18.87l-.24-2.97c-1.03-14.22-1.07-28.49-1.2-42.74l-.08-7.52c-.4-35.83-.48-71.66-.47-107.49V1490l.01-49.87v-174.77c.13-2.36.13-2.36 1.13-5.36"/><path fill="#1e1e25" d="m420.94 1056.81 2.25-.03 2.15-.01 1.98-.01c1.68.24 1.68.24 3.68 2.24.36 1.74.36 1.74.53 3.82l.21 2.31.38 4.78c.38 4.05.96 7.65 2.34 11.49 1.84 5.13 2.55 10.27 3.23 15.66l.39 2.86A102 102 0 0 1 439 1116h2c2.65 25.69 3.44 51.14 3.4 76.95l.02 15.94q.02 13.74.02 27.48a17131 17131 0 0 0 .06 58.36v1.88l.05 61.58.08 91.19v1.87l.03 18.52.04 39.73a20760 20760 0 0 0 .02 44.53 4161 4161 0 0 0 .02 19.5v9.06c.03 4.9 1.05 8.98 3.26 13.41q.1 2.06.06 4.13L448 1604c-4.76-2.83-5.94-5.7-7.4-10.94-1.17-6-1-12.04-.95-18.13l-.01-4.54q0-6.23.03-12.44v-13.44l.04-23.3q.04-16.91.05-33.83.02-27.54.07-55.08a98149 98149 0 0 0 .12-92.22v-3.2l.02-12.66.05-36.78c.15-78.77.15-78.77-.96-112.54q-.19-6.24-.35-12.48c-.42-13.74-1.89-27.35-3.44-41q-.41-3.64-.81-7.3l-.99-8.9-.35-3.23q-1-8.83-2.67-17.56c-.7-3.75-1.15-7.52-1.57-11.3q-.14-1.08-.27-2.18A76 76 0 0 1 428 1060q-2.91.35-5.81.75l-3.27.42c-3.4.97-4.16 1.84-5.92 4.83l-1.69 2.44c-1.31 2.56-1.31 2.56-1.25 4.81-.08 3.65-1.5 6.15-3.04 9.41-3 6.86-3.87 14.39-4.46 21.78-.32 3.8-.92 7.13-2.08 10.74-1.47 5.55-1.92 11.25-.48 16.82l-1 1q-.36 3.43-.56 6.88l-.13 1.93q-.29 4.1-.31 8.19a174 174 0 0 1-1.94 6.38 72 72 0 0 0-3.56 17.99l-.12 1.8-.76 11.38-.34 5.16c-.28 3.29-.28 3.29-.76 6.23-.78 5.41-.9 10.86-1.07 16.32l-.07 1.87c-.6 18.21-.53 36.43-.53 54.64v15.8l-.02 55.81v9.06l-.02 50.5v17.31q0 30.08-.02 60.17l-.02 67.78v9.03l-.01 28.57a18401 18401 0 0 0-.01 44.21 4080 4080 0 0 0 0 19.35v6.99l-.01 2.01c.03 4.16.67 7.86 1.78 11.87.67 2.46 1 4.94 1.36 7.46.9 5.68 2.43 10.95 4.22 16.4a65 65 0 0 1 1.9 7.91c-2.95-1.47-3.43-4.08-4.56-7-2.33-5.89-2.33-5.89-3.44-7q-.22-1.8-.32-3.6l-.12-2.18-.12-2.28-.13-2.3-.31-5.64h-2a88 88 0 0 1-1.13-14.59v-9.61l-.01-5.35v-14.7l-.02-15.86-.01-56.49v-9.16l-.02-51.23v-17.64q0-30.42-.02-60.84l-.02-68.55v-9.19l-.01-25.25c-.08-89.07-.08-89.07 1.13-119.8l.12-3.03c.42-10.41 1.2-20.7 2.55-31.03.72-5.67.86-11.35 1-17.06l.2-6.46.07-2.8c.17-2.36.17-2.36 1.17-5.36q.38-3.81.65-7.63l.36-4.64.54-7.26c1.15-15.93 1.15-15.93 3.74-22.53 1.29-3.51 1.76-7.2 2.33-10.88l.73-4.46.3-1.96c.35-1.64.35-1.64 1.35-3.64l-.62-1.94c-.38-2.06-.38-2.06.56-3.81 2.82-1.7 5.08-1.44 8.32-1.26l1.74.01c2.05-2.05 6.18-1.19 8.94-1.19"/><path fill="#8f9ca6" d="M1570 1046c6.88.81 11.53 5.05 16 10l1.5 2.81 1.5 2.19 2.3-.14c3.8.2 4.82 1.82 7.33 4.58 4.1 4.47 4.1 4.47 7.37 5.56v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v10l4 2v42l-4 2v10l-4 2v9l-4 1-.31 1.88c-.69 2.12-.69 2.12-1.88 3.37-2.62 1.08-5 .87-7.81.75l-.19 1.69c-1.2 3.42-3.05 4.95-5.81 7.31h-3v5h-4v-6h3v-4h-2c-.69-2.19-.69-2.19-1-5l1.44-2.31c1.85-3.19 1.75-4.93 1.6-8.5-.09-4.54.47-9.13 1.52-13.54.54-3.24.34-6.13.07-9.4l-.28-3.48c-.35-2.77-.35-2.77-1.35-3.77l.07 2.54c.14 7.81-.21 15.5-.94 23.27l-.24 2.6c-1.72 17.14-1.72 17.14-5.77 22.09l-1.49 1.9a17 17 0 0 1-5.63 3.6l.91-1.75c4.95-10.23 9.2-23.5 9.22-34.86v-8.45c-.03-7.16-.13-14.3-.57-21.44l-.12-1.95c-.17-2.46-.33-4.33-1.44-6.55-.31-2.85-.51-5.7-.72-8.57-.28-2.43-.28-2.43-1.28-3.43q-.06-2.5 0-5h-2c-1.5-3-1.06-5.66-1-9h-2l-1 2v-10h-2c-1.57-5.73-2.24-10.16-1-16l6 1 1-2-2-1z"/><path fill="#797880" d="M505 1641q98.25-.06 196.52-.08h2.94l94.16-.03h3.11q49.99 0 99.98-.04l120.1-.04 46-.02q23.1-.03 46.2-.02l27.46-.01a8553 8553 0 0 1 28.8-.01h9.5l5.04-.01c5.16.02 9.43.2 14.19 2.26-2.6 2.6-3.75 2.3-7.4 2.4l-1.74.05q-3 .08-6.01.14l-4.52.11c-25.82.6-51.66.69-77.5.85l-7.24.04c-74.06.48-148.12.55-450.39 0l-12.23-.06-51.32-.24q-38.33-.17-76.65-1.29z"/><path fill="#909193" d="M231 918a45 45 0 0 1 10.06 2.44c4.9 1.72 9.79 2.34 14.92 2.93 3.02.63 3.02.63 5.4 2.09 3.52 2.07 6.93 2.57 10.93 3.23l2.25.39q2.71.48 5.44.92v2l3-.12c2.69 0 4.94.29 7.52 1.03 5.32 1.39 10.72 2 16.17 2.65l8.76 1.08q2.55.36 5.1.87c2.35.47 4.64.75 7.03.95l7.85.7 2.59.24 2.32.2c2.22.33 4.22.8 6.38 1.4 4.57 1.18 9.01 1.4 13.71 1.56l2.66.11q4.17.18 8.35.33 5.5.2 11 .44l2.5.08 2.4.1 2.07.08c2.34.27 4.48.74 6.77 1.29 4.87 1.11 9.6 1.43 14.59 1.6l2.86.11c12.03.44 24.06.64 36.1.82l6.13.1c32.8.49 65.6.57 98.41.57q9.6 0 19.18.02a8630 8630 0 0 0 30.09.03c17.93.05 35.6-.54 53.46-2.24l2.36-.22q5.92-.55 11.83-1.22l3.16-.33c6.4-.7 12.62-1.79 18.9-3.1 6.3-1.3 12.61-2.13 18.99-2.9q5.07-.61 10.13-1.25l2.01-.26q8.2-1.05 16.37-2.35c6-.93 11.96-1.68 18-2.25 9.16-.87 18.23-2.27 27.31-3.68l2.2-.34 6.21-.98 1.91-.3c2.4-.38 4.43-.73 6.62-1.82q2.84-.26 5.69-.44A57 57 0 0 0 822 922c5.38-1 10.54-1.11 16-1-1.75 2.18-2.65 2.94-5.45 3.44l-2.67.12c-2.7.13-2.7.13-4.88.44l-1 2h15c-3.25 3.25-4.05 3.45-8.37 4.06-2.88.42-5.33.82-8.01 1.94-3.93 1.5-7.93 1.73-12.1 2.12-4.27.48-6.83 1.55-10.52 3.88a65 65 0 0 1-7.19 2.06l-2.1.52a133 133 0 0 1-17.9 2.7 174 174 0 0 0-15.87 2.35c-5.74 1.08-11.07 1.69-16.94 1.37v2l-2.24.3-10.45 1.45-1.92.27a480 480 0 0 0-27.45 4.68A124 124 0 0 1 679 959c2.2-2.2 2.63-2.25 5.56-2.5l3.44-.5 1-2h-21l1-2c6.57-2.14 13.88-2.54 20.73-3.3 5.17-.6 5.17-.6 6.27-1.7q-2.48-.09-4.94-.12l-2.77-.08c-2.71.17-4.85.53-7.45 1.18-5.42 1.27-10.74 1.4-16.28 1.52-7.05.16-13.96.62-20.96 1.45a428 428 0 0 1-28.24 2.29c-24.85 1.29-49.75 1.06-74.62 1.02h-14.26c-51.88.09-51.88.09-77.02-1.21l-2.52-.13-6.87-.4-1.98-.1c-3.44-.23-6.72-.7-10.09-1.42a173 173 0 0 0-7.94-.34l-5.5-.16-7.24-.22-3.24-.1a83 83 0 0 1-9.08-1.18q-2.31-.17-4.62-.25l-2.56-.1c-.44 0-.44 0-2.7-.09-6.9-.28-13.67-.78-20.5-1.76-4.53-.62-9.06-1-13.62-1.3l-1.9-.13a1171 1171 0 0 0-12.95-.8l-3.15-.2c-3.2-.4-5.96-1.3-9-2.37a92 92 0 0 0-5.75-.44 53 53 0 0 1-13.5-2.74c-3.36-1-6.74-1.67-10.19-2.32-4.44-.84-8.78-1.79-13.12-3.06-4.2-1.21-8.3-1.9-12.63-2.44-4.53-.58-8.67-1.36-12.87-3.22-2.3-.93-4.66-1.56-7.05-2.23-3.1-.9-6.15-1.97-9.2-3.05l-1.88-.64c-2.82-1-4.67-1.72-6.81-3.86"/><path fill="#828283" d="M1521 673c-1.1 3.31-1.42 3.55-4.25 5.23l-2.02 1.24-2.17 1.28-4.38 2.66-2.16 1.33a166 166 0 0 0-10.2 7.08C1494 693 1494 693 1492 693l-.77 1.84c-1.4 2.47-2.54 3.26-4.98 4.66-2.5 1.47-4.94 2.95-7.31 4.63a49 49 0 0 1-8.1 4.1c-6.13 2.56-11.8 5.87-17.45 9.32a182 182 0 0 1-6.2 3.58 40 40 0 0 0-5.75 3.93c-4.6 3.65-10.08 5.67-15.44 7.94l-4.81 2.06-2.27.98c-1.92.96-1.92.96-3.92 2.96l-2.12.81a40 40 0 0 0-6.76 3.63 56 56 0 0 1-9.59 4.93c-1.53.63-1.53.63-3.78 1.82-1.75.81-1.75.81-4.75.81l-1 3a45 45 0 0 1-10.95 6.09c-2.6 1.16-5.06 2.53-7.55 3.91a100 100 0 0 1-10.83 5.3c-1.9.8-3.71 1.7-5.55 2.64q-5.34 2.68-10.85 5.06-2.22.98-4.42 2.04a61 61 0 0 1-12.66 4.4c-9.27 2.2-17.51 6.18-25.94 10.5a178 178 0 0 1-22.37 9.38q-1.98.72-3.95 1.5c-3.65 1.42-6.85 2.52-10.8 2.74-3.92.23-3.92.23-6.13 2.44-2.6.41-2.6.41-5.62.63l-3.04.22-2.34.15c.74-1.95.74-1.95 2-4 1.95-.6 1.95-.6 4.13-.75l2.19-.17 1.68-.08v-3l-16 3c2.29-4.57 2.95-4.77 7.5-6.5l1.62-.63q2.43-.95 4.88-1.87l7.75-3.1c3.9-1.56 7.78-3.13 11.5-5.09 1.75-.81 1.75-.81 4.75-.81l1-3c1.54-.96 1.54-.96 3.63-1.81l2.45-1.03L1286 781l1.72-.68 7.62-3.03c4.77-1.9 9.45-3.98 14.11-6.14a254 254 0 0 1 14.9-6.04 112 112 0 0 0 13-5.84q1.8-.93 3.57-1.9a26 26 0 0 1 10.3-3.05c3.34-.6 6.26-2.19 9.28-3.7l3.78-1.83 1.86-.9c8.14-3.89 8.14-3.89 11.86-3.89l.5-1.81c2.34-3.42 5.67-3.92 9.5-5.19 4.98-1.84 9.7-3.75 14.25-6.5 4.4-2.56 8.89-3.85 13.79-5.07A32 32 0 0 0 1426 721c5.53-2.63 10.92-3.6 17-4l1-3 2.38-.31c2.62-.69 2.62-.69 3.53-2.22 1.09-1.47 1.09-1.47 3.04-1.76l2.17.1 2.2.08 1.68.11.13-1.75c1.08-2.78 2.62-3.66 5.12-5.18a72 72 0 0 0 5.31-3.7A33 33 0 0 1 1478 695q3.48-1.98 6.81-4.2a53 53 0 0 1 12.18-5.98c2.96-1.2 5.52-2.93 8.18-4.69 2.18-1.34 4.46-2.2 6.83-3.13l1-2a11.7 11.7 0 0 1 8-2"/><path fill="#595862" d="M436 1207h1c2.42 16.74 2.28 33.42 2.23 50.29v12.62q0 10.9-.02 21.8l-.02 31.53q0 25.58-.04 51.17l-.04 52.76v15.4L439 1570l-2 1c-2.18-32.99-2.42-65.96-2.59-99l-.14-21.98q-.15-22.5-.27-45.02a461 461 0 0 0-1.19 31.81l-.07 14.2-.08 15.52-.15 30.37-.24 48.72c-.14 26.48-.42 52.95-.93 79.42-1.32 69-1.32 69 .66 72.96q.65 4.65 1.16 9.33A38 38 0 0 0 436 1718c-.37 2.31-.37 2.31-1 4l-1-5h-2c-3.97-11.09-4.56-21.25-4.49-32.95l-.01-5.66q-.01-7.74.02-15.47.02-8.36 0-16.71 0-14.46.03-28.92a16562 16562 0 0 0 .03-39.9q0-34.9.06-69.79.06-32.94.06-65.9v-12.29l.01-2.04v-4.08a134586 134586 0 0 1 .16-168.1v-2.2c.02-4.88.02-4.88 1.13-5.99q.59-3.02 1.06-6.06c.1-.55.1-.55.54-3.35l.4-2.59h1l1 52 .26-23.18q.1-7.11.16-14.21a2870 2870 0 0 1 .18-15.83c.08-9.12.74-17.8 2.4-26.78"/><path fill="#131316" d="M1149 717c-5.6 2.3-11.18 4.43-16.93 6.29a58 58 0 0 0-5.32 2.15c-4.37 1.95-8.98 3.18-13.57 4.5-3.18 1.06-3.18 1.06-6.3 2.62a24 24 0 0 1-7.38 2.44c-3.99.8-6.93 2.1-10.5 4-3.1 1.12-6.2 2.06-9.37 2.94a75 75 0 0 0-7.5 2.56 68 68 0 0 1-10.84 2.97c-5.22 1.2-10.2 3.1-15.16 5.1-4.09 1.56-8.21 2.19-12.51 2.9-3.51.71-6.38 2.01-9.62 3.53l-4 1q-3.75 1.17-7.5 2.38a223 223 0 0 1-18.05 4.94c-2.45.68-2.45.68-4.49 1.7-2.21 1.1-4.15 1.5-6.59 1.92-6.09 1.14-11.9 2.94-17.8 4.85a102 102 0 0 1-16.8 3.68c-2.77.53-2.77.53-5.23 1.53-2.97 1.17-5.74 1.58-8.91 2-4.01.57-7.86 1.23-11.73 2.44a180 180 0 0 1-19.2 4.35l-2.17.4-4.24.72c-4.97.87-4.97.87-6.77 2.09-1.78 1.17-3.15 1.45-5.25 1.78l-4.46.72c-5.06.81-10 1.76-14.97 3.04a88 88 0 0 1-7.72 1.46q-5.43.85-10.81 1.94c-5.44 1.1-10.9 2.06-16.37 3-11.7 2-11.7 2-16.51 2.92a246 246 0 0 1-25.48 3.4c-3.74.33-7.27.75-10.89 1.74-4.77 1.28-9.58 1.62-14.5 2-5.15.4-10.15.83-15.18 2.04-5 1.19-9.89 1.34-15.01 1.44-4.24.15-8.26.66-12.41 1.52-12.2 2.53-24.55 3.34-36.96 4.19l-1.88.13c-22.98 1.6-45.9 2.04-68.93 2l-2.28-.01c-54.92-.1-54.92-.1-77.41-2.75l-3.43-.37c-11.3-1.24-22.58-2.7-33.7-5.07l-2.44-.51a129 129 0 0 1-16.22-4.74c-3.7-1.2-7.44-1.81-11.28-2.43A47 47 0 0 1 458 808v-2c-4.65-.81-9.31-1.44-14-2v-2l-2.46-.15c-10.25-.7-20-1.81-29.54-5.85l-2.01-.75c-1.93-.75-1.93-.75-4.99-2.25l-1-3a89 89 0 0 0-5-1.43c-3.36-.96-6.36-2.6-9.44-4.2l-1.93-.98L383 781v-2l-1.64-.11c-6.6-.61-6.6-.61-9.36-3.89l-2.28-.91a40 40 0 0 1-7.53-4.09l-2.4-1.56L358 767v-2h-2l-1.37-2.81A44 44 0 0 0 351 756c2.16.97 3.79 1.78 5.44 3.5 1.56 1.5 1.56 1.5 3.85 2.38 2.91 1.2 5.16 2.7 7.71 4.56a84 84 0 0 0 7.81 5.16L378 773v2l1.6.3c4.83 1.02 6.94 2.13 10.4 5.7 1.76.8 3.57 1.4 5.4 2.02 3.42 1.29 6.68 2.85 9.98 4.42 8.05 3.8 16.02 7.18 24.62 9.56l1 1c2.96.54 5.94.97 8.92 1.4 2.93.57 5.32 1.48 8.08 2.6q3.15.72 6.31 1.31l6.19 1.2c6.08 1.2 12.14 2.5 18.2 3.78 4.75 1 9.5 1.97 14.3 2.71v2l1.65.17q7.01.74 13.98 1.86c72.71 11.48 154.14 9.07 227.01-1.06l1.8-.25c2.65-.38 5-.87 7.56-1.72q3.15-.25 6.34-.4l3.88-.2 2.05-.1c10.9-.58 21.45-1.48 32.14-3.77 6.6-1.4 13.2-2.31 19.9-3.1a321 321 0 0 0 25.98-4.1c3.57-.7 7.17-1.24 10.77-1.8 9.5-1.7 18.87-4.13 28.26-6.32A535 535 0 0 1 894 788l3.06-.6 4.81-.94C904 786 904 786 907 785q2.1-.1 4.19-.06l2.17.02 1.64.04v-2l3.04-.73c7.71-1.89 15.3-3.94 22.87-6.33 8.92-2.71 18.06-4.68 27.18-6.59 5.06-1.1 9.86-2.2 14.6-4.29 5.45-2.34 11.1-3.47 16.9-4.67 3.34-.69 6.6-1.4 9.83-2.5 2.58-.89 2.58-.89 4.58-.89v-2l3.21-.62 4.16-.82 2.12-.4 2.03-.4 1.88-.37c1.6-.39 1.6-.39 3.6-1.39q3-.06 6 0v-2a27 27 0 0 1 9.19-3.06c4.7-.84 4.7-.84 5.81-1.94q3.3-.78 6.63-1.44c7.78-1.6 7.78-1.6 11.12-2.62 2.74-.8 5.39-1.36 8.19-1.88a61 61 0 0 0 11.2-3.33A36.4 36.4 0 0 1 1105 732v-2l9.38-3 2.69-.87 2.58-.82 2.38-.76c1.97-.55 1.97-.55 3.97-.55v-2l2.59-.84 3.35-1.1 1.7-.55c4.25-1.4 4.25-1.4 5.36-2.51a58 58 0 0 1 3.94-.62l2.15-.3c1.91-.08 1.91-.08 3.91.92"/><path fill="#010105" d="M1241 1612c.68 1.79.68 1.79 1 4-4.76 7.3-13.8 12.6-22 15l-2 2c-15.84 5.1-34.53 3.15-50.97 3.14h-10.64q-10.54.03-21.07.02l-25.25.01-89.85.04-98.85.03h-27.99q-50.13 0-100.26.04-53.04.04-106.07.04h-14.54q-23.06 0-46.1.03-23.18.02-46.34.01-13.77 0-27.52.02h-38.41l-5.01.01c-7.6-.05-14.9-.97-22.13-3.39l-1-2a122798 122798 0 0 1 100.56-.08l42.54-.03c17.9-.02 35.75-.04 53.63.77 8.15.36 16.25.38 24.41.18l4.1-.07 10.46-.22a5476 5476 0 0 1 22.8-.49l3.56-.07 3.12-.07c2.82.08 2.82.08 5.08.61 3.22.55 6.19.34 9.42.04 8.82-.7 17.6-.7 26.45-.68h5.36l14.61.02 15.8.02q13.68 0 27.35.03l58.05.04h1.87l43.93.03h1.94l46.77.05 9.73.01h5.82l64.17.06h10.87l28.66.03a19478 19478 0 0 0 44.4.04 4123 4123 0 0 0 19.45.02c18.74.08 18.74.08 26.09-2.24l2-2c2.32-.56 4.62-.74 7-1l1-3q2.47-1.55 5-3 2.46-2.04 4.81-4.19l2.4-2.17z"/><path fill="#a498bd" d="m911.69 540.94 3 .02 2.31.04c-1.32 2.63-2.27 2.92-5 4q-1.8.3-3.62.5c-3.38.5-3.38.5-5.38 2.5-3.12.13-3.12.13-6 0v2a490 490 0 0 1-49.96 17.36C845 568 845 568 843 569q-2.71.24-5.44.38a68 68 0 0 0-15.28 3C820 573 820 573 817 573v2l-13 2v2l-2.58.45c-7.64 1.39-15 3.24-22.42 5.55a187 187 0 0 1-23.13 5.66q-3.32.61-6.63 1.3a448 448 0 0 1-15.99 2.85l-6.32 1.05-3.38.55a3714 3714 0 0 0-21.04 3.53c-12.07 2.04-24.12 3.94-36.32 5-6.21.58-12.31 1.34-18.44 2.5-21.42 3.94-42.04 4.98-63.75 4.56v-1l1.93-.04c11.98-.3 23.8-.84 35.7-2.4l2.84-.37c5.01-.7 9.7-1.72 14.53-3.19 2.44-.12 2.44-.12 4 0v-2c5.32-1.28 10.67-1.36 16.11-1.6l2.25-.12 2.05-.1c1.59-.18 1.59-.18 2.59-1.18l-8-1v-1l3.49.04c8.7.06 17.38-.04 26.07-.48l2.47-.12 2.29-.12 2.01-.11C696 597 696 597 698 596q2.36-.3 4.73-.5l2.88-.25 3.01-.25c6.48-.55 12.93-1.14 19.38-2v-2l12-2v-2h-7l1-3c1.93-.88 1.93-.88 4.44-1.56l2.7-.74q6.4-1.54 12.86-2.7c-11.38-.42-22.56.27-33.87 1.38l-2 .19c-6.33.61-12.58 1.32-18.84 2.43a156 156 0 0 1-18.29 1.82l-3.38.18-7.06.38-10.49.56c-25.47 1.39-50.93 2.3-76.45 2.31h-1.84c-20.62.02-41.2-.5-61.8-1.47q-3.04-.14-6.1-.27-4.8-.21-9.62-.47-2.59-.13-5.18-.24a61 61 0 0 1-16.9-3.12c-4.22-1.32-8.62-1.5-13.01-1.85a33 33 0 0 1-11.52-3.13c-3.44-1.45-7.2-1.62-10.88-2.08-2.96-.66-4.5-1.63-6.77-3.62 5.6-.25 10.38.35 15.75 1.88 6.69 1.85 13.4 3.06 20.25 4.12l3.28.52 10.16 1.54 3.4.53A272 272 0 0 0 516 583v2a50150 50150 0 0 0 63.5.08l26.84.03h10.36l3.13.01h5.31C627 585 627 585 628 584q4.08-.18 8.15-.21l5.2-.08 2.76-.04c16.2-.25 32-1.1 48.05-3.3 4.75-.62 9.51-1.02 14.28-1.43a157 157 0 0 0 17.62-2.45c3.17-.53 6.3-.81 9.5-1.09a258 258 0 0 0 27.5-3.96l1.74-.34c9.48-1.86 9.48-1.86 13.2-3.1q2.21-.18 4.42-.25l2.61-.1 5.4-.17A54 54 0 0 0 803 565c2.34-.41 4.7-.67 7.06-.94a178 178 0 0 0 27.69-5.68 64 64 0 0 1 12.75-2.13c4.22-.42 8.35-1.34 12.5-2.25v-2l2.67-.37 3.52-.5 1.75-.24c3.62-.54 6.7-1.48 10.06-2.89q2.55-.35 5.11-.66c2.78-.5 5.29-1.54 7.9-2.58 2.95-1.13 5.95-1.93 8.99-2.76 3.15-1.05 5.39-1.1 8.69-1.06"/><path d="M512 1822h748v4l-212.95.04h-3.18l-325 .05h-18.83l-99.9.02-49.63.01h-33.18C515 1826 515 1826 512 1825z"/><path fill="#303033" d="M321 642.88c3 .12 3 .12 4 1.12-.48 6.48-.48 6.48-2.45 8.44-5.9 5.94-6.28 19.5-7.55 27.56h-2c.08 12.92.17 24.3 6 36l1.44 3.75c1.58 3.65 3.87 6.81 6.16 10.04 1.4 2.21 1.4 2.21 2.3 4.72 1.37 3.1 3.08 4.56 5.66 6.74l4.1 3.54c2.9 2.6 5.57 5.43 8.3 8.21a58 58 0 0 0 5.54 4.75c2.73 2.46 3.3 3.64 3.5 7.25h2v2l2.19.75c3.07 1.36 5.2 3.16 7.81 5.25a41 41 0 0 0 5 2v2l10 2v2l2.52.79c3.86 1.29 7.53 2.94 11.23 4.65l2.04.9 1.94.9q.86.4 1.76.8c1.51.96 1.51.96 2.53 2.5 1.7 2.52 4.74 2.83 7.54 3.59 6.34 1.77 6.34 1.77 7.44 2.87q1.8.44 3.6.75l2.18.4 2.28.41 2.3.42L436 801v4h-9v3c5.6 2.87 11.02 3.73 17.2 4.47 2.8.53 2.8.53 4.8 2.53 1.8.6 3.6 1 5.44 1.44C456 817 456 817 457 819c2.08.67 2.08.67 4.81 1.25l3.01.65 3.18.66 3.08.67a132 132 0 0 0 15.98 2.55c2.05.23 4 .66 6 1.16 5.56 1.3 11.19 1.89 16.85 2.56q5.37.66 10.71 1.5c6.82 1 13.65 1.63 20.51 2.21q6.6.56 13.18 1.24a592 592 0 0 0 34.88 2.66l2.67.13c20.96.97 41.9 1.07 62.88 1.02h15.7c19.44.04 38.84-.04 58.25-1.21l2.15-.13c5.8-.37 11.46-.76 17.16-1.92q2.19-.2 4.39-.3l2.53-.12 2.7-.12 2.8-.13 5.89-.26 14.68-.67 2.68-.12q7.65-.34 15.33-.28v1l-2.65.24-20.6 1.9q-1.05.11-2.15.21-6.6.63-13.18 1.42a419 419 0 0 1-31.54 2.36l-2.99.12c-22.65.93-45.31.94-67.98.94h-5.77c-66.67.03-66.67.03-90.67-2.92q-5.92-.71-11.85-1.36l-2.3-.26a232 232 0 0 0-18.07-1.21c-8.48-.27-16.58-1.48-24.91-3.02-4.3-.77-8.6-1.4-12.9-2.04l-2.45-.38q-5.8-.88-11.6-1.47a80 80 0 0 1-12.01-2.34l-2.07-.54-4.22-1.1q-3.03-.8-6.07-1.56a182 182 0 0 1-19.63-6.1c-3.1-1.15-6.2-2.04-9.39-2.89v-2l-2.87.25c-4.36.02-7.81-1.53-11.76-3.23a58 58 0 0 0-5.8-2.14c-2.57-.88-2.57-.88-4.07-1.94-2.33-1.46-4.81-1.58-7.5-1.94v-2l-2.37-.69c-3.95-1.3-7.54-3.25-11.22-5.15a36 36 0 0 0-5.97-2.28L370 791l-1-3c-2.56-1.19-2.56-1.19-5-2v-2l-5-1v-2h-3v-2l-1.69-.19c-2.74-.96-4.14-2.52-6.1-4.62C347 773 347 773 345 772.06c-2.61-1.39-3.98-3.3-5.75-5.62-1.25-1.44-1.25-1.44-2.9-2.5L335 763v-2h-2l1-3h2l.44-1.94C337 754 337 754 338 753l-2-1v-2h-2l-1-4h-3l-.31-1.94L329 742l-3-1c-1.69-1.37-1.69-1.37-3-3v-3l-4 2v-7l2-1c-2.17-2.5-3.73-3.44-7-4l1-4 2 1q-.37-.82-.77-1.65c-3.67-8.04-6.43-15.52-7.23-24.35l-.15 1.9-.23 2.48-.2 2.46C308 705 308 705 306 707v-33h2v7h2l.08-1.62c.41-5.47 1.7-9.93 3.88-14.96a95 95 0 0 0 2.54-6.73c1.26-3.46 3.18-5.83 5.5-8.69.75-2.81.75-2.81 1-5-2.83.4-3.85.83-5.81 3-3.03 2.77-5.16 2.69-9.19 3 1.47-2.65 2.75-3.56 5.56-4.62 6.35-2.46 6.35-2.46 7.44-2.5"/><path fill="#5f5e67" d="M1235 1630c-2.25 5.01-5.15 7.47-10 10q-3.48 1.2-7.01 2.25c-1.99.75-1.99.75-3.99 2.75-9.23 3.07-18.34 3.44-27.97 3.43q-2.22 0-4.43.03-6.06.04-12.11.04l-13.21.06-26.17.1q-10.87.02-21.75.07-48.18.19-96.36.27h-1.82c-62.21.11-124.43.15-213.63.13H554.74c-4.63-.02-4.63-.02-5.74-1.13-7.8-1.63-16.11-1.2-24.04-1.33-22.35-.42-22.35-.42-31.71-1.98l-1.97-.32c-4.85-.95-8.96-3.02-13.28-5.37l-3-1.62-2-1.38v-2c4.1.3 7.76.73 11.5 2.5 7.26 3.38 15.7 2.65 23.52 2.64h3.65l10 .01 10.9.02 21.59.02 25.87.02 83.24.07q55.09.05 110.17.06l25.52.01h3.2q51.4.01 102.77.09a99289 99289 0 0 0 120.62.07h2.97q23.62.01 47.26.05 23.76.04 47.51.03 14.1 0 28.21.03h18.88q5.36-.02 10.73.01c17.37.1 33.51-.37 49.12-8.85 1.77-.78 1.77-.78 4.77-.78m-730 11-1 4a3236 3236 0 0 0 79.5 1.3l12.05.06 50.42.23c64.1.3 128.22.47 451.05-.01l7.19-.05c55.66-.35 55.66-.35 77.43-.89l4.33-.1q2.81-.05 5.63-.15l3.06-.08c2.34-.31 2.34-.31 4.34-2.31-5.75-2.48-11.29-2.28-17.48-2.25h-23.89l-18.58.01-27.63.01q-23.23 0-46.47.02l-46.02.02h-17.41q-51.45 0-102.92.04l-99.98.04h-34.06z"/><path fill="#1c1c21" d="M1233 86c3.11 1.5 6.05 3.2 9 5v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l1.94.69c2.06 1.31 2.06 1.31 2.81 3.94l.25 2.37h2l1-6h3l.25 3.38c.26 1.93.26 1.93.75 3.62 2.06 1.44 2.06 1.44 4 2-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v-12h3l1 11-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v-12h3l1 11-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v-12h3l1 11-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v-12h3l1 11-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v-12h3l1 11-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l1.96.72c2.04 1.28 2.04 1.28 2.54 3.37l.06 2.41c.07 2.4.07 2.4.44 4.5q1.49 1.03 3 2c.4 2.39.14 4.56 0 7l4 2v-10h3l1 9-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.37.69c-1.63 1.31-1.63 1.31-2.57 3.87-1.06 2.44-1.06 2.44-3.06 3.38l-2 .06c-2-2-2-2-2.12-4.62l.12-2.38-4-1v-7l-4-1v-7l-4-1v-7l-4-1-.49-5.18c-.51-1.82-.51-1.82-2.01-3.26-1.97-2.05-1.98-3.44-2.19-6.25l-.2-2.45-.11-1.86-3-1v-8h-4v-8h-3l-.33-1.86-.48-2.45-.46-2.43c-.73-2.26-.73-2.26-2.3-3.82-1.43-1.44-1.43-1.44-1.72-3.26l.29-5.18-4-2v-7h-3l-.59-2.12-.79-2.75-.77-2.75C1301 236 1301 236 1299 234c-.27-2.36-.09-4.62 0-7l-4-1-1-7h-4l-1-9-3-1v-10l-3-1v-8l-1.94-.5c-2.06-1.5-2.06-1.5-2.55-3.41a85 85 0 0 1-.51-6.09h-4l-.15-1.93-.22-2.5-.22-2.5-.41-2.07-2-1v-7h-5l-.15-2.12-.22-2.75-.22-2.75C1265 153 1265 153 1263 151q-.7-2.46-1.37-4.92c-1.42-4.68-3.15-9.6-6.63-13.08-.12-2.12-.12-2.12 0-4l-1.87-.62c-2.98-1.93-3.16-4.06-4.13-7.38l-2-2c-.12-2.62-.12-2.62 0-5l-4-2v-5l-4-1-1-7-3-1v-6h-2zm20 28 1 4 4 1v-6c-1.72 0-3.34.57-5 1"/><path fill="#1e1d22" d="m1234 695 3 1c-3.85 3.33-8 5.84-12.37 8.42a158 158 0 0 0-7.38 4.77c-4.98 3.35-10.08 5.75-15.6 8.06-1.65.75-1.65.75-4.09 2.25a46 46 0 0 1-8.79 3.75c-1.77.75-1.77.75-3.77 2.75-1.66.75-1.66.75-3.62 1.44l-2.17.77-2.21.79-4 1.5q-3.01 1.13-6.04 2.25-5.52 2.03-10.91 4.37-4.02 1.71-8.05 3.38a828 828 0 0 0-9.94 4.19q-2.7 1.16-5.43 2.25-2.28.91-4.52 1.93a135 135 0 0 1-16.5 6.2A23 23 0 0 0 1106 758l-2.37-.06c-2.63.06-2.63.06-4.82 1.37-3.36 2.02-6.65 2.92-10.4 3.94-2.3.72-4.27 1.66-6.41 2.75q-1.67.55-3.37 1a95 95 0 0 0-11.71 3.88c-6.34 2.55-12.52 3.88-19.23 5.06-6.21 1.09-12.07 2.3-17.94 4.62-8.36 3.26-17.73 5.44-26.75 5.44v2l-2.12.33a75 75 0 0 0-18.59 5.42c-9.2 3.83-19.8 5.24-29.69 6.04-1.6.21-1.6.21-3.6 1.21q-2.06.1-4.12.06l-2.2-.02L941 801v2c-11.52 3.44-11.52 3.44-17 3v3l-2.74.4-5.38.8c-2.74.42-5.23.92-7.88 1.8-3.03.27-6.06.44-9.09.62-2.87.37-4.38 1.1-6.91 2.38-2.29.45-4.5.82-6.81 1.13l-1.8.26c-4.16.57-8.19.69-12.39.61v2a763 763 0 0 1-56.27 6.91 150 150 0 0 0-18.58 2.67c-4.13.8-8.27.83-12.46.98a35 35 0 0 0-8.42 1.9c-5.5 1.74-10.85 2.27-16.58 2.66l-5.74.45-2.52.18c-2.43.25-2.43.25-4.72.76-2.87.52-5.51.62-8.43.62l-3.46.02h-7.76l-17.65.02-23.86.02q-11.02 0-22.05.02a8639 8639 0 0 1-30.37.03c-16.68.05-33.09-.34-49.7-2.01-3.73-.35-7.46-.58-11.2-.8l-2.07-.11q-3.75-.23-7.48-.42c-7.7-.42-15.16-1.1-22.68-2.9v-1l3.01.18c64.93 3.92 131.53 7.68 196.4 1.54 5.48-.5 10.91-.82 16.4-.96 17.5-.46 34.78-3.09 52.1-5.47q4.26-.59 8.53-1.15a504 504 0 0 0 29.86-5.04c5.89-1.15 11.8-2.19 17.72-3.22q15.88-2.78 31.66-6.14a411 411 0 0 1 17.26-3.3 301 301 0 0 0 20.7-4.4c5.36-1.3 10.74-2.47 16.12-3.65q6.13-1.34 12.2-2.9 3.14-.74 6.3-1.4c5.63-1.24 11.14-2.89 16.68-4.53l3.09-.9q7.14-2.1 14.27-4.27a196 196 0 0 1 11.95-3.14c9.4-2.28 18.55-5.28 27.74-8.27q7.91-2.57 15.82-5.1l3.18-1.03a561 561 0 0 1 20.62-6.22 75 75 0 0 0 10.78-3.95c2.22-.94 4.5-1.53 6.82-2.16 1.81-.53 3.54-1.15 5.29-1.83 4.68-1.8 9.45-3.35 14.22-4.89a58 58 0 0 0 9.28-3.8q1.77-.72 3.56-1.37l2.1-.78 4.95-1.8c8.26-2.97 16.37-6.2 24.45-9.61 4.16-1.75 8.3-3.3 12.61-4.57 3.58-1.34 6.93-3.13 10.33-4.87q1.96-.99 3.93-1.96l1.96-.98 13.2-6.62 2.6-1.32 2.2-1.11c2.33-1.12 4.7-2.07 7.11-3.01 4.82-1.95 9.3-4.04 13.54-7.11A72 72 0 0 1 1234 695"/><path fill="#4d4c56" d="M1260 1482h7c1.14 3.42 1.14 6.46 1.13 10.02v13.17c.01 6.54-.09 13.02-.63 19.55-.69 8.6-.65 17.19-.63 25.8v8.45c0 9.12.08 18.17.96 27.25q.18 2.37.17 4.76l-2 2-1-4c-2.65 2.65-2.32 4.18-2.5 7.88-.2 3.95-.48 6.7-2.5 10.12l-1.12 2.16c-3.33 5.91-7.25 10.62-11.9 15.5a47 47 0 0 0-7.88 10.82c-1.42 1.97-2.9 2.52-5.1 3.52a129 129 0 0 0-3.19 2.5c-3.4 2.48-6.39 3.3-10.49 4.04a62 62 0 0 0-8.89 2.68l-2.43.78-3.15 1.03c-6.83 1.68-13.96 1.24-20.95 1.22h-33.36l-29.09-.01-32.74-.01q-33.03 0-66.04-.03-28.56-.01-57.1-.01h-16.13a80825 80825 0 0 1-364.94-.9 3771 3771 0 0 1-71.93-.89l-2.33-.03A45 45 0 0 1 498 1647c-2.17-.32-2.17-.32-3.96-.41l-1.99-.12-1.99-.1-2.07-.11q-2.5-.14-4.99-.26v-2l-1.68-.3a44 44 0 0 1-6.32-1.7c-1.12-1.81-1.12-1.81-2-4a66 66 0 0 0-4.76-3.75C467 1633 467 1633 467 1630c4.62.44 7.47.75 11 4h-5v2l2 .75c2.35.98 4.5 2.03 6.73 3.22a38 38 0 0 0 11.52 3.72l2.2.36c10.52 1.52 21.25 1.48 31.87 1.66 20.75.36 20.75.36 22.68 2.29q3.4.14 6.81.13h45.45a41871 41871 0 0 0 58.84 0h137.38c62.67.01 125.34 0 213.52-.13h1.82a42935 42935 0 0 0 119.33-.35l25.85-.1q6.5-.05 13-.05 5.93 0 11.85-.06h4.25c21.7.02 21.7.02 29.9-6.44l3.25-.81c5.07-1.3 9.9-4.65 12.98-8.88l.77-1.31-2.7 1.35-3.55 1.71-1.78.9c-2.64 1.26-4.44 2.1-7.4 1.72l-1.57-.68 1.8-.81c7.02-3.32 13.09-7.42 19.2-12.19l2.37-1.82c3.84-3.13 6.34-5.7 7.98-10.4 1.15-3.13 2.94-5.93 4.65-8.78h2l.15-2.05.22-2.83.22-2.74c.33-2.73.82-5.27 1.42-7.96 1.05-5.22 1.3-10.23 1.3-15.55l.04-2.9.06-9.38.06-6.55q.08-8.58.13-17.16l.14-17.53q.14-17.17.26-34.35"/><path fill="#3e3d43" d="m898.07 899.9 2.5.04 2.5.02 1.93.04c-3.85 3.94-8.56 3.77-13.75 4.19l-2.7.25q-3.27.3-6.55.56v2c-2.85.95-4.98 1.2-7.96 1.38-6.12.52-11.8 1.94-17.73 3.56-7.7 2.02-15.2 3.64-23.13 4.44a60 60 0 0 0-12.78 2.83c-7.85 2.58-15.72 4.05-23.9 5.02-4.77.6-9.2 1.74-13.8 3.12-3.9.94-7.78 1.11-11.78 1.24-2.92.41-2.92.41-6.01 1.83-4.34 1.9-8.68 2.6-13.34 3.3l-2.73.43q-4.35.68-8.71 1.35l-6.01.94a787 787 0 0 1-34.33 4.64c-5.44.6-10.79 1.4-16.16 2.48-13.04 2.6-26.12 3.54-39.39 3.98l-1.9.07c-19.46.63-38.92.59-58.38.58h-9.72c-45.76 0-91.5-.92-137.24-2.19l1-2 1.96-.03 74.7-1.22a30295 30295 0 0 1 89.11-1.46l15.95-.24c8.83-.15 17.48.1 26.28.95v2l19-2v-2h-12v-2l30-1v-3l27-1 1-3 14.56-.09h3.12l2.87-.02C734 930 734 930 737 931c2.26-.12 2.26-.12 4.69-.44l2.45-.3L746 930v-2l-7-1v-1l1.9-.04 8.48-.27 2.98-.07 2.85-.1 2.64-.09c2.15-.43 2.15-.43 3.17-1.94L762 922c2.02-.38 2.02-.38 4.46-.4l2.68-.07 2.8-.03q2.73-.03 5.46-.1l2.46-.02c2.64-.47 3.3-1.5 5.14-3.38 2-.43 2-.43 4.23-.51l2.43-.1 2.53-.08 2.56-.1q3.13-.12 6.25-.21v-3l8.25-.44 2.36-.12 2.28-.12 2.1-.11c2.08-.22 4-.66 6.01-1.21v-2q4.4-.04 8.81-.06l2.53-.03h2.43l2.24-.02C842 910 842 910 845 911c2.04-.12 2.04-.12 4.19-.44l2.17-.3L853 910v-2l-9-1v-1l18-1 1-3 14-1v2l-5.37.59-1.63.41-1 2a712 712 0 0 0 9.08-1.37c2.92-.63 2.92-.63 4.46-1.67 2.24-1.47 4.43-1.36 7.09-1.52 3.22-.21 5.56-1.4 8.44-1.54"/><path fill="#494752" d="m1287 940 1 2-1 2 2 1-.56 1.88c-.4 2.84-.33 4.87 0 7.68.91 8.21 1.55 16.02-1.45 23.84-1.31 3.45-2.4 6.95-3.49 10.48l-.65 2.08A382 382 0 0 0 1278 1008h-2l-1.14 19.73c-.41 7.14-.84 14.27-1.44 21.4-1.49 18.48-1.6 36.94-1.6 55.47v4.27c-.02 15.8.05 31.56 1.1 47.34q.12 2.89.08 5.79c9.68 2.47 18.98 4.61 29 5v1q-5.85.09-11.69.13l-3.34.05q-1.6 0-3.23.02l-2.97.03c-2.95-.25-5.07-1.05-7.77-2.23-2.76-.31-5.5-.44-8.28-.56-3.47-.56-5.06-1.08-7.72-3.44-.78-3.31-.66-6.6-.62-9.98l-.01-3.12q-.01-4.22.02-8.45.02-4.45.02-8.9 0-7.5.05-14.99.04-8.6.04-17.19a3576 3576 0 0 1 .03-23.66c.01-21.01.01-21.01 1.75-31.35l.33-1.98c.8-4.07 2.23-7.25 4.42-10.76 1.33-2.22 2.27-4.54 3.25-6.92 3.3-7.8 3.3-7.8 5.72-10.7h2l.11-1.72.2-2.34.18-2.29a22 22 0 0 1 1.94-5.85c1.63-3.71 2.45-7.34 3.14-11.32l.38-2.14q.4-2.22.78-4.46.6-3.33 1.2-6.64a166 166 0 0 0 2.69-24.48c.16-4.55.98-8.41 2.38-12.76"/><path fill="#020208" d="M509 1774h700l-1 3c-76.65.5-153.3.97-396.98 1.13H652.9c-49.31.02-98.6-.1-147.9-1.13l4-1z"/><path fill="#bf94e8" d="M392 570h1l.09 10.7v2.29l.02 2.1C393 587 393 587 392 590a34 34 0 0 0 1 4.38c.87 3.54 1.15 6.06 0 9.62h-1l-1-4-1 5h-2l1 3c1.13 4 1.64 7.87 1 12-1.44 1.69-1.44 1.69-3 3-.87 2.76-1.15 4.44-.37 7.25.5 3.68-.67 6.17-1.93 9.6-.81 2.5-.84 4.56-.7 7.15-.53 4.16-2.14 7.27-4 11v3l-.22 5.32-.14 3.12-.14 3.25-.14 3.28L379 684h3l-.07 1.54c-.12 4.63.2 8.2 2.07 12.46l2 1c.85 1.63.85 1.63 1.63 3.56l.78 1.94.59 1.5 2-5h1l1 8 .04-3.18q.1-5.84.22-11.66l.09-5.06q.05-3.62.14-7.25l.02-2.29c.06-2.12.06-2.12.49-5.56q1.47-1.05 3-2c.73-2.6.73-2.6 1.19-5.75l.48-3.1c.32-3.08.39-6.06.33-9.15h-1l-1-17-2 1q-.08-3.37-.12-6.75l-.06-1.92c-.03-3.77.47-6.04 2.18-9.33.25-2.25.41-4.43.5-6.69.82-14.45.82-14.45 4.07-18.05L403 594l1 2 3-5 3 3 1-1c5.16-.3 9.1.4 14 2v2l2 1c-3.06-.43-5.76-.9-8.62-2.06-2.39-.94-3.86-1.02-6.38-.94l-.17 1.62-1.3 12.25-.23 2.25C410 613 410 613 409 615q-.37 4.2-.6 8.39c-.3 4.5-.3 4.5-1.4 5.61a215 215 0 0 0-1.2 10.24l-.53 5.14-.8 7.67a500 500 0 0 0-2.53 55.01l.01 6.13L402 728q-1.73-.39-3.44-.81l-1.93-.46L395 726l-1-3-2.12-.31c-4.54-1.09-9.04-2.86-11.88-6.69q-1.1-2.97-2-6l-1-2h3l-.84-2.52c-3.62-12.24-3.7-24.41-3.74-37.07l-.03-2.23v-4.2c-.02-1.9-.02-1.9-.39-4.98-1.52-1.44-1.52-1.44-3-2l1-3q.17-2.6.3-5.2c.23-5.73.6-10.7 2.95-16.01.8-1.93 1.22-3.74 1.63-5.79.87-4.3 2.2-8.4 3.56-12.56a124 124 0 0 0 4.57-19.3c.8-4.85.8-4.85 2.01-6.67 1.55-2.33 1.34-4.7 1.48-7.47.32-6.63.32-6.63 2.5-9"/><path fill="#7a4ac0" d="M694 602a57 57 0 0 1-13.3 3.07l-2.07.26-4.32.53q-3.26.4-6.52.82l-4.22.52-1.94.24c-4.56.54-9.04.65-13.63.56v2l-12 2 10 1v1q-3.66.3-7.31.56l-2.1.17-2.02.15-1.85.14C631 615 631 615 628 614l2 4-1.87.03-26.47.47-10.33.18-3.25.05-3.04.06-2.66.05c-3.7.25-5.71.5-8.38 3.16l-19 1-1 48h-2v-34c-.95 7.59-1.36 15-1.56 22.63l-.07 2.25c-.49 18.3-.48 36.6-.5 54.89v7.27a4336 4336 0 0 0 0 21.9v3.41C550 752 550 752 551 754c1.63.63 1.63.63 3.56 1.13l3.44.87v2l1.51-.09a711 711 0 0 1 42.31-1.04h19.81c17.29.05 17.29.05 19.37 2.13q2.52.34 5.06.56l4.94.44v2h-17v2h-88a45 45 0 0 1-1.13-9.62v-82.62a859 859 0 0 1 .7-38.6c.83-18.56.83-18.56 2.43-20.16 7.04-1.34 14.3-1.15 21.44-1.18l7.35-.06 1.92-.02c26.64-.23 52.63-1.32 78.81-6.73 7.68-1.56 15.5-2.1 23.3-2.79q3.07-.32 6.12-.8c2.8-.39 4.43-.37 7.06.58"/><path fill="#84828b" d="M425 1281h1a67 67 0 0 1 1.12 13.28v44.25l-.03 54.7v10.61a176704 176704 0 0 1-.04 123.47v60.89L427 1706l-4 2c-1.43-2.86-1.14-5.66-1.12-8.79v-44.28l.03-54.78v-10.61a177117 177117 0 0 1 .04-123.63v-60.94L422 1287h1l1 9z"/><path fill="#40256e" d="m1003 513 .81 2.98c1.2 4.2 2.7 8.29 4.19 12.4 1.9 5.25 3.54 10.36 4.57 15.85.43 1.77.43 1.77 1.93 4.7 1.7 3.46 2.23 6.57 2.9 10.34.87 3.95 2.31 7.62 3.8 11.38.8 2.35.8 2.35.8 5.35h2c1.85 5.8 3.4 11.55 4.53 17.53.73 3.84 1.6 7.65 2.47 11.47h2c2.34 4.48 4.2 9 5.94 13.75 1.68 4.57 3.39 9.12 5.25 13.63a96 96 0 0 1 4 12.5c.81 2.12.81 2.12 2.3 3.55 2.11 2.2 2.37 4.24 3.01 7.2 1.05 4.63 2.33 9 4.05 13.43.58 2.48.6 3.65-.55 5.94-2.58 1.27-2.58 1.27-5.81 2.25l-3.21 1.02c-2.5.61-4.43.9-6.98.73l-1 3c-1.75.8-1.75.8-4 1.44-6 1.83-11.57 4.32-17.16 7.16-8.85 4.42-17.74 7.27-27.27 9.86a416 416 0 0 0-19.85 6.1c-8.85 2.86-17.66 5.33-26.83 6.91a165 165 0 0 0-26.86 7.24c-7.12 2.5-14.38 3.93-21.77 5.4-7.64 1.58-7.64 1.58-11.34 3.44-4.17 2.07-8.34 2.48-12.92 3.08l-5.25.72-2.48.33q-4.15.6-8.27 1.32c6.31-6.31 19.75-7.15 28.18-8.5 3.38-.6 6.53-1.5 9.82-2.5q3.58-.7 7.18-1.34L903 722l1-3h-13v-1l3-.11 3.88-.2 1.97-.07c1.9-.1 1.9-.1 5.15-.62l1-1.51 1-1.49c3.02-.38 5.91-.48 8.95-.53 1.86-.06 1.86-.06 5.05-.47l1-1.52 1-1.48c2.47-.22 4.67-.28 7.13-.19l2 .04 4.87.15 1-4 15-1v-3l11-1 1-3 13-1v-3l2.34-.11 3.03-.2 3.03-.18 2.6-.51.97-1.5L991 690c2.82-.51 2.82-.51 6.13-.69l3.32-.2 2.55-.11 1-3 2.55-.37 3.33-.5 3.3-.5C1016 684 1016 684 1018 682c2.38-.2 2.38-.2 5.13-.12l2.75.05 2.12.07 2-4h10l1-4 3.38-.31c3.44-.47 3.44-.47 4.93-2.25l.69-1.44h2a112 112 0 0 0-6.06-21.12l-.78-1.94A45 45 0 0 0 1041 639c-.53-1.73-.53-1.73-.94-3.62-1.99-8.6-5.32-16.74-8.63-24.9-1.66-4.18-2.9-8-3.43-12.48h-2l-.75-3.31a105 105 0 0 0-4.45-13.17 16 16 0 0 1-.8-6.52h-2l-.31-3.19a13.5 13.5 0 0 0-2.13-6.18c-1.85-3.12-2.39-5.91-3.03-9.45-.53-2.18-.53-2.18-1.55-4.2a16 16 0 0 1-1.6-6.23l-.23-2.14-.15-1.61h-2l-.52-1.98A306 306 0 0 0 1000 519l-1 3-2.52.11-3.3.2-3.26.18c-3.25.57-3.76 1.2-5.92 3.51-2.26.51-2.26.51-4.69.69l-2.45.2-1.86.11-1-2 8-1v-2c-7.2.52-7.2.52-10.37 2.44-6.5 3.86-15.23 3.96-22.58 5.06-3.16.52-6.2 1.23-9.28 2.07-1.84.45-3.64.7-5.52.93-3.25.5-3.25.5-5.44 1.63-1.81.87-1.81.87-4.06.5L923 534a38 38 0 0 1 12.31-3.87c6.21-.92 12.1-2.55 18.1-4.37a57 57 0 0 1 13.44-2.33c2.8-.56 4.72-1.94 7.15-3.43a90 90 0 0 1 5.56-1.82l3.17-.93 3.27-.94 3.27-.97c8.8-2.54 8.8-2.54 13.73-2.34"/><path fill="#282831" d="M835 1049c3.47 2.21 5.25 5.37 7 9l1.44.94c1.56 1.06 1.56 1.06 2.93 3.5 1.63 2.56 1.63 2.56 3.88 3.88 3.26 2 5.67 4.34 8.31 7.06l1.42 1.42a70 70 0 0 1 6.05 6.86c2.15 2.55 4.1 3.7 6.97 5.34q1.59 1.24 3.13 2.52c6.55 5.45 12.36 9.68 20.44 12.58 2.1.78 3.73 1.44 5.43 2.9v2l1.69.3c3.97.78 7.49 1.64 11.06 3.58 3.94 2.12 7.97 3.31 12.26 4.55 3.7 1.1 7.09 2.46 10.55 4.13 4.76 2.27 9.42 3.53 14.56 4.7 2.7.7 5.03 1.6 7.57 2.74 6.57 2.81 13.65 3.63 20.68 4.67 1.63.33 1.63.33 3.63 1.33v2l3.31.4q4.82.6 9.63 1.35c16.73 2.34 33.42 2.56 50.28 2.56l5.69.01q5.42.01 10.85-.02h3.28a75 75 0 0 0 16.85-1.86c2.78-.58 5.28-.59 8.11-.44-1 2-1 2-3.68 3.1-3.31.9-6.08 1.18-9.5 1.22q-.89 0-1.78.03l-5.75.06-4.01.06q-5.25.08-10.5.13l-10.74.14q-10.53.14-21.04.26v1c-11.84.21-23.05-.49-34.52-3.48L988 1139l-1 1 2 2h-9l4-3-1.98-.49c-44.3-10.92-44.3-10.92-47.03-15.03-1.37-2.05-2.8-2.38-5.12-3.17l-2.19-.76-1.68-.55v-2l-2.16-.11c-5.98-.41-10.47-1.26-15.84-3.89h-6v-3c-2.5-.69-4.38-1-7-1l-1.22-2.2c-2.41-3.79-5.37-5.32-9.22-7.49l-2.02-1.18c-2.6-1.5-5.18-2.95-7.88-4.26-2.1-1.1-3.48-2.67-5.09-4.37-2.7-2.59-5.76-4.73-8.78-6.95L859 1081v-2l-1.75-.74c-3.4-1.9-5.65-4.73-8.25-7.58-2-1.68-2-1.68-4.25-1.92l-1.75.24-.12-2.06c-1.25-4.17-3.82-6.92-6.88-9.94l-2-1v2c-3-1-3-1-4-2-5-.32-9.34.43-14.22 1.49-4.89.9-9.83 1.19-14.78 1.51v2l2.05-.29 2.7-.34 2.67-.35c2.8-.02 4.21.53 6.58 1.98l-1.51.04q-3.4.13-6.8.27l-2.38.07-2.3.1-2.1.09c-2.46.55-3.37 1.48-4.91 3.43-.69 2.69-.69 2.69-1 5l-3-3c-2.92 5.6-2.92 5.6-3.37 8-.86 2.75-2.55 4.43-4.47 6.54C782 1084 782 1084 781 1087l-2 1c-.71 1.45-.71 1.45-1.37 3.25-1.38 3.43-3.29 5.88-5.63 8.75-1.25 2.81-1.25 2.81-2 5l3 1-1.2 1.57-1.61 2.12-1.58 2.07c-1.61 2.24-1.61 2.24-3.04 4.7a47 47 0 0 1-5.82 7.35l-2.4 2.63L755 1129q-1.88 2.12-3.75 4.25l-1.7 1.94a88 88 0 0 0-2.85 3.5c-3.03 3.93-6.25 6.13-10.7 8.31l-1-2-5 1-1 3-5 3a174 174 0 0 0-3.94 3.44 67 67 0 0 1-12.68 9c-2.76 1.81-4.36 3.98-6.38 6.56h-2v2l-2.81.88c-3.19 1.12-3.19 1.12-4.47 2.07-2.62 1.6-5.53 2.17-8.47 2.92-4.68 1.27-8.66 2.64-12.77 5.27-2 1.16-4.11 1.68-6.35 2.23a72 72 0 0 0-6.7 2.45 73 73 0 0 1-17.29 4.82c-2.14.36-2.14.36-4.14 1.36q-2.06.1-4.12.06l-2.2-.02-1.68-.04c3.58-3.58 8.04-4.02 12.88-4.75 5.04-.85 9.51-2.44 14.25-4.31 5.61-2.23 10.75-3.9 16.87-3.94v-2l2.67-1.02 3.52-1.36 1.75-.67 7.92-3.05c2.14-.9 2.14-.9 3.14-1.9q3-.06 6 0l.63-1.69c2.84-4.78 7.63-7.58 12.37-10.31 4.46-2.66 7.29-5.39 10.56-9.4 1.88-2.08 4.01-3.23 6.44-4.6a277 277 0 0 0 5-5l3.34-3.3 3.6-3.58 1.8-1.77c3.48-3.46 6.75-7 9.84-10.81 2.02-2.19 4.37-3.7 6.82-5.36 3.92-2.89 6.17-7.06 8.6-11.18q1.5-2.32 3-4.62l1.5-2.3c1.5-2.08 1.5-2.08 3.12-3.66 2.06-2.12 2.5-4.65 3.38-7.42l2-2c.56-1.8.95-3.6 1.34-5.44l.66-1.56 3-1c.66-1.82.66-1.82 1.06-4.12a90 90 0 0 1 2-8.7c.94-3.18.94-3.18 1.38-5.5.75-2.25 1.44-2.6 3.56-3.68 2.7-.45 2.7-.45 5.92-.75l3.55-.35 5.56-.5c12.56-1.15 12.56-1.15 18.3-3.75a16 16 0 0 1 9.67-.65"/><path fill="#14131a" d="M1260 1478.73c6.05.04 11.99.48 18 1.14l2.25.24 2.13.26 1.9.22c1.92.46 3.15 1.22 4.72 2.41l-11.25-.56-2.42-.12-2.22-.11c-2.11-.21-2.11-.21-3.88-.73-2.22-.48-4.1-.53-6.37-.51l-2.19.01-1.67.02-.01 1.53a46811 46811 0 0 1-.64 72.39l-.06 6.58a1528 1528 0 0 1-.34 23.1q-.08 3.53-.22 7.07l-.04 2.06c-.23 4.64-1.56 6.91-4.69 10.27-1.16 2.06-1.16 2.06-2.06 4-2.92 6.22-6.03 10.3-11.31 14.62l-1.72 1.45a64 64 0 0 1-9.91 6.93l-2.22 1.32c-10.43 5.7-21.71 5.98-33.38 5.93l-3.65.01h-9.95l-10.86.01h-19l-28.27.02-53.4.01h-47.18l-8.93.01-83.05.01h-28.57q-49.57-.01-99.13.02-55.71.03-111.42.02h-14.83q-22.07 0-44.14.02h-78.3q-9.37.02-18.77 0h-10.68c-33.18.1-33.18.1-48.27-8.38q-1.74-.83-3.5-1.63c-2.34-1.28-3.37-2-4.5-4.37 3.33.55 6.1 1.24 9 3l1 2a49 49 0 0 0 5.38 2.5l2.96 1.28c8.66 3.16 17.69 3.5 26.81 3.45h3.58l9.77-.01q5.32-.02 10.65-.02l18.66-.03 27.74-.04 46.68-.07 46.24-.08h2.91l11.68-.02 109.58-.19 97.32-.16h6.25l3.12-.01 15.55-.02h3.1l81.35-.14h2.92l11.64-.02 92.62-.15a38984 38984 0 0 1 56.5-.1c40.18-.03 40.18-.03 48.99-3.17l1.45-1.5c2.07-2 3.74-2.08 6.55-2.56 8.69-2.17 15.42-8.94 20.16-16.38 1.39-2.57 2.13-5.25 2.9-8.06.95-3.4 1.88-6.39 3.5-9.53 2.95-6.09 2.6-12.54 2.61-19.17a30669 30669 0 0 0 .3-49.55 6847 6847 0 0 1 .16-24.96l.07-9.61v-2.9l.03-2.63.01-2.3c.4-2.8 1.5-3.75 4.26-4.12"/><path fill="#d2c4b6" d="M1373.87 1224.68c7.44.11 14.16 1.63 21.26 3.76q2.7.8 5.44 1.54A71 71 0 0 1 1419 1238l3.06 1.63 1.94 1.37v3c-5.88-.87-5.88-.87-7-2-10.43-1.77-19.54-1.69-29.5 2.13-2.5.87-2.5.87-5.5.87v2c-3.43 2.62-6.89 4.41-10.81 6.19-7.57 3.7-13.06 9.54-17.19 16.81-3.66 5.56-3.66 5.56-5.41 7.86-4.6 6.27-6.8 12.86-9 20.22q-.75 2.39-1.64 4.72c-1.54 5.18-1.28 10.4-1.27 15.76q0 2.96-.04 5.93A86 86 0 0 0 1340 1349l.7 2.64c1.39 4.96 3.6 8.5 6.9 12.44 1.4 1.92 1.4 1.92 2.32 4.35 2.1 4.99 6.4 8.68 10.08 12.57l1.9 2.1c5.66 6.04 11.04 7.8 19.01 9.43 2.49.56 4.77 1.42 7.09 2.47-8.85 6.03-22.52 3.15-32.6 1.42-6.2-1.2-12-3-17.4-6.42l-1-2 1.4.52c12.95 4.53 24.86 7.2 38.6 6.48l-2.77-.95-3.6-1.3-1.83-.62c-4.51-1.65-4.51-1.65-5.94-4.02l-.86-2.11q-2.09-2.29-4.31-4.44l-2.44-2.38-2.25-2.18-2.81-2.87c-2.19-2.13-2.19-2.13-4.38-3.5-2.2-1.97-2.58-3.73-3.25-6.55a57 57 0 0 0-2.5-6.95c-1.9-4.65-3.63-9.32-5.06-14.13l-.52-1.68c-1.63-5.7-1.79-11.16-1.73-17.07l.02-2.94c.23-17.02.23-17.02 3.74-22.8 1.58-2.65 2.05-4.54 2.49-7.57.7-4.22 1.48-8.4 2.44-12.56l.68-3.04.88-2.34 3-1 1-5 3-1c.69-2.06.69-2.06 1-4h2v-3c1.57-1.65 1.57-1.65 3.69-3.37a82 82 0 0 0 7.12-6.5c2.19-2.13 2.19-2.13 5.19-3.13.73-1.63.73-1.63 1.19-3.56l.48-1.94.33-1.5h2v-2l-1.62.25c-5.96.82-11.42.56-17.38-.25l-.81 1.94C1352 1241 1352 1241 1349 1242c-2.69-.44-2.69-.44-5-1l2-4h5v-4l-1.98.8a239 239 0 0 1-14.02 5.2 32 32 0 0 0-11.74 7.01c-1.26.99-1.26.99-3.26.99l-.7 1.66a35 35 0 0 1-4.73 6.35 67 67 0 0 0-14.74 26.48c-.83 2.51-.83 2.51-2.05 5.23-3.61 8.24-4.51 15.32-4.32 24.27q.05 3.1.03 6.22c.05 9.38.7 18.85 5.26 27.29 1.25 2.5 1.25 2.5.94 4.88l-.69 1.62c-7.21-10.94-8.18-24.04-8.19-36.81l-.03-2.83c-.02-6.37.8-12.17 2.22-18.36l.64-3.43a55 55 0 0 1 6.96-18.15 38 38 0 0 0 2.84-6.04c1.92-4.74 5.35-8.47 8.56-12.38l1.25-1.7c2.9-3.85 6.35-6.21 10.41-8.79 2.28-1.47 4.43-3.05 6.59-4.7a81 81 0 0 1 8.75-5.81l1-1q2.11-.49 4.25-.87 6.43-1.24 12.81-2.7l3.35-.75c4.69-1.23 8.32-1.98 13.46-2"/><path fill="#4c4b50" d="M319 644h4c-.5 6.4-.5 6.4-2.87 9.25-2.6 3.36-3.66 6.54-4.88 10.56l-.67 2.1L313 671h-1v-8l-1 2h-2v-3l-1.14.99A66 66 0 0 1 298 670l-2 2 2 2c-3.03 2.6-5.16 4.07-9 5l-.94 1.44C287 682 287 682 284.5 682.87c-2.5 1.13-2.5 1.13-3.5 3.7l-1 2.43c-2.31.63-2.31.63-5 1-2.44 1.31-2.44 1.31-5 3l-2.66 1.7c-4.77 3.18-9.1 6.95-13.5 10.61A78 78 0 0 1 243 713a172 172 0 0 0-5.25 3.94l-2.64 2.02c-2.26 2.18-2.51 3.09-3.11 6.04-1.3 1.27-1.3 1.27-2.87 2.25l-1.56 1.02C226 729 226 729 223 729c-3.14 1.47-5.6 2.97-6.87 6.25L216 737h-3l-1.13 2.62c-1.92 3.84-4.66 6.5-7.75 9.44l-4.64 4.5q-2.14 2.1-4.22 4.24c-2.08 2.03-3.72 2.93-6.26 4.2l-1 3-1.77.4c-3.16.85-4.77 2.91-6.85 5.29l-2.36 2.57c-2.13 2.89-3.07 5.3-4.02 8.74l-1 3h-3c-1.5 1.8-1.5 1.8-3 4.19l-1.58 2.5L163 794l-1.69 2.69a96 96 0 0 0-2.87 5.5C157 805 157 805 155 807a90 90 0 0 0-1 6h-1l-1-7c-2.72 2.34-3.66 4.32-4.62 7.75l-.73 2.48c-1.86 7.91-1.77 15.64-1.71 23.7l.01 3.81.05 9.26c-2.89-3.22-3.37-5.28-3.33-9.57l.02-3.22.06-3.34.02-3.36c.08-8.2.08-8.2 1.23-10.51q.44-2.77.81-5.56c.93-5.7 2.5-10.35 5.19-15.44l1.2-2.27q1.85-3.4 3.8-6.73l1.29-2.24c2.25-3.78 4.13-6.16 7.71-8.76l.82-2.32c1.42-3.24 3.09-4.63 5.8-6.87 5.18-4.42 9.27-9.39 13.38-14.81a49 49 0 0 1 9-9l3.6-3.65c2.13-2.06 4.44-3.88 6.75-5.73C204 738 204 738 205.3 735.65c2.22-3.45 4.94-5.46 8.2-7.89l3.78-2.9 1.9-1.45a351 351 0 0 0 7.88-6.27l2.54-2.06c1.75-1.5 3.35-3 4.97-4.64a49 49 0 0 1 6.3-5.37 100 100 0 0 0 7.75-6.25c2.92-2.55 5.91-4.08 9.38-5.81 1.26-1.2 2.53-2.4 3.72-3.67 3.66-3.8 7.73-6.7 12.28-9.33l2-1.56c2.25-1.62 4.43-2.42 7-3.44a89 89 0 0 0 13.56-8.81l1.71-1.32A58 58 0 0 0 309 654l-1.39.53-6.3 2.4-2.18.85c-3.7 1.4-7.21 2.6-11.13 3.22v2c-2.9 1.26-4.8 2-8 2v2c-2.9 1.26-4.8 2-8 2l-.13 1.83c-.87 2.17-.87 2.17-3.49 3.65l-3.25 1.27-3.25 1.3C259 678 259 678 256 678l-1 3c-1.38.5-2.76 1-4.18 1.4-2.86.95-5.44 2.44-8.09 3.9l-1.73.7-2-1c4.43-3.64 8.48-6.46 13.93-8.3a93 93 0 0 0 6.63-2.58l2.07-.86a43 43 0 0 0 5.65-3.64c3.7-2.7 7.59-4.7 11.72-6.68 4.58-2.22 9.05-4.48 13.4-7.1a22 22 0 0 1 5.1-1.84 23 23 0 0 0 6.81-2.94c3.68-2.26 7.48-3.24 11.65-4.25L318 647z"/><path fill="#404145" d="m1511.86 385.89 2.4.01 2.58.01 2.72.03 9.44.06 2 4h17l1 3 3.43.08 4.44.17 2.27.04 2.16.1 2 .06 1.7.55 2 4h10l2 4h14l2 4h10l2 4h10l2 4h10l2 4 5.27-.3 1.73.3.95 1.48L1640 421c2.32.41 4.55.44 6.9.5 2.1.5 2.1.5 3.38 2.54l.72 1.96c2.37-.31 2.37-.31 5-1l2-3 11 1v3h-12l2 4h10l2 4c4.68-.62 4.68-.62 6.31-2.56l.69-1.44 7 1v3h-8l2 4c4.68-.62 4.68-.62 6.31-2.56l.69-1.44 4 1v3h-5c1.88 3.48 3.48 6.08 7 8 2.12-.13 2.12-.13 4-1 1.25-1.56 1.25-1.56 2-3l4 1v3h-5c1.88 3.48 3.48 6.08 7 8 2.12-.13 2.12-.13 4-1 1.25-1.56 1.25-1.56 2-3l4 1v3h-5c4.2 7.4 4.2 7.4 9 9v-5l4 1c-.38 1.94-.38 1.94-1 4l-2 1c-.94 2.25-.94 2.25-1 5 2.14 3.17 4.38 4.8 8 6v-5l4 1c-.38 1.94-.38 1.94-1 4l-2 1c-.63 2.56-.63 2.56-1 5l4 2v19c-5.4-6.75-7.46-10.51-8-19l-3-1v-6h-4l-1-5-3-1-1-3h-3l-1 2-6-1 1-3 5 1v-4h-5l-1 2v-6l-5-1-1-3c-2.06-.69-2.06-.69-4-1l-1-4-2.31-.31c-2.96-.76-3.68-1.5-5.69-3.69-2.19-1.19-2.19-1.19-4-2v-2l-3.25.19c-3.6.01-4.9-.97-7.75-3.19-2.34-.55-4.6-.77-7-1l-1-4h-8l-1-4-3.25.19c-3.6.01-4.9-.97-7.75-3.19-1.73-.28-1.73-.28-3.63-.44L1631 425c-1.44-2.06-1.44-2.06-2-4l-1.47.1c-4.73.2-8.21-.48-12.57-2.3a50 50 0 0 0-5.9-1.93C1606 416 1606 416 1605 415c-2.2-.24-4.36-.42-6.56-.56l-1.87-.13-4.57-.31v-4l-2.77-.18-3.6-.26-1.83-.12c-4.57-.33-4.57-.33-6.8-1.44v-2l-12-1-1-3-2.38-.11c-9.63-.6-9.63-.6-12.93-2.33a25 25 0 0 0-7.45-2.2l-2.07-.28q-4.58-.58-9.17-1.08v-3l-2.85.07-3.78.05-1.87.06c-4.26.04-7.57-.48-11.5-2.18-.94-2.06-.94-2.06-1-4 1-1 1-1 2.86-1.11"/><path fill="#2f2d34" d="M961 363h4l-.02 2.38q-.08 11.23-.14 22.45-.01 5.78-.07 11.54c-.29 33.46-.29 33.46 3.23 48.63l.68 3q.84 3.3 1.88 6.56l.63 2.15c.92 3.02 1.54 5.02 3.81 7.29.44 2.94.44 2.94 0 6-4.22 3.33-9.2 4.27-14.31 5.5A128 128 0 0 0 943 484c-4.44 1.78-8.29 3.1-13.09 3.62-3.32.66-6.35 2.14-9.46 3.46-3.19 1.2-6.07 1.63-9.45 1.92v2l-2.76.48c-14.46 2.58-29.02 5.3-42.71 10.71-6.5 2.4-13.31 3.27-20.12 4.36a114 114 0 0 0-18.68 4.57 74 74 0 0 1-10.51 2.39c-2.22.49-2.22.49-4.53 1.52-3.45 1.24-6.56 1.3-10.19 1.4a87 87 0 0 0-18.57 2.94A41 41 0 0 1 771 524v2c-7 2.06-13.9 3.6-21.1 4.7l-6.05.97a992 992 0 0 1-33.16 4.83l-2.54.32c-18.17 2.27-36.38 3.3-54.66 4.11l-5.17.24q-3.17.16-6.34.27l-2.88.14-2.48.1c-2.62.32-2.62.32-5.77 1.33-4.8 1.44-9.6 1.27-14.57 1.25h-3.2q-4.32.02-8.64 0l-9.09.01h-15.27q-8.79-.03-17.58 0a5356 5356 0 0 1-24.18 0l-10.08-.01-2.97.01a87 87 0 0 1-16.95-1.8 68 68 0 0 0-7.88-1.07l-2.73-.24-2.71-.22-7.9-.67C485 540 485 540 482 539q-1.95-.27-3.91-.43l-2.3-.22-2.42-.23c-5.75-.58-11.06-1.27-16.48-3.4-2.66-1.01-5.38-1.6-8.16-2.2A15 15 0 0 1 444 530c6.35-.33 12.07.93 18.25 2.31 13.74 2.96 27.4 5.62 41.46 6.35 5.06.27 10 .98 15 1.84 10.57 1.6 21 1.65 31.68 1.63h47.14a388 388 0 0 0 26.93-.7c2.45-.17 4.32-.32 6.54-1.43q3.15-.3 6.3-.46l3.93-.24 2.09-.12c15.8-.9 31.49-2.12 47.13-4.68 4.54-.69 9.1-1.03 13.67-1.44 13.38-1.2 26.53-3.3 39.66-6.08 4.81-.98 9.55-1.35 14.44-1.66 3.69-.42 7-1.52 10.51-2.69 3.2-.9 5.96-.85 9.27-.63v-2h-6c2-2 2-2 4.38-2.2l2.75.08 2.75.05 2.12.07-4 2 3.38-.44c4.86-.6 9.74-1.09 14.62-1.56v-2l-6-1v-1h10l-3 1v2l1.68-.37 15.57-3.44 2.29-.5q6.55-1.47 13.09-3.06l2.83-.7q2.73-.66 5.46-1.36c4.48-1.1 8.46-1.84 13.08-1.57v-2h-8v-2h10v3l1.76-.66c4.61-1.64 8.34-2.72 13.24-2.34v-2h-8v-2h11l-1 3 7-3-1-4h11l1 2q2.73-.67 5.44-1.37l3.06-.78C901 493 901 493 902 491l-8 1v-2c8.32-.16 8.32-.16 11.63.63 3.5.55 6.02-.51 9.37-1.63l-6-1v-2l2.05.14c2.7.11 5.26.15 7.95-.14a181 181 0 0 0 4-4c2.35-.14 4.62 0 6.96.13L932 482c1.63-2 1.63-2 3-4a27 27 0 0 1 9.78.67c4.08.42 6.68-.58 10.4-2.1 6.5-2.57 6.5-2.57 9.82-2.57-2.14-2.14-3.13-2.43-6-3v-1l6-1q.55-2.25 1.06-4.5l.6-2.53c.9-7.82-1.6-15.85-3.2-23.46l-.52-2.54-.48-2.24c-1.26-7.44-1.62-14.78-1.63-22.32l-.05-18.62-.01-6.07-.02-2.8c.02-6.19.82-11.9 2.25-17.92h-2v2h-2z"/><path fill="#111016" d="M1318.56 888.81c2.44.19 2.44.19 3.44 1.19q.22 2.47.3 4.94l.23 6.74.12 3.68c.46 13.92.7 27.85.9 41.78l.03 2.16c.6 41.32.6 82.63.6 123.95q0 12.79.03 25.58a15119 15119 0 0 1 .03 39.8 3710 3710 0 0 1 .01 18.75v6.82l.01 2.02c-.03 4.55-.03 4.55-2.26 6.78-2.71.27-2.71.27-5.94.25l-3.21.02c-2.85-.27-2.85-.27-4.25-1.25-2.05-1.3-3.64-1.43-6.05-1.65l-2.57-.26-2.67-.23-2.7-.27-6.61-.61v-1h33a144316 144316 0 0 0-1.37-125.42 13247 13247 0 0 1-.36-32.88c-.1-11.77-.35-23.23-3.27-34.7l-1 4-2-1 1-52 3 4a203 203 0 0 0 1.06-24.19L1318 894l-3 3-1-2a237 237 0 0 0-20 9l-4.5 2.25-2.03 1.02-1.47.73c-.11 5.47.12 10.6 1 16q.33 3.2.62 6.4l.38 1.6 2 1-2 2q-.6 2.99-1 6l-.5 2.73a74 74 0 0 0-.74 8.77c-.33 9.41-1.9 18.38-3.78 27.59-.84 4.08-1.48 8.15-2.04 12.28-.59 4.02-1.41 7.3-3.21 10.93-1.46 3.4-2.03 7.08-2.73 10.7h-1c-.28-7.56.9-14.07 3.5-21.17 1.65-4.87 2.21-9.6 2.63-14.7l.44-5 .18-2.19c.25-1.94.25-1.94 1.25-4.94q.22-2.49.32-4.99l.12-3.05.12-3.27.13-3.35q.4-11.41.43-22.84l.02-2.86-.01-4.9C1282 919 1282 919 1281 918a76 76 0 0 1-.25-5.31l-.08-2.93c.38-3.22 1.05-4.49 3.33-6.76 2.02-.94 2.02-.94 4.25-1.56a65 65 0 0 0 15.25-6.5 27 27 0 0 1 7.31-3.07c3.37-.92 4.4-2.81 7.75-3.06"/><path fill="#89898b" d="M231 918a45 45 0 0 1 10.06 2.44c4.9 1.72 9.79 2.34 14.92 2.93 3.02.63 3.02.63 5.4 2.09 3.52 2.07 6.93 2.57 10.93 3.23l2.25.39q2.71.48 5.44.92v2l3-.12c2.69 0 4.94.29 7.52 1.03 5.32 1.39 10.72 2 16.17 2.65l8.76 1.08q2.55.36 5.1.87c2.35.47 4.64.75 7.03.95l7.85.7 2.59.24 2.32.2c2.22.33 4.22.8 6.38 1.4 4.57 1.18 9.01 1.4 13.71 1.56l2.66.11q4.17.18 8.35.33 5.5.2 11 .44l2.5.08 2.4.1 2.07.08c2.34.27 4.48.74 6.77 1.29 4.87 1.11 9.6 1.43 14.59 1.6l2.86.11c12.03.44 24.06.64 36.1.82l6.13.1c32.8.49 65.6.57 98.41.57q9.6 0 19.18.02a8630 8630 0 0 0 30.09.03c17.93.05 35.6-.54 53.46-2.24l2.36-.22q5.92-.55 11.83-1.22l3.16-.33c6.4-.7 12.62-1.79 18.9-3.1 6.93-1.42 13.92-2.28 20.94-3.13l8.12-1.01 2-.25q9.93-1.28 19.8-2.96l2.21-.36 1.88-.32c2.1-.12 3.79.3 5.8.9-6.38 2.07-12.81 2.95-19.44 3.88l-7.27 1.05-1.86.26q-8.98 1.34-17.93 2.87l-2.97.5-5.75.97c-21.7 3.64-21.7 3.64-30.16 4.26C667 948 667 948 666 949q-1.96.3-3.96.46l-2.55.24-2.8.24-2.92.26-6.18.53-9.18.8-8.7.77c-19.85 1.67-39.76 1.86-59.66 1.87h-5.79l-20.42.02q-8.3 0-16.59.02c-53.14.13-53.14.13-77.84-1.16l-2.52-.13q-3.42-.18-6.85-.4l-1.97-.1A69 69 0 0 1 428 951a173 173 0 0 0-7.94-.34l-5.5-.16-7.24-.22-3.24-.1a83 83 0 0 1-9.08-1.18q-2.31-.17-4.62-.25l-2.56-.1c-.44 0-.44 0-2.7-.09-6.9-.28-13.67-.78-20.5-1.76-4.53-.62-9.06-1-13.62-1.3l-1.9-.13a1171 1171 0 0 0-12.95-.8l-3.15-.2c-3.2-.4-5.96-1.3-9-2.37a92 92 0 0 0-5.75-.44 53 53 0 0 1-13.5-2.74c-3.36-1-6.74-1.67-10.19-2.32-4.44-.84-8.78-1.79-13.12-3.06-4.2-1.21-8.3-1.9-12.63-2.44-4.53-.58-8.67-1.36-12.87-3.22-2.3-.93-4.66-1.56-7.05-2.23-3.1-.9-6.15-1.97-9.2-3.05l-1.88-.64c-2.82-1-4.67-1.72-6.81-3.86"/><path fill="#070708" d="m370 374 3 1v30l-3 1 .06 3.34c0 5.1-.52 10.1-1.06 15.16l-1 9.5h-2l1 32 2 1 1 20-2 1-1-10h-1l-2 10-5 1-.15 2.19q-.35 4.87-.73 9.75l-.23 3.44-.48 6.3L357 513l-2 1c-.5 2.88-.84 5.73-1.12 8.63-.37 3.14-.71 5.1-2.38 7.84-2 3.36-2.2 6.38-2.56 10.22-.68 7.05-.68 7.05-2.94 9.31-.56 1.88-.56 1.88-1 4-.6 2.95-.8 3.8-3 6q-.45 2.3-.75 4.63c-.88 4.94-2.68 7.9-5.91 11.68C334 578 334 578 333 581l-5 1v4h-9v3c-6.43.29-6.43.29-9-2l1-5h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.31 1.56 1.31 1.56 3.6 2.4 2.71.16 2.71.16 5.17-1.36 6.26-6.1 6.26-6.1 7.12-10.3A44 44 0 0 0 334 559l4-2-.19-3.37c-.1-1.9-.1-1.9.19-3.63l1.47-.81C341 548 341 548 341.5 544.96q.1-1.8.18-3.59l.1-1.85q.12-2.25.21-4.52l4-1-.05-1.9q-.09-4.23-.14-8.48l-.07-2.98-.03-2.85-.05-2.64.34-2.15 1.49-.96L349 511c.43-2.43.43-2.43.51-5.45l.1-3.26.08-3.42.1-3.44q.12-4.2.21-8.43l4-2v-22l4-2v-26l4-2v-26l4-2v-29z"/><path fill="#4b4a57" d="M482 1178c4 1 7.93 2.12 11.88 3.31l2.1.61c4.65 1.42 8.05 3.29 12.02 6.08 2.34 1.05 2.34 1.05 4.38 1.75l2.08.73 1.54.52c-1.63 1.96-2.46 2.92-5.04 3.3l-2.15-.11-2.17-.08-1.64-.11 1-5-1.56.5c-3.2.66-6.17.56-9.44.5l-2-5-5 2c-1.04 8.96-1.14 17.82-1.12 26.83v31.78l.01 24.3.02 35.13a146353 146353 0 0 0 .04 112.36v20.58L487 1580l-4 1c-1.11-3.57-1.14-7.02-1.12-10.72v-42.16l.01-25.44.02-36.78a160406 160406 0 0 1 .04-119.45v-17.95z"/><path fill="#797682" d="M1316 999h2c.97 4.09 1.17 8.04 1.2 12.22l.18 14.47.25 22.5a8083 8083 0 0 1 .5 103.43v13.87c-.13 2.51-.13 2.51-1.13 3.51-3.92-.03-7.84-.34-11.75-.61q-2.93-.2-5.85-.37c-8.26-.53-16.51-1.37-24.4-4.02-1.3-2.6-1.13-4.46-1.13-7.37v-29.49a1224 1224 0 0 1 .82-49.44l.1-2.98c.2-2.65.63-5.13 1.21-7.72h1l.08 45.1a7638 7638 0 0 1 .03 26.42l.01 2.22c0 5.03 0 5.03-1.12 7.26h2v4l1-26h1l.04 2.52.22 9.28.09 4.01q.05 2.9.14 5.77l.02 1.81c.1 2.65.27 4.3 1.84 6.48 2.51 1.72 4.83 1.66 7.84 1.82l3.29.2 2.52.11v3l8 1 1-2 3-1 .01-1.44a17605 17605 0 0 1 .41-51.6 5382 5382 0 0 1 .18-22.42l.08-8.66v-2.6l.04-2.39.02-2.07c.3-2.1 1.04-3.12 2.26-4.82.27-2.2.27-2.2.29-4.82l.04-2.96.01-3.21.05-3.33.11-10.56c.26-28.83.26-28.83 2.5-39.12"/><path fill="#000003" d="M383 1176h1l.08 1.54.36 6.9.12 2.43.12 2.31.11 2.15c.21 1.67.21 1.67 1.21 2.67q.15 3.76.13 7.54v2.43l.02 14.05.01 16.22.01 15.68.04 61.9.03 72.22v15.02q0 34.53.04 69.05.04 36.48.04 72.95v10l.03 31.76q.02 15.96.01 31.9l.01 17.3v15.81l.01 5.75v7.76l.01 2.3c-.02 2.07-.02 2.07-.39 5.36-1.52 1.2-1.52 1.2-3 2a609 609 0 0 1-1.13-38.42v-418.53A670 670 0 0 1 383 1176"/><path fill="#181a1e" d="M348 1382h1c1.09 13.2 1.35 26.39 1.57 39.62l.03 1.9a4802 4802 0 0 1 .42 89.88q-.03 30.52-.1 61.06v3.7l-.02 3.7v1.86l-.05 30.6q0 10.15-.04 20.32l-.04 18.35c-.04 9.05-.04 17.77 1.78 26.67.65 3.38.68 6.66.57 10.09l-.02 1.86-.1 4.39c-1.81 1.06-1.81 1.06-4 2l-3-1-1-30-3-1v-54l3-1 .08-21.96.02-8.02.01-2.5c0-4.02-.31-7.58-1.11-11.52l1-3q.14-2.13.14-4.27l.02-2.64v-2.88l.04-13.04.02-6.94.02-12.7q0-9.33.05-18.66l.03-16.2.03-6.87v-9.62q.02-1.42.03-2.87c-.03-5.34-.62-8.72-3.38-13.31-.39-2.05-.39-2.05-.4-3.87l-.01-2.05.04-2.08-.04-2.08.01-2.05.01-1.82c.45-2.4 1.5-4.26 2.63-6.4 1.32-2.86 1.88-5.82 2.44-8.9l.38-2.09c1.37-8.2 1.27-16.37 1.13-24.66l-.04-4.7z"/><path fill="#26262e" d="M457 1757c8.5 2.72 8.5 2.72 12.38 4.5 9.78 4.26 20.12 5.38 30.62 6.5v2l186.78.04h2.8l299.52.05h2.76l87.68.02h23.79c54.37.05 54.37.05 80.6-.44l6.01-.1c18.12-.3 18.12-.3 27.06-2.57a409 409 0 0 1 6.25-.81 67 67 0 0 0 16.08-4.6c3.27-1.3 5.34-1.93 8.67-.59l-3 1.81-1.69 1.02c-5.95 3.01-11.93 4.69-18.5 5.73l-2.02.34q-2.1.35-4.2.68-2.72.44-5.45.92a132 132 0 0 1-22.66 1.76l-3.62.01h-9.88l-10.78.02-18.86.02q-14.02 0-28.05.02l-55.95.04h-5.85l-32.19.01q-48.63.04-97.26.04h-28.39q-49.2 0-98.43.07a101013 101013 0 0 1-122.45.06h-2.94q-23.35 0-46.7.04-23.49.04-46.98.02-13.94-.01-27.87.02-9.33.02-18.66 0-5.3-.02-10.6 0c-15.7.1-31.05-.3-46.02-5.63l-1-1q-2.12-.2-4.25-.31c-4.41-.44-6.45-1.66-9.75-4.69l-2.69-1.06c-2.53-1.03-2.97-1.68-4.31-3.94"/><path fill="#85848c" d="M606.15 1641.88h60.61a73703 73703 0 0 1 79.95.03h15.06a356553 356553 0 0 1 175.4.04h86.48l167.35.05 1 3-117.3.04h-1.79l-38.58.01h-20.85l-56.13.02-87 .03h-3.58c-86 0-171.99-.31-277.77-1.1v-1c5.76-.99 11.32-1.14 17.15-1.12"/><path fill="#5b5b5e" d="m602.81 121.88 7.09.02q8.55.03 17.1.1v2h19l-1 4c-1.57.8-1.57.8-3.56 1.31-5.21 1.7-8.56 4.93-12.44 8.69l-2.31 2.13c-4.2 4.66-5.12 8.12-4.88 14.18l.04 1.96.15 4.73 2 1-1 4c-3.89-4.73-4.17-8.5-4.25-14.56l-.08-3.44c.37-3.38.88-3.87 3.33-6l1-4h-19v-2l-24.18.05-22.08.04-4.12.02c-6.67.02-13.24.03-19.85-.97-5.3-.4-11.34 3.13-15.55 6.13q-1.38 1.2-2.72 2.47c-2.52 2.12-5.44 3.53-8.35 5.05-2.15 1.21-2.15 1.21-4.3 2.86C501 153 501 153 499 153l-.77 2.24c-1.3 2.92-2.7 4.6-4.92 6.88-3.9 4.23-6.76 8.54-9.67 13.5A18 18 0 0 1 478 181c3.39-12.32 13.13-23.24 23-31l5.63-4.69 1.38-1.15c10.52-8.66 25.19-15.6 38.64-17.7 2.35-.46 2.35-.46 4.92-1.42 4.13-1.39 8.31-1.76 12.63-2.16l2.75-.25c11.94-1 23.9-.85 35.86-.75"/><path fill="#4b4a4d" d="M1419 1682h3v19l-4 2v10l-4 2 .1 2.55.09 3.33.1 3.3-.29 2.82c-1.48 1.13-1.48 1.13-3 2-.37 1.88-.37 1.88-.44 4-.18 2.16-.18 2.16-.56 4-2.06 1.5-2.06 1.5-4 2 .62 4.68.62 4.68 2.56 6.31l1.44.69-1 4h-3v-5c-3.48 1.88-6.08 3.48-8 7 .13 2.13.13 2.13 1 4a13 13 0 0 0 3 2l-1 4h-3v-5a34 34 0 0 0-12 10c-.12 2.81-.12 2.81 1 5a10 10 0 0 0 3 2l-1 4h-3v-5c-4.26 2.3-7.4 5.09-10.75 8.5l-1.5 1.47c-2.31 2.33-3.7 3.87-4.75 7.03h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.27-.9-2.27-.9-5-1-2.42 1.77-2.42 1.77-4.75 4.25l-2.36 2.45c-1.89 2.3-1.89 2.3-2.89 5.3h-7l-1 3-3 2-1 3h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.56-.62-2.56-.62-5-1l-2 4h8v3l-7 1-.69-1.44c-1.83-2.18-3.56-2.2-6.31-2.56l-2 4h-8l-1 4h-13l-2 4h-10l-2 4h-20v-2h-2l1-3c6.6-2.61 14-3.09 21-4v-2l12-1 1-3 2.48-.11 3.27-.2 3.23-.18c3.27-.55 4.54-1.4 7.02-3.51 1.89-.91 1.89-.91 3.69-1.62 3.22-1.29 3.22-1.29 4.31-2.38 2.34-.14 4.66-.04 7 0l.06-1.81c1.11-2.6 1.7-2.8 4.19-3.94 3-1.15 5.51-1.54 8.75-1.25l.81-1.94c1.19-2.06 1.19-2.06 4.19-3.06l1.44-1.94c1.73-2.28 2.88-3.09 5.56-4.06 2.25-.12 2.25-.12 4 0v-5l1.82-.8c2.31-1.27 3.48-2.46 5.12-4.51a85 85 0 0 1 8.06-8.69h2l1-4h4l.19-3.19c.4-2.97 1.04-4.4 3.25-6.43 2.6-2.42 3.51-4.04 4.56-7.38h3l-.19-2.37.19-2.63q1.5-1.01 3-2c1.19-3.12 1.19-3.12 2-6h2v-11l4 1 .15-2.8.22-3.64.1-1.85c.1-1.58.31-3.15.53-4.71l2-1c.56-2.43.56-2.43 1-5.44.78-5.34.78-5.34 3-7.56.84-2.96 1.47-5.95 2.12-8.96.9-3.13 1.92-5.43 3.88-8.04"/><path fill="#997e69" d="M1051 1218q-.17 1.94-.37 3.88l-.22 2.17c-.41 1.95-.41 1.95-2.41 3.95-.93 2.2-1.77 4.37-2.56 6.63l-.68 1.9A53 53 0 0 0 1042 1250c-1.67 1.67-3.58 1.21-5.87 1.25-7.99-.07-15.71-1.46-23.56-2.81q-6.62-1.11-13.26-2.13L991 1245v-2l-3.31.06c-2.66.05-4.07-.19-6.69-1.06a73 73 0 0 0-4.01-.32l-2.3-.12-2.38-.12-8.31-.44c.3-1.91.3-1.91 1-4 2.4-1.1 4.55-1.9 7.06-2.62l2.07-.65c3.99-1.23 7.99-2.38 12.06-3.27 1.81-.46 1.81-.46 4.56-1.46 3.61-1.11 6.95-1.48 10.69-1.81 8.26-.85 16.26-2.53 24.35-4.36a239 239 0 0 1 18.2-3.47c2.73-.49 4.1-1.36 7.01-1.36"/><path fill="#d2d2d4" d="M510 1838h755v2H510z"/><path fill="#3a3941" d="m471 1628 1.65.84q3.91 1.93 7.91 3.66l2.88 1.28c8.65 3.27 17.8 3.51 26.92 3.47h119.4q23.57-.02 47.15-.02h14.86q55.86 0 111.7-.03 49.63-.01 99.24-.01h28.57l85.9-.02h11.87l94.44-.02 46.9-.01h25.59c6.72-.02 13.33-.41 20.02-1.14-6.67 4.71-14.08 4.28-21.92 4.25l-3.65.01h-68.51q-25.4.03-50.79.01h-59.39l-112.27-.01h-99.76q-56.02.02-112.04.01H549.54a9006 9006 0 0 1-29.64 0q-4.9 0-9.8-.02-2.58 0-5.17.02c-9.58-.05-18.08-1.56-26.93-5.27v-2l-1.81-.25c-2.79-.95-3.62-2.32-5.19-4.75"/><path fill="#36363f" d="m1232.06 1762.88 2.29.05 1.65.07c-5.12 3.98-11.47 4.45-17.69 5.06l-4.5.48-2 .2c-1.81.26-1.81.26-4.81 1.26q-1.8.16-3.62.2l-2.22.06-2.44.05-2.6.06-8.73.18-3.12.07c-27.52.55-55.04.52-82.56.51h-23.7a88482 88482 0 0 1-86.87 0H514.85A86 86 0 0 1 500 1770v-2h-7v-1l2.55.09c31.43 1.07 62.85 1.07 94.29 1.07l23.95.01 85.16.04 105.7.03h14.77q47.52 0 95.04.04 50.38.04 100.77.04h13.82q21.86 0 43.7.03 21.97.02 43.93.01 13.02 0 26.04.02h27.35c18.53.07 36.96-.64 55.06-4.98 2.34-.5 4.55-.6 6.93-.53"/><path fill="#a79dbe" d="M756 578c-2.73 2.73-5.65 2.9-9.31 3.56l-5.9 1.1q-3.4.65-6.79 1.34v2l9 1c-3.88 3.88-5.97 4.04-11.25 4.06L728 591v2c-6.45 1.83-12.7 2.55-19.37 3.06q-2.96.23-5.9.48l-2.61.2C698 597 698 597 696 598c-3.7.37-7.41.47-11.12.63l-3.1.15c-5.72.25-11.14.32-16.78-.78l-1-2c-3.06-.62-3.06-.62-6-1l1-2 3.27-.08 17.2-.42q3.73-.08 7.46-.18l2.35-.05 2.19-.06 1.92-.05C695 592 695 592 697 591a4979 4979 0 0 0-28.19-.15q-5.14-.04-10.29-.05l-3.22-.03h-3.03l-2.65-.01c-2.62.24-2.62.24-5.4 1.24-4.09 1.27-7.73 1.27-11.99 1.24h-2.6q-4.26.01-8.52-.01h-5.95l-12.46-.02-15.9-.02-18.15-.02c-19.54-.02-19.54-.02-28.65-1.17l-1-3 39.47-.5c16.97-.2 33.94-.45 50.9-.98l1.95-.05a203 203 0 0 0 22.04-1.68c9.17-1.28 18.48-1.54 27.72-2 7.07-.38 13.9-1 20.88-2.28 5.98-1 12.01-1.55 18.04-2.13l2.04-.2A263 263 0 0 1 756 578"/><path fill="#14171c" d="M279 1031h20l1 2c3.02 1.06 5.95 1.16 9.13 1.25l2.94.1 6.13.17 2.93.1 2.69.08c2.18.3 2.18.3 4.18 2.3 2.1.33 2.1.33 4.68.46l2.81.18q2.94.18 5.88.3l2.81.2 2.58.14c2.24.72 2.24.72 3.86 2.56 2.09 4.79 1.77 9.33 1.7 14.5v3.5q-.01 4.77-.05 9.54-.04 4.06-.05 8.12c-.12 30.86-.6 61.72-1.11 92.57q-.19 11.5-.36 22.99L350 1239l-2 1v-2h-2v-191c-2.24-1.12-3.43-1.12-5.91-1.1l-2.3.01-2.42.03-2.42.01-5.95.05-2-4h-26v-4h-20c-1-5-1-5 0-7"/><path fill="#0f0e14" d="M1401 437c5 6.36 5 6.36 5 11h2l2.25 4.4c.75 1.6.75 1.6 1.75 4.6h2c2.4 4.59 4.51 9.04 6 14h2c2.72 5.26 5.23 10.34 7 16q1.52 3.83 3.1 7.63c3.5 9.2 3.6 22.4-.18 31.5L1431 528l-.98 2.1q-1.46 2.97-3.02 5.9l-1.37 2.88c-3.72 7.32-7.9 13.74-13.5 19.76-1.92 2.13-3.71 4.3-5.5 6.55a78 78 0 0 1-11.42 11.95c-2.2 1.85-4.29 3.79-6.39 5.76a215 215 0 0 1-17.9 14.87q-4.33 3.3-8.36 6.98c-2.05 1.65-4.3 2.9-6.56 4.25q-2.55 1.82-5.06 3.69a303 303 0 0 1-13.67 9.44c-2.87 1.9-5.63 3.87-8.33 6-3.86 2.95-8 5.17-12.28 7.44a102 102 0 0 0-6.66 3.93 130 130 0 0 1-9.5 5.44 61 61 0 0 0-9.44 6.06c-4.02 3.15-8.37 5.27-12.96 7.49a190 190 0 0 0-16.65 9.09c-2.82 1.63-5.61 2.64-8.68 3.67-1.77.75-1.77.75-3.77 2.75q-2.5 1.03-5 2-3.52 1.7-7 3.5a124 124 0 0 1-12 5.5 223 223 0 0 0-18.21 8.1c-4.43 2.23-9.06 4.01-13.66 5.84a231 231 0 0 0-9.63 4.06c-3.9 1.75-7.84 3.36-11.82 4.93q-4.45 1.8-8.87 3.7A88 88 0 0 1 1150 716l-1-2 11.43-4.59 1.85-.75q2.35-.88 4.72-1.66v-2l2.02-.7a187 187 0 0 0 17.09-7.02c2.58-1.14 5.18-2.14 7.83-3.1a57 57 0 0 0 14.8-7.84c3.44-2.04 7.07-2.75 10.97-3.5 2.94-1.08 2.94-2.13 4.29-4.84 2.47-1.34 2.47-1.34 5.5-2.5l1.62-.63L1236 673l5.13-2.12a84 84 0 0 1 9.62-3.2 78 78 0 0 0 12.56-5.05l1.78-.85a26 26 0 0 0 7.56-5.35 22 22 0 0 1 6.66-4.87 56 56 0 0 0 8.88-5.75c2.33-1.82 4.27-3.16 7.12-4 2.87-.86 3.76-1.62 5.69-3.81 3.56-3 3.56-3 7-3v-2l1.57-.77c6.07-3 6.07-3 8.8-4.8 2.54-1.38 4.54-1.93 7.32-2.62 5.37-1.5 8.77-4.31 12.95-7.9a25 25 0 0 1 6.3-3.78c5.48-2.48 9.54-6.9 13.85-10.99A70 70 0 0 1 1371 593q2.11-1.69 4.16-3.44c2.28-1.94 4.63-3.8 6.96-5.68 8.94-7.28 17.63-14.89 24.88-23.88l1.87-2.32A91 91 0 0 0 1417 546l2-2.81c2.05-3 3.13-6.1 4.2-9.55l.8-1.64 3-1 .15-1.72c.36-3.7.72-7.19 1.91-10.72 2.66-8.78 1.05-19.47-3.08-27.56q-1.01-1.64-2.06-3.27c-1.76-3.32-2.29-7.05-2.92-10.73h-2c-3.37-4.52-5.47-8.72-6.75-14.25-1.1-4.07-3.4-7.12-5.79-10.55-3.2-4.8-4.77-9.5-5.46-15.2"/><path fill="#aaa9ab" d="M1420 1441h6v236l-5 1c-1-1-1-1-1.12-2.64v-20.14l.01-22.03.02-21.77a56197 56197 0 0 1 .04-69.64v-12.74z"/><path fill="#89898a" d="m1049 869-1 3-10 1v2h10l-1 4-1.57.43q-3.57.97-7.12 1.95l-2.47.67c-6.35 1.75-6.35 1.75-9.41 3.03-3.44 1.3-6.93 1.7-10.56 2.23a57 57 0 0 0-17.48 5.5c-6.13 3.05-12.9 4.65-19.46 6.5a640 640 0 0 0-13.4 3.96c-10.42 3.18-20.75 6.3-31.63 7.42-4.72.77-9.23 2.69-13.7 4.35-3.63 1.09-7.07 1.48-10.83 1.84a71 71 0 0 0-17.73 4.24c-3.55 1.19-6.92 1.53-10.64 1.88l-1 1c-2.72.66-5.5 1.09-8.25 1.56l-2.32.42c-3.85.68-7.5 1.2-11.43 1.02v2a958 958 0 0 1-37 6c4.35-3.2 8.97-4.29 14.25-5.06 5.65-.84 5.65-.84 6.75-1.94q3.71-.75 7.44-1.37a99 99 0 0 0 13.87-3.25c4.39-1.43 8.58-1.82 13.16-2.13 2.77-.27 5.15-.92 7.78-1.78 2.83-.76 5.72-.92 8.64-1.13 2.11-.34 2.11-.34 3.9-1.28 2.54-1.22 4.92-1.77 7.67-2.35l3.18-.66 3.3-.67 3.28-.7c4.68-.97 8.99-1.82 13.78-1.68l1-4 3.1-.37 4.09-.5 2.04-.24c3.92-.5 7.1-1.38 10.77-2.89 2.52-.6 5.07-1.05 7.63-1.5l2.12-.38 4.24-.76 5.5-1L976 898v-3l12-2c-1.57-1.57-3.33-1.3-5.5-1.5-4.39-.39-4.39-.39-5.5-1.5-6.81-.66-12.03-.47-17.92 3.05-3.43 1.57-6.94 1.6-10.66 1.77C946 895 946 895 943 896q-2.77.22-5.56.38c-5.04.37-9.33 1.4-14.1 2.99-3.54.95-6.7.83-10.34.63 2.55-1.96 4.98-2.64 8.09-3.4l9.57-2.35c6.57-1.6 13.15-3.17 19.75-4.6 2.59-.65 2.59-.65 5.56-1.67 3-.97 5.78-1.46 8.9-1.86 17-2.41 33.9-7.7 50.13-13.12q3.8-.22 7.63-.37c5.53-.27 10.15-.94 15.33-3 3.62-1.12 7.3-.87 11.04-.63"/><path fill="#010102" d="M1079 2h57l1 3 2.55.08 3.33.17 3.3.14L1149 6l2 4h9l1 3 1.68.08c2.18.17 4.2.38 6.32.92 1.5 2.06 1.5 2.06 2 4l1.69-.87A64 64 0 0 1 1180 14l2 1v3h-6l1 3 1.75.75c3.43 1.9 5.14 5.01 7.25 8.25-7.24-.35-7.24-.35-10.19-2.87L1174 25c-2.92-2.13-5.14-3.12-8.69-3.5L1162 21l-.93-1.5c-1.07-1.5-1.07-1.5-3.16-1.94l-2.41-.12c-4.3-.23-4.3-.23-6.5-2.44l-1 3-5.87-.44-3.31-.24C1136 17 1136 17 1134 16v-2q-.6.73-1.2 1.48c-2.3 1.95-3.5 1.9-6.5 1.9l-2.88.04-3.1-.02h-9.89q-5.12-.02-10.25.01l-6.5-.01-3.1.02-2.88-.03h-2.54c-2.66-.48-3.53-1.28-5.16-3.39-.69-2.19-.69-2.19-1-4v3h-25l1-7h22z"/><path fill="#a680d8" d="m544 614 3 1-.1 2.17a1515 1515 0 0 0-1 62.2q0 11.65.03 23.3l.02 20.12L546 764l-11.6-.44-3.3-.12c-6.24-.24-12.15-.75-18.28-1.98-3.5-.57-6.96-.73-10.5-.9-8.6-.42-16.9-1.89-25.32-3.56l-2.86-.54c-4.5-.9-8.14-2.2-12.14-4.46v-1c4.54-.25 8.17-.1 12.5 1.31 12.57 4.05 25.37 5.22 38.5 5.69v-2l5.96.68C521 757 521 757 524 758v-2l5.31-1 3-.56C535 754 535 754 538 754v-2.09q-.1-25.11-.16-50.22-.01-12.15-.07-24.29-.05-10.57-.05-21.16 0-5.61-.04-11.21-.02-5.27-.02-10.55 0-1.93-.02-3.87v-8.28c.36-2.33.36-2.33 1.7-3.66C541 618 541 618 544 618z"/><path fill="#191922" d="m887 1367 2 1a49 49 0 0 1-9.56 6.5 31 31 0 0 0-8.44 6.5l-2.24 1.33c-3.09 1.87-5.82 4-8.63 6.23a286 286 0 0 1-20.37 14.26 146 146 0 0 0-5.43 3.9c-2.54 1.82-4.65 3-7.77 3.6l-2.06.42-1.5.26v3c-1.98 1.54-1.98 1.54-4.75 3.19l-4.66 2.82q-2.3 1.45-4.54 3.01a87 87 0 0 1-15.67 8.98c-7 3.01-12.65 7.22-18 12.65-1.9 1.87-4.08 3.01-6.38 4.35-3.83 2.46-7.47 5.1-11.12 7.81-5.7 4.22-11.71 7.7-17.88 11.19a239 239 0 0 0-12.37 7.88l-1.93 1.27a362 362 0 0 0-11.2 7.78c-5.17 3.68-10.92 6.38-16.64 9.08-1.86.99-1.86.99-3.55 2.74-2.77 2.7-5.77 4.64-9 6.73a135 135 0 0 0-6 4.2A62 62 0 0 1 670 1513a403 403 0 0 0-6 4 509 509 0 0 1-20.48 13.2c-3.43 2.1-6.75 4.28-9.96 6.7-2.11 1.49-4.34 2.77-6.56 4.1q-2.99 1.95-5.94 3.94l-3.02 2.02-1.51 1.02-3.12 2.07a180 180 0 0 0-7.91 5.51 47 47 0 0 1-7.7 4.5c-3.49 1.83-6.73 4.07-10.02 6.22-2.78 1.72-2.78 1.72-5.73 3.13-3.51 1.83-6.3 4.03-9.3 6.59l-1.6 1.33c-2.47 2.08-4.44 3.9-6.15 6.67v-2l-16 10 4 2q-1.59.76-3.19 1.5l-1.79.84c-2.02.66-2.02.66-4.42.6-3.2.07-5.13 1.02-7.98 2.43-12.52 6.18-27.8 6.71-41.22 2.82-4.8-2.38-7.08-6.55-9.4-11.19 2.8 1.1 3.9 1.85 5.56 4.44 3.18 4.17 8.54 4.76 13.44 5.56 4.03.3 7.97.21 12 0l3.25-.13c5.56-.32 9.02-.77 13.75-3.87l2.75-1.06C538 1595 538 1595 539 1593a169 169 0 0 1 21.34-11.03c4.64-2.07 7.17-4.46 10.3-8.44 1.84-2.07 3.79-2.59 6.36-3.53 1.84-1.45 3.56-3 5.3-4.55 2.09-1.78 4.35-3.06 6.7-4.45q1.49-1.23 2.94-2.5a29 29 0 0 1 12.56-6.37c5.14-1.37 8.4-4.97 12-8.7 3.55-3.45 7.3-5.9 11.75-8.06C631 1534 631 1534 633 1532l2.13-.75c5.18-2.25 9.42-6.04 13.75-9.62C651 1520 651 1520 653 1520v-2c5.42-4.32 11.51-7.45 17.81-10.25 3.74-1.85 6.84-4.53 10.09-7.1 4.2-3.29 8.08-4.98 13.1-6.65q2.55-1.43 5-3l3.25-1.75c3.86-2.18 7.22-4.9 10.68-7.65 2.07-1.6 2.07-1.6 4.82-3.22 2.34-1.44 3.98-3.14 5.87-5.11 3.1-2.84 6.89-4.73 10.82-6.14 3.27-1.44 5.81-3.5 8.63-5.67 3.18-2.4 6.16-4.2 9.93-5.46l1-3a56 56 0 0 1 16.56-10.44c3.23-1.34 5.03-3 7.44-5.56 3.52-2.89 7.06-5.67 11.19-7.62a28 28 0 0 0 6.18-4.2c3.18-2.3 6.74-4 10.2-5.81C808 1418 808 1418 810 1416l2.7-.86c3.82-1.32 6.82-3.16 10.17-5.39l1.86-1.21q5.99-3.95 11.68-8.34c2.1-1.58 4.33-2.87 6.59-4.2l3.13-2.31a76 76 0 0 1 9.43-5.69 57 57 0 0 0 11.44-8l5-3 1-2 3-1 1-2c2.21-1.32 2.21-1.32 4.94-2.69l2.71-1.38zm-276 184 2 3-3-1zm-49 34 2 1Z"/><path fill="#0d0c11" d="m951 39 1 2c2.56.63 2.56.63 5 1l2-4h8l1 5c-4.62 3-4.62 3-8 3l-1 4h-7v4h7l1 3c-3.29.8-4.71 1.1-8 0l-.75 1.94C950 61 950 61 948 61.8c-2 .19-2 .19-4-.81v-3h7v-4h-8l-1 4h-10l-1 3-7 1 1 3 6 1-2 4-6-1v-3q-2.44.14-4.87.31l-2.75.18c-2.38.51-2.38.51-3.47 2.04L911 70c-1.73.3-1.73.3-3.62.19L904 70v4l-1.93.15-2.5.22-2.5.22L895 75l-1 2c-2.29.41-2.29.41-5.06.63l-2.79.22L884 78v4h6v2l3 1h-9l-1-2c-2.72-.41-2.72-.41-6.06-.62l-3.35-.23L871 82v2c-3.77 1.75-6.67 2.22-10.81 2.13l-2.96-.06L855 86l-1 3h-20l-1-3 2-4h18l2-4h14l2-4h10l2-4h10l2-4h-12v-3l11-1 2 3c2.63.69 2.63.69 5 1l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h10l2-4h-12v-3l11-1 2 3c2.63.69 2.63.69 5 1l.72-1.96C931 50 931 50 933.09 49.5l2.41-.06c2.4-.07 2.4-.07 4.5-.44q1.03-1.48 2-3c1.73-.3 1.73-.3 3.63-.19L949 46l2-4h-10v-3c3.67-.94 6.35-1.11 10 0m-28 30-2 4h-8v-3c3.38-1.04 6.48-1.08 10-1"/><path fill="#cdcccb" d="m1233 909 5 1v7h2c.42 25.17.42 25.17-2 36l-.5 2.38q-.88 4.04-1.81 8.06-.3 1.27-.57 2.58c-1.36 5.53-3.03 8.98-7.12 12.98h-2l-.75 2.69c-.95 3.39-3.02 5.63-5.25 8.31l-1.86 2.4a131 131 0 0 1-19.43 20.16q-1.75 1.47-3.46 3c-3.7 3.3-7.57 6.65-12.25 8.44l-2-1c5.03-4.8 9.85-8.65 16.02-11.87a22 22 0 0 0 5.62-4.64 71 71 0 0 1 3.38-3.42c7.28-6.97 13.34-14.59 18.04-23.54 1.04-1.7 2.27-2.89 3.69-4.28 3.19-3.57 2.64-7.5 2.43-12.05l-.18-2.2 3-1 2-12h2v-28l-2-1-1 8-5 1-.25 1.63c-1.05 3.31-2.66 5.58-4.75 8.37h-2l-.75 2c-2.65 6.35-5.03 11.42-10.25 16l-2.19 2.19c-3.4 2.19-6.25 2.6-10.19 3.25-3.44.74-6.37 2.12-9.54 3.62a44 44 0 0 1-7.3 2.45c-1.98.55-3.9 1.23-5.84 1.93-5.17 1.75-10.46 2.82-15.8 3.91-3.64.75-7.1 1.63-10.62 2.8-10.58 3.48-20.4 4.7-31.52 4.85v2a87 87 0 0 1-11.62 2.81c-5.67.98-11.23 2.33-16.82 3.69-6.6 1.6-13.14 3.02-19.86 4.04-4.97.85-9.82 2.2-14.7 3.46a88 88 0 0 1-13.56 2.44c-4.37.42-10.2 1.32-13.44 4.56-2.34.14-4.66.04-7 0l.63 1.88.37 2.12c-2 2-2 2-5.12 2.13l-2.88-.13v-2l-4 1 2.59 1.24 3.35 1.63 1.7.82c4.25 2.08 4.25 2.08 5.36 4.31q2.97 1.1 6 2c-2.17.95-3.56 1.14-5.82.4q-2.62-1.13-5.18-2.4v-2l-2.12-.19c-3.8-1.07-6.04-3.16-8.88-5.81l1-3h5l.63-1.9c1.37-2.1 1.37-2.1 4.02-3.08l3.29-.64 1.77-.37c4.4-.9 8.82-1.64 13.24-2.4 6.4-1.28 12.72-2.97 19.05-4.61v-2l2.87-.58 15.4-3.13a474 474 0 0 0 26.28-5.94c4.35-1.13 8.7-1.98 13.14-2.73 7.46-1.29 14.6-3.4 21.85-5.56 5.48-1.6 10.47-2.73 16.2-2.76 2.26-.3 2.26-.3 5.15-1.8 3.44-1.66 5.58-1.8 9.36-1.81 6.71-.25 12.48-1.98 18.77-4.26 3.95-1.43 7.85-2.7 11.95-3.66 12.06-2.83 12.06-2.83 15.2-5.86 1.33-2.22 1.33-2.22 2.17-4.34l.66-1.57h2l.69-1.94a79 79 0 0 1 11.08-18.33C1229 925 1229 925 1230 922h3v-4h-7v-4l2.38-.81c2.59-1.17 3.38-1.72 4.62-4.19"/><path fill="#121214" d="m1492.06 772.94 1.94.06c-.31 1.88-.31 1.88-1 4q-1.47 1.05-3 2l-1 3h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.26-.88-2.26-.88-5-1-2.46 1.66-2.46 1.66-4.81 4.06l-2.4 2.35c-2.15 3.11-2.15 4.88-1.79 8.59l-4 1v39l-4 2v29l-4 1v37h-3l-.04 1.57c-.27 3.94-.61 5.98-3.09 9.18a31 31 0 0 1-6.24 4.06c-3.7 2.01-6 4.62-8.67 7.84a219 219 0 0 1-7.09 7.98A253 253 0 0 0 1424 948c0-3 0-3 2-5l.8-2.25c1.56-3.58 3.83-5.9 6.51-8.69l2.87-3.02 1.42-1.5q2.1-2.33 4.05-4.78c1.7-2.08 3.3-3.58 5.6-5.01 2.15-1.68 2.69-2.46 3.28-5.18q.21-2.62.27-5.27c.2-2.3.2-2.3 1.2-3.73 1.74-2.72 1.36-5.94 1.41-9.08l.06-2.2.15-6.98.12-4.73q.14-5.79.26-11.58l-2-1 5-1 .04-2.63q.1-4.82.22-9.65l.09-4.18q.05-3 .14-6l.02-1.9c.06-1.75.06-1.75.49-4.64l1.5-.94c1.5-1.06 1.5-1.06 1.9-2.78l.03-2.09.06-2.36.02-2.56.06-2.61.12-8.28.1-5.61q.13-6.9.21-13.77l-2-1 5-1 .19-3.19c.62-4.4 2.32-6.13 5.81-8.81 2.31-.31 2.31-.31 4 0l.13-1.75c1.3-3.34 3.73-4.8 6.87-6.25 2.35-.54 4.6-.76 7-1 1-1 1-1 3.06-1.06"/><path fill="#4a4a51" d="M397 1154h1a530 530 0 0 1 1.13 37.03v16.46c0 3.97-.15 7.63-1.13 11.51-2.83-2.83-2.35-5.62-2.41-9.41l-.06-2.22q-.08-3.5-.15-7l-.12-4.74q-.15-5.8-.26-11.63c-.58 10.87-1.14 21.7-.94 32.58C394 1219 394 1219 393 1220c-2.32 12.37-2.29 25.19-2.24 37.72v20.09q.02 7.95.01 15.9 0 13.75.02 27.49l.02 28.8v10.94q0 32.25.04 64.5l.04 62.6v21.38L391 1672c-2.74-5.11-3.15-9.4-3.13-15.16v-45.12a40742 40742 0 0 1 0-57.71v-285.18c-.02-35.94-.02-35.94.58-53.3l.11-3.4c.34-8.56 1.05-17.07 1.98-25.6q.54-5.16.8-10.34c.94-17.47.94-17.47 5.66-22.19"/><path fill="#57575b" d="M472.11 1819.77c1.89.23 1.89.23 3.69 1.22 2.57 1.18 4.48 1.3 7.3 1.39l2.99.1 3.1.08c19.98.6 19.98.6 21.81 2.44q3.44.15 6.88.15l2.24.01q3.77.02 7.54.03l5.48.02 15.38.06 15.34.06a46087 46087 0 0 0 63.59.22c77.17.28 154.34.47 638.55 1.45l-1 3-2-1a90 90 0 0 0-5.07-.13h-83.51a118233 118233 0 0 0-104.3 0H513.36c-2.35.13-2.35.13-5.35 1.13q-3.31.13-6.64.1h-1.93l-10.27-.05L479 1830l-2-4h-10a89 89 0 0 1-1-5c1.7-1.7 3.84-1.18 6.11-1.23"/><path fill="#131219" d="M1487 571c4.3 3.33 6.63 7.04 7.63 12.4.33 3.89.24 7.76.12 11.66l-.06 4.17c-.23 9.92-.93 20.83-6.73 29.29-.96 1.48-.96 1.48-1.9 4.32-3.48 10.38-10.6 18.64-17.75 26.68a224 224 0 0 0-3.48 4.05c-6.84 8.05-14.73 13.6-23.88 18.75-2.6 1.48-5.11 3.06-7.64 4.68-8.99 5.43-19.19 9.27-29.31 12l-2.08.63c-5.82 1.66-12.36 2.83-18.11.47a47 47 0 0 1-4.81-3.1v-3h-2c-.8-5.21.16-7.73 3.19-12.06q1.88-2.48 3.81-4.94l1.3-1.76c2.8-3.67 6.14-6.22 9.93-8.85A14 14 0 0 0 1399 662h-3l8-7 2 1-2 3q4-3.49 8-7l1.31-1.15 4-3.54 2.5-2.2c2.19-2.11 2.19-2.11 3.86-4.38L1425 639h2l.66-1.63c1.82-3.21 4.23-5.6 6.78-8.25a210 210 0 0 0 28.99-39.31c12.5-21.34 12.5-21.34 23.57-18.81m-6 2-1 2-1.81.75c-4.61 2.64-6.54 7.79-8.48 12.52-2.82 6.85-2.82 6.85-5.16 9.17a24 24 0 0 0-3.8 5.28c-2.72 4.44-6 8-9.62 11.72a154 154 0 0 0-12.62 14.65 52 52 0 0 1-5.07 5.41 34 34 0 0 0-6 7.63c-4.94 7.8-15.02 18.56-23.88 21.75-2.71 1.19-4.3 2.68-6.31 4.83-1.91 1.97-4.11 3.58-6.25 5.29-1.71 1.63-3.35 3.3-5 5l-2 2a25 25 0 0 0-2 12l1 2c6.97 2.25 14.25 1.34 21-1q2.65-.22 5.31-.37c5.53-.4 9.82-2.05 14.69-4.63v-2l1.64-.37c8.77-2.33 15.06-5.36 20.64-12.64A13 13 0 0 1 1452 670v-2l2.25-.69c2.75-1.31 3.78-2.42 5.54-4.84 1.48-1.8 3.16-3.17 4.96-4.66 14.21-12.2 24.9-31.83 26.67-50.56.27-3.87.34-7.74.33-11.62l.01-1.87c0-7.66-.92-14.01-4.76-20.76z"/><path fill="#454449" d="M1642 659a6213 6213 0 0 1-17.6 17.76l-6.42 6.46-2.03 2.05-1.88 1.88-1.65 1.67C1611 690 1611 690 1609 690l-2 4h-2v3l5 1v3c-3.53 1.22-3.53 1.22-5.75.19L1603 700v-2c-5.32.34-7.76 3.23-11.25 6.88l-1.64 1.64c-3.97 4.06-3.97 4.06-5.11 7.48h-7l-1 3c-1.72 1.62-3.55 3.04-5.4 4.5-1.6 1.5-1.6 1.5-2.6 4.5h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.27-.9-2.27-.9-5-1-2.42 1.77-2.42 1.77-4.75 4.25l-2.36 2.45C1554 735 1554 735 1553 738h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.27-.9-2.27-.9-5-1-2.42 1.77-2.42 1.77-4.75 4.25l-2.36 2.45C1538 747 1538 747 1537 750h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.25-.94-2.25-.94-5-1-3.17 2.14-4.8 4.38-6 8h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.25-.94-2.25-.94-5-1-3.17 2.14-4.8 4.38-6 8h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.25-.94-2.25-.94-5-1-3.17 2.14-4.8 4.38-6 8h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2v-5h3l2-5h5v-3l2.38-.81c2.62-1.19 2.62-1.19 3.62-2.82l1-1.37c2.63-.19 2.63-.19 5 0l1-4 1.88-.31c2.12-.69 2.12-.69 3.06-2.25L1524 747c2.63-.19 2.63-.19 5 0l1-4 1.82-.28c2.7-.9 3.45-2 5.12-4.28 2.71-3.39 2.71-3.39 5.93-3.88q1.04.23 2.13.44l1-4 1.75-.25c2.88-.96 4.19-2.55 6.25-4.75 2.6-2.26 4.61-2.18 8-2l.29-1.8c.86-2.67 1.92-3.58 4.09-5.33l1.83-1.5c3.56-2.72 5.18-3.62 9.79-3.37l1-4 2.38-.19 2.62-.81c1.19-2.37 1.19-2.37 2-5 .88-1.75.88-1.75 2-3q2-.55 4-1l1-2 1.94-.37c2.06-.63 2.06-.63 3.31-2.7l.75-1.93h3l.38-1.94.62-2.06 2-1 1-3-2-1 4-3-1-4h4v-4h4v-4h3l1 4h5l.75-1.87c1.25-2.13 1.25-2.13 3.19-3.07 2.06-1.06 2.06-1.06 3.5-3.56 2.75-4.4 5.83-4.88 10.56-3.5"/><path fill="#070709" d="M111 694c1.94.38 1.94.38 4 1l1 2q2.46 1.09 5 2c-.37 2.44-.37 2.44-1 5l-2 1-.81 1.81c-1.58 2.92-3.45 4.36-6.19 6.19-2.31.25-2.31.25-4 0a11.2 11.2 0 0 0 0 8l-5 1v4l-2.81-.19c-3.19.19-3.19.19-4.75 1.5L93 729l-2.44 1.44C88 732 88 732 86 734.19c-2 1.81-2 1.81-4.75 2.06L79 736v5l-3.14 1.16c-4.04 1.73-6.64 4.15-9.61 7.34l-1.42 1.47q-2.8 2.88-5.47 5.87C58 758 58 758 55 758l.19 2.22c-.28 4.11-2.44 6.3-4.88 9.5-1.52 2.65-1.56 4.27-1.31 7.28l-2 1c-.62 2.56-.62 2.56-1 5l-4-1-.25 1.88C41 786 41 786 39.13 787.3 37 788 37 788 34 787v6h3v5h-4v6l-3 1v-7l2-1v-3l-1.81 2C28 798 28 798 25 798l-1 4h-4v6l-5 1-.81-2.37c-1.17-2.6-1.72-3.4-4.19-4.63l1-7h3v8l4-2c-.62-4.68-.62-4.68-2.56-6.31L14 794l1-4h3v5a34 34 0 0 0 12-10c.13-2.81.13-2.81-1-5-1.56-1.31-1.56-1.31-3-2l1-4h3v5a41 41 0 0 0 9.64-7.45l1.26-1.26 3.91-3.98 2.68-2.7L54 757c-1.07-2.92-1.78-4.78-4-7 .38-2.12.38-2.12 1-4h3v5h4l.68-1.68c1.68-2.95 3.94-5.08 6.38-7.38 4.8-4.54 4.8-4.54 5.94-7.94h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.25.9 2.25.9 5 1a32 32 0 0 0 5.05-4.4l1.38-1.39q1.44-1.46 2.86-2.94 2.19-2.26 4.4-4.5l2.8-2.85 1.32-1.35c2.11-2.21 3.2-3.62 4.19-6.57h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.27.9 2.27.9 5 1 2.42-1.77 2.42-1.77 4.75-4.25l2.36-2.45C114 701 114 701 115 698h-5zm-73 95h2v3l-3 1z"/><path fill="#2d2c31" d="M1565 640h2c-.56 3.24-1.41 4.93-4 7h-2l-.62 1.63c-3.78 6.51-9.2 9.47-15.76 12.86-3 1.73-5.06 3.67-7.36 6.2-1.83 1.9-3.95 3.04-6.26 4.31q-2.25 1.46-4.46 2.97-1.52 1.02-3.08 2a33 33 0 0 0-6.96 5.65c-2.9 2.76-4.7 3.4-8.5 4.38v2l-1.58.81c-7.01 3.74-14.44 8.13-20.03 13.83a13 13 0 0 1-4.7 2.92l-1.85.75q-2.55.94-5.15 1.75c-2.69.94-2.69.94-4.69 2.94q-2.1.71-4.21 1.36c-2.82 1-5.41 2.43-8.06 3.84-1.73.8-1.73.8-3.73.8l-1 3c-2.15 1.4-2.15 1.4-4.94 2.81-3.76 1.96-7.4 3.99-11 6.25a69 69 0 0 1-11 5.38c-6.86 2.74-13.87 6.2-19.02 11.63-2.4 2.28-4.51 3.14-7.6 4.3a95 95 0 0 0-14.87 7.5c-3.03 1.33-5.3 1.34-8.57 1.13 1.22-2.44 1.83-2.47 4.31-3.37 2.53-1.02 3.72-1.66 5.69-3.63 2.17-2.17 4.24-3.02 7.05-4.18 3.15-1.33 6.2-2.85 9.26-4.38l1.88-.92c2.63-1.31 4.72-2.43 6.81-4.52l5-2 2.32-1.44c3.53-2.06 7.2-3.21 11.06-4.5A25 25 0 0 0 1436 727l4.45-2.13c1.55-.87 1.55-.87 4.17-3.18 3.7-3.25 8.07-5.16 12.48-7.26 3.6-1.78 6.96-3.83 10.34-6 2.56-1.43 2.56-1.43 5.37-2.24 3.69-1.37 6.18-3.33 9.24-5.74a26 26 0 0 1 5.14-3.01c2.81-1.44 2.81-1.44 4.97-3.69 3.3-3.38 7.17-5.75 11.15-8.25l2.19-1.4q4.36-2.79 8.8-5.49l2.18-1.33 1.98-1.18c1.54-1.1 1.54-1.1 2.54-3.1l-2.31 1c-2.69 1-2.69 1-5.69 1l-1 3a78 78 0 0 1-5 2q-2.52 1.46-5 3-1.74 1.05-3.5 2.06l-1.66.98c-1.84.96-1.84.96-4.2 1.9a80 80 0 0 0-8.33 4.06l-2.88 1.56C1479 695 1479 695 1477 697l-2.12.75c-3.42 1.48-6 3.51-8.92 5.81-1.96 1.44-1.96 1.44-4.14 2.44-1.82 1-1.82 1-2.82 4l-1.68.11-2.2.2-2.17.18c-2.43.64-2.7 1.43-3.95 3.51-2.62.69-2.62.69-5 1v3l-2.05.15-2.7.23-2.67.2c-2.58.42-4.28 1.23-6.58 2.42-2.25-.37-2.25-.37-4-1l2.06-1.19a76 76 0 0 0 6.32-4.37 20 20 0 0 1 7.5-3.63l3.12-.81 1-2c1.56-.78 1.56-.78 3.44-1.5 2.52-.97 3.6-1.53 5.56-3.5 2.72-.9 5.45-1.67 8.21-2.41 3.45-.99 5.37-1.99 7.79-4.59h3v-2l1.86-.8 2.45-1.08 2.43-1.05c2.26-1.07 2.26-1.07 3.87-2.2 1.39-.87 1.39-.87 4.39-.87l.88-1.87c2-3.81 4.27-4.6 8.12-6.13q2.46-1.42 4.88-2.94a56 56 0 0 1 8.43-4.5c8.3-3.34 15.98-7.67 22.69-13.56q2.97-1.54 6-3a143 143 0 0 0 6-4c5.5-3.77 5.5-3.77 8.25-5.06 1.75-.94 1.75-.94 2.63-2.44 1.79-2.4 4.33-2.67 7.12-3.5z"/><path fill="#63616c" d="M431 1235h1c.06 66.8.03 133.6-.22 200.4v2.27l-.46 118.16v2.3l-.04 9.15-.1 24.01a42563 42563 0 0 0-.3 98.3v4.65c.12 1.76.12 1.76 1.12 3.76q.65 4.65 1.16 9.33A38 38 0 0 0 436 1718c-.37 2.31-.37 2.31-1 4l-1-5h-2c-3.97-11.09-4.56-21.25-4.49-32.95l-.01-5.66q-.01-7.74.02-15.47.02-8.36 0-16.71 0-14.46.03-28.92a16562 16562 0 0 0 .03-39.9q0-34.9.06-69.79.06-32.94.06-65.9v-12.29l.01-2.04v-4.08a134586 134586 0 0 1 .16-168.1v-2.2c.02-4.88.02-4.88 1.13-5.99q.59-3.02 1.06-6.06c.1-.55.1-.55.54-3.35z"/><path fill="#818082" d="m1327 770-7 7c2.38.13 2.38.13 5 0l2-2c1.57-.69 1.57-.69 3.4-1.29.34-.1.34-.1 2.01-.66l2.09-.67 2.09-.7c5.13-1.68 5.13-1.68 7.41-1.68l-1 4h-6l-1 3h-2v2l-12.43 4.98-2.38.96-2.51 1q-2.82 1.12-5.67 2.14a118 118 0 0 0-17.38 7.92 97 97 0 0 1-19.39 8c-3.24 1-3.24 1-5.88 2.1-3.13 1.2-6.06 1.33-9.37 1.56-1.99.34-1.99.34-3.99 2.34-2.6.41-2.6.41-5.62.63l-3.04.22-2.34.15c.74-1.95.74-1.95 2-4 1.95-.6 1.95-.6 4.13-.75l2.19-.17 1.68-.08v-3l-16 3c2.29-4.57 2.95-4.77 7.5-6.5l1.62-.63q2.43-.95 4.88-1.87l5.31-2.12 2.47-.99q2.8-1.13 5.6-2.33l1.66-.71 4.7-2.04c17.38-7.42 17.38-7.42 25.26-6.81h1l1-2.25c4.06-5.59 11.55-7.07 18-8.75 3.6-1.2 6.23-1.07 10-1"/><path fill="#989798" d="M309 654c-1.94 5.82-7.63 9.44-12.37 13.06l-1.64 1.28c-5.67 4.35-11.73 7.62-18.2 10.63-1.94 1.12-3.24 2.42-4.79 4.03a394 394 0 0 1-5 3c-3.47 2.3-6.74 4.61-9.62 7.63A27 27 0 0 1 250 699h-2l-1 3a119 119 0 0 1-6 4q-1.83 1.2-3.62 2.44l-1.67 1.12C234 711 234 711 232.08 713.42a38 38 0 0 1-6.7 6.4q-1.31 1-2.64 2.05L220 724l-2.77 2.26-2.67 2.18-2.43 2C210 732 210 732 207 733l-.86 2.29c-1.43 3.39-3.59 5.01-6.45 7.15A58 58 0 0 0 189 753l-3.6 3.56c-2.55 2.62-4.87 5.44-7.21 8.25l-1.52 1.81L173 771c0-3.94 1.27-4.84 3.81-7.81l2.48-2.94 1.34-1.58c1.92-2.34 3.75-4.74 5.58-7.14 7.38-9.62 7.38-9.62 12.16-13.12a29 29 0 0 0 3.92-4.24c2.7-3.3 5.75-5.8 9.15-8.36l3.72-2.86 1.92-1.47q5.56-4.3 11.06-8.69 5.6-4.44 11.5-8.47c1.7-1.66 1.98-3 2.36-5.32-3 1-3 1-4 3-2.87-.25-2.87-.25-6-1-1.37-2.06-1.37-2.06-2-4l3-1 1-3a56 56 0 0 0-10 5 387 387 0 0 1-7.01 4.1l-1.99.9-2-1 3.69-2.81 2.07-1.58A89 89 0 0 1 226 693l2.5-1.46a94 94 0 0 1 17.29-7.68C248 683 248 683 250 681l2.56-.5 2.44-.5 1-2c1.47-.73 1.47-.73 3.34-1.4l2.03-.76 4.26-1.54 2.03-.76 1.87-.68C271 672 271 672 272 669c1.85-.73 1.85-.73 4.06-1.19l2.23-.48L280 667v-2l8-2v-2q4.13-1.76 8.25-3.5l2.36-1.01 2.28-.96 2.1-.89c2.12-.67 3.8-.76 6.01-.64m-73 41 1 3Z"/><path fill="#19191f" d="M1542 643h12c-.5 3.71-1.03 5.66-4 8a157 157 0 0 1-5 3l-4 2.5a226 226 0 0 1-6.69 4.06c-2.31 1.44-2.31 1.44-4.6 3.35a45 45 0 0 1-8.4 5.03L1515 672c-5.62 2.73-11 5.54-16.25 8.95-1.75 1.05-1.75 1.05-4.56 2.11-2.19.94-2.19.94-3.5 2.82-3.94 4.95-10.76 6.64-16.69 8.12v2h-3l-1 3c-2.07 1.1-2.07 1.1-4.69 2.06l-2.74 1.03q-2.93 1.04-5.88 1.97c-2.69.94-2.69.94-5.38 2.57C1449 708 1449 708 1446 708l-1 3c-1.57.74-1.57.74-3.56 1.31-4.25 1.43-7.26 3.63-10.68 6.48C1429 720 1429 720 1426 720v2c-5.7 3.05-11.03 5.3-17.41 6.42-4.19.74-7.62 1.37-11.21 3.7-3.72 2.4-7.07 2.28-11.38 1.88l-1-4 9-1 1-3c1.6-.66 1.6-.66 3.5-1.06s1.9-.41 3.5-.94l1-2c1.85-.41 1.85-.41 4.06-.62l2.23-.23 1.71-.15v-3l8-1 1-3a95 95 0 0 1 5.16-1.47c2.59-.74 4.6-2.06 6.84-3.53 3.6-2.3 6.96-3.65 11-5l2-2c4.43-2.34 9.18-3.72 14-5v-2l1.5-.59 1.94-.78 1.93-.78C1466 692 1466 692 1467 690c1.78-.63 3.57-1.02 5.4-1.44 1.6-.56 1.6-.56 2.6-2.56 3.06-.62 3.06-.62 6-1v-2l1.93-.59 2.5-.78 2.5-.78C1490 680 1490 680 1491 678c3.06-.62 3.06-.62 6-1v-3a81 81 0 0 1 10.38-4l2.62-1 1-3 2.38-.31c2.62-.69 2.62-.69 3.62-2.19s1-1.5 3.63-2.19l2.37-.31 1-3a56 56 0 0 1 8.75-4c2.42-1.07 2.95-1.8 4.25-4h2v-3l3-1z"/><path fill="#1d1e25" d="M434 1103h1l.35 1.91 1.59 8.53.55 3 .54 2.87.49 2.65c.48 2.04.48 2.04 1.48 3.04a127 127 0 0 1 .62 7.56 1489 1489 0 0 1 .76 11.2l.34 5.1c.28 3.14.28 3.14.79 5.87.54 3.63.63 7.1.62 10.77l.01 2.24.02 12.8.01 14.77.02 15.94.04 27.6.09 58.55v1.88l.07 44.32v1.96l.07 47.23.01 9.82v5.88l.11 64.69.02 9.13.04 30.76a19843 19843 0 0 0 .06 44.83 4197 4197 0 0 0 .02 19.64q0 3.54.02 7.08l-.01 2.07c.04 4.55.67 8.2 3.46 11.87.81 1.44.81 1.44.44 3.63L447 1604c-4.77-3.62-5.87-8.36-7-14-.41-5.03-.39-10.03-.35-15.07l-.01-4.54q0-6.23.03-12.44v-13.44l.04-23.3q.04-16.91.05-33.83.02-27.54.07-55.08a98149 98149 0 0 0 .12-92.22v-3.2l.02-12.66.05-36.78c.15-78.06.15-78.06-.9-110.64q-.22-7.01-.36-14.02c-.34-12.9-1.83-25.67-3.39-38.46-.86-7.15-1.62-14.1-1.37-21.32"/><path fill="#898889" d="M1154 835v2l-5 2v2l3.31.13c.31 0 .31 0 1.87.07 1.82-.2 1.82-.2 3.26-1.17 2.07-1.37 3.79-1.44 6.25-1.65l2.45-.23 1.86-.15v4l-1.66.41-7.53 1.9-2.62.65c-4.59 1.18-8.15 2.51-12.19 5.04-2.68.63-2.68.63-5.44 1-4.42.6-7.59 1.98-11.56 4a90 90 0 0 1-6.87 2.13l-1.86.52a61 61 0 0 1-10.02 1.98c-3.25.37-3.25.37-5.25 2.37-2.17.2-4.27.33-6.44.38-3.5.08-3.5.08-6.56.62a83 83 0 0 0-3 4c-2.82 1.16-2.82 1.16-6.12 2.06a102 102 0 0 0-11.26 3.69c-6.25 2.17-13.1 1.48-19.62 1.25l1-3 1.71-.15 4.44-.44 1.85-.41 1-2h-8c2.43-3.64 4.73-4.22 8.69-5.5l3.85-1.3 1.84-.63a51 51 0 0 0 5.18-2.2c4.62-2.05 9.64-2.87 14.56-3.95q5.05-1.13 10.07-2.36l1.9-.44c3.73-.91 7.19-2.1 10.73-3.6 6.02-2.47 12.16-3.75 18.5-5.09 5.05-1.12 9.4-2.52 13.93-5.02 3.95-2.05 8.28-3.27 12.75-2.91"/><path fill="#0a090e" d="M1149 717c-5.6 2.3-11.18 4.43-16.93 6.29a58 58 0 0 0-5.32 2.15c-4.37 1.95-8.98 3.18-13.57 4.5-3.18 1.06-3.18 1.06-6.3 2.62a24 24 0 0 1-7.38 2.44c-3.99.8-6.93 2.1-10.5 4-3.1 1.12-6.2 2.06-9.37 2.94a75 75 0 0 0-7.5 2.56 68 68 0 0 1-10.84 2.97c-5.22 1.2-10.2 3.1-15.16 5.1-4.09 1.56-8.21 2.19-12.51 2.9-3.51.71-6.38 2.01-9.62 3.53l-4 1q-3.75 1.17-7.5 2.38a223 223 0 0 1-18.05 4.94c-2.45.68-2.45.68-4.49 1.7-2.21 1.1-4.15 1.5-6.59 1.92-6.09 1.14-11.9 2.94-17.8 4.85a102 102 0 0 1-16.8 3.68c-2.77.53-2.77.53-5.23 1.53-2.97 1.17-5.74 1.58-8.91 2-4.01.57-7.86 1.23-11.73 2.44a180 180 0 0 1-19.2 4.35l-2.17.4-4.24.72c-4.97.87-4.97.87-6.77 2.09-1.78 1.17-3.15 1.45-5.25 1.78l-4.46.72c-5.07.81-10.03 1.73-15 3.05-6.86 1.7-13.8 1.57-20.81 1.45a27 27 0 0 1 7.94-2.94q5.65-1.18 11.27-2.5l3.33-.77 6.78-1.58A535 535 0 0 1 894 788l3.06-.6 4.81-.94C904 786 904 786 907 785q2.1-.1 4.19-.06l2.17.02 1.64.04v-2l3.04-.73c7.71-1.89 15.3-3.94 22.87-6.33 8.92-2.71 18.06-4.68 27.18-6.59 5.06-1.1 9.86-2.2 14.6-4.29 5.45-2.34 11.1-3.47 16.9-4.67 3.34-.69 6.6-1.4 9.83-2.5 2.58-.89 2.58-.89 4.58-.89v-2l3.21-.62 4.16-.82 2.12-.4 2.03-.4 1.88-.37c1.6-.39 1.6-.39 3.6-1.39q3-.06 6 0v-2a27 27 0 0 1 9.19-3.06c4.7-.84 4.7-.84 5.81-1.94q3.3-.78 6.63-1.44c7.78-1.6 7.78-1.6 11.12-2.62 2.74-.8 5.39-1.36 8.19-1.88a61 61 0 0 0 11.2-3.33A36.4 36.4 0 0 1 1105 732v-2l9.38-3 2.69-.87 2.58-.82 2.38-.76c1.97-.55 1.97-.55 3.97-.55v-2l2.59-.84 3.35-1.1 1.7-.55c4.25-1.4 4.25-1.4 5.36-2.51a58 58 0 0 1 3.94-.62l2.15-.3c1.91-.08 1.91-.08 3.91.92"/><path fill="#858687" d="M1283 802a573 573 0 0 1-7 4l-2.81 1.63c-4.83 2.08-9.93 2.82-15.1 3.69-1.59.37-1.59.37-4.09 1.68-1.13 2.36-1.52 4.4-2 7l-1-2c-7.73.97-14.64 3.64-21.89 6.4a125 125 0 0 1-16.41 4.93c-7.03 1.75-13.67 4.6-20.25 7.6-3.55 1.55-7.16 2.9-10.78 4.28a145 145 0 0 0-8.48 3.48 58 58 0 0 1-9.06 2.78c-2.39.6-4.72 1.29-7.07 2.03l-2.27.72c-1.79.78-1.79.78-2.79 2.78-2.58.34-2.58.34-5.81.5-5.76.28-5.76.28-8.1 1.46a22 22 0 0 1-6.65 1.95c-5.67 1.06-11.03 2.77-16.5 4.59a161 161 0 0 1-17.89 5.02 66 66 0 0 0-8.99 3.04c-5 1.9-9.4 2.84-14.77 3.14-3.32.44-5.96 1.86-8.95 3.32-3.43 1.43-6.9 1.9-10.56 2.48a77 77 0 0 0-10.59 2.91c-2.17.58-3.96.7-6.19.59v2l-1.58.3c-6.4 1.3-12.33 3.39-18.42 5.7-4.74 1.78-9.41 2.64-14.39 3.5-2.61.5-2.61.5-5.92 1.63-3.02.98-5.53 1.02-8.69.87v-1l2.63-.77 3.5-1.04 1.72-.5a41 41 0 0 0 7.99-3.21c4.11-1.93 8.14-2.58 12.6-3.36a140 140 0 0 0 22.5-6.07c2.69-.92 5.26-1.57 8.06-2.05l1-3h-10v-2l2.15-.37 2.79-.5 2.77-.5 2.29-.63 1-2c-11.1 1.69-11.1 1.69-15.25 3.13-3.63 1.15-6.97 1.04-10.75.87 2.82-2.23 6.07-3.08 9.46-4.1l1.9-.58 6.02-1.82 9.7-2.95c3.39-1.03 6.64-2.23 9.92-3.55q2.9-.63 5.81-1.12l2.96-.51 2.23-.37c-2.7 3.43-6.13 4.32-10.12 5.63-6.78 2.27-6.78 2.27-7.88 3.37l7 1-1 3-1.71.15-2.23.23-2.21.2-1.85.42-1 2c15.22-.58 15.22-.58 21.67-3.12 4.07-1.54 8.32-2.57 12.51-3.72 2.82-1.16 2.82-1.16 4.3-3.25 1.52-1.91 1.52-1.91 4.59-2.45l5.28-.12c4.48-.17 4.48-.17 6.65-2.34q3-.58 6.06-.94c6.73-.91 12.83-2.37 18.82-5.62 3.77-1.74 7.34-2.26 11.44-2.82 2.68-.62 2.68-.62 4.93-2.02 3.86-2.25 7.87-3.11 12.19-4.16A183 183 0 0 0 1174 839l6.25-1.87c4.09-1.24 8.06-2.6 12-4.25a55 55 0 0 1 11.74-3.39c2.21-.54 3.99-1.47 6.01-2.49 2.25-.12 2.25-.12 4 0l1-3 2.05-.33 2.7-.48 2.67-.46c2.8-.8 4.35-1.92 6.58-3.73a50 50 0 0 1 10.56-2.81c5.49-1.03 10.4-2.98 15.56-5.07 2.3-.9 4.47-1.6 6.88-2.12v-2l1.69-.55 2.31-.76 2.48-.82c2.95-1.02 5.83-2.19 8.7-3.42 2.24-.55 3.65-.16 5.82.55"/><path fill="#2d2d2f" d="M559 66h77l1 4h40l1 6c-2.2 1.1-3.32 1.13-5.74 1.15l-2.33.01-10.52.03q-4.15 0-8.3.03l-5.26.01-2.5.02h-4.4C637 77 637 77 635.2 76c-3.13-1.42-6.17-1.3-9.55-1.32l-2.22-.03q-3.61-.05-7.24-.06l-5.02-.06q-6.62-.08-13.22-.13l-13.5-.14Q571.23 74.1 558 74v3h-22l-2-4 1-3h22z"/><path fill="#0f1116" d="m346 1242 3 1c1.04 3.11 1.18 5.46 1.28 8.73l.27 8.68.17 5.13.09 3.1c.19 2.36.19 2.36 1.19 3.36q.14 3 .13 6.02l.01 1.92v6.43l.02 4.6v12.5l.02 10.43v24.6q0 12.7.03 25.4a11465 11465 0 0 1 .03 34.82 2943 2943 0 0 1 .01 16.75v9.6C352 1428 352 1428 350 1432l-1-30v14c-.58 3.43-1.14 6.66-2 10h-1l-.82-96.22c-.22-24.9-.22-24.9-.1-36.68l.01-2.36q.04-5.07.15-10.13l.04-3.34.05-2.76c-.4-3.08-1.65-4.94-3.33-7.51-.39-2.14-.39-2.14-.4-4.25l-.01-2.34.04-2.41-.04-2.4c.02-4.74.49-7.69 3.41-11.6z"/><path fill="#525258" d="m1307 218 2 1c.59 2.31.74 4.62 1 7l-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v-12h3l1 11-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v-12h3l1 11-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l1.96.72c2.04 1.28 2.04 1.28 2.54 3.37l.06 2.41c.07 2.4.07 2.4.44 4.5q1.49 1.03 3 2c.4 2.39.14 4.56 0 7l4 2v-10h3l1 9-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.37.69c-1.63 1.31-1.63 1.31-2.57 3.87-1.06 2.44-1.06 2.44-3.06 3.38l-2 .06c-2-2-2-2-2.12-4.62l.12-2.38-4-1v-7l-4-1v-7l-4-1v-7l-4-1-.49-5.18c-.51-1.82-.51-1.82-2.01-3.26-1.97-2.05-1.98-3.44-2.19-6.25l-.2-2.45-.11-1.86-3-1v-8h-4v-8h-3l-.33-1.86-.48-2.45-.46-2.43c-.73-2.26-.73-2.26-2.3-3.82-1.43-1.44-1.43-1.44-1.72-3.26l.29-5.18-4-2v-7h-3c-1.42-2.84-1.38-5.66-1.62-8.8-.4-2.3-1.07-3.34-2.38-5.2-.12-2.69-.12-2.69 0-5 3-1 3-1 6 0l-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#898890" d="m392 1260 2 1c.41 2.6.41 2.6.63 6l.13 1.82.37 5.74.26 3.9.61 9.54 1-18h1a85 85 0 0 1 1.12 14.1v42.23l-.01 25.49-.02 36.84a160916 160916 0 0 1-.04 117.83v19.78L399 1677l-2-1c-2.77-13.94-2.08-28.89-2.06-43.04V1631c.01-12.06.2-24.11.44-36.17.93-47.81.77-95.64.75-143.46v-101.61c.01-9.96-.1-19.9-.51-29.86l-.1-2.42c-.44-9.55-1.57-18.93-2.52-28.48l-1 298h-1l-.05-123.67v-17.53l-.04-95.35-.02-50.23-.01-22.58v-12.19c.12-2.45.12-2.45 1.12-5.45"/><path fill="#0e0e14" d="M897.82 1598.87h42.25c13.5-.02 13.5-.02 18.63.7 3.42.47 6.52.3 9.95-.04 8.41-.72 16.82-.66 25.26-.64h19.55l14.95.02q14.12 0 28.25.02l32.18.02 66.16.05 1 2c-76.17.72-152.34 1.08-260.64 1.32h-4.04l-163.09.3-2.16.01-21.47.04-43.67.09h-2.02L527 1603c6.72-4.77 17.53-3.14 25.43-3.11h13.73l10.88.02 18.85.01 27.36.03 44.56.03h2.69l21.57.02 18.74.02h2.66l69.1.06c62.55.08 62.55.08 90.42-.92 8.28-.29 16.55-.3 24.83-.3"/><path fill="#454448" d="M302 931q4.16-.09 8.31-.12l2.38-.06q1.13 0 2.3-.02l2.1-.03c1.91.23 1.91.23 3.26 1.22 2.53 1.55 5.18 1.37 8.07 1.42l1.87.06q2.94.08 5.9.15l4 .12q4.9.14 9.81.26c-2.98 2.2-4.97 2.21-8.69 2.13l-3-.06L336 936v2l3.31-.12c5.35-.02 10.47 1.04 15.69 2.12l1-2h37c-2.9 1.94-3.56 2.26-6.8 2.41l-2.15.12-2.24.1-2.27.11q-2.78.14-5.54.26a3145 3145 0 0 0 15.78 1.72c8.12.9 16.03 1.45 24.22 1.28l-10-1v-1h155v1l-25.62.44-1.97.03A70369 70369 0 0 1 438 945a8412 8412 0 0 0 40.15 1.07c37.48.92 74.93 1.1 112.41 1.05h1.99q25.22-.03 50.45-.12v1c-8.55.85-16.98 1.14-25.57 1.13H552.7a6135 6135 0 0 1-104.99-.63c-12.31-.19-24.62-.43-36.93-.91l-2.62-.09a68 68 0 0 1-14.08-2.05c-3.77-.82-7.58-.86-11.43-.98l-5.32-.21-8.24-.29c-10-.35-19.46-.94-29.2-3.3a59 59 0 0 0-10.75-1.36l-1.84-.1L323 939v-2l-3.07-.37-4.06-.5-2.01-.24A52 52 0 0 1 302 933z"/><path fill="#5b5b5f" d="M504 210c.75 1.56.75 1.56 1 4-1.32 3-3.05 5.79-5.07 8.37-1.31 2.3-1.54 4.52-1.93 7.13-.52 3.48-1.06 5.62-3 8.5-.34 2.16-.34 2.16-.5 4.5-.22 3.26-.7 4.84-2.5 7.5q-.59 2.48-1 5c-.78 4.68-.78 4.68-2 6.46-1.47 2.26-1.54 4.45-1.87 7.1a54 54 0 0 1-4.25 14.12c-1.1 2.88-1.41 5.57-1.67 8.62A13.4 13.4 0 0 1 479 297h-2l-.87 7.19-.28 2.28q-.77 6.43-1.32 12.88c-1.34 15.57-1.34 15.57-3.1 22.09-1.84 7.02-1.94 14.08-2.14 21.31l-.08 2.8-.05 2.52C469 370 469 370 468 371c-2.1 9.88-2.54 19.94-3 30h-2l.13 2.44c0 2.68-.42 5-1.08 7.6-1.2 5.24-1.65 10.43-2.05 15.77l-1 12.63-1.21 15.5a118 118 0 0 1-3.34 20.17c-.62 2.6-.75 5.14-.91 7.8l-.24 3.44-.35 5.38-.35 5.23-.2 3.15A27 27 0 0 1 450 508h-2l.04 2.13c.05 5.73-.18 11.2-1.04 16.87-5.95.19-11.2-.43-17-1.75l-2.29-.48a49 49 0 0 1-12.9-4.8 69 69 0 0 0-5.75-2.66C406 516 406 516 405 515q-.06-2.5 0-5h2l1 5a195 195 0 0 0 27.13 7.47c1.87.53 1.87.53 3.3 1.53 2.1 1.34 4.14 1.6 6.57 2l-.1-2.32c-.2-7.7.39-15.06 1.48-22.68l.38-2.71c.7-4.65 1.73-8.83 3.24-13.29q.4-3.18.63-6.37l.22-3.22.15-2.41h2l-.07-2.04c-.19-10.28.76-20.42 1.75-30.64l.64-6.81A561 561 0 0 1 462 384h2l-.04-1.61c-.07-5.91.39-11.64 1.1-17.5l.33-2.82.7-5.85 1.72-14.64.31-2.69c.49-4 1-7.95 1.88-11.89h2l-.08-3.3c0-5.4.69-10.67 1.4-16.01l.35-2.94c2.3-17.73 2.3-17.73 5.33-20.75.54-2.74.96-5.48 1.4-8.24.6-2.76.6-2.76 1.65-4.78 1.21-2.53 1.15-4.5 1.2-7.3.48-7.31 3.15-13.32 6.04-20a96 96 0 0 0 3.07-8.85c.64-1.83.64-1.83 1.67-3.87.97-1.96.97-1.96 1.78-5.15 1.56-5.76 4.84-10.92 8.19-15.81"/><path fill="#a49bbc" d="M516 586q4.21-.04 8.44-.06l2.31-.03c4.55-.02 9.05.13 13.59.45 12.2.84 24.4.8 36.61.77h9.56q23.24.04 46.47-.9c7.34-.3 14.68-.33 22.02-.23v1a407 407 0 0 1-27.86 1.59c-16.88.52-33.76.73-50.65.94l-12.41.16L540 590v2a20321 20321 0 0 0 57.4.15 6085 6085 0 0 0 24.26.06q4.7.03 9.39.02l2.8.02A35 35 0 0 0 647 590c2.66-.24 2.66-.24 5.36-.23h3.1l3.28.03 3.4.01 8.91.05 9.11.04q8.93.03 17.84.1c-2.49 2.88-2.49 2.88-4.73 3.4l-1.92.03-2.18.06-2.34.02-2.4.06-7.62.12-5.16.1q-6.33.13-12.65.21l7 2-1 2c-4.87.97-9.67 1.18-14.62 1.19l-1.95.03c-3.98.02-6.73-.62-10.43-2.22a62 62 0 0 0-4 0l-1.85.02-1.94.03-2.24.04-2.42.03q-13.02.2-26.04.37l-15.75.23a4584 4584 0 0 1-21.06.3q-4.05.08-8.12.12l-2.42.05c-4.2.03-7.33-.25-11.16-2.19v-2h-17l-1-3-4.44.44-2.5.24C514 592 514 592 513 593c-3.42.41-6.87.52-10.31.69l-2.92.18-2.82.14-2.58.14c-2.6-.16-4.15-.84-6.37-2.15l1-2 44-1-17-2z"/><path fill="#987354" d="M1181.4 1206.87h23.54a3827 3827 0 0 1 18.07 0h3.22c2.45.12 4.43.47 6.77 1.13q3.2.28 6.41.46l3.75.24 5.87.35q2.85.16 5.7.35l3.42.2c2.64.37 3.88.69 5.85 2.4-1.52 1.52-2.87 1.13-5 1.15l-2.57.01h-2.8l-2.88.02h-6.07q-4.6.01-9.2.04l-5.91.01-2.75.02a72 72 0 0 1-15.62-1.8c-2.61-.53-5.1-.7-7.76-.68h-2.89l-2.99.04-3.15.03q-9.9.1-19.78.53l-2.21.1c-3.76.2-7.27.69-10.94 1.54-5.85 1.19-11.55 1.29-17.5 1.3l-6.84.07q-5.34.05-10.67.08-5.2.04-10.37.1h-3.24l-3 .04-2.63.01c-2.7.47-3.42 1.4-5.23 3.39-2.92.51-2.92.51-6.19.69l-3.29.2-2.52.11 8 1v1l-3.18.11-4.13.2-2.1.07c-5.2.28-5.2.28-7.38 2.16L1080 1225h-3v7l-2-2-.87 3.37c-.71 2.74-1.06 3.56-3.13 5.63-.41 2.6-.41 2.6-.62 5.62l-.23 3.04-.15 2.34h8l1 2h-2l-1 3-8.5.1c-2.5-.1-2.5-.1-3.5-1.1-.56-6.38 1.15-11.92 3-18h2l-.18-1.58c-.45-7.4 1.84-14.17 6.45-20.04 2.32-1.85 3.8-1.78 6.74-1.82l2.99-.08 3.13-.04c11.42-.2 11.42-.2 16.17-1.46 6.46-1.61 13.15-1.2 19.76-1.1l4.07.02q4.94.04 9.87.1l-1 2 19-1 1-3c8.13-1.04 16.21-1.15 24.4-1.13"/><path fill="#7c7f82" d="M519 74h11c-2.25 4.5-2.25 4.5-6 6v2c-4.4 3.53-9.59 4.1-15 5l-1 3h-8l.63 1.88L501 94l-2 2v-2h-8v4h-7l-.62 1.88c-1.62 2.48-2.74 2.73-5.52 3.5C473.73 104.76 468 107 466 111h-4l-.25 2.31c-.91 3.28-2.06 3.76-4.75 5.69l-.38 1.87c-.83 2.84-2.22 3.83-4.5 5.7-5.04 4.17-7.9 7.64-10.94 13.47C440 142 440 142 438 143l-.44 2.44c-.56 2.56-.56 2.56-2 3.62L434 150q-1.02 2-2 4l-2 1c-.62 2.56-.62 2.56-1 5l-6 1-.81-2.37c-1.17-2.6-1.72-3.4-4.19-4.63l1-7h3v8l4-2c-.62-4.68-.62-4.68-2.56-6.31L422 146l1-4h3v5c3.48-1.88 6.08-3.48 8-7-.12-2.12-.12-2.12-1-4-1.56-1.25-1.56-1.25-3-2l1-4h3v5c5.04-2.62 8.84-6.36 12.88-10.31l2.12-1.98a36 36 0 0 0 5-5.71c-.05-3.48-.05-3.48-1-6 1.75-1.06 1.75-1.06 4-2l1.94.6c2.06.4 2.06.4 3.7-.63l1.56-1.61 1.72-1.75 1.77-1.86 1.8-1.82c4.36-4.49 4.36-4.49 5.51-7.93h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.56.63 2.56.63 5 1l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h10z"/><path fill="#5b585d" d="M1721 540c2.63.38 2.63.38 5 1v8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 4h-3v-5c-3.48 1.88-6.08 3.48-8 7 .13 2.13.13 2.13 1 4a13 13 0 0 0 3 2l-1 4h-3v-5c-4.33 2.32-7.44 5.2-10.81 8.69l-1.52 1.55L1682 615c1.07 2.92 1.78 4.78 4 7-.37 2.13-.37 2.13-1 4h-3v-5h-4l-.69 1.67c-1.67 2.97-3.9 5.17-6.32 7.53l-1.47 1.47-4.64 4.58-3.16 3.13L1654 647c1.07 2.92 1.78 4.78 4 7-.37 2.13-.37 2.13-1 4h-3l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-3.06-.62-3.06-.62-6-1-.25-2.31-.25-2.31 0-5 1.81-1.5 1.81-1.5 4-3l1.56-2.69L1649 645h3v-4l3-1 .81-1.81c1.48-2.73 3.16-3.65 5.74-5.26 2.7-1.73 4.22-3.53 5.83-6.3A33 33 0 0 1 1674 619h2l1-5h3v-5l3-1 .13-2.19c1.07-3.47 2.52-4.41 5.31-6.56 4.3-3.44 5.93-7.12 7.56-12.25l2-1q1.09-2.46 2-5h3v-7l4-1v-6l4-1v-8h4v-8l4-1-.12-3.37c-.08-1.9-.08-1.9.12-3.63zm-71 113-1 5h5v-5z"/><path fill="#a5b0b8" d="M1493 822h8l.25 2.38c.75 2.62.75 2.62 2.5 3.62l2.25 1q1.32 1.24 2.56 2.56C1511 834 1511 834 1514 835v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.94 2.25-.94 2.25-1 5 2.14 3.17 4.38 4.8 8 6v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v10l4 2 1 22 3 1q.17 4.15.25 8.31l.1 2.38c.1 5.79.1 5.79-2.12 8.35-2.32 1-3.74 1.04-6.23.96l-.06-1.86q-.15-4.2-.32-8.39l-.09-2.93-.12-2.84-.1-2.6c-.33-2.53-1.13-4.14-2.31-6.38q-.36-3.21-.5-6.44c-.44-9.5-.44-9.5-3.5-12.56q-.1-2.77-.06-5.56l.02-3.07.04-2.37-4-1-1-5-3-1 1-6h-5l-.87-2.44C1505 837 1505 837 1503 836l-1 31-4 1v-2l-2-1v-2h-2z"/><path fill="#84828b" d="M425 1281h1c.96 4.84 1.14 9.46 1.12 14.39v16.81l-.02 16.2v16.93l-.05 57.7v10.88L427 1489h-1l-1-11-2 6h-1v-197h1l1 9z"/><path fill="#212023" d="M924 807v2l-2.74.4-5.38.8c-2.74.42-5.23.92-7.88 1.8-3.03.27-6.06.44-9.09.62-2.87.37-4.38 1.1-6.91 2.38-2.29.45-4.5.82-6.81 1.13l-1.8.26c-4.16.57-8.19.69-12.39.61v2a763 763 0 0 1-56.27 6.91 150 150 0 0 0-18.58 2.67c-4.13.8-8.27.83-12.46.98a35 35 0 0 0-8.42 1.9c-5.5 1.74-10.85 2.27-16.58 2.66l-5.74.45-2.52.18c-2.43.25-2.43.25-4.72.76-2.87.52-5.51.62-8.43.62l-3.46.02h-7.76l-17.65.02-23.86.02q-11.02 0-22.05.02a8639 8639 0 0 1-30.37.03c-16.68.05-33.09-.34-49.7-2.01-3.73-.35-7.46-.58-11.2-.8l-2.07-.11q-3.75-.23-7.48-.42c-7.7-.42-15.16-1.1-22.68-2.9v-1l3.01.18c64.93 3.92 131.53 7.68 196.4 1.54 5.48-.5 10.91-.82 16.4-.96 17.49-.46 34.75-3.08 52.06-5.47l5.8-.78 8.7-1.2 2.36-.32c3.64-.53 7.2-1.19 10.79-2.04a66 66 0 0 1 12.86-1.45c5.75-.27 11.24-.8 16.89-1.97 3.19-.62 6.39-1.04 9.6-1.47 5.74-.77 11.29-1.98 16.92-3.34 5.36-1.2 10.77-1.94 16.21-2.72l5.34-.85 4.88-.78A40 40 0 0 1 924 807"/><path fill="#ab6beb" d="M391 559h1l.1 6.05C392 567 392 567 391 569q-.18 1.8-.25 3.59l-.1 2.13-.17 4.43-.1 2.13-.08 1.94c-.35 2.05-1.13 3.08-2.3 4.78-.45 1.68-.45 1.68-.75 3.51l-.34 2.04-.35 2.14c-.88 5.1-1.87 9.83-3.88 14.62-.94 2.35-1.5 4.74-2.08 7.2q-1.28 5.2-2.66 10.37l-.53 2.02c-1.3 4.87-1.3 4.87-2.41 7.1q-.34 4.75-.52 9.5l-.1 2-.08 1.81c-.35 1.97-1.1 3.11-2.3 4.69l4 2 .04 2.93q.1 5.48.22 10.94.05 2.34.09 4.7c.13 8.66.48 16.6 3.02 24.94.66 2.6.74 4.83.63 7.49h-3l2 1c.78 1.74.78 1.74 1.5 3.88.9 2.66 1.84 4.84 3.5 7.12a21 21 0 0 1-7.31-5.25 93 93 0 0 0-9.2-8.3c-4.38-4.3-4.52-10.12-4.62-15.95v-7.42c0-4.8.33-9.35 1.13-14.08h2v-3h2l.15-2.78c.35-5.95.7-11.62 2.39-17.36.53-2.13.72-4.18.9-6.36a34 34 0 0 1 2.77-10.3c.82-2.27 1.3-4.43 1.73-6.8 1.02-5.33 2.29-10.58 3.62-15.84l.67-2.68c.84-3.33 1.68-6.61 2.77-9.88q.3-4.4.43-8.82c.21-4.1 1.03-6.03 3.57-9.18.66-2.59.66-2.59 1.06-5.37A57 57 0 0 1 391 559"/><path fill="#593795" d="M835 570c-19.44 8.7-19.44 8.7-27.87 9.65-2.36.39-4 1.28-6.13 2.35-2.04.34-4.04.5-6.1.66-2.12.38-3.13 1.15-4.9 2.34-2.2.6-2.2.6-4.56 1.06-2.38.47-4.27.85-6.44 1.94l12 1v1l-3.18.11-4.13.2-2.1.07c-5.25.28-5.25.28-7.1 2.13-2.49 2.49-5.67 1.99-9.05 2.05-7.18.18-7.18.18-9.44 2.44-2.21.4-2.21.4-4.94.6l-2.96.24-3.1.22-6.06.48-2.73.2C734 599 734 599 732 600q-2.75.14-5.5.13h-2.97C721 600 721 600 719 599v2h20v1l-2.56.18c-17.74 1.31-17.74 1.31-19.85 2.84-2.17 1.34-3.83 1.25-6.37 1.29l-2.8.07-5.84.1-2.8.08-2.56.04c-2.22.4-2.22.4-3.65 1.9-2.33 2.23-4.24 1.92-7.41 1.98l-3.47.09-3.63.05-7.09.17-3.18.05c-2.42.14-4.47.47-6.79 1.16l1 2c-10.06.12-19.98-.12-30-1 5.39-2.8 10.02-3.12 16-3v-2l1.46-.17a3488 3488 0 0 0 20.85-2.47q4.08-.47 8.16-.97l2.52-.29c4.74-.59 8.84-1.63 13.26-3.42 2.2-.85 4.34-1.3 6.66-1.67l2.7-.44 2.89-.45 3.07-.49q8.38-1.33 16.75-2.6l8.72-1.33 10.32-1.55 1.8-.26c3.53-.55 6.56-1.5 9.84-2.89q3.37-.72 6.75-1.31c5.72-1.06 11.2-2.52 16.75-4.25 7.77-2.42 15.42-3.65 23.5-4.44v-2l4.25-1 2.4-.56c2.18-.4 4.13-.52 6.35-.44v-2q3.38-1.05 6.75-2.06l1.92-.6 1.88-.56 1.71-.53c2.14-.3 3.7.12 5.74.75"/><path fill="#000001" d="M1415 1439h3v237h-4l-.05-89.71v-12.71l-.04-69.17-.02-36.43-.01-17.82v-7.4c.12-1.76.12-1.76 1.12-3.76"/><path fill="#a48dc5" d="m437 600 2.45.43c4.12.7 8.27 1.16 12.43 1.63l8.12.94 1 3 2.01-.25 2.68-.31 2.63-.32c2.78-.12 4.96.34 7.68.88 2.5.1 5 .05 7.5 0l5.63-.1c1.87.1 1.87.1 4.19.6 2.56.48 4.84.61 7.44.6l2.77-.01 2.95-.03h3.06l9.65-.06q6.36-.04 12.73-.06l2.97-.03h2.74l2.42-.02C542 607 542 607 544 608c2.13-.44 2.13-.44 4-1v2l19.4.08 7.06.02 2.24.01q2.65-.01 5.3-.11l1-1q3.37-.32 6.75-.5l3.73-.22 1.68-.1c1.84-.18 1.84-.18 4.12-.7 3.3-.58 6.45-.5 9.77-.36l8.26.32 4.27.16q5.21.18 10.42.4v1c-16.63 2.54-33.07 3.64-49.87 4.13l-16.77.53c-16.43.51-16.43.51-19.55 3.28L544 618c-3.25 1.25-3.25 1.25-6 2v-3l-10.7-.09h-2.29l-2.1-.02C521 617 521 617 518 618c-2.14-.18-4.2-.43-6.31-.75l-4.06-.58-2.23-.32q-5.25-.75-10.5-1.46l-5.68-.79c-4.3-.59-8.59-1.16-12.9-1.6-11.87-1.22-11.87-1.22-17.34-4.5-3.47-1.76-7.28-2.4-11.06-3.27A77 77 0 0 1 437 601z"/><path fill="#876a51" d="M1188.1 1203.68q4.57 0 9.15-.02c21.78-.12 43.22.95 64.9 3.15l8.25.81a1942 1942 0 0 1 20.45 2.1L1303 1211l1 5h-2v-3l-1.93.03c-7.6.06-15.12-.34-22.7-.87l-11.76-.81a4014 4014 0 0 0-18.03-1.25l-8.98-.62-3-.2c-2.6-.28-2.6-.28-4.58-.8-2.7-.64-5.23-.6-8-.6l-1.8-.01h-41.8c-17.38-.03-17.38-.03-22.42 1.13l-1 2c-8.78 2.53-17.96 2.22-27 2v-1l8-1a1680 1680 0 0 0-15.36-.15c-6.67-.09-12.9-.03-19.4 1.68-5.61 1.18-11.53.87-17.24.95l-3 .08-2.7.04c-2.75.48-3.6 1.22-5.3 3.4a44 44 0 0 0-5 19h-2c-1.41 6.75-2.3 13.1-2 20h7v1q-1.96.05-3.94.06l-2.21.04c-1.85-.1-1.85-.1-2.85-1.1-.68-11.35.7-21.44 5-32l.94-2.58 1-2.67.9-2.45c1.47-2.91 2.5-4.37 5.16-6.3 3.3-.94 6.6-1.31 10-1.69l3.07-.37c8.93-1 17.86-1.36 26.84-1.58 7.68-.2 15.3-.63 22.96-1.23 16.4-1.25 32.78-1.47 49.23-1.45"/><path fill="#ba9de0" d="m407 591 3 3 1-1c5.16-.3 9.1.4 14 2v2l2 1c-3.06-.43-5.76-.9-8.62-2.06-2.39-.94-3.86-1.02-6.38-.94l-.17 1.62-1.3 12.25-.23 2.25C410 613 410 613 409 615q-.37 4.2-.6 8.39c-.3 4.5-.3 4.5-1.4 5.61a215 215 0 0 0-1.2 10.24l-.53 5.14-.8 7.67a500 500 0 0 0-2.53 55.01l.01 6.13L402 728q-1.73-.39-3.44-.81l-1.93-.46L395 726l-1-3-2.12-.31c-5.23-1.25-9-3.05-11.88-7.69l9 3 1-2c1.94.38 1.94.38 4 1l1 2h3v-1.93l-.08-27.52-.03-14.11c0-7.2.4-14.28 1.11-21.44h-1l-1-17-2 1q-.08-3.37-.12-6.75l-.06-1.92c-.03-3.77.47-6.04 2.18-9.33.25-2.25.41-4.43.5-6.69.82-14.45.82-14.45 4.07-18.05L403 594l1 2z"/><path fill="#8a97a2" d="m1594 1062 1.36 1.2c3.39 2.98 6.66 5.63 10.64 7.8v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v10l4 2v42l-4 2v10l-4 2v9l-4 1-.31 1.94-.69 2.06-3 1a527 527 0 0 1 1.37-7.52 13 13 0 0 1 2.63-5.48l-2-1v-3h2v-11h-2l-1 8h-1c-.24-6.46-.24-6.46.88-8.94 1.12-1.06 1.12-1.06 3.12-1.06l.08-24.02.02-8.8.01-2.72c0-5.24-.18-10.3-1.11-15.46l-2-1v-7l-1.87-.25c-2.13-.75-2.13-.75-3.38-2.5a17 17 0 0 1-.75-7.25z"/><path fill="#424146" d="M319 644h4c-.5 6.4-.5 6.4-2.87 9.25-2.6 3.36-3.66 6.54-4.88 10.56l-.67 2.1L313 671h-1v-8l-1 2h-2v-3l-1.14.99A66 66 0 0 1 298 670l-2 2 2 2c-3.03 2.6-5.16 4.07-9 5l-.94 1.44C287 682 287 682 284.5 682.87c-2.5 1.13-2.5 1.13-3.5 3.7l-1 2.43c-2.31.63-2.31.63-5 1-2.44 1.31-2.44 1.31-5 3l-2.66 1.7c-4.77 3.18-9.1 6.95-13.5 10.61A77 77 0 0 1 243 713l-2.62 2.25L238 717l-3-1 1-2-6 2c2.93-5.57 7.16-8.5 12.25-12.04a73 73 0 0 0 6.56-5.27c2.84-2.52 5.8-4 9.19-5.69 1.26-1.2 2.53-2.4 3.72-3.67 3.66-3.8 7.73-6.7 12.28-9.33l2-1.56c2.25-1.62 4.43-2.42 7-3.44a89 89 0 0 0 13.56-8.81l1.71-1.32A58 58 0 0 0 309 654l-1.39.53-6.3 2.4-2.18.85c-3.7 1.4-7.21 2.6-11.13 3.22v2c-2.9 1.26-4.8 2-8 2v2c-2.9 1.26-4.8 2-8 2l-.13 1.83c-.87 2.17-.87 2.17-3.49 3.65l-3.25 1.27-3.25 1.3C259 678 259 678 256 678l-1 3c-1.38.5-2.76 1-4.18 1.4-2.86.95-5.44 2.44-8.09 3.9l-1.73.7-2-1c4.43-3.64 8.48-6.46 13.93-8.3a93 93 0 0 0 6.63-2.58l2.07-.86a43 43 0 0 0 5.65-3.64c3.7-2.7 7.59-4.7 11.72-6.68 4.58-2.22 9.05-4.48 13.4-7.1a22 22 0 0 1 5.1-1.84 23 23 0 0 0 6.81-2.94c3.68-2.26 7.48-3.24 11.65-4.25L318 647z"/><path fill="#3e3d49" d="M481 1176c4.55.47 4.55.47 6.31 2.56l.69 1.44-6-2 .37 170.3v1.81l.27 119.26.13 61.87.06 27.81.03 11.35v1.92c.02 2.85.23 4.94 1.14 7.68l2-2v4l6 1-1 3q1.96 2.53 4 5v3q-2.51-.44-5-1l-2.62-.56c-3.2-1.94-3.73-4.01-4.9-7.47-.48-1.97-.48-1.97-.48-4.97h-2a642446 642446 0 0 1-.08-232.57 168148 168148 0 0 1-.03-124.44l-.01-28.11v-11.46l-.01-1.95c.02-4.36.02-4.36 1.13-5.47"/><path fill="#1b1c20" d="m356 1700 .59 2.8.79 3.64.38 1.85q.55 2.38 1.24 4.71l2 1v8l4 1v10l4 1c1.13 2.5 1.13 2.5 2 5l2 1v5l4-1 1-5 3 1v5l-3 1v6l1.88.13c2.12.87 2.12.87 3.3 3.62q1.07 4.1 1.82 8.25l1.75.25c2.9.97 4.29 2.44 6.25 4.75v2l4 2v2l4 2v3l1.81.33c2.43.74 3.44 1.54 5.2 3.32l1.6 1.6 1.64 1.69 1.68 1.67c4.07 4.12 4.07 4.12 4.07 6.39h4v6l-2.37.81c-2.6 1.17-3.4 1.72-4.63 4.19l-4-1v-3h5c-2.65-5.24-6.71-9-10.84-13.1l-8.42-8.41-3.72-3.74-7.18-7.17-2.24-2.26-2.1-2.07-1.83-1.83C377 1762 377 1762 374 1761v5l-4-1c.38-1.94.38-1.94 1-4l2-1c.63-2.56.63-2.56 1-5l-4-2v8h-3l-1-7 1.44-.69C369 1752 369 1752 370 1749.7c0-3.03-.25-3.36-2.25-5.44-2-1.85-3.04-2.35-5.75-3.25v5l-4-1c.38-1.94.38-1.94 1-4l2-1c.63-2.56.63-2.56 1-5l-4-2v-10l-4-2v12h-3l-1-11 3-2c.69-2.62.69-2.62 1-5l-4-2v-12c2.22-1.11 3.56-1.08 6-1"/><path fill="#212029" d="M1318 919h1c2.13 12.12 2.67 24.23 3.1 36.51l.13 3.47c1.35 37.8 1.1 75.66 1.08 113.48l.03 25.26q.03 12.32.02 24.63 0 7.3.02 14.6v25.31q0 .99.02 2c-.06 4.38-.06 4.38-1.73 6.15-5.72 2.03-12.03.02-17.67-1.41v-1h17a137696 137696 0 0 0-1.37-125.42 13247 13247 0 0 1-.36-32.88c-.1-11.77-.35-23.23-3.27-34.7l-1 4-2-1 1-52 3 4z"/><path fill="#787780" d="M426 1262c2.94 2.94 2.47 6.12 2.62 10.08l.11 2.76c.57 15.94.4 31.9.37 47.85v15.3l-.02 26.46-.04 57.95-.02 42.44v1.88l-.04 58.4v1.87l-.03 42.47v1.8l-.03 57.76a36112 36112 0 0 0-.03 55l-.01 11.77v1.97c0 3.84.4 7.47 1.12 11.24l-1-2h-2a103 103 0 0 1-1.12-15.38v-14.23l.02-13.96v-15.07l.02-26.1.04-57.2.02-41.97v-1.86l.07-101.38v-1.78l.03-57.02a35316 35316 0 0 0 .03-54.38v-4.93c.03-8.42-.13-16.73-.9-25.12a50 50 0 0 1 .23-9.43l.3-3.02z"/><path fill="#0a0b11" d="M1218 1584c1 3 1 3-.19 5.94-1.53 2.84-3.45 4.85-5.81 7.06l-.78 1.54c-1.88 2.25-3.88 2.17-6.72 2.59-5.36.8-5.36.8-7.5 1.87q-2.1.13-4.22.11h-2.7l-2.98-.01h-3.14l-6.85-.02-11.08-.03-16-.03a21562 21562 0 0 0-44.82-.05q-30.83-.03-61.64.25l-1.76.02q-17.21.15-34.43.37-38.34.44-76.7.42h-7.85q-57.76-.03-115.54-.22l-28.84-.09-104.21-.3-15.56-.05L531 1603v-1h2.93l129.35-.24h2l43.32-.1q10.64 0 21.26-.03H732l61.18-.12c131.84-.23 131.84-.23 308.95-.95l12.08-.08a16773 16773 0 0 0 45.45-.3 197 197 0 0 0 17.67-1c5.11-.34 9.75.59 14.67 1.82 2.34.22 3.64.18 5.75-.87 2.17-1.09 3.7-1.4 6.06-1.82 5.1-1.2 8.03-4.27 11.19-8.31a41 41 0 0 0 3-6"/><path fill="#b2bec9" d="m1585 1173 4 2-1.56 2.25c-1.44 2.75-1.44 2.75-1.32 4.94 1 2.06 1.87 2.76 3.88 3.81l-1 4h-3v-5c-2.24 1-3.88 1.88-5.62 3.63L1579 1190h-2v3l5 1-1 4c-3.69-.5-5.6-1.1-8-4a24 24 0 0 0-7.69 5.44l-1.82 1.8C1562 1203 1562 1203 1561 1206h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.25-.94-2.25-.94-5-1-3.17 2.14-4.8 4.38-6 8h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.56-.62-2.56-.62-5-1l-2 4h8v3l-7 1-.69-1.44c-1.83-2.18-3.56-2.2-6.31-2.56l-2 4h-10l-2 4h-15v-7l2.38-.25 2.62-.75.78-1.66c1.81-3.48 4.55-5.88 7.35-8.59l1.7-1.68 4.17-4.07 3 1v5h-3l.19 1.81c-.19 2.19-.19 2.19-1.69 3.94l-1.5 1.25 3-1q2.1-.1 4.19-.06l2.17.02 1.64.04 1-3 3.31-.31c3.23-.45 4.57-1.15 6.69-3.69l3-1 1-3 6 1-.12-2.25a11.5 11.5 0 0 1 2.12-6.75l2-1 .19-2.87c.81-3.13.81-3.13 2.36-4.16q2.69-1.09 5.45-1.97l2-1-1 3-2 1-1 3h5v-7l1 2h5l1-3 2-1c1.13-2.06 1.13-2.06 2-4h5z"/><path fill="#9164cd" d="M412 594c3.73.53 7.14 1.68 10.69 2.94A77 77 0 0 0 434 600c4.91.89 9.5 2.42 14.21 4.05 3.24 1.1 6.5 2.05 9.79 2.95.98 3.8.76 6.2 0 10q-.32 3.71-.54 7.42l-.14 2.22-.44 7.26-1.15 18.72-.99 16.12-.8 13.16q-.74 12.09-1.54 24.18l-.14 2.15-.67 9.77c-.7 10.34-.82 20.64-.59 31h-2v-2h-2a2521 2521 0 0 1-.15-18.91c-.09-8.33.03-16.37 1.3-24.62 1-6.8 1.41-13.62 1.85-20.47l.12-1.94q.38-6 .75-12.03l1.13-17.97.12-1.95q.93-14.6 2.27-29.16l.24-2.62.22-2.26q.21-3.03.15-6.07l-1.68-.15-4.37-.44C447 608 447 608 445 606c-2.38-.41-2.38-.41-5.12-.62l-2.76-.23L435 605v-2l-2.16-.37c-8.79-1.53-8.79-1.53-12.84-2.75-3-.88-3-.88-6-.88l-.18 3.32-.26 4.37-.12 2.18c-.18 3.07-.4 5.95-1.04 8.96-.4 2.17-.4 2.17.6 5.17-.48 2.44-.48 2.44-1.3 5.34-1.78 6.7-2.85 13.42-3.82 20.28l-1.01 6.92-.44 3.04C406 661 406 661 405 663c-1.19 7.53-1.2 15.19-1.32 22.79l-.21 12.93-.16 9.57L403 727l3 1v2l-10-2v-1h5l-.01-1.44c-.21-23.28-.32-46.38 2.01-69.56l.18-1.85q.66-6.68 1.38-13.34l.2-1.8.56-4.99.32-2.87C406 629 406 629 407 628q.32-3.21.5-6.44c.37-6.3.37-6.3 1.5-8.56q.3-2.04.5-4.1l.25-2.43.75-7.5.22-2.24C411 595 411 595 412 594"/><path fill="#282831" d="m481.56 1172.94 2.44.06v2q4.32 2.18 8.75 4.13l2.42 1.07 1.83.8c-3.21 1.04-3.82 1.08-7.12-.31C487 1179 487 1179 485 1177h-4l-1 403h2l1.5 4.38.84 2.46c.66 2.16.66 2.16.66 4.16h-2l-.31-1.94-.69-2.06-3-1c-1.12-3.35-1.14-6.27-1.13-9.75v-397.73c.02-5.5.02-5.5 3.7-5.58"/><path fill="#5b5a5e" d="M231 918a45 45 0 0 1 10.06 2.44c4.9 1.72 9.79 2.34 14.92 2.93 3.02.63 3.02.63 5.4 2.09 3.52 2.07 6.93 2.57 10.93 3.23l2.25.39q2.71.48 5.44.92v2l3-.12c2.69 0 4.94.29 7.52 1.03 5.32 1.39 10.72 2 16.17 2.65l8.76 1.08q2.55.36 5.1.87c2.35.47 4.64.75 7.03.95l7.85.7 2.59.24 2.32.2c2.22.33 4.22.8 6.38 1.4 4.57 1.18 9.01 1.4 13.71 1.56l2.66.11q4.17.18 8.35.33 5.5.2 11 .44l2.5.08 2.4.1 2.07.08c2.34.27 4.48.74 6.77 1.29 4.88 1.11 9.6 1.44 14.6 1.6l2.86.11c11.99.44 23.99.63 35.98.8l6.13.09c33.33.5 66.66.72 100 .93l24.72.16L631 949v1c-32.33.93-64.64 1.16-96.98 1.13h-39.46c-10.84.01-21.64-.01-32.46-.75-7.53-.5-15.06-.65-22.6-.8l-7.86-.16-22.33-.47-18.95-.4c-9.7-.18-19.19-.71-28.8-2.08-3.67-.51-7.34-.8-11.04-1.03l-1.9-.12-7.08-.42q-2.7-.16-5.4-.34l-3.14-.18c-3.2-.4-5.96-1.3-9-2.38a92 92 0 0 0-5.75-.44 53 53 0 0 1-13.5-2.74c-3.36-1-6.74-1.67-10.19-2.32-4.44-.84-8.78-1.79-13.12-3.06-4.2-1.21-8.3-1.9-12.63-2.44-4.53-.58-8.67-1.36-12.87-3.22-2.3-.93-4.66-1.56-7.05-2.23-3.1-.9-6.15-1.97-9.2-3.05l-1.88-.64c-2.82-1-4.67-1.72-6.81-3.86"/><path fill="#35245d" d="m1303.83 350.68 2.17.32.33 1.65c1.18 5.6 2.76 10.96 4.67 16.35h-5l-1-11-2 4-2-2c-9.43 7.79-9.43 7.79-10.62 12.4.49 6.32 3.19 12.52 5.46 18.4 1 2.76 1.68 5.32 2.16 8.2-1.87-1.03-2.85-1.66-3.75-3.63l-.53-1.92-.61-2.12-.61-2.2c-2.49-8.98-2.49-8.98-5.5-12.13-1.87-.56-1.87-.56-4 0a62 62 0 0 0-5 5c-1.69 1.81-1.69 1.81-3 3h-2v2h-4v-2l-5 1-.12 1.87-.88 2.13q-3 1.05-6 2c-1.87 2.12-1.87 2.12-3 4h2c-.36 2.63-.65 4.53-2.25 6.69-2.13 1.6-4.27 2.32-6.75 3.31-3.87 2.09-7.58 4.12-10.25 7.69-1.83 2.41-3.1 3.03-5.75 4.31l-1 3c-2.72 1.78-4.77 2.23-8 2l-2 4h2l1-2c2.26-.71 2.26-.71 5.13-1.38A43 43 0 0 0 1241 418h2v-2l8-1-4 2v2l-1.83.77c-4.1 1.77-7.61 3.5-11.17 6.23q-1.7.75-3.44 1.44a15 15 0 0 0-5.75 3.62c-5.6 3.86-13.14 4.09-19.81 3.94 1.26-3.77 2.79-4.73 6-7l5-2q2.54-1.95 5-4l1.54-.83c2.7-1.46 4.15-2.41 5.33-5.32a75 75 0 0 0 1.2-4.73c1.3-2.96 3.13-3.56 5.93-5.12 1.92-1.58 3.7-3.29 5.5-5a50 50 0 0 1 10.5-8 184 184 0 0 0 11.86-8.44 119 119 0 0 1 6.64-4.44c4.02-2.63 7.23-5.67 10.55-9.13a85 85 0 0 1 5.76-5.3 89 89 0 0 0 6.96-6.57l1.47-1.53q1.43-1.5 2.82-3.03c2.26-2.38 3.46-3.5 6.77-3.88"/><path fill="#4b4a54" d="M1260 1482h7c1.14 3.42 1.14 6.46 1.13 10.02v13.17c.01 6.54-.09 13.02-.63 19.55-.69 8.6-.65 17.19-.63 25.8v8.45c0 9.12.08 18.17.96 27.25q.18 2.37.17 4.76l-2 2-1-4c-2.65 2.65-2.32 4.18-2.5 7.88-.2 3.95-.47 6.71-2.5 10.12l-1.33 2.4c-2.82 4.83-5.47 8.9-9.67 12.6v-5l1.94-.69c2.06-1.31 2.06-1.31 2.62-3.62l.44-2.69a85 85 0 0 1 4.56-7.26c1.67-3.17 1.93-5.5 2.13-9.05l.2-3.24.11-2.45h-2l-1 6c-1.06-3.24-.9-5.33-.08-8.62 1.13-5.22 1.39-10.18 1.4-15.51l.03-2.9.06-9.38.06-6.55q.08-8.58.13-17.16l.14-17.53q.14-17.17.26-34.35m-13 140 2 1-7 6v-3c2.5-2.19 2.5-2.19 5-4"/><path fill="#37363a" d="M413 808c5.37-.34 9.62-.07 14.58 2 8.34 3.43 16.93 6.24 25.86 7.54 2.61.47 5.11 1.16 7.65 1.91 8.47 2.43 17.18 4.63 25.97 5.34 2.06.22 3.99.65 6 1.15 5.56 1.3 11.19 1.89 16.85 2.56q5.37.66 10.71 1.5c6.82 1 13.65 1.63 20.51 2.21q6.6.56 13.18 1.24a592 592 0 0 0 37.55 2.79c20.96.97 41.9 1.07 62.88 1.02h15.7c19.44.04 38.84-.04 58.25-1.21l2.15-.13c5.8-.37 11.46-.76 17.16-1.92q2.19-.2 4.39-.3l2.53-.12 2.7-.12 2.8-.13 5.89-.26 14.68-.67 2.68-.12q7.65-.34 15.33-.28v1l-2.65.24-20.6 1.9q-1.05.11-2.15.21-6.6.63-13.18 1.42a419 419 0 0 1-31.54 2.36l-2.99.12c-22.65.93-45.31.94-67.98.94h-5.77c-66.67.03-66.67.03-90.67-2.92q-5.92-.71-11.85-1.36l-2.3-.26a232 232 0 0 0-18.07-1.21c-8.48-.27-16.58-1.48-24.91-3.02-4.3-.77-8.6-1.4-12.9-2.04l-2.45-.38q-5.8-.88-11.6-1.47a80 80 0 0 1-12.01-2.34l-2.07-.54-4.22-1.1q-3.03-.8-6.07-1.56a182 182 0 0 1-19.63-6.1c-3.1-1.15-6.2-2.04-9.39-2.89v-2l-3.25.13c-3.08 0-5.1-.44-7.75-2.13z"/><path fill="#6e6d70" d="m1233.85 907.9 2.21.04 2.23.02 1.71.04c2.9 18.07 4.22 38.68-3 56-1.55 1.13-1.55 1.13-3 2l.35-1.42q.8-3.25 1.59-6.52l.55-2.23c2.22-9.24 2.7-18.41 3.07-27.9l.13-3.2.31-7.73h-2l-1-7c-2.7.46-5.08 1.03-7.63 2.06-2.37.94-2.37.94-5.37.94v2l-2.34.73-3.16 1.02-3.1.98a55 55 0 0 0-7.28 3.22 95 95 0 0 1-12.62 5.18l-2.14.76c-3.46 1.2-6.88 2.27-10.44 3.11a70 70 0 0 0-11.1 3.88c-9.3 3.81-18.6 6.8-28.5 8.63-4.82 1.02-9.4 2.77-14.01 4.5-4.27 1.6-8.4 3.1-12.87 3.99-4.1.83-7.72 2.25-11.57 3.86-7.3 2.9-14.85 5.49-22.5 7.33-3.37.81-3.37.81-5.58 1.81-3.58 1.28-6.96 1.5-10.73 1.81a67 67 0 0 0-19.62 4.97c-3.52 1.37-7.11 1.99-10.81 2.68-4.35.9-8.57 2.2-12.82 3.48A226 226 0 0 1 990 984c4.47-3.3 9.52-4.44 14.87-5.5 5.47-1.13 10.83-2.29 16.09-4.22 5.63-2.03 11.08-2.97 17.02-3.65 3.25-.68 5.24-1.88 8.02-3.63 2.14-.62 2.14-.62 4.25-1 4.74-.95 9.19-2.41 13.75-4l7.25-2.35C1073 959 1073 959 1074 958q2.52-.34 5.06-.56l2.79-.26 2.15-.18v-2l1.98-.33c7.45-1.35 7.45-1.35 10.83-3.23 3.42-1.54 5.66-1.7 9.38-1.88 5.4-.31 9.65-1.3 14.65-3.31 4.08-1.61 8.2-3.03 12.35-4.44l2.27-.8a52 52 0 0 1 12.76-2.76c2.53-.36 4.62-1.35 6.92-2.42a77 77 0 0 1 6.61-2.52l2.23-.76c2.02-.55 2.02-.55 5.02-.55v-2l3.08-.92c9.25-2.8 18.46-5.69 27.05-10.2 2.24-1.06 4.37-1.18 6.8-1.47 4.9-.97 9.68-3.15 14.08-5.47a27 27 0 0 1 6.68-2c2.59-.52 4.75-1.91 7.16-2.04"/><path fill="#191721" d="M851 553v1l-9.44 2.25-2.86.68q-6.88 1.64-13.77 3.26l-2.34.54-4.35 1.01c-2.8.66-5.51 1.35-8.24 2.26-2.34.13-4.65.04-7 0v2c-5.45 1.82-10.22 2.23-15.94 2.31-10.06.31-19.55 2.2-29.34 4.44a224 224 0 0 1-30.23 4.46c-4.25.4-8.38.93-12.56 1.75-7.5 1.43-15.08 1.97-22.68 2.6l-10.7.92c-3 .26-5.94.58-8.92 1.05-4.4.65-8.8.68-13.25.75l-22.56.44-2.64.04-2.41.05-2.14.04C628 585 628 585 627 586c-2.3.1-4.56.14-6.86.13h-38.67c-21.84.03-43.64-.27-65.47-1.13v-2l-2.34.07c-8.52.14-16.57-.7-24.97-2.07l-3.73-.58q-4.48-.7-8.96-1.42v-1c8.45-.32 16.47-.07 24.81 1.38 35.84 5.84 73 3.95 109.19 3.62l2.74-.02c19.36-.17 38.65-1.43 57.95-2.73l5.71-.37c23.94-1.57 23.94-1.57 35.63-4.22a95 95 0 0 1 12.4-1.72c4.26-.38 8.34-.82 12.47-1.93 4.71-1.26 9.44-1.66 14.29-2.07 11.74-1.05 11.74-1.05 14.81-1.94l1-2 2.06-.04q4.69-.13 9.38-.27l3.24-.07c5.9-.2 10.86-.85 16.4-2.94 2.57-.9 5.01-1.12 7.73-1.24 4.8-.23 4.8-.23 6.98-1.38 3.44-1.65 7.08-1.95 10.84-2.5a103 103 0 0 0 17.25-4c3.43-.9 6.6-.76 10.12-.56"/><path fill="#838386" d="M996 112c.2 1.84.2 1.84 0 4-1.36 1.41-1.36 1.41-3.19 2.63l-1.79 1.22C989 121 989 121 986.56 121.96a43 43 0 0 0-7.31 3.98 40 40 0 0 1-10.19 4.69c-3.65 1.08-5 2.08-7.06 5.37-5.44 3.84-11.82 6.3-18.12 8.38-13.65 4.51-13.65 4.51-18.3 9.25-2.61 2.26-5.75 3.14-8.96 4.3-3.68 1.5-6.92 3.58-10.29 5.67A50 50 0 0 1 897 168l-2-1 4-4h-7l1.75-.81c2.25-1.19 2.25-1.19 4.13-2.75 2.66-1.8 4.96-2.08 8.12-2.44l1-3 7-1 1-3 7-1v-3l5-1-7-2v-1l2.88-.31L926 141q.46-.75.95-1.5c1.05-1.5 1.05-1.5 2.92-1.94l2.13-.12c3.8-.23 3.8-.23 6-2.44 2.44-.82 4.9-1.46 7.4-2.12 2.6-.88 2.6-.88 4.98-2.48 3.32-1.78 6.1-1.63 9.75-1.4 6.4.4 6.4.4 8.52-1.4L970 126c2.12-.84 2.12-.84 4.44-1.5A24 24 0 0 0 983 120c2.75-.69 2.75-.69 5-1v-2c-13.54 3.62-13.54 3.62-18.37 6.06-5.47 2.64-11.41 3.34-17.35 4.33C949 128 949 128 948 129c-2.78.28-5.55.45-8.34.62-2.61.37-3.6.87-5.66 2.38-2.19-.37-2.19-.37-4-1 8.34-4.74 17.51-6.4 27-7v-2c4.02-1.87 8.03-2.67 12.35-3.5 3.9-.74 7.78-1.62 11.65-2.5v-2a39 39 0 0 1 15-2"/><path fill="#8b8b8e" d="M436 337h1v24l-3 1 .03 2.17.1 9.7.05 3.42q0 1.62.02 3.26l.03 3.02C434 386 434 386 432 388q.39 3.01 1 6 .32 3.43.56 6.88l.13 1.8.31 4.32-2 1-1-10-.11 2.7-.2 3.55-.07 1.78c-.18 2.82-.31 4.59-2.13 6.82-1.91 2.76-1.87 4.5-1.9 7.83l-.06 3.31-.03 3.45q-.03 3.38-.1 6.74 0 1.5-.02 3.02c-.44 3.24-1.68 5.04-3.38 7.8-.37 2.3-.37 2.3-.37 4.65l-.05 2.6-.02 5.36c-.16 7.35-.16 7.35-2.52 10.57-2.47 3.42-2.84 5.91-3.23 10-.74 6.6-.74 6.6-1.81 9.82l-3 1q-.09-2-.12-4l-.08-2.25c.17-2.29.5-3.96 1.11-6.14 1.68-6.33 2.01-12.91 2.59-19.42.72-8 1.5-15.85 3.04-23.74.68-3.65 1.07-7.32 1.46-11.01.58-5.3 1.35-10.44 2.5-15.64.84-4.73 1.23-9.51 1.63-14.3.42-4.39 1.3-7.74 3.18-11.72 1.29-3.3 1.41-6.77 1.69-10.28 1.1-11.46 2.87-22.7 7-33.5"/><path fill="#4d4c59" d="m484 1181 3 1-.5 1.58c-.63 3.04-.64 5.98-.64 9.07v2.03l-.03 11.64-.03 13.45q0 7.26-.04 14.5l-.05 25.12-.08 36.32a160752 160752 0 0 0-.26 116.17v1.79l-.04 17.72q-.18 74.3-.33 148.61l-2 1c-1.1-3.55-1.13-6.98-1.11-10.66v-2.03l.02-6.78v-4.9l.04-13.47.04-14.53a36492 36492 0 0 1 .14-54.9l.17-67.52.14-55.6.16-62.41.01-6.65.25-93.27v-1.9c.03-4.27.03-4.27 1.14-5.38"/><path fill="#363540" d="M819 1056v1l-18 2v2l2.05-.29 2.7-.34 2.67-.35c2.8-.02 4.21.53 6.58 1.98l-1.51.04q-3.4.13-6.8.27l-2.38.07-2.3.1-2.1.09c-2.46.55-3.37 1.48-4.91 3.43-.69 2.69-.69 2.69-1 5l-3-3c-2.92 5.6-2.92 5.6-3.37 8-.86 2.75-2.55 4.43-4.47 6.54C782 1084 782 1084 781 1087l-2 1c-.71 1.45-.71 1.45-1.37 3.25-1.38 3.43-3.29 5.88-5.63 8.75-1.25 2.81-1.25 2.81-2 5l3 1-1.2 1.57-1.61 2.12-1.58 2.07c-1.61 2.24-1.61 2.24-3.04 4.7a47 47 0 0 1-5.82 7.35l-2.4 2.63L755 1129q-1.88 2.12-3.75 4.25l-1.7 1.94a88 88 0 0 0-2.85 3.5c-3.03 3.93-6.25 6.13-10.7 8.31l-1-2-5 1-1 3-5 3a174 174 0 0 0-3.94 3.44 67 67 0 0 1-12.68 9c-2.76 1.81-4.36 3.98-6.38 6.56h-2v2l-2.81.88c-3.19 1.12-3.19 1.12-4.47 2.07-2.62 1.6-5.53 2.17-8.47 2.92-4.68 1.27-8.66 2.64-12.77 5.27-2 1.16-4.11 1.68-6.35 2.23a72 72 0 0 0-6.7 2.45 73 73 0 0 1-17.29 4.82c-2.14.36-2.14.36-4.14 1.36q-2.06.1-4.12.06l-2.2-.02-1.68-.04c3.58-3.58 8.04-4.02 12.88-4.75 5.04-.85 9.51-2.44 14.25-4.31 5.61-2.23 10.75-3.9 16.87-3.94v-2l2.67-1.02 3.52-1.36 1.75-.67 7.92-3.05c2.14-.9 2.14-.9 3.14-1.9q3-.06 6 0l.63-1.69c2.84-4.78 7.63-7.58 12.37-10.31 4.46-2.66 7.29-5.39 10.56-9.4 1.88-2.08 4.01-3.23 6.44-4.6a277 277 0 0 0 5-5l3.34-3.3 3.6-3.58 1.8-1.77c3.48-3.46 6.75-7 9.84-10.81 2.02-2.19 4.37-3.7 6.82-5.36 3.92-2.89 6.17-7.06 8.6-11.18q1.5-2.32 3-4.62l1.5-2.3c1.5-2.08 1.5-2.08 3.13-3.67 2.05-2.1 2.47-4.64 3.37-7.41q1.44-2.05 3-4l1.06-2.19c.94-1.81.94-1.81 2.94-2.81.9-2.06 1.69-4.08 2.44-6.19 4.1-11.35 4.1-11.35 7.56-14.81 2.36-.31 4.63-.51 7-.62l2.03-.12q7.98-.42 15.97-.26"/><path fill="#343438" d="m1507 1242 2 1c.59 2.31.74 4.62 1 7l-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v10l4 2v12l4 1v45l-4 2v14l-7 1q-.08-3.12-.12-6.25l-.06-1.78c-.02-2.59-.03-4.56 1.18-6.89 1.16-2.4 1.24-4 1.23-6.66v-2.73l-.03-2.92-.01-3.01q-.02-4.76-.07-9.51l-.02-6.45q-.03-7.9-.1-15.8h-3v-13h-2c-1-3.01-1.1-5.04-1.06-8.19l.02-2.73.04-2.08h-4v-8h-4l1-7c3-1 3-1 6 0l-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#1c1c20" d="m303 583 1 2 2.5.81c2.5 1.19 2.5 1.19 3.44 3.69l.06 2.5c-2 2-2 2-4.62 2.13L303 594l1 3 6 1-1 3-7 1v-4h-7v4h-9l-2 4h-5v3h6v1h-6l-1 3v-3h-7v4h-8v3h-8v3l-1.69.48c-4.25 1.3-7.86 2.72-11.56 5.2-3.04 2.04-5 2.9-8.75 2.32l-1 4h-6l1-6h7l1-3 3-2 1-3h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.56.63 2.56.63 5 1l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.31 1.56 1.31 1.56 3.69 2.5 2.62.06 2.62.06 4.8-1.88L281 598h2l1-4h10l1-4h6l2-4h-9v-3c3.31-1.1 5.66-1 9 0"/><path fill="#301e55" d="M852 735v1l-1.8.37-2.39.5-2.35.5c-2.46.63-2.46.63-4.93 1.67-3.5 1.33-6.8 1.35-10.52 1.52-2.54.37-4.26.87-6.6 1.81-5.41 2.14-11.02 3.15-16.72 4.25l-3.23.66a139 139 0 0 1-18.13 2.45c-2.26.26-4 .77-6.08 1.64-9.83 4.08-20.96 4.98-31.46 5.9-4.65.42-9.06 1.2-13.6 2.26a87 87 0 0 1-10.5 1.4l-2.5.25c-10.62 1-21.26 1.84-31.9 2.71-7.3.6-14.59 1.2-21.87 1.96-19.16 1.9-38.3 2.31-57.55 2.32l-36.6.05-19.42.02c-19.55.07-19.55.07-29.17-1.78a66 66 0 0 0-8.87-.77c-7.31-.45-14.44-1.77-21.62-3.13l-1.8-.33-5.1-.98-3.01-.58L482 760l-1-2c5.2-.3 10 .26 15.1 1.24l2.02.39 4.26.82c18.93 3.58 37.62 5.05 56.86 5.48l8.58.24c36.88.98 73.28-1.58 110-4.76l8.5-.75 1.85-.15A84 84 0 0 0 698 759q2.2-.18 4.4-.25l2.45-.1 5.02-.17 2.47-.1 2.2-.08c2.65-.32 5.07-1.06 7.63-1.82 2.95-.77 5.91-1 8.95-1.23l4.06-.34 2.11-.17c27.9-2.3 27.9-2.3 37.59-5.78 3.82-1.18 7.46-1.5 11.43-1.84 7.74-.75 15.1-2.2 22.63-4.12q4.41-1.1 8.9-1.96a207 207 0 0 0 13.11-3q2.7-.66 5.37-1.36A53 53 0 0 1 852 735"/><path fill="#7b7588" d="m782.19 567.94 3.29.02 2.52.04v1l-1.72.37c-4.14.9-8.21 1.87-12.28 3.06-12.95 3.69-24.63 4.73-38 4.57v2l-2.66.24-14.19 1.33-6.2.58q-.92.07-1.86.17c-3.99.38-7.9.98-11.84 1.68-6.08 1.04-12.1 1.5-18.25 1.82l-3.38.18-7.06.38-10.49.56c-25.47 1.39-50.93 2.3-76.45 2.31h-1.84c-20.62.02-41.2-.5-61.8-1.47q-3.04-.14-6.1-.27-4.8-.21-9.62-.47-2.59-.13-5.18-.24a61 61 0 0 1-16.9-3.12c-4.22-1.32-8.62-1.5-13.01-1.85a33 33 0 0 1-11.52-3.13c-3.44-1.45-7.2-1.62-10.88-2.08-2.96-.66-4.5-1.63-6.77-3.62 5.6-.25 10.38.35 15.75 1.88 6.69 1.85 13.4 3.06 20.25 4.12l3.28.52 10.16 1.54 3.4.53A272 272 0 0 0 516 583v2a50150 50150 0 0 0 63.5.08l26.84.03h10.36l3.13.01h5.31C627 585 627 585 628 584q4.08-.18 8.15-.21l5.2-.08 2.76-.04c16.2-.25 32-1.1 48.05-3.3 4.75-.62 9.51-1.02 14.28-1.43a157 157 0 0 0 17.62-2.45c3.17-.53 6.3-.81 9.5-1.09a258 258 0 0 0 27.5-3.96l1.74-.34c16.3-3.2 16.3-3.2 19.39-3.16"/><path fill="#121218" d="M903 496v2a37 37 0 0 1-10.5 2.88c-4.55.7-8.83 1.75-13.19 3.18A58 58 0 0 1 864 507v2l-2.46.55a299 299 0 0 0-19.1 4.9c-2.56.58-4.83.65-7.44.55v2l-2.45.37-3.24.5-3.2.5c-3.11.63-3.11.63-5.66 1.67-3.56 1.4-7.03 1.4-10.83 1.62-2.62.34-2.62.34-4.5 1.31a20 20 0 0 1-6.67 1.81l-2.62.36-2.7.36-5.31.72-2.37.3c-2.45.48-2.45.48-5.23 1.45-3.85 1.23-7.58 1.74-11.6 2.21l-2.41.31-7.65.95c-18.74 2.3-18.74 2.3-26.95 4.1-9.52 2.04-19.18 2.66-28.86 3.46l-11.78 1.02-2.9.25q-8.07.7-16.14 1.62l-1.8.2q-3.87.46-7.74.96c-24.43 2.97-49.28 2.07-73.84 2.02l-15.1-.02L544 545v-1l3.1-.02a14017 14017 0 0 0 43.91-.4 4317 4317 0 0 0 19.88-.18l7.7-.08h2.27c4.75-.08 8.6-.93 13.14-2.32 2.51-.3 2.51-.3 4.86-.4l2.74-.1 2.93-.1 6.45-.29 3.43-.14a769 769 0 0 0 64.53-5.53l3.68-.47q5.18-.67 10.34-1.35l3.09-.4A91 91 0 0 0 751 529q3.24-.6 6.5-1.06l3.39-.5L764 527l2.4-.35 4.6-.65v-2l1.8-.26 8.08-1.18 2.83-.4 2.72-.4 2.5-.37C791 521 791 521 793 520q2.73-.17 5.46-.29c11.05-.43 11.05-.43 16.41-2.89 3.56-1.37 7.3-2 11.03-2.73 2.7-.54 5.27-1.15 7.87-2.07a71 71 0 0 1 10.87-2.26 155 155 0 0 0 29.04-7.25 125 125 0 0 1 18.7-5c8.12-1.58 8.12-1.58 10.62-1.51"/><path fill="#717375" d="M356 487q.09 4.3.13 8.63l.05 2.44c.03 4.81-.6 8.35-2.18 12.93-.38 3.6-.41 7.2-.48 10.81-.17 4.7-1 7.14-3.52 11.19-.58 2.54-.82 5.1-1.06 7.69-.68 7.05-.68 7.05-2.94 9.31-.56 1.88-.56 1.88-1 4-.6 2.95-.8 3.8-3 6q-.45 2.3-.75 4.63c-.88 4.94-2.68 7.9-5.91 11.68C334 578 334 578 333 581l-5 1v4h-9v3c-6.43.29-6.43.29-9-2l1-5h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.31 1.56 1.31 1.56 3.6 2.4 2.71.16 2.71.16 5.17-1.36 6.26-6.1 6.26-6.1 7.12-10.3A44 44 0 0 0 334 559l4-2-.19-3.37c-.1-1.9-.1-1.9.19-3.63l1.47-.81C341 548 341 548 341.5 544.96q.1-1.8.18-3.59l.1-1.85q.12-2.25.21-4.52l4-1-.05-1.9q-.09-4.23-.14-8.48l-.07-2.98-.03-2.85-.05-2.64.34-2.15 1.49-.96L349 511c.43-2.43.43-2.43.51-5.45l.1-3.26.08-3.42.1-3.44q.12-4.2.21-8.43c3-1 3-1 6 0"/><path fill="#131318" d="M1661 430h8l2 4c4.68-.62 4.68-.62 6.31-2.56l.69-1.44 7 1v3h-8l2 4c4.68-.62 4.68-.62 6.31-2.56l.69-1.44 4 1v3h-5c1.88 3.48 3.48 6.08 7 8 2.13-.12 2.13-.12 4-1 1.25-1.56 1.25-1.56 2-3l4 1v3h-5c1.88 3.48 3.48 6.08 7 8 2.13-.12 2.13-.12 4-1 1.25-1.56 1.25-1.56 2-3l4 1v3h-5c4.2 7.4 4.2 7.4 9 9v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.94 2.25-.94 2.25-1 5 2.14 3.17 4.38 4.8 8 6v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v19c-5.4-6.75-7.46-10.51-8-19l-3-1v-6h-4l-1-5-3-1-1-3h-3l-1 2-6-1 1-3 5 1v-4h-5l-1 2v-6l-5-1-1-3c-2.06-.69-2.06-.69-4-1l-1-4-2.31-.31c-2.96-.76-3.68-1.5-5.69-3.69-2.19-1.19-2.19-1.19-4-2v-2l-3.25.19c-3.53.01-5.09-.81-7.75-3.19l-3-1c-2.27-2.5-2-3.32-2-7"/><path fill="#9ca8b0" d="M1519 904c.87 2.6 1.12 4 1.11 6.65v2.18l-.01 2.34-.04 10.02-.01 5.16L1520 943l2 1 .14-2.54q.26-4.65.55-9.31l.23-4.04q.15-2.9.35-5.79l.09-1.82c.29-4.14.29-4.14 2-5.9 1.64-.6 1.64-.6 4.64-.6v31l-4 2v23l4 1v14l4 2q.08 1.94.13 3.88l.07 2.17c-.2 1.95-.2 1.95-2.2 3.95-2.12.13-2.12.13-4 0l-.84-2.15-1.1-2.79-1.09-2.77C1524 987 1524 987 1523 986a55 55 0 0 1-.32-3.82l-.37-7.19-.31-5.99-2 1-1 19h-1l-.15-1.79-.72-8.02-.24-2.82-.26-2.7-.22-2.5c-.43-2.25-1.1-3.33-2.41-5.17-.27-2.82-.27-2.82-.25-6.12l-.02-3.26c.11-2.7.6-5.14 1.25-7.76 1.08-4.76 1.43-9.37 1.62-14.24l.12-2.57.34-8.05.57-13.17.1-2.28.09-2.01c.18-1.54.18-1.54 1.18-2.54"/><path fill="#7b7a7c" d="m1384.06 738.94 2.94.06c-1.44 2.88-3.48 3.47-6.31 4.94a52 52 0 0 0-7.84 4.7c-2.5 1.84-4.82 2.02-7.85 2.36l-1 3-8 1v2l-2 1-1 3c-2.56 1.19-2.56 1.19-5 2l.69 1.81.31 2.19c-1.44 1.75-1.44 1.75-3 3l7-1v-3l3.38-.31c1.96-.27 1.96-.27 3.62-.69l1-1.5 1-1.5c1.7-.26 3.4-.52 5.12-.7 3.04-.49 5.24-1.73 7.88-3.3l1-2-3.06.94A69 69 0 0 1 1362 759c4.25-4.7 9.02-6.48 15-8l1 4 7-1c-2.66 3.09-5.1 4.6-8.88 6.09-2.64 1.13-5.1 2.51-7.62 3.91a100 100 0 0 1-10.83 5.3c-1.9.8-3.71 1.7-5.55 2.64-3.55 1.8-7.15 3.45-10.8 5.04a217 217 0 0 0-4.82 2.2l-2.44 1.13-2.21 1.05-1.85.64-2-1 6-2v-2c1.88-2.61 2.98-3 6.19-3.69l2.81-.31 1-3q-4.2 1.43-8.37 2.88l-2.41.81-2.3.8-2.13.73c-1.79.78-1.79.78-3.79 2.78-3.12.13-3.12.13-6 0 2.15-2.82 3.98-5.15 7-7-6.4.44-12 1.7-18 4l-2.05.76c-3.27 1.28-5.2 2.2-6.95 5.24l2 2-5 1v-1c-6.86.66-13.34 2.24-20 4 1.26-2.51 2.08-2.66 4.63-3.75l2.45-1.05q6.1-2.5 12.26-4.91c4.77-1.9 9.45-3.98 14.11-6.14a254 254 0 0 1 14.9-6.04 112 112 0 0 0 13-5.84q1.8-.93 3.57-1.9a26 26 0 0 1 10.3-3.05c3.34-.6 6.26-2.19 9.28-3.7l3.78-1.83 1.86-.9c8.14-3.89 8.14-3.89 11.86-3.89l.94-1.94c1.57-3.05 1.57-3.05 5.12-3.12"/><path fill="#27282c" d="M447 260h1c.33 9.5-.02 17.93-2.52 27.16-.96 3.7-1.73 7.43-2.54 11.15-.92 4.21-1.86 8.4-2.94 12.58-1.22 4.83-2.1 9.71-3 14.61a254 254 0 0 1-4 18.5h-2l-.11 2.54c-.7 13.46-2.7 26.75-5.01 40.02-.66 3.76-1.06 7.45-1.32 11.25a95 95 0 0 1-2 13.31c-1.2 5.61-2 11.17-2.56 16.88a170 170 0 0 1-5.12 28.19c-1.38 5.15-1.54 10.5-1.88 15.81h-2l-.18 2.2c-.44 4.97-.9 9.89-1.82 14.8h-1q.17-4.27.38-8.56l.09-2.43c.21-4.32.73-8.09 2.04-12.2.99-3.65 1.21-7.43 1.55-11.19l.26-2.64q.69-7.36.97-14.74a68 68 0 0 1 3.27-19.15c1.43-4.57 1.87-8.92 2.08-13.72.22-4.4.87-8.6 1.74-12.93A295 295 0 0 0 426 347h3l.14-1.83q.31-4.15.67-8.3l.22-2.88c.47-5.44 1.4-9.12 3.97-13.99 1.05-3.71 1.84-7.47 2.63-11.25l.65-2.99c1.18-5.53 2-10.96 2.5-16.6.27-2.67.88-5.15 1.66-7.72.98-3.39 1.89-6.74 2.62-10.19.82-3.8 1.83-7.52 2.94-11.25"/><path fill="#e5e7e8" d="M1477 894c.6 1.82.6 1.82 1 4a55 55 0 0 1-6.19 8.56c-1.91 2.58-2.45 4.29-2.81 7.44l-5 1-.19 3.19c-.5 3.54-1.7 4.93-4.41 7.31-6.07 6.5-8.9 17.06-10.86 25.57a34 34 0 0 1-2.1 5.43c-3.43 7.7-2.64 16.73-2.63 25v1.87c.04 24.39.04 24.39 4.19 32.63q.48 2.8.81 5.63c.96 6.2 3.08 10.96 6.19 16.37l1.14 2.14c2.56 4.71 2.56 4.71 4.86 5.86l-1 4c-5.07-4.8-7.86-9.35-10.18-15.93-.79-2-1.74-3.72-2.82-5.57-3.53-6.78-5-15.89-5-23.5h-3q-.11-8.11-.16-16.22l-.07-5.51q-.05-3.98-.06-7.96l-.05-2.46c0-4.87.7-8.66 2.65-13.15.96-2.37 1.13-4.6 1.25-7.14.34-5 1.2-9.7 2.44-14.56h-2v-4h3l-.25-1.75c.25-2.25.25-2.25 2-4.5 2.25-1.75 2.25-1.75 4.5-2l1.75.25v-5l-3.37 1.44-1.9.8A79 79 0 0 0 1445 925c1.2-3.6 2.19-4.06 5.31-6.06l2.37-1.54c5.63-3.4 5.63-3.4 9.32-3.4v-8l1.64-.15c3.52-1.27 5.57-3.74 8.11-6.41l1.54-1.58z"/><path fill="#18181f" d="M488 1769q1.8-.09 3.63-.12l2.03-.08c2.34.2 2.34.2 4.76 1.2 4.91 1.66 10.08 1.27 15.2 1.25l3.6.01h67.45q25 .03 50.01.01h58.48l110.55-.01h223.22l43.74.01h77.59a8733 8733 0 0 0 29.19 0q4.82 0 9.65-.02 2.55 0 5.08.02c5.74-.03 11.02-.5 16.6-1.83 3.45-.68 6.72-.57 10.22-.44-6.67 5.49-20.47 4.29-28.55 4.25l-3.63.01h-9.9l-10.8.01q-9.45.02-18.9 0l-28.12.02-53.13.01h-46.92l-8.88.01-82.6.01h-28.41q-49.3-.01-98.61.02-55.41.03-110.82.02h-14.75q-21.96 0-43.92.02H553.2q-9.34.02-18.67 0H523.9q-4.84.02-9.7 0-2.53 0-5.08.02c-7.48-.06-13.94-1.3-21.11-3.4z"/><path fill="#8b8a92" d="M393 1288h1a1670 1670 0 0 1 1.2 11.6c2.24 22.52 1.94 45.16 1.9 67.76v14.03l-.03 26.46-.02 30.18L397 1500h-1l-.9-110.57q0-2.63-.04-5.25-.08-9.6-.06-19.18c-1.64 1.64-1.13 3.51-1.14 5.78l-.03 10.34-.03 10.19q0 5.48-.04 10.97l-.05 19.01-.08 27.5a90848 90848 0 0 0-.27 90.64l-.04 13.4L393 1664h-1l-.05-143.27v-20.29l-.04-110.46-.02-58.18-.01-26.15v-14.12c.12-2.53.12-2.53 1.12-3.53"/><path fill="#030306" d="M512 578h149l-4 2v2a609 609 0 0 1-27.44 1.56l-1.97.07c-15.99.48-31.98.48-47.97.5h-6.3c-25.74.05-25.74.05-36.6-.58l-2.05-.11c-2.5-.17-4.41-.31-6.67-1.44a221 221 0 0 0-7.62-.56l-2.15-.13L513 581z"/><path fill="#38373e" d="m898.07 899.9 2.5.04 2.5.02 1.93.04c-3.85 3.94-8.56 3.77-13.75 4.19l-2.7.25q-3.27.3-6.55.56v2c-2.85.95-4.98 1.2-7.96 1.38-6.12.52-11.8 1.94-17.73 3.56-7.7 2.02-15.2 3.64-23.13 4.44a60 60 0 0 0-12.78 2.83 119 119 0 0 1-40.67 5.76C777 925 777 925 775 926q-2.87.5-5.75.94c-3.3.52-6.17 1.03-9.31 2.19-4.59 1.36-9.19 1.02-13.94.87v-2l-7-1v-1l1.9-.04 8.48-.27 2.98-.07 2.85-.1 2.64-.09c2.15-.43 2.15-.43 3.17-1.94L762 922c2.02-.38 2.02-.38 4.46-.4l2.68-.07 2.8-.03q2.73-.03 5.46-.1l2.46-.02c2.64-.47 3.3-1.5 5.14-3.38 2-.43 2-.43 4.23-.51l2.43-.1 2.53-.08 2.56-.1q3.13-.12 6.25-.21v-3l8.25-.44 2.36-.12 2.28-.12 2.1-.11c2.08-.22 4-.66 6.01-1.21v-2l11.34-.09h2.43l2.24-.02C842 910 842 910 845 911c2.04-.12 2.04-.12 4.19-.44l2.17-.3L853 910v-2l-9-1v-1l18-1 1-3 14-1v2l-5.37.59-1.63.41-1 2a712 712 0 0 0 9.08-1.37c2.92-.63 2.92-.63 4.46-1.67 2.24-1.47 4.43-1.36 7.09-1.52 3.22-.21 5.56-1.4 8.44-1.54"/><path fill="#76757d" d="M505 1641q98.25-.06 196.52-.08h2.94l94.16-.03h3.11q49.99 0 99.98-.04l120.1-.04 46-.02q23.1-.03 46.2-.02l27.46-.01a8553 8553 0 0 1 28.8-.01h9.5l5.04-.01c5.16.02 9.43.2 14.19 2.26-2 2-2 2-4.62 2.13l-2.38-.13-1-2-685-1v2h30v1h-32z"/><path fill="#53525b" d="M1266 1035c2 2 2 2 2.23 4.14l-.03 2.68-.01 3.02-.05 3.28-.03 3.4-.07 7.04-.09 9.51a5364 5364 0 0 1-.24 24.58l-.17 16.96-.18 17.42-.36 33.97h-6q-.14-22.67-.2-45.35l-.1-21.06q-.06-9.18-.08-18.36 0-4.86-.04-9.72-.05-5.43-.03-10.86l-.05-3.23c.04-6.4.73-12.65 5.5-17.42"/><path fill="#101017" d="M943 1689c3.04 3.7 3.44 6.38 3.31 11.06l-.03 2.01c-.34 12.38-2.96 21.92-11.4 31.16-3 2.83-6.6 5.77-10.88 5.77v2c-6.72 2.01-12.94 2.33-19.93 2.38l-3.56.05a1930 1930 0 0 1-15.6.17l-21.03.2q-9.77.08-19.52.21-8.41.11-16.83.18l-10.02.1q-4.72.08-9.43.1-2.53 0-5.06.07c-9.83 0-18.59-2.2-25.7-9.21l-2.52-2.42-1.8-1.83c1.86-.64 1.86-.64 4-1q1.81 1.8 3.55 3.66c5.96 5.51 14.71 4.76 22.26 4.71l3.84.01q5.18.01 10.35-.01h10.84q9.1 0 18.2-.03 10.51-.03 21.04-.02a8096 8096 0 0 0 28.88-.02 1933 1933 0 0 0 13.85-.02q2.53 0 5.07-.02h2.87C920 1738 920 1738 922 1736q1.6-.72 3.25-1.31A26.4 26.4 0 0 0 938 1724q1.02-3 2-6l1.54-2.33c1.69-3.09 1.8-4.92 1.75-8.4l-.03-3.24-.07-3.34-.04-3.4q-.06-4.15-.15-8.29"/><path fill="#543488" d="m1007.6 686.8 3.02.07 3.04.06 2.34.07-1 3h-10l-2 4c-2.35 1.23-2.35 1.23-5.06 2.19-4.89 1.76-4.89 1.76-5.94 2.81-2.96.54-5.94.97-8.92 1.4A41 41 0 0 0 975 703c-1.91.34-1.91.34-3.63.5-3.37.5-3.37.5-4.78 1.47-2.09 1.35-3.96 1.57-6.4 1.97-3.23.58-5.32 1.13-8.25 2.68-4.02 1.89-7.57 1.64-11.94 1.38v2l-5 1v2l-3.03.86q-3.8 1.08-7.56 2.25a281 281 0 0 1-28.57 7.1c-3.92.8-7.3 1.52-10.9 3.34-4.17 2.06-8.35 2.48-12.94 3.07l-5.25.73-2.48.33q-4.15.6-8.27 1.32c6.31-6.31 19.75-7.15 28.18-8.5 3.38-.6 6.53-1.5 9.82-2.5q3.59-.7 7.18-1.34L903 722l1-3h-13v-1l3-.11 3.87-.2 1.98-.07c1.9-.1 1.9-.1 5.15-.62l1-1.51 1-1.49c3.02-.38 5.91-.48 8.95-.53 1.86-.06 1.86-.06 5.05-.47l1-1.52 1-1.48c2.47-.22 4.67-.28 7.12-.19l2 .04q2.44.07 4.88.15l1-4 15-1v-3l11-1 1-3 13-1v-3l2.34-.11 3.03-.2 3.03-.18 2.6-.51q.48-.75.98-1.5L991 690c2.75-.44 2.75-.44 6-.56 4.55-.18 6.62-2.34 10.6-2.64"/><path fill="#545159" d="M1500 1375h5l.81 2.38c1.17 2.59 1.72 3.38 4.19 4.62l-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 4h-3v-5c-4.87 2.53-8.39 6.1-12.23 9.97l-6.34 6.34q-3.27 3.28-6.57 6.55-2.07 2.1-4.16 4.17l-1.99 1.98-1.83 1.85-1.62 1.62C1466 1423 1466 1423 1465 1426h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.25-.94-2.25-.94-5-1-3.17 2.14-4.8 4.38-6 8h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.56-.62-2.56-.62-5-1l-2 4h8v3l-7 1-.69-1.44c-1.83-2.18-3.56-2.2-6.31-2.56l-2 4h-9c-.62-2.37-.62-2.37-1-5 2.85-2.85 5.05-2.56 9-3v-2c3-1.5 5.66-1.06 9-1l1-4 1.88-.31c2.12-.69 2.12-.69 3.06-2.25l1.06-1.44c2.63-.19 2.63-.19 5 0l1-4 1.82-.29c3.28-1.07 4.5-3.33 6.52-6.02 1.66-1.69 1.66-1.69 4.09-2.62 3.2-1.33 4.58-3.04 6.76-5.7q1.85-2.24 3.81-4.37h2l-.2-1.83c.2-2.17.2-2.17 1.49-3.6l1.77-1.32c6.38-5.14 11.19-10.47 13.94-18.25"/><path fill="#2d2d30" d="m1377 1770 2 1a43 43 0 0 1-8.63 8.79c-1.37 1.21-1.37 1.21-2.36 2.8-1.01 1.41-1.01 1.41-4.01 2.41l-.69 2.56c-1.17 4.27-4.36 6.7-7.53 9.6-1.78 1.84-1.78 1.84-2.78 4.84h-7l-1 3-3 2-1 3h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.56-.62-2.56-.62-5-1l-2 4h8v3l-7 1-.69-1.44c-1.83-2.18-3.56-2.2-6.31-2.56l-2 4h-8l-1 4h-13l-2 4h-10l-2 4h-20v-2h-2l1-3c6.6-2.61 14-3.09 21-4v-2l12-1 1-3 2.48-.11 3.27-.2 3.23-.18c3.27-.55 4.54-1.4 7.02-3.51 1.89-.91 1.89-.91 3.69-1.62 3.22-1.29 3.22-1.29 4.31-2.38 2.34-.14 4.66-.04 7 0l.06-1.81c1.11-2.6 1.7-2.8 4.19-3.94 3-1.15 5.51-1.54 8.75-1.25l.81-1.94c1.19-2.06 1.19-2.06 4.19-3.06l1.44-1.94c1.73-2.28 2.88-3.09 5.56-4.06 2.25-.12 2.25-.12 4 0v-5l1.82-.8c2.31-1.27 3.48-2.46 5.12-4.51 2.54-3.1 5.2-5.9 8.06-8.69"/><path fill="#b3b2b5" d="M1430 1446h7v3l-3 1v3.47a95741 95741 0 0 1 .11 91.48v2.01l.04 32.32.04 33.16.03 20.46a5621 5621 0 0 1 .02 22.13v8.78l.02 2.58c-.03 4.65-.5 8.3-2.26 12.61l-2-1z"/><path fill="#3d291c" d="M1062 1237c.93 3.01 1.04 3.87 0 7a95 95 0 0 0-.19 5.5l-.04 2.9c.23 2.6.23 2.6 1.25 4.01 1.62 2.62 1.05 5.54.92 8.53-.19 8.15 1.43 15.25 4.04 22.97.9 2.71 1.67 5.43 2.41 8.19 1.42 5.24 2.87 10.15 5.58 14.9 1.03 2 1.03 2 1.94 4.79 2.6 7.48 2.6 7.48 6.98 9.62 2.29.66 4.52 1.24 6.83 1.77 3.08.77 5.26 1.71 7.97 3.32 6.04 3.58 12.18 6.22 19.12 7.44 4.64.82 8.79 2.34 13.15 4.1 5.9 2.36 11.95 4.14 18.04 5.96q2.63.8 5.25 1.63l2.14.66c1.61.71 1.61.71 2.61 2.71a52 52 0 0 0 10.5 2.88c23.97 4.6 23.97 4.6 28.5 9.12q2.27.34 4.56.56l2.5.26 1.94.18v2l4 1v2c-3.1-.44-5.92-.97-8.87-2a31 31 0 0 0-7.07-1.44c-3.34-.36-5.9-1.3-8.95-2.74-4.84-1.89-10.05-2.74-15.11-3.82q-4.5-.98-9-2l-3.31-.75c-4.5-1.3-8.6-3.64-12.73-5.81a62 62 0 0 0-8.96-3.44v-2l-2.69-.37c-3.31-.63-3.31-.63-5.65-1.58a58 58 0 0 0-8.54-2.49A64 64 0 0 1 1108 1338a49 49 0 0 0-13.37-4.19c-7.82-1.73-12.68-5.45-17.15-12.05a95 95 0 0 1-3.48-6.76l-2.19-4.31-1.81-3.69-1.04-1.78c-1.33-3.09-1.67-6.15-2.09-9.47l-.26-1.97-.61-4.78-3-1c-3.92-8.81-3.67-19.34-3.94-28.8a345 345 0 0 0-.22-5.8c-.18-6.1.44-10.9 3.16-16.4"/><path fill="#53515c" d="M433 1287h1c1.13 4.56 1.17 8.95 1.2 13.62l.04 2.63.14 15.09a9752 9752 0 0 1 .25 28.13c.54 61.51.47 123.02.37 184.53h-1l-1-126c-1.15 18.38-1.15 18.38-1.16 24.11l-.01 2.06-.07 11.43-.06 12.94-.08 13.57-.14 25.64-.16 29.22L432 1584h-1l-.05-90.3v-1.94l-.02-26.8c-.1-100.04-.1-100.04.48-144.15l.1-7.28a3581 3581 0 0 1 .29-20.36l.04-2.98c.16-2.19.16-2.19 1.16-3.19"/><path fill="#c3b9af" d="M1373.86 1224.68c11.11.16 21.77 3.52 32.14 7.32v1c-4.57.25-7.97.13-12.26-1.5-6.63-2.06-13.18-1.92-20.06-1.84q-3.3.03-6.62.02l-4.22.02h-2c-4.6.07-4.6.07-6.84 2.3l14.14.5c7.32.25 14.58.65 21.86 1.5l-1 3c-2.58.84-2.58.84-5.81 1.5-5.76 1.18-5.76 1.18-8.39 3.16l-1.8 1.34-3-1 1-3h2v-2l-1.62.25c-5.96.82-11.42.56-17.38-.25l-.81 1.94C1352 1241 1352 1241 1349 1242c-2.69-.44-2.69-.44-5-1l2-4h5v-4l-1.98.8a239 239 0 0 1-14.02 5.2 32 32 0 0 0-11.74 7.01c-1.26.99-1.26.99-3.26.99l-.7 1.66a35 35 0 0 1-4.73 6.35 67 67 0 0 0-14.74 26.48c-.83 2.51-.83 2.51-2.05 5.23-3.61 8.24-4.51 15.32-4.32 24.27q.05 3.1.03 6.22c.05 9.38.7 18.85 5.26 27.29 1.25 2.5 1.25 2.5.94 4.88l-.69 1.62c-7.21-10.94-8.18-24.04-8.19-36.81l-.03-2.83c-.02-6.37.8-12.17 2.22-18.36l.64-3.43a55 55 0 0 1 6.96-18.15 38 38 0 0 0 2.84-6.04c1.92-4.74 5.35-8.47 8.56-12.38l1.25-1.7c2.9-3.85 6.35-6.21 10.41-8.79 2.28-1.47 4.43-3.05 6.59-4.7a81 81 0 0 1 8.75-5.81l1-1q2.11-.49 4.25-.87a394 394 0 0 0 16.16-3.45c4.69-1.23 8.32-1.98 13.45-2"/><path fill="#24242c" d="m862 1237-3.56 2.69-2.22 1.67q-2 1.5-4.1 2.87c-5.54 3.64-10 8.4-12.12 14.77h-2a520 520 0 0 0 .59 8.1c.53 3.71 1.02 6.84 4.1 9.2l1.87.89c3.35 1.63 3.35 1.63 4.44 3.81 2.48 1 2.48 1 5.63 2l3.1 1q3.3 1 6.62 1.94a215 215 0 0 1 12.46 4.06l2.31.79 2.21.77 2 .7c1.67.74 1.67.74 3.67 2.74q2.39.9 4.83 1.62l2.92.9 6.09 1.83 2.93.9 2.67.8c2.8 1.04 5.14 2.2 7.56 3.95v3l2.44.75c7.88 2.66 15.14 6.55 22.42 10.52l1.88 1.02 1.69.93c1.57.78 1.57.78 4.57 1.78l1 6-1.39.84q-3.15 1.91-6.3 3.85l-2.18 1.32A54 54 0 0 0 927 1344c-2.69 1.19-2.69 1.19-5 2l-2 2q-2.48 1.05-5 2-2.01.97-4 2c2.05-3.88 3.96-6 7.64-8.37q1.34-.9 2.75-1.8l2.86-1.83 5.58-3.62 2.67-1.74q4.83-3.2 9.5-6.64c-.34-1.94-.34-1.94-1-4-2.49-.95-2.49-.95-4.13-1.4-3.9-1.26-7.45-3.46-11.06-5.41-9.54-5.1-18.97-9.63-29.19-13.22-2.46-.9-4.8-1.93-7.18-3.03a74 74 0 0 0-12.47-4.23C875 1296 875 1296 874 1294a87 87 0 0 0-5-1q-3.04-.85-6.06-1.81l-3.1-.96c-3.06-1.32-4.6-2.8-6.84-5.23q-2.1-1.08-4.2-2.08A65 65 0 0 1 838 1276v-2h-2c-1.77-5.32-1.86-10.5-1-16 3.67-6.9 9.93-12.2 16-17l1.92-1.57c4.96-3.86 4.96-3.86 9.08-2.43"/><path fill="#414146" d="M1353 342c3.11 1.5 6.05 3.2 9 5v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.94 2.25-.94 2.25-1 5 2.14 3.17 4.38 4.8 8 6v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.84 2.27-.84 2.27-1 5a22.4 22.4 0 0 0 9 9c5.14 1.2 10.07 1.06 15.32.88l4.4-.04q5.74-.06 11.5-.22 5.87-.14 11.76-.18 11.51-.15 23.02-.44l2 6c-1.95 1.95-5.53 1.17-8.15 1.19h-6.59a906 906 0 0 1-35.01-1.06l-2.9-.12-8.27-.38-2.5-.1c-4.55-.23-8.39-.72-12.58-2.53-3.01-3.61-3.32-6.38-3-11l-2.31-.19c-2.69-.81-2.69-.81-4.44-3.06A27 27 0 0 1 1359 355l-1.87-.12c-2.13-.88-2.13-.88-3.32-3.63-1.48-5.92-1.48-5.92-1.81-8.25z"/><path fill="#909da7" d="m1502 835 5 3c.31 2.19.31 2.19 0 4h5l-1 6 4 1 1 5 3 1 .15 2.34.22 3.03.22 3.03c.41 2.6.41 2.6 2.41 4.6 1.54 4.43 1.37 9.26 1.62 13.9.38 3.1.38 3.1 1.4 4.98 1.18 2.54 1.2 4.31 1.18 7.1l-.02 2.84-.06 2.93-.02 2.98-.1 7.27h4v4l-6 2-.08 2.14-.48 12.98-.12 3.24-.11 2.98c-.19 2.35-.55 4.4-1.21 6.66l-3-1-.08-20.4-.02-7.43-.01-2.34v-2.18l-.01-1.92c.12-1.73.12-1.73 1.12-4.73q.1-3.09.06-6.19l-.02-3.29-.04-2.52h-2l-1-24-3-1-.37-2.44-.63-2.56-2-1c.5-2.17 1-4 2-6l-3 1-2-4-2 11h-3l-1 3z"/><path fill="#7b7b7e" d="M1233 86c3.11 1.5 6.05 3.2 9 5v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l1.94.69c2.06 1.31 2.06 1.31 2.81 3.94l.25 2.37h2l1-6h3l.25 3.38c.26 1.93.26 1.93.75 3.62 2.06 1.44 2.06 1.44 4 2-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v8c-2.37.13-2.37.13-5 0-2-2-2-2-2.12-5.12l.12-2.88-1.87-.69c-2.86-1.76-3.05-3.14-4.13-6.31q-.98-2.35-2-4.69l-1-2.32c-1-1.99-1-1.99-3-3.99-.12-2.12-.12-2.12 0-4l-1.87-.62c-2.98-1.93-3.16-4.06-4.13-7.38l-2-2c-.12-2.62-.12-2.62 0-5l-4-2v-5l-4-1-1-7-3-1v-6h-2zm20 28 1 4 4 1v-6c-1.72 0-3.34.57-5 1"/><path fill="#7b46c6" d="M446 648h2v8h2a3378 3378 0 0 1 .15 22.35c.09 9.48.07 18.74-1.4 28.11-1.2 7.95-1.22 15.96-1.37 23.98l-.12 4.85q-.15 5.85-.26 11.71h2v2l2.53.3c6.33.84 11.55 1.88 17.33 4.75 3.56 1.58 7.31 2.26 11.14 2.95v1c-7.03.4-13.28-.92-20-3l-1-1a60 60 0 0 0-3.5-.81q-7.2-1.56-14.37-3.25l-2.28-.53-4.1-.96c-4.48-1.15-4.48-1.15-5.99-2.83-2.61-2.4-5.65-3.33-8.95-4.56l-4.06-1.58-1.96-.77c-5.4-2.15-9.95-4.23-13.79-8.71a59 59 0 0 1 13 5c4.14 1.96 8.3 2.68 12.8 3.44 2.2.56 2.2.56 4.2 2.56 1.84.23 1.84.23 3.94.19l2.09-.02C438 741 438 741 441 740l.06-2.9a2489 2489 0 0 1 .36-15.4q.07-3.35.17-6.7l.03-2.06c.14-4.7.5-9.58 2.38-13.94l1.56-1.03L447 697c.19-3.12.19-3.12 0-6h-1z"/><path fill="#302f34" d="m989 151 2 1-5.72 5-4.31 3.81a116 116 0 0 1-9.97 8.06c-4.06 2.96-7.14 6.52-10.34 10.37L959 181h-2l-.62 2.06c-3.63 7.74-10.88 13.62-17.2 19.19-4.1 3.63-7.76 7.5-11.3 11.7-1.88 2.05-1.88 2.05-4.35 4a43 43 0 0 0-6.72 6.99l-2.04 2.52A32 32 0 0 0 911 235a43 43 0 0 1-5.5 6.38c-1.93 2.09-3.17 4.4-4.54 6.9-1.85 3.3-1.85 3.3-2.96 4.72h-2l-.71 1.98c-1.69 3.95-3.78 7.6-5.91 11.33l-1.28 2.25L885 274c-1-3-1-3 .2-5.8q.88-1.6 1.8-3.2l.89-1.54q1.4-2.37 2.86-4.71l1.05-1.7c3.61-5.8 7.54-11.35 11.52-16.9 2.68-3.82 2.68-3.82 2.68-7.15l-4 2c0-2 0-2 1.75-3.81L906 229a262 262 0 0 0 4-5l1.88-2.31a142 142 0 0 0 5.25-7l1.5-2.12L920 211h2l.75-1.7c1.46-2.7 3.22-4.76 5.25-7.05l2.13-2.42C932 198 932 198 934 198l2-4h2l.75-1.75A22 22 0 0 1 944 186h2l.75-1.75c1.5-2.7 2.81-4.34 5.25-6.25 1.75-.33 1.75-.33 3.63-.5l3.37-.5c1.31-1.87 1.31-1.87 2-4 1-1.75 1-1.75 2-3h2l.69-1.81c1.68-2.8 3.48-3.61 6.31-5.19l2.44-2c2.56-2 2.56-2 5.5-3.44 3.54-1.8 6.08-3.94 9.06-6.56m-36 28 1 2Z"/><path fill="#5f5d68" d="M1280.94 1483.81A69 69 0 0 1 1297 1486v1l-3.87.06-2.18.04c-2.74-.1-5.41-.33-8.13-.6l-2.74-.28-2.08-.22a71494 71494 0 0 0-.08 76.73l-.03 32.47c-.07 40.36-.07 40.36 1.88 58.5 1.39 13.84 1.35 27.87 1.4 41.76l.04 4.79c.14 17.25.14 17.25-2.21 23.75l-1 1q-.3 2.7-.5 5.44c-.39 5.34-.39 5.34-1.5 7.56 9.98-5.54 16.43-14.3 22-24l1.18-2.04 1.91-3.39c1.51-2.6 3.21-5.08 4.91-7.57 1 3 1 3 .44 4.62l-.95 1.74-1.05 1.94-1.13 2.01-1.07 1.98c-3.34 6.1-6.6 11.48-11.93 16.09l-3.98 3.45q-1.96 1.74-3.88 3.53l-2.39 2.2-2.15 2C1276 1742 1276 1742 1273 1742l2.22-14.02.8-5.12.5-3.11c.48-2.75.48-2.75 1-4.6.56-2.53.61-4.85.63-7.45l.02-6.66.02-11.03q0-4.76.03-9.52v-7.36l.03-5.2v-6.07c-.25-2.86-.25-2.86-1.23-5.94-1.61-5.3-1.5-10.79-1.64-16.29l-.11-3.71c-.41-14.26-.42-28.53-.4-42.8v-52.66a17140 17140 0 0 1 0-41.07v-12.18c.24-4.13 2.44-3.37 6.07-3.4"/><path fill="#6f6f72" d="M821 935v1l-1.59.3a487 487 0 0 0-31.16 7.2c-7.3 1.85-14.7 3.16-22.1 4.54q-5.34.99-10.67 2.02l-2.46.47C751 951 751 951 749 952q-1.86.29-3.74.46l-2.23.24-4.65.46A40 40 0 0 0 727 956c-3.17.63-6.36 1.06-9.56 1.5l-7.7 1.1q-5.85.88-11.7 1.9-4.45.73-8.9 1.43a933 933 0 0 0-28.08 4.73c-16.83 3.17-32.96 3.48-50.06 3.34v-1a193 193 0 0 1 18.37-2.6c9.5-.86 18.66-2.54 27.98-4.5 8.5-1.76 16.98-2.96 25.6-3.91 5.02-.56 9.86-1.33 14.79-2.5 4.17-.9 8.4-1.42 12.63-1.99 5.68-.78 11.32-1.58 16.94-2.69 4.31-.83 8.3-.98 12.69-.81v-2l13.67-2.16c7.3-1.14 14.6-2.14 21.96-2.84 9.46-.93 18.6-3.07 27.61-6.09 5.88-1.94 11.64-2.05 17.76-1.91"/><path fill="#4a484e" d="M200 904q5.69 1.12 11.31 2.56l3.24.82 2.45.62v-2h8l1 4h10v6l1.24-1c2.15-1.22 3.49-1.2 5.95-1.12l2.17.05 1.64.07 1 4h13l1 5 2 1v-2h14v5l2 1v-2h20v4h21c-2 2-2 2-3.79 2.14l-4.43-.32-2.4-.2q-1.2-.07-2.43-.17-2.98-.21-5.95-.45v2l2.82.37c6.2.83 12.22 1.64 18.18 3.63v1q-11.35-.97-22.62-2.44l-2.95-.37c-5.9-.76-11.65-1.78-17.43-3.19v-2l-2.63.08a47 47 0 0 1-18.87-4.64c-3.01-1.42-5.9-1.9-9.19-2.38-6.4-.96-12.22-2.9-18.31-5.06l-4.81-1.5c-3.5-1.16-6.5-2.7-9.65-4.59a23 23 0 0 0-10.73-3.28c-2.07-.21-2.07-.21-3.81-.63-1.5-2.06-1.5-2.06-2-4"/><path fill="#db4a09" d="M1300 1353a29 29 0 0 1 8 11.13c1 1.87 1 1.87 2.62 3.49 1.38 1.38 1.38 1.38 1.38 3.38l1.69.81c4.75 2.44 8.22 5 11.9 8.84 1.64 1.57 3.36 2.4 5.41 3.35q.45.74.94 1.5c1.56 2.2 3.57 2.53 6.06 3.5l2.26 1.18c11.04 5.7 23.44 7.05 35.74 7.13l2.05.02c4.7-.07 4.7-.07 6.95-2.33-3.82-1.73-7.51-2.55-11.62-3.24-6.24-1.99-10.6-6.7-15.07-11.26l-1.72-1.72c-6.13-6.2-6.13-6.2-7.59-8.78.31-2.25.31-2.25 1-4q1.76 1.9 3.5 3.81l1.97 2.15c1.53 2.04 1.53 2.04 1.53 5.04 6 2.47 11.57 4.23 18 5v2l2.92.33 3.83.48 1.92.22c4.92.64 4.92.64 7.25 2.52 3.31 2.3 6.71 1.85 10.64 1.76l2.33.01c3.84-.03 5.93-.11 9.11-2.32 2.69-.12 2.69-.12 5 0-4.31 4.15-8.56 5.2-14.31 6.5-9.2 2.1-9.2 2.1-13.46 4.64-5.51 2.93-11.47 2.32-17.54 2.17q-1.8 0-3.57-.03c-8.61-.11-8.61-.11-12.12-1.28q-3.09-.33-6.19-.56l-3.29-.26-2.52-.18-1 2-.08-2.28c-.92-2.72-.92-2.72-3.87-4.56q-1.86-.86-3.74-1.66c-4.94-2.17-9.63-4.43-13.31-8.5v-2l-1.69-.81c-9.36-4.81-16.91-12-20.31-22.19q-.56-2.49-1-5"/><path fill="#69676c" d="M611 970c-7 5.07-19.57 3.12-28 3v2a1079 1079 0 0 1-52.6 2.66c-23.86.57-23.86.57-27.4 2.34-1.66.13-1.66.13-3.67.13h-52.26c-63.97.06-63.97.06-87.94-2.38q-2.99-.3-5.97-.56l-3.9-.37-3.38-.33c-2.7-.46-4.54-1.11-6.88-2.49l9.17-.09c4.16-.02 8.24.2 12.38.56 11.55.95 23.17.9 34.75 1.08l12.36.21c4.78.09 9.5.31 14.27.75 6.14.54 12.24.65 18.4.66l12.6.02h5.7c15.91 0 31.74-.28 47.62-1.55 7.75-.62 15.48-.86 23.26-1.03a380 380 0 0 0 35.21-2.31A386 386 0 0 1 611 970"/><path fill="#212129" d="M1215 1087h4v64h-2l.02-1.68c.14-18.46-.38-36.87-1.02-55.32-1.87-.62-1.87-.62-4-1l-2 2 2 2a42 42 0 0 1-6 5c-2.25-.19-2.25-.19-4-1-3.26 0-5.72 2.03-8.44 3.69l-1.77 1.03c-3.79 2.3-5.63 4.32-7.79 8.28h-2l-1-2-1.28.94c-2.27 1.4-4.52 1.88-7.1 2.5-1.62.56-1.62.56-3.62 2.56-1.6.71-1.6.71-3.5 1.38q-4.67 1.67-9.25 3.56c-4.76 1.92-9.6 3.55-14.49 5.13q-3.93 1.34-7.76 2.93l3 1v2h-6v-3l-2.82 1.34c-4.85 2.07-9.86 3.15-15 4.29l-2.82.65a112 112 0 0 1-16.3 2.49c-2.59.29-4.68 1.2-7.06 2.23-22.66 3.96-47.3 2.94-70 0v-1l2.53-.02q11.85-.09 23.7-.22 6.1-.06 12.19-.1l11.77-.12 4.48-.04q3.15-.01 6.3-.07h1.84c4.04-.08 7.02-.99 10.58-2.73 2.72-1.18 5.5-1.3 8.42-1.58 6.11-.66 11.9-1.95 17.82-3.62l2.3-.65c3.78-1.1 7.42-2.35 11.07-3.85q1.8-.51 3.63-.94l2.07-.5 2.3-.56c6.88-1.7 13.8-3.44 20-7l1-2c2.29-.63 2.29-.63 5.06-1.12l2.79-.51 2.15-.37 1-3a83 83 0 0 1 7.06-2.31c5.53-1.69 9.78-3.5 14.24-7.28 2.2-1.83 4.56-3.12 7.06-4.48a84 84 0 0 0 6.02-3.85c2.62-1.75 5.3-3.38 7.98-5.03 1.64-1.05 1.64-1.05 2.64-2.05"/><path fill="#6f6f77" d="M392 1248c2.55 3.65 3.1 6.91 3.32 11.33l.12 2.42.12 2.5.13 2.55.31 6.2-2-1c-.41-2.5-.41-2.5-.62-5.56l-.23-3.07-.15-2.37c-1.26 2.53-1.13 4.26-1.13 7.1v81.8a104195 104195 0 0 0 0 92.97v76.05a9239 9239 0 0 0 .56 114.92c.49 52.03.49 52.03 4.57 62.16l3 1a27 27 0 0 1 1.94 4.69c1.18 3.21 2.32 5.94 4.37 8.68 1.69 2.63 1.69 2.63 1.3 4.8L407 1717c-4.6-5.45-7.87-10.21-10-17l-2.06-5.5a68 68 0 0 1-4.56-21.42l-.11-2.22c-.38-8.04-.41-16.07-.4-24.12l-.01-5v-13.6l-.02-14.71-.01-52.17-.02-55.62v-16.19q0-28.11-.02-56.22a81377 81377 0 0 1-.03-96.74 16994 16994 0 0 1-.01-42.99 3555 3555 0 0 1 0-18.1c-.04-9.34.5-18.2 2.25-27.4"/><path fill="#929ea8" d="M1533 998c2.44.81 2.44.81 5 2l1 3 3 1v-6h3c.93 3.01 1.04 3.87 0 7-2.06.69-2.06.69-4 1 2.4 4.5 5.48 7.83 9.02 11.43l1.77 1.8 3.7 3.74a706 706 0 0 1 5.62 5.76l3.6 3.64 1.68 1.76a27 27 0 0 0 4.61 3.87c2.81-.05 2.81-.05 5-1 1.31-1.56 1.31-1.56 2-3l4 1v3h-5c2.3 4.26 5.09 7.4 8.5 10.75l1.47 1.5c2.33 2.31 3.87 3.7 7.03 4.75v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1q-1.1 2.97-2 6c-2.12 0-2.12 0-5-1a56 56 0 0 1-4.57-6c-3.37-4.73-7.62-7.92-13.43-9l-1 3-1-3-7-1v-3l-1.87-.19c-2.13-.81-2.13-.81-3.44-2.87-.9-3.86-.4-7.09.31-10.94l-2.19-.69a15 15 0 0 1-5.43-3.87C1546 1021 1546 1021 1543 1020c-3.34-2.75-4.52-4.7-5-9l-3-1-1-3-3-1c-.12-2.87-.12-2.87 0-6zm27 47h1v5h-1z"/><path fill="#494a4c" d="m412 162 2 1v8l2.44-.56L419 170c1 1 1 1 1.1 2.63L420 178l-3 1 .04 1.9.02 2.47.04 2.47C417 188 417 188 416 190h-3l.06 4.88.04 2.74C413 200 413 200 412 202h-2l-.15 2.59-.23 3.35-.2 3.34L409 214l-2 1c-.56 2.43-.56 2.43-1 5.44-.78 5.34-.78 5.34-3 7.56-.53 3.01-.89 6-1.21 9.03q-.32 2.5-.79 4.97l-2 1c-.43 2.3-.75 4.55-1 6.88-.84 7.69-.84 7.69-3 10.12l-5-1v-16l4-2v-14l4-2v-10l4-2v-10l4-2v-10l4-2v-10l4-2c-.62-4.68-.62-4.68-2.56-6.31L410 170c.88-6.87.88-6.87 2-8"/><path fill="#1d1d26" d="m688 1496 2 1a18 18 0 0 1-5.31 4.25 21 21 0 0 0-5.63 5.25c-2.68 3.27-5.15 4.79-9.06 6.5a313 313 0 0 0-6 4 509 509 0 0 1-20.48 13.2c-3.43 2.1-6.75 4.28-9.96 6.7-2.11 1.49-4.34 2.77-6.56 4.1q-2.99 1.95-5.94 3.94l-3.02 2.02-1.51 1.02-3.12 2.07a180 180 0 0 0-7.91 5.51 47 47 0 0 1-7.7 4.5c-3.49 1.83-6.73 4.07-10.02 6.22-2.78 1.72-2.78 1.72-5.73 3.13-3.51 1.83-6.3 4.03-9.3 6.59l-1.6 1.33c-2.47 2.08-4.44 3.9-6.15 6.67v-2l-16 10 4 2q-1.59.76-3.19 1.5l-1.79.84c-2.02.66-2.02.66-4.42.6-3.2.07-5.13 1.02-7.98 2.43-12.52 6.18-27.8 6.71-41.22 2.82-4.8-2.38-7.08-6.55-9.4-11.19 2.8 1.1 3.9 1.85 5.56 4.44 3.18 4.17 8.54 4.76 13.44 5.56 4.03.3 7.97.21 12 0l3.25-.13c5.56-.32 9.02-.77 13.75-3.87l2.75-1.06C538 1595 538 1595 539 1593a169 169 0 0 1 21.34-11.03c4.64-2.07 7.17-4.46 10.3-8.44 1.84-2.07 3.79-2.59 6.36-3.53 1.84-1.45 3.56-3 5.3-4.55 2.09-1.78 4.35-3.06 6.7-4.45q1.49-1.23 2.94-2.5a29 29 0 0 1 12.56-6.37c5.14-1.37 8.4-4.97 12-8.7 3.55-3.45 7.3-5.9 11.75-8.06C631 1534 631 1534 633 1532l2.13-.75c5.18-2.25 9.42-6.04 13.75-9.62C651 1520 651 1520 653 1520v-2c5.42-4.32 11.51-7.45 17.81-10.25 3.73-1.84 6.83-4.52 10.07-7.1A39 39 0 0 1 688 1496m-77 55 2 3-3-1zm-49 34 2 1Z"/><path fill="#2c2b35" d="m1056 1172-1 2c-1.76.47-1.76.47-4.04.78l-2.5.36-2.65.36a84 84 0 0 0-15.81 3.5c-2.61.43-5.24.68-7.87.94-5.02.55-9.4 1.24-14.04 3.28-2.8 1.05-5.67 1.58-8.59 2.15a126 126 0 0 0-15 4c-7.45 2.46-15.01 4.19-22.7 5.75a61 61 0 0 0-14.91 5.1c-1.89.78-1.89.78-4.89.78v2l-2.09.55c-16.64 4.45-16.64 4.45-19.97 7.08-2.17 1.53-3 1.55-5.58 1.71-4.65.52-8.04 2.27-12.05 4.6l-3.82 2.15-1.81 1.02A60 60 0 0 1 890 1223a82 82 0 0 0-3.31 1.94 51 51 0 0 1-10 4.35c-1.69.71-1.69.71-3.69 2.71-1.84.82-1.84.82-3.94 1.56l-2.09.76q-2.48.86-4.97 1.68v-2c5.51-4.16 12.1-7.14 18.25-10.25l2.04-1.03A75 75 0 0 1 894 1218v-2h-3v-2h5l1 2 1.81-1c2.19-1 2.19-1 5.19-1v-2h-4v-2l5.27-1.37C907 1208 907 1208 909 1206q2.67-.35 5.34-.65c1.66-.35 1.66-.35 3.16-1.85 2.1-2.1 4-1.9 6.88-2.18 1.62-.32 1.62-.32 3.06-1.88 2.39-2.2 4.79-1.66 7.92-1.55l1.64.11v2l6.25-1.87 1.78-.53c3.15-.96 6.08-2.01 8.97-3.6v-4h7l-1 3q1.04-.38 2.13-.74a297 297 0 0 1 22.04-6.55l5.38-1.42 3.48-.9 3.11-.81c2.72-.55 5.1-.7 7.86-.58l1-2q2.74-.4 5.49-.7c4.97-.6 9.86-1.73 14.76-2.74 22.93-4.71 22.93-4.71 30.75-4.56"/><path fill="#15171c" d="M341.67 1050.02c2.06 3.07 1.71 6.22 1.65 9.78v2.43q-.02 4.05-.07 8.1l-.03 5.8-.12 15.75-.1 16.46-.2 27.63q-.12 14.22-.2 28.45l-.02 1.78-.06 8.8q-.23 36.5-.52 73c-1.46-.8-1.46-.8-3-2-1.07-4.95-1.14-9.82-1.12-14.87v-15.38l.02-14.82v-15.5l.03-29.33.02-33.4.05-68.7-3.32.06c-3.3.06-5.68-.6-8.68-2.06l1-2a59 59 0 0 1 6.37-.81l1.8-.2c2.5-.2 4.32-.3 6.5 1.03"/><path fill="#b4acc8" d="m679.56 584.88 5.12.02q6.15.03 12.32.1l-2 4-2.9.08-15.52.42c-7.78.2-15.55.43-23.32.91l-3.24.2c-3.02.39-3.02.39-5.66 1.4-4.01 1.37-7.93 1.26-12.13 1.23h-2.6q-4.26.01-8.52-.01h-5.95l-12.46-.02-15.9-.02-18.15-.02c-19.54-.02-19.54-.02-28.65-1.17l-1-3 39.47-.5c60.69-.73 60.69-.73 75.7-2.83 8.42-1.12 16.9-.88 25.4-.8"/><path fill="#816aaf" d="m911.69 540.94 3 .02 2.31.04c-1.32 2.63-2.27 2.92-5 4q-1.8.3-3.62.5c-3.38.5-3.38.5-5.38 2.5-3.12.13-3.12.13-6 0v2a490 490 0 0 1-49.96 17.36C845 568 845 568 843 569q-2.71.24-5.44.38a68 68 0 0 0-15.28 3C820 573 820 573 817 573v2l-13 2v2l-2.58.45c-7.64 1.39-15 3.24-22.42 5.55a207 207 0 0 1-25 6l-5.25 1.13c-5.21 1.05-10.45 1.49-15.75 1.87 3.16-2.29 6.5-3.04 10.25-3.94l2.15-.52c4.74-1.15 9.5-2.13 14.29-3.04 6.88-1.32 13.78-2.9 20.31-5.5l1-2c2.7-.3 2.7-.3 6.13-.44 4.48-.23 7.98-.78 11.97-2.86 4.37-1.6 9.29-1.48 13.9-1.7v-3l1.68-.3c25.26-4.64 25.26-4.64 29.32-8.7q2.25-.49 4.5-.87c12.83-2.46 12.83-2.46 15.5-5.13 2.32-.32 4.64-.51 6.97-.72C873 555 873 555 874 554l-11-1c3.47-1.74 4.82-2.25 8.44-2.5 3.56-.33 6.26-1.1 9.56-2.5q2.55-.35 5.11-.66c2.78-.5 5.29-1.54 7.9-2.58 2.95-1.13 5.95-1.93 8.99-2.76 3.15-1.05 5.39-1.1 8.69-1.06"/><path fill="#8f9090" d="m761 942-1 4c-6.75 1.41-13.1 2.3-20 2v2l-2.24.3-10.45 1.45-1.92.27a480 480 0 0 0-27.45 4.68A124 124 0 0 1 679 959c2.2-2.2 2.63-2.25 5.56-2.5l3.44-.5 1-2h-21l1-2c6.57-2.14 13.88-2.54 20.73-3.3 5.17-.6 5.17-.6 6.27-1.7-6.2-.2-11.71 0-17.72 1.55-4.07.8-8.16.6-12.28.45l1-2c2.36-.47 4.62-.8 7-1.06l2.14-.25c6.08-.68 12.17-1.08 18.27-1.35 5.21-.24 5.21-.24 7.6-.86 3.18-.77 6.36-.62 9.62-.6l2.06-.02c5.06.01 5.06.01 7.31 1.14 3.5 1.12 6.84 1.17 10.5 1.19l3.4.04c3.1-.23 4.4-.87 7.1-2.23 2.34-.3 4.59-.5 6.94-.62l1.84-.12q5.1-.31 10.22-.26"/><path fill="#0e0e0f" d="m1492.06 772.94 1.94.06c-.31 1.88-.31 1.88-1 4q-1.47 1.05-3 2l-1 3h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.26-.88-2.26-.88-5-1-2.46 1.66-2.46 1.66-4.81 4.06l-2.4 2.35c-2.15 3.11-2.15 4.88-1.79 8.59l-4 1v39l-4 2v3l-2.44 1.31c-2.56 1.69-2.56 1.69-3.37 4.88L1460 851h-1c-.1-4.47-.1-8.63 1-13h2v-41l-2-1 5-1 .19-3.19c.62-4.4 2.32-6.13 5.81-8.81 2.31-.31 2.31-.31 4 0l.13-1.75c1.3-3.34 3.73-4.8 6.87-6.25 2.35-.54 4.6-.76 7-1 1-1 1-1 3.06-1.06"/><path fill="#79797b" d="M1193 818a31 31 0 0 1-8.8 5.1q-2.58 1.06-5.11 2.2l-3.15 1.39-2.98 1.32c-3.16 1.06-5.65 1.17-8.96.99v3l14-1 1 2h3l-1 1 6 1a17 17 0 0 1-6.8 3.07l-2.15.53-2.24.52-2.27.56L1168 841v-3q-2.16.43-4.31.88l-2.43.49c-2.26.63-2.26.63-3.8 1.7-1.95 1.25-3.34 1.13-5.65 1.05l-2.17-.05-1.64-.07 1-3c2.56-1.19 2.56-1.19 5-2v-2l-2 .88a49 49 0 0 1-6.75 2c-2.95.7-5.42 1.64-8.12 3-4.45 2.08-8.91 3.02-13.7 4a105 105 0 0 0-17.93 5.25c-4.98 1.96-9.68 3.57-14.99 4.43-4.26.74-8.44 1.89-12.63 2.95-2.84.48-4.19.27-6.88-.51l16-5.58c13.33-4.66 13.33-4.66 19.32-6.03 2.85-.66 5.63-1.53 8.43-2.39l3.3-1c2.95-1 2.95-1 5-2.04 2.43-1.2 4.8-1.7 7.45-2.27a71 71 0 0 0 12.44-4.19c6.4-2.8 13.98-5.5 21.06-5.5v-2l3.4-.77 4.41-1.04 2.24-.5c6.39-1.54 6.39-1.54 8.95-4.69 3.69-1.57 6.17-2.47 10-1"/><path fill="#68439c" d="M658 755c5.27-.2 5.27-.2 7 0l2 2c1.7.23 1.7.23 3.7.2l2.16-.02 2.26-.05 2.29-.03 5.59-.1 1-2h26l-1 2-18 1v2l-2.24.15-3.07.23-3 .2c-2.96.28-5.85.81-8.78 1.38-5.39.99-10.75 1.63-16.2 2.11l-2.94.26q-4.75.41-9.52.8l-3.35.26c-45.52 3.57-95.24 6.19-139.9-4.39v-1c4.68-.18 8.9.09 13.5.94a117 117 0 0 0 15.57 1.73c9.82.55 19.66.57 29.5.65l23.03.21 17.06.16L634 764v-2h17v-2h-10v-3h16z"/><path fill="#6a4931" d="M1271 1247c.68 1.67.68 1.67 1 4-1.18 2.55-1.18 2.55-2.87 5.31-3.48 6.01-3.48 6.01-3.82 9.5-.3 3.05-1.02 4.8-2.48 7.44-1.45 3.06-2.27 6.33-3.18 9.58-.65 2.17-.65 2.17-1.65 4.17h-2l-2 6a388 388 0 0 1-34.9-4.73q-8.5-1.53-17.01-2.95l-5.4-.9q-4.02-.68-8.05-1.35A650 650 0 0 1 1152 1276l-2.18-.43-1.82-.57-1-2c-2.17-.45-4.25-.8-6.44-1.06-6.34-.83-6.34-.83-8.56-1.94q-2-.29-4-.46l-2.42-.24-2.58-.24a159 159 0 0 1-20-3.06l-4.96-1a244 244 0 0 1-16.73-4.06l-1.78-.5c-2.8-.8-5.22-1.6-7.53-3.44a601 601 0 0 1 19 3v2c19.01 3.06 19.01 3.06 26.56 3.38 7.12.4 14.07 1.7 21.07 3l3.43.63c7.39 1.4 14.66 3.09 21.94 4.99v2l3-.09c4.08 0 7.9.66 11.88 1.46l1.98.39c2.76.54 5.51 1.09 8.25 1.75 7.1 1.72 14.18 2.19 21.45 2.7 14.79 1.14 14.79 1.14 17.44 3.79q2.45.23 4.91.32l3 .12 3.15.12 10.94.44v2h4l.31-3.06c.56-4.03 1.97-7.27 3.69-10.94h2l.38-2.56c1.64-9.07 4.79-18.18 10.62-25.44"/><path fill="#38373e" d="m782.13 924.44 2.75.3 2.12.26c-2.24 2.24-2.67 2.28-5.67 2.53l-2.2.21-4.57.38-2.23.22-2 .17c-2.33.49-2.33.49-5.51 1.92-4.3 1.9-8.63 2.63-13.25 3.36l-2.73.45a1329 1329 0 0 1-11.7 1.86c-13.53 2.15-26.73 3.7-40.44 3.73-3.79.04-7.03.63-10.66 1.66-3.91.98-7.91 1.26-11.91 1.63q-1.25.14-2.53.27c-5.88.57-11.7.7-17.6.61v-1l11-1v-2h-12v-2l30-1v-3l27-1 1-3 11.31-.06 3.25-.03h3.12l2.87-.02C734 930 734 930 737 931q2.5-.47 5-1 2.65-.16 5.32-.3c5.93-.25 11.03-.82 16.61-3 3.03-1.02 6.05-1.25 9.22-1.49 7.88-.9 7.88-.9 8.98-.77"/><path fill="#1d1c1f" d="M1060 768c-6.43 4.68-15.44 6-23 8v2c-10.76 4.31-22.29 8-34 8v2l-2.12.33a75 75 0 0 0-18.59 5.42c-9.2 3.83-19.8 5.24-29.69 6.04-1.6.21-1.6.21-3.6 1.21q-2.06.1-4.12.06l-2.2-.02L941 801v2c-11.29 2.8-22.46 5.48-34 7-6.39.9-12.67 1.87-18.94 3.44-7.13 1.73-14.35 2.64-21.63 3.56l-2.78.35-2.5.3C859 818 859 818 856 819q-2.6.1-5.19.06l-2.73-.02L846 819v-1l2.65-.47c15.47-2.76 30.91-5.56 46.28-8.84a404 404 0 0 1 17-3.25 301 301 0 0 0 20.72-4.4c5.35-1.3 10.73-2.47 16.11-3.65q6.13-1.34 12.2-2.9 3.14-.74 6.3-1.4c5.63-1.24 11.14-2.89 16.68-4.53l3.09-.9q7.14-2.1 14.27-4.27c3.96-1.2 7.93-2.2 11.95-3.14 11.16-2.7 22.04-6.39 32.95-9.97q2.75-.9 5.49-1.78l2.95-.97c2.36-.53 2.36-.53 5.36.47"/><path fill="#403e43" d="m581 163 1 2-2.04-.03-9.34-.1-3.21-.05c-9.12-.05-17.68.09-25.1 5.87l-2.75 2.14c-8.63 7.32-8.63 7.32-10.56 11.17l1.57-1.53 2.12-1.97 2.07-1.97c2.65-1.8 4.1-1.84 7.24-1.53l-1 4-2.31.44C536 182 536 182 533 183a89 89 0 0 0-2 4l-2 1-.87 2.44c-1.36 3.1-2.18 3.17-5.13 4.56-3.1 3.38-5.06 6.87-7 11-2.71 5.71-2.71 5.71-5 8l-1 2.5c-1 2.5-1 2.5-3 4.5l-1 3c-1 3-1 3-3 5-.56 1.66-.56 1.66-1 3.5-.65 2.73-.92 3.42-3 5.5-.41 1.73-.41 1.73-.62 3.63L498 245l-2-2-2 1c-.36-4.99.8-7.6 3-12q.22-1.87.31-3.75c.42-4.72 2.75-7.89 5.27-11.8 1.66-2.86 2.3-5.42 2.92-8.64.84-3.04 2.7-5.25 4.5-7.81.75-2.37.75-2.37 1-4l1.88-.19L515 195l.81-2.06c3.04-7.5 10.14-12.84 16.19-17.94l1.82-1.66c3.23-2.94 6.15-5.1 10.27-6.62 1.91-.72 1.91-.72 3.98-1.79 9.33-4.53 22.87-4.54 32.93-1.93m-39 11 2 1-2 2z"/><path fill="#525059" d="M1259 1588h2q.12 2.85.19 5.69l.1 3.2c-.33 3.5-1.29 5.25-3.29 8.11a41 41 0 0 0-4 7.69c-1 2.31-1 2.31-2.56 3.31l-1.44 1-.37 2.38c-.86 3.6-2.6 4.63-5.63 6.62h-2l-.31 2.69a18.3 18.3 0 0 1-3.69 8.31c-1.75 1-1.75 1-4 2l-3.19 2.5c-3.4 2.5-6.47 3.36-10.6 4.04-2.21.46-2.21.46-4.56 1.45-9.71 3.7-22.07 2.15-32.34 2.12h-20.79l-15.89-.02q-15.02 0-30.04-.02l-34.23-.02-70.36-.05v-1h2.92q35.16-.1 70.33-.24 17.01-.06 34.02-.1l29.66-.1q7.84-.05 15.7-.06 7.4 0 14.79-.05l5.4-.02c24.91.02 24.91.02 33.18-6.43l3.25-.81c5.07-1.3 9.9-4.65 12.98-8.88l.77-1.31-2.7 1.35-3.55 1.71-1.78.9c-2.64 1.26-4.44 2.1-7.4 1.72l-1.57-.68 1.8-.81c7.02-3.32 13.09-7.42 19.2-12.19l2.37-1.82c3.84-3.13 6.34-5.7 7.98-10.4 1.15-3.13 2.94-5.93 4.65-8.78h2l.4-2.08 1.06-5.44c.54-2.48.54-2.48 1.54-5.48"/><path fill="#331c57" d="M1319 437c3.59 3.07 4.95 5.97 6.13 10.44 3.07 11.02 3.07 11.02 5.37 12.56 4.87 3.25 5.84 13.25 6.93 18.73.87 3.48 2.26 6.72 3.67 10 1.72 4.33 2.04 7.98 2.03 12.64v3.34A62 62 0 0 1 1342 514h4v-2c3.44-3.65 7.16-6.53 12-8v5h-2l-.5 2.06c-1.86 3.64-4.23 5.46-7.34 8.03a68 68 0 0 0-7.62 8.3C1339 529 1339 529 1336 530v2c-1.6 1.47-1.6 1.47-3.75 3.06l-2.1 1.6c-2.1 1.3-3.74 1.9-6.15 2.34v-6l-2 6h-3l-1 4-3-1 2-1 1-3 3-1-.4-2.53A339 339 0 0 1 1318 515c3.93 4.2 5.42 8.51 7 14 2.97-.34 3.96-.96 6.07-3.14l2.06-2.67 2.07-2.65c2.76-3.9 4.05-6.52 4.03-11.35v-2.76l-.04-2.87v-2.85c-.03-4.4-.23-8.4-1.19-12.71l-3-1-.37-2.08-.5-2.73-.5-2.7a14 14 0 0 0-2.63-5.49q-.71-2.44-1.36-4.9c-1.02-3.36-2.45-6.55-3.84-9.77a14 14 0 0 1-.8-6.33h-2c-4.44-10.47-4.44-10.47-4-16"/><path fill="#2f2f3a" d="m911 1349 2 1a348 348 0 0 1-4.5 3.9c-1.5 1.1-1.5 1.1-3.5 1.1l-1 3c-2.07.95-2.07.95-4.56 1.69l-2.5.76-1.94.55v2l-5 1-1 3c-1.7 1.13-1.7 1.13-3.81 2.13l-2.08 1c-2.11.87-2.11.87-4.3 1.3l-1.81.57-1 3h-3l-.62 1.75c-1.87 3.06-4.4 4.43-7.38 6.25l-5.25 3.38q-6.74 4.28-13.66 8.26c-2 1.3-3.43 2.67-5.09 4.36-1.47.92-1.47.92-3.06 1.75-4.9 2.7-9.37 6.02-13.94 9.25-4.97 3.5-9.22 6.07-15 8a150 150 0 0 0-6.76 4.48 80 80 0 0 1-6.05 3.7 44 44 0 0 0-5.19 3.32 45 45 0 0 1-5.44 3.44 40 40 0 0 0-6.37 4.31C777 1439 777 1439 775 1439l-.56 1.81c-1.84 2.8-3.53 3.24-6.58 4.41a33 33 0 0 0-6.4 3.75c-1.47 1.04-3 2-4.52 2.97a31 31 0 0 0-5.44 4.56c-2.56 2.67-5.39 4.53-8.5 6.5l-2.87 2a59 59 0 0 1-7.13 4c-4.7 2.3-8.9 4.76-12.56 8.56-2.39 2.39-4.53 3.77-7.44 5.44q-1.57 1.15-3.12 2.31c-3.95 2.88-8.2 5.1-12.52 7.36C695 1494 695 1494 693 1496l-3-1h2l.16-1.8c1.24-3.25 3.28-3.82 6.34-5.33 5.4-2.68 5.4-2.68 6.5-4.87l2.38-.94c2.62-1.06 2.62-1.06 4.01-2.47 1.84-1.81 3.65-2.83 5.92-4.03 9-4.87 9-4.87 12.25-8.12 2.49-2.49 5.53-4.01 8.69-5.5C740 1461 740 1461 741 1459q2.46-1.41 5-2.69a79 79 0 0 0 12.96-8.72 49 49 0 0 1 7.08-4.45 84 84 0 0 0 6.48-4.3 537 537 0 0 1 16.8-11.28l1.52-1c6.66-4.33 13.37-8.6 20.17-12.72q5.25-3.23 10.43-6.59l1.72-1.1 1.65-1.07 1.6-1.04q1.64-1.08 3.26-2.2c4.13-2.84 4.13-2.84 6.33-2.84v-2q1.62-1.06 3.29-2.01c2.11-1.22 4.14-2.55 6.19-3.88l2.51-1.64 2.63-1.72q12.72-8.3 25.56-16.38l4.76-3 2.42-1.52a679 679 0 0 0 13.24-8.55l2.77-1.83q2.66-1.76 5.3-3.54l2.43-1.6 2.14-1.44z"/><path fill="#6a6b6e" d="M346 1603h1c1.79 7.41 2.3 14.46 2.34 22.07l.07 5.14q.06 5.36.1 10.72l.1 8.87.06 6.62c.08 5.84.66 10.99 2.33 16.58 1.4 7.58 1.1 15.32 1 23-1.81 1.06-1.81 1.06-4 2l-3-1-1-30-3-1v-54l3-1-.06-3.44c-.04-1.93-.04-1.93.06-3.56z"/><path fill="#151618" d="M393 262q.12 2.85.19 5.69l.1 3.2c-.29 3.11-.29 3.11-1.77 5.41-2.23 3.95-2.01 7.68-2.08 12.14-.19 8.83-.19 8.83-1.43 12.42-1.58 4.91-1.48 9.88-1.63 15.01l-.12 3.25q-.15 3.93-.26 7.88l-3 1-.24 1.53-1.13 6.85-.39 2.4-.39 2.3-.35 2.13C380 345 380 345 378 347l-4-1v-19l4-2v-22l4-2-.05-1.71q-.09-3.84-.14-7.67l-.07-2.69-.03-2.58-.05-2.38.34-1.97q1.49-1.02 3-2c.43-1.79.43-1.79.51-3.91l.1-2.3.08-2.42.1-2.42.21-5.95c2.67-.9 4.26-1.1 7-1"/><path fill="#976fcc" d="M546 615h1l-.1 2.17a1515 1515 0 0 0-1 62.2q0 11.65.03 23.3l.02 20.12L546 764l-11.6-.44-3.3-.12c-6.24-.24-12.15-.75-18.28-1.98-3.5-.57-6.96-.73-10.5-.9-8.6-.42-16.9-1.89-25.32-3.56l-2.86-.54c-4.5-.9-8.14-2.2-12.14-4.46v-1c4.54-.25 8.17-.1 12.5 1.31 12.57 4.05 25.37 5.22 38.5 5.69v-2l3.28.46c5.4.68 10.8.89 16.22 1.1l10.5.44v-1.65l-.08-59.47-.03-25.87c-.02-12.25.08-24.48.55-36.73l.12-3.13c.62-14.33.62-14.33 2.44-16.15"/><path fill="#87898a" d="m909.25 899.88 2.14.05 1.61.07c-3.89 3.59-6.96 4.8-12.12 5.69-6.75 1.27-6.75 1.27-9.88 2.31q-2.22.12-4.44.19c-11.46.93-22.7 5.43-33.5 9.2-3.32.98-6.61 1.3-10.06 1.61l15 1-1 3h-17l-1-3-8.06 1.38-2.28.38c-3.52.6-6.93 1.27-10.34 2.31-4.12 1.15-8.01 1.36-12.26 1.59-3.06.34-3.06.34-6.26 1.28a84 84 0 0 1-11.64 2.35l-2.2.33-4.58.67q-3.46.5-6.93 1.03l-4.48.66-2.06.3A83 83 0 0 1 754 933v-1l4.79-.49a26 26 0 0 0 5.44-1.99c3.97-1.76 7.73-2.17 12.02-2.52a88 88 0 0 0 17.1-3.29 71 71 0 0 1 8.77-1.65c5.44-.77 10.73-1.86 16.07-3.12l2.25-.53c5.29-1.27 5.29-1.27 7.56-2.41q2.33-.27 4.64-.5c8.06-.78 15.67-2.5 23.49-4.56 8.7-2.3 16.85-3.9 25.87-3.94v-2a124 124 0 0 1 17-2.66c2-.34 2-.34 4-1.36 2.27-1.11 3.73-1.19 6.25-1.1"/><path fill="#28282e" d="M751 529a13 13 0 0 1-6.8 2.76l-2.29.36-2.45.35-2.57.38q-3.95.59-7.89 1.15l-2.32.34q-7.98 1.15-16 2.16l-2.53.32c-18.17 2.27-36.38 3.3-54.66 4.11l-5.17.24q-3.17.16-6.34.27l-2.88.14-2.48.1c-2.62.32-2.62.32-5.77 1.33-4.8 1.44-9.6 1.27-14.57 1.25h-3.2q-4.32.02-8.64 0l-9.09.01h-15.27q-8.79-.03-17.58 0a5356 5356 0 0 1-24.18 0l-10.08-.01-2.97.01a87 87 0 0 1-16.95-1.8 68 68 0 0 0-7.88-1.07l-2.73-.24-2.71-.22-7.9-.67C485 540 485 540 482 539q-1.95-.27-3.91-.43l-2.3-.22-2.42-.23c-5.75-.58-11.06-1.27-16.48-3.4-2.66-1.01-5.38-1.6-8.16-2.2A15 15 0 0 1 444 530c6.35-.33 12.07.93 18.25 2.31 13.74 2.96 27.4 5.62 41.46 6.35 5.06.27 10 .98 15 1.84 10.57 1.6 21 1.65 31.68 1.63h47.14c8.36.02 16.7-.05 25.04-.58l1.9-.11c2.44-.18 4.31-.33 6.53-1.44q3.15-.3 6.3-.46l3.93-.24 2.09-.12c15.79-.9 31.45-2.11 47.07-4.68 5.79-.87 11.63-1.3 17.45-1.8l2.63-.24 2.35-.2c2.18-.26 2.18-.26 4.11-.76a48 48 0 0 1 7.68-1.04l3.29-.26 3.41-.26 3.38-.28c5.46-.43 10.84-.78 16.31-.66"/><path fill="#3f3f42" d="M1079 2h57c2 4 2 4 2 7l-59 1v3h-25l1-7h22z"/><path fill="#817e8a" d="M1316 1555h1c1.32 11.9 1.35 23.86 1.56 35.81l.06 3.36a6271 6271 0 0 1 .42 25.48c.45 30.9.45 30.9-2.04 44.35l-.39 2.2q-1.24 6.9-2.61 13.8h-2l-.25 3.19a75 75 0 0 1-2.62 11.75l-.5 1.75c-.5 1.65-.5 1.65-1.63 4.31l-3 1c-2.49-2.8-2.16-5.4-2-9l4-1v-11h3l1-21h3l.02-1.57c.42-26.55 1.03-53.08 2.04-79.62l.07-1.82z"/><path fill="#737376" d="m136.63 991.9 5.37.1 1 2c2.26.36 4.49.44 6.77.56 2.71.54 3.34 1.5 5.23 3.44 2.48.95 2.48.95 5.19 1.69l2.73.76 2.08.55v2l1.8-.1 2.39-.09 2.35-.1c2.46.29 2.46.29 4.9 1.77 2.98 1.77 4.87 1.91 8.31 2.02a42 42 0 0 1 13.22 2.81c1.99.68 3.76.98 5.84 1.19 3.48.55 4.56 1.28 7.19 3.5 2.3 1.4 4.65 2.7 7 4v4h-11l-2-4h-10l-2-4h-13l-1-3-2.55-.08-3.32-.17-3.31-.14-2.82-.61-2-4h-10l-2-4h-10l-2-4c-2.37.31-2.37.31-5 1l-2 3-11-1v-3h12l-1-5c1-1 1-1 2.63-1.1"/><path fill="#bec6ce" d="M1459 925c0 2.61-.22 4.06-.99 6.48l-.64 2.02-.68 2.13-.7 2.18c-4.03 12.65-4.03 12.65-5.8 17.34-4.71 12.88-3.4 27.35-3.19 40.85l.02 2.04c.23 13.88 5.16 27.5 10.98 39.96l.92 2.04c2.24 4.46 5.42 7.27 9.28 10.38 1.67 1.47 2.8 2.6 3.8 4.58v3l3 1 .17 2.08c1.14 4.03 3.38 6.53 6.08 9.67l1.45 1.79c2.33 2.74 3.65 4.26 7.16 5.35l2.14.11.38 1.94.62 2.06 2 1-5-1v-3l-2.62.25c-4.08-.3-6.46-1.52-9.38-4.25a112 112 0 0 1-4.54-6.12 17 17 0 0 0-4.34-4c-2.12-1.88-2.12-1.88-2.43-5.13l.31-2.75h-3v-3h-3v-8l-1.81-.62c-2.94-1.85-3.77-4.35-5.19-7.38l-1.57-3.11c-2.78-5.65-5.08-10.57-5.23-16.99-.2-1.9-.2-1.9-1.15-3.74-4.59-9.41-3.24-22.42-3.24-32.66v-1.84c0-18.03 0-18.03 3.13-24.16a38 38 0 0 0 3.06-8.5c1.52-6.74 5.04-17.04 10-22"/><path fill="#7f7e85" d="M433 1717c2 2 2 2 2 5l1.75.63c3.92 2.39 5.73 6.2 7.56 10.28 1.63 3.29 3.83 5.34 6.63 7.71 6.95 5.99 6.95 5.99 9.06 8.38v3l1.88.44c2.12.56 2.12.56 4.12 1.56v2l3 1v1c-5.3.7-8.38-1.08-13-3.57-4.17-1.99-8.49-3.33-12.91-4.64-3.56-1.1-5.54-2.16-8.09-4.79a74 74 0 0 0-3.3-1.32c-4.2-1.7-6.83-4.35-9.89-7.62l-1.53-1.59q-1.49-1.53-2.94-3.07-1.81-1.87-3.7-3.65C412 1726 412 1726 412 1724l10 4-2 1 1 3a42 42 0 0 0 6 2l2-5 2 1c1.83-2.34 3.01-4.09 2.98-7.11a82 82 0 0 0-.98-5.89"/><path fill="#2b292f" d="M958 368c0 3 0 3-1.8 4.83l-2.39 1.98c-2.84 2.42-5.4 4.73-7.68 7.69L944 385h-3v4l4 1-2.12 1.19a55 55 0 0 0-7.45 5.72C934 398 934 398 931 399l-.87 1.44c-1.61 2.23-3.6 2.61-6.13 3.56l-2 2-5 2-2 2-6 2-2 2-5 2-2 2c-1.66.3-1.66.3-3.5.44-1.9.2-1.9.2-3.5.56l-1 1.56-1 1.44c-5.95.54-5.95.54-8.94-1l-1.06-1-1.12 1c-2.65 1.41-4.93 1.13-7.88 1v2l5 1v1h-11l2-1v-2l-2.34.29C853.2 424.6 853.2 424.6 848 422c-1.21-1.58-1.21-1.58-2-3l3 1c9.65.77 19.62.4 29-2l2.54-.62a71 71 0 0 0 14.7-5.93q3.04-1.6 6.15-3.08c6.41-3.08 12.34-6.32 18.1-10.54 2.89-2.1 5.84-4.1 8.82-6.08l1.5-1a231 231 0 0 1 10.82-6.7c3.32-2.08 5.54-4.31 7.94-7.39 2.8-3.24 6.16-5.91 9.43-8.66"/><path d="M572 979v2l-1.87.06c-20.87.7-20.87.7-29.94 1.94l-1.85.25c-2.32.35-4.22.7-6.34 1.75q-2.9.15-5.8.14h-1.83l-10.4.04-19.15.03q-11.51.02-23.02.03-13.3 0-26.6.05l-20.55.03-12.28.03q-5.77.02-11.54 0l-4.24.02h-9.05c-2.54-.37-2.54-.37-3.9-1.9L383 982l2.08-.01a38693 38693 0 0 0 74.8-.41 10414 10414 0 0 0 32.53-.18c15.63-.06 31.06-.32 46.6-2.17 10.95-1.28 22.09-2.21 32.99-.23"/><path fill="#9a9a9c" d="M366 971a1895 1895 0 0 1 59.44.5 2801 2801 0 0 1 15.83.34C443 972 443 972 445 973q2.49.14 4.97.13h14.93C467 973 467 973 468 972q2.25-.23 4.51-.31l2.94-.13 3.23-.12 3.37-.13c13.03-.46 26.06-.43 39.09-.38l9.82.02L550 971c-9.78 5.16-24.4 3.3-35.3 3.55l-6.61.17-1.97.04c-3.74.1-7.42.36-11.14.75-7.25.69-14.48.63-21.75.62h-26.17c-66.67.09-66.67.09-81.06-3.13z"/><path fill="#19191f" d="M1542 643h12c-.5 3.71-1.03 5.66-4 8a157 157 0 0 1-5 3l-4 2.5a226 226 0 0 1-6.69 4.06c-2.31 1.44-2.31 1.44-4.6 3.35a45 45 0 0 1-8.4 5.03L1515 672c-5.62 2.73-11 5.55-16.25 8.94-2.15 1.3-4.42 2.13-6.75 3.06l-1.36 1.45c-1.94 1.83-3.54 2.43-6.08 3.24l-2.3.76c-2.19.53-4.02.66-6.26.55l-1 3c-4.34 3.52-7.46 4.56-13 5 1.38-2.98 2.7-5.63 5-8 2.3-.84 4.63-1.38 7-2l1-2c3.06-.62 3.06-.62 6-1v-2l1.93-.59 2.5-.78 2.5-.78C1490 680 1490 680 1491 678c3.06-.62 3.06-.62 6-1v-3a81 81 0 0 1 10.38-4l2.62-1 1-3 2.38-.31c2.62-.69 2.62-.69 3.62-2.19s1-1.5 3.63-2.19l2.37-.31 1-3a56 56 0 0 1 8.75-4c2.42-1.07 2.95-1.8 4.25-4h2v-3l3-1z"/><path fill="#2c2d35" d="M439 1157h2a87 87 0 0 1 1.13 14.39l.01 2.16v7.16l.02 5.19.01 14.24.02 15.38.04 26.64.09 56.52v1.82l.07 42.81v1.9l.07 43.68v1.9l.01 9.47v5.66l.11 62.47.02 8.83a31188 31188 0 0 1 .08 57.75l.02 15.2a3944 3944 0 0 0 .02 18.95c-.03 24.02-.03 24.02 5.28 30.88-.31 2.25-.31 2.25-1 4-4.77-3.62-5.87-8.36-7-14-.41-5.03-.39-10.03-.35-15.07l-.01-4.54q0-6.23.03-12.44v-13.44l.04-23.3q.04-16.91.05-33.83.02-27.54.07-55.08a98149 98149 0 0 0 .12-92.22v-3.2l.02-12.66.05-36.78c.15-76.04.15-76.04-.87-108.34a621 621 0 0 1-.15-22.1"/><path fill="#17161a" d="m1031 756-3.25 1.44-1.83.8A46 46 0 0 1 1020 760q-3.75 1.17-7.5 2.38a223 223 0 0 1-18.05 4.94c-2.45.68-2.45.68-4.49 1.7-2.21 1.1-4.15 1.5-6.59 1.92-6.09 1.14-11.9 2.94-17.8 4.85a102 102 0 0 1-16.8 3.68c-2.77.53-2.77.53-5.23 1.53-2.97 1.17-5.74 1.58-8.91 2-4.01.57-7.86 1.23-11.73 2.44a180 180 0 0 1-19.2 4.35l-2.17.4-4.24.72c-4.97.87-4.97.87-6.77 2.09-1.78 1.17-3.15 1.45-5.25 1.78l-4.46.72c-5.07.81-10.03 1.73-15 3.05-6.85 1.7-13.8 1.57-20.81 1.45a27 27 0 0 1 7.94-2.94q5.65-1.18 11.27-2.5l3.33-.77 6.78-1.58A535 535 0 0 1 894 788l3.06-.6 4.81-.94C904 786 904 786 907 785q2.1-.1 4.19-.06l2.17.02 1.64.04v-2l3.18-.77 4.26-1.04 2.23-.55c3.77-.94 7.43-1.96 11.08-3.33 4.34-1.54 8.57-1.72 13.14-2 4.2-.42 8.12-1.46 12.18-2.56 2.8-.72 5.57-1.2 8.43-1.62 5.2-.9 9.7-2.72 14.48-4.91 7.6-3.07 15.85-4.21 23.86-5.72 4.28-.83 8.15-1.75 12.16-3.5q2.33-.65 4.69-1.19l2.32-.54c1.99-.27 1.99-.27 3.99.73"/><path fill="#4f4f53" d="m1559 648 2 1c-4.96 5.6-9.8 9.18-16.45 12.56-2.98 1.68-5.01 3.62-7.3 6.13-1.82 1.9-3.94 3.04-6.25 4.31q-2.25 1.46-4.46 2.97-1.52 1.02-3.08 2a33 33 0 0 0-6.96 5.65c-2.9 2.76-4.7 3.4-8.5 4.38v2l-1.58.81c-7.01 3.74-14.44 8.13-20.03 13.83a13 13 0 0 1-4.7 2.92l-1.85.75q-2.55.94-5.15 1.75c-2.69.94-2.69.94-4.69 2.94q-2.1.71-4.21 1.36c-2.82 1-5.41 2.43-8.06 3.84-1.73.8-1.73.8-3.73.8l-1 3c-2.15 1.4-2.15 1.4-4.94 2.81-3.76 1.96-7.4 3.99-11 6.25a69 69 0 0 1-11 5.38c-6.86 2.74-13.87 6.2-19.02 11.63-2.4 2.28-4.51 3.14-7.6 4.3a95 95 0 0 0-14.87 7.5c-3.03 1.33-5.3 1.34-8.57 1.13 1.22-2.44 1.83-2.47 4.31-3.37 2.53-1.02 3.72-1.66 5.69-3.63 2.17-2.17 4.24-3.02 7.05-4.18 3.15-1.33 6.2-2.85 9.26-4.38l1.88-.92c2.63-1.31 4.72-2.43 6.81-4.52l5-2 2.32-1.44c3.53-2.06 7.2-3.21 11.06-4.5A25 25 0 0 0 1436 727l4.45-2.13c1.55-.87 1.55-.87 4.17-3.18 3.7-3.25 8.07-5.16 12.48-7.26 3.6-1.78 6.96-3.83 10.34-6 2.56-1.43 2.56-1.43 5.37-2.24 3.69-1.37 6.18-3.33 9.24-5.74a26 26 0 0 1 5.14-3.01c2.81-1.44 2.81-1.44 4.96-3.68 3.33-3.42 7.26-5.8 11.29-8.32l4.43-2.82 2.13-1.34q2.71-1.74 5.36-3.57c2.64-1.71 2.64-1.71 5.52-2.59 3.68-1.32 6.18-3.27 9.23-5.67 1.89-1.45 1.89-1.45 4.64-3.01 2.25-1.44 2.25-1.44 4.25-4 2.21-2.7 3.77-3.3 7-4.44l1-2c1.45-.68 1.45-.68 3.25-1.31 3.86-1.55 6-3.6 8.75-6.69"/><path fill="#5b427e" d="m950.13 528.94 2.19.02 1.68.04c-1 3-1 3-2.7 3.88l-2.11.68a40 40 0 0 0-6.19 2.5c-2.62 1.23-5.28 1.77-8.1 2.38-1.98.58-3.2 1.42-4.9 2.56a98 98 0 0 1-4.62 1.56c-4.28 1.34-4.28 1.34-5.38 2.44q-2.02.1-4.06.06l-2.23-.02L912 545c1-2 1-2 4-3q-2.37-.08-4.75-.12l-2.67-.08c-2.78.22-4.14.93-6.58 2.2q-3.33.86-6.69 1.56c-6.18 1.31-6.18 1.31-7.31 2.44q-2.46.55-4.94 1c-4.85.9-4.85.9-7.06 2q-3.3.35-6.62.56l-1.86.13-4.52.31v2a84 84 0 0 1-15.75 3.88c-7.26 1-14.34 2.67-21.47 4.36-6.38 1.5-12.62 2.94-19.22 2.82l-2.06-.02-1.5-.04v-1l2.43-.52 13.84-2.97c8.02-1.72 15.92-3.48 23.74-5.98 2.77-.74 5.15-.75 7.99-.53v-2c3.97-1.77 7.87-2.6 12.13-3.31 8.72-1.54 17.2-3.8 25.75-6.11 6.6-1.8 13.16-3.48 19.93-4.52 4.88-.76 9.6-1.85 14.38-3.12l2-.53c4.68-1.28 4.68-1.28 5.81-2.41q3.21-.55 6.44-1c4.37-.6 8.32-2.13 12.68-2.06"/><path fill="#9a8372" d="M1051 1218q-.17 1.94-.37 3.88l-.22 2.17c-.41 1.95-.41 1.95-2.41 3.95-.93 2.2-1.77 4.37-2.56 6.63l-.68 1.9A53 53 0 0 0 1042 1250c-1.67 1.67-3.58 1.21-5.87 1.25-7.99-.07-15.71-1.46-23.56-2.81q-6.62-1.11-13.26-2.13L991 1245v-2l-3.31.06c-2.66.05-4.07-.19-6.69-1.06a73 73 0 0 0-4.01-.32l-2.3-.12-2.38-.12-8.31-.44 1-4 2 1-1 2 1.74-.05c19.33-.46 19.33-.46 28.14 2.25 4.98 1.27 10.05 1.67 15.16 2.15l2.25.22 2.02.18c1.69.25 1.69.25 3.69 1.25q2.5.22 5 .32l3 .12 3.13.12 3.15.13 7.72.31v-13l-2 2a43 43 0 0 1 3-10l-4.44 1.44-2.5.8c-2.06.76-2.06.76-3.06 1.76q-3.78.11-7.56.06l-7.44-.06v-1h10v-3q-2.85.17-5.69.38l-3.2.2c-3.04.41-5.28 1.28-8.11 2.42q-2.69.3-5.37.44c-4.09.3-7.05.95-10.7 2.73A48 48 0 0 1 972 1236c4.3-2.96 8.8-4.28 13.84-5.44 2.16-.56 2.16-.56 4.95-1.57 3.6-1.1 6.92-1.47 10.65-1.8 8.26-.85 16.26-2.53 24.35-4.36a239 239 0 0 1 18.2-3.47c2.73-.49 4.1-1.36 7.01-1.36"/><path fill="#271644" d="M1148 452c-8.18 5-8.18 5-12 5v2l-1.86.55-2.45.76-2.43.74c-2.4 1.01-3.49 2.08-5.26 3.95a47 47 0 0 1-13.06 5.43c-10.42 3.05-10.42 3.05-12.94 5.57q-2.6.3-5.21.52c-1.79.48-1.79.48-3.08 1.85-2.54 2.42-5.49 3.21-8.77 4.32l-1.93.68c-4.7 1.63-4.7 1.63-7.01 1.63v3c-6.6 2.3-13.25 4.22-20 6v2l-5.81 2-3.27 1.13c-2.92.87-2.92.87-5.92.87v2h-3l-1 3-1.77.3c-17.77 3.24-17.77 3.24-22.23 7.7-2.1.3-4.15.51-6.25.69-7.1.76-13.7 2.62-20.5 4.81l-2.92.94c-3.76 1.24-7.06 2.33-10.33 4.56-2.32.5-2.32.5-5.12.88-8.07 1.22-15.95 3.19-23.88 5.12v-2l2.09-.55a235 235 0 0 0 15.3-4.58c4-1.33 8.01-2.57 12.03-3.81q5.78-1.8 11.52-3.68l2.52-.82q2.4-.78 4.8-1.6c4.6-1.48 8.91-2.35 13.74-2.7 2.7-.35 4.88-1.35 7.33-2.5 2.78-1.26 5.73-1.97 8.67-2.76 5.2-1.46 10.25-3.05 15.25-5.12A37 37 0 0 1 1044 497v-2l2.08-.77 2.73-1.04 2.7-1.02c2.49-1.17 2.49-1.17 3.99-2.72 2.05-1.99 3.92-2.26 6.69-2.89A57 57 0 0 0 1076 481c6.52-3.35 12.92-6.55 20.02-8.46q3.73-1.03 7.42-2.17l2.62-.78q.96-.3 1.94-.59v-2l1.94-.77c7.91-3.15 7.91-3.15 11.62-4.8a75 75 0 0 1 8-2.87c4.06-1.3 6.99-3.07 10.44-5.56 3.1-1.92 4.53-2.24 8-1"/><path fill="#5f422f" d="m1049.5 1214.81 2.6.08 1.9.11c.9 2.4 1.34 4.2.32 6.62l-1 1.82c-2.04 3.7-3.28 7.44-4.38 11.5q-.26.9-.5 1.83l-.98 3.57q-.96 3.47-2.02 6.91l-.63 2.08c-1.03 2.12-1.64 2.73-3.81 3.67-6.1.56-12.2.57-18.02-1.5-2.85-.72-5.74-.79-8.67-.94a75 75 0 0 1-16.42-2.87c-3.8-.9-7.53-1.19-11.42-1.38-2.47-.31-2.47-.31-4.83-1.3-3.35-1.28-6.4-1.46-9.95-1.63l-1.96-.12q-2.36-.14-4.73-.26v2h-12l1-4c2.94-1.62 2.94-1.62 6-3l2-2q2.91-.85 5.88-1.5A41 41 0 0 0 978 1231q2.45-.72 4.94-1.31l3.23-.78 1.83-.43 6.58-1.6a596 596 0 0 1 32.9-7.1c5.18-.98 9.9-2.2 14.8-4.18 2.44-.85 4.65-.9 7.22-.79m-6.58 4.66c-3.12.86-6.3 1.4-9.48 1.97q-7.73 1.38-15.38 3.12a137 137 0 0 1-18.02 2.62c-4.4.43-8.13 1.42-12.22 3.1-1.82.72-3.6 1.21-5.5 1.66a309 309 0 0 0-11.26 2.93l-1.76.46c-1.64.47-1.64.47-4.3 1.67-.67 2.07-.67 2.07-1 4l1.51.08q3.4.16 6.8.36l2.38.12 2.3.12 2.1.11c1.91.21 1.91.21 4.91 1.21 1.82.1 1.82.1 3.69.06l3.31-.06v2l2.95.44 24.45 3.68c2.81.43 5.6.88 8.39 1.45 2.85.55 5.56.56 8.46.56h3.2c2.55-.13 2.55-.13 3.55-1.13q.41-2.4.75-4.81c1.12-6.52 3.56-12.18 6.25-18.19 1.36-3.21 2.26-5.5 2-9-2.97 0-5.25.62-8.08 1.47"/><path fill="#e1e0df" d="M1236 922c2 2 2 2 2.24 4.1l-.01 2.58v2.81l-.04 2.95v2.93c-.05 7.2-.05 7.2-1.19 10.63h-2l-.11 2.55-.2 3.33-.18 3.3c-.51 2.82-.51 2.82-2.07 3.92l-1.44.9c-.01 2.03-.01 2.03.31 4.5.21 6.03-2.35 8.72-6.24 13.08a29 29 0 0 0-4.32 6.73c-4.46 8.55-12.21 15.81-19.75 21.69l-2.06 2.31c-3.44 3.56-7.93 5.75-12.2 8.14-2.74 1.55-2.74 1.55-4.38 2.75-2.58 1.51-5.45.91-8.36.8l1-3 6-1 1-4 1.94-.37 2.06-.63 1-2c2.31-.59 4.62-.74 7-1v-4h4l2-4h3v-3l3-1q1.01-1.5 2-3h2v-5h4l.38-2.94.62-3.06 2-1q1.09-2.46 2-5h3v-7l4-1v-9l3-1 1-16h3a35 35 0 0 0 1.28-8.07l.33-7 .1-2.25.1-2.07c.19-1.61.19-1.61 1.19-2.61"/><path fill="#e4e8eb" d="M1450 1042c1.75.06 1.75.06 4 1 2.47 3.1 3.91 5.98 4.25 9.94l-.25 2.06 4 2-1 3 3-1q2.91 2.58 5.81 5.19l1.66 1.46c3.2 2.89 5.4 5.56 7.53 9.35 3.54 2.68 6.62 3.5 11 4l1 3c1.63.73 1.63.73 3.56 1.19l1.94.48 1.5.33v4l2.75-.25c3.25.25 3.25.25 5.56 2.06 1.69 2.19 1.69 2.19 2.69 5.19a24 24 0 0 0 4 2l-1 5-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-4-1v-3h5a34 34 0 0 0-10-12c-2.81-.12-2.81-.12-5 1-1.31 1.56-1.31 1.56-2 3l-7-1v-3h8l-2-4c-2.92 1.07-4.78 1.78-7 4-2.12-.37-2.12-.37-4-1v-3h5v-4l-1.81-.31c-2.19-.69-2.19-.69-3.69-2.32l-1.5-1.37c-2.06.13-2.06.13-4 1-1.25 1.56-1.25 1.56-2 3l-6-1-1-3h8l-2-4h-6v-4l2-1c.63-2.06.63-2.06 1-4l-4-1v-3l-8-1c-1.02-5.62-1.02-5.62-.87-8.25l-.13-1.75-1.5-.87-1.5-1.13c-.19-3.12-.19-3.12 0-6"/><path fill="#272730" d="M909 1109a224 224 0 0 1 16.4 5.96c3.63 1.45 7.31 2.78 11 4.1q3.41 1.24 6.79 2.57a58 58 0 0 0 7.68 2.3c3.05.71 5.72 1.73 8.57 3 6.68 2.81 13.8 3.68 20.93 4.74 1.63.33 1.63.33 3.63 1.33v2l3.31.4q4.82.6 9.63 1.35c16.73 2.34 33.42 2.56 50.28 2.56l5.69.01a1558 1558 0 0 0 14.13-.02 75 75 0 0 0 16.85-1.86c2.78-.58 5.28-.59 8.11-.44-1 2-1 2-3.68 3.1-3.31.9-6.08 1.18-9.5 1.22l-1.78.03-5.75.06-4.01.06q-5.25.08-10.5.13l-10.74.14q-10.53.14-21.04.26v1c-11.84.21-23.05-.49-34.52-3.48L988 1139l-1 1 2 2h-9l4-3-1.98-.49c-44.3-10.92-44.3-10.92-47.03-15.03-1.37-2.05-2.8-2.38-5.12-3.17l-2.19-.76-1.68-.55v-2l-9-1v-2l-2.87-.81c-3.16-1.2-3.6-1.46-5.13-4.19"/><path fill="#191a1e" d="M472.11 1819.77c1.89.23 1.89.23 3.69 1.22 2.57 1.18 4.48 1.3 7.3 1.39l2.99.1 3.1.08c19.98.6 19.98.6 21.81 2.44q2.13.14 4.26.14l2.8.02h3.12l3.28.01 9.06.03q4.88 0 9.75.04l16.91.05 24.45.08a72835 72835 0 0 0 80.6.27l11.91.04 98.86.32v1H511l-1 3h-31l-2-4h-10a89 89 0 0 1-1-5c1.7-1.7 3.84-1.18 6.11-1.23"/><path fill="#0e0e14" d="M1661 511h1q-.09 4.25-.25 8.5l-.04 2.4c-.21 5.16-1.2 8.85-3.76 13.35a24 24 0 0 0-2.01 5.19L1655 543l-3 1q-.55 1.99-1 4a83 83 0 0 1-2.94 4.31c-1.88 2.72-3.31 4.88-3.93 8.13-1.23 5.43-5.6 9.46-9.13 13.56a96 96 0 0 0-8 10.56 42 42 0 0 1-8 9.44h-2l-.69 1.69c-2.91 5.13-8.18 9.13-12.5 13.12l-1.61 1.52c-3.97 3.67-3.97 3.67-6.2 3.67l-.81 1.81A16 16 0 0 1 1591 621h-2v2a101 101 0 0 1-6.56 6.38c-1.88 2.12-3.1 4.49-4.46 6.97A18 18 0 0 1 1574 641l-2-1 1-4a413 413 0 0 0-8.75 3.56l-1.8.76c-1.45.68-1.45.68-2.45 1.68q-2.5.06-5 0l1-3c2.06-.69 2.06-.69 4-1v-3l2.38-.25 2.62-.75q1.05-1.98 2-4c2.06-.62 2.06-.62 4-1l1-2 4 1-2 1 4 2 .77-1.5 1.04-1.94 1.02-1.93C1582 624 1582 624 1585 623l.81-1.75c1.45-2.74 3.22-4.06 5.62-5.97a62 62 0 0 0 5.22-4.96 83 83 0 0 1 4.91-4.32 36 36 0 0 0 5.38-5.44 40 40 0 0 1 5.81-5.81c21.3-18.4 40.1-48.18 46.38-75.62l.55-2.38z"/><path fill="#0e0f16" d="M387 1203h1l-1 461h2a120 120 0 0 1 2.88 13.69c.9 5.68 2.43 10.95 4.22 16.4a65 65 0 0 1 1.9 7.91c-2.95-1.47-3.43-4.08-4.56-7-2.33-5.89-2.33-5.89-3.44-7q-.22-1.8-.32-3.6l-.12-2.18-.12-2.28-.13-2.3-.31-5.64h-2a88 88 0 0 1-1.13-14.67v-395.76c-.03-40.7-.03-40.7 1.13-58.57"/><path fill="#161619" d="m1426 1436 2 1-1 5-7-1 .37 100.56.01 2.13.26 69.35.13 36.53.07 17.87v2.74c.03 4.07.14 7.85 1.16 11.82l-1.87.69c-2.74 1.69-3.18 3.3-4.13 6.31-1.14-2.85-.98-4.05-.04-7.04 1.38-4.6 1.33-9.06 1.32-13.84l.02-2.96.02-9.8q0-3.53.03-7.04l.05-15.14q.02-10.95.06-21.88l.12-35.51.1-34.49.01-2.15.04-10.67.27-88.48 1.5-.62 1.94-.82 1.93-.8c1.63-.76 1.63-.76 2.63-1.76"/><path fill="#3f3f42" d="M321 642.88c3 .12 3 .12 4 1.12-.48 6.48-.48 6.48-2.45 8.44-5.9 5.94-6.28 19.5-7.55 27.56h-2c.08 12.92.17 24.3 6 36l1.44 3.75c1.58 3.65 3.87 6.81 6.16 10.04A41 41 0 0 1 330 737q1.96 2.53 4 5c3 4.5 3 4.5 3 8h-3l-1-4h-3l-.31-1.94L329 742l-3-1c-1.69-1.37-1.69-1.37-3-3v-3l-4 2v-7l2-1c-2.17-2.5-3.73-3.44-7-4l1-4 2 1q-.37-.82-.77-1.65c-3.67-8.04-6.43-15.52-7.23-24.35l-.15 1.9-.23 2.48-.2 2.46C308 705 308 705 306 707v-33h2v7h2l.08-1.62c.41-5.47 1.7-9.93 3.88-14.96a95 95 0 0 0 2.54-6.73c1.26-3.46 3.18-5.83 5.5-8.69.75-2.81.75-2.81 1-5-2.83.4-3.85.83-5.81 3-3.03 2.77-5.16 2.69-9.19 3 1.47-2.65 2.75-3.56 5.56-4.62 6.35-2.46 6.35-2.46 7.44-2.5"/><path fill="#242329" d="M1070 81a16.8 16.8 0 0 1 2 12c-4.21 9.58-12.55 17.18-21 23l-2.52 1.82c-4.36 3.13-8.58 5.96-13.48 8.18l-2.37 1.19-1.63.81v-2c3.06-2.25 5.4-3.8 9-5l1-2c1.6-.86 1.6-.86 3.63-1.75a39 39 0 0 0 9.72-6.76 64 64 0 0 1 4.65-3.74c4.67-3.94 9.34-9.2 10.48-15.33.03-3.16-.06-5.58-1.48-8.42-10.65-3.55-20.9.09-30.94 3.75q-3.79 1.34-7.59 2.67l-1.87.66c-6.45 2.27-13 4.1-19.6 5.92l-5.62 1.63-2.3.66c-2.08.71-2.08.71-4.17 1.8-1.91.91-1.91.91-4.91.91l-1 3a51 51 0 0 1-6.06 2c-3.8 1.08-7.57 2.17-11.25 3.63A87 87 0 0 1 961 113c-5.64 1.38-10.73 3.56-16 6a137 137 0 0 1-15.87 6.28 65 65 0 0 0-5.75 2.28c-4.95 2.19-10.07 3.47-15.38 4.44 3.04-3.47 6.11-5.19 10.38-6.75l1.67-.67A25 25 0 0 1 931 123l1-3c4.36-2.63 9.3-4.21 14.22-5.47 1.78-.53 1.78-.53 4.98-1.95 3.92-1.71 7.9-2.77 12.05-3.77l2.14-.53c3-.74 5.5-1.28 8.61-1.28v-2h3v-2c4.8-2.2 9.58-3.83 14.67-5.25C994 97 994 97 996.09 95.94c2.32-1.14 4.53-1.3 7.07-1.6 1.9-.35 3.37-.93 5.11-1.75 3.61-1.6 7.22-2.6 11.04-3.53a79 79 0 0 0 12.57-4.06c10.07-4.23 28.04-10.4 38.12-4"/><path fill="#302f35" d="M882 274h2c.31 4.58-.07 6.51-3 10l-.87 2.31A34 34 0 0 1 874 296h-2l-.48 1.77c-2.2 7.75-4.72 15.8-9.05 22.65a30 30 0 0 0-2.97 7.7c-.89 3.32-2.03 6.56-3.12 9.82l-.7 2.07L854 345h-2l-.35 2.1c-.59 2.62-1.4 5.01-2.36 7.5-2.99 7.71-4.58 15.37-5.93 23.5a76 76 0 0 1-1.41 5.83c-1.1 4.27-1.37 8.37-1.39 12.76v2.35c.14 7.42 2.16 13.33 5.44 19.96-4.82-2.23-7.15-5.01-9.25-9.94-2.77-11.55-.54-24.85 3.23-35.93.81-2.5 1.42-4.85 1.96-7.4 1.05-4.88 2.48-9.57 4.06-14.3l.9-2.67c3.8-11.15 8.44-21.95 13.1-32.76h2l.45-2.24c1.7-7.73 4.09-14.13 8.55-20.76l2-1c.56-1.6.56-1.6 1-3.5s.44-1.9 1-3.5l2-1q1.39-2.5 2.63-5.06l1.35-2.79z"/><path fill="#7d7d7f" d="M1244 807v4q2.69-.43 5.38-.87l3.02-.5c2.6-.63 2.6-.63 4.6-2.63 2.63-.12 2.63-.12 5 0v2c-18.94 8.48-18.94 8.48-27.82 9.64-3.42.57-6.69 1.87-9.18 4.36q-2.33.51-4.69.94c-4.6.88-8.88 2.04-13.2 3.85A27 27 0 0 1 1194 830c1-3 1-3 2.7-3.88l2.11-.68a39 39 0 0 0 6.38-2.63c2.34-1.05 4.27-.95 6.81-.81v-3q-1.3.45-2.62.94c-7.98 2.61-7.98 2.61-11.76 1.12L1196 820a71 71 0 0 1 18.83-8.35C1217 811 1217 811 1219 810c3.82-.14 6.36-.21 10 1q3.04-1.43 6-3c3.05-1.02 5.79-1.09 9-1"/><path fill="#2a2a2d" d="m436 801 8 1v2l1.54-.07c4.65-.12 8.14.34 12.46 2.07v2l1.86.34a2511 2511 0 0 1 14.16 2.59l2.6.48c2.39.6 4.28 1.3 6.38 2.59.75 2.13.75 2.13 1 4h-7v2l3.19.44q4.43.63 8.81 1.56v1c-8.47.46-15.82-.9-24-3l-4.81-1c-10.5-2.3-10.5-2.3-13.19-5q-4.07-.66-8.16-1.16c-3.13-.44-5.34-.9-7.84-2.84a75 75 0 0 0-5-1l1-4h9z"/><path fill="#1a1b1f" d="m1270.31 1822.88 2.68.05 2.01.07c-1.78 1.26-2.97 2-5.12 2.5l-1.88.5-1 2h2v2h-3v-3H776v-1h3.1l135.12-.2h2.06l66.23-.12h2.2l70.31-.1q36.13-.05 72.25-.12l10.2-.02h2.03l32.34-.05a24112 24112 0 0 0 50.11-.07 5277 5277 0 0 0 21.96-.04q3.96 0 7.92-.02l2.3.01c5.77-.05 10.48-2.55 16.18-2.4"/><path fill="#84838b" d="M603.12 1641.87h389.52c4.51.02 8.9.49 13.36 1.13v1c-5.95.86-11.71 1.14-17.72 1.11h-2.84l-15.64-.02-16.85-.02-17.22-.02L902 1645v-1H589v-1a85 85 0 0 1 14.12-1.13"/><path fill="#101013" d="m614.8 969.9 2.15.01 2.24.03 7.81.06v1l-14.48 1.64c-7.33.84-14.64 1.56-22 2-3.1.26-6.14.8-9.2 1.33-5.9 1-11.78 1.6-17.75 2.07l-22.7 1.8-6.07.49-2.79.22-2.43.2q-2.88.29-5.74.77c-4.75.7-9.49.62-14.27.6l-3.12.01H458.8c-54.55.05-54.55.05-77.8-1.13l-1-2h1.92l68.38.08a16331 16331 0 0 0 39.39.03l8.4.01h2.74C503 979 503 979 505 978c10.86-1.21 21.85-1.29 32.76-1.55 7.87-.2 15.66-.52 23.5-1.24 4.33-.33 8.65-.32 12.99-.27l8.75.06v-2l2.25-.17 16.99-1.3 3.12-.23c3.3-.37 6.08-1.42 9.43-1.4"/><path fill="#1c1c1f" d="M1181 1024c-3.19 2.3-7 5-11 5l-1 3-11 2v3h7v1h-7l-1 3h-2v-3q-3.15.17-6.31.38l-1.75.09c-3.68.24-7.15.78-10.76 1.55-7.39 1.4-14.78 1.28-22.27 1.24q-3.7-.01-7.42.01c-7.3 0-14.23-.07-21.37-1.8-3.55-.8-7.12-.93-10.74-1.1l-2.18-.11q-2.6-.14-5.2-.26l-1-3-2.74-.04-3.57-.15q-.9 0-1.8-.02c-1.75-.08-1.75-.08-4.89-.79-1.92-2.55-1.92-2.55-3-5 3.53.43 6.73.92 10.16 1.93 15.53 4.45 31.3 4.55 47.32 4.46q4.89-.02 9.78.01h7.68q2.65 0 5.32.02a68 68 0 0 0 18.99-2.7c2.93-.77 5.8-1.18 8.81-1.53 5.58-.84 9.64-2.93 14.37-5.95 3.04-1.47 5.23-1.46 8.57-1.24"/><path fill="#8b8b8b" d="m1049 869-1 3-10 1v2h10l-1 4-1.57.43q-3.57.97-7.12 1.95l-2.47.67c-6.35 1.75-6.35 1.75-9.38 3.02-3.62 1.37-7.33 1.88-11.15 2.5l-6.6 1.07c-1.71.36-1.71.36-3.71 1.36q-3.06.1-6.12.06l-3.33-.02L993 890v-2l10-2-9-1v2h-2c.61-3.67.61-3.67 2.25-5.07 2.15-1.14 4.1-1.51 6.5-1.93 6-1.13 11.37-2.61 17-5 4.55-1.87 8.94-2.8 13.79-3.56 2.46-.44 2.46-.44 5.59-1.57 3.97-1.2 7.74-1.04 11.87-.87"/><path fill="#626266" d="M1594 406h11l2 4h10l2 4h10l2 4 5.27-.3 1.73.3.95 1.48L1640 421c2.32.41 4.55.44 6.9.5 2.1.5 2.1.5 3.38 2.54l.72 1.96c2.38-.31 2.38-.31 5-1l2-3 11 1v3h-12c2 4 2 4 4 5q1.1 2.97 2 6l-5-1v-3h-8l-1-4-3.25.19c-3.6.01-4.9-.97-7.75-3.19-1.73-.28-1.73-.28-3.62-.44L1631 425c-1.44-2.06-1.44-2.06-2-4l-1.47.1c-4.73.2-8.21-.48-12.57-2.3a50 50 0 0 0-5.9-1.93C1606 416 1606 416 1605 415c-2.2-.24-4.36-.42-6.56-.56l-1.87-.13-4.57-.31v-4l-3-1h5z"/><path fill="#8e8e8f" d="m977 889 1 1c2.29.28 4.57.45 6.87.62C987 891 987 891 989 893c-3.06 1.78-5.22 2.23-8.75 2.13l-2.42-.06L976 895v3c-9.67 2.76-19.31 5.25-29.25 6.81l-2.34.38c-4.52.67-8.84.92-13.41.81v-2h9l1-4-12 1v3h-15l1-2c3.06-.62 3.06-.62 6-1v-2a68 68 0 0 1 19.43-3.79C943 895 943 895 946 894q2.6-.2 5.19-.31c3.81-.3 6.46-.8 9.75-2.75 5-2.88 10.45-2.49 16.06-1.94"/><path fill="#fcfcfd" d="m1446 1121 4 1 .08 38.3a5507 5507 0 0 1 .03 22.44l.01 1.88c0 4.27 0 4.27-1.12 5.38q-3.29-.14-6.56-.44l-1.87-.16-4.57-.4a2931 2931 0 0 1-.15-20.29c-.26-25.79-.26-25.79 5.15-33.71h1v53h4z"/><path fill="#7c7a7b" d="M1521 673c-1.1 3.31-1.42 3.55-4.25 5.23l-2.02 1.24-2.17 1.28-6.54 3.99a166 166 0 0 0-10.2 7.08C1494 693 1494 693 1492 693l-.77 1.84c-1.4 2.47-2.54 3.26-4.98 4.66-2.5 1.47-4.94 2.95-7.31 4.63a49 49 0 0 1-8.1 4.1c-7.88 3.3-15.25 7.74-22.37 12.44-2.58 1.39-3.9 1.66-6.78 1.52l-2.12-.08-1.57-.11-2-4 7-1 1-3 2.38-.31c2.62-.69 2.62-.69 3.53-2.22 1.09-1.47 1.09-1.47 3.04-1.76l2.17.1 2.2.08 1.68.11.13-1.75c1.08-2.78 2.62-3.66 5.12-5.18a72 72 0 0 0 5.31-3.7A33 33 0 0 1 1478 695q3.48-1.98 6.81-4.2a53 53 0 0 1 12.18-5.98c2.96-1.2 5.52-2.93 8.18-4.69 2.18-1.34 4.46-2.2 6.83-3.13l1-2a11.7 11.7 0 0 1 8-2"/><path fill="#88878e" d="m394 1681 2 1c.41 2.29.41 2.29.63 5.06l.22 2.79.15 2.15 4 1v-12c4.03 4.16 4 6.13 4 12a262 262 0 0 0 4 5c.25 3.31.25 3.31 0 6h3l1 7h3l.33 1.5.48 1.94.46 1.93.73 1.63 3 1c.69 2.06.69 2.06 1 4l2-1 3 3c.08-3.34-.04-6.46-.62-9.75L426 1712l2-2 1-3v2h2q1.22 4.08 2.31 8.19l.7 2.33a23 23 0 0 1 .99 6.48c-1.4 2.32-2.78 3.34-5 5l-1-1-2 4-6-1-.25-1.85c-.98-2.8-2.1-3.23-4.69-4.59-3.59-2.07-4.95-3.45-6.06-7.56l-2-2-.81-2.06a59 59 0 0 0-3.69-7.38 38 38 0 0 1-3.69-8.06l-.81-2.5-2-1c-2.25-4.9-3.41-9.63-3-15"/><path fill="#383a3c" d="M721 78h27l1 4 1.84-.03q9.46-.13 18.93-.19l7.07-.08q5.07-.06 10.15-.1l3.2-.05h2.97l2.62-.03C798 82 798 82 799.8 83.9 801 86 801 86 801 88c-2.7.9-4.23 1.13-7 1.13h-30.03A84 84 0 0 1 750 88l-1-3-3.37.14c-16.94.5-16.94.5-23.63-4.14z"/><path fill="#605e68" d="M430 1424h1v1.74l-.05 102.11v14.71l-.04 80.07-.02 42.18v9.9l-.01 10.72v8.58c.12 1.99.12 1.99 1.12 3.99q.65 4.65 1.16 9.33A38 38 0 0 0 436 1718c-.37 2.31-.37 2.31-1 4l-1-5h-2c-5.13-12.63-4.54-25.61-4.44-39.01q.02-4.86.02-9.72l.06-16.26q.06-10.45.07-20.89a9555 9555 0 0 1 .11-34.49l.01-3.24.02-2.92.02-2.56c.13-1.91.13-1.91 1.13-2.91q.15-3.11.16-6.23l.01-2 .04-6.66q0-2.39.03-4.76l.06-12.96.08-13.53.14-25.64.16-29.18z"/><path fill="#12141a" d="M341 1268h1v169c-4.87-4.87-4.11-10.86-4.14-17.43l.01-2.86v-51.41a9717 9717 0 0 1 0-32.75v-2.84a117 117 0 0 1 .9-14.76c.47-4.02.41-7.87-.27-11.87-.81-5.54-.68-11.12-.69-16.7l-.03-3.4-.01-3.27-.01-2.96c.28-3.2.9-6.4 3.24-8.75"/><path fill="#6d6b76" d="M1316 999h2c.97 4.09 1.17 8.04 1.2 12.22l.12 9.34.06 5.13.25 22.5c.44 39.94.48 79.87.37 119.81h-1l-.5-42.1-.2-17.82a693 693 0 0 0-.87-29.09l-.12-2.29-.11-2.02c-.2-1.68-.2-1.68-1.2-3.68l-.11 2.3-.2 3.01-.18 3c-.56 2.93-1.2 3.87-3.51 5.69-1.64-3.29-1.18-6.82-1.19-10.44l-.03-2.35-.01-2.27-.01-2.07c.24-1.87.24-1.87 1.24-3.19 1.34-2.25 1.27-3.88 1.29-6.5l.04-2.96.01-3.21.05-3.33.11-10.56c.26-28.83.26-28.83 2.5-39.12"/><path fill="#67656f" d="M1299 1487q4.1.13 8.19.31l2.33.07 2.27.1 2.08.09c2.96.6 4.74 1.58 7.13 3.43.62 2.78.62 2.78.6 6.22l.02 1.92q.01 3.18-.04 6.37l.01 4.57q0 6.22-.05 12.43-.04 6.53-.03 13.04-.02 10.96-.08 21.93-.07 15.4-.1 30.8a15789 15789 0 0 1-.14 45.82 376 376 0 0 1-.71 23.77c-.37 5.28-1.49 10.2-2.8 15.32a49 49 0 0 0-1.18 6.75 21 21 0 0 1-2.6 7.31c-1.58 3.08-2.72 6.35-3.94 9.58-.96 2.17-.96 2.17-2.96 4.17.48-5.3 1.48-9.9 3.28-14.92.7-2.01 1.24-4.01 1.72-6.08h2l.15-2.6c.68-10.49.68-10.49 2.35-15.45 2.35-9.15 1.64-19.27 1.63-28.68v-72.14a19394 19394 0 0 0 0-43.8v-4.16c.01-6.8-.07-13.45-1.13-20.17-5.17-1.9-9.95-3.33-15.47-3.7l-1.53-.3z"/><path fill="#17161c" d="m1287 650 3 1c-2.47 2.83-5.12 4.2-8.5 5.81l-3.22 1.57-1.65.8a191 191 0 0 0-15.18 8.4c-2.82 1.63-5.61 2.64-8.68 3.67-1.77.75-1.77.75-3.77 2.75q-2.5 1.03-5 2-3.52 1.7-7 3.5a124 124 0 0 1-12 5.5 223 223 0 0 0-18.21 8.1c-4.43 2.23-9.06 4.01-13.66 5.84a231 231 0 0 0-9.63 4.06c-3.9 1.75-7.84 3.36-11.82 4.93q-4.45 1.8-8.87 3.7A88 88 0 0 1 1150 716l-1-2 7.31-2.94 2.1-.84 2.02-.81 1.85-.75q2.35-.88 4.72-1.66v-2l2.02-.7a187 187 0 0 0 17.09-7.02c2.58-1.14 5.18-2.14 7.83-3.1a57 57 0 0 0 14.8-7.84c3.44-2.04 7.07-2.75 10.97-3.5 2.94-1.08 2.94-2.13 4.29-4.84 2.47-1.34 2.47-1.34 5.5-2.5l1.62-.63L1236 673l5.13-2.12a84 84 0 0 1 9.62-3.2 73 73 0 0 0 12.63-5.18l1.8-.9a45 45 0 0 0 6.95-4.22c2.08-1.53 3.91-2.24 6.37-3l2.28-.73q3.09-.9 6.22-1.65z"/><path fill="#545757" d="M15 915h4l.69 2.31C21 920 21 920 23.19 921.3c3.5 2.12 6.1 4.73 8.93 7.64l1.6 1.59a55 55 0 0 1 5.89 6.72c1.65 2.08 3.09 2.78 5.52 3.81 2.13.91 2.13.91 3.87 1.94l1 3q2.5 1.51 5 3v2l2.13.25c3.75.98 5.85 2.3 8.87 4.75.38 2.25.38 2.25 0 4h7c.63 1.88.63 1.88 1 4-2 2-2 2-4 2.44-2 .56-2 .56-3.25 2.12L66 970l-7-1v-3h8l-2-4c-4.68.62-4.68.62-6.31 2.56L58 966l-4-1v-3h5c-1.88-3.48-3.48-6.08-7-8-2.12.13-2.12.13-4 1-1.25 1.56-1.25 1.56-2 3l-4-1v-3h5c-2.4-4.52-5.5-7.79-9.1-11.36l-1.78-1.79q-1.84-1.86-3.72-3.7l-5.69-5.7q-1.8-1.82-3.62-3.62l-1.71-1.73-1.6-1.59-1.41-1.4C17 922 17 922 14 921v5l-4-1c.38-1.94.38-1.94 1-4l2-1q1.09-2.46 2-5"/><path fill="#99a6af" d="m1565 1052 4 1v9h2l.37 1.69A455 455 0 0 0 1575 1079h2l1 8h1c4.27 16.55 4.25 32.13 3 49l-.23 3.13c-2.05 26.76-2.05 26.76-6.64 32.37l-1.5 1.9a17 17 0 0 1-5.63 3.6l.91-1.75c4.95-10.23 9.2-23.5 9.22-34.86v-8.45c-.03-7.16-.13-14.3-.57-21.44l-.12-1.95c-.17-2.46-.33-4.33-1.44-6.55-.31-2.85-.51-5.7-.72-8.57-.28-2.43-.28-2.43-1.28-3.43q-.06-2.5 0-5h-2c-1.5-3-1.06-5.66-1-9h-2l-1 2v-10h-2c-1.57-5.73-2.24-10.16-1-16"/><path fill="#343537" d="m8 802 2 1v8l2.44-.56L15 810c1 1 1 1 1.1 2.63L16 818l-3 1 .19 3.31c.1 1.87.1 1.87-.19 3.69l-1.48 1.32c-2.24 2.48-2 5.11-2.08 8.3-.07 2.16-.2 4.23-.44 6.38l-2 1c-1.46 4.72-1.22 9.78-1.32 14.68l-.37 17.63L5 890l-5-1a4796 4796 0 0 1-.15-26.12q-.04-4.77-.05-9.52l-.03-3v-2.8l-.01-2.45c.26-2.32.91-3.25 2.24-5.11.2-2.92.2-2.92.13-6.19l-.06-3.29L2 828l3-1 .08-1.68c.17-2.18.38-4.2.92-6.32 2.06-1.5 2.06-1.5 4-2-.62-4.68-.62-4.68-2.56-6.31L6 810c.88-6.87.88-6.87 2-8"/><path fill="#aca3c3" d="M516 586q4.21-.04 8.44-.06l2.31-.03c4.55-.02 9.05.13 13.59.45 12.2.84 24.4.8 36.61.77h9.56q23.24.04 46.47-.9c7.34-.3 14.68-.33 22.02-.23v1a407 407 0 0 1-27.86 1.59c-16.88.52-33.76.73-50.65.94l-12.41.16L540 590v2a20321 20321 0 0 0 57.4.15 6085 6085 0 0 0 24.26.06q4.7.03 9.39.02l2.8.02c4.87-.02 8.61-.49 13.15-2.25 2.88-.12 2.88-.12 5 0l-2 4H524l-1-3-4.44.44-2.5.24C514 592 514 592 513 593c-3.42.41-6.87.52-10.31.69l-2.92.18-2.82.14-2.58.14c-2.6-.16-4.15-.84-6.37-2.15l1-2 44-1-17-2z"/><path fill="#1f1e24" d="M1377 738c-.06 1.81-.06 1.81-1 4a35 35 0 0 1-5.4 2.2c-1.6.8-1.6.8-2.6 3.8-1.35.95-1.35.95-3.06 1.75l-1.92.9-2.02.91-1.93.92a46 46 0 0 1-10.5 3.48c-4.9 1-9.1 2.9-13.57 5.1l-2.3 1.1c-5.53 2.67-5.53 2.67-6.7 3.84-2.74.54-5.48.96-8.24 1.4a26 26 0 0 0-6.76 2.6c-2.29.48-2.29.48-4.62.75l-2.36.3C1302 771 1302 771 1300 769c.71-1.46.71-1.46 2-3 2.16-.51 2.16-.51 4.63-.69l2.47-.2 1.9-.11 1-3 1.68-.11 2.2-.2 2.17-.18c2.44-.64 2.63-1.47 3.95-3.51 1.66-.4 1.66-.4 3.5-.5 1.9-.19 1.9-.19 3.5-.5l1-1.5 1-1.5q2.66-.4 5.34-.69c1.66-.31 1.66-.31 3.16-1.81 1.81-1.81 2.57-1.75 5.06-2l3.44-.5 1-1.5 1-1.5c1.79-.35 3.52-.5 5.34-.6 1.66-.4 1.66-.4 2.59-1.89 1.07-1.51 1.07-1.51 3.23-2.02l2.46-.18 2.48-.2 1.9-.11 1-3c5.75-1.12 5.75-1.12 8 0"/><path fill="#18181b" d="M519 74h11c-2.25 4.5-2.25 4.5-6 6v2c-4.4 3.53-9.59 4.1-15 5l-1 3h-8l.63 1.88L501 94l-2 2v-2h-8v4h-7l-2 4-4-1 2-2a70 70 0 0 0 1.63-4.62l.78-2.48q.3-.93.59-1.9h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h10z"/><path fill="#464551" d="M533 1197c6.38-.2 12.48.24 18.8 1.07 14.91 1.9 29.74 2.27 44.76 2.3l3.1.03c20.06.12 20.06.12 29.09-2.74 3.19-.94 5.95-.88 9.25-.66l-3 1v2l4 2-2.48.11-3.27.2-3.23.18c-3.02.51-3.02.51-4.78 2.02-3 2-5.63 1.89-9.12 1.83h-2.07q-3.37 0-6.75-.05l-4.7-.01q-6.15-.02-12.32-.07l-12.6-.06L553 1206l-1-2 13-1-3.1-.4-4.09-.54-2.04-.26c-3.62-.48-7.2-1.03-10.77-1.8l-1 2h-10l2-3z"/><path fill="#545457" d="M820 205c-2.71 3.46-6.15 4.3-10.19 5.56l-2.06.67c-3.27 1.05-6.51 2-9.87 2.7-4.88 1.11-9.29 3.49-13.78 5.61a70 70 0 0 1-19.4 5.93 41 41 0 0 0-6.89 2.03c-5.03 1.82-10.2 2.3-15.49 2.85-5.23.56-5.23.56-6.32 1.65-19.71 3.6-41.13 3.02-61 1v-2l-2.78-.26c-6.6-.72-11.9-1.42-17.4-5.43-3.04-2.19-6.44-3.7-9.82-5.31 1.86-.58 1.86-.58 4-1l5 3c2.14.6 4.29 1.03 6.46 1.47a131 131 0 0 1 16.73 4.66c7.94 2.47 16.45 2.09 24.69 2.11q3.22.02 6.46.07c8.43.07 16.2-.7 24.42-2.61 3-.65 5.87-1 8.93-1.2 4.97-.36 9.55-1.47 14.36-2.75a90 90 0 0 1 8.2-1.62c9.57-1.67 19.22-4.53 27.75-9.22 5.18-2.36 11.3-3.91 17-3.91v-2c7.3-3.63 7.3-3.63 11-2"/><path fill="#08090f" d="M1170 1444c7.85-.91 16.88 1.28 23.37 6 2.12 1.3 4.17 1.8 6.6 2.4l2.92.76 6.08 1.54 2.93.76 2.67.68a17 17 0 0 1 6.43 3.86c.98 2.93 1.13 4.84 1.15 7.9l.02 3.06a31531 31531 0 0 0 .2 63.06q.04 8.4.05 16.8 0 5.01.03 10.02.04 4.71.02 9.44 0 2.53.04 5.08c-.04 6.3-.68 11.45-4.51 16.64l-2 1 .94-2.37c2.67-8.1 2.2-17.09 2.17-25.5v-12.77l-.02-9.76q0-9.22-.02-18.43l-.02-21-.05-43.17-7-1v-2l-3 1c-4.42-1.08-8.68-2.56-13-4l-1.79-.43c-2.87-.74-5.65-1.69-8.46-2.63l-3.27-1.1-2.48-.84v-2l-1.93-.15-2.5-.22-2.5-.22-2.07-.41z"/><path fill="#483179" d="m1283 378 5 1c.34 2.23.34 2.23 0 5a38 38 0 0 1-5.37 4.25l-3.3 2.34-1.7 1.2a94 94 0 0 0-7.2 6.02l-2.37 2.15A51 51 0 0 0 1263 406c-3.36 2.43-6.5 3.46-10.44 4.46-1.56.54-1.56.54-3.12 2.17-1.44 1.37-1.44 1.37-4.63 1.56L1242 414l-2 4h-2l-1 3c-4.26 2.63-8.92 4.65-14 4l-1 3-3-1c2.2-3.49 4.05-4.81 8-6h3l.13-1.75c1.1-2.85 2.77-3.64 5.33-5.18 1.54-1.07 1.54-1.07 3.54-3.63 2.4-2.93 4.56-3.93 8-5.44 5.23-2.39 9.45-5.5 14-9q2.93-2.2 5.88-4.37l2.8-2.1C1272 388 1272 388 1274 388v-2c1.71-1.44 1.71-1.44 3.94-3 3.96-2.8 3.96-2.8 5.06-5"/><path fill="#a591c3" d="m745.85 587.9 2.21.04 2.23.02 1.71.04c-2.17 1.6-4 2.4-6.62 3l-2.09.5-2.17.5c-7.55 1.76-7.55 1.76-10.67 3.07-3.2 1.22-6.38 1.52-9.76 1.87-5.53.62-10.98 1.42-16.44 2.5a411 411 0 0 1-40.97 5.8c-5.23.5-10.36 1.22-15.53 2.2-21.42 3.94-42.04 4.98-63.75 4.56v-1l1.93-.04c11.98-.3 23.8-.84 35.7-2.4l2.84-.37c5.01-.7 9.7-1.72 14.53-3.19 2.44-.12 2.44-.12 4 0v-2c5.32-1.28 10.67-1.36 16.11-1.6l2.25-.12 2.05-.1C665 601 665 601 666 600l-8-1v-1l3.49.04c8.7.06 17.38-.04 26.07-.48l2.47-.12 4.3-.23C696 597 696 597 698 596q2.36-.3 4.73-.5l2.88-.25 3.01-.25c6.48-.55 12.93-1.14 19.38-2v-2l3.25-.4 6.33-.8c2.98-.39 5.57-1.76 8.27-1.9"/><path fill="#bbbaba" d="m1050 974-1 3-2.74.37-3.57.5-1.8.24c-3.2.47-5.26 1.1-7.89 2.89-2.26.2-2.26.2-4.69.13l-2.45-.06-1.86-.07-.81 1.94C1022 985 1022 985 1019 986l-1 3c-7.52.16-7.52.16-10.19-.75-1.81-.25-1.81-.25-4.56 1.63L1001 992v1l-8.58.64c-6.34.47-11.33.27-17.42-1.64l1-5 2.2-.3c5.46-.8 10.5-1.84 15.67-3.76 5.76-2.06 10.95-2.49 17-2.67 3.14-.27 5.25-1.08 8.13-2.27 2.76-.35 5.5-.5 8.28-.66 2.87-.36 5.07-1.21 7.72-2.34a70 70 0 0 1 15-1"/><path fill="#111318" d="M341 1455h1v157c-3-2-3-2-3.5-4.16l-.18-2.67-.2-3q-.28-7.92-.25-15.86v-3.26c0-5.1.07-10.03 1.01-15.05.12-2 .12-2-.36-5.03-.67-4.52-.68-9-.68-13.56l-.02-2.83-.02-9.24-.02-6.44-.02-13.49q0-8.62-.06-17.26-.03-6.64-.02-13.3l-.03-6.36v-8.9q-.02-1.3-.03-2.64c.03-5.4.92-9.13 3.38-13.95"/><path fill="#29282e" d="m958.06 112.94 2.94.06v2l3 1v2l-2.74.11-3.57.2-1.8.07c-3.83.24-5.37.8-7.89 3.62-2.05.43-3.98.5-6.06.6-1.94.4-1.94.4-3.3 1.83-1.88 1.8-3.24 2.31-5.7 3.06l-2.42.75-2.52.76-4.96 1.6-4.98 1.59-2.57.82-2.45.78-2.21.7C909 135 909 135 907 135l-1 3c-1.68.7-1.68.7-3.87 1.18l-2.46.56-2.67.57a170 170 0 0 0-19.69 5.63l-2.07.75L869 149c-16.95 6.28-16.95 6.28-25.65 7.57a31 31 0 0 0-6.48 2c-3.56 1.45-6.86 1.86-10.68 2.18-3.21.37-6.12 1.27-9.19 2.25 2.3-2.65 4.12-3.57 7.5-4.46q1.28-.38 2.63-.74l5.47-1.46c5.1-1.43 8.27-2.93 12.4-6.34a43 43 0 0 1 7.19-1.69c6.66-1.23 6.66-1.23 8.81-2.31q2.12-.22 4.25-.37c4.19-.43 7.02-1.61 10.75-3.63 8.57-3.57 17.95-5.08 27-7v-2l1.9-.37 2.48-.5 2.46-.5C912 131 912 131 914 129q2.71-.73 5.45-1.37 4.64-1.17 9.24-2.57l3.26-.96A40 40 0 0 0 940 120q2.24-1.08 4.5-2.06l2.34-1.03q2.57-1.07 5.16-2.1c4.54-1.84 4.54-1.84 6.06-1.87"/><path fill="#0c0a10" d="M968 359c.1 5.37.1 5.37 0 7l-1 1q-.14 2.1-.13 4.2v38.74C967 412 967 412 968 415q.21 1.96.32 3.94a198 198 0 0 0 5.65 34.56c3.42 14.13 3.42 14.13 2.03 20.5-2.53 2.47-5.13 3.57-8.48 4.6l-2.53.8-2.62.79-2.64.82C953.3 483 953.3 483 951 483v2l-1.62.3c-10.93 2.08-21.84 4.66-32.36 8.28-2.34.49-3.75.06-6.02-.58l1.45-.44 6.49-2 2.28-.7 2.18-.67 2.02-.62C927 488 927 488 928 487l3.06-.37c4.27-.57 7.96-2.04 11.94-3.63 7.06-2.8 14.13-4.9 21.52-6.6 3.4-.8 6.42-1.63 9.48-3.4.38-5.4.38-5.4-1.35-8.18-2.1-3.6-3.12-7.19-4.21-11.2l-.63-2.15c-3.83-13.78-4.11-27.58-4-41.78l.02-5.29q.1-20.7 1.17-41.4c-2.87.57-3.86.86-6 3v-5c3.14-1.4 5.55-2.26 9-2"/><path fill="#000106" d="m797.57 1669.8 2.73.01 6.03.08 6.45.07 9.14.09a5721 5721 0 0 1 24.83.24l17.19.17 17.64.18 34.42.36v2c-7.28.87-14.46 1.17-21.78 1.2l-3.44.04-9.22.06-9.67.08-18.27.14-20.83.16-42.79.32-1-3c3-1.73 5.12-2.24 8.57-2.2M790 1675l-1 2-2.75.87c-3.5 1.21-5.57 2.6-8.25 5.13l-1 3-3 3-3 5h-1c-.19-2.31-.19-2.31 0-5 1.37-1.5 1.37-1.5 3-3l.75-2.31c1.8-3.9 4.77-6.24 8.25-8.69 3.29-1.1 4.71-.8 8 0"/><path fill="#4f4e52" d="M1283 802a573 573 0 0 1-7 4l-2.81 1.63c-4.83 2.08-9.93 2.82-15.1 3.69-1.59.37-1.59.37-4.09 1.68-1.13 2.36-1.52 4.4-2 7l-1-2c-7.73.97-14.64 3.64-21.89 6.4a125 125 0 0 1-16.41 4.93c-7.03 1.75-13.67 4.6-20.25 7.6-3.55 1.55-7.16 2.9-10.78 4.28a145 145 0 0 0-8.48 3.48 58 58 0 0 1-9.06 2.78c-2.39.6-4.72 1.29-7.07 2.03l-2.27.72c-1.79.78-1.79.78-2.79 2.78-2.58.34-2.58.34-5.81.5-4.04.2-6.64.65-10.19 2.5-2.14.2-2.14.2-4.25.13l-2.14-.06-1.61-.07c4.58-3.14 8.75-4.24 14.23-4.66C1144 851 1144 851 1146 849c1.8-.63 1.8-.63 3.88-1.12l2.05-.51c2.07-.37 3.97-.44 6.07-.37v-2h3v-2l5.69-1.75 1.77-.55q4.47-1.38 8.95-2.72c5.04-1.5 9.98-3.08 14.84-5.1a55 55 0 0 1 11.74-3.39c2.21-.54 3.99-1.47 6.01-2.49 2.25-.12 2.25-.12 4 0l1-3 2.05-.33 2.7-.48 2.67-.46c2.8-.8 4.35-1.92 6.58-3.73a50 50 0 0 1 10.56-2.81c5.49-1.03 10.4-2.98 15.56-5.07 2.3-.9 4.47-1.6 6.88-2.12v-2l1.69-.55 2.31-.76 2.48-.82c2.95-1.02 5.83-2.19 8.7-3.42 2.24-.55 3.65-.16 5.82.55"/><path fill="#0e0e14" d="m1394 425 16.38-.08 5.98-.02 1.88-.01c2.89 0 4.99.19 7.76 1.11 3.24.46 6.5.72 9.75 1l3.04.27c8.6.7 17.2.96 25.84 1.17 11.57.27 22.77 1.26 34.2 3.22q3.3.5 6.62.8c4 .43 7.88 1.16 11.81 1.99q4.54.89 9.1 1.66c11.19 1.93 22.34 3.9 33.39 6.51l2.5.6c4.44 1.08 8.64 2.44 12.9 4.1 3.67 1.35 7.5 2.08 11.31 2.93 2.54.75 2.54.75 4.54 2.75l-9-1v-2l-2.34-.11c-5.63-.36-10.35-.9-15.53-3.13-4.53-1.62-9.39-2.06-14.13-2.76l-2.96-.5q-3.15-.51-6.3-.96l-3.3-.48-3.32-.46c-3.12-.6-3.12-.6-5.48-1.6-3.72-1.4-7.4-1.64-11.33-2-9.67-.96-9.67-.96-14.43-2.06-4.79-1.06-9.53-1.3-14.4-1.54-4.8-.25-9.54-.72-14.3-1.33a620 620 0 0 0-28.37-2.63l-11.92-.92c-4.02-.31-7.92-.84-11.89-1.52q-3.06-.06-6.12 0a171 171 0 0 1-18.88-1v5h2l1 6c-3.33-2.56-4.33-4.66-5.13-8.66q-.26-1.2-.5-2.46z"/><path fill="#28262d" d="M961 363h4l-.02 2.38q-.08 11.23-.14 22.45-.01 5.78-.07 11.54c-.29 33.46-.29 33.46 3.23 48.63l.68 3q.84 3.3 1.88 6.56l.63 2.15c.92 3.02 1.54 5.02 3.81 7.29.44 2.94.44 2.94 0 6-4.22 3.33-9.2 4.27-14.31 5.5a143 143 0 0 0-20.12 6.48l-1.88.77-1.93.8C935 487 935 487 932 486c3.69-2.7 6.54-4.23 11.01-5.43 4.42-1.26 8.71-2.95 13.03-4.53l2.2-.8 1.97-.72C962 474 962 474 965 474c-2.14-2.14-3.13-2.43-6-3v-1l6-1q.55-2.25 1.06-4.5l.6-2.53c.9-7.82-1.6-15.85-3.2-23.46l-.52-2.54-.48-2.24c-1.26-7.44-1.62-14.78-1.63-22.32l-.05-18.62-.01-6.07-.02-2.8c.02-6.19.82-11.9 2.25-17.92h-2v2h-2z"/><path fill="#0c0e13" d="m346 1242 3 1c1.04 3.11 1.18 5.46 1.28 8.73l.27 8.68.17 5.13.09 3.1c.19 2.36.19 2.36 1.19 3.36.09 2.38.12 4.72.1 7.1l-.04 8.96-.01 4.61L352 1304h-2l-1 17-3 1c-1.2-2.41-1.1-3.86-1.07-6.55l.03-2.87.05-3.1.05-6.47q.04-5.12.11-10.23l.09-9.86.05-3.09c.02-5.25-.14-8.45-3.31-12.83-.39-2.14-.39-2.14-.4-4.25l-.01-2.34.04-2.41-.04-2.4c.02-4.74.49-7.69 3.41-11.6z"/><path fill="#000003" d="M1161.5 1197.87h38.25a9714 9714 0 0 1 32.65 0h2.82c5.98.01 11.84.43 17.78 1.13v1l-2.19.08-9.75.36-3.44.12-3.27.12-3.03.11c-2.32.21-2.32.21-3.32 1.21q-2.13.15-4.26.16l-2.78.03q-1.52 0-3.08.02l-3.22.03-8.77.06-9.16.08-17.35.14-19.75.16-40.63.32 1-4c13.84-1.04 27.62-1.15 41.5-1.13"/><path fill="#19171d" d="M852 345h1c.42 4.34.43 7.68-1.81 11.5-2.58 4.6-3.25 9.31-4.11 14.44a86 86 0 0 1-2.7 11.56c-2.77 9.81-3.82 20.14.22 29.68 1.82 2.36 3.6 2.94 6.4 3.82v2c11.82.65 21.04-.58 32-5l2.66-.98c9.16-3.43 17.97-7.98 26.34-13.02l5-2c4.52-2.47 4.52-2.47 6.38-4.5 1.69-1.56 2.87-2.06 5-2.87 3.1-1.25 4.93-2.78 7.2-5.27a32 32 0 0 1 5.48-3.99c6.3-4.03 11.55-9.24 16.94-14.37 0 3 0 3-2.27 5.3q-1.47 1.3-2.98 2.57c-2.87 2.47-5.47 4.77-7.8 7.76-2.12 2.58-4.02 3.8-6.95 5.37a160 160 0 0 0-15.56 10.06 97 97 0 0 1-12.51 7.92q-2.39 1.27-4.68 2.7C903 409 903 409 900.75 409.9q-3.72 1.51-7.3 3.26c-11.73 5.6-22.67 7.98-35.64 8.21l-2.36.12c-3.88.06-6.47.01-9.36-2.75-7.05-9.23-7.14-19.56-6.09-30.74a64 64 0 0 1 2.16-8.18 66 66 0 0 0 1.82-8.47c1.51-9.29 4.45-17.66 8.02-26.35"/><path fill="#4c4b55" d="M467 1630c4.62.44 7.47.75 11 4h-5v2l2 .75c2.35.98 4.5 2.03 6.73 3.22a38 38 0 0 0 11.52 3.72l2.2.36c10.52 1.52 21.25 1.48 31.87 1.66 20.75.36 20.75.36 22.68 2.29q4.01.16 8.02.16l2.57.01 8.62.04 6.15.03 16.74.06 15.71.07a44861 44861 0 0 0 61.38.26h1.88l9.35.05 77.58.32v1l-87.36.05h-1.87l-60.23.04q-8.55 0-17.1.02a3061 3061 0 0 1-78.11-.7l-2.26-.03c-4.69-.13-8.6-.95-13.07-2.38-2.17-.32-2.17-.32-3.96-.41l-1.99-.12-1.99-.1-2.07-.11q-2.5-.14-4.99-.26v-2l-1.68-.3a44 44 0 0 1-6.32-1.7c-1.12-1.81-1.12-1.81-2-4a66 66 0 0 0-4.76-3.75C467 1633 467 1633 467 1630"/><path fill="#131316" d="M1642 659a6213 6213 0 0 1-17.6 17.76l-6.42 6.46-2.03 2.05-1.88 1.88-1.65 1.67C1611 690 1611 690 1609 690l-2 4h-2v3l5 1v3c-3.53 1.22-3.53 1.22-5.75.19L1603 700v-2c-2.8.39-4.6.67-6.81 2.5-3.18 2.18-6.4 2.23-10.19 2.5.75-1.94.75-1.94 2-4 2.06-.62 2.06-.62 4-1l1-2 1.94-.37c2.06-.63 2.06-.63 3.31-2.7l.75-1.93h3l.38-1.94.62-2.06 2-1 1-3-2-1 4-3-1-4h4v-4h4v-4h3l1 4h5l.75-1.87c1.25-2.13 1.25-2.13 3.19-3.07 2.06-1.06 2.06-1.06 3.5-3.56 2.75-4.4 5.83-4.88 10.56-3.5"/><path fill="#9486b3" d="M875 553c-.73 1.47-.73 1.47-2 3-1.95.4-1.95.4-4.25.5-4.22.33-7.02 1.43-10.75 3.5a34 34 0 0 1-9.87 2.38 18 18 0 0 0-7.63 2.43c-4.03 2.19-8.15 3.32-12.56 4.44l-2.04.53A45 45 0 0 1 813 571v3h-5l-2-5-17.48 1.46-2.84.24c-2.6.3-5.12.75-7.68 1.3 2.3-2.7 3.83-3.43 7.32-3.88l2.56-.35 2.68-.33c5.97-.79 11.86-1.67 17.75-2.89a547 547 0 0 1 12.52-2.4l5.16-.97 5.09-.96 3.01-.57A87 87 0 0 0 843 557c2.42-.3 2.42-.3 4.75-.44 3.62-.29 6.7-.94 10.13-2.12 5.7-1.87 11.18-1.72 17.12-1.44"/><path fill="#4a494e" d="M302 931q4.16-.09 8.31-.12l2.38-.06q1.13 0 2.3-.02l2.1-.03c1.91.23 1.91.23 3.26 1.22 2.53 1.55 5.18 1.37 8.07 1.42l1.87.06q2.94.08 5.9.15l4 .12q4.9.14 9.81.26c-2.98 2.2-4.97 2.21-8.69 2.13l-3-.06L336 936v2l3.31-.12c5.35-.02 10.47 1.04 15.69 2.12l1-2h37c-2.9 1.94-3.56 2.26-6.8 2.41l-2.15.12-2.24.1-2.27.11q-2.78.14-5.54.26c25.31 2.74 50.56 4.86 76 6v1c-13.05.1-26.09.16-39.12-.37l-2.63-.1-2.5-.12-2.14-.1a59 59 0 0 1-6.82-1.3c-4.73-1.09-9.3-1.4-14.15-1.54l-5.32-.21-8.24-.29c-10-.35-19.46-.94-29.2-3.3a59 59 0 0 0-10.75-1.36l-1.84-.1L323 939v-2l-3.07-.37-4.06-.5-2.01-.24A52 52 0 0 1 302 933z"/><path fill="#333334" d="M883 70q1.94-.08 3.88-.12l2.17-.08C891 70 891 70 893 72c.13 2.13.13 2.13 0 4-3.14 1.4-5.55 2.26-9 2v4h6v2l3 1h-9l-1-2c-2.72-.41-2.72-.41-6.06-.62l-3.35-.23L871 82v2c-3.77 1.75-6.67 2.22-10.81 2.13l-2.96-.06L855 86l-1 3h-20l-1-3 2-4h18l2-4h14l2-4h10z"/><path fill="#06040b" d="M1070 81c2 0 2 0 4.13 1.63 1.87 2.37 1.87 2.37 2.24 5.3A25 25 0 0 1 1074 96l4-3c.5 3.59.17 4.77-2.05 7.72q-1.34 1.4-2.7 2.78l-2.67 2.78-1.32 1.36a69 69 0 0 0-3.42 4.1c-2.05 2.52-3.95 3.81-6.84 5.26h-2l-.62 1.81c-1.95 3.1-4.06 3.75-7.38 5.19v-3l-2.12 1.94c-2.79 2.25-5.3 2.73-8.88 3.06v3c-3.17 1.63-6.3 3.13-9.62 4.44-3.13 1.3-5.15 2.97-7.38 5.56l1 3-2.44.88C1017 144 1017 144 1016 146l-1.8-.32-2.39-.37-2.35-.38c-3.24.1-4.79 1.27-7.46 3.07q-2.77 1.55-5.56 3.06l-2.82 1.54q-3.29 1.75-6.62 3.4c2.98-4.74 6.52-6.5 11.56-8.79 2.4-1.2 4.34-2.55 6.44-4.21 2.83-1.91 4.97-3 8.31-3.81 3.97-1.02 6.68-3.23 9.85-5.75 2.17-1.7 4.48-3.02 6.84-4.44l3.94-3a40 40 0 0 1 7-4.44c3.61-1.84 6.54-4.14 9.7-6.68a134 134 0 0 1 4.7-3.58c6.57-4.82 12.44-10.66 15.66-18.3.23-4.23 0-7.87-1-12m-19 37v3l3-1v-2z"/><path fill="#222224" d="M460 808c10.62.36 21.51 1.85 31.39 6a38 38 0 0 0 7.98 2l3.15.5 3.36.5 3.45.53A437 437 0 0 0 543 821v2q21.45.07 42.9.1l19.94.05a6396 6396 0 0 0 26.59.06c14.49.07 28.77-.42 43.2-1.76 7.15-.65 14.26-.68 21.43-.58l3.85.03q4.54.03 9.09.1v1c-32.28 2.8-64.4 4.4-96.81 4.31h-2.27c-54.98-.1-54.98-.1-77.42-2.75l-3.43-.37c-11.3-1.24-22.58-2.7-33.7-5.07l-2.44-.51a128 128 0 0 1-16.21-4.74 69 69 0 0 0-11.35-2.38c-2.4-.5-4.23-1.3-6.37-2.49"/><path fill="#6c42af" d="M694 602a57 57 0 0 1-13.3 3.07l-2.07.26-4.32.53q-3.26.4-6.52.82l-4.22.52-1.94.24c-4.56.54-9.04.65-13.63.56v2l-12 2 10 1v1q-3.66.3-7.31.56l-2.1.17-2.02.15-1.85.14C631 615 631 615 628 614l2 4-53-1v-1l16-1c-9.44-.13-18.77.4-28.19 1L549 617v-3l15-.94 2.32-.14c8.3-.5 16.61-.97 24.94-1.13l1.98-.05 1.97-.03c20.94-.42 41.78-2.44 62.3-6.7 7.69-1.55 15.5-2.1 23.3-2.79q3.08-.32 6.13-.8c2.8-.39 4.43-.37 7.06.58"/><path fill="#525156" d="M462 384h1a89 89 0 0 1-1.44 17.38c-1.24 7.99-1.85 15.85-2.06 23.93a477 477 0 0 1-2.12 33.05l-.56 5.97-.33 3.46c-.49 3.21-.49 3.21-1.5 6.25a33 33 0 0 0-1.55 9.06l-.23 3.43-.31 5.31q-.16 2.6-.34 5.2l-.18 3.1c-.39 2.91-1.17 5.2-2.38 7.86h-2l.04 2.13c.05 5.73-.18 11.2-1.04 16.87-5.95.19-11.2-.43-17-1.75l-2.29-.48a49 49 0 0 1-12.9-4.8 69 69 0 0 0-5.75-2.66C406 516 406 516 405 515q-.06-2.5 0-5h2l1 5a195 195 0 0 0 27.13 7.47c1.87.53 1.87.53 3.3 1.53 2.1 1.34 4.14 1.6 6.57 2l-.1-2.32c-.2-7.7.39-15.06 1.48-22.68l.38-2.71c.7-4.65 1.73-8.83 3.24-13.29q.4-3.18.63-6.37l.22-3.22.15-2.41h2l-.07-2.04c-.19-10.28.76-20.42 1.75-30.64l.64-6.81A561 561 0 0 1 462 384"/><path fill="#979598" d="M309 654c-1.94 5.82-7.63 9.44-12.37 13.06l-1.64 1.28c-5.67 4.35-11.73 7.62-18.2 10.63-1.94 1.12-3.24 2.41-4.79 4.03a197 197 0 0 1-5 3q-3.01 1.98-6 4l-1-2 4-1v-3h-4l2-6-3-1 2.59-.99 3.35-1.32 1.7-.65c1.64-.65 1.64-.65 4.36-2.04l1-3c1.85-.73 1.85-.73 4.06-1.19l2.23-.48L280 667v-2l8-2v-2q4.13-1.76 8.25-3.5l2.36-1.01 2.28-.96 2.1-.89c2.12-.67 3.8-.76 6.01-.64"/><path fill="#312f35" d="m805 214 1 4-5.18.49c-1.82.51-1.82.51-3.23 2.02-2.66 2.5-5.82 1.96-9.31 2.08L786 223q-1.02 1.49-2 3c-2.06.4-2.06.4-4.44.5-4.25.17-4.25.17-5.97 2-2.9 2.73-6.97 1.98-10.78 2.1-2.81.4-2.81.4-4.18 1.9-3.08 2.84-7.82 1.98-11.83 2.03l-2.11.06-1.93.03c-1.76.38-1.76.38-3.17 1.89-2.23 2.09-3.69 1.84-6.7 1.78l-2.97-.03-3.1-.07-3.15-.04L716 238c1-3 1-3 2.71-3.91a62 62 0 0 1 15.32-2.88C736 231 736 231 737 230q2.58-.32 5.17-.6a79 79 0 0 0 20.1-4.47 47 47 0 0 1 7.67-1.87c4.7-.84 8.96-2.46 13.36-4.23 4.2-1.68 8.3-2.83 12.7-3.83 3-1.5 5.66-1.06 9-1"/><path fill="#c8a3ee" d="m398 672 1 47-4 1-.81-1.44C393 717 393 717 390 716l-1 3c-7.12-2.22-7.12-2.22-9-4-1.12-2.5-1.12-2.5-2-5l-1-2h3l-.22-2.71c-.55-7.11-.93-14.15-.78-21.29h3l-.07 1.54c-.12 4.63.2 8.2 2.07 12.46l2 1c.85 1.63.85 1.63 1.63 3.56l.78 1.94.59 1.5 2-5h1l1 8 .04-3.18q.1-5.84.22-11.66l.09-5.06q.05-3.62.14-7.25l.02-2.29c.06-2.12.06-2.12.49-5.56 2.41-2 2.41-2 4-2"/><path fill="#e8e6e9" d="m1424 1443 2 1v233l-4 1c-.25-6.35-.25-6.35 1-9.56 1.4-4.16 1.27-8.24 1.24-12.59v-2.76l-.01-9.15v-6.56q0-7.06-.02-14.13l-.02-20.42q0-16.56-.04-33.13l-.04-34.19v-9.95z"/><path fill="#51311a" d="m1258 1345 1 3h2l.3 1.84c1.47 8.27 3.24 16.39 9.7 22.16v3l1.88.88c2.12 1.12 2.12 1.12 4.12 3.12.13 2.13.13 2.13 0 4-8.16-.3-16.04-1.42-24.06-2.94l-2.78-.51-2.63-.51-2.35-.46c-2.26-.6-4.11-1.5-6.18-2.58q-2.58-.6-5.19-1.06c-4.7-.84-4.7-.84-5.81-1.94q-1.77-.3-3.56-.46l-2.17-.24-4.54-.46-2.17-.24-2-.2-1.56-.4-1-2c-1.56-1.12-1.56-1.12-3-2a422 422 0 0 1 18.04 2.9l2.2.38 1.97.35c1.79.37 1.79.37 4.79 1.37l1-2h8l-1 3 3.94 1.06 2.21.6 1.85.34 1-1c2.34-.14 4.66-.04 7 0l1-3h2v-6h-4z"/><path fill="#8b61b6" d="M391 559h1l.1 6.05C392 567 392 567 391 569q-.18 1.8-.25 3.59l-.1 2.13-.17 4.43-.1 2.13-.08 1.94c-.35 2.05-1.13 3.08-2.3 4.78-.45 1.68-.45 1.68-.75 3.51l-.34 2.04-.35 2.14c-.88 5.1-1.87 9.83-3.88 14.62-.95 2.36-1.5 4.77-2.09 7.24-.78 3.25-1.66 6.47-2.53 9.7A811 811 0 0 0 370 660l-.6 2.62A122 122 0 0 0 367 682l-.2 3.3c-.35 10.34 1.66 17.33 8.7 25.01l2.28 2.37A94 94 0 0 1 384 720a21 21 0 0 1-7.31-5.25 93 93 0 0 0-9.2-8.3c-4.38-4.3-4.52-10.12-4.62-15.95v-7.42c0-4.8.33-9.35 1.13-14.08h2v-3h2l.15-2.78c.35-5.95.7-11.62 2.39-17.36.53-2.13.72-4.18.9-6.36a34 34 0 0 1 2.77-10.3c.82-2.27 1.3-4.43 1.73-6.8 1.02-5.33 2.29-10.58 3.62-15.84l.67-2.68c.84-3.33 1.68-6.61 2.77-9.88q.3-4.4.43-8.82c.21-4.1 1.03-6.03 3.57-9.18.66-2.59.66-2.59 1.06-5.37A57 57 0 0 1 391 559"/><path fill="#36353a" d="m984 105 1 2h9v3l-2.31.88C989 112 989 112 987.5 113.13c-2.16 1.26-4.05 1-6.5.87v2a111 111 0 0 1-24 6v2l-1.83.41-8.3 1.9-2.88.65A59 59 0 0 0 930 132a121 121 0 0 1-9.62 2.25l-2.57.56-2.47.52-2.24.46a14 14 0 0 1-6.1-.79l2.68-.95q10-3.56 19.97-7.21 2.94-1.04 5.91-1.97C938 124 938 124 940 122c2.01-.34 3.99-.44 6.03-.56 1.97-.44 1.97-.44 3.3-1.96 2.52-2.24 5.17-1.8 8.36-1.67l1.84.04 4.47.15v-2l-4-1 1-3c1.57-.62 1.57-.62 3.56-1 2.96-.63 5.69-1.4 8.5-2.5 3.6-1.4 7.21-2.49 10.94-3.5"/><path fill="#5d5c66" d="M436 1207h1c1.8 14.1 2.3 27.95 2.27 42.15v19.93q-.02 8.63 0 17.24a4772 4772 0 0 1 0 19.69l-.01 8.84.01 2.64-.02 2.44v2.1c-.3 2.32-1.11 3.95-2.25 5.97-.99-5.82-1.2-11.52-1.28-17.42l-.06-2.98-.21-12.53-.17-9.18-.04-2.87-.05-2.66-.04-2.34C435 1276 435 1276 434 1273q-.14-2.5-.15-5l-.02-6.43v-3.46l-.02-7.27q0-4.6-.03-9.2l-.01-8.88-.02-3.28c.02-7.69.9-14.93 2.25-22.48"/><path fill="#8559c3" d="m570.56 611.94 3.64.01 8.8.05v1l-34 2-.08 81.53a21695 21695 0 0 0-.03 45.69l-.01 9.75v3.18c.11 2.72.5 5.2 1.12 7.85h-4a29 29 0 0 1-1.13-8.5V671.8c-.02-11.93.09-23.83.58-35.74l.11-2.89c.84-18.56.84-18.56 2.44-20.16 7.4-1.46 15.06-1.12 22.56-1.06"/><path fill="#17161a" d="M852 800c-2.65 2.65-4.36 2.5-8.06 2.94l-3.83.5-2.04.27c-3.47.49-6.93 1.1-10.38 1.73l-2.17.38q-5.34.96-10.68 1.97a251 251 0 0 1-25.89 3.48 60 60 0 0 0-10.89 1.73c-4.77 1.28-9.58 1.62-14.5 2-5.14.4-10.14.82-15.17 2.03-4.43 1.06-8.72 1.33-13.26 1.47-4.97.2-9.7.47-14.57 1.56-8.02 1.7-16.4 1.1-24.56.94v-1l2.4-.32c26.59-3.57 26.59-3.57 36.73-5.37l2.03-.36c2.7-.5 5.23-1.08 7.84-1.95q3.15-.25 6.34-.4l3.88-.2 2.05-.1c10.9-.58 21.45-1.48 32.14-3.77 7.61-1.61 15.25-2.55 22.98-3.45 5.8-.68 11.47-1.57 17.17-2.77 7.5-1.56 14.8-1.6 22.44-1.31"/><path fill="#b68be6" d="M455 686c3.12 6.25 3.12 6.25 3.28 9.55l.11 2.12.11 2.2c.2 4.2.44 8.06 1.5 12.13l-1 1a83 83 0 0 0-.06 4.88l.02 3.04.08 6.34c.04 5.37-.08 10.45-1.04 15.74l3.19-.56c3.17-.47 5.7-.29 8.81.56v1l-5 1 1.68.11 2.2.2 2.17.18 1.95.51.84 1.47c1.16 1.53 1.16 1.53 4.2 2.26q1.8.25 3.58.46l1.86.25q2.25.3 4.52.56v3q-3.09.08-6.19.13l-3.48.07c-3.34-.2-5.26-.96-8.33-2.2-2.92-.52-5.86-.82-8.81-1.12l-2.4-.27L453 750c-1.28-6.65-.96-13.16-.7-19.9l.42-11.5q.2-4.98.36-9.98a3173 3173 0 0 1 .6-16.48l.12-2.94c.2-2.2.2-2.2 1.2-3.2"/><path fill="#250707" d="M1446 1259c3.78 1.51 3.78 1.51 4.88 3.94 1.63 3 4.1 3.58 7.12 5.06 2.62 2.72 3.84 5.82 5.25 9.27 1.03 2.38 2.4 4.52 3.75 6.73 2.12 4.3 2.3 7.96 2.3 12.65l.01 2.23v4.68q.01 3.51.05 7.01c.05 12.1-1.03 23.33-6.46 34.34a30 30 0 0 0-1.9 6.78c-2.3 11.23-13.51 20.3-22 27.31l-2.5 2.19a36 36 0 0 1-6.62 3.75c-3.65 1.73-5.05 3.4-6.88 7.06-3.14 2.6-6.53 2.37-10.44 2.5-4.4.14-7.66.27-11.56 2.5q-2.2.57-4.44 1a29 29 0 0 0-6.83 2.02c-3.08 1.1-5.43 1.2-8.69 1.18l-3.37-.02-3.48-.06-3.55-.02-8.64-.1v-1l2.03-.01c22.73-.25 22.73-.25 30.6-4.72 5.59-3 12.26-4.2 18.56-4.58 4.3-.42 7.2-2.63 10.63-5.12 2.18-1.57 2.18-1.57 4.57-2.93 3.23-2.03 5.89-4.42 8.67-7.02l1.57-1.42a58 58 0 0 0 6.62-7c1.75-2.2 1.75-2.2 3.69-3.14 4.5-2.32 7.93-7.58 10.06-12.06.32-1.98.5-3.96.7-5.96.49-3.29 1.9-6.05 3.3-9.04q.84-1.95 1.63-3.94l.72-1.77c1.93-6.78 1.83-13.86 1.84-20.85l.03-2.75c.02-5.35-.58-9.6-2.22-14.69-.46-2.3-.79-4.6-1.12-6.94l-.27-1.78-.61-4.28h-2l-1.25-2.69a31 31 0 0 0-8.87-10.81c-2.73-2.17-3.9-4.1-4.88-7.5"/><path fill="#151417" d="M111 694c1.94.38 1.94.38 4 1l1 2q2.46 1.09 5 2c-.37 2.44-.37 2.44-1 5l-2 1-.81 1.81c-1.58 2.92-3.45 4.36-6.19 6.19-2.31.25-2.31.25-4 0a11.2 11.2 0 0 0 0 8l-5 1v4l-2.81-.19c-3.19.19-3.19.19-4.75 1.5L93 729l-2.44 1.44C88 732 88 732 86 734.19c-2 1.81-2 1.81-4.75 2.06L79 736v5l-7 2 1-3 3-1 .9-2.76c1.27-3.75 2.8-5.37 5.64-8.1l1.32-1.3 4.2-4.03 4.21-4.06 3.81-3.67C98 713 98 713 99 710h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.27.9 2.27.9 5 1 2.42-1.77 2.42-1.77 4.75-4.25l2.36-2.45C114 701 114 701 115 698h-5z"/><path fill="#0d0c12" d="M1401 437c5 6.36 5 6.36 5 11h2l2.25 4.4c.75 1.6.75 1.6 1.75 4.6h2c2.4 4.59 4.51 9.04 6 14h2c2.72 5.26 5.23 10.34 7 16q1.52 3.83 3.1 7.63c3.5 9.2 3.6 22.4-.18 31.5L1431 528l-.98 2.1q-1.46 2.97-3.02 5.9l-1.37 2.88c-5.95 11.72-13.52 20.76-23.63 29.12 0-3 0-3 1.36-4.58l1.83-1.67c4.9-4.7 8.37-9.92 11.81-15.75l2-2.81c2.05-3 3.13-6.1 4.2-9.55l.8-1.64 3-1 .15-1.72c.36-3.7.72-7.19 1.91-10.72 2.66-8.78 1.05-19.47-3.08-27.56q-1.01-1.64-2.06-3.27c-1.76-3.32-2.29-7.05-2.92-10.73h-2c-3.37-4.52-5.47-8.72-6.75-14.25-1.1-4.07-3.4-7.12-5.79-10.55-3.2-4.8-4.77-9.5-5.46-15.2"/><path fill="#492b7d" d="M1050 670c2.13-.19 2.13-.19 4 0 0 2.81 0 2.81-1 6-3.74 2.34-7.62 3.18-12 3l-1 3c-1.75.8-1.75.8-4 1.44-6 1.83-11.57 4.32-17.16 7.16-8.85 4.42-17.74 7.27-27.27 9.86a416 416 0 0 0-19.85 6.1c-8.03 2.6-16.13 5.14-24.47 6.57l-2.72.46q-4.76.77-9.53 1.41c2.75-2 2.75-2 5-2v-2l2.38-.33c5.3-.8 10.12-1.58 14.96-3.94 2.6-1.14 5.3-1.7 8.06-2.3C967 704 967 704 968 703q2.3-.51 4.63-.94a86 86 0 0 0 9.06-2.12 39 39 0 0 1 6.93-1.38c3.58-.6 5.38-1.6 8.38-3.56q2.75-1.2 5.55-2.25C1004 692 1004 692 1005 690c2.58-.4 5.14-.5 7.75-.66l2.25-.34 1-2h-12v-1l2.55-.37 3.33-.5 3.3-.5C1016 684 1016 684 1018 682c2.38-.2 2.38-.2 5.13-.12l2.75.05 2.12.07 2-4h10l1-4 3.38-.31c1.94-.27 1.94-.27 3.62-.69 1.19-1.56 1.19-1.56 2-3"/><path fill="#08070d" d="M1391 423c12.7-.1 25.37-.1 38.06.38l2.97.09 2.82.12 2.44.1c2.29.26 4.27.7 6.49 1.3 4.32 1.13 8.48 1.41 12.93 1.57l2.47.11q3.88.18 7.76.33c12.44.5 24.72 1.34 37.06 3l1.91.25c7.76 1.03 15.45 2.3 23.14 3.7 3.24.58 6.48 1.14 9.74 1.58 2.33.34 4.42.7 6.65 1.4 3.19.98 6.4 1.72 9.67 2.41 2.34.82 3.94 2.15 5.89 3.66-7.56-.57-14.84-1.87-22.25-3.44-8.1-1.67-16.16-3.2-24.37-4.2C1512 435 1512 435 1510 434a133 133 0 0 0-5.23-.72l-3.18-.39-9.82-1.17-2.92-.35q-2.9-.37-5.8-.9c-3.23-.5-6.34-.65-9.6-.72l-3.84-.1q-.98 0-2-.04a497 497 0 0 1-31.86-1.61l-2.88-.25c-3.5-.33-6.52-.63-9.87-1.75q-3.31-.23-6.64-.32l-1.93-.06-10.27-.31L1394 425l1.81 5.38 1.02 3.02c1.18 2.63 1.72 3.3 4.17 4.6 1.2 1.8 1.2 1.8 2.25 3.88l1.08 2.05c.79 2.44.5 3.69-.33 6.07-2-2-2-2-2.12-4.12l.12-1.88-3-1c-1.5-2.33-1.5-2.33-3-5.25a85 85 0 0 0-4.75-8.36c-1.47-2.8-1.16-3.49-.25-6.39"/><path fill="#8e8e90" d="M1089 856v2l3-1v2h9l-1 3c-3.15 1.05-5.06.95-8.28.64-1.72.36-1.72.36-3.06 2.24-2.26 2.88-4.28 3.23-7.79 4.18a102 102 0 0 0-11.24 3.69c-6.26 2.17-13.1 1.48-19.63 1.25l1-3 1.71-.15 4.44-.44 1.85-.41 1-2h-8c2.43-3.64 4.73-4.22 8.69-5.5l5.68-1.92a51 51 0 0 0 5.38-2.33c5.54-2.13 11.35-2.32 17.25-2.25"/><path fill="#a789cf" d="m437 600 2.45.43c4.12.7 8.27 1.16 12.43 1.63l8.12.94 1 3 1.98-.29 2.64-.34 2.6-.35c3.31-.02 5.66.9 8.78 1.98 3.21.56 6.46.87 9.7 1.21 5.02.59 5.02.59 6.7 1.8 2.26 1.4 4.07 1.27 6.71 1.3l3.16.07 3.4.03 3.48.06q4.58.08 9.16.13l9.35.14q9.17.14 18.34.26l-1 2h-2v4l-6 2v-3l-10.7-.09h-2.29l-2.1-.02C521 617 521 617 518 618c-2.14-.18-4.2-.43-6.31-.75l-4.06-.58-2.23-.32q-5.25-.75-10.5-1.46l-5.68-.79c-4.3-.59-8.59-1.16-12.9-1.6-11.87-1.22-11.87-1.22-17.34-4.5-3.47-1.76-7.28-2.4-11.06-3.27A77 77 0 0 1 437 601z"/><path fill="#191922" d="m481.56 1172.94 2.44.06v2h-5q.12 77.64.28 155.3l.04 18.31v1.85l.1 59.11.12 62.46.07 37.43a16732 16732 0 0 0 .07 40.45 3731 3731 0 0 0 .04 18.46q0 3.33.02 6.66l-.01 1.94A22 22 0 0 0 482 1587l-3-1c-1.12-3.35-1.14-6.27-1.13-9.75v-397.73c.02-5.5.02-5.5 3.7-5.58"/><path fill="#1f1f22" d="M1419 1682h3v19l-4 2v10l-4 2 .1 2.55.09 3.33.1 3.3-.29 2.82c-1.47 1.13-1.47 1.13-3 2-.51 1.95-.51 1.95-.69 4.13l-.2 2.19-.11 1.68-2.69.88q-4.2 1.43-8.31 3.12l1-5h2v-11l4 1 .15-2.8.22-3.64.1-1.85c.1-1.58.31-3.15.53-4.71l2-1c.56-2.43.56-2.43 1-5.44.78-5.34.78-5.34 3-7.56.84-2.96 1.47-5.95 2.12-8.96.9-3.13 1.92-5.43 3.88-8.04"/><path fill="#16161d" d="M408 1071h2c.59 4.58-1.09 7.64-2.98 11.66-3 6.86-3.87 14.39-4.46 21.78-.32 3.8-.92 7.13-2.08 10.74-1.47 5.55-1.92 11.25-.48 16.82l-1 1q-.36 3.43-.56 6.88l-.13 1.93q-.29 4.1-.31 8.19a174 174 0 0 1-1.94 6.38 72 72 0 0 0-3.56 17.99l-.12 1.8-.76 11.38-.34 5.16c-.3 3.47-.76 6.86-1.28 10.29h-1a217 217 0 0 1 1.15-28.36 580 580 0 0 0 1.73-21.23c.57-8.65 1.22-17.2 2.63-25.75.46-3.06.68-6.1.88-9.2 1-15.05 2.8-29.78 9.61-43.46l3-1z"/><path fill="#797879" d="m1383 741 2 1-2 3 1.94-.62A78 78 0 0 1 1400 741c-2 2-2 2-5 3l-1 3 2 1c-4.62 3-4.62 3-8 3l-1 3c-1.93.8-1.93.8-4.31 1.31l-2.37.55c-2.71.16-4.03-.46-6.32-1.86h3l-1-2-4.25 1.88-2.4 1.05q-3.2 1.46-6.35 3.07c5.88-.87 5.88-.87 7-2q3-.06 6 0c-1 3-1 3-3.62 4.69-3.54 1.64-6.53 1.54-10.38 1.31l-2 4h-7v3l-7 1c1.36-6.55 1.36-6.55 4.56-8.81L1353 760l1-2-4-1h6v-2l1.71-.37 2.23-.5 2.21-.5C1364 753 1364 753 1365 751c1.75-.86 1.75-.86 4-1.75 5.1-2.16 9.5-5.05 14-8.25"/><path fill="#3d3c46" d="M1316 949h1l1 8h1l.18 2.3.26 3.01.24 3c.32 2.69.32 2.69 1.32 5.69q.17 2.55.2 5.1l.06 3.19.11 7.22.24 16.53c.47 32.05.58 64.1.58 96.14q0 9.13.02 18.25a7770 7770 0 0 1 .03 28.5v9.76l.02 5.27-.01 3.14v2.74c-.25 2.16-.25 2.16-2.25 4.16-5.6.46-10.59-.62-16-2v-1h17a137696 137696 0 0 0-1.37-125.42 13247 13247 0 0 1-.36-32.88c-.1-11.77-.35-23.23-3.27-34.7l-1 4-2-1a1553 1553 0 0 1 .42-14.02q.07-2.55.17-5.12l.1-3.11c.3-2.72.92-4.42 2.31-6.75"/><path fill="#848587" d="m1278 790-1 3-7 1-1 3q1.94.08 3.88.13c.35 0 .35 0 2.17.07 1.95-.2 1.95-.2 3.95-2.2q2.26-.87 4.56-1.56c4.34-1.34 4.34-1.34 5.44-2.44q3-.06 6 0v2c-10.8 6.2-10.8 6.2-16.04 7.44-11.77 3.37-11.77 3.37-14.96 6.56q-2.66.35-5.3.62c-1.7.38-1.7.38-3.7 2.38-2.6.41-2.6.41-5.62.63l-3.04.22-2.34.15c.74-1.95.74-1.95 2-4 1.95-.6 1.95-.6 4.13-.75l2.19-.17 1.68-.08v-3l-16 3c2.29-4.57 2.95-4.77 7.5-6.5l1.62-.63L1252 797l5.19-2.12c13.54-5.34 13.54-5.34 20.81-4.88"/><path fill="#f2f2f2" d="m1450 1190 7 1v3h-8l2 4c4.68-.62 4.68-.62 6.31-2.56l.69-1.44 4 1v3h-5c2.39 4.63 5.69 9.01 10 12 2.69-.26 3.66-.63 5.5-2.62l1.5-1.38c2.19.31 2.19.31 4 1v3l-5 1v3l1.8.27c2.76.91 3.69 2.1 5.51 4.36a143 143 0 0 0 9.11 10.02c1.58 1.35 1.58 1.35 4.58 2.35v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1a78 78 0 0 0-2 5l-5-1-1-3a98 98 0 0 0-4-2c-1.89-1.62-1.89-1.62-3.69-3.44l-1.82-1.8C1476 1224 1476 1224 1475 1221c-2.28-1.65-4.65-3.05-7.07-4.5l-1.93-1.5v-3l-2.12-.25c-3.58-.93-5.94-2.57-8.88-4.75q-.74-.53-1.5-1.05c-1.37-1.01-1.37-1.01-3.5-2.95v-3h-5l-1-6 1.88-.31c2.44-.8 2.96-1.47 4.12-3.69"/><path d="M710 86q7.4-.11 14.79-.16l5.03-.07 7.23-.06 2.27-.05h2.13l1.86-.02c1.69.36 1.69.36 3.12 1.86 2.1 2 3.32 1.89 6.2 1.92l2.82.05h3.05l3.14.05q4.95.07 9.92.1l9.94.13 9 .1 2.5.01c2 .14 2 .14 4 1.14v2l38 1-1 3h-37l-1-3-2.38.02c-26.4.15-26.4.15-37.25-.4l-2.18-.09c-4.83-.28-4.83-.28-6.67-1.53-1.8-1.19-2.93-1.3-5.07-1.41l-2.13-.12-2.27-.1-2.34-.13q-3.7-.2-7.4-.36-2.5-.14-5.02-.27L711 89z"/><path fill="#3f3e42" d="m1424 1044 2 1v2h3l1 3 3 1c3.92 3.24 3.92 3.24 4.25 6.25l-.25 1.75h7c2.76 8.27 2.14 17.81 2.1 26.45l-.04 12.8-.01 6.63-.05 16.12c-2.37.63-2.37.63-5 1-2.38-2.38-2.24-3.54-2.28-6.8l.01-1.97v-6.39q.01-3.26 0-6.5c-.01-8.35.59-16.15 2.27-24.34.82-4.13 1.37-6.94 0-11-3.56-4.74-8.05-8.6-12.44-12.55a24 24 0 0 1-4.56-5.45z"/><path fill="#b4abc3" d="M392 556h6l1 2c2.25.94 4.45 1.78 6.75 2.56l1.88.67c2.77.97 5.37 1.77 8.26 2.3q1.05.22 2.11.47l1 2c2.25.69 4.46 1.27 6.75 1.81a105 105 0 0 1 14.94 4.82 60 60 0 0 0 15.75 3.91c2.53.46 4.34 1.2 6.56 2.46v5h-6v1h-14l-1-4q-2.6-.01-5.2.07l-2.36.05-2.38.08C430 581 430 581 428 579l1-2h10l-1-2c-2.47-.56-2.47-.56-5.5-1s-3.03-.44-5.5-1l-1-2c-1.85-.41-1.85-.41-4.06-.62l-2.23-.23L418 570v2l2 1c-3.82-.53-6.06-1.5-9-4h3v-2l-2.15-.15-5.56-.44L404 566l-1-2q-2.5-1.02-5-2l-2-2c-2.12-.62-2.12-.62-4-1z"/><path fill="#1c0f32" d="M819 744c-2.43 1.87-4.54 2.47-7.54 3.04l-2.7.5-2.82.52-2.74.53c-5.4 1-10.72 1.71-16.2 2.06-2 .35-2 .35-4 2.35-2.6.41-2.6.41-5.62.63l-3.04.22-2.34.15v2c-3.21 1.89-5.65 2.27-9.36 2.34l-3.13.1-3.26.06c-6.32.13-12.18.28-18.27 2.03-3.93.93-7.96.95-11.98 1.1l-2.64.11q-3.18.14-6.36.26v2l-1.77.1-25.24 1.47-9.9.58q-1.5.07-3.03.17c-5.22.31-10.29.86-15.44 1.72-9.79 1.54-19.67 1.2-29.55 1.16l-6.26-.01-16.35-.05-16.73-.04q-16.37-.03-32.73-.1v-1l2.62-.03 37.53-.47c47.35-.56 47.35-.56 68.76-2.74q5.07-.51 10.16-1l2.04-.2c6.37-.62 12.75-1.13 19.14-1.62 29.44-2.31 29.44-2.31 42.23-4.9a87 87 0 0 1 11.58-1.54c8.26-.56 17.24-1.74 24.88-5.06 4.44-1.87 9.1-2.15 13.84-2.67 5.63-.63 10.93-1.81 16.42-3.17 3.4-.73 6.35-.84 9.8-.6"/><path fill="#403d4a" d="M476 578c8.45-.32 16.47-.07 24.81 1.38 57.5 9.37 123.5 6.3 181.26 1.02 4.33-.4 8.58-.48 12.93-.4v1c-17.9 2.67-35.58 3.45-53.65 3.7l-7.65.11c-5.59.08-5.59.08-6.7 1.19-2.3.1-4.56.14-6.86.13h-38.67c-21.84.03-43.64-.27-65.47-1.13v-2l-2.34.07c-8.52.14-16.57-.7-24.97-2.07l-3.73-.58q-4.48-.7-8.96-1.42z"/><path fill="#d8d3cc" d="M1307 1269h2l2 7-5 2 .1 1.68.09 2.2.1 2.17-.29 1.95-1.48.98c-1.52 1.02-1.52 1.02-1.93 3.08l-.09 2.38c-.1 2.37-.1 2.37-.5 4.56l-1.5 1.27c-2.15 2.48-1.87 4.3-1.86 7.53v3.46l.02 1.8q.03 2.7.02 5.4c.03 5.43.18 9.52 2.32 14.54.2 3.33.2 3.33.13 6.81l-.03 1.81-.1 4.38h-4q-.73-2.06-1.44-4.12l-.8-2.33c-1.55-5.21-1.94-10.37-2-15.78l-.02-2.06-.04-4.29-.06-4.32c-.17-8.6-.26-16.47 4.36-24.1a133 133 0 0 1 3-3c.8-2.05.8-2.05 1.44-4.31A30 30 0 0 1 1307 1269"/><path fill="#909091" d="M753 937v2c4.7.25 4.7.25 6.22-1 2.83-1.59 5.81-1.2 8.97-1.12l1.98.02 4.83.1c-4.32 2.95-6.8 3.1-12 3v2q3.56-.17 7.13-.37l2.01-.1c4.29-.25 7.77-1.15 11.86-2.53 3-.2 3-.2 5.88-.12l2.92.05 2.2.07v1a97 97 0 0 1-20.31 4.19l-2.32.25c-4.15.42-8.2.65-12.37.56l1-3-2.06.47c-3.93.7-7.87.88-11.86 1.05-2.61.15-4.72.3-7.07 1.5-2.95 1.44-5.86 1.23-9.07 1.17h-1.9A24 24 0 0 1 721 945c-3.53-1.1-6.82-1.13-10.5-1.12l-3.4-.01c-2.6.1-4.62.47-7.1 1.13q-2.12.1-4.25.06L692 945a25 25 0 0 1 8.17-2.87l5.1-.86 2.73-.46 5.75-.95q4.37-.72 8.72-1.47l8.2-1.37c7.57-1.23 14.8-1.56 22.33-.02"/><path fill="#ac82dd" d="M425 595v2l2 1c-3.06-.43-5.76-.9-8.62-2.06-2.39-.94-3.86-1.02-6.38-.94l-.17 1.62-1.3 12.25-.23 2.25C410 613 410 613 409 615q-.37 4.2-.6 8.39c-.3 4.5-.3 4.5-1.4 5.61a215 215 0 0 0-1.2 10.24l-.53 5.14-.8 7.67a500 500 0 0 0-2.53 55.01l.01 6.13L402 728q-1.73-.39-3.44-.81l-1.93-.46L395 726l-1-3-2.12-.31c-5.23-1.25-9-3.05-11.88-7.69l9 3 1-2c1.94.38 1.94.38 4 1l1 2h3l.02-1.53q.1-8.05.24-16.12l.09-5.97c.14-12.4 1-24.37 2.93-36.62a204 204 0 0 0 1.85-19.07c1.3-20.13 1.3-20.13 2.87-21.69q.34-2.67.54-5.35l.26-3.27.26-3.44.28-3.46.66-8.48c6.08-2.03 11.1-.93 17 1"/><path fill="#d7d6d5" d="M1161 1023h11v2c-3.6 1.2-6.23 1.07-10 1v4l-2.78.59-3.72.79-1.82.38-1.84.39-1.92.4q-1.88.44-3.72 1c-6.34 1.86-12.63 1.71-19.2 1.64l-3.7-.03q-12.3-.09-24.61-.54l-2.96-.09c-5.24-.22-9.76-.84-14.73-2.53v-2l61-1v-3l18-1z"/><path fill="#27262c" d="M1242 791h8l-1 3q-2.55.77-5.12 1.4c-2.37.76-4.13 2.13-6.13 3.58-3.6 2.1-7.74 2.97-11.75 4.02v2l-2.82.77c-11.25 3.12-11.25 3.12-15.12 4.86A65 65 0 0 1 1198 814l-3 1c-2.34.13-4.65.04-7 0l-1 3h-9l-1 4h-9l1-3c2.07-.73 2.07-.73 4.56-1.19l2.5-.48 1.94-.33v-3l10-1v-3l2.15-.37 2.79-.5 2.77-.5 2.29-.63 1-2c2-.56 2-.56 4.44-1 4.34-.78 4.34-.78 6.56-3 2.01-.34 4-.44 6.03-.56 1.97-.44 1.97-.44 3.4-1.93 2.17-2.08 3.8-2 6.76-2.2l2.73-.2 2.08-.11 1-3 1.93-.15 2.5-.23 2.5-.2 2.07-.42z"/><path fill="#121219" d="M443 1210h1v2.42a281382 281382 0 0 0 .32 162.17q.06 27.44.1 54.87l.12 56.28.07 34.74a14424 14424 0 0 0 .07 37.56 3217 3217 0 0 0 .04 17.13q0 3.1.02 6.19v3.47c.27 3.35 1.11 6.03 2.26 9.17q.56 3 1 6c-2.88-2.6-4.36-4.17-5-8q-.14-3.23-.12-6.46v-40.35l.01-24.36.02-35.21a147013 147013 0 0 1 .04-112.63v-20.61z"/><path fill="#9b7a5d" d="m1124.06 1209.88 4.07.02q4.94.03 9.87.1l-1 2c-7.6 1.41-15.4 1.29-23.1 1.4q-2.6.05-5.2.12l-3.1.06c-3.04.49-3.53 1.3-5.6 3.42-2.36.43-2.36.43-5.1.51l-2.98.1-3.1.08-3.15.1q-3.83.12-7.67.21l-.93 2.4c-1.07 2.6-1.07 2.6-2.16 4.16-1.16 1.84-1.25 3.28-1.41 5.44-.28 3.7-.28 3.7-1.5 5.33-1.56 2.6-1.47 5.4-1.62 8.36l-.12 1.84q-.14 2.24-.26 4.47h8l1 2h-2l-1 3q-2.71.05-5.44.06l-3.06.04c-2.5-.1-2.5-.1-3.5-1.1-.56-6.38 1.15-11.92 3-18h2l-.18-1.58c-.45-7.4 1.84-14.17 6.45-20.04 2.32-1.85 3.8-1.78 6.74-1.82l2.99-.08 3.13-.04c11.42-.2 11.42-.2 16.17-1.46 6.46-1.62 13.15-1.2 19.76-1.1"/><path fill="#7b0d04" d="M1454 1341h1v5h2l-1.37 4.88-.78 2.74c-.85 2.38-.85 2.38-2.85 4.38q-.7 1.64-1.31 3.31c-1.54 3.7-3.76 6.03-6.69 8.69q-1.95 1.95-3.87 3.94c-7.69 7.62-16.01 15.35-27.13 17.06 1.38-1.5 1.38-1.5 3-3h2v-2l4-1c-1.32-3.91-2.87-6.19-5.87-9-6.24-6.04-6.24-6.04-8.13-9 .25-2.31.25-2.31 1-4a71 71 0 0 1 8 9v2h4l1-2 1 5h3c1.96-2.16 2-2.97 2-6l8-2v-2l2.44-.81 2.56-1.19 1-3 3-1 1-5 3-1 1-6 3-1z"/><path fill="#101017" d="M1216 1085h5l.08 44.6a7467 7467 0 0 1 .03 26.13l.01 2.2c0 4.96 0 4.96-1.12 6.07q-2.56.22-5.14.28l-3.33.11-3.65.11-13.73.44c-48.01 1.48-48.01 1.48-61.6 3.61-6.92.97-13.95.93-20.92 1.08l-4.58.11q-5.53.14-11.05.26v-1l1.51-.17q3.4-.37 6.8-.77l2.38-.26 2.3-.26 2.1-.24c1.91-.3 1.91-.3 4.91-1.3 13.4-2.02 26.88-2.37 40.4-2.66l2.08-.05 3.9-.08c4.4-.1 4.4-.1 6.62-1.21q3.26-.34 6.5-.53l2.01-.13c8.9-.52 17.8-.7 26.71-.9q3.4-.06 6.77-.16l2.1-.04c4.8-.13 4.8-.13 5.91-1.24q.18-3.36.2-6.72l.18-13.97.15-12.76.16-13 .31-25.55h-5z"/><path fill="#4b4954" d="M1274 1486h1l.01 2.08a89315 89315 0 0 0 .63 96.16l.07 11.24a1697 1697 0 0 0 .77 47.63c.2 5.69.8 10.48 2.52 15.89.24 2.9.24 2.9.23 5.75v3.28l-.03 3.47-.01 3.6-.05 9.41-.04 9.64q-.03 9.42-.1 18.85h-1l-.03-1.54-.47-21.8-.18-8.5-.05-2.68-.06-2.49-.05-2.2c-.16-1.79-.16-1.79-1.16-3.79-1.5-12.77-1.52-25.79-2-38.62l-1.75-45.17c-.74-19.9-.17-39.86.12-59.77l.28-20.23.14-10.03.05-3.33c.14-2.45.46-4.51 1.16-6.85"/><path fill="#767578" d="M231 918a45 45 0 0 1 10.06 2.44c4.9 1.72 9.79 2.34 14.92 2.93 3.02.63 3.02.63 5.4 2.09 3.52 2.07 6.93 2.57 10.93 3.23l2.25.39q2.71.48 5.44.92v2l3-.12c2.69 0 4.94.29 7.52 1.03 5.32 1.39 10.72 2 16.17 2.65l8.76 1.08q2.55.36 5.1.88c2.34.46 4.61.72 7 .91l2.56.22q2.61.23 5.23.42c4.26.38 8.03.95 12.05 2.43 5.46 1.98 10.91 2.22 16.67 2.56l6.63.47 3.24.23c6.39.5 12.73 1.35 19.07 2.24v1c-9.52.2-18.85.25-28.28-1.15-4.57-.65-9.13-1.05-13.73-1.36l-1.89-.12a1233 1233 0 0 0-12.95-.8l-3.15-.2c-3.2-.4-5.96-1.3-9-2.37a92 92 0 0 0-5.75-.44 53 53 0 0 1-13.5-2.74c-3.36-1-6.74-1.67-10.19-2.32-4.44-.84-8.78-1.79-13.12-3.06-4.2-1.21-8.3-1.9-12.63-2.44-4.53-.58-8.67-1.36-12.87-3.22-2.3-.93-4.66-1.56-7.05-2.23-3.1-.9-6.15-1.97-9.2-3.05l-1.88-.64c-2.82-1-4.67-1.72-6.81-3.86"/><path fill="#212028" d="M1260 1478.73c6.05.04 11.99.48 18 1.14l2.25.24 2.13.26 1.9.22c1.92.46 3.15 1.22 4.72 2.41l-8.75-.44-2.5-.12-2.42-.12-2.22-.11c-2.11-.21-2.11-.21-3.88-.73-2.22-.48-4.1-.53-6.37-.51l-2.19.01-1.67.02-.01 1.53a46811 46811 0 0 1-.64 72.39l-.06 6.58q-.07 10.25-.29 20.5l-.05 2.6q-.08 3.53-.22 7.07l-.04 2.06c-.23 4.64-1.56 6.91-4.69 10.27-1.16 2.06-1.16 2.06-2.06 4-2.92 6.22-6.03 10.3-11.31 14.62l-1.72 1.45C1226 1634 1226 1634 1220 1634v-2l1.43-.66c11.33-5.44 22.1-12.5 27-24.59a156 156 0 0 0 1.57-4.75l1.13-3.31.87-2.69 1.06-2.5c2.29-6.09 2.1-12.28 2.11-18.7a30669 30669 0 0 0 .3-49.55 6847 6847 0 0 1 .16-24.96l.07-9.61v-2.9l.03-2.63.01-2.3c.4-2.8 1.5-3.75 4.26-4.12"/><path fill="#949495" d="m691.56 945.88 3.13.05 2.31.07c-.68 1.44-.68 1.44-2 3-2.3.6-2.3.6-5.12.91l-3.09.37-3.23.35-6.3.72-2.84.3C672 952 672 952 669 953h22l-2 1-1 3c-1.64.66-1.64.66-3.81 1.06l-2.43.47-2.76.47-3.2.57C667.48 961 667.48 961 665 961v-2l13-2c-11.21-.13-11.21-.13-16.28.5-4.94.59-9.81.61-14.78.56l-2.63-.01L638 958c.5-1.94.5-1.94 2-4l8-1h-28v-1l2.3-.17 17.43-1.3 3.2-.23c3.07-.3 3.07-.3 6.12-.82 2.86-.46 5.57-.65 8.47-.73l3.1-.1 6.35-.17c5.56-.18 10.65-.7 16.04-2.05 2.92-.63 5.57-.62 8.55-.55"/><path fill="#020308" d="M525 546h112l-1 3c-18.27.94-36.54 1.11-54.83 1.12l-5.28.01c-16.99.03-33.92-.38-50.89-1.13z"/><path fill="#74727d" d="M1288 1487q2.72-.05 5.44-.06l3.06-.04c2.5.1 2.5.1 3.5 1.1q2.58.51 5.19.94A63 63 0 0 1 1317 1492l-2 1 .05 19.15.03 8.9c.05 15.68-.23 31.3-1.08 46.95h-2c-.9-8.86-1.13-17.62-1.1-26.52v-4.05l.03-10.53.02-10.8.05-21.1-3-1-1 2v-2l-3.18.1-4.13.09-2.1.07c-5.08.08-5.08.08-7.5-1.52a15 15 0 0 1-2.09-5.74"/><path fill="#96a4ae" d="m1585 1173 4 2-1.56 2.25c-1.44 2.75-1.44 2.75-1.32 4.94 1 2.06 1.87 2.76 3.88 3.81l-1 4h-3v-5c-2.24 1-3.88 1.88-5.62 3.63L1579 1190h-2v3l5 1-1 4c-3.69-.5-5.6-1.1-8-4a24 24 0 0 0-7.69 5.44l-1.82 1.8C1562 1203 1562 1203 1561 1206h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.72-.63-2.72-.63-6.06-1.12l-3.35-.51-2.59-.37v-2l6 1-.12-2.25a11.5 11.5 0 0 1 2.12-6.75l2-1 .19-2.87c.81-3.13.81-3.13 2.36-4.16q2.69-1.09 5.45-1.97l2-1-1 3-2 1-1 3h5v-7l1 2h5l1-3 2-1c1.13-2.06 1.13-2.06 2-4h5z"/><path fill="#828189" d="m392 1260 2 1c1.38 19.4 1.38 19.4 0 27l-1 1q-.14 2.38-.14 4.78l-.03 10.34-.03 10.19q0 5.48-.04 10.97l-.05 19.01-.08 27.5a90848 90848 0 0 0-.27 90.64l-.04 13.4L392 1587h-1l-.05-123.67v-17.53l-.04-95.35-.02-50.23-.01-22.58v-12.19c.12-2.45.12-2.45 1.12-5.45"/><path fill="#46464b" d="M201 707c2.19.31 2.19.31 4 1l-1.69.69c-2.37 1.34-3.9 2.94-5.78 4.89-4.5 4.18-9.89 7.18-15.4 9.8-8.04 3.88-14.88 9.44-21.53 15.33a29 29 0 0 1-4.97 3.16c-3.94 2.12-6.99 5-10.27 8.01a124 124 0 0 1-6.99 5.75c-5.82 4.6-11.16 9.68-16.55 14.78q-3.66 3.45-7.39 6.8c-5.81 5.23-11.05 10.36-15.75 16.63A66 66 0 0 1 93 800a132 132 0 0 0-3 5 334 334 0 0 1-9 13c-.66-1.73-.66-1.73-1-4 1.04-2.08 1.04-2.08 2.63-4.25 2.5-3.53 4.82-7.09 7-10.83a42 42 0 0 1 5.11-5.64c1.88-1.9 3.45-4.03 5.07-6.15 5.68-7.16 12.47-13.17 19.2-19.31q2.76-2.52 5.45-5.1a222 222 0 0 1 19.96-16.76C147 744 147 744 149 742l2.25-.87c6.02-2.47 10.78-6.7 15.7-10.87 2.88-2.4 5.8-4.4 9.05-6.26 7.23-4.37 7.23-4.37 10.44-7 2.98-2.33 6.12-4 9.47-5.75 2.09-1.25 2.09-1.25 3.73-2.92z"/><path fill="#9a9999" d="m225 710 2 1c-.54 3.84-2.1 5.34-5 7.81q-1.2 1.04-2.45 2.09c-10.73 8.83-10.73 8.83-14.55 10.1-1.19 2.06-1.19 2.06-2 4h-2l-.8 1.83c-1.26 2.28-2.34 3.3-4.39 4.86-3.67 3-6.3 6.43-9.12 10.2a34 34 0 0 1-6.28 5.91 27 27 0 0 0-4.03 4.51c-2.1 2.64-2.1 2.64-4.7 3.07L170 765v4h-2l-1-4h2l.19-1.75c1.14-3.15 3.17-4.21 5.81-6.25a96 96 0 0 0 3.89-4.73C180 751 180 751 182 750l1-3h3l.63-2.19c1.55-3.17 3.15-4.53 5.93-6.6 1.44-1.21 1.44-1.21 2.5-2.86L196 734h2l2-4h2v-2l-3-1 4-2v-2l3-1c1.8-1.64 3.35-3.4 4.94-5.25L213 715c2.13.38 2.13.38 4 1l2-2c2.13-.12 2.13-.12 4 0z"/><path fill="#2d2c30" d="m951 39 1 2c2.56.63 2.56.63 5 1l2-4h8l1 5c-4.62 3-4.62 3-8 3l-1 4h-8l-1 3-1.6.11c-5.96.56-5.96.56-8.46 2.45C937 57.74 934.6 57.21 931 57l-1 3h-7c-1-3-1-3 0-6h-12v-3l11-1 2 3c2.63.69 2.63.69 5 1l.72-1.96C931 50 931 50 933.09 49.5l2.41-.06c2.4-.07 2.4-.07 4.5-.44q1.03-1.48 2-3c1.73-.3 1.73-.3 3.63-.19L949 46l2-4h-10v-3c3.67-.94 6.35-1.11 10 0"/><path fill="#85838e" d="M1316 1083h1c1.23 9.05 1.13 18.06 1.1 27.16v4.94l-.03 12.89-.02 13.2-.05 25.81-8.1-.45-2.29-.13a207 207 0 0 1-18.67-1.98l-2.95-.42-6.99-1.02-1-4h20v2l8.76.56c2.95.2 5.42.5 8.24 1.44l-.05-26.86-.04-24.54-.02-4.58c-.02-8.07.04-16 1.11-24.02"/><path fill="#7245b5" d="M641 759q2.52.34 5.06.56l4.94.44v2h-17v2l-21.69.08-7.9.02-2.5.01h-2.3l-2.05.01C596 764 596 764 595 763a115 115 0 0 0-7.53-.1l-9.28.05-9.19.05-1-3c11.22-2.32 22.47-2.45 33.88-2.63l9.73-.2 6.27-.12 2.89-.06c17.95-.27 17.95-.27 20.23 2.01"/><path fill="#aa9bc2" d="M743 587c-2.21 2.54-3.65 3.55-7 4q-4 .1-8 0v2c-6.45 1.83-12.7 2.55-19.37 3.06q-2.96.23-5.9.48l-2.61.2C698 597 698 597 696 598c-3.7.37-7.41.47-11.12.63l-3.1.15c-5.72.25-11.14.32-16.78-.78l-1-2c-3.06-.62-3.06-.62-6-1l1-2 2.94-.04c49.76-.8 49.76-.8 58.06-4.96q3.21-.3 6.44-.5c14.45-.86 14.45-.86 16.56-.5"/><path fill="#000006" d="M789 1735h128l-1 3a63480 63480 0 0 1-71.44.08 16651 16651 0 0 1-40.03.03l-8.54.01h-2.8C791 1738 791 1738 789 1737z"/><path fill="#404345" d="m263 603 1 2 2.5.81L269 607c.88 2.56.88 2.56 1 5-1 1-1 1-4.06 1.06L263 613v4h-8v3l-1.69.48c-4.25 1.3-7.86 2.72-11.56 5.2-3.04 2.04-5 2.9-8.75 2.32l-1 4h-6l1-6h7l1-3 3-2 1-3h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.56.63 2.56.63 5 1l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#63409e" d="M720 597a17.5 17.5 0 0 1-6.62 3.07l-2.09.53-2.16.52-2.15.56c-3.74.92-7.1 1.55-10.98 1.32v2h19v1l-1.88.04-8.43.27-2.96.07-2.84.1-2.62.09c-2.27.43-2.27.43-3.71 1.93-2.32 2.23-4.24 1.92-7.4 1.98l-3.47.09-3.63.05-7.09.17-3.18.05c-2.42.14-4.47.47-6.79 1.16l1 2c-10.06.12-19.98-.12-30-1 5.39-2.8 10.02-3.12 16-3v-2l1.46-.17a3488 3488 0 0 0 20.85-2.47q4.08-.47 8.16-.97l2.52-.29c4.7-.58 8.83-1.57 13.2-3.4 3.18-1.23 6.47-1.8 9.81-2.39l2-.39c4.7-.87 9.22-1.07 14-.92"/><path fill="#34343c" d="M1295 1485a110 110 0 0 1 22.38 1.27c2.62.73 2.62.73 3.76 1.89 1.26 2.71 1.14 5.1 1.18 8.09l.03 1.92.06 6.4.06 4.6a3165 3165 0 0 1 .21 20.3q.2 19.26.32 38.53l.02 2.78a8553 8553 0 0 1 .19 45.02c.06 15.16.1 30.29-1.07 45.42l-.22 2.97c-.94 10.48-4.13 19.5-8.92 28.81-.69-1.62-.69-1.62-1-4l1.38-2.57c2.12-4.03 2.61-8.21 3.24-12.68l.73-4.83.3-2.14c.35-1.78.35-1.78 1.35-3.78q.36-3.3.56-6.62l.12-2.02c.44-7.5.45-14.97.43-22.47v-17.11l-.02-13.08q0-12.36-.02-24.7l-.02-28.16-.05-57.84a20 20 0 0 0-8.48-2.56l-2.53-.23-5.26-.42-2.52-.23-2.3-.18c-1.91-.38-1.91-.38-3.91-2.38"/><path fill="#72707b" d="M426 1262c2.28 2.28 2.27 2.67 2.41 5.73l.12 2.38.09 2.64.11 2.81c.45 12.44.4 24.88.39 37.32v11.04l-.01 19.04-.02 27.54a89957 89957 0 0 1-.04 88.05v16.16L429 1586h-1v-1.77l-.33-103.84-.04-12.5-.01-2.5-.26-81.45a34209 34209 0 0 1-.16-52.97c-.04-17.02-.23-34.01-1.82-50.97-.52-6.08-.09-11.95.62-18"/><path fill="#3d3c40" d="M413 808c5.37-.34 9.62-.07 14.58 2 8.34 3.43 16.93 6.24 25.86 7.54 2.61.47 5.11 1.16 7.65 1.91 8.47 2.43 17.18 4.63 25.97 5.34 2.06.22 3.99.65 6 1.15 4.87 1.14 9.76 1.72 14.72 2.3q6.66.8 13.28 1.82c5.68.85 11.31 1.29 17.06 1.42 4.52.2 4.52.2 6.57 2.05L546 835c-14.13-.3-27.84-.9-41.74-3.58-4.26-.79-8.54-1.4-12.82-2.04l-2.46-.38q-5.77-.88-11.6-1.47a80 80 0 0 1-12-2.34l-2.07-.54-4.22-1.1q-3.03-.8-6.07-1.56a182 182 0 0 1-19.63-6.1c-3.1-1.15-6.2-2.04-9.39-2.89v-2l-3.25.13c-3.08 0-5.1-.44-7.75-2.13z"/><path fill="#727173" d="m987 897-2.36.66-3.39.97-1.87.52a799 799 0 0 0-13.86 4.13c-10.42 3.17-20.75 6.29-31.62 7.41-4.72.77-9.23 2.69-13.7 4.35-3.63 1.09-7.07 1.48-10.83 1.84a71 71 0 0 0-17.73 4.24c-3.55 1.19-6.92 1.53-10.64 1.88l-1 1c-2.72.66-5.5 1.09-8.25 1.56l-2.32.42c-3.85.68-7.5 1.2-11.43 1.02v2a958 958 0 0 1-37 6c4.35-3.2 8.97-4.29 14.25-5.06 5.65-.84 5.65-.84 6.75-1.94q3.71-.75 7.44-1.37a99 99 0 0 0 13.87-3.25c4.39-1.43 8.58-1.82 13.16-2.13 2.77-.27 5.15-.92 7.78-1.78 2.83-.76 5.72-.92 8.64-1.13 2.11-.34 2.11-.34 3.9-1.28 2.54-1.22 4.92-1.77 7.67-2.35l3.18-.66 3.3-.67 3.28-.7c4.68-.97 8.99-1.82 13.78-1.68l1-4 3.1-.37 4.09-.5 2.04-.24c3.92-.5 7.1-1.38 10.77-2.89 2.44-.59 4.9-1.01 7.38-1.44l4.25-.77 2.18-.39q5.26-.97 10.5-2.02l1.9-.38c9.15-1.82 9.15-1.82 11.79-1"/><path fill="#545357" d="M1327 296c2.16 1.27 3.47 2.07 5 4 .73 3 .93 5.93 1 9l2.25-.12c2.75.12 2.75.12 6.75 2.12v-10h3l1 9-3 2c-.69 2.63-.69 2.63-1 5l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.37.69c-1.63 1.31-1.63 1.31-2.57 3.87-1.06 2.44-1.06 2.44-3.06 3.38l-2 .06c-2-2-2-2-2.12-4.62l.12-2.38-4-1v-7l-4-1v-7l-4-1v-7l-4-1-.31-3.31c-.45-3.27-1.24-4.45-3.69-6.69-.19-2.19-.19-2.19 0-4"/><path fill="#242327" d="M578.63 118.85h1.78l9.92-.03h8.53q5.46 0 10.92-.04l12.42-.02h8.89c3.01.25 5.17 1 7.91 2.24 1.85.32 1.85.32 3.55.41l1.87.12 1.9.1 1.95.11q2.36.14 4.73.26v6l-9 1 2-5h-19v-2a5785 5785 0 0 0-27.63.42c-11.68.16-23.34.4-34.98 1.43l-2.5.2a56 56 0 0 0-14 3.27 49 49 0 0 1-6.58 1.8l-2.22.47q-4.04.8-8.09 1.41l2-1 1-3 1.98-.37 2.64-.5 2.6-.5c2.78-.63 2.78-.63 5.4-1.6 4.73-1.63 9.76-1.99 14.7-2.6l5.98-.75 2.66-.32c8.57-1.5 8.57-1.5 8.67-1.5"/><path fill="#55358f" d="m861 564-22 8c9 0 9 0 12-2 2.69-.12 2.69-.12 5 0-3.45 3.82-6 4.28-11.12 4.56l-3.14.07c-3.18.43-3.6 1.15-5.74 3.37-2.34.4-2.34.4-4.94.5-4.62.17-4.62.17-7.03 2-3.23 2.39-6.57 1.97-10.47 2.1-2.56.4-2.56.4-4 1.9-2.4 2.32-4.73 1.95-8 2.06-6.16.23-6.16.23-8.55 1.46-2.87 1.4-5.64 1.2-8.76 1.1l-1.82-.02-4.43-.1c2.5-3.05 4.84-3.72 8.63-4.62 5.29-1.3 5.29-1.3 6.37-2.38 2.74-.54 5.48-.96 8.24-1.4 2.6-.56 4.4-1.42 6.76-2.6q2.37-.4 4.75-.69c5.21-.86 9.56-2.62 14.31-4.81 5.16-2.38 9.84-4.19 15.5-5.03 4.07-.78 7.9-2.31 11.78-3.76C857 563 857 563 861 564"/><path fill="#ce9c80" d="M1339 1293c1.08 3.22.93 4.75-.41 7.83-2.12 5.91-2.04 11.76-1.98 17.95q.01 2.93-.02 5.85A88 88 0 0 0 1340 1349l.7 2.64c1.39 4.96 3.6 8.5 6.9 12.44 1.4 1.92 1.4 1.92 2.32 4.35 2.1 4.99 6.4 8.68 10.08 12.57l1.9 2.1c5.66 6.04 11.04 7.8 19.01 9.43 2.49.56 4.77 1.42 7.09 2.47-8.85 6.03-22.52 3.15-32.6 1.42-6.2-1.2-12-3-17.4-6.42l-1-2 1.4.52c12.95 4.53 24.86 7.2 38.6 6.48l-2.77-.95-3.6-1.3-1.83-.62c-4.51-1.65-4.51-1.65-5.94-4.02l-.86-2.11q-2.09-2.29-4.31-4.44l-2.44-2.38-2.25-2.18-2.81-2.87c-2.19-2.13-2.19-2.13-4.38-3.5-2.2-1.97-2.58-3.73-3.25-6.55a57 57 0 0 0-2.5-6.95 151 151 0 0 1-5.58-15.8c-1.63-5.72-1.79-11.17-1.73-17.08l.02-2.95c.15-10.21.81-19.36 6.23-28.3"/><path fill="#b0a8be" d="M463 580q3.94.14 7.88.31l2.2.07c4.15.2 7.75.79 11.7 2.1 5.88 1.88 11.65 2.23 17.78 2.58l6.88.48 3.37.23q6.61.49 13.19 1.23l2.1.22c4.66.54 4.66.54 6.9 2.78a5055 5055 0 0 1-27.27.72q-4.96.14-9.94.26l-3.13.1-2.91.06c-.43 0-.43 0-2.57.06-2.35-.22-3.45-.6-5.18-2.2v-2l-1.53.98c-3.37 1.4-6.36 1.39-9.97 1.4l-2.01.07c-3.66.02-5.03-.07-7.96-2.47L461 585l2-1z"/><path fill="#000004" d="M1252 1480a15 15 0 0 1 2.25 8.43l.02 2.66-.01 2.9v3.08q.02 5.04 0 10.08l.01 7v14.68q-.03 8.47 0 16.95a4949 4949 0 0 1 0 23.26l-.01 9.7.01 2.9c-.04 6.65-1.09 11.45-4.27 17.36h-2l-1 2q-.12-1.87-.19-3.75l-.1-2.1c.29-2.15.29-2.15 1.78-4 1.75-2.49 1.89-3.68 1.9-6.68l.04-2.74-.01-2.97.02-3.16.03-8.55q.01-4.47.05-8.94.06-8.48.08-16.94a10413 10413 0 0 1 .2-39.26l.04-9.92v-3l.02-2.71.01-2.37c.13-1.91.13-1.91 1.13-3.91"/><path fill="#2e1757" d="M1052 650c2.06 1.08 2.9 1.77 3.88 3.92l.68 2.27.74 2.41.7 2.4.63 2.15q.7 2.42 1.37 4.85l2-2c1.74-.29 3.49-.57 5.25-.75C1069 665 1069 665 1072 664v-2l2.12-.11 2.76-.2 2.74-.18c2.38-.51 2.38-.51 3.5-2.04l.88-1.47c3.13-.19 3.13-.19 6 0-.31 1.94-.31 1.94-1 4l-3 1-1.44 1.94c-2.43 3.2-4.85 3.86-8.68 4.93a155 155 0 0 0-12.2 4.13l-4.23 1.56-1.87.7c-1.58.74-1.58.74-3.58 2.74-1.7.75-1.7.75-3.69 1.44-2.55.9-5 1.86-7.47 2.95-4.45 1.93-9.03 3.4-13.65 4.86l-2.6.85-2.48.8-2.25.71c-1.86.39-1.86.39-3.86-.61l2.52-1.02 3.3-1.36 1.65-.67A41 41 0 0 0 1033 683c1.81-.57 3.63-1 5.48-1.44C1040 681 1040 681 1041 679c1.58-.69 1.58-.69 3.6-1.29l2.18-.66 2.28-.67 2.3-.7L1057 674a99 99 0 0 0-3.62-13.69A23 23 0 0 1 1052 650"/><path fill="#8b8b8e" d="M415 452h1q-.17 4.75-.37 9.5l-.1 2.69a61 61 0 0 1-2.1 14.11c-.53 2.1-.63 4.04-.56 6.2l.06 2.03.07 1.47h2l2-17h1q.13 4.63.19 9.25l.07 2.64c.06 5.02-.2 7.11-3.26 11.11-.53 3.24-.6 6.47-.69 9.75l-.1 2.7q-.12 3.27-.21 6.55l12 1v5c-7.2.63-12.49-.95-19-4a180 180 0 0 1 4-43h2l-.07-2.34c-.1-6.3.22-11.64 2.07-17.66"/><path fill="#454648" d="m356 1700 .59 2.8.79 3.64.38 1.85q.55 2.38 1.24 4.71l2 1v8l4 1v10l4 1c1.3 4.17 2.27 7.59 2 12-3.11-1.5-6.05-3.2-9-5v5l-4-1c.38-1.94.38-1.94 1-4l2-1c.63-2.56.63-2.56 1-5l-4-2v-10l-4-2v12h-3l-1-11 3-2c.69-2.62.69-2.62 1-5l-4-2v-12c2.22-1.11 3.56-1.08 6-1"/><path fill="#524f5b" d="M1276 1008h1l-.08 1.66a1427 1427 0 0 0-1.48 43.22l-.07 3.24c-.68 35.62-.46 71.26-.37 106.88l-3-1 .04-2.2.02-3.05.04-2.95c-.03-3.1-.3-6.14-.6-9.22-.55-6.54-.7-13.05-.74-19.6l-.01-1.86a2459 2459 0 0 1-.07-16.15c-.05-22.45.7-44.77 2.26-67.16l.14-2.01q.45-6.6.95-13.22l.25-3.35.24-3.08.2-2.66c.29-2.54.74-5 1.28-7.49"/><path fill="#242629" d="M1450 378h30l1 3 24 1 1 6c-3.62 1.81-7.88 1.18-11.87 1.19l-2.77.03-2.66.01-2.44.01c-2.5-.27-4.03-1.1-6.26-2.24-2.18-.35-2.18-.35-4.52-.5q-1.29-.09-2.6-.2l-5.35-.34c-5.89-.46-9.62-1.65-14.53-4.96v-2z"/><path fill="#ccc0b6" d="m1361.08 1228.47 1.96.03h2.06l4.32.03q3.27.03 6.52.01c7.06.01 13.35.31 20.06 2.78 2.05.7 3.84.93 6 1.05 5.19.48 9.51 3.14 14 5.63v2l-1.51-.22c-9.95-1.28-19.22-1.97-28.49 2.22a69 69 0 0 1-14 3c1.26-3.79 2.52-4.26 5.88-6.09 3.02-1.3 6.07-1.88 9.3-2.42l1.82-.49 1-2-38-1c4.46-4.46 4.46-4.46 9.08-4.53"/><path fill="#8750c9" d="M412 594q2.51.43 5 1v3l-3 1-.18 3.32-.26 4.37-.12 2.18c-.18 3.07-.4 5.95-1.04 8.96-.4 2.17-.4 2.17.6 5.17-.48 2.44-.48 2.44-1.3 5.34-1.78 6.7-2.85 13.42-3.82 20.28l-1.01 6.92-.44 3.04C406 661 406 661 405 663c-1.19 7.53-1.2 15.19-1.32 22.79l-.21 12.93-.16 9.57L403 727l3 1v2l-10-2v-1h5l-.01-1.44c-.21-23.28-.32-46.38 2.01-69.56l.18-1.85q.66-6.68 1.38-13.34l.2-1.8.56-4.99.32-2.87C406 629 406 629 407 628q.32-3.21.5-6.44c.37-6.3.37-6.3 1.5-8.56q.3-2.04.5-4.1l.25-2.43.75-7.5.22-2.24C411 595 411 595 412 594"/><path fill="#706e78" d="m1278 1489 6 1v2l-4 2 .02 3.26c.05 14.02-.17 28.02-.41 42.04a12210 12210 0 0 0-.7 46.9c-.3 20.1.35 39.95 1.8 60 1.56 21.6 1.6 43.15 1.29 64.8h-1l-.07-2.02a18188 18188 0 0 0-1.33-38.16l-.18-5.3-.12-3.34-.1-2.89q-.25-4.02-.68-8.02c-.54-5.25-.85-10.49-1.07-15.76l-.13-3a847 847 0 0 1-.45-33.97v-66.1c-.02-14.5.27-28.95 1.13-43.44"/><path fill="#38393b" d="m370 374 3 1v30l-3 1 .04 1.5c.06 4.62-.28 8.94-1.04 13.5h-2l-.31 1.69c-.83 2.77-2.1 4.91-3.69 7.31h-1v-23l4-2v-29z"/><path fill="#615e6b" d="M1278 1018c2 2 2 2 2.2 3.95l-.05 2.4-.05 2.74-.09 2.96-.07 3.04-.16 6.38q-.14 4.88-.24 9.75l-.22 9.14-.08 2.77-.07 2.42c-.17 2.45-.17 2.45-.67 5.4-.86 5.25-.89 10.56-1.06 15.87l-.06 1.9c-.5 15-.5 30-.45 45.01l.02 10.65.05 20.62-3-1c-.4-77.33-.4-77.33.94-109.25l.13-3.2c1.19-28.07 1.19-28.07 2.93-31.55"/><path fill="#06070c" d="M390 986h109v1l27 1v1H390z"/><path fill="#7f8081" d="M237 950h9l1 3 13 1v1l-12 1c9.22 3.76 18.02 5.25 27.94 5.48 3.77.15 6.97.73 10.55 1.92 7.05 2.25 14.32 2.92 21.63 3.79l4.35.53q5.26.65 10.53 1.28v2l2.65-.04c13.4-.12 26.71.38 39.99 2.3 6.1.88 12.2 1.35 18.36 1.74v1c-23 .65-45.8-1.94-68.56-4.87l-2.93-.38c-11-1.4-21.83-3.13-32.68-5.41-5.4-1.12-10.6-1.78-16.11-2.11-4.99-.42-9.84-1.54-14.72-2.6l-3.18-.69A354 354 0 0 1 215 952a12.3 12.3 0 0 1 8.13-.12l2.07.55c1.8.57 1.8.57 3.8 1.57q3.56.11 7.13.06l2-.01L243 954v-2l-6-1z"/><path fill="#3c3d41" d="m219 627 1 2q2.46 1.09 5 2l-1 6-2.37.31c-2.63.69-2.63.69-3.75 2.19L217 641l-3 1-1 3h-6v4l-1.68.11-2.2.2-2.17.18c-2.43.64-2.7 1.42-3.95 3.51a23 23 0 0 1-11 2c1.5-3.11 3.2-6.05 5-9h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.56.63 2.56.63 5 1l2-4h-8v-3l7-1 .69 1.44C200 641 200 641 202.3 642c3.03 0 3.36-.25 5.44-2.25 1.85-2 2.35-3.04 3.25-5.75h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.56.63 2.56.63 5 1l2-4h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#18181c" d="M109 908c1.88-.19 1.88-.19 4 0l.94 1.43c1.5 2.24 3.56 2.72 6 3.69 2.29.98 4.4 2.15 6.56 3.38a86 86 0 0 0 9.88 4.82 65 65 0 0 1 9.74 5.02c3.81 2.2 7.79 3.9 11.82 5.66l7 3.06q2.55 1.18 5.04 2.5c4.02 1.91 8.13 3.13 12.4 4.38l2.65.79q9.3 2.7 18.72 4.94a80 80 0 0 1 11.51 3.68c2.6.97 5.27 1.55 7.98 2.12 1.88.57 3.19 1.37 4.76 2.53q-2.4.08-4.81.13l-2.7.07c-2.69-.22-3.37-.7-5.49-2.2q-2.8-.79-5.62-1.44a82 82 0 0 1-10.63-3.12c-4.89-1.75-9.93-2.15-15.08-2.53-4.76-.45-7-1.68-10.67-4.91-2.02-.6-2.02-.6-3.81-.75L166 941l1 4h-2v-5h-2v-2l-3.44-.37c-1.93-.22-1.93-.22-3.56-.63l-1-2q-2.54-1.1-5.09-2.15c-3.51-1.56-6.78-3.58-9.35-6.48-2-1.76-4.06-2.13-6.6-2.78-4.75-1.43-9.07-3.9-13.09-6.78-3.06-1.93-5.3-2.47-8.87-2.81l-1-4h-2z"/><path fill="#513289" d="m975.95 521.8 2.18.07 2.19.06 1.68.07v2c-2.7 1.35-5 1.06-8 1l-.62 1.87c-1.97 3.04-3.92 3.27-7.38 4.13l-3.25.5c-2.75.5-2.75.5-4.75 2.5h-3l-2 4-6-1-.44 1.94C946 541 946 541 945 542c-1.78.13-1.78.13-3.94.12l-2.15.01C937 542 937 542 935 541l-.25 1.87c-.75 2.13-.75 2.13-2.5 3.32-2.25.81-2.25.81-4.87 1.31-2.38.5-2.38.5-4.38 2.5-2.6-.02-2.6-.02-5.62-.38l-3.04-.33L912 549l-1-4 1.8-.11c4.93-.41 8-1.13 12.2-3.89a68 68 0 0 1 5.35-1.47C932 539 932 539 933.46 538c2.35-1.53 4.88-1.86 7.6-2.44 2-.58 3.23-1.4 4.94-2.56 1.72-.53 1.72-.53 3.5-.94 3.04-.8 4.32-1.78 6.5-4.06 4.48-1.86 9.06-2.63 13.83-3.44 2.67-.69 3.4-2.48 6.12-2.76"/><path fill="#454549" d="M1039 10h12v2h2l-1 4-2.77.59-3.6.79-1.83.38c-3 .66-4.58 1.02-6.8 3.24-2.55.62-5.1 1.02-7.69 1.44C1027 23 1027 23 1025 25c-2.38.41-2.38.41-5.12.63l-2.76.22-2.12.15v3h-13l1-7h10l2-4h10l2-4h10z"/><path fill="#27262f" d="M457 1757c8.5 2.72 8.5 2.72 12.38 4.5 9.78 4.26 20.12 5.38 30.62 6.5v2l254 1v1a124382 124382 0 0 1-109.62.11l-36.53.04-37.49.04-23.13.03a6353 6353 0 0 1-25.02.02h-9.92l-2.92.02c-4.93-.02-8.8-.41-13.37-2.26-2.23-.4-2.23-.4-4.27-.6l-2.25-.24-2.3-.22-4.57-.48-2.03-.2C479 1768 479 1768 478 1767q-2.12-.2-4.25-.31c-4.41-.44-6.45-1.66-9.75-4.69l-2.69-1.06c-2.53-1.03-2.97-1.68-4.31-3.94"/><path fill="#5e4938" d="M1188.1 1203.68q4.57 0 9.15-.02c24.67-.14 49.3.72 73.75 4.34v1q-3.52.05-7.06.06l-2 .03h-1.98l-1.79.02c-2.17-.11-2.17-.11-5.3-.58-4-.58-7.98-.87-12.01-1.08l-2.36-.13c-9.4-.47-18.81-.46-28.23-.45h-33.12a7608 7608 0 0 0-25.45 0h-2.32c-4.28.01-8.4.32-12.65.9-1.96.26-3.87.35-5.85.34h-2.12l-2.2-.01h-2.32l-12.19-.05-12.05-.05v2l-5.37.68c-1.63.32-1.63.32-2.63 1.32q-4.95.27-9.92.34c-5.05.2-7.47 1.1-11.08 4.66 1.3-2.9 2.56-4.95 5-7 2-.6 2-.6 4.34-.91l2.68-.37 2.92-.34 3.08-.39c8.95-1.03 17.89-1.41 26.9-1.63 7.67-.2 15.29-.63 22.95-1.23 16.4-1.25 32.78-1.47 49.23-1.45"/><path fill="#3e3d43" d="M461 409h1l-.44 9.88-.12 2.78c-.2 4.5-.48 8.96-.97 13.43-.52 4.86-.59 9.65-.53 14.54l.01 2.46.05 5.91h2q.12 3.94.19 7.88l.07 2.26.03 2.16.05 2-.34 1.7-1.49.82C459 476 459 476 458.57 478.9q-.06 1.83-.08 3.65l-.06 1.94-.12 6.14-.1 4.17q-.13 5.1-.21 10.21a14.3 14.3 0 0 1-2.2-8.33l.02-2.42.06-2.5.02-2.55.1-6.2a24 24 0 0 0-1.32 7.23l-.12 2.43-.12 2.53-.13 2.56L454 504h-2a152 152 0 0 0-1.56 15.94L450 528q-3.09.08-6.19.13l-3.48.07c-3.25-.2-5.42-.8-8.33-2.2 5.11-1.74 9.84-.04 15 1l1-19h2c1.16-9.31 2.2-18.57 2.66-27.95.34-3.05.34-3.05 1.3-5.66 1.42-4.14 1.68-8.37 2.04-12.71l.25-2.73.75-8.64 1-11.26.25-2.78c.8-9.1 1.76-18.19 2.75-27.27"/><path fill="#0f0f16" d="M1321 890h1c.8 19.05 1.28 38.08 1.55 57.14l.03 2.16c.6 41.32.6 82.63.6 123.95q0 12.79.03 25.58a15119 15119 0 0 1 .03 39.8 3710 3710 0 0 1 .01 18.75v6.82l.01 2.02c-.03 4.55-.03 4.55-2.26 6.78-2.71.27-2.71.27-5.94.25l-3.21.02c-2.85-.27-2.85-.27-4.25-1.25-2.05-1.3-3.64-1.43-6.05-1.65l-2.57-.26-2.67-.23-2.7-.27-6.61-.61v-1c16.04-.35 16.04-.35 23.1 1.55 4.12.98 7.89.79 11.9-.55a185687 185687 0 0 0-.68-134.57l-.02-3a8088 8088 0 0 0-1.1-98.72l-.03-1.87A2095 2095 0 0 1 1321 890"/><path fill="#101016" d="M903 496v2a37 37 0 0 1-10.5 2.88c-4.55.7-8.83 1.75-13.19 3.18A58 58 0 0 1 864 507v2l-2.46.55a299 299 0 0 0-19.1 4.9c-2.56.58-4.83.65-7.44.55v2l-2.45.37-3.24.5-3.2.5c-3.11.63-3.11.63-5.66 1.67-3.56 1.4-7.03 1.4-10.83 1.62-2.62.34-2.62.34-4.5 1.31a20 20 0 0 1-6.67 1.81l-2.62.36-2.7.36c-9.93 1.31-9.93 1.31-13.49 2.59a17 17 0 0 1-7.01 1.03l-2.1-.05L769 529c2.3-2.3 3.43-2.51 6.54-3.22q1.36-.3 2.76-.64 1.41-.3 2.89-.64l5.6-1.28 2.72-.62q3.15-.75 6.25-1.7c4.34-1.2 8.7-1.74 13.15-2.34A39 39 0 0 0 817 516q3.45-.81 6.94-1.5c3.37-.66 6.6-1.34 9.84-2.49 3.6-1.13 7.13-1.69 10.86-2.25a155 155 0 0 0 29.04-7.25 125 125 0 0 1 18.7-5c8.12-1.58 8.12-1.58 10.62-1.51"/><path fill="#3e3e42" d="M809 165c-2.52 1.93-4.94 2.65-8 3.44l-2.96.77-3.04.79-5.56 1.56a163 163 0 0 1-12.5 2.84 367 367 0 0 0-13.77 3.14l-4.3 1.02a494 494 0 0 1-25.13 5.11q-2.12.41-4.22.88c-13.43 2.38-27.74 1.55-41.33 1.57l-2.42.01h-9A80 80 0 0 1 664 185v-1l1.5-.02q7.85-.1 15.72-.24l5.82-.09c31.14-.38 31.14-.38 43.96-4.65q1.87-.28 3.77-.46l2.2-.24 4.52-.46c3.44-.38 6.7-.82 10-1.84 4.06-1.25 8.12-1.6 12.32-2 9.26-.96 17.49-2.04 25.97-6.05 6.27-2.69 12.47-3.2 19.22-2.95"/><path fill="#5b5964" d="M434 1273c1.88 2.63 2.26 4.16 2.29 7.42l.04 2.7.01 2.91.05 3q.09 6.33.14 12.68l.13 9.26.01 2.9.04 2.7.03 2.38c.26 2.05.26 2.05 1.26 3.42 1.56 2.55 1.27 5.24 1.24 8.15v1.99q.01 3.26-.01 6.53v4.55l-.02 9.57q-.02 6.09-.02 12.18l-.02 13.92c-.01 7.97-.13 15.82-1.17 23.74h-1a73 73 0 0 1-1.54-12.23l-.13-2.22c-.57-10.94-.57-21.89-.65-32.84l-.06-7.19a16041 16041 0 0 1-.3-36.07z"/><path fill="#53525b" d="M397 1154h1a530 530 0 0 1 1.13 37.03v16.46c0 3.97-.15 7.63-1.13 11.51-2.83-2.83-2.35-5.62-2.41-9.41l-.06-2.22q-.08-3.5-.15-7l-.12-4.74q-.15-5.8-.26-11.63c-2.72 8.64-2.68 17.57-3.1 26.54q-.16 3.23-.34 6.45l-.08 2c-.3 5.62-.3 5.62-2.48 8.01-.24-14.38.18-28.42 1.77-42.72.27-2.7.43-5.4.58-8.1.92-17.45.92-17.45 5.65-22.18"/><path fill="#8e8e90" d="m808.13 924.94 4.87.06v1l-8 1 14 1v2l-1.68.24-7.57 1.13-2.64.39-2.56.39-2.35.35c-2.31.53-4.1 1.44-6.2 2.5-3.02.2-3.02.2-6.25.13l-3.27-.06L784 935v-2l9-2v-2l-13 3v2l-2 1 1 3-7 1v-1q-2.65-.08-5.31-.12l-3-.08c-2.69.2-2.69.2-4.13 1.2-2.54 1.62-5.33 1.2-8.25 1.13l-1.84-.03-4.47-.1v-1h8v-2h-6v-2c5.9-1.87 11.77-2.82 17.9-3.7l3.11-.45 9.8-1.41 18.76-2.72 2.45-.35c7.72-1.45 7.72-1.45 9.1-1.43"/><path fill="#13161c" d="M346 1466h2c.9 2.67 1.14 4.25 1.17 7l.04 2.45.03 2.68.04 2.84.18 15.76.15 13.55q.1 8.68.21 17.36l.24 19.75.1 8.96q0 1.3.04 2.66c.02 5.05-.41 9.24-2.2 13.99l-2 1c-2.12-6.62-2.12-6.62-1-10q.15-2.04.16-4.08l.03-2.51.02-2.75.03-2.9.14-16.07.14-15.55.17-19.45z"/><path fill="#909091" d="M921 900c-2 2-2 2-6 3l14 1v-3c2.52-1.26 4.31-1.1 7.13-1.06l4.87.06-1 4h-9v2l13-1c-3.77 1.88-5.68 2.33-9.75 2.63l-2.98.22-2.27.15-1 3c-2.15.68-4.1 1.14-6.3 1.54l-1.92.37q-3.04.6-6.1 1.15l-6.05 1.16-3.78.72c-3.27.62-5.9 1.54-8.85 3.06-2.25-.37-2.25-.37-4-1 3.98-2.8 6.98-3.44 11.81-3.69l1.81-.1L909 914v-3h-13v-3l13-1-11-1c3.03-2.02 4.73-2.25 8.25-2.65 1.75-.35 1.75-.35 4.19-1.91 3.54-1.99 6.59-1.7 10.56-1.44"/><path fill="#9aa6af" d="M1520 969h2l.15 1.53.72 6.85.24 2.4.26 2.3.22 2.13c.41 1.79.41 1.79 2.41 3.79a37 37 0 0 1 1.13 4.63l.87 4.37 5-1c0 3.7-.93 6.47-2 10l1.94.31 2.06.69 1 3 1.56 1.5c1.44 1.5 1.44 1.5 1.44 4.5l4 2v2l4 1 .13 1.75c1 2.6 1.61 2.88 3.93 4.25 1.64.98 1.64.98 2.94 2 .38 2.25.38 2.25 0 4l-3-1v-2l-2.19.38c-3.18-.43-3.37-.69-5.25-3.07l-1.24-1.66a25 25 0 0 0-4.44-4.21c-2.71-2.15-4.2-4.41-5.88-7.44l-2-1c-.82-1.6-.82-1.6-1.56-3.5-1.35-3.41-1.35-3.41-2.44-4.5q-.06-3 0-6l-4-1-.4-2.15-1.06-5.56C1520 988 1520 988 1519 987c-.13-1.66-.13-1.66-.13-3.78v-11.56c.13-1.66.13-1.66 1.13-2.66"/><path fill="#000003" d="M589.48 113.87h29.31c5.8.01 11.46.35 17.21 1.13v4h-2.5l-35.23-.08a5343 5343 0 0 0-22.1-.03l-1.86-.01c-4.2 0-4.2 0-5.31 1.12-7.53 2.06-15.24 2.54-23 3 1.03-1.93 1.03-1.93 3-4 2.46-.74 4.94-.93 7.5-1.13q2.02-.17 4.03-.38l1.8-.14c1.67-.35 1.67-.35 2.96-1.33 6.25-3.73 17.1-2.17 24.19-2.15"/><path fill="#676670" d="M1221 1638c-1.1 3.31-2.23 4.1-5.04 6.04-5.69 2.8-11.1 3.25-17.32 3.2h-2.47l-8.14-.01h-5.86l-15.86-.03-16.6-.01q-13.9 0-27.83-.04l-28.67-.04h-10.66L1009 1647v-1l2.31-.01a113066 113066 0 0 0 107.06-.63l12.52-.07q33.06-.16 66.11-1.29l-4-3 3.44-.15 4.56-.22 2.25-.1c4.53-.23 8.66-.86 13.02-2.11 1.73-.42 1.73-.42 4.73-.42"/><path fill="#07080e" d="m537.38 574.87 2.78-.02h6.29l14.25-.02 19.12-.02q8.9 0 17.8-.02a5652 5652 0 0 1 24.44-.03h10.2l3.03-.02c5.02.02 9.08.3 13.71 2.26v1a27216 27216 0 0 1-66.29.15 7261 7261 0 0 1-28.02.06q-5.4.02-10.81.02l-3.27.02-2.97-.01h-2.58C533 578 533 578 531 576c2.35-1.18 3.75-1.13 6.38-1.13"/><path fill="#15151c" d="m542.38 1597.63 1.93-.02q3.2 0 6.4.02l4.6-.01q6.22-.01 12.46.01h13.05l21.91.03q12.68.03 25.34.02a11642 11642 0 0 1 34.74.02 2809 2809 0 0 1 16.7.02q3.05 0 6.11.02h3.46c2.85.25 4.5.78 6.92 2.26-9.8.83-19.52 1.14-29.36 1.11H650.3l-12.5-.02q-11.82 0-23.62-.02l-26.9-.02L532 1601c3.41-3.14 5.8-3.37 10.38-3.37"/><path fill="#1d1d26" d="M756 1117c0 3.29-.18 3.6-2.2 5.93l-1.46 1.7-1.59 1.8-1.62 1.87q-5.7 6.45-11.75 12.58l-1.54 1.56c-3.6 3.56-3.6 3.56-5.84 3.56l-1 3-5 3a174 174 0 0 0-3.94 3.44 67 67 0 0 1-12.68 9c-2.76 1.81-4.36 3.98-6.38 6.56h-2v2l-2.81.88c-3.19 1.12-3.19 1.12-4.47 2.07-2.62 1.6-5.53 2.17-8.47 2.92-4.68 1.27-8.66 2.64-12.77 5.27-2 1.16-4.11 1.68-6.35 2.23a72 72 0 0 0-6.7 2.45 73 73 0 0 1-17.29 4.82c-2.14.36-2.14.36-4.14 1.36q-2.06.1-4.12.06l-2.2-.02-1.68-.04c3.58-3.58 8.04-4.02 12.88-4.75 5.04-.85 9.51-2.44 14.25-4.31 5.61-2.23 10.75-3.9 16.87-3.94v-2l2.67-1.02 3.52-1.36 1.75-.67 7.92-3.05c2.14-.9 2.14-.9 3.14-1.9q3-.06 6 0l.63-1.69c2.84-4.78 7.63-7.58 12.37-10.31 4.46-2.66 7.29-5.39 10.56-9.4 1.88-2.08 4.01-3.23 6.44-4.6a277 277 0 0 0 5-5l3.34-3.3 3.6-3.58 1.8-1.77c3.5-3.5 6.81-7.07 9.96-10.88a25 25 0 0 1 5.3-4.47"/><path fill="#39393d" d="M334 758h3l1 2 3 1c1.96 2.25 3.66 4.33 5 7l1.88.44c2.54.67 3.32 1.69 5.12 3.56a40 40 0 0 0 4 2v2l1.75.75c2.55 1.42 4.2 3.2 6.25 5.25a30 30 0 0 0 4 2v2l2.88.44C375 787 375 787 377 788v2l1.54.33A46 46 0 0 1 391 795v2l1.43.3c7.75 1.75 15.13 3.74 19.57 10.7q-2.38-.39-4.75-.81l-2.67-.46c-2.8-.79-4.32-1.96-6.58-3.73-3.25-.69-3.25-.69-6-1v-2l-2.37-.69c-3.95-1.3-7.54-3.25-11.22-5.15a36 36 0 0 0-5.97-2.28q-1.2-.44-2.44-.88l-1-3c-2.56-1.19-2.56-1.19-5-2v-2l-5-1v-2h-3v-2l-1.69-.19c-2.74-.96-4.14-2.52-6.1-4.62C347 773 347 773 345 772.06c-2.61-1.39-3.98-3.3-5.75-5.62-1.25-1.44-1.25-1.44-2.9-2.5L335 763v-2h-2z"/><path fill="#565559" d="m602.81 121.88 7.09.02q8.55.03 17.1.1v2l-2.79.06c-17.48.39-34.83.88-52.21 2.94l16 1 1 3h5v1h-33v-3l6-1c-10.72-.39-21.46 1.16-32 3a16 16 0 0 1 8.88-4.06c2.71-.45 5.12-.94 7.71-1.9 4.12-1.39 8.3-1.76 12.6-2.16l2.76-.25c11.94-1 23.9-.85 35.86-.75"/><path fill="#262529" d="m539 829 2.9.19a1097 1097 0 0 0 31.3 1.51l9.06.35a2054 2054 0 0 1 14.2.56l2.22.08c2.32.31 2.32.31 5.1 1.31 3.21 1 5.65 1.25 9 1.24h3.4l3.71-.01h3.93q5.33 0 10.64-.03l11.14-.01 21.07-.05 24-.04q24.66-.04 49.33-.1v-2h30v1l-1.94.17q-4.37.37-8.75.77l-3.05.26-2.96.26-2.71.24c-2.59.3-2.59.3-4.9.81-2.86.52-5.5.62-8.4.62l-3.47.02h-7.76l-17.65.02-23.86.02q-11.02 0-22.05.02a8639 8639 0 0 1-30.37.03c-16.68.05-33.09-.34-49.7-2.01-3.73-.35-7.46-.58-11.2-.8l-2.07-.11q-3.75-.23-7.48-.42c-7.7-.42-15.16-1.1-22.68-2.9z"/><path fill="#020206" d="M474 183c.63 1.88.63 1.88 1 4l-2 2q-.66 2.39-1.3 4.78c-1.45 4.58-3.65 8.88-5.7 13.22h-2l-.33 1.55A591 591 0 0 1 459 228h-2l-.33 2.17A292 292 0 0 1 452 253h-2l-1 7h-2a361 361 0 0 0-3.95 19.59c-.94 5.2-.94 5.2-2.05 7.41a319 319 0 0 0-.87 7.38A128 128 0 0 1 436 315h-1q-.12-3.31-.19-6.62l-.07-1.88c-.08-4.6 1.06-7.37 3.26-11.5.44-2.56.44-2.56.56-5 .22-2.98.48-5.1 1.5-7.94 1.42-4.64 1.1-9.24.94-14.06l-2 7h-1c-.3-5.07-.23-7 3-11 .69-3.25.69-3.25 1-6l1 3h2l.11-1.94c.38-4.91.96-8.96 3.13-13.4a32 32 0 0 0 1.82-5.53L451 237l2-1a69 69 0 0 0 4.88-17.62c.82-5.08 2.75-8.56 5.53-12.84a65 65 0 0 0 7.78-17.6C472 185 472 185 474 183"/><path fill="#000005" d="m1251 1050 1 3h2v111l-3 1z"/><path fill="#414246" d="m226.38 1019.9 6.34.05 5.28.05 1 2c2.92.3 5.79.42 8.72.48 4.88.2 4.88.2 7.18 2.01 3.32 2.39 6.86 2.08 10.85 2.2l2.4.1 5.85.21q.57 3 1 6c-2.06 2.06-6.17 1.23-8.94 1.25l-2.25.06-2.15.02-1.98.03c-1.68-.36-1.68-.36-2.66-1.85-1.02-1.51-1.02-1.51-2.8-1.94l-2.13-.08-2.3-.1-2.41-.08-2.43-.1-5.95-.21-2-4h-14l-1-5c1-1 1-1 4.38-1.1"/><path fill="#8042c6" d="M412 623h1a1944 1944 0 0 1 .15 16.3q.04 3 .05 5.97l.03 1.86c0 3.59-.61 5.73-2.23 8.87-.3 2.26-.51 4.42-.63 6.69l-.13 1.97-.37 6.34-.26 4.35c-.52 9-.81 17.97-.45 26.99.06 2.21.06 2.21-.16 5.66-1.51 1.22-1.51 1.22-3 2-1.52-1.52-1.16-2.89-1.2-5.01q0-1.29-.04-2.6l-.03-2.86-.04-2.97q-.05-4.8-.06-9.62l-.02-3.29c-.03-13.92.43-27.53 2.76-41.27l.34-1.99c1.04-5.95 2.3-11.68 4.29-17.39"/><path fill="#1e1e21" d="M351 756c2.16.97 3.79 1.78 5.44 3.5 1.56 1.5 1.56 1.5 3.85 2.38 2.91 1.2 5.16 2.7 7.71 4.56a84 84 0 0 0 7.81 5.16L378 773v2l1.6.3c4.83 1.02 6.94 2.13 10.4 5.7 1.76.8 3.57 1.4 5.4 2.02 3.42 1.29 6.68 2.85 9.98 4.42 14.37 6.8 14.37 6.8 21.43 8.56 3.64 1.14 5.4 2.47 8.19 5l-4.94-.72a100 100 0 0 0-4.02-.46c-5.08-.5-9.35-1.86-14.04-3.82l-2.02-.76A64 64 0 0 1 405 793l-1-3a89 89 0 0 0-5-1.43c-3.36-.96-6.36-2.6-9.44-4.2l-1.93-.98L383 781v-2l-1.64-.11c-6.6-.61-6.6-.61-9.36-3.89l-2.28-.91a40 40 0 0 1-7.53-4.09l-2.4-1.56L358 767v-2h-2l-1.37-2.81A44 44 0 0 0 351 756"/><path fill="#2d2639" d="m395.63 554.94 2.47.02 1.9.04v3l1.54.11c4.83.46 8.1 1.67 12.46 3.89 1.71.31 1.71.31 3.5.5 3.63.39 6.23 1.87 9.5 3.5 2.98.8 5.97 1.43 9 2v2l8 1v1c-5.21.37-9.1-.17-13.9-2.21-3.03-1.14-6.13-1.64-9.31-2.16L419 567l-1-3-3.25-.19c-5.98-.7-11.31-3.35-16.75-5.81v-2h-6a221 221 0 0 0-3.34 17.52c-.66 2.48-.66 2.48-2.2 4.4-2 2.86-1.83 5-1.9 8.46-.29 7.05-1.94 13.24-4.05 19.94a230 230 0 0 0-4.98 20.55C375 629 375 629 374 631.8a54 54 0 0 0-2 10.51c-.61 5-1.38 9.85-2.56 14.75A93 93 0 0 0 368 666h-2l-1 3a433 433 0 0 1-.1-5.96c.1-2.04.1-2.04 1.1-5.04h2l-.12-2.69c-.01-4.75.85-9.35 1.62-14.02.44-2.9.72-5.75.94-8.66a26 26 0 0 1 2.74-9.68c3.15-7.46 4.14-15.75 5.29-23.71.85-5.17 2.2-10.18 3.53-15.24q.76-3.18 1.47-6.38C384 576 384 576 385 574.7c1.54-2.57 1.67-5.18 2.07-8.13 1.13-8.2 1.13-8.2 1.94-10.56 2.4-1.2 3.95-1.1 6.63-1.06"/><path fill="#1a1921" d="m862 1237-3.56 2.69-2.22 1.67q-2 1.5-4.1 2.87c-5.54 3.64-10 8.4-12.12 14.77h-2a520 520 0 0 0 .59 8.1c.53 3.71 1.02 6.84 4.1 9.2l1.87.89c3.35 1.63 3.35 1.63 4.44 3.81 2.48 1 2.48 1 5.63 2l3.1 1q3.3 1 6.62 1.94a215 215 0 0 1 12.46 4.06l2.31.79 2.21.77 2 .7c1.67.74 1.67.74 3.67 2.74q2.39.9 4.83 1.62l2.92.9 6.09 1.83 2.93.9 2.67.8A33 33 0 0 1 914 1305v2c-11.03-1.64-11.03-1.64-16-5-2.1-.85-4.2-1.6-6.33-2.35C890 1299 890 1299 889 1298l-3.19-.31c-3.38-.43-5.39-1.62-8.16-3.64-4.48-2.84-9.87-3.93-15.02-4.86-8.71-1.84-17.79-7.58-24.63-13.19v-2h-2c-1.77-5.32-1.86-10.5-1-16 3.67-6.9 9.93-12.2 16-17l1.92-1.57c4.96-3.86 4.96-3.86 9.08-2.43"/><path fill="#929fab" d="m1582 1121 2 1c.34 2.3.56 4.5.69 6.81l.14 1.91c.22 3.36 0 6.05-.83 9.28q-.16 1.95-.21 3.9l-.09 2.2q-.08 2.26-.13 4.53c-.2 4.94-1.14 8.07-3.57 12.37-.19 2.38-.19 2.38 0 4h2v4h-3v6h4v-5l3-1c1.35-1.09 1.35-1.09 2.63-2.37l1.28-1.28c1.09-1.35 1.09-1.35 2.09-4.35h5l.81 2.38c1.17 2.59 1.72 3.38 4.19 4.62l-1 4h-3v-5c-4.7 2.67-4.7 2.67-6.56 4.75L1590 1175c-2.14-.25-3.46-.46-5-2v5l-2.37.25c-2.63.75-2.63.75-3.76 2.63l-.87 2.12c-1.31 1.75-1.31 1.75-3 3-2.69.19-2.69.19-5 0l-1 5h-5c.38-1.94.38-1.94 1-4l2-1 1.44-2.56 1.56-2.44h3l.38-1.94.62-2.06 2-1-.07-1.5c-.11-4.13.23-7.46 1.5-11.43 2.6-9.48 3.04-19.61 3.7-29.38l.26-3.73z"/><path fill="#7b7688" d="m782.19 567.94 3.29.02 2.52.04v1l-1.72.37c-4.14.9-8.21 1.87-12.28 3.06-12.95 3.69-24.63 4.73-38 4.57v2l-2.66.24-14.19 1.33c-6.75.62-13.44 1.24-20.1 2.47-15.83 2.75-32.12 2.09-48.11 2.02l-6.7-.01L628 585v-1l3.35-.04c20.43-.3 40.55-.8 60.81-3.6 4.75-.6 9.51-1 14.28-1.42a157 157 0 0 0 17.62-2.45c3.17-.53 6.3-.81 9.5-1.09a258 258 0 0 0 27.5-3.96l1.74-.34c16.3-3.2 16.3-3.2 19.39-3.16"/><path fill="#bec0c2" d="M559 66h77l1 5c-3.17 1.58-6.44 1.18-9.94 1.19l-2.2.03-2.14.01-1.94.01C619 72 619 72 617.67 71c-2.56-1.53-5.18-1.27-8.07-1.23h-1.91l-4.02.02q-3 .02-6.01.02c-7.33.03-14.56.34-21.86.96-3.34.27-6.64.4-9.99.42l-3.23.04C560 71 560 71 558 69z"/><path fill="#515054" d="M1594 701c-1.42 3.7-3.8 5.9-6.66 8.54C1586 711 1586 711 1585 714h-7l-1 3c-1.72 1.62-3.55 3.04-5.4 4.5-1.6 1.5-1.6 1.5-2.6 4.5h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.27-.9-2.27-.9-5-1-2.42 1.77-2.42 1.77-4.75 4.25l-2.36 2.45C1554 735 1554 735 1553 738h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2l1-4 4-1 .25-1.75c1-2.99 2.27-4.32 4.75-6.25 2.75-.25 2.75-.25 5 0l.29-1.8c.86-2.67 1.92-3.58 4.09-5.33l1.83-1.5c3.56-2.72 5.18-3.62 9.79-3.37l1-4 2.38-.25 2.62-.75c1.06-1.94 1.06-1.94 2-4 2.72-1.81 4.76-2.23 8-2"/><path fill="#0e0f14" d="M352 1039c1.07 3.12 1.13 6.05 1.13 9.32v29.22c-.04 55.82-.64 111.64-1.13 167.46l-3 1-1-3-2-1v-4l3 1 1.15-72.15a6826 6826 0 0 0 1.1-114.9v-5.63c-.27-2.54-.9-4.16-2.25-6.32-1.6-.55-1.6-.55-3.48-.6l-2.03-.1-2.12-.05-2.14-.09q-2.6-.1-5.23-.16v-1q3.93-.33 7.88-.62l2.26-.2 2.16-.16 2-.16c1.7.14 1.7.14 3.7 2.14"/><path fill="#32313a" d="M1259 1044h1v119h17c6.81 1.44 6.81 1.44 9.9 2.27 4.98 1.18 10 1.46 15.1 1.73v1q-5.85.09-11.69.13l-3.34.05q-1.6 0-3.23.02l-2.97.03c-2.95-.25-5.07-1.05-7.77-2.23-2.76-.31-5.5-.44-8.28-.56-3.48-.56-5.06-1.07-7.72-3.44-.77-3.35-.65-6.67-.6-10.1l-.01-3.14q0-4.28.04-8.54.03-4.5.02-8.97 0-7.54.06-15.07.06-8.68.07-17.36a5457 5457 0 0 1 .11-33.82v-2.94c.07-6.22.87-12 2.31-18.06"/><path fill="#3f291c" d="M947 1269c5.52.84 10.22 1.78 14.95 4.8 3.31 1.94 6.84 3.44 10.34 5a89 89 0 0 1 9.71 5.2q1.89.9 3.81 1.69L989 1287v2l2.52.59 3.3.79 3.26.77c2.92.85 2.92.85 4.4 1.85 2.06 1.36 4.29 1.78 6.66 2.42 3.01.94 5.7 2.41 8.49 3.9 9.43 4.86 19.25 8.46 29.37 11.68l-1.44-2.12c-2.73-5.04-2.68-10.12-2.62-15.76l.02-3.49.04-2.63h1v6h2l.77 3.14 1.04 4.11.5 2.07c1 3.9 2.1 6.51 4.69 9.68.19 2.75.19 2.75 0 5-5.11-.63-9.32-2.94-13.87-5.19a255 255 0 0 0-20.23-8.87c-9.67-3.7-9.67-3.7-11.9-5.94-2.03-.28-4.05-.45-6.09-.62C999 1296 999 1296 997 1294a68 68 0 0 0-4.44-1.5c-5.35-1.62-5.35-1.62-7.56-3.5v-2l-2.12-.31c-4.25-1.02-7.78-2.59-10.88-5.69q-2.05-.66-4.1-1.3c-2.35-.87-4.5-1.99-6.71-3.14A81 81 0 0 0 947 1271z"/><path fill="#c7cfd3" d="M1544 1137c1.14 6.03 1.19 11.93 1.19 18.03l.03 5.7c.06 11.77.06 11.77-2.22 17.27l-2 1c-.41 1.85-.41 1.85-.62 4.06l-.23 2.23-.15 1.71c-3.75 1.13-3.75 1.13-6 0-.19-2.87-.19-2.87 0-6l1.48-.94c1.52-1.06 1.52-1.06 1.92-2.9l.03-2.24.06-2.55.02-2.75.06-2.82q.07-4.46.12-8.92l.1-6.05q.13-7.42.21-14.83c2.5-1.25 3.41-.78 6 0"/><path fill="#523185" d="m1007.6 686.8 3.02.07 3.04.06 2.34.07-1 3h-10l-2 4c-2.35 1.23-2.35 1.23-5.06 2.19-4.89 1.76-4.89 1.76-5.94 2.81-2.96.54-5.94.97-8.92 1.4A41 41 0 0 0 975 703c-1.91.34-1.91.34-3.63.5-3.37.5-3.37.5-4.78 1.47-2.09 1.35-3.96 1.57-6.4 1.97-3.12.56-5.73 1.15-8.5 2.68-2.86 1.47-4.53 1.66-7.69 1.38l1.69-.75c2.75-1.49 5-3.13 7.31-5.25v-3l11-1 1-3 13-1v-3l2.34-.11 3.03-.2 3.03-.18 2.6-.51q.48-.75.98-1.5L991 690c2.75-.44 2.75-.44 6-.56 4.55-.18 6.62-2.34 10.6-2.64"/><path fill="#6a0900" d="M1453 1268c3.3 3.12 5.9 5.91 8 10v2h2a43 43 0 0 1 3.06 10.56c.88 5.21.88 5.21 1.46 7.31.53 2.34.61 4.44.61 6.83v8.33c-.01 13.94-.01 13.94-2.5 20.03l-.8 2q-.8 1.88-1.66 3.72c-1.46 3.16-2.28 5.66-2.48 9.16-.68 7.14-6.7 12.37-11.69 17.06.96-2.87 1.64-4.69 3.95-6.7 2.71-3.04 3.3-5.83 4.17-9.74l.95-3.93.46-1.9c.47-1.73.47-1.73 1.52-4.2 1.32-3.52 1.68-6.99 2.08-10.72l.25-2.36c.57-5.8.8-11.56.8-17.39l.01-2.3c-.03-3.28-.14-5.62-1.19-8.76q-.1-2.85-.06-5.69l.06-5.31h-2l-.59-1.46-.79-1.91-.77-1.9c-.85-1.73-.85-1.73-2.85-3.73-1.48-3.12-2.26-5.54-2-9"/><path fill="#504f53" d="m278 677 2 1c-3.37 4.23-6.28 7.27-11 10a52 52 0 0 0-4 5l4 1-2 1.1c-3.98 2.38-7.44 5.44-11 8.4-4.2 3.5-8.24 6.79-13 9.5l-2.62 2.25L238 717l-3-1 1-2-6 2c2.93-5.57 7.16-8.5 12.25-12.04a73 73 0 0 0 6.56-5.27c2.84-2.52 5.8-4 9.19-5.69 1.26-1.2 2.53-2.4 3.72-3.67 3.66-3.8 7.73-6.7 12.28-9.33q2.02-1.47 4-3"/><path fill="#46444a" d="M162 875h4v2l2.44.88C171 879 171 879 172 881l2-1v2h2l1 3q2.48 1.05 5 2l1 2 2-1v2l4 1v3h2l1-2v2h5v4h8l1 5 2-1q3-.06 6 0l1 4c-3.35 1.34-5.62.68-8.97-.42-2.03-.58-2.03-.58-4.21-.85-8.06-1-13.8-5.13-20.3-9.68q-1.84-1.26-3.72-2.47c-4.1-2.7-7.09-5.97-10.22-9.72-1.58-1.86-1.58-1.86-4.02-4.24C162 877 162 877 162 875"/><path fill="#828081" d="m1327 770-7 7c2.38.13 2.38.13 5 0l2-2c1.57-.69 1.57-.69 3.4-1.29l2.01-.66 2.09-.67 2.09-.7c5.13-1.68 5.13-1.68 7.41-1.68l-1 4h-6l-1 3h-2v2a103 103 0 0 1-18 6v-3q-2.4.43-4.81.88l-2.71.49c-2.33.6-3.55 1.29-5.48 2.63-2.69.13-2.69.13-5 0v-2l2-1-2-2c3.2-4.94 6.44-6.35 12-8l1.97-.59q2.5-.73 5.03-1.41c3.6-1.2 6.23-1.07 10-1m-10 10-1 2 8-1v-1z"/><path fill="#c4c3c2" d="M1114 982v1c-30.14 8.33-30.14 8.33-43.5 10.42-5.26.83-10.36 2.23-15.5 3.58a88 88 0 0 1-13.56 2.44c-4.37.42-10.2 1.32-13.44 4.56-2.34.14-4.66.04-7 0l.63 1.88.37 2.12c-2 2-2 2-5.12 2.13l-2.88-.13v-2l-4 1 2.59 1.24 3.35 1.63 1.7.82c4.25 2.08 4.25 2.08 5.36 4.31q2.97 1.1 6 2c-2.17.95-3.56 1.14-5.82.4q-2.62-1.13-5.18-2.4v-2l-2.12-.19c-3.8-1.07-6.04-3.16-8.88-5.81l1-3h5l.63-1.9c1.37-2.1 1.37-2.1 4.02-3.08l3.29-.64 1.77-.37c4.42-.9 8.87-1.65 13.32-2.42 2.83-.56 5.58-1.26 8.37-2a159 159 0 0 1 20.97-4.03 156 156 0 0 0 24.45-5.35c3.95-1.17 7.91-2.15 11.93-3.02l3.23-.71c3.06-.49 5.93-.57 9.02-.48"/><path fill="#3f3e47" d="m1287 940 1 2-1 2 2 1-.56 1.88c-.4 2.84-.33 4.87 0 7.68.91 8.21 1.55 16.02-1.45 23.84-1.31 3.45-2.4 6.95-3.49 10.48l-.65 2.08A382 382 0 0 0 1278 1008h-2l-.37 2.81-.3 1.92q-.35 2.48-.6 4.97c-1.04 8.3-1.04 8.3-5.1 11.49l-1.88 1.34c-2.54 2.14-3.62 4.64-4.93 7.64-.79 1.76-1.7 3.26-2.82 4.83-.47-4.79.47-7.29 2.92-11.21 1.37-2.28 2.35-4.64 3.36-7.1 3.3-7.78 3.3-7.78 5.72-10.69h2l.11-1.72.2-2.34.18-2.29a22 22 0 0 1 1.94-5.85c1.63-3.71 2.45-7.34 3.14-11.32l.38-2.14q.4-2.22.78-4.46.6-3.33 1.2-6.64a166 166 0 0 0 2.69-24.48c.16-4.55.98-8.41 2.38-12.76"/><path fill="#16161b" d="m1399 663 2 1-3.63 3.62q-1.8 1.82-3.56 3.7C1392 673 1392 673 1390 673l-1 3-2.37 1.88c-2.86 2.3-4.2 3.74-5.63 7.12a66 66 0 0 0 0 9c3.83 2.49 7.47 2.33 11.88 2.31l2.1.05c4.9 0 7.75-1 12.02-3.36 2.63-.66 2.63-.66 5.13-1.06 2.6-.43 4.63-.82 6.99-2 1.88-.94 1.88-.94 4.88-.94v-2a21 21 0 0 1 8.06-3.75c8.52-2.25 16.39-8.28 23-13.87A13 13 0 0 1 1461 667a159 159 0 0 1-8.44 8.13l-1.62 1.44c-2 1.48-4.07 2.52-6.3 3.6a60 60 0 0 0-4.91 2.85c-11.22 7.1-22.9 12.5-35.73 15.98l-2.08.63c-5.82 1.66-12.36 2.83-18.11.47a47 47 0 0 1-4.81-3.1v-3h-2c-.8-5.21.16-7.73 3.19-12.06q1.88-2.48 3.81-4.94l1.3-1.76c2.81-3.68 6.17-6.27 9.93-8.94 1.77-1.3 1.77-1.3 3.77-3.3"/><path fill="#6a6c6e" d="M422 1801c5.23-.38 8.38.78 13 3v3l3.44.38c1.93.2 1.93.2 3.56.62l1 2c2 .38 2 .38 4.44.56 4.37.35 4.37.35 6.56 1.44v3h10q.06 3 0 6c-1 1-1 1-2.85 1.1l-2.21-.04-2.23-.02-1.71-.04-2-4h-10l-2-4c-4.68.62-4.68.62-6.31 2.56L434 1818l-7-1v-3h8l-2-4c-4.68.62-4.68.62-6.31 2.56L426 1814l-4-1v-3h5l-1.37-2.25A89 89 0 0 1 422 1801"/><path fill="#504f5a" d="M1266 1035c2 2 2 2 2.23 4.14l-.03 2.68-.01 3.02-.05 3.28-.03 3.4-.07 7.04-.09 9.51a5364 5364 0 0 1-.24 24.58l-.17 16.96-.18 17.42-.36 33.97h-2l-1-118a22 22 0 0 0-1.2 7.56l-.07 2.8-.1 6.08-.32 16.35-.31 16.21h-1q-.15-10.04-.22-20.07l-.08-6.82q-.07-4.91-.1-9.83l-.05-3.06c0-6.32.93-10.9 4.45-16.22z"/><path fill="#828283" d="M1168 838v4l-1.66.41-7.53 1.9-2.62.65c-4.59 1.18-8.15 2.5-12.19 5.04-2.72.66-2.72.66-5.5 1.06-3.78.56-6.99 1.4-10.5 2.94v-2q-2.44.17-4.87.38l-2.75.2c-2.38.42-2.38.42-4.38 2.42-2.16.2-2.16.2-4.62.13l-2.48-.06-1.9-.07v-2c11.35-5.2 11.35-5.2 16.63-5.5 4.64-.28 8.02-2.11 12.08-4.29 4.4-2.33 8.24-3.5 13.29-3.21v1c7.07.27 7.07.27 10-2a30 30 0 0 1 9-1"/><path fill="#000004" d="M536 770h132l-2 3c-2.3.37-2.3.37-5.21.36l-3.35.01-3.7-.03h-3.87q-5.26 0-10.53-.04l-11-.02q-10.4 0-20.83-.06-11.85-.05-23.72-.07-24.4-.04-48.79-.15z"/><path fill="#22133c" d="M964 710h-3v2l-14 2v2c-11 3.03-22.05 5.65-33.2 8.08l-6.06 1.33-1.83.4A57 57 0 0 0 896 729q-2.46.55-4.94 1a45 45 0 0 0-7.85 2.02c-3.9 1.2-7.53 1.43-11.59 1.6l-2.23.12q-2.7.14-5.39.26v2a3834 3834 0 0 1-20.82 4.64c-7.97 1.72-16.09 2.48-24.18 3.36 4.31-2.97 8.14-4.19 13.38-4.44 3.3-.24 5.93-.47 8.87-2 4.12-2.05 8.55-2.34 13.07-2.83 3.98-.44 7.74-1.2 11.61-2.22 4.33-1.06 8.75-1.68 13.15-2.37l2.19-.36 1.96-.3c1.77-.48 1.77-.48 3.16-1.44 2.26-1.46 4.48-1.83 7.1-2.4l3.31-.75 1.74-.38q4.44-.97 8.87-1.99l1.75-.4A280 280 0 0 0 928 717l3.57-1.02a249 249 0 0 0 8.12-2.48c4.86-1.48 9.72-2.32 14.74-3.07 7.12-1.2 7.12-1.2 9.57-.43"/><path fill="#3b3a40" d="m567.56 163.75 2.4-.03c8.24.05 15.5 1.04 22.04 6.28l1 2-2.02-.47a49 49 0 0 0-17.02-.82c-3.42.33-6.77.3-10.21.23-4.95-.07-9.12.23-13.75 2.06l-1 1c-2.34.14-4.66.04-7 0v3l-2.69.69c-3.76 1.21-5.63 3.48-8.31 6.31h-2c3.5-7.9 11.45-14.12 19-18 6.24-2.39 12.97-2.18 19.56-2.25"/><path fill="#4f4e59" d="M433 1299h1l.5 39.55.2 16.76c.1 7.82.25 15.64.54 23.45.64 17.45.9 34.87.86 52.33v10.6l-.03 19.92-.02 22.75L436 1531h-1l-1-126-1 16h-1q-.18-45.12.5-90.25l.04-2.4q.22-14.67.46-29.35"/><path fill="#8b8b92" d="M1300 1700h2c.97 3.97 1.57 6.62-.5 10.25l-2.5 3.75-1.37 2.43c-3.49 6-7.5 10.84-12.38 15.76l-1.72 1.75-1.64 1.6-1.47 1.44C1279 1738 1279 1738 1276 1738c.75-6.75.75-6.75 3-9 .6-1.88.6-1.88 1.07-4.13l.53-2.44.53-2.56.55-2.57 1.32-6.3h3l3 7c1.43-2.35 2.09-3.48 1.63-6.25l-.63-1.75c3 1 3 1 4 3l1-5h3l-.12-2.87c.12-3.13.12-3.13 2.12-5.13"/><path fill="#454349" d="M169 780c-.38 6.13-2.28 9.23-6 14a82 82 0 0 0-4.56 8.19C157 805 157 805 155 807a90 90 0 0 0-1 6h-1l-1-7c-2.72 2.34-3.66 4.32-4.62 7.75l-.73 2.48c-1.86 7.91-1.77 15.64-1.71 23.7l.01 3.81.05 9.26c-2.89-3.22-3.37-5.28-3.33-9.57l.02-3.22.06-3.34.02-3.36c.08-8.2.08-8.2 1.23-10.51q.44-2.77.81-5.56c.93-5.7 2.5-10.35 5.19-15.44l1.2-2.27q1.85-3.4 3.8-6.73l1.18-2.17c3.1-5.4 7.14-10.83 13.82-10.83"/><path fill="#757678" d="M1145.13 5.94 1149 6l2 4h9l1 3 1.68.08c2.18.17 4.2.38 6.32.92 1.5 2.06 1.5 2.06 2 4l1.69-.87A64 64 0 0 1 1180 14l2 1v3h-6l1 3 1.75.75c3.43 1.9 5.14 5.01 7.25 8.25-7.24-.35-7.24-.35-10.19-2.87L1174 25c-2.92-2.13-5.14-3.12-8.69-3.5L1162 21l-.93-1.5c-1.07-1.5-1.07-1.5-3.16-1.94l-2.41-.12c-4.3-.23-4.3-.23-6.5-2.44a101 101 0 0 0-5.12-2.12l-2.76-1.08-2.12-.8V7c2.29-1.14 3.6-1.1 6.13-1.06"/><path fill="#323139" d="M773 1730c2.82.08 4.51.5 6.5 2.55q1.47 1.86 2.88 3.76c2.61 2.72 5.51 3.58 9.23 3.94h2.85l3.28.01 3.57-.02h14.05l10.75-.01 18.05-.02q10.43-.03 20.85-.02a16632 16632 0 0 0 47.4-.06h2.85c2.98-.14 5.81-.59 8.74-1.13v2c-6.72 2.01-12.94 2.33-19.93 2.38l-3.56.05a1930 1930 0 0 1-15.6.17l-21.03.2q-9.77.08-19.52.21-8.41.11-16.83.18l-10.02.1q-4.72.08-9.43.1-2.53 0-5.06.07c-9.8 0-18.57-2.2-25.7-9.15l-2.52-2.38C773 1731 773 1731 773 1730"/><path fill="#686870" d="M1276 1625h1l.15 2.97c.36 6.75.84 13.4 1.85 20.1 2.25 15.65 2.1 31.6 2.18 47.39q0 2.4.03 4.79c.14 17.25.14 17.25-2.21 23.75l-1 1q-.3 2.7-.5 5.44c-.39 5.34-.39 5.34-1.5 7.56 9.98-5.54 16.43-14.3 22-24l1.18-2.04 1.91-3.39c1.51-2.6 3.21-5.08 4.91-7.57 1 3 1 3 .44 4.62l-.95 1.74-1.05 1.94-1.13 2.01-1.07 1.98c-3.34 6.1-6.6 11.48-11.93 16.09l-2.64 2.3-1.34 1.15q-1.96 1.73-3.88 3.53l-2.39 2.2-2.15 2C1276 1742 1276 1742 1273 1742l2.22-14.02.8-5.12.5-3.11c.48-2.75.48-2.75 1-4.6.56-2.53.61-4.85.63-7.45l.02-6.66.02-11.03q0-4.76.03-9.52v-7.36l.03-5.2c-.01-5.79-.5-10.44-2.25-15.93-.23-3.27-.23-3.27-.2-6.59l.01-1.79q.02-2.77.07-5.56l.02-3.8q.03-4.63.1-9.26"/><path fill="#26252a" d="M1130 748a139 139 0 0 1-18.4 7.07 23 23 0 0 0-5.6 2.93l-2.37-.06c-2.63.06-2.63.06-4.82 1.37-3.36 2.02-6.65 2.92-10.4 3.94-2.3.72-4.27 1.66-6.41 2.75q-1.67.55-3.37 1c-4.03 1.1-7.98 2.33-11.85 3.91a91 91 0 0 1-17.72 4.9l-2.22.41c-6.43 1.09-6.43 1.09-9.84-.22l7.06-2.87 2-.82c3.72-1.5 7.41-2.8 11.3-3.85a85 85 0 0 0 4.6-1.47c2.9-.94 5.77-1.66 8.75-2.33 5.4-1.24 10.4-2.46 15.29-5.16 6.91-3.82 16.06-6.5 24-6.5v-2l1.72-.37c3.75-.81 7.44-1.65 11.1-2.82 3.16-.8 4.2-.72 7.18.19"/><path fill="#5e5d65" d="M1235 1630c-2.24 5-5.15 7.46-10 10-2.81.69-2.81.69-5 1l1-3-1.67.64c-9.89 3.27-19.84 2.76-30.13 2.7h-6.3q-7.64 0-15.26-.03-8.89-.03-17.78-.03-15.84-.01-31.7-.05l-30.76-.07h-11.45q-39.47-.06-78.95-.16v-1h3.21a33433 33433 0 0 0 85.1-.15h1.88q15-.04 30.02-.05 15.43 0 30.87-.06l17.31-.03q8.18.02 16.34-.04h5.95c15.11.07 29.12-1.54 42.56-8.89 1.76-.78 1.76-.78 4.76-.78"/><path fill="#1f2326" d="m337.38 1039.63 2.94-.12c7.05-.09 7.05-.09 9.83 2.45 1.14 4.09 1.21 7.81 1.1 12.04l-.02 2.4a48 48 0 0 1-.71 9c-.57 2.85-.8 5.62-.98 8.52l-.24 3.36-.35 5.24q-.16 2.55-.35 5.09l-.2 3.06-.4 2.33-2 1v-47c-2.24-1.12-3.43-1.12-5.91-1.1l-2.3.01-2.42.03-2.42.01-5.95.05a88 88 0 0 1-1-5c2.38-2.38 8.18-1.33 11.38-1.37"/><path fill="#191921" d="M488 1769q1.8-.09 3.63-.12l2.03-.08c2.34.2 2.34.2 4.78 1.2 4.8 1.63 9.82 1.29 14.84 1.28l3.45.02 9.47.02 10.22.03 17.7.05 25.59.06 41.52.12 40.33.1 2.5.01 12.48.04q51.73.15 103.46.27v1a101712 101712 0 0 1-118.97.16h-2.53l-40.48.07q-20.77.05-41.56.05-12.82 0-25.63.04-8.8.03-17.6.02-5.06 0-10.13.02-5.51.01-11.03 0l-3.2.03c-7.31-.05-13.85-1.34-20.87-3.39z"/><path fill="#35343a" d="m898.07 899.9 2.5.04 2.5.02 1.93.04c-3.85 3.94-8.56 3.77-13.75 4.19l-2.7.25q-3.27.3-6.55.56v2c-2.85.95-4.98 1.2-7.96 1.38-6.12.52-11.8 1.94-17.73 3.56-7.7 2.02-15.2 3.64-23.13 4.44-5.34.56-10.1 1.94-15.18 3.62-10.19 3.09-20.43 4.07-31 5 2.72-2.72 5.55-2.81 9.2-3.4l4.41-.76q3.48-.6 6.95-1.16 3.36-.56 6.7-1.14l2.08-.34c3.24-.56 5.98-1.24 8.9-2.78 2.57-1.32 3.82-1.79 6.63-1.71l1.99.03 2.02.07 2.08.04 5.04.15v-2l10-2v-2l-9-1v-1l18-1 1-3 14-1v2l-5.37.59-1.63.41-1 2a712 712 0 0 0 9.08-1.37c2.92-.63 2.92-.63 4.46-1.67 2.24-1.47 4.43-1.36 7.09-1.52 3.22-.21 5.56-1.4 8.44-1.54"/><path fill="#010104" d="M441 267h1q.08 2.8.13 5.63l.07 3.16c-.21 3.41-1.08 6-2.2 9.21-.57 3.35-1 6.7-1.44 10.07-.56 2.93-.56 2.93-1.58 4.35-1.26 2.02-1.23 3.38-1.25 5.75l-.07 2.46-.05 5.06c-.15 5.32-1.1 8.86-3.7 13.5-1.3 2.57-1.52 4.95-1.66 7.81l-.17 2.88L430 339l-3 1q-.09-4.94-.12-9.87l-.06-2.84-.02-2.72-.03-2.5C427 320 427 320 429 318c.38-1.74.38-1.74.56-3.8l.23-2.27.21-2.37q.2-2.31.44-4.62l.18-2.07c.42-2.07 1.22-3.13 2.38-4.87.41-1.9.41-1.9.63-4.01l.26-2.3.24-2.38.26-2.41.61-5.9h2l.37-2.55.5-3.32.5-3.31C439 269 439 269 441 267"/><path fill="#0d0d13" d="m1474 578 2 1-1.31 1.56a37 37 0 0 0-3.86 7.56c-1.36 3.1-3.1 5.98-4.83 8.88h-2l-.56 2.5c-1.6 4.72-5.09 7.93-8.44 11.5l-6.3 6.85a156 156 0 0 0-9.05 11.1 57 57 0 0 1-5.21 5.55 34 34 0 0 0-5.94 7.56c-5.29 8.37-14.27 16.44-22.5 21.94l-1.94.38c-3.13.94-4.91 3.27-7.06 5.62a89 89 0 0 1-4 4h-2l-1 3q-1.19.9-2.44 1.81c-3.1 2.66-3.72 4.22-4.56 8.19a81 81 0 0 0 0 8c16.15.38 16.15.38 24-2-4.93 3.78-7.74 4.2-13.81 4.25l-2.16.06c-4.45.04-7.51-.28-11.03-3.31-1.08-3.06-.7-5.86 0-9 3.44-7.03 10.2-11.98 16-17l2.47-2.28 2.34-2.16 2.08-1.93c2.11-1.63 2.11-1.63 4.64-2.63 2.75-1.11 4.18-2.37 6.22-4.5l1.8-1.84C1417 651 1417 651 1418 649h2v-2h2l.75-1.75c1.45-2.6 3.1-4.16 5.25-6.25q3.54-3.97 7-8l3.5-4 1.74-1.99 3.62-4.12c8.87-10.08 8.87-10.08 10.14-13.89h2l.81-1.94C1458 603 1458 603 1461 602q1.1-2.97 2-6a45 45 0 0 1 3-6l3-1c.63-1.74.63-1.74 1-3.87.68-3.22 1.23-5.12 4-7.13"/><path fill="#838386" d="M859 184c2.06.44 2.06.44 4 1-2.63 3.3-6 4.25-9.87 5.5-4.41 1.44-8.18 3-12.13 5.5q-2.48 1.08-4.98 2.1c-2.4 1.06-4.66 2.3-6.96 3.53a27 27 0 0 1-11.38 3.19c-2.18.23-3.78 1.12-5.68 2.18h-3v2c-4.32 1.5-8.6 2.95-13.06 3.94a27 27 0 0 0-7.38 2.87c-4.25 2.25-8.72 3.62-13.31 5l-2.26.7-2.16.63-1.94.58c-2.22.33-3.77-.05-5.89-.72l5.38-2 3.02-1.12c2.6-.88 2.6-.88 4.6-.88l1-5h8l1-2h-10v-1l2.08-.11 2.73-.2 2.71-.18c2.85-.59 3.55-1.44 5.48-3.51 3.19-.19 3.19-.19 6 0v1h-5v3a168 168 0 0 0 3.88-.31l2.17-.18c1.95-.51 1.95-.51 2.86-2.02 1.09-1.49 1.09-1.49 3.62-1.93l2.97-.12c5.3-.23 5.3-.23 7.5-2.44q3.48-1.09 7-2c7.94-2.12 7.94-2.12 11-4l1-2c1.68-.42 1.68-.42 3.88-.75 5.12-1.01 9.1-3.27 13.6-5.86A40 40 0 0 1 858 185z"/><path fill="#262731" d="m1056 1172-1 2c-1.76.47-1.76.47-4.04.78l-2.5.36-2.65.36a84 84 0 0 0-15.81 3.5c-2.61.43-5.24.68-7.87.94-5.02.55-9.4 1.24-14.04 3.28-2.8 1.05-5.67 1.58-8.59 2.15a126 126 0 0 0-15 4c-7.45 2.46-15.01 4.19-22.7 5.75a61 61 0 0 0-14.91 5.1c-1.89.78-1.89.78-4.89.78v2l-5.19 1.44-2.92.8q-3.91 1.02-7.89 1.76c1.07-2.02 1.72-2.88 3.85-3.8l2.21-.64a94 94 0 0 0 11.72-4.58c3.35-1.48 6.77-2.74 10.22-3.98v-4h7l-1 3q1.04-.38 2.13-.74a297 297 0 0 1 22.04-6.55l5.38-1.42 3.48-.9 3.11-.81c2.72-.55 5.1-.7 7.86-.58l1-2q2.74-.4 5.49-.7c4.97-.6 9.86-1.73 14.76-2.74 22.93-4.71 22.93-4.71 30.75-4.56"/><path fill="#4c4b56" d="M1261 1482h4v73l-2-3-1 18h-1z"/><path fill="#bcbcbc" d="M1061 992v1l-1.54.34-7.02 1.53c-.4.1-.4.1-2.43.53-3.62.8-7.2 1.63-10.77 2.67a78 78 0 0 1-8.55 1.93l-2.83.5q-2.85.5-5.72.96l-2.7.48-2.44.4c-2.4.79-2.74 1.57-4 3.66-2.62.69-2.62.69-5 1 1.4 3.48 2.64 4.65 6.06 6.13 3.84 1.68 3.84 1.68 4.94 3.87 1.97.71 1.97.71 4.5 1.38a81 81 0 0 1 12.18 4.57c4.05 1.83 8.19 3.43 12.32 5.05-3.15 1.04-4.78 1-7.81-.31a59 59 0 0 0-7.57-2.63c-6.1-1.69-12.17-3.45-16.62-8.06-1.58-.77-1.58-.77-3.25-1.37-6.37-2.59-10.52-6.27-14.75-11.63a261 261 0 0 0-5-3v-2l1.57.04c3.96.05 7.56-.07 11.43-1.04v3h-3c5.6 1.25 5.6 1.25 7.5.06l1.5-1.06h6q3-.96 6-2c2.31-.5 4.64-.92 6.96-1.36 2.32-.49 4.57-1.04 6.86-1.66 7.88-2.13 14.93-3.6 23.18-2.98"/><path fill="#585453" d="M1300 1353a29 29 0 0 1 8 11.13c1 1.87 1 1.87 2.62 3.49 1.38 1.38 1.38 1.38 1.38 3.38l1.69.81c4.75 2.44 8.22 5 11.9 8.84 1.64 1.57 3.36 2.4 5.41 3.35q.45.74.94 1.5c1.56 2.2 3.57 2.53 6.06 3.5l2.26 1.18c12.99 6.71 28.34 7.78 42.74 6.82l2-1q2.06-.1 4.13-.06l2.19.02 1.68.04c-2.96 2.96-5.74 3.9-9.9 4.24l-3.17-.01h-3.47l-1.78-.02q-2.7-.03-5.42-.02c-8.84-.05-8.84-.05-12.26-1.19q-3.09-.33-6.19-.56l-3.29-.26-2.52-.18-1 2-.08-2.28c-.92-2.72-.92-2.72-3.87-4.56q-1.86-.86-3.74-1.66c-4.94-2.17-9.63-4.43-13.31-8.5v-2l-1.69-.81c-9.36-4.81-16.91-12-20.31-22.19q-.56-2.49-1-5"/><path fill="#a69abf" d="M799.79 568.8c2.15-.1 4.09-.17 6.21.2 2.06 2 2.06 2 3 4-1 1-1 1-2.58 1.11l-2.02-.01-2.18-.01-2.28-.03L792 574v4l-2.23-.05-8.33-.11-3.58-.07c-7-.15-13.05.13-19.66 2.58-2.49.74-4.62.76-7.2.65l3-2-18-1v-1l2.01-.14c11.64-.8 22.76-1.6 33.99-4.86 9.24-2.09 18.38-2.53 27.79-3.2"/><path fill="#000001" d="m560 74 41.66-.08a6519 6519 0 0 1 24.41-.03l2.05-.01c4.65 0 4.65 0 6.88 1.12v3h-75z"/><path fill="#504f5c" d="m484 1181 3 1-.49 1.64c-.6 2.77-.65 5.32-.67 8.16l-.01 1.74-.07 9.9-.06 11.24-.08 11.73-.14 22.23-.16 25.3L485 1326l-2 1a85626 85626 0 0 1-.08-82.97 22454 22454 0 0 1-.03-46.49l-.01-9.91v-3.24c.12-2.39.12-2.39 1.12-3.39"/><path fill="#9d9d9d" d="M266 959h23v3l3.11-.07c8.7-.14 17.09-.04 25.49 2.56 6.7 1.96 13.48 1.96 20.4 2.13l3.8.12q4.6.14 9.2.26v3c-15.03.46-29.7.15-44.62-1.81l-1.97-.25c-6.92-.9-13.52-2.12-20.18-4.28-3.39-1-6.79-1.38-10.3-1.72A93 93 0 0 1 267 961z"/><path fill="#a879dd" d="M459 608h1q.3 5.2.54 10.4.13 2.64.28 5.27l.16 3.37.16 3.1c-.15 3.07-.96 5.04-2.14 7.86-.4 2.8-.4 2.8-.6 5.82l-.24 3.31-.22 3.43-.48 6.76-.2 3.01c-.23 2.34-.69 4.4-1.26 6.67-1.13 9.74-1.24 19.57-1.45 29.37q-.14 6.6-.3 13.19l-.06 2.66q-.3 12.65-.75 25.28l-.07 1.96L453 750h6v1h-7c-2.44-3.75-2.3-7.49-2.25-11.81l.03-2.4c.12-6.85.48-13.67.95-20.51l.68-10.17.14-2.2q.8-12.26 1.51-24.5l.96-15.78.8-13.47 1.1-18.34c.96-16.5.96-16.5 3.08-23.82"/><path fill="#b9b8b8" d="M1165 967v2l-2.16.59-2.9.78-2.85.78c-4.6 1.26-9.18 2.55-13.72 4-10.83 3.4-21.72 5.5-32.9 7.42a275 275 0 0 0-28.34 6.72 65 65 0 0 1-19.44 2.77L1055 992v-1l2.87-.58 15.4-3.13a474 474 0 0 0 26.28-5.94c4.35-1.13 8.7-1.98 13.14-2.73 7.46-1.29 14.6-3.4 21.85-5.56a66 66 0 0 1 16.37-2.85c2.18-.22 3.5-.72 5.4-1.77 3.13-1.67 5.2-1.69 8.69-1.44"/><path fill="#19181f" d="M1266 1023c0 3.27-.7 5.12-1.94 8.13l-1.09 2.69c-.97 2.18-.97 2.18-1.97 3.18-3.54 13.95-3.4 27.91-3.33 42.21v7.18l.05 14.93q.04 9.58.04 19.15a9210 9210 0 0 0 .08 31.66v2.96l.02 2.68v2.34c.14 1.89.14 1.89 1.14 3.89l-4-1q-.1-21.99-.16-43.97-.01-10.2-.07-20.42-.05-8.9-.05-17.81 0-4.71-.04-9.42-.03-5.28-.02-10.55l-.03-3.1c.04-9.22 1.4-18.34 6.46-26.2.96-1.6 1.5-3.11 2.04-4.9l.87-2.63z"/><path fill="#010104" d="M1296 346h2c.26 2.91.36 4.48-1.34 6.93l-1.88 1.81-2.08 2.05-2.2 2.08-2.1 2.07A318 318 0 0 1 1274 374l-1.58 1.37c-3.69 3.2-7.67 5.95-11.7 8.72a156 156 0 0 0-5.63 4.14l-1.47 1.11-4.08 3.16c-2.8 1.66-4.34 1.83-7.54 1.5v-3l1.94-.31 2.06-.69 1-3c1.74-1.39 1.74-1.39 3.88-2.69l2.11-1.32c2.01-.99 2.01-.99 5.01-.99l.24-1.86c.76-2.14.76-2.14 2.62-3.38l2.33-1.07c4.24-2.16 6.68-4.48 9.42-8.33 1.39-1.36 1.39-1.36 3.56-1.56l1.83.2v-3l1.88-.25c2.12-.75 2.12-.75 3.37-2.81l.75-1.94h2l.63-1.75c1.64-2.69 3.34-3.62 5.97-5.23 1.88-1.37 2.63-2.86 3.4-5.02"/><path fill="#202026" d="m978 160 2 1-3.5 3.06-2.2 1.94a425 425 0 0 1-4.44 3.81c-3.18 2.72-6.12 5.36-8.74 8.63L959 181h-2l-.62 2.06c-3.63 7.74-10.88 13.62-17.2 19.19-4.1 3.63-7.76 7.5-11.3 11.7-1.88 2.05-1.88 2.05-4.35 4a43 43 0 0 0-6.72 6.99l-2.04 2.52A32 32 0 0 0 911 235a43 43 0 0 1-5.5 6.38c-1.93 2.09-3.17 4.4-4.54 6.9-1.85 3.3-1.85 3.3-2.96 4.72h-2l-.71 1.98c-1.69 3.95-3.78 7.6-5.91 11.33l-1.28 2.25L885 274c-1-3-1-3 .2-5.8q.88-1.6 1.8-3.2l.89-1.54a256 256 0 0 1 3.92-6.42q4.05-6.46 8.5-12.67 1.65-2.3 3.23-4.65c6.38-9.42 13.78-18.08 21.04-26.83l2.17-2.64 1.86-2.23c1.42-2.06 1.8-3.63 2.39-6.02 2.38-1.19 2.38-1.19 5-2l3-1v-2c1.63-1.73 3.28-3.36 5-5l2.44-2.5a74 74 0 0 1 5.56-5 96 96 0 0 0 11.25-11.22c3.62-4.1 7.02-7.46 11.75-10.28 1.88-1.75 1.88-1.75 3-3"/><path fill="#353639" d="m412 162 2 1v8l2.44-.56L419 170c1 1 1 1 1.1 2.63L420 178l-3 1 .04 1.9.02 2.47.04 2.47C417 188 417 188 416 190h-3l.06 4.88.04 2.74C413 200 413 200 412 202h-2l.19 2.81c-.19 3.19-.19 3.19-1.38 5.06-2.37 1.48-4.07 1.35-6.81 1.13v-8l4-2v-10l4-2v-10l4-2c-.62-4.68-.62-4.68-2.56-6.31L410 170c.88-6.87.88-6.87 2-8"/><path fill="#87868e" d="M425 1476h1l.08 52.44.03 22.17c.05 20.5.05 20.5-1.11 29.39l-2-1a18366 18366 0 0 1-.15-54.46 5614 5614 0 0 1-.06-23.02l-.02-8.91-.02-2.65c.02-5.04.63-9.18 2.25-13.96"/><path fill="#353537" d="M363.19 434.94 365 436c.34 23.18.34 23.18-3 33l-1 4h-2l-.25 2.25C358 478 358 478 354 482v-19l4-2v-26c3-1 3-1 5.19-.06"/><path fill="#737275" d="M395 948a19343 19343 0 0 1 44 .65l3.9.06c6.34.08 12.66.24 18.98.66 9.13.6 18.22.77 27.37.74h16.97l12.98-.02q12.26 0 24.5-.02l27.93-.02L629 950v1c-33.17 1.12-66.33 1.19-99.5 1.19q-7.7 0-15.37.02c-30.02.07-60-.02-90-1.16l-3.49-.13q-4.8-.18-9.63-.4l-2.83-.1c-4.47-.21-8.78-.56-13.18-1.42z"/><path fill="#b0b8c0" d="M1533 998c2.44.81 2.44.81 5 2l1 3 3 1v-6h3c.93 3.01 1.04 3.87 0 7-2.06.69-2.06.69-4 1 2.18 4.01 4.83 7.06 8.1 10.2l2.98 2.91 4.69 4.54 4.52 4.42 1.43 1.36a60 60 0 0 1 3.28 3.57v3l-6-1v-2l-4-1v-4l-2.19-.69a15 15 0 0 1-5.43-3.87C1546 1021 1546 1021 1543 1020c-3.34-2.75-4.52-4.7-5-9l-3-1-1-3-3-1c-.12-2.87-.12-2.87 0-6z"/><path fill="#57585a" d="M356 487q.09 4.3.13 8.63l.05 2.44c.03 4.81-.6 8.35-2.18 12.93-.38 3.6-.41 7.2-.48 10.81-.17 4.65-.9 7.23-3.52 11.19-.51 2.46-.51 2.46-.69 4.81l-.2 2.4L349 542h-1l-.18-1.71-.26-2.23-.24-2.21C347 534 347 534 346 533c-.28-3.3-.23-6.62-.25-9.94l-.06-2.82-.02-2.7-.03-2.5.36-2.04 1.49-.94c1.51-1.06 1.51-1.06 1.94-3.49l.08-3.02.1-3.26.08-3.42.1-3.44q.12-4.2.21-8.43c3-1 3-1 6 0"/><path fill="#86848d" d="M397 1270h1a90 90 0 0 1 1.12 15.24v16.22l-.02 15.63v16.34l-.05 55.64v10.5L399 1472h-2l-.01-2.14a89690 89690 0 0 0-.63-98.86l-.07-11.56c-.1-19.92-.25-39.83-2.1-59.68-.36-5.25-.27-10.5-.19-15.76l2 4z"/><path fill="#2b2b30" d="M951 140c2.06.44 2.06.44 4 1l-3 1-.94 1.94c-1.3 2.51-2.48 3.03-5.06 4.06l4 2-2.87.88C944 152 944 152 942 154l-5 2-2 2c-1.95.85-1.95.85-4.12 1.63l-2.2.78q-.82.3-1.68.59v-3a134 134 0 0 0-10.87 4 81 81 0 0 1-13.13 4c5.6-6.05 13.26-9.83 21.15-12.07 1.85-.93 1.85-.93 3.97-3.29 3.48-3.64 7.95-4.83 12.63-6.39l5.17-1.8 2.3-.77C950 141 950 141 951 140"/><path fill="#21202a" d="M1215 1087h4v64h-2l.02-1.68c.14-18.46-.38-36.87-1.02-55.32-1.87-.62-1.87-.62-4-1l-2 2 2 2a42 42 0 0 1-6 5c-2.25-.19-2.25-.19-4-1-3.26 0-5.72 2.03-8.44 3.69l-1.77 1.03c-3.79 2.3-5.63 4.32-7.79 8.28h-2l-1-2-1.19 1c-2.36 1.3-4.16 1.14-6.81 1l1.34-.89 4.29-2.92c2.47-1.66 5-3.1 7.62-4.5 4.36-2.38 8.37-5.13 12.4-8.04 2.38-1.67 4.83-3.17 7.35-4.65l4.25-2.62 2.08-1.3c1.67-1.08 1.67-1.08 2.67-2.08"/><path fill="#4a4954" d="M433 1398h1c1.1 6.25 1.17 12.38 1.2 18.71l.04 3.53.06 9.56.08 10.15.12 17.16.16 21.6c.24 31.1.42 62.2.34 93.29h-1c-.85-16.18-1.27-32.35-1.53-48.54l-.16-9.34L433 1496h-1a20791 20791 0 0 1-.08-41.22c-.04-18.95-.07-37.86 1.08-56.78"/><path fill="#2d221d" d="M1188.14 1202.68q5.64 0 11.29-.02a764 764 0 0 1 87.28 4.37l2.22.25c16.17 1.82 16.17 1.82 18.07 3.72q.06 2.5 0 5h-3l-1-4a8770 8770 0 0 0-35.65-3.67q-3.58-.35-7.15-.72a610 610 0 0 0-65.23-2.93h-9.16c-17.64-.02-35.13.53-52.72 1.9-4.72.35-9.36.51-14.09.42 4.93-2.8 10.01-2.73 15.57-3.1l1.82-.13c17.23-1.17 34.5-1.1 51.75-1.09"/><path fill="#969697" d="M680 956c-2.66 2.66-5.05 2.44-8.62 2.63l-1.86.11q-2.25.14-4.52.26l-1 3c-6.77 1.13-13.5 1.32-20.35 1.52-7.05.27-7.05.27-10.53 1.5-5.08 1.59-10.22 1.19-15.5 1.1l-3.38-.02-8.24-.1c1-2 1-2 2.64-2.6a95 95 0 0 1 16.07-1.96c2.86-.38 5.1-1 7.8-1.9 5.75-1.8 11.47-2.27 17.44-2.62q2.67-.16 5.35-.36c8.24-.55 16.45-.65 24.7-.56"/><path fill="#303237" d="M348 1382h1c1.3 13.64 1.19 27.32 1.19 41.01q0 5.1.03 10.2l.02 11.69v8.24c-.28 3.27-1.1 5.79-2.24 8.86h-2c-3.75-5.1-4.4-9.98-4.44-16.12l-.03-2.07c.07-4.07.8-6.5 2.71-10.16 1.32-2.86 1.88-5.82 2.44-8.9l.38-2.09c1.37-8.2 1.27-16.37 1.13-24.66l-.04-4.7z"/><path fill="#8b8a91" d="M393 1288h1a1670 1670 0 0 1 1.2 11.6c2.24 22.52 1.94 45.16 1.9 67.76v14.03l-.03 26.46-.02 30.18L397 1500h-1l-.82-97.8c-.1-13.28-.2-26.57-.14-39.85l.01-2.9q.03-6.38.1-12.78c.08-9.7-.7-19.07-2.15-28.67h-1l-.08-16.53-.02-6.03-.01-1.9q.01-2.28.11-4.54z"/><path fill="#353539" d="M279 1031h20l1 2c2.54.48 5 .84 7.56 1.13l2.16.26 5.28.61v2l2.38.81C320 1039 320 1039 322 1042h-23v-4h-20c-1-5-1-5 0-7"/><path fill="#303133" d="m175 1004 1.64.97c2.8 1.22 5.01 1.34 8.05 1.47 4.81.32 8.78 1.18 13.28 2.87 1.99.68 3.76.98 5.84 1.19 3.48.55 4.56 1.28 7.19 3.5 2.3 1.4 4.65 2.7 7 4v4h-11l-2-4h-10l-2-4h-13l-1-3-3-1z"/><path fill="#202026" d="M1489 688a64 64 0 0 1-15 6v2h-3l-1 3c-2.07 1.1-2.07 1.1-4.69 2.06l-2.74 1.03q-2.93 1.04-5.88 1.97c-2.69.94-2.69.94-5.38 2.57C1449 708 1449 708 1446 708l-1 3c-1.81.91-1.81.91-4 1.63-3.93 1.3-3.93 1.3-5 2.37l-5.16.68c-2.43.42-4.57 1.37-6.84 2.32h-3c1-3 1-3 2.45-3.91l1.8-.72 1.9-.76c1.85-.61 1.85-.61 4.66-1.05l2.19-.56.88-1.87c1.54-2.93 3.44-3.33 6.46-4.42C1443 704 1443 704 1445 702c4.43-2.34 9.18-3.72 14-5v-2l5-1-2 4c12.12-4.24 12.12-4.24 14-8 1.93-.89 1.93-.89 4.31-1.69l2.37-.82c2.5-.53 3.92-.3 6.32.51"/><path fill="#0a0b11" d="M1130 1129a85 85 0 0 1-14.12 5.06l-2.04.52c-6.7 1.58-13.45 2.23-20.3 2.74-4.13.3-8.06.75-12.09 1.7-5.91 1.2-11.67 1.29-17.7 1.28l-3.49.02a1374 1374 0 0 1-14.95-.01c-41.9-.05-41.9-.05-61.31-5.31v-1l8.82.47c4.19.24 8.3.69 12.46 1.27 11.94 1.62 23.9 1.6 35.93 1.58q3.7 0 7.4.02c17.17.12 34.47-.15 51.39-3.34l1-1q2.13-.4 4.3-.68l2.65-.38 5.55-.75c16.24-2.29 16.24-2.29 16.5-2.19"/><path fill="#7c4ac0" d="M419 599c3.66.69 7.22 1.57 10.81 2.56l2.96.82 2.23.62v2l2.12-.07 2.75-.05 2.75-.08c2.38.2 2.38.2 4.38 2.2 1.95.41 1.95.41 4.13.63l2.19.22 1.68.15c.83 3.53 1.05 6.53.76 10.15l-.23 3.04-.27 3.26-.4 5.17-.59 7.24-1.26 15.96-.27 3.44A942 942 0 0 0 451 684h-1l-.08-17.72-.02-6.5-.01-2c0-4.06.31-7.98.88-12 .33-2.56.36-5.08.36-7.65v-2.81C451 633 451 633 450 631h3l-1-2-2 1-.08-1.51q-.17-3.4-.36-6.8l-.12-2.38-.12-2.3-.11-2.1C449 613 449 613 448 610l-1.71.58c-2.74.5-4.24.07-6.85-.83l-2.32-.77c-2.12-.98-2.12-.98-3.54-2.48-2.2-2.1-4.35-2.47-7.27-3.19l-3-.76L421 602v-2z"/><path fill="#8b8c8e" d="M452 262h1q.08 3.4.13 6.81l.05 1.95c.05 4.9.05 4.9-1.17 6.71-1.25 1.9-1.28 3.1-1.32 5.37l-.07 2.22-.1 4.6c-.17 4.75-.93 7.28-3.52 11.34-.42 3.1-.5 6.13-.6 9.26-.4 2.74-.4 2.74-1.9 4.12-1.98 2.13-1.92 3.4-2.01 6.29l-.1 2.7-.08 2.82-.1 2.85L442 336h-1v-21h-2c2.67-17.54 2.67-17.54 4.5-22.94 2.58-7.72 3.35-16.02 4.5-24.06h2l.44-2.44C451 263 451 263 452 262"/><path fill="#f5f5f6" d="M345 1604h1c1.25 13.63 1.11 27.26 1.06 40.94l-.01 7.02L347 1669h-2v-2l-3-1v-54l3-1z"/><path fill="#b0afaf" d="m1233 909 5 1v7h2c.42 25.17.42 25.17-2 36l-.5 2.38q-.88 4.04-1.81 8.06-.3 1.27-.57 2.58c-1.36 5.53-3.03 8.98-7.12 12.98h-2l-.75 2.69c-.95 3.39-3.02 5.63-5.25 8.31l-1.86 2.4a131 131 0 0 1-19.43 20.16q-1.75 1.47-3.46 3c-3.7 3.3-7.57 6.65-12.25 8.44l-2-1c5.03-4.8 9.85-8.65 16.02-11.87a22 22 0 0 0 5.62-4.64 71 71 0 0 1 3.38-3.42c7.28-6.97 13.34-14.59 18.04-23.54 1.04-1.7 2.27-2.89 3.69-4.28 3.19-3.57 2.64-7.5 2.43-12.05l-.18-2.2 3-1 2-12h2v-31h-3l2-7-4 1z"/><path fill="#9f9fa0" d="M79 818h1c.2 5.18.08 10.04-.94 15.13C77.18 843.27 77.31 853.7 77 864l4 1 1-2c3.86 3.01 3.86 3.01 4.27 5.82A76 76 0 0 1 86 874l-4-4a39 39 0 0 0 1 5l2 1c1.4 3.61 2.33 7.2 3 11-2.7-2.52-4.4-4.7-6-8l-2-1c-7.32-11.61-8.06-24.6-7-38l1-3a219 219 0 0 0 .7-9.17L75 823l3-1c.69-2.06.69-2.06 1-4"/><path fill="#7044b3" d="m597 614-4 2 35 1v1l-1.8.03-25.39.47-13.04.23-2.91.06-2.56.05c-3.66.25-5.66.52-8.3 3.16-2.06.23-2.06.23-4.57.2l-2.72-.02-2.84-.05-2.86-.03-7.01-.1-.68 6.84C553 631 553 631 552 633h-2v-17l17.2-1.15c9.95-.67 19.83-.98 29.8-.85"/><path fill="#010102" d="M1371 379q2.5-.06 5 0l1 1q2.1.17 4.2.19l8.77.15 9.84.16c15.08.24 30.13.63 45.19 1.5l-1 4-69-1v-3l-4-1z"/><path fill="#8d8e8f" d="m639 182 6.93-.1c2.07.1 2.07.1 3.07 1.1q3.14.54 6.3.94l14.07 1.86 3.78.5c2.35.32 4.65.65 6.94 1.24 2.39.58 4.66.68 7.11.78l9.17.37 7.63.31v2c-6.62 2-12.62 2.24-19.5 2.13l-3.07-.03-7.43-.1v-2l-26-1-1-3c-1.85-.73-1.85-.73-4.06-1.19l-2.23-.48L639 185z"/><path fill="#d9d8d7" d="m1235 919 3 1-1 4-1-1-.06 1.5q-.14 3.38-.32 6.75l-.09 2.36c-.36 7.11-.36 7.11-2.53 10.39h-3v16h-2c-1.26-5.08-2.25-9.73-2-15v-3c-13.63 16.25-13.63 16.25-17 23l-2-1c2.35-4.41 4.7-8.73 7.69-12.75A31 31 0 0 0 1219 943c2.63-4.81 2.63-4.81 5-6a45 45 0 0 0 3.94-7.25C1229 928 1229 928 1233 927z"/><path fill="#17171c" d="M1149 717c-5.6 2.3-11.18 4.43-16.93 6.29a58 58 0 0 0-5.32 2.15c-4.37 1.95-8.98 3.18-13.57 4.5-3.18 1.06-3.18 1.06-6.3 2.62a24 24 0 0 1-7.38 2.44c-3.99.8-6.93 2.1-10.5 4-3.1 1.12-6.2 2.06-9.37 2.94a75 75 0 0 0-7.5 2.56 68 68 0 0 1-10.84 2.97 81 81 0 0 0-13.25 4.35c-5.6 2.34-11 2.86-17.04 3.18l1-2c2.36-.56 2.36-.56 5.31-1 3.68-.55 6.4-1.32 9.69-3h3v-2l1.41-.38c12.2-3.29 24.28-6.8 36.09-11.3 6.03-2.3 11-3.59 17.5-3.32v-2l9.38-3 2.69-.87 2.58-.82 2.38-.76c1.97-.55 1.97-.55 3.97-.55v-2l2.59-.84 3.35-1.1 1.7-.55c4.25-1.4 4.25-1.4 5.36-2.51a58 58 0 0 1 3.94-.62l2.15-.3c1.91-.08 1.91-.08 3.91.92"/><path fill="#1a1a1d" d="M1553 394h12l2 4h10l2 4h14l1 7-7.37.06-2.12.03c-5.28.02-5.28.02-7.51-1.09v-2l-12-1-1-3h-5v-1h7c-1-1-1-1-2.67-1.03l-2.08.1c-3.53.05-6.05-.47-9.25-2.07z"/><path fill="#545150" d="M1340 1231c2.06.44 2.06.44 4 1l-2.12 1.25q-3.73 2.28-7.34 4.71-1.53 1.04-3.08 2.04a77 77 0 0 0-7.21 5.25C1322 1247 1322 1247 1320 1247l-.7 1.66a35 35 0 0 1-4.73 6.35 67 67 0 0 0-14.74 26.48c-.83 2.51-.83 2.51-2.05 5.23-3.61 8.24-4.51 15.32-4.32 24.27q.05 3.1.03 6.22c.05 9.38.7 18.85 5.26 27.29 1.25 2.5 1.25 2.5.94 4.88l-.69 1.62c-7.21-10.94-8.18-24.04-8.19-36.81l-.03-2.83c-.02-6.37.8-12.17 2.22-18.36l.64-3.43a55 55 0 0 1 6.96-18.15 38 38 0 0 0 2.84-6.05c1.92-4.73 5.35-8.46 8.56-12.37l1.25-1.7c2.9-3.85 6.35-6.21 10.41-8.79 2.28-1.47 4.43-3.05 6.59-4.7a81 81 0 0 1 8.75-5.81z"/><path fill="#454448" d="M504 210c.75 1.56.75 1.56 1 4-1.32 3-3.05 5.79-5.07 8.37-1.31 2.3-1.54 4.52-1.93 7.13-.52 3.48-1.06 5.62-3 8.5-.34 2.16-.34 2.16-.5 4.5-.22 3.26-.7 4.84-2.5 7.5q-.59 2.48-1 5c-.78 4.68-.78 4.68-2 6.46-1.47 2.26-1.54 4.45-1.87 7.1a54 54 0 0 1-4.25 14.12c-1.1 2.89-1.35 5.57-1.6 8.62-.4 2.45-1.6 3.94-3.28 5.7l.48-2c.75-4.3.83-8.6.95-12.94.2-4.35 1.05-7.52 2.9-11.45.98-2.37.97-4.56.98-7.11.43-7.28 3.12-13.21 5.98-19.81a96 96 0 0 0 3.07-8.86c.64-1.83.64-1.83 1.67-3.87.97-1.96.97-1.96 1.78-5.15 1.56-5.76 4.84-10.92 8.19-15.81"/><path fill="#14171c" d="m346 1326 3 1-.04 3.14c-.07 10.07.15 20.11.54 30.17q.7 18.35.5 36.69h-1l.03 1.59c.34 19.2.34 19.2-2.03 27.41h-1z"/><path fill="#2d1d4c" d="m1250 392 2 1a20 20 0 0 1-5.26 4.29c-3.7 2.31-6.66 5.2-9.81 8.21-2.33 2.18-3.86 3.48-6.93 4.5-.48 1.67-.48 1.67-.75 3.75-.75 3.5-1.65 5.03-4.56 7.13A42 42 0 0 1 1216 425l-2.81 1.13-2.19.87c1.2-2.5 2.45-4.68 4-7l-9 1c2.8-4.21 5.41-5.88 9.98-7.96 2.01-1.04 3.47-2.16 5.14-3.67 2.75-2.43 5.35-3.32 8.88-4.37 4.87-2.63 9.4-5.91 14-9l3.56-2.37z"/><path fill="#352d3e" d="m907 540-7.31 2.5-2.1.72-2.02.69-1.85.63C892 545 892 545 889 545l-1 3-2.12.4-5.5 1.06C878 550 878 550 876 551q-3.3.35-6.62.56l-1.86.13-4.52.31v2a84 84 0 0 1-15.75 3.88c-7.26 1-14.34 2.67-21.47 4.36-6.38 1.5-12.62 2.94-19.22 2.82l-2.06-.02-1.5-.04v-1l2.43-.52 13.84-2.97c8.02-1.72 15.92-3.48 23.74-5.98 2.77-.74 5.15-.75 7.99-.53v-2c3.97-1.77 7.87-2.6 12.13-3.31 6.29-1.11 12.45-2.53 18.62-4.19l2.43-.65 8.76-2.38 5-1.35 2.71-.74c2.47-.4 4-.18 6.35.62"/><path fill="#2d2c34" d="M800.82 1664.9h3l3.3.03 3.48.02 15.34.1q9.26.05 18.5.12 10.67.09 21.32.13l16.5.1q4.92.05 9.84.07 4.64 0 9.28.07l4.98.01c6.4.1 11.71 1 17.64 3.45l-2 1-3-1q-2.62-.13-5.26-.12H903l-10.35.02h-10.83l-20.49.03-23.33.02-48 .05 3-1v-2c2.93-.98 4.77-1.12 7.82-1.1"/><path fill="#747376" d="m1436 718 .75 1.44c1.25 1.56 1.25 1.56 4.38 2.25l2.87.31c-3.43 3.73-6.3 5.93-11 8l-1.8.81c-3.02 1.35-5.93 2.54-9.2 3.19v-3q-3.35-.08-6.69-.12l-1.9-.06c-3.97-.04-6.72.54-10.41 2.18h-3v2l-4.81 1.5-2.71.84c-2.48.66-2.48.66-5.48.66 2.16-2.16 3.2-2.49 6.06-3.31a39 39 0 0 0 9.13-4.13c4.42-2.6 8.9-3.9 13.85-5.13A32 32 0 0 0 1426 721a73 73 0 0 1 4.19-1.69l2.04-.76c1.77-.55 1.77-.55 3.77-.55"/><path fill="#282831" d="m911 1349 2 1a348 348 0 0 1-4.5 3.9c-1.5 1.1-1.5 1.1-3.5 1.1l-1 3c-2.07.95-2.07.95-4.56 1.69l-2.5.76-1.94.55v2l-5 1-1 3c-1.7 1.13-1.7 1.13-3.81 2.13l-2.08 1c-2.11.87-2.11.87-4.3 1.3l-1.81.57-1 3h-3l-.62 1.75c-1.87 3.06-4.4 4.43-7.38 6.25l-5.25 3.38q-6.74 4.28-13.66 8.26c-2 1.3-3.43 2.67-5.09 4.36-1.47.92-1.47.92-3.06 1.75a94 94 0 0 0-8.82 5.63l-2.55 1.8q-2.55 1.8-5.1 3.64l-2.4 1.68-2.16 1.53-1.91.97-3-1 1.83-1.17c6.73-4.39 6.73-4.39 9.17-6.83 3.22-3.22 7.06-5.72 11-8h2v-2q1.62-1.06 3.29-2.01c2.11-1.22 4.14-2.55 6.19-3.88l2.51-1.64 2.63-1.72q12.72-8.3 25.56-16.38l4.76-3 2.42-1.52a679 679 0 0 0 13.24-8.55l2.77-1.83q2.66-1.76 5.3-3.54l2.43-1.6 2.14-1.44z"/><path fill="#1c1035" d="M1181 628c-7.84 4.23-15 7.98-24 9v2l-12 3v2l-2.85.95-3.78 1.3-1.87.62a34 34 0 0 0-8.63 4.17 32 32 0 0 1-8.18 4.02l-2.91 1.01q-3.5 1.17-7.03 2.3a175 175 0 0 0-12.12 4.63 124 124 0 0 1-18.63 6c1.89-3.77 6.94-5.27 10.8-6.63l1.58-.5C1094 661 1094 661 1096 659c1.61-.71 1.61-.71 3.53-1.37l2.13-.74 2.28-.76 4.65-1.61 2.3-.8c7.53-2.58 14.87-5.38 22.11-8.72 8.87-4.1 17.73-7.9 27-11q5.2-2.05 10.19-4.62c4.2-2.06 6.33-2.6 10.81-1.38"/><path fill="#453935" d="m1049.5 1214.81 2.6.08 1.9.11c.9 2.4 1.34 4.2.32 6.62l-2.14 3.86c-1.76 3.75-2.75 7.68-3.78 11.68-.8 2.99-1.6 5.34-3.4 7.84q.42-4.2.88-8.37l.23-2.41.26-2.3.22-2.13c.41-1.79.41-1.79 2.41-3.79.63-1.95.63-1.95 1.13-4.12l.5-2.2.37-1.68-1.62.55c-6.01 1.93-12.01 3.22-18.21 4.32q-6.59 1.2-13.1 2.7a137 137 0 0 1-18.03 2.6c-4.39.43-8.1 1.42-12.17 3.1-2.61 1.02-5.27 1.63-8 2.26-1.87.47-1.87.47-5 1.6-3.1.94-5.65 1.02-8.87.87v-1l6.73-2.02q3.46-1.05 6.91-2.12c13.79-4.25 27.84-7.22 42-9.96l2.7-.52q2.5-.49 5.01-.95c3.8-.72 7.28-1.6 10.87-3.04 3.2-1.26 5.8-1.72 9.28-1.58"/><path fill="#b5a1d1" d="M392 559c4.75.75 4.75.75 7 3 1.73.63 1.73.63 3.63 1.13l3.37.87v1l8 2v2l-10 2-3 9-2-3-.15 1.9-.23 2.48-.2 2.46C398 586 398 586 396 588l-4-1v-16l-2 2 .44-2.5c.64-3.82 1.1-7.66 1.56-11.5"/><path fill="#2f1c5a" d="m1306 351 .33 1.65c1.18 5.6 2.76 10.96 4.67 16.35h-5l-1-11-2 4-2-2c-9.43 7.8-9.43 7.8-10.62 12.4.49 6.32 3.19 12.52 5.46 18.4a45 45 0 0 1 2.16 8.2c-1.86-1.02-2.84-1.67-3.77-3.62l-.55-1.9-.62-2.1-.62-2.2c-2.92-9.98-2.92-9.98-4.44-13.18l-2-1v-5l-2.31 1.5C1281 373 1281 373 1278 373l1.25-1.16 5.75-5.34 2.08-1.93c4.26-3.96 8.34-7.97 12.2-12.31 2.3-1.7 3.94-1.5 6.72-1.26"/><path fill="#716f72" d="M543 130h8v3l75 1v-2h4c-.35 3.26-1.02 4.02-3.56 6.25L624 140v-4l-2 1q-3.76.1-7.52.04l-2.35-.03-13.1-.16-11.3-.15a6187 6187 0 0 0-25.53-.31l-12.77-.17-2.2-.02c-4.47-.07-8.83-.45-13.26-1.07-2.86-.19-4.87.63-7.4 1.87q-2.25 1.08-4.57 2l-2-1 1.71-.84 2.23-1.1 2.21-1.09C528 134 528 134 529 133q3.43-.36 6.88-.56l1.93-.13q4.1-.29 8.19-.31z"/><path fill="#545357" d="m179 651 1 2q2.46 1.09 5 2l-1 6-2.37.31c-3.06.8-3.07 1.16-4.63 3.69a21 21 0 0 1-10 4l-1 4-2.94.38-3.06.62-1 2-5-1c1.5-3.11 3.2-6.05 5-9h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.25.94 2.25.94 5 1 3.17-2.14 4.8-4.38 6-8h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.56.63 2.56.63 5 1l2-4h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#000001" d="M1459 797h3q.12 8.28.16 16.55l.07 5.64.06 8.08.05 2.56v2.36l.02 2.09c-.36 1.72-.36 1.72-1.85 2.78l-1.51.94a46 46 0 0 0-.51 6.54l-.06 1.94-.12 6.14-.1 4.17q-.13 5.1-.21 10.21l-4 1-.08-14.44-.02-5.29-.01-3.2c.11-3.1.53-6.02 1.11-9.07h2z"/><path fill="#343238" d="M906 232c.75 1.61.75 1.61 1 4a56 56 0 0 1-4.06 6.19l-2.52 3.49-1.25 1.71A134 134 0 0 0 884 274h-2l-.81 2.13A120 120 0 0 1 877 285h-2l-1 7h-3c-.75-2.25-.75-2.25-1-5q1.97-2.04 4-4a100 100 0 0 0 1-6l2-1 1-3 1-3 2-1 .81-2.87c1.41-3.7 2.69-4.47 6.19-6.13h-2c0-3 0-3 1.94-5l2.06-2 1-3 2-1q1.1-2.14 2.16-4.32c2.73-5.45 6.02-8.96 10.84-12.68"/><path fill="#333237" d="m851.06 193.94 2.94.06v2c-1.24 1-1.24 1-2.87 2a26 26 0 0 0-5.13 4l-6 2-2 2c-1.79.33-3.53.44-5.34.56-1.66.44-1.66.44-2.6 1.94-1.06 1.5-1.06 1.5-3.12 1.9l-2.38.1c-3.84.15-4.92.8-7.56 3.5-2.4.39-4.55.14-7 0l-1-3c2.9-1.26 4.8-2 8-2l.64-1.84c1.88-2.99 3.83-3.45 7.11-4.66 3.38-1.28 6.52-2.47 9.38-4.75 3.63-2.21 6.6-2.36 10.8-2.57 2.94-.25 2.8-1.17 6.13-1.24"/><path fill="#aea7c0" d="m679.56 584.88 5.12.02q6.15.03 12.32.1l-2 4-2.9.08-15.52.42c-7.78.2-15.55.43-23.32.91l-3.24.2c-3.2.41-5.97 1.33-9.02 2.39-3 .2-3 .2-5.87.13l-2.93-.06-2.2-.07c2.78-2.2 4.1-2.22 8-3l-26-1v-1l2.58-.06a2376 2376 0 0 0 13.9-.36c7.67-.18 15.21-.6 22.83-1.61 9.43-1.23 18.77-1.2 28.25-1.1"/><path fill="#49494b" d="m1426 1436 2 1-1 5-7-1 .33 90.1.04 10.62.01 2.14.26 69.46.13 36.59.07 17.89v2.77l.02 2.5v2.17c.14 1.76.14 1.76 1.14 3.76l-3-1v-238l6-3z"/><path fill="#d1d6d9" d="M1488 823h2v44l7-1v8l-3 1-1 5-6 1-.81-2.37c-1.17-2.6-1.72-3.4-4.19-4.63l1-7h3v8l4-2c-.31-2.37-.31-2.37-1-5l-1.52-.95L1486 866c-.3-2.69-.4-5.14-.36-7.82v-2.37q0-2.47.02-4.96.03-3.76.02-7.54l.02-4.82v-2.26c.06-4.76.8-8.7 2.3-13.23"/><path fill="#2d2d31" d="m308 651-4.12 2.38-2.33 1.33C299 656 299 656 296.28 656.86c-4.45 1.55-8.45 3.73-12.6 5.95l-2.43 1.3q-3.63 1.92-7.25 3.89l-2.47 1.33-2.39 1.3-2.12 1.17c-2.02 1.2-2.02 1.2-4.11 2.79-2.49 1.84-4.96 2.46-7.91 3.41-6.15 2.31-11.16 5.42-16.46 9.27a56 56 0 0 1-7.66 4.3 57 57 0 0 0-11 7A55 55 0 0 1 211 704l-2-1a53 53 0 0 1 7.5-5.44c2.5-1.56 2.5-1.56 4.56-3.87 2.98-3.2 6.9-4.8 10.84-6.55A57 57 0 0 0 236 685l1-3h6l1-5 5.27.3 1.73-.3.88-1.44C253 674 253 674 255.6 673.27l3.03-.46 3.03-.48L264 672l1-3c2.21-1.32 2.21-1.32 4.94-2.56 4.88-2.26 4.88-2.26 7.06-4.44l3.06-1c2.94-1 2.94-1 3.94-3 2.7-1.1 5.46-1.99 8.24-2.9 2.69-1.07 4.47-2.37 6.76-4.1 3.1-.91 6.03-1.48 9 0"/><path fill="#25242d" d="M1248 1761c-9.94 6.12-19.17 8.05-30.73 8.52-3.69.19-7.12.64-10.71 1.49-6.24 1.27-12.38 1.26-18.72 1.22h-3.88l-10.43-.03-10.93-.01-20.67-.05-23.54-.04q-24.2-.04-48.39-.1v-1l3.1-.03c86.76-.88 86.76-.88 112.54-1.36l5.65-.09c8.82-.13 17.1-.44 25.71-2.52a409 409 0 0 1 6.25-.81 67 67 0 0 0 16.08-4.6c3.27-1.3 5.34-1.93 8.67-.59"/><path fill="#08070d" d="M1229 1628c-5.62 4-5.62 4-9 4v2c-12.42 2.9-24.28 3.41-37 3.34h-6.19q-8.31 0-16.64-.04l-15.67-.02q-15.5-.01-31-.05l-30.08-.07h-11.22q-38.6-.06-77.2-.16v-1l3.05-.01q35.92-.14 71.86-.32l8.86-.04 1.78-.01 57.77-.26 31.92-.14 5.65-.02c38.87-.13 38.87-.13 43.68-4.7l1.43-1.5c5.5-2.25 5.5-2.25 8-1"/><path fill="#808182" d="m258 690 2 1c-3.12 3.3-5.91 5.9-10 8h-2l-1 3a119 119 0 0 1-6 4q-1.83 1.2-3.62 2.44l-1.67 1.12C234 711 234 711 232.08 713.42a38 38 0 0 1-6.7 6.4q-1.31 1-2.64 2.05L220 724l-2.77 2.26-2.67 2.18-2.43 2C210 732 210 732 207 733l-.86 2.29c-1.43 3.39-3.59 5.01-6.45 7.15A58 58 0 0 0 189 753l-3.6 3.56c-2.55 2.62-4.87 5.44-7.21 8.25L173 771c0-3.94 1.27-4.84 3.81-7.81l2.48-2.94 1.34-1.58c1.92-2.34 3.75-4.74 5.58-7.14 7.38-9.62 7.38-9.62 12.16-13.12a29 29 0 0 0 3.92-4.24c2.7-3.3 5.75-5.8 9.15-8.36l5.65-4.34q3.7-2.86 7.33-5.76a399 399 0 0 1 17.37-13.05c2.21-1.66 2.21-1.66 3.76-3.22 2.21-2.2 4.84-3.58 7.51-5.16A26 26 0 0 0 258 690"/><path fill="#8d8c8e" d="m351 945 9.7.56c3.84.23 7.57.62 11.38 1.2 5.05.62 10.18.65 15.27.85l2.72.1 2.43.1c2.5.19 2.5.19 5.41.69 3.17.51 6.2.69 9.4.78l1.76.06 7.35.21 8.63.26c2.75.18 5.26.63 7.95 1.19q2.33.2 4.64.32l2.55.12 2.62.12 2.68.13 6.51.31v1l-19.64.08c-8.9.04-17.67-.06-26.54-.86-4.05-.32-8.07-.33-12.13-.28l-2.28.01-5.41.05 13 1v1h-30c4-2 5.7-2.2 10-2v-2l-3.12.13c-4.05 0-8-.45-12-1l-1.78-.24A56 56 0 0 1 351 946z"/><path fill="#929394" d="m910 906-1 2h-13v3h13v3l-2.67.33-3.52.48-1.75.22c-4.04.57-6.63 1.86-10.06 3.97-2.86.7-2.86.7-5.75 1.13-5.16.78-5.16.78-6.25 1.87a67 67 0 0 1-4.25.32l-2.6.12-2.71.12-2.73.13-6.71.31 1-4h16v-3l16-2v-2h-13c2.72-2.72 5.75-3.22 9.38-4.12l2.15-.55A60 60 0 0 1 910 906"/><path fill="#54535d" d="M435 1381h1l1 17 1-5h1v87l-3 1-.57-43.66c-.53-39.26-.53-39.26-.43-56.34"/><path fill="#020203" d="M1407 962h4l-1 10-4 1 .04 2.16.02 2.9.04 2.85a68 68 0 0 1-.87 8.28c-.4 3.08-.42 6.15-.42 9.25v2.05c.08 7.58 1.07 15.02 2.19 22.51l-4-1c-1.32-3.95-1.09-7.89-1-12l-3-1v-20h2l.08-3.21.17-4.16.04-2.12c.1-1.92.2-3.66.71-5.51 2-1.16 2-1.16 4-2 .6-2.38.6-2.38.75-5.12l.17-2.76z"/><path fill="#2e2d31" d="m1374 760 2 1c-4.83 4-9.87 7-15.48 9.76q-3 1.48-5.97 2.98l-1.86.94-3.68 1.86a86 86 0 0 1-7.74 3.51 19 19 0 0 0-5.46 3.51c-2.82 2.09-4.63 2.7-8.12 2.82-6.9.33-12.92 3.24-19.23 5.83-11.72 4.79-11.72 4.79-16.46 4.79v2c-3.53 1.67-5.28 2.14-9 1 17.85-8.45 17.85-8.45 23.93-11.07q2.25-1.02 4.49-2.14a78 78 0 0 1 18.83-6.27 34 34 0 0 0 11.25-4.62c2.07-1.24 4.26-2.02 6.5-2.9l3.63-1.81c3.27-1.67 6.58-3.2 9.93-4.69a86 86 0 0 0 9.5-4.87z"/><path fill="#07090d" d="M1412 1439c1.1 2.18 1.12 3.24 1.12 5.65v16.59l-.01 20.34-.02 20.1a47894 47894 0 0 1-.04 64.29v11.77L1413 1659h1v15l-3-1-.05-89v-12.6l-.04-68.62-.02-37.88-.01-15.93v-7.35c.12-1.62.12-1.62 1.12-2.62"/><path fill="#c4c2c7" d="m1517 1290 5 2v45l-5 1c-1.57-1.57-1.13-3.14-1.13-5.32v-39.82c.13-1.86.13-1.86 1.13-2.86"/><path fill="#807d7e" d="m1490 694 2 1a73 73 0 0 1-10 7l-2.87 2a45 45 0 0 1-8.29 4.23c-7.88 3.3-15.25 7.74-22.37 12.44-2.58 1.39-3.9 1.66-6.78 1.52l-2.12-.08-1.57-.11-2-4 7-1 1-3 2.38-.31c2.62-.69 2.62-.69 3.53-2.22 1.09-1.47 1.09-1.47 3.04-1.76l2.17.1 2.2.08 1.68.11.13-1.75c1.08-2.78 2.61-3.66 5.12-5.16 2.5-1.55 4.75-3.4 7.04-5.23 1.71-.86 1.71-.86 3.92-.5l1.79.64c-2.97 1.9-5.51 3.35-9 4v4h5v-4l3.31-.31c3.3-.47 4.47-1.18 6.69-3.69 1.96-1.1 3.97-2.03 6-3z"/><path fill="#63626a" d="M473 1634c4.1.3 7.76.73 11.5 2.5 7.29 3.4 15.93 2.67 23.8 2.7l13.55.1 10.36.08 19.6.14 22.31.16 45.88.32v1l-115 1-1 3c-9.57.35-17.67-1.21-26-6l-3-1.62-2-1.38z"/><path fill="#ba3707" d="M1405 1290h1a98 98 0 0 1-5 24 133 133 0 0 0-.17 6.43v1.89l-.02 3.97-.03 6.02-.01 3.87-.02 1.8c.02 4.03.87 7.25 2.25 11.02.13 2.38.13 2.38 0 4h2l3 10-3-2v-2h-2c-9.43-17.63-9.27-39.92-4-59 1.45-3.68 3.42-7 6-10"/><path fill="#929294" d="m559 963 15.09-.08 5.52-.02 3.34-.01c2.8.1 5.32.47 8.05 1.11-.73 1.47-.73 1.47-2 3-1.97.42-1.97.42-4.36.48l-2.63.09-2.76.05-5.36.17-2.4.05c-2.88.18-5.66.65-8.49 1.16 28.87.38 28.87.38 40.4-1.28 6.55-.94 13-.9 19.6-.72v1a238 238 0 0 1-36.54 3.65c-7.03.2-13.9.74-20.86 1.69-5.9.77-11.65.87-17.6.66l4-1v-3l4-2-1-2 26-1-22-1z"/><path fill="#27252b" d="M1272 778h5l-1.81.81c-2.19 1.19-2.19 1.19-3.57 2.69-2.26 2.09-4.71 2.6-7.62 3.5l-1.46.99c-2.61 1.71-5.53 2.22-8.54 3.01 4.8.34 7-.46 11-3q2.55-.78 5.11-1.47a65 65 0 0 0 5.89-2.03c10.33-3.92 10.33-3.92 15-2.5-7.96 3.97-15.41 7.36-24.15 9.25-4.05 1.06-7.67 2.8-11.44 4.62a40 40 0 0 1-5.91 2.25c-2.5.88-2.5.88-3.92 2.34-1.75 1.7-3.06 2.3-5.35 3.11l-2.2.78-2.28.77-2.24.8A60 60 0 0 1 1220 807c2.32-1.76 2.91-2 6-2v-2l3-1.25c9.72-4.17 9.72-4.17 13.57-6.84 1.43-.91 1.43-.91 4.06-1.41l2.37-.5 1-2h-7v-1l8-1v-2l1.9-.33 2.47-.48 2.47-.46c2.16-.73 2.16-.73 3.16-2.26 1-1.47 1-1.47 3.16-1.98l2.46-.18 2.48-.2 1.9-.11z"/><path fill="#636366" d="M1353 342c3.11 1.5 6.05 3.2 9 5v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.94 2.25-.94 2.25-1 5 2.14 3.17 4.38 4.8 8 6v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.87 2.3-.87 2.3-1 5a20 20 0 0 0 3.94 4.31l2.27 2.12L1378 373l-1 3h-5l-.87-1.94C1370 372 1370 372 1368 371v-5l-2.31-.19c-2.69-.81-2.69-.81-4.44-3.06A27 27 0 0 1 1359 355l-1.87-.12c-2.13-.88-2.13-.88-3.32-3.63-1.48-5.92-1.48-5.92-1.81-8.25z"/><path fill="#272628" d="M801 86h32l1 2h20l1-2 2 1-1 3-1.91.08q-4.27.17-8.53.36l-3 .12-2.87.12-2.65.11C835 91 835 91 834 92q-3.94.15-7.87.13h-26.4C798 92 798 92 795 91q-3.09-.33-6.19-.56L783 90v-1l18-1z"/><path fill="#8b98a2" d="M1614 1121v12l-4 2v10l-4 2v9l-4 1-.31 1.94-.69 2.06-3 1a527 527 0 0 1 1.37-7.52 13 13 0 0 1 2.63-5.48l-2-1v-3h2v-11h-2l-1 8h-1c-.25-6.52-.25-6.52 1-8.94l1-1.06 6 1v-7h1v8l1-1-.19-3.25c-.06-3.19.22-3.8 2.07-6.69 2.12-2.06 2.12-2.06 4.12-2.06"/><path fill="#3f4043" d="M228 956c6.94.59 13.59 1.92 20.38 3.44 8.3 1.8 16.47 3.23 24.94 3.84a73 73 0 0 1 12.24 2.08c10.27 2.46 20.79 3.52 31.25 4.83l6.47.82L339 973v1c-15.2.39-30.42-.48-45.32-3.57-3.45-.55-6.88-.7-10.37-.87-4.85-.24-8.84-.85-13.27-2.86-3.69-1.26-7.54-1.28-11.4-1.49C256 965 256 965 253 964v-2l-2.46-.37a1458 1458 0 0 1-13.82-2.14l-2.97-.49-2.7-.44L229 958z"/><path fill="#0d0d12" d="M1194 694c-1.47 2.94-4 3.32-6.94 4.38l-5.34 1.97c-3.27 1.23-6.48 2.6-9.7 3.95-2.02.7-2.02.7-5.02.7v2c-4.95 3.63-11.85 7-18 7v2l-1.94.33c-4.8.88-9.04 1.93-13.45 4-2.5 1.04-4.94 1.34-7.61 1.67v2l-8.81 3-2.53.87-2.43.82-2.24.76c-1.99.55-1.99.55-4.99.55v2l-2.77.37-3.6.5-1.83.24c-4.57.66-4.57.66-6.8 2.89-4.3 1.4-8.5 2.34-13 3 1.76-2.27 3.1-3.81 5.98-4.42a90 90 0 0 1 4.22-.3c1.8-.28 1.8-.28 3.8-2.28 1.95-.41 1.95-.41 4.13-.62l2.19-.23 1.68-.15v-2a357 357 0 0 1 25-7l4-1v-2l2.81-.75a177 177 0 0 0 32.13-12.75c4.34-2.23 8.8-4 13.37-5.68 3.18-1.2 6.2-2.53 9.19-4.13 2.95-1.56 5.1-2.11 8.5-1.69"/><path fill="#aab8c3" d="M1523 1190h3v5l2.25-1.37a89 89 0 0 1 6.75-3.63c3 4.75 3 4.75 3 7h3l-1 4h-2l-1-3-3-1v5l5 2-5 6h6v-3l4 1h-2l-1 3-5.37.59-1.63.41-1 2c-2.5.41-2.5.41-5.56.63l-3.07.22-2.37.15 1-3h2v-4h3q-.63-2.94-1.31-5.87l-.74-3.31c-.95-2.82-.95-2.82-2.56-4.2l-1.39-.62z"/><path fill="#717174" d="M447 260h1c.33 9.5-.02 17.93-2.52 27.16-.96 3.7-1.73 7.43-2.54 11.15-.92 4.21-1.86 8.4-2.94 12.58-1.22 4.83-2.1 9.71-3 14.61a254 254 0 0 1-4 18.5h-1q-.08-3.87-.12-7.75l-.06-2.21c-.03-4 .2-6.5 2.18-10.04a117 117 0 0 0 .94-6.44c.62-4.86 1.46-9.55 2.62-14.31a70 70 0 0 0 1.94-11.37c.37-4.6 1.46-8.8 2.74-13.21q1.04-3.68 1.82-7.42c.82-3.8 1.83-7.52 2.94-11.25"/><path fill="#b8b8b7" d="M1105 957v3l-2.12.3-2.76.45-2.74.42-2.38.83-.94 2.04L1093 966c-2.79.54-2.79.54-6.06.63q-2.98.08-5.94.37l-1 2c-2.38.31-4.67.51-7.06.63l-2.01.11q-2.47.14-4.93.26v3c-4.23 2.11-9.55 1.08-14 0l1-3a56 56 0 0 1 6.5-1.56c2.2-.41 4.33-.88 6.5-1.44l1-2c2.14-.56 2.14-.56 4.81-1a29 29 0 0 0 9.19-3c7.84-2.31 15.78-4.25 24-4"/><path fill="#a581d4" d="M462 751c4.54-.25 8.17-.1 12.5 1.31 12.57 4.05 25.37 5.22 38.5 5.69v-2l3.28.46c5.4.68 10.8.89 16.22 1.1l3.07.13 7.43.31 1 5-9.62-.44-2.74-.12a84 84 0 0 1-10.58-.95c-3.76-.6-7.45-.68-11.26-.78-10.6-.29-20.75-1.12-31.12-3.35q-2.25-.47-4.5-.9c-4.52-.88-8.17-2.2-12.18-4.46z"/><path fill="#010006" d="M1372 497c.79 1.6.79 1.6 1 4-1.23 2.33-2.89 4.29-4.56 6.31l-1.4 1.75q-2 2.49-4.04 4.94l-1.9 2.3a86 86 0 0 1-5.7 6.25C1354 524 1354 524 1353 526l-3 1-1 2h-3l-1 5h-4l-1 3h-2l-.19 1.81c-1.14 3.07-3.02 3.54-5.81 5.19a72 72 0 0 0-5 6l-3-1c3.04-4.48 6.17-8.3 10.31-11.81a63 63 0 0 0 9.13-9.69c3.69-4.75 7.64-8.08 12.46-11.71 2.8-2.38 3.8-4.4 5.1-7.79l2-2h3l1-4h2l.38-1.94.62-2.06z"/><path fill="#78777d" d="M1233 86c3.11 1.5 6.05 3.2 9 5v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v-8h3l1 7-1.44.69c-1.56 1.31-1.56 1.31-2.37 3.56-.35 5.03 2.03 9.37 4.02 13.88a135 135 0 0 1 3.04 8.17c.75 1.7.75 1.7 2.75 3.7 1.35 2.92 2 4.75 2 8-1.5-1.25-1.5-1.25-3-3-.19-2.19-.19-2.19 0-4l-1.87-.62c-2.98-1.93-3.16-4.06-4.13-7.38l-2-2c-.12-2.62-.12-2.62 0-5l-4-2v-5l-4-1-1-7-3-1v-6h-2z"/><path fill="#a799c0" d="m653.99 589.64 2.46.02h2.79l3.02.05 3.08.01q4.89.03 9.79.1l6.62.03L698 590c-2.49 2.88-2.49 2.88-4.73 3.4l-1.92.03-2.18.06-2.34.02-2.4.06-7.62.12-5.16.1q-6.32.13-12.65.21l7 2-1 2c-4.9.97-9.7 1.18-14.69 1.19l-1.97.03c-3.63.02-6.25-.24-9.34-2.22v-2l2.34-.33 3.04-.48 3.02-.46c2.6-.73 2.6-.73 3.48-2.25 1.12-1.48 1.12-1.48 3.1-1.84"/><path fill="#38373a" d="M1721 540c2.63.38 2.63.38 5 1v8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-9 3-.19 2.88-.81 3.12c-2.56 1.38-2.56 1.38-5 2l1-3h3v-6l4-1v-8h4v-8l4-1-.12-3.37c-.08-1.9-.08-1.9.12-3.63z"/><path fill="#383741" d="M1262.19 1479.81c6.32.06 12.58 1.2 18.81 2.19v1q-2.71.3-5.44.56l-3.06.32-2.5.12-1-1a86 86 0 0 0-4.56-.56l-2.5-.26-1.94-.18v1.5q.07 18.15.1 36.31l.05 17.57a5777 5777 0 0 1 .06 23.41q.03 4.54.02 9.08l.02 2.67A76 76 0 0 1 1258 1590h-1l.5-62.35.2-26.36.1-10.17.01-3.08.03-2.78.02-2.43c.25-3.22 1.03-2.82 4.33-3.02"/><path fill="#47484c" d="m1456.06 1424.94 1.94.06c-1.5 3.11-3.2 6.05-5 9h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.56-.62-2.56-.62-5-1l-2 4h8v3l-7 1-.69-1.44c-1.83-2.18-3.56-2.2-6.31-2.56l-2 4h-9c-.62-2.37-.62-2.37-1-5 2.85-2.85 5.05-2.56 9-3v-2c3-1.5 5.66-1.06 9-1l1-4 5.37-.68c2.21-.43 2.3-1.31 4.7-1.38"/><path fill="#abb8c4" d="m1525 1202 3 1v5h-3l.19 1.81c-.19 2.19-.19 2.19-1.69 3.94l-1.5 1.25 2 1-1 2 1 3c-.96 2.67-.96 2.67-3 5-2.58.62-5.05.47-7.69.31l-2.12-.07q-2.6-.08-5.19-.24v-7l2.38-.25 2.62-.75.78-1.66c1.81-3.48 4.55-5.88 7.35-8.59l1.7-1.68z"/><path fill="#2c2c2d" d="M1111 950c-5.78 4.21-13.61 5.5-20.42 7.25-2.58.75-2.58.75-5.12 1.79-2.41.94-4.59 1.42-7.15 1.84-4.52.84-8.44 2.32-12.67 4.11-3.86 1.47-7.81 2.35-11.83 3.3a54 54 0 0 0-6.93 2.27 42 42 0 0 1-8.49 2.35 160 160 0 0 0-15.58 4.03A226 226 0 0 1 990 984c4.47-3.3 9.52-4.44 14.88-5.5 5.46-1.13 10.82-2.29 16.08-4.22 5.63-2.03 11.08-2.97 17.02-3.65 3.25-.68 5.24-1.88 8.02-3.63 2.14-.62 2.14-.62 4.25-1 4.74-.95 9.19-2.41 13.75-4l7.25-2.35C1073 959 1073 959 1074 958q2.52-.34 5.06-.56l2.79-.26 2.15-.18v-2l1.98-.33c7.45-1.35 7.45-1.35 10.96-3.3 4.6-2.06 9.1-1.6 14.06-1.37"/><path fill="#3b3a40" d="M407 490h1c.1 6.77.03 13.3-1 20h-2c.6 2.83.6 2.83 2 6a30 30 0 0 0 6.31 2.75q1.72.6 3.43 1.17L420 521l5 2q2.54.74 5.08 1.43c1.92.57 1.92.57 3.8 1.3 6.58 2.5 12.9 2.87 19.87 3.2 11.26.66 22.1 2.27 33.14 4.64 3.02.61 6.05 1.03 9.11 1.43l-1 2 3 2c-7.04-.32-13.8-1.47-20.69-2.87l-2.63-.54-9.83-2.04c-11.22-2.31-22.44-3.57-33.85-4.55v-2l-2.12-.26c-5.89-.87-10.15-2.1-15.02-5.5-2.79-1.86-5.77-2.98-8.86-4.24-1.45-1.45-1.2-2.46-1.25-4.5.05-7.58 1.82-15.08 3.25-22.5"/><path fill="#a18775" d="m1021.31 1225.88 3.24.05 2.45.07v3l-2.05.08-2.7.17-2.67.14c-2.58.61-2.58.61-4.45 2.62-2.94 2.75-5.68 2.55-9.5 2.55-5.45.04-8.72.84-13.63 3.44-5.1 1.25-10.17 1.12-15.37 1.06l-2.52-.01-6.11-.05 1-2c2.14-.57 4.2-1.03 6.38-1.44l3.83-.77 1.93-.4c5.2-1.1 10.44-2.35 15.32-4.45 3.59-1.33 7.03-1.38 10.82-1.6 2.72-.34 2.72-.34 5.17-1.36 3.1-1.19 5.56-1.18 8.86-1.1"/><path fill="#1b1b23" d="m887 1367 2 1a49 49 0 0 1-9.56 6.5 31 31 0 0 0-8.44 6.5l-2.24 1.33c-3.09 1.87-5.82 4-8.63 6.23a286 286 0 0 1-20.37 14.26 146 146 0 0 0-5.43 3.9c-2.54 1.82-4.65 3-7.77 3.6l-2.06.42-1.5.26v3c-1.98 1.54-1.98 1.54-4.75 3.19l-3.03 1.83-1.6.97a126 126 0 0 0-4.81 3.17l-1.58 1.09-4.4 3.05a35 35 0 0 1-8.83 3.7c3.89-4.25 8.04-7.1 13-10a271 271 0 0 0 8.06-4.94l1.41-.9c1.53-1.16 1.53-1.16 3.37-3.07a41 41 0 0 1 7.6-5.59A96 96 0 0 0 838 1399l5-3 3.13-2.31a76 76 0 0 1 9.43-5.69 57 57 0 0 0 11.44-8l5-3 1-2 3-1 1-2c2.21-1.32 2.21-1.32 4.94-2.69l2.71-1.38z"/><path fill="#917156" d="m1171.25 1209.88 4.03.02q4.86.03 9.72.1v1l-1.59.06q-3.64.14-7.29.32l-2.49.09-2.5.12-2.25.1c-2.57.28-4.94.76-7.45 1.32-5.83 1.2-11.52 1.29-17.46 1.3l-6.83.07q-5.34.06-10.67.08-5.2.03-10.37.1h-3.24l-3 .04-2.63.01c-2.7.47-3.42 1.4-5.23 3.39-2.92.51-2.92.51-6.19.69l-3.3.2-2.51.11 8 1v1l-3.18.11-4.13.2-2.1.07c-5.2.28-5.2.28-7.38 2.16L1080 1225h-3v7l-2-2-.81 3.38c-.57 2.35-.83 3.34-2.76 4.85q-.7.37-1.43.77l.88-1.87c1.3-3.6 1.92-7.15 2.56-10.92.56-2.21.56-2.21 2.56-4.21q1.06-2.48 2-5l1.97-.04q4.43-.13 8.84-.27l3.1-.07 2.98-.1 2.75-.09c2.36-.43 2.36-.43 3.76-1.93 1.97-1.85 2.85-1.88 5.51-1.9l2.37-.04h2.56l2.66-.04q4.21-.06 8.44-.08c20.95-.18 20.95-.18 30.17-2 6-.91 12.09-.65 18.14-.57"/><path fill="#3a2656" d="M481 758c5.2-.3 10 .26 15.1 1.24l2.01.39 2.1.4c43.56 8.4 88.6 6.53 132.79 6.97v1a18685 18685 0 0 1-55.15.15 5738 5738 0 0 1-23.33.06c-20.33.1-20.33.1-29.87-1.75a64 64 0 0 0-8.84-.77c-7.31-.45-14.44-1.77-21.62-3.13l-1.8-.33-5.1-.98-3.01-.58L482 760z"/><path fill="#2f2d33" d="M899 409q3 .43 6 1v2l2 2-2.37.88C902 416 902 416 900 418c-1.66.3-1.66.3-3.5.44-1.9.2-1.9.2-3.5.56l-1 1.56-1 1.44c-5.95.54-5.95.54-8.94-1l-1.06-1-1.12 1c-2.65 1.41-4.93 1.13-7.88 1v2l5 1v1h-11l2-1v-2l-2.34.29C853.2 424.6 853.2 424.6 848 422c-1.21-1.58-1.21-1.58-2-3l3 1c9.65.77 19.62.4 29-2l2.54-.62c6-1.64 13.96-3.88 18.46-8.38"/><path fill="#7f7d88" d="M1285 1624h1v52h-1l-1-14 .06 4.1q.11 7.68.16 15.36l.08 6.6c.17 11.13.15 21.95-2.13 32.89l-.5 2.4c-1.25 5.33-1.25 5.33-3.24 6.87l-1.43.78c-.2-4.32.35-7.05 2-11 2.96-8.73 2.25-18.08 2.2-27.16l-.01-5.28-.05-13.8-.04-14.12q-.03-13.82-.1-27.64h1l1 20h2l-.25-1.55c-1.18-8.84-.39-17.6.25-26.45"/><path fill="#616169" d="M393 1187h1l.08 18.25.02 6.65.01 2.1q-.01 2.5-.11 5l-1 1c-3.04 17.36-2.35 35.28-2.4 52.83l-.07 13.13-.12 24.78-.14 28.24L390 1397h-1a117361 117361 0 0 1-.08-97.14 30787 30787 0 0 1-.03-54.43l-.01-11.62v-1.97c0-2.91.19-5.05 1.12-7.84q.3-3.56.5-7.12l.12-2.1.76-13.29.23-4.12c.2-3.54.6-6.92 1.39-10.37"/><path fill="#3d246f" d="M1032 605c3.66 2.78 4.5 6.75 5.88 10.94a242 242 0 0 0 7.03 18.33c1.4 3.5 2.4 7.03 3.32 10.67.77 2.06.77 2.06 2.26 3.49 2.1 2.2 2.37 4.24 3.01 7.2 1.05 4.63 2.33 9 4.05 13.43.54 2.3.18 3.72-.55 5.94l-4-1 1-4h-2l-.45-2.32a109 109 0 0 0-5.61-18.8l-.78-1.94A45 45 0 0 0 1041 639c-.53-1.73-.53-1.73-.94-3.62-1.3-5.54-3.1-10.88-5.14-16.18-3.48-9.06-3.48-9.06-2.92-14.2"/><path fill="#2b2a30" d="M882 274h2c.31 4.58-.07 6.51-3 10l-.87 2.31A34 34 0 0 1 874 296h-2l-.48 1.77c-2.2 7.75-4.72 15.8-9.05 22.65a30 30 0 0 0-2.97 7.7c-.89 3.32-2.03 6.56-3.12 9.82l-.7 2.07L854 345h-2l-1 5c-.91-2.08-1.2-3.46-.4-5.61a89 89 0 0 1 1.7-3.41c.7-1.98.7-1.98.58-4.65.16-4.56 1.8-8.07 3.62-12.2l1.01-2.38q1.23-2.88 2.49-5.75h2l.45-2.24c1.7-7.73 4.09-14.13 8.55-20.76l2-1c.56-1.6.56-1.6 1-3.5s.44-1.9 1-3.5l2-1q1.39-2.5 2.63-5.06l1.35-2.79z"/><path fill="#4f5454" d="M389 1766c2 1.38 2 1.38 4 3v2l4 2v2l4 2v3l1.81.33c2.43.74 3.44 1.54 5.2 3.32l1.6 1.6 1.64 1.69 1.68 1.67c4.07 4.12 4.07 4.12 4.07 6.39h4v6l-2.37.81c-2.6 1.17-3.4 1.72-4.63 4.19l-4-1v-3h5a36 36 0 0 0-7.16-9.21l-2.4-2.33-5.01-4.78-2.4-2.33-2.2-2.1c-2.14-2.63-2.88-5-3.83-8.25a394 394 0 0 0-3-5z"/><path fill="#4c4c55" d="M439 1387h1a138351 138351 0 0 1 .08 105.9 36656 36656 0 0 1 .03 59.4v5.32c.03 10.84-.3 21.58-1.11 32.38-4.72-4.72-3.05-15.6-3.07-22q0-6 .07-12h1l1 14z"/><path fill="#2f2f34" d="M915 916v1l-3.4.8a147 147 0 0 0-9.3 2.67c-8.52 2.73-16.36 5.2-25.38 5.23a19 19 0 0 0-6.32 1.65c-5.73 2.29-11.8 3.06-17.86 4.01a964 964 0 0 0-10.17 1.65l-1.86.3c-2.62.42-5.06.69-7.71.69v2c-11.7 3.47-22.81 4.77-35 5v-1c19.71-5.04 39.61-9.97 60-11v-2l9.05-1.85q4.92-1.01 9.88-1.69C880 923 880 923 881 922q2.5-.49 5-.87a79 79 0 0 0 13.02-3.47c5.28-1.77 10.48-1.79 15.98-1.66"/><path fill="#1d1d25" d="M1219 1087h1a10712 10712 0 0 1 .15 41.09 3190 3190 0 0 1 .06 17.36q.03 3.36.02 6.71l.02 2.02c-.02 4.6-.02 4.6-2.25 6.82-2.11.25-4.1.37-6.23.41l-1.95.06-6.44.15-4.5.12c-18.3.44-36.59.35-54.88.26v-1l1.74-.06c12.43-.44 24.8-1.08 37.2-2 10.37-.77 20.66-1.21 31.06-.94v-3l1.97-1.8c2.7-2.92 2.54-4.59 2.58-8.53l.03-1.9q.04-3.07.03-6.16l.05-4.29q.06-5.62.08-11.26.03-5.76.1-11.5.12-11.28.16-22.56"/><path fill="#000001" d="M1431 1121h3v66h-4l-.08-24.19c-.05-13.97.26-27.86 1.08-41.81"/><path fill="#77777b" d="M996 112c.2 1.84.2 1.84 0 4-1.36 1.41-1.36 1.41-3.19 2.63l-1.79 1.22c-2.02 1.15-2.02 1.15-4.43 2.1-3.75 1.52-6.99 3.73-10.38 5.9L974 129l-2-1c10.08-8.3 10.08-8.3 16-9v-2c-13.54 3.62-13.54 3.62-18.37 6.06-5.47 2.64-11.41 3.34-17.35 4.33C949 128 949 128 948 129c-2.78.28-5.55.45-8.34.62-2.61.37-3.6.87-5.66 2.38-2.19-.37-2.19-.37-4-1 8.34-4.74 17.51-6.4 27-7v-2c4.02-1.87 8.03-2.67 12.35-3.5 3.9-.74 7.78-1.62 11.65-2.5v-2a39 39 0 0 1 15-2"/><path fill="#6f7174" d="M991 26q1.94-.08 3.88-.12l2.17-.08C999 26 999 26 1001 28c-.37 2.63-.37 2.63-1 5l-1.6.11c-6 .56-6 .56-8.34 2.45-3.35 2.34-7.1 1.67-11.06 1.44l-1 3c-2.7 1.35-5 1.06-8 1l1-7h-12v-3l11-1 2 3c2.63.69 2.63.69 5 1l2-4h10z"/><path fill="#474550" d="M433 1423h1l1 150-3-2c-.37-2.6-.37-2.6-.36-5.95v-1.87l.03-6.22v-4.44l.06-12.07q.04-5.03.05-10.05c.1-35.8.62-71.6 1.22-107.4"/><path fill="#949395" d="M198 943h16l1 2c1.58.32 1.58.32 3.6.41l2.18.12 2.28.1 2.3.11q2.82.14 5.64.26l1 3 11 2v4q-3.37.08-6.75.13l-1.92.05c-3.77.04-6.05-.44-9.33-2.18-2.39-.42-4.72-.78-7.12-1.06-6.64-.82-6.64-.82-8.88-1.94v-2l-2.12-.31c-3.34-.8-5.86-2.07-8.88-3.69z"/><path fill="#626165" d="m1019.19 872.94 2.17.02 1.64.04c-2.02 1.4-4 2.53-6.25 3.5l-1.64.72c-5.53 2.05-11.31 3.37-17.05 4.72l-2.22.52a315 315 0 0 1-15.15 3.1c-8.11 1.47-16.1 3.17-23.89 5.9-5 1.7-10.04 2.64-15.24 3.54a125 125 0 0 0-18.3 4.4c-3.5.93-6.66.8-10.26.6 2.55-1.96 4.98-2.64 8.09-3.4l6.28-1.54 3.3-.8c6.56-1.61 13.14-3.18 19.74-4.62 2.59-.64 2.59-.64 5.56-1.66 3-.97 5.78-1.46 8.9-1.86 17.85-2.54 36.69-13.47 54.32-13.18"/><path fill="#341c61" d="m1011 537 2 3 1.13 1.5c.87 1.5.87 1.5.87 4.5h2l.37 2.12.5 2.75.5 2.75c.63 2.38.63 2.38 2.63 4.38a79 79 0 0 1 1.25 5.38 249 249 0 0 0 4.75 18.18l.75 2.63c1.26 4.2 2.38 7.43 5.25 10.81q.74 2.54 1.4 5.1a30 30 0 0 0 2.6 5.65c2.17 4.12 2.87 8.15 3.5 12.73.5 2.52.5 2.52 1.62 4.6 1.1 2.39.75 3.5-.12 5.92-3.5-7.88-6.8-15.63-9-24h-2a139 139 0 0 1-7-29h-2c-3.27-8.19-6.3-15.97-7.57-24.74a17 17 0 0 0-2.05-5.57c-1.6-3.12-1.63-5.24-1.38-8.69"/><path fill="#464549" d="M339 974c9.08-.23 18.03.07 27.09.84 31.85 2.66 63.57 3.65 95.54 3.91l3.1.03q19.14.15 38.27.22v1a33549 33549 0 0 1-52.57.08c-67.14.13-67.14.13-91.3-2.33q-2.99-.3-5.97-.56l-3.9-.37-3.38-.33c-2.7-.46-4.54-1.11-6.88-2.49"/><path fill="#2d2c33" d="M988 122h6l-1 4-1.87.31c-2.13.69-2.13.69-3 2.13-1.48 2.05-3 2.16-5.35 2.83-3 1.23-5.35 3.34-7.89 5.34A25 25 0 0 1 968 140l-2 2-6 2-2 2c-4.33 2.43-7.99 3.37-13 3 1.55-2.84 3.16-3.58 6-5l1.8-1.91c2.43-2.31 4.54-3.32 7.64-4.59 4.13-1.7 7.1-3.7 10.56-6.5q2.99-1.7 6.06-3.25l3.1-1.58c2.6-1.07 4.09-1.46 6.84-1.17z"/><path fill="#565659" d="m903 59 1 2c2.56.63 2.56.63 5 1l2-4h8l1 5c-3.13 1.86-5.37 2.2-9 2l-1 4h-7l-2 4h-7l1-7h-12v-3l11-1 2 3c2.63.69 2.63.69 5 1l2-4h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#000003" d="M788 562c-.94 1.94-.94 1.94-3 4-5.12 1.33-10.52 1.08-15.76.74-2.24.26-2.24.26-4.68 1.67-5.1 2.73-11.15 2.67-16.81 3.22l-7.08.72-3.15.3C735 573 735 573 733 574q-3.57.11-7.12.06l-2-.01L719 574v-3c5.1-1.21 10.17-1.33 15.38-1.52l2.1-.1 1.9-.08C740 569 740 569 742 567c2.03-.38 2.03-.38 4.5-.56l2.69-.23 5.62-.42 2.69-.23 2.47-.18C762 565 762 565 764 563c1.78-.32 1.78-.32 3.92-.41l2.36-.12 2.47-.1 2.42-.11A230 230 0 0 1 788 562"/><path fill="#68676a" d="M614 125h30c-1 3-1 3-2.5 3.82l-1.94.74c-4 1.86-6.8 4.29-9.87 7.38l-1.28 1.27c-4.33 4.49-6.77 7.94-6.67 14.2l.07 1.9.04 1.96.15 4.73 2 1-1 4c-3.89-4.73-4.17-8.5-4.25-14.56l-.08-3.44c.37-3.38.88-3.87 3.33-6l1-4h-19v-1l20-1 1 3c2.53-2.36 3.89-3.66 5-7l-3-1 1-1-2.77-.18-3.6-.26-1.83-.12c-4.57-.33-4.57-.33-6.8-1.44z"/><path fill="#98a5ae" d="m1565 1052 4 1v9h2l.37 1.69A455 455 0 0 0 1575 1079h2l1 8h1q1.01 4.96 2 9.94l.58 2.8A71 71 0 0 1 1583 1117l-2 1v-10h-2l-.33-2.67-.48-3.52-.22-1.75c-.56-4.01-1.8-6.68-3.97-10.06-.19-2.81-.19-2.81 0-5h-2c-1.5-3-1.06-5.66-1-9h-2l-1 2v-10h-2c-1.57-5.73-2.24-10.16-1-16"/><path fill="#c85428" d="m1409 1240 2.27.14c4.73.4 8.46 1.81 12.73 3.86l1-3 1.06 1.25c1.8 1.63 3.3 2.52 5.44 3.63 3.32 1.8 4.86 3.65 6.5 7.12q-.86-.45-1.75-.94c-3.89-1.83-7.22-2.18-11.5-2.12l-2.7.02-2.05.04 2-1v-3q-5.66-.12-11.3-.16l-3.83-.07c-8.8-.17-16.35.77-24.87 3.23q-2.5.53-5 1c1.38-1.5 1.38-1.5 3-3h2v-2c9.2-3.52 17-5.87 27-5"/><path fill="#000003" d="m1038.63 885.94 2.47.02 1.9.04-1 3-2.12.37-2.76.5-2.74.5c-2.38.63-2.38.63-4.38 2.63-2.82.41-2.82.41-6.12.63l-3.33.22-2.55.15-1 3-3 .37-3.87.5-1.98.24c-4.92.66-4.92.66-7.15 2.89-2.43.3-2.43.3-5.31.44-5.4.37-5.4.37-8.19 2.12-4.17 2.4-8.8 1.66-13.5 1.44.71-1.49.71-1.49 2-3 2.16-.3 2.16-.3 4.63-.19l2.47.08 1.9.11 1-3c3.8-1.27 7.42-1.32 11.4-1.52 2.6-.48 2.6-.48 4.1-1.98 2.2-2.2 4.08-1.94 7.13-2.06 2.97-.13 2.97-.13 5.37-.44l1-2c1.64-.58 1.64-.58 3.75-1.04l2.28-.5 2.4-.52 4.7-1.04 2.1-.45c6.1-1.55 6.1-1.55 8.4-1.51"/><path fill="#83828a" d="M1121.4 1641.89h2.29l12.61.02 13.6.02 13.88.02 27.22.05 1 3a12852 12852 0 0 1-45.1.15 3842 3842 0 0 1-19.06.06q-3.67.02-7.36.02l-2.22.02c-5.03-.02-5.03-.02-7.26-2.25 3.54-1.18 6.73-1.13 10.4-1.11"/><path fill="#010103" d="m1148.69 1037.88 1.87.02 4.44.1v3c-6.03.8-11.92 1.13-18 1l-1 3-21.31.08-7.8.02-2.42.01c-4.28 0-8.27-.26-12.47-1.11l1-3 3.22-.06a3170 3170 0 0 0 17.14-.36c8.61-.16 16.93-.35 25.38-2.15 3.33-.64 6.57-.65 9.95-.56"/><path fill="#88878b" d="M75 964c4.37.51 8.03 2.2 12 4v3l3.44.38c1.93.2 1.93.2 3.56.62l1 2c1.56.56 1.56.56 3.44 1 2.75.64 3.47.9 5.56 3-.37 2.13-.37 2.13-1 4h-8l-2-4c-4.68.62-4.68.62-6.31 2.56L86 982l-7-1v-3h8l-2-4c-4.68.62-4.68.62-6.31 2.56L78 978l-4-1v-3h5l-1.37-2.25A89 89 0 0 1 74 965z"/><path fill="#dfdedd" d="M1236 922c2 2 2 2 2.24 4.1l-.01 2.58v2.81l-.04 2.95v2.93c-.05 7.2-.05 7.2-1.19 10.63h-2l-.11 2.55-.2 3.33-.18 3.3c-.51 2.82-.51 2.82-2.06 3.9l-1.45.92c-.15 2.2-.15 2.2.06 4.81.17 5.66.17 5.66-1.81 8.32-2.25 1.87-2.25 1.87-4.5 2.18L1223 977v-5l3-1v-10l3-1 1-16h3a35 35 0 0 0 1.28-8.07l.33-7 .1-2.25.1-2.07c.19-1.61.19-1.61 1.19-2.61"/><path fill="#9ba7b1" d="m1502 835 5 3c.31 2.19.31 2.19 0 4h5l-1 6 4 1 1 5 3 1-1 4v-3h-3l.44 4.88.24 2.74c.32 2.38.32 2.38 1.32 4.38l-3-1-.37-2.44-.63-2.56-2-1c.5-2.17 1-4 2-6l-3 1-2-4-2 11h-3l-1 3z"/><path fill="#959699" d="m570.25 57.98 2.19.03 7.14.11 4.96.07 13.04.2q6.65.11 13.31.2L637 59v3h-78v-3c3.73-1.2 7.38-1.1 11.25-1.02"/><path fill="#787778" d="m1376 751 2 4 7-1c-2.66 3.09-5.1 4.6-8.88 6.09-2.64 1.13-5.1 2.51-7.62 3.91a100 100 0 0 1-10.83 5.3c-1.9.8-3.71 1.7-5.55 2.64-3.55 1.8-7.15 3.45-10.8 5.04a217 217 0 0 0-4.82 2.2l-2.44 1.13-2.21 1.05-1.85.64-2-1 6-2v-2c1.88-2.61 2.98-3 6.19-3.69l2.81-.31 1-3-2 1q-3 .06-6 0c2.2-3.47 4-4.95 8-6 2.81.38 2.81.38 5 1l-3 4 7-1v-3l3.38-.31c1.96-.27 1.96-.27 3.62-.69l1-1.5 1-1.5c1.7-.26 3.4-.52 5.12-.7 3.04-.49 5.24-1.73 7.88-3.3l1-2-3.06.94A69 69 0 0 1 1362 759c3.93-4.34 8.52-6.34 14-8"/><path fill="#505152" d="M393 262q.12 2.85.19 5.69l.1 3.2c-.29 3.11-.29 3.11-1.76 5.4-2.28 4.03-2.1 7.95-2.22 12.52l-.1 2.68L389 298l-7 1q-.12-4.2-.19-8.37l-.07-2.41-.08-4.43.34-1.79 3-2c.43-1.79.43-1.79.51-3.91l.1-2.3.08-2.42.1-2.42.21-5.95c2.67-.9 4.26-1.1 7-1"/><path fill="#7a797c" d="M1422 949c0 2.81-.2 3.81-1.31 6.25l-.74 1.64-.95 2.11-2.2 5.1q-.78 1.86-1.6 3.68c-4.5 10.2-5.8 20-5.83 31.03q0 .9-.02 1.84c-.07 11.5 1.89 22.29 8.7 31.86.95 1.49.95 1.49.95 3.49h2q1.6 2.95 3 6l-1 2c-3-3.75-3-3.75-3-6l-1.84-.68c-2.85-1.74-3.43-3.4-4.72-6.44l-1.28-3.01-1.16-2.87-.91-2.12c-5.73-13.94-6.36-32.4-2.09-46.88h2l.33-1.72c1.81-8.82 5.17-18.78 11.67-25.28"/><path fill="#5c5a5f" d="M1709 567q2.51.43 5 1l.31 1.88c.8 2.44 1.47 2.96 3.69 4.12l-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 4h-3v-5l-2.25 1.38A89 89 0 0 1 1697 594l.44-2.06c.63-3.3 1.1-6.6 1.56-9.94l4-1-.12-2.37c.12-2.63.12-2.63 2.12-4.63h3l-.06-2.94c.06-3.06.06-3.06 1.06-4.06"/><path fill="#131216" d="M433 150h1v5h3l1-5h3c.63 5.18-.74 7.67-3.71 11.68L436 163h-2v-7h-4c.62 6.52.62 6.52 2.56 8.94L434 166l-2 5h-2v-7h-4l-1 7h-3v9l-3 1-1 6h-1v-8l2-1c.21-5.15-.2-9.15-2-14-1.57-1.2-1.57-1.2-3-2l1-7h3v8l1.9-.66 2.48-.84 2.46-.84C427 160 427 160 429 160l-.19-2.37.19-2.63c1.44-1.12 1.44-1.12 3-2z"/><path fill="#57565a" d="m909.25 899.88 2.14.05 1.61.07c-3.89 3.59-6.96 4.8-12.12 5.69-6.75 1.27-6.75 1.27-9.88 2.31q-2.56.22-5.12.38c-3.62.3-6.69.76-10.07 2.06-4.77 1.76-9.5 2.24-14.52 2.79a168 168 0 0 0-15.84 2.66c-4.14.87-8.26 1.57-12.45 2.17a73 73 0 0 0-15.94 4.07c-3.77 1.07-7.17 1.05-11.06.87a16.4 16.4 0 0 1 6.94-3.04l2.27-.5 2.35-.52 6.7-1.5C826 917 826 917 828 916q2.33-.27 4.64-.5c8.06-.78 15.67-2.5 23.49-4.56 8.7-2.3 16.85-3.9 25.87-3.94v-2a124 124 0 0 1 17-2.66c2-.34 2-.34 4-1.36 2.27-1.11 3.73-1.19 6.25-1.1"/><path fill="#615d64" d="m1671 623 1 2-2 1 .22 2.25c-.28 3.48-1.23 4.35-3.72 6.74l-2.19 2.14-2.31 2.18-2.31 2.25A662 662 0 0 1 1654 647c1.07 2.92 1.78 4.78 4 7-.37 2.13-.37 2.13-1 4h-3l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-3.06-.62-3.06-.62-6-1-.25-2.31-.25-2.31 0-5 1.81-1.5 1.81-1.5 4-3l1.56-2.69L1649 645h3v-4l3-1 .81-1.81c1.48-2.73 3.16-3.65 5.74-5.26 2.8-1.8 4.3-3.7 6.01-6.55C1669 624 1669 624 1671 623m-21 30-1 5h5v-5z"/><path fill="#2e2d33" d="m924.56 157.94 2.44.06v4l-1.87.31c-2.13.69-2.13.69-3.04 2.17-1.38 1.93-2.3 2.08-4.59 2.58-2.76.62-3.4.84-5.5 2.94l-5 2-2 2-3 .44-3 .56-.96 1.46c-1.34 1.99-2.3 2.14-4.6 2.66-3.36.8-3.36.8-4.44 1.88-2.34.14-4.66.04-7 0 2.87-4.9 7.22-6.22 12.44-7.69L896 173v-2c2.56-2.3 3.48-3 7-3v-2q2.8-1.2 5.63-2.37l3.16-1.34a98 98 0 0 1 6.55-2.4c5.78-1.94 5.78-1.94 6.22-1.95"/><path fill="#472e1c" d="M1138 1348c14.59 1.59 14.59 1.59 18 5q1.87.38 3.75.65l2.35.36q2.42.37 4.85.7c4.67.73 8.87 1.79 13.24 3.6a42 42 0 0 0 5.81 1.69c10.47 2.47 10.47 2.47 13 5q2.27.34 4.56.56l2.5.26 1.94.18v2l4 1v2c-3.1-.44-5.92-.97-8.87-2a31 31 0 0 0-7.07-1.44c-3.34-.36-5.9-1.3-8.95-2.74-4.84-1.89-10.05-2.74-15.11-3.82q-4.5-.98-9-2l-3.31-.75c-4.5-1.3-8.6-3.64-12.73-5.81a62 62 0 0 0-8.96-3.44z"/><path fill="#403f44" d="M143 670c1.94.38 1.94.38 4 1l1 2q2.46 1.09 5 2c.38 2.19.38 2.19 0 5-2.33 2.81-4.72 4.83-8.37 5.31L143 685l-.19 1.81c-1.16 3.13-2.88 3.72-5.81 5.19l-2.44.94a25 25 0 0 0-5.62 3.25C126 698 126 698 123.82 697.6L122 697l1.09-1.05 4.85-4.76 1.7-1.66 1.64-1.61 1.5-1.48C134 685 134 685 135 682h-5l1-4c1.94.38 1.94.38 4 1l1 2c2.25.94 2.25.94 5 1 3.17-2.14 4.8-4.38 6-8h-5z"/><path fill="#a49eb3" d="M516 586q4.21-.04 8.44-.06l2.31-.03c4.55-.02 9.05.13 13.59.45 12.2.84 24.4.8 36.61.77h9.56q23.24.04 46.47-.9c7.34-.3 14.68-.33 22.02-.23v1a407 407 0 0 1-27.86 1.59c-16.88.52-33.76.73-50.65.94l-12.41.16L540 590v2h11v1h-25v-3l7-1-17-2z"/><path fill="#a1a0a2" d="M213 706v3h-3l-1 7-2.87.81c-3.19 1.21-3.53 1.49-5.13 4.19h-3l-2 2-2-.06-2 .06-.94 1.44C190 726 190 726 187.44 726.56L185 727l-1 2c-3.06.63-3.06.63-6 1v3h-6c.19-1.81.19-1.81 1-4a44 44 0 0 1 7.19-4c1.81-1 1.81-1 2.69-2.44 1.42-1.98 3.1-2.66 5.28-3.68a123 123 0 0 0 17.37-9.95c4.19-2.93 4.19-2.93 7.47-2.93"/><path fill="#805f45" d="M1271 1247c.68 1.67.68 1.67 1 4-1.18 2.55-1.18 2.55-2.87 5.31-3.48 6.01-3.48 6.01-3.82 9.5-.3 3.05-1.02 4.8-2.48 7.44-1.45 3.06-2.27 6.33-3.18 9.58-.65 2.17-.65 2.17-1.65 4.17h-2l-1 4h-12v-2l-3.06-.04c-13.67-.4-26.62-3.02-39.94-5.96v-1q4.5.17 9 .38l2.54.09c13.56.63 13.56.63 16.46 3.53q2.45.23 4.91.32l3 .12 3.15.12 3.17.13 7.77.31v2h4l.31-3.06c.56-4.03 1.97-7.27 3.69-10.94h2l.38-2.56c1.64-9.07 4.79-18.18 10.62-25.44"/><path fill="#321e5b" d="M1319 437c3.59 3.07 4.95 5.97 6.13 10.44 3.07 11.02 3.07 11.02 5.37 12.56 4.87 3.25 5.84 13.25 6.93 18.73.87 3.48 2.26 6.72 3.67 10 3.2 8.02 3.27 18.3-.1 26.27-1.57 1.18-1.57 1.18-3 2l1-3q.14-2.36.13-4.71v-13.84A44 44 0 0 0 1338 488l-3-1-.37-2.08-.5-2.73-.5-2.7a14 14 0 0 0-2.63-5.49q-.71-2.44-1.36-4.9c-1.02-3.36-2.45-6.55-3.84-9.77a14 14 0 0 1-.8-6.33h-2c-4.44-10.47-4.44-10.47-4-16"/><path fill="#717174" d="M1661 430h8l2 4c4.68-.62 4.68-.62 6.31-2.56l.69-1.44 7 1v3h-8l2 4c4.68-.62 4.68-.62 6.31-2.56l.69-1.44 4 1v3h-5l1.38 2.25A89 89 0 0 1 1690 447c-5.11.64-8.67-1.6-13-4v-2l-3.25.19c-3.53.01-5.09-.81-7.75-3.19l-3-1c-2.27-2.5-2-3.32-2-7"/><path fill="#433e3b" d="M1372.88 1222.81h2.15c4.27.05 8.1.4 12.2 1.7 2.06.57 4 .71 6.14.87 5.31.5 9.78 2.52 14.63 4.62q6.92 2.7 14 5v3l2.23-.28c3.8.38 4.7 1.88 7.08 4.78l2.12 2.53 1.57 1.97c-3.34 0-4.36-.81-7.01-2.66-16.17-10.9-35.96-18.51-55.62-18.53l-2.3-.03-2.23-.01-2.02-.01c-2.11.28-3.11 1-4.82 2.24q-2.67.63-5.37 1.06l-2.84.48-2.79.46-5 1c4.1-5.4 10.5-6.3 16.83-7.57 3.7-.5 7.3-.62 11.05-.62"/><path fill="#21143c" d="M1148 452c-8.18 5-8.18 5-12 5v2l-1.86.55-2.45.76-2.43.74c-2.4 1.01-3.49 2.08-5.26 3.95a47 47 0 0 1-13.06 5.43c-10.42 3.05-10.42 3.05-12.94 5.57q-2.6.3-5.21.52c-1.79.48-1.79.48-3.04 1.83-2.7 2.54-5.93 3.43-9.37 4.65l-2.18.8a310 310 0 0 1-13.4 4.56c-1.8.64-1.8.64-3.9 1.74-2.38 1.12-3.47.77-5.9-.1 2.4-2.4 3.86-2.6 7.13-3.37A58 58 0 0 0 1076 481c6.52-3.35 12.92-6.55 20.02-8.46q3.73-1.03 7.42-2.17l2.62-.78q.96-.3 1.94-.59v-2l1.94-.77c7.91-3.15 7.91-3.15 11.62-4.8a75 75 0 0 1 8-2.87c4.06-1.3 6.99-3.07 10.44-5.56 3.1-1.92 4.53-2.24 8-1"/><path fill="#d36231" d="m1367 1254 2 1-2.34 2.2-3.04 2.92-1.53 1.44c-3.35 3.26-4.83 5.84-6.09 10.44-1 1.65-1 1.65-2 2.94-1.9 2.69-2.71 5.16-3.5 8.33-.8 2.76-2.18 5.2-3.5 7.73q-.45 1.27-.94 2.56c-1.06 2.44-1.06 2.44-3.06 4.82-5.41 7.1-5.18 17.34-5.12 25.93v2.23c.05 5.86.42 11.63 1.12 17.46-5.04-5.04-3.33-15.54-3.37-22.37l-.02-2.04c-.04-14.96 2.72-27.49 11.33-40.09l1.58-2.33 1.48-2.17 2.13-3.19 1.87-2.81 1.69-2.81c3.05-4.63 7.34-8.37 11.31-12.19"/><path fill="#17171d" d="M964 420h1l.49 2.8q.91 5.19 1.85 10.38l.8 4.47c1.43 8.24 2.82 15.91 6.86 23.35 1.29 2.57 1.24 4.57 1.31 7.44l.12 2.93c-.43 2.63-.43 2.63-2.18 4.2a26 26 0 0 1-6.73 3.04l-2.53.79-2.62.79-2.64.82C953.3 483 953.3 483 951 483v2l-1.62.3c-10.93 2.08-21.84 4.66-32.36 8.28-2.34.49-3.75.06-6.02-.58l1.45-.44 6.49-2 2.28-.7c.36-.1.36-.1 2.18-.67l2.02-.62C927 488 927 488 928 487l3.06-.37c4.27-.57 7.96-2.04 11.94-3.63 7.06-2.8 14.13-4.9 21.52-6.6 3.4-.8 6.42-1.63 9.48-3.4.38-5.4.38-5.4-1.35-8.18-2.1-3.6-3.13-7.19-4.21-11.2l-.66-2.4c-2.7-10.22-4.3-20.62-3.78-31.22"/><path fill="#444247" d="M480 293c1.25 2.5.78 3.41 0 6h2v18h-1l-2-7q-.55 2.1-1.06 4.19l-.6 2.35c-.39 2.8.1 4.7.66 7.46.13 3.1.1 6.2.06 9.31L478 342c-2-2-2-2-3-8l-5 23h-1c-.36-5.18.07-9.72 1.38-14.75 1.45-5.83 2.12-11.52 2.62-17.5.78-9.38 2.19-18.52 4-27.75h2z"/><path fill="#616169" d="m444 1733 1.36.99c5.27 3.81 5.27 3.81 8.08 5.32 2.5 1.65 4.2 3.33 6.25 5.5 3.69 3.82 7.55 6.35 12.2 8.87 2.12 1.32 2.8 2.29 4.11 4.32l3 1c.69 2.06.69 2.06 1 4-4.58.2-8.47-.48-12.81-1.81l-1.86-.56q-2.67-.8-5.33-1.63l-2.63-.8c-6-1.88-12.38-4.27-17.37-8.2v-2c7.15 2.28 14.46 4.61 21.12 8.11 2.38 1.13 4.28 1.03 6.88.89l-2-1v-2l-2.81-.69c-3.9-1.6-4.15-2.7-6.19-6.31a83 83 0 0 0-11-10.37l-2-1.63z"/><path fill="#0a0b11" d="M1006 1365c5.57 1.6 9.52 3.23 14 7 1.73.79 3.46 1.46 5.25 2.1 1.75.9 1.75.9 3.43 2.74 3.23 3 6.87 4.46 10.88 6.22q2.3 1 4.57 2.04l2.03.89c2 1.1 3.29 2.37 4.84 4.01 1.58.84 1.58.84 3.25 1.5l1.85.75 1.9.75 3.63 1.5 1.6.66c2.36 1.12 4.54 2.48 6.77 3.84 5.85 3.4 11.5 6.46 18.03 8.36 2.12.69 4 1.6 5.97 2.64v1l-4.37-.44-2.47-.24c-2.16-.32-2.16-.32-4.16-1.32v-2l-1.9-.4-2.47-.54-2.47-.52c-2.16-.54-2.16-.54-4.16-1.54v-2l-2.87-.37c-3.13-.63-3.13-.63-5.13-2.63-1.95-.85-1.95-.85-4.12-1.62l-2.2-.8-1.68-.58v-2l-8-2v-2l-2-.75a113 113 0 0 1-9.04-4.35c-1.96-.9-1.96-.9-4.14-1.43-2.34-.6-2.52-1.52-3.82-3.47-2.12-.69-2.12-.69-4-1v-2l-2.22-.32c-3-.73-4.77-1.78-7.28-3.56a86 86 0 0 0-7.56-4.87L1006 1367z"/><path fill="#97989a" d="m602.82 962.8 3.3.07 3.33.06 2.55.07v1l-5 1q5.56.09 11.13.12l3.17.06c5.43.03 9.55-.35 14.7-2.18a86 86 0 0 1 8.88-.13l2.37.03 5.75.1v1c-10.76 2.41-21.28 3.33-32.29 3.6-6.73.18-13.35.46-20.02 1.46-12.73 1.86-25.86 1.1-38.69.94l1-2c5.18-1.26 10.5-1.29 15.81-1.5 5.08-.2 10.14-.44 15.2-.9l2.18-.2c3-.66 3.28-2.36 6.63-2.6"/><path fill="#cbd0d5" d="M1477 894c.6 1.82.6 1.82 1 4a55 55 0 0 1-6.19 8.56c-1.91 2.58-2.45 4.29-2.81 7.44l-5 1-.19 3.19c-.5 3.54-1.7 4.93-4.41 7.31-6.07 6.5-8.87 17.03-10.86 25.54-.86 3.14-2.18 6-3.54 8.96h-1c-.31-5.46.48-9.03 2.81-13.94l2.48-5.46c.86-1.94 1.58-3.9 2.27-5.91a60 60 0 0 1 8.19-15.07c2.05-4.3 2.01-8.91 2.25-13.62l1.64-.15c3.52-1.27 5.57-3.74 8.11-6.41l1.54-1.58z"/><path fill="#291a49" d="M1202 421c2.06.44 2.06.44 4 1l-1.24 1.43-1.63 1.88-1.62 1.87A47 47 0 0 0 1198 432l-1.56-.69c-3.63-.46-6.23 1.1-9.44 2.69v3h-3l-1 3-1-2-5.69 2.31-3.2 1.3c-3.11 1.39-3.11 1.39-5.82 3.06C1165 446 1165 446 1162 446v2l-1.42.5-6.4 2.31-2.23.8-2.16.78-1.98.72c-2.04 1-3.24 2.26-4.81 3.89-2.34.66-4.56.77-7 1a91 91 0 0 1 13.63-7.69l2.85-1.32c2.52-.99 2.52-.99 5.52-.99l1-3c1.56-.88 1.56-.88 3.44-1.56 2.55-.94 3.57-1.45 5.56-3.44l2.44-.94c2.56-1.06 2.56-1.06 4.06-2.56 1.95-1.95 3.95-2.49 6.5-3.5l4.3-2.91c2.88-1.84 5.94-3.3 9.01-4.78 6.57-3.2 6.57-3.2 7.69-4.31"/><path fill="#2e2e35" d="m1507 1242 2 1c.59 2.31.74 4.62 1 7l-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v-8h3l1 7-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2q.08 1.94.13 3.88l.07 2.17c-.2 1.95-.2 1.95-2.2 3.95-2.62-.37-2.62-.37-5-1v-10h-4v-8h-4l1-7c3-1 3-1 6 0l-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#5b5b5f" d="M1565 640h2c-.33 3.08-.95 3.96-3.31 6.06L1561 648l-2.19 1.75C1557 651 1557 651 1555 651l-.62 1.94c-1.38 2.06-1.38 2.06-4.5 2.81l-2.88.25-1 3-5 1-1 3h-2l-.56 1.81c-2.58 3.93-6.97 4.84-11.38 5.88l-2.06.31v-3l5-3a59 59 0 0 0 2.44-3c2.44-3.1 4.94-4.38 8.56-6 2.05-1.26 4.02-2.63 6-4 5.5-3.77 5.5-3.77 8.25-5.06 1.75-.94 1.75-.94 2.63-2.44 1.79-2.4 4.33-2.67 7.12-3.5z"/><path fill="#5e3a9c" d="m768 589-5.81 1.5-3.27.84C756 592 756 592 753 592v2c-2.4 1.2-4.2 1.25-6.87 1.44-5.15.42-10.17 1.2-15.25 2.08-3.94.66-7.9 1.1-11.88 1.48v2h20v1l-2.56.18c-17.95 1.33-17.95 1.33-19.44 2.82q-2.34.12-4.7.1l-2.85-.01-3.01-.03L696 605v-2l10.31-2.5 2.96-.72 2.84-.69 2.62-.63C717 598 717 598 720 598v-2l2.86-.43 15.1-2.28 6.55-1 2.04-.3A40 40 0 0 0 757 589c3.86-.92 7.16-1.12 11 0"/><path fill="#99999a" d="m1194 26 4 1v3h-5c2.6 5.09 6.46 8.76 10.5 12.75l2.05 2.07 1.98 1.98 1.8 1.79C1211 50 1211 50 1214 51v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1a78 78 0 0 0-2 5c-4.02-.5-5.7-1.26-8.19-4.44a145 145 0 0 0-9-9.94l-1.26-1.3c-2.34-2.34-4.48-4.04-7.55-5.32v-5l2.38-.81c2.59-1.17 3.38-1.72 4.62-4.19"/><path fill="#000004" d="M385 1485h1q.1 35.94.16 71.87.01 16.68.07 33.37.05 14.54.05 29.07 0 7.7.04 15.4.03 7.24.02 14.49 0 2.66.02 5.32v7.25l.03 2.16c-.02 1.95-.02 1.95-.39 5.07l-3 2v-21h1z"/><path fill="#53341f" d="M1042 1251q0 3.93-.04 7.86l-.02 5.89-.03 2.96A94 94 0 0 0 1043 1283c.25 3.56.39 7.12.52 10.68.22 4.75 1.02 8.8 2.48 13.32q.51 2 1 4c-6.96-.39-11.94-2.63-18-6 2.86-1.43 4.93-.6 8 0l1-2-4-1 4-1v-11h2l.06-3.37.04-1.9c-.1-1.73-.1-1.73-1.1-3.73-.23-3.84-.23-7.68-.24-11.52q-.02-2.85-.07-5.72l-.02-3.63-.03-3.34c.36-2.79.36-2.79 1.88-4.67z"/><path fill="#838486" d="M438 315h3q.09 5.43.13 10.88l.05 3.12q0 1.48.02 3l.03 2.75C441 337 441 337 439 339c-.2 1.92-.2 1.92-.12 4.25l.04 2.53.16 5.3.05 2.54.07 2.33C439 358 439 358 437 361l-1-22c-2.75 11.44-5.42 22.54-6.37 34.27-.33 3.94-.91 7.1-2.63 10.73-1.31-3.75-.8-7.15-.26-11l.27-2.03q.42-3.2.87-6.4l1.11-8.44.26-1.94q.69-5.12 1.75-10.19h2l-.09-2.64c.09-3.28.6-5.9 1.46-9.05A191 191 0 0 0 438 315m-4 47 2 1-1 3z"/><path fill="#0a0a0f" d="M883 70q1.94-.08 3.88-.12l2.17-.08C891 70 891 70 893 72c.13 2.13.13 2.13 0 4-3.14 1.4-5.55 2.26-9 2v4h6v2l3 1h-9l-1-2c-2.72-.41-2.72-.41-6.06-.62l-3.35-.23L871 82v2c-4.41 2.2-8.19 2.21-13 2v-1l9-2v-5l3-1 1-3h10z"/><path fill="#53525c" d="M1218 1641c2.25.31 2.25.31 4 1l-2.44.88C1217 1644 1217 1644 1216 1646c-10.74 3.62-21.84 3.41-33.03 3.34h-5.62q-7.58 0-15.14-.04l-15.86-.02q-15 0-29.98-.06-17.08-.05-34.16-.07-35.1-.04-70.21-.15v-1h2.92q35.16-.1 70.33-.24 17.01-.06 34.02-.1l29.66-.1q7.84-.05 15.7-.06 7.4 0 14.79-.05l5.4-.02c24.78.02 24.78.02 33.18-6.43"/><path fill="#484752" d="m1265 1482 3 1 .08 18.99c.04 7.63.06 15.21-.58 22.82-.7 8.58-.65 17.14-.63 25.74v8.44c0 9.12.08 18.17.96 27.25q.18 2.37.17 4.76l-2 2a43 43 0 0 1-1.12-9.63v-8.8l.02-8.48v-8.87l.03-16.8.02-19.1z"/><path fill="#9da8b1" d="M1609 1090c2.63.38 2.63.38 5 1q.18 6.27.27 12.54l.1 4.27q.1 3.06.12 6.14l.08 1.9c0 2.56-.12 4.56-1.7 6.62a107 107 0 0 1-3.87 3.53c-.49 2.24-.49 2.24-.31 4.31l.13 2.12.18 1.57h-2a4016 4016 0 0 1-.15-23.9l-.05-8.72-.03-2.75v-2.55l-.01-2.24c.24-1.84.24-1.84 2.24-3.84"/><path fill="#1a1921" d="M1285 917c2.42 4.12 2.4 7.96 2.66 12.64l.34 2.36 2 1-2 2q-.6 2.99-1 6l-.5 2.73a74 74 0 0 0-.74 8.77c-.33 9.41-1.9 18.38-3.78 27.59-.84 4.08-1.48 8.15-2.04 12.28-.59 4.02-1.41 7.3-3.21 10.93-1.46 3.4-2.03 7.08-2.73 10.7h-1a53 53 0 0 1 3.5-21.12c1.67-4.77 2.24-9.57 2.77-14.56.3-2.67.56-4.97 1.73-7.4 1.48-4.3 1.45-8.52 1.6-13.04l.12-2.96.34-9.36.57-15.15c.18-4.56.48-8.93 1.37-13.41"/><path fill="#131318" d="m1602 605 2 1a516 516 0 0 1-5.27 5.47C1597 613 1597 613 1595 613l-.69 1.75c-1.31 2.25-1.31 2.25-3.68 4.06-2.83 2.36-4.04 4.4-5.73 7.6-1.05 1.86-2.4 3.08-3.9 4.59q-1.55 2.64-3.02 5.35A18 18 0 0 1 1574 641l-2-1 1-4a413 413 0 0 0-8.75 3.56l-1.8.76c-1.45.68-1.45.68-2.45 1.68q-2.5.06-5 0l1-3c2.06-.69 2.06-.69 4-1v-3l2.38-.25 2.62-.75q1.05-1.98 2-4c2.06-.62 2.06-.62 4-1l1-2 4 1-2 1 4 2 .77-1.5 1.04-1.94 1.02-1.93C1582 624 1582 624 1585 623l.81-1.75c1.45-2.74 3.21-4.06 5.6-5.96 2.38-1.94 4.45-4.1 6.59-6.29z"/><path fill="#2b2a2c" d="m1729 506 5 2q.12 2.69.19 5.38l.1 3.02-.29 2.6-1.49.93c-1.51 1.07-1.51 1.07-1.94 2.77l-.08 2-.1 2.16-.08 2.26-.1 2.29q-.12 2.8-.21 5.59l-7 1q-.08-3.62-.12-7.25l-.06-2.07c-.03-3.87.27-6.24 2.18-9.68.32-2.56.44-5.09.56-7.66.51-2.72 1.33-3.62 3.44-5.34"/><path fill="#15141b" d="M1063 105v3c-2.05 1.87-2.05 1.87-4.75 3.88A44 44 0 0 0 1049 121c-3.57 3.39-6.1 4.54-11 5v3c-3.17 1.63-6.3 3.13-9.62 4.44-3.13 1.3-5.15 2.97-7.38 5.56l1 3-2.44.88C1017 144 1017 144 1016 146l-1.8-.32-2.39-.37-2.35-.38c-3.24.1-4.79 1.27-7.46 3.07q-2.77 1.55-5.56 3.06l-2.82 1.54q-3.29 1.75-6.62 3.4c2.98-4.74 6.52-6.5 11.56-8.79 2.4-1.2 4.34-2.55 6.44-4.21 2.83-1.91 4.97-3 8.31-3.81 3.97-1.02 6.68-3.23 9.85-5.75 2.17-1.7 4.48-3.02 6.84-4.44l3.94-3a40 40 0 0 1 7-4.44c4.47-2.28 8.15-5.43 12.06-8.56q2.5-1.95 5-3.87c3.88-3 3.88-3 5-4.13"/><path fill="#6d6d6f" d="M472.11 1819.77c1.89.23 1.89.23 3.71 1.22 2.44 1.13 4.24 1.33 6.92 1.47l2.73.18q2.82.18 5.65.3c6.15.45 10.42 1.62 14.88 6.06v1h-27l-2-4h-10a89 89 0 0 1-1-5c1.7-1.7 3.84-1.18 6.11-1.23"/><path fill="#67686a" d="M1181 1024c-28.98 20.99-73.59 17.06-107 12v-1l2.42.02q11.4.09 22.77.14 5.85.01 11.7.07 5.66.05 11.33.05l4.29.04c7.98.08 15.08-.31 22.82-2.6a59 59 0 0 1 8.73-1.53c5.58-.84 9.64-2.93 14.37-5.95 3.04-1.47 5.23-1.46 8.57-1.24"/><path fill="#e9e9ea" d="M1415 971h2q.08 2.69.13 5.38l.07 3.02c-.2 2.6-.2 2.6-2.2 4.6a70 70 0 0 0-.41 6.54l-.06 1.94-.15 6.14-.12 4.17q-.14 5.1-.26 10.21l4 1v11l-4-1c-4.2-7.67-4.67-16.25-4.56-24.81l.01-3.42c.2-9 1.79-16.63 5.55-24.77"/><path fill="#a996c2" d="m481.64 605.77 3.17.04 3.15.02c3.04.17 3.04.17 5.43.67 2.54.49 4.8.61 7.37.6l2.77-.01 2.95-.03h3.06l9.65-.06 15.7-.09h2.74l2.42-.02C542 607 542 607 544 608c2.13-.44 2.13-.44 4-1v2c1.5 1.13 1.5 1.13 3 2l-31.85.08a3769 3769 0 0 1-18.67.03l-3 .01C495 611 495 611 492 610q-1.78-.3-3.57-.54l-2.03-.26-2.09-.26L477 608c2-2 2-2 4.64-2.23"/><path fill="#86848f" d="M1316 1580h1c1.14 18.03 2.2 36.05 2.25 54.13l.02 1.9c.05 7.69.02 15.56-2.27 22.97l-2 1c-.11-26.69-.07-53.33 1-80"/><path fill="#2d1954" d="m1149.19 452.81 2.81.19c-.69 1.94-.69 1.94-2 4q-2.5.55-5 1l-.94 1.43c-1.32 1.95-2.38 2.32-4.56 3.13-2.52.95-3.53 1.47-5.5 3.44l-3.06 1c-2.94 1-2.94 1-3.94 3l-2.69-.25c-3.31.25-3.31.25-5.75 2.25-3.46 2.7-5.27 2.34-9.56 2l-2 4-4-1v-3l2.38-.25 2.62-.75.81-1.94c1.19-2.06 1.19-2.06 2.87-2.66q2.1-.47 4.22-.9a22 22 0 0 0 5.47-2.12C1124 464 1124 464 1126 464l1-3c1.53-.75 1.53-.75 3.5-1.28l2.13-.61 2.24-.61q2.2-.6 4.37-1.22l1.97-.53c1.79-.75 1.79-.75 3.33-2.32 1.46-1.43 1.46-1.43 4.65-1.62"/><path fill="#7f6aa4" d="m911.69 540.94 3 .02 2.31.04c-1.32 2.63-2.27 2.92-5 4q-1.8.3-3.62.5c-3.38.5-3.38.5-5.38 2.5-3.12.13-3.12.13-6 0v2c-5.4 3.21-10.83 4.07-17 5l-2.27.34q-2.36.35-4.73.66l1-2-11-1c3.47-1.74 4.82-2.25 8.44-2.5 3.56-.33 6.26-1.1 9.56-2.5q2.55-.35 5.11-.66c2.78-.5 5.29-1.54 7.9-2.58 2.95-1.13 5.95-1.93 8.99-2.76 3.15-1.05 5.39-1.1 8.69-1.06"/><path fill="#2a282f" d="M1142 827h8v3c-10.56 4.7-20.8 8.23-32.11 10.57-1.93.44-3.8.97-5.7 1.54-3.19.89-3.19.89-6.19.89v-3c1.84-1.74 3.63-2.2 6.06-3 2.94-1 2.94-1 4.94-3 2.27-.34 4.51-.44 6.8-.56 2.2-.44 2.2-.44 3.6-1.94 2.39-2.24 4.52-1.94 7.72-2.06 3.26-.13 3.26-.13 5.88-.44z"/><path fill="#ae9ec8" d="M647 599h20l-2 3c-2.25.43-2.25.43-5 .51l-3 .1-3.12.08-3.16.1q-3.86.12-7.72.21v2c-7.46 1.83-14.47 2.25-22.12 2.13l-3.2-.03-7.68-.1-2-5h32q1.85-.22 3.69-.5l3.31-.5z"/><path fill="#141319" d="m1000.25 95.38 1.75.62-6 1v2l-2.54.81a103 103 0 0 0-19.09 8.38c-5.73 3.26-10.45 4.83-17.08 5.46C955 114 955 114 953 116c-1.63.71-1.63.71-3.56 1.38a109 109 0 0 0-8.88 3.62 105 105 0 0 1-11.43 4.28 65 65 0 0 0-5.75 2.28c-4.95 2.19-10.07 3.47-15.38 4.44 3.04-3.47 6.11-5.19 10.38-6.75l1.67-.67A25 25 0 0 1 931 123l1-3c4.36-2.63 9.3-4.21 14.22-5.47 1.78-.53 1.78-.53 4.98-1.95 3.92-1.71 7.9-2.77 12.05-3.77l2.14-.53c3-.74 5.5-1.28 8.61-1.28v-2h3v-2c4.8-2.2 9.58-3.83 14.67-5.25C994 97 994 97 996.16 95.89c1.84-.89 1.84-.89 4.09-.52"/><path fill="#616267" d="M471 90c1.94.38 1.94.38 4 1l1 2q2.46 1.09 5 2c-.34 3.93-1.5 6.1-4.4 8.8-2.45 1.84-5.13 3.07-7.93 4.3-1.67.9-1.67.9-2.67 2.9h-4l-.25 2.31c-.91 3.28-2.06 3.76-4.75 5.69l-.44 2c-.56 2-.56 2-3.12 3.75L451 126c-.75-2.25-.75-2.25-1-5l2-1.94 2-2.06c-.25-3.25-.25-3.25-1-6 1.75-1.06 1.75-1.06 4-2l1.94.6c2.06.4 2.06.4 3.7-.63l1.56-1.61 1.72-1.75c.3-.3.3-.3 1.77-1.86l1.8-1.82c4.36-4.49 4.36-4.49 5.51-7.93h-5z"/><path fill="#0a0a10" d="M1391 423c45.35-.38 45.35-.38 60.88 3.28 3.37.78 6.69 1.27 10.12 1.72v1c-8.43.3-16.72-.21-25.12-.94l-3.1-.26c-7.4-.67-7.4-.67-10.78-1.8q-3.31-.23-6.64-.32l-1.93-.06-10.27-.31L1394 425l1.81 5.38 1.02 3.02c1.18 2.63 1.72 3.3 4.17 4.6 1.2 1.8 1.2 1.8 2.25 3.88l1.08 2.05c.79 2.44.5 3.69-.33 6.07-2-2-2-2-2.12-4.12l.12-1.88-3-1c-1.5-2.33-1.5-2.33-3-5.25a85 85 0 0 0-4.75-8.36c-1.47-2.8-1.16-3.49-.25-6.39"/><path fill="#b0b0b1" d="M519 74h11c-2.68 5.36-5.45 7.06-11 9-4.1 1.36-7.63 2.26-12 2l-1 3h-7c-1-3-1-3 0-6h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h10z"/><path fill="#392718" d="M1062 1237c.93 3.01 1.04 3.87 0 7a95 95 0 0 0-.19 5.5l-.04 2.9c.23 2.6.23 2.6 1.25 4.01 1.62 2.62 1.05 5.54.92 8.53-.2 8.16 1.44 15.26 4.04 22.99q1.57 4.8 2.9 9.7c1.6 5.77 3.46 11.4 5.48 17.04.64 2.33.64 2.33-.36 4.33l-6-12-1.12-1.94c-1.29-3.02-1.6-6.07-2-9.31q-.14-.97-.27-1.97l-.61-4.78-3-1c-3.92-8.81-3.67-19.34-3.94-28.8a345 345 0 0 0-.22-5.8c-.18-6.1.44-10.9 3.16-16.4"/><path fill="#2e2e38" d="M819 1056v1l-18 2v2l2.05-.29 2.7-.34 2.67-.35c2.8-.02 4.21.53 6.58 1.98l-1.51.04q-3.4.13-6.8.27l-2.38.07-2.3.1-2.1.09c-2.46.55-3.37 1.48-4.91 3.43-.69 2.69-.69 2.69-1 5l-3-3c-2.92 5.6-2.92 5.6-3.37 8-.86 2.75-2.55 4.43-4.47 6.54C782 1084 782 1084 781 1087l-2 1-1.25 3.13c-1.5 3.73-3.66 6.29-6.34 9.25A38 38 0 0 0 767 1107c-1.98 3.24-3.69 6.01-7 8a57 57 0 0 1 4.8-8.22q1.35-2.01 2.69-4.06l1.57-2.4 1.48-2.27c1.46-2.05 1.46-2.05 3.08-3.64 2.06-2.1 2.48-4.64 3.38-7.41q1.44-2.05 3-4l1.06-2.19c.94-1.81.94-1.81 2.94-2.81.9-2.06 1.69-4.08 2.44-6.19 4.1-11.35 4.1-11.35 7.56-14.81 2.36-.31 4.63-.51 7-.62l2.03-.12q7.98-.42 15.97-.26"/><path fill="#6e6e71" d="M754 934c-6.38 2.07-12.81 2.95-19.44 3.88l-7.27 1.05-1.86.26q-8.98 1.34-17.93 2.87l-3 .5q-6.66 1.12-13.3 2.29c-19.92 3.44-40.02 4.56-60.2 4.15v-1l1.96-.14c12.76-.9 25.5-1.93 38.23-3.3l3.2-.33c6.38-.71 12.6-1.79 18.86-3.1 6.93-1.42 13.92-2.28 20.94-3.13l8.12-1.01 2-.25q9.93-1.28 19.8-2.96l2.21-.36 1.88-.32c2.1-.12 3.79.3 5.8.9"/><path fill="#353437" d="M1442 1126q.12 2.1.19 4.19l.1 2.35c-.29 2.46-.29 2.46-1.74 4.8-5.48 9.4-3.98 23.35-4.17 33.91l-.12 5.18q-.14 6.3-.26 12.57h2v2l1.81.69c2.78 1.67 3.75 3.44 5.19 6.31v2h5l2 5-1.62-.87c-2.38-1.13-2.38-1.13-5.57-2-.46-.2-.46-.2-2.81-1.13-.87-2.12-.87-2.12-1-4h-6l-.08-34.41-.03-14.55c-.06-16.25-.06-16.25 1.92-20.98 1.69-1.5 3-1.14 5.19-1.06"/><path fill="#3e2b69" d="m1251 416-4 1v2l-1.83.77c-4.1 1.77-7.61 3.5-11.17 6.23q-1.7.75-3.44 1.44a15 15 0 0 0-5.75 3.62c-5.6 3.86-13.14 4.09-19.81 3.94 1.26-3.77 2.79-4.73 6-7l5-2q1.77-1.34 3.46-2.78c3.34-2.65 6.98-4.9 10.54-7.22l2 1c-.98 2.45-1.65 3.77-3.87 5.25-2.2.78-3.82.92-6.13.75l-2 4h2l1-2c2.26-.71 2.26-.71 5.13-1.37A43 43 0 0 0 1241 418h2v-2c3.05-.98 4.95-.98 8 0"/><path fill="#68666b" d="m1377 1770 2 1a43 43 0 0 1-8.63 8.79c-1.37 1.21-1.37 1.21-2.36 2.8-1.01 1.41-1.01 1.41-4.01 2.41l-.69 2.56c-1.17 4.27-4.36 6.7-7.53 9.6-1.78 1.84-1.78 1.84-2.78 4.84h-7l-1 3-3 2-1 3h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2c-.37-2.25-.37-2.25 0-5a12.7 12.7 0 0 1 8.88-4.25l2.12.25.81-1.94c1.19-2.06 1.19-2.06 4.19-3.06l1.44-1.94c1.73-2.28 2.88-3.09 5.56-4.06 2.25-.12 2.25-.12 4 0v-5l1.82-.8c2.31-1.27 3.48-2.46 5.12-4.51 2.54-3.1 5.2-5.9 8.06-8.69"/><path fill="#413f4b" d="M494 1590c3 1 3 1 4 3l3 .94 3 1.06 1 3h22v-2l-12-1v-1q3.19-.08 6.38-.12l1.82-.06c1.6-.01 3.2.08 4.8.18l2 2 7-5 2 1-2.25 1.75a57 57 0 0 0-4.94 4.44c-5.14 5.14-15.02 3.87-21.87 4.06-6.68-.1-13.96-.33-18.96-5.23A53 53 0 0 1 487 1591l1.86.96c2.05 1 3.92 1.56 6.14 2.04z"/><path fill="#261811" d="M1270 1253c.69 1.69.69 1.69 1 4l-1.37 2.06c-2.45 4.42-2.38 8.97-2.63 13.94l2-2c-.25 2.38-.25 2.38-1 5l-1.88.7c-3.34 2.05-3.75 5.16-4.8 8.8l-1.27 4.1-.54 1.83c-.51 1.57-.51 1.57-1.51 3.57-.13 1.64-.13 1.64-.15 3.6l-.02 4.66v2.5l-.02 5.24q0 4-.03 7.97l-.01 5.1-.02 2.4c.02 4.9.75 8.87 2.25 13.53.2 2.43.2 2.43.13 4.44l-.06 2.06-.07 1.5c-7.05-9.48-5.25-26.54-5.35-37.92q-.03-2.08-.08-4.16c-.24-8.72.3-15.35 4.18-23.3 1.66-3.48 2.65-7.05 3.67-10.76.58-1.86.58-1.86 1.58-2.86q.44-2.06.81-4.12c.97-4.62 2.67-7.92 5.19-11.88"/><path fill="#da4308" d="M1405.25 1244.81h2.9c4.72.03 9.21.22 13.85 1.19v3l-2.04.06c-38.83 1.2-38.83 1.2-47.96 5.94l-1 2-2.06 1.13c-4.23 2.7-7.48 6.26-10.94 9.87 0-3 0-3 2.15-5.41q1.38-1.35 2.79-2.65c4.95-4.73 4.95-4.73 6.06-6.94 2.3-1.1 4.57-2.07 6.94-3l1.9-.79c9.02-3.6 17.76-4.52 27.41-4.4"/><path fill="#5c5c60" d="M319 648c-.19 2.31-.19 2.31-1 5-2.44 1.69-2.44 1.69-5 3l-2 2q-2.97 1.08-6 2c1-3 1.75-3.95 4-6l-1.39.53-6.3 2.4-2.18.85c-3.7 1.4-7.21 2.6-11.13 3.22v2c-2.9 1.26-4.8 2-8 2v2c-2.9 1.26-4.8 2-8 2l-.13 1.83c-.87 2.17-.87 2.17-3.49 3.65l-3.25 1.27-3.25 1.3C259 678 259 678 256 678l-1 3c-1.38.5-2.76 1-4.18 1.4-2.86.95-5.44 2.44-8.09 3.9l-1.73.7-2-1c4.43-3.64 8.48-6.46 13.93-8.3a93 93 0 0 0 6.63-2.58l2.07-.86a43 43 0 0 0 5.65-3.64c3.7-2.7 7.59-4.7 11.72-6.68 4.58-2.22 9.05-4.48 13.4-7.1a22 22 0 0 1 5.1-1.84 24 24 0 0 0 7-3c4.46-2.69 9.2-4.42 14.5-4"/><path fill="#c79ef1" d="M392 570h1a1719 1719 0 0 1 .1 12.99l.01 2.1C393 587 393 587 392 590a34 34 0 0 0 1 4.38c.87 3.54 1.15 6.06 0 9.62h-1l-1-4-1 5h-2v2l-2 1c-.36 3.07-.44 6.12-.56 9.21-.44 2.79-.44 2.79-1.98 4.17L382 622q-.08-3.09-.12-6.19l-.08-3.48c.2-3.34.92-5.28 2.2-8.33.77-3.4 1.3-6.85 1.86-10.3l.36-2.08.3-1.89c.53-1.9 1.39-3.1 2.48-4.73.34-2.81.34-2.81.5-6 .32-6.63.32-6.63 2.5-9"/><path fill="#311f58" d="M1215 420a57 57 0 0 1-4 7l6-4 2 1c-2.05 2.28-3.55 3.83-6.44 4.94-3.4 1.4-5.08 3.37-7.56 6.06l9-1c-4.9 4.14-9.65 5.3-16 5a29 29 0 0 1 5-9l-2.12 1.88c-4.08 3.25-8.82 4.23-13.88 5.12v-4c6.23-3.1 6.23-3.1 9.44-2.69l1.56.69 1.75-2.75c4.63-6.85 4.63-6.85 9.31-8 2.94-.25 2.94-.25 5.94-.25"/><path fill="#121317" d="m472 105 2 1q-1.65 1.77-3.31 3.5l-1.87 1.97C467 113 467 113 464 113v5l1-1q2-.06 4 0c-.53 3.99-1.52 6.06-4.25 9l-1.9 2.07L461 130l-1.47 1.55c-2.02 2.09-4 3.99-6.53 5.45v-3l-3-1h3l1-3 3-1q.53-2 1-4c1.56-1.75 1.56-1.75 3-3l-1-2h-3l.23 1.78c-.32 3.08-1.55 3.8-3.92 5.72l-2.2 1.82q-1.05.82-2.11 1.68l-1.64 1.34q-1.67 1.34-3.36 2.66c1.47-3.4 3.38-5.65 6-8.25l2.13-2.14C454 123 454 123 456 123l.25-2.31c.91-3.28 2.06-3.76 4.75-5.69.75-2.19.75-2.19 1-4h4l1-3c2.44-1.75 2.44-1.75 5-3m1 5h4v4h-3l-1 3h-4l1-4h3z"/><path fill="#2c2d30" d="m706.9 955.9 2.14.01 2.21.03 2.26.01 5.49.05a23 23 0 0 1-8.9 2.82l-3.02.42-3.33.45a586 586 0 0 0-25.7 4.1l-2.58.46-2.61.48c-15.32 2.8-30.23 5.4-45.86 5.27v-1l40.85-7.22c9.41-1.67 18.8-3.24 28.29-4.39 2.86-.39 2.86-.39 4.8-.9 2.06-.5 3.83-.6 5.95-.59"/><path fill="#c2a0e8" d="m398 672 1 45h-4l-.31-1.94L394 713l-3-1q-.33-2.2-.62-4.37l-.36-2.47L390 703l2-2 1 8 .04-3.18q.1-5.84.22-11.66l.09-5.06q.05-3.62.14-7.25l.02-2.29c.06-2.12.06-2.12.49-5.56 2.41-2 2.41-2 4-2"/><path fill="#1a1a20" d="M444 530c6.35-.33 12.07.93 18.25 2.31 13.74 2.96 27.4 5.62 41.46 6.35 5.15.27 10.17 1.02 15.26 1.87 3.73.58 7.44.88 11.2 1.13l1.83.34 1 2c-8.09.18-15.79.3-23.73-1.43-5.66-.99-11.38-1.39-17.1-1.87l-5.08-.43C485 540 485 540 482 539q-1.95-.27-3.91-.43l-2.3-.22-2.42-.23c-5.75-.58-11.06-1.27-16.48-3.4-2.66-1.01-5.38-1.6-8.16-2.2A15 15 0 0 1 444 530"/><path fill="#3a3a43" d="m1232.06 1762.88 2.29.05 1.65.07c-5.12 3.98-11.47 4.45-17.69 5.06l-4.5.48-2 .2c-1.81.26-1.81.26-4.81 1.26a110 110 0 0 1-5.52.12h-11.33l-10.92-.02h-11.4l-21.62-.03-24.6-.02-50.61-.05v-1h1.85q22.5-.1 45.02-.24 10.88-.06 21.77-.1l19.01-.1q5.01-.05 10.03-.06c18.98-.04 37.9-.66 56.45-5.1 2.34-.5 4.55-.6 6.93-.53"/><path fill="#8148c5" d="M384 720c3.56.61 6.81 1.28 10 3l.69 2.5c1.31 2.5 1.31 2.5 3.65 3.27q3.8.75 7.66 1.23l-3-4 7-1 .56 1.94C412 729 412 729 413.7 729.49c1.76.24 3.54.38 5.31.51v4h9l1 4 7 1v3c-4.83.34-6.95-.53-11-3q-2.48-.35-4.98-.63c-6.35-1.16-12.3-4.5-18.02-7.37q-3-1.14-6.03-2.24A77 77 0 0 1 388 725v-2l-4-2z"/><path fill="#45454a" d="M1378 366a7990 7990 0 0 1 35.63-.15 2391 2391 0 0 1 15.07-.06q2.91-.03 5.84-.02l3.35-.01c3.41.26 5.93 1.03 9.11 2.24l-1 2h-68z"/><path fill="#0a0b10" d="M560 78h73c-4.11 3.09-5.8 3.43-10.57 3.36h-1.98l-6.43-.03h-4.47l-9.37-.05q-6-.04-12.02-.04a3691 3691 0 0 1-21.7-.08c-4.23-.05-4.23-.05-6.46-1.16z"/><path fill="#b2b3b8" d="M1079 2h57l1 5q-2.85.08-5.69.13l-3.2.07C1125 7 1125 7 1122.72 6c-4.15-1.53-8.2-1.29-12.6-1.25l-2.64-.02c-3.9 0-7.45.1-11.19 1.27-3.52 1.07-6.43 1.27-10.1 1.25l-3.4.02C1080 7 1080 7 1078 5z"/><path fill="#14151c" d="M835 1049c3.47 2.21 5.25 5.37 7 9l1.44.94c1.56 1.06 1.56 1.06 2.93 3.5 1.63 2.56 1.63 2.56 3.87 3.86 3.36 2.07 5.84 4.56 8.57 7.39L864 1079c-3 0-3 0-5.37-2.31l-1.32-1.4c-1.31-1.29-1.31-1.29-3.62-2.6-6.85-4.3-16.1-12.92-18.69-20.69l-3.1.59c-12.9 2.34-25.82 3.48-38.9 4.41a787 787 0 0 0-5.5 14.88l-.6 1.71c-1.14 3.06-1.94 4.91-4.9 6.41-1.19 1.63-1.19 1.63-2 3l1-5 3-1c.66-1.82.66-1.82 1.06-4.12a90 90 0 0 1 2-8.7c.94-3.18.94-3.18 1.38-5.5.75-2.25 1.44-2.6 3.56-3.68 2.7-.45 2.7-.45 5.92-.75l3.55-.35 5.56-.5c12.56-1.15 12.56-1.15 18.3-3.75a16 16 0 0 1 9.67-.65"/><path fill="#cdcccb" d="M1132 980c-2.6 2.6-4.4 2.63-8 3.31a215 215 0 0 0-19.6 4.98 72 72 0 0 1-12.66 2.48c-2.13.28-4 .88-5.99 1.67-4.4 1.55-8.74 2.32-13.35 3.03q-5.94.93-11.84 2.03l-2.04.36A51 51 0 0 0 1048 1001q-2.8.6-5.62 1.06c-2.94.49-5.55 1-8.38 1.94-2.69-.44-2.69-.44-5-1 1-2 1-2 3.81-3 4.2-1.1 8.46-1.75 12.73-2.43 3.92-.63 7.81-1.35 11.71-2.07l1.97-.36c3.89-.73 7.71-1.6 11.55-2.6 5.58-1.36 11.21-2.5 16.83-3.68 8.3-1.79 8.3-1.79 12.12-2.95a74 74 0 0 1 11.22-2.03q1.02-.14 2.07-.27l4.99-.61v-2c4.75-.78 9.19-1.1 14-1"/><path fill="#92a1a9" d="M1521 894h1l1 13h3v3h4v4l-6 2-.08 2.14-.36 9.61-.12 3.37-.12 3.24-.11 2.98c-.19 2.35-.55 4.4-1.21 6.66l-3-1a2737 2737 0 0 1-.15-19.64c-.1-10 .2-19.52 2.15-29.36"/><path fill="#a87fda" d="m540 684 2 1v71l-3-1-.08-38.72a5636 5636 0 0 1-.03-22.7l-.01-1.9c0-2.86.2-4.93 1.12-7.68"/><path fill="#020108" d="M1295 570c-1.28 3.84-2.5 4.1-6 6l-3.04 1.75q-2.38 1.38-4.76 2.73a28 28 0 0 0-6.2 4.52l-5 2q-2.3 1.44-4.5 3c-3.09 2.17-6 3.6-9.5 5l-2 2c-2.62.13-2.62.13-5 0 3.92-6.35 11.08-9.7 18-12l.8-1.8c1.7-3.13 4.34-4.51 7.33-6.26l1.77-1.06c5.72-3.36 11.26-6.52 18.1-5.88"/><path fill="#08080e" d="m1659 491 2 1v4h2c1.88 3.97 2.35 7.48 2.31 11.81l-.01 1.96c-.11 5-.68 9.87-1.55 14.8l-.33 1.97c-.79 4-2.07 6.73-4.47 10-1.5 2.3-2.11 4.87-2.95 7.46a99 99 0 0 1-3.31 5.25c-2.95 4.48-5.4 9-7.65 13.85-1.04 1.9-1.04 1.9-3.04 2.9.81-6.88 3.26-11.36 7.44-16.84C1651 547 1651 547 1652 544l1.46-.91c2.14-1.51 2.38-2.98 3.1-5.47.7-2.33 1.33-4.41 2.5-6.56 1.98-4.32 1.7-9.37 1.94-14.06l.13-2.45c.2-7.91-1.04-15.73-2.13-23.55m-19 75 2 1-4.81 6.94-1.37 2-1.34 1.9-1.22 1.76C1632 581 1632 581 1629 582c1.2-3.8 2.79-5.96 5.7-8.66 2.15-2.2 3.66-4.74 5.3-7.34"/><path d="M664 178h55v3c-6.32.94-12.46 1.13-18.84 1.1l-12.66-.04-6.54-.01L665 182z"/><path fill="#0d0e14" d="M432 1077c4.17 5.98 4.7 12.8 5.63 19.88l.4 2.89c.74 5.46 1.11 10.72.97 16.23h2c2.6 22.43 3.28 44.7 3.19 67.25l-.01 2q-.05 12.38-.18 24.75h-1l-.02-1.86a5834 5834 0 0 0-.4-26.64c-.16-12.46-.43-24.76-2.13-37.12-.62-4.66-.85-9.3-1.02-14A28 28 0 0 0 438 1122c-1.14-3.46-1.28-6.65-1.37-10.25-.28-7.05-1.5-13.63-3.25-20.46A49 49 0 0 1 432 1077"/><path fill="#141317" d="M432 799c4.14-.3 7.7-.32 11.56 1.31 5.47 2.26 11.21 3.12 16.99 4.21 6.06 1.18 12.1 2.5 18.15 3.77 4.75 1 9.5 1.97 14.3 2.71v2l3.44.38q11.28 1.27 22.56 2.62v2l9 1v1c-7.13.34-13.93-.78-20.93-1.97l-6-.99c-6.92-1.14-13.49-2.6-20.07-5.04-7.49-2.29-15.25-3.05-23-4v-2c-4.65-.81-9.31-1.44-14-2v-2l-12-2z"/><path fill="#56368d" d="M893 720v2h10v1l-3.14.59-4.11.78-2.07.39c-3.57.7-6.44 1.54-9.68 3.24-1.76.24-1.76.24-3.69.31-7.93.7-17.16 3.11-24.31 6.69q-3.06.1-6.12.06l-3.33-.02L844 735v-1l7-2-4-2 5.27-.59C854 729 854 729 856 727l3 1-1 1c5.79.48 10.23-.52 15-4l1-3 3.21-.18 6.28-.38c3.32-.2 6.36-1.44 9.51-1.44"/><path fill="#717077" d="M1003 1641a72738 72738 0 0 1 108.37-.15 19414 19414 0 0 1 45.8-.06 4501 4501 0 0 1 20.41-.03h7.49l2.18-.02c4.34.03 7.7.67 11.75 2.26-2 2-2 2-4.62 2.13l-2.38-.13-1-2-188-1z"/><path fill="#aea7c1" d="M756 578c-2.3 2.3-3.24 2.43-6.37 3l-2.68.5q-6.24 1.06-12.48 2.04C732 584 732 584 730 585q-3.38.13-6.76.1h-2l-10.67-.05L700 585c1.3-2.62 2.3-2.93 5-4 2.18-.37 4.28-.65 6.47-.85l1.9-.2 6.13-.58 2.1-.2A271 271 0 0 1 756 578"/><path fill="#ad8cd7" d="m460 609 5 1q4.84.74 9.69 1.38l2.86.38c11.8 1.59 23.62 2.92 35.45 4.24-2.57 2.11-4.3 2.2-7.57 2.01l-2.72-.14-2.83-.18-2.77-.15q-7.58-.43-15.11-1.33a74 74 0 0 0-7.94-.34h-2.77C469 616 469 616 467 617c-.41 1.63-.41 1.63-.62 3.56L466 624l-2-1-1-3-2 8h-1z"/><path fill="#111117" d="m1394 425 16.38-.08 5.98-.02 1.88-.01c2.89 0 4.99.19 7.76 1.11q2.12.27 4.24.43l2.6.22 2.82.22 9.17.74c18.1 1.47 36.16 3.06 54.17 5.39-2 2-2 2-4.88 2.05q-1.85-.14-3.68-.3l-1.93-.16q-7.18-.63-14.34-1.54a641 641 0 0 0-28.36-2.61l-11.92-.92c-4.02-.31-7.92-.84-11.89-1.52q-3.06-.06-6.12 0a171 171 0 0 1-18.88-1v5h2l1 6c-3.33-2.56-4.33-4.66-5.13-8.66q-.26-1.2-.5-2.46z"/><path fill="#9d9d9f" d="m1450 1190 7 1v3h-8l2 4c4.68-.62 4.68-.62 6.31-2.56l.69-1.44 4 1v3h-5c2.28 4.56 5.56 7.66 9.26 11.13 1.74 1.87 1.74 1.87 2.74 4.87 1.41 1.39 1.41 1.39 3.06 2.69l1.66 1.32 1.28.99c-3.07 0-3.75-.2-6.19-1.81l-1.6-1.02-1.21-1.17v-3l-2.12-.25c-3.58-.93-5.94-2.57-8.88-4.75q-.74-.53-1.5-1.05c-1.37-1.01-1.37-1.01-3.5-2.95v-3h-5l-1-6 1.88-.31c2.44-.8 2.96-1.47 4.12-3.69"/><path fill="#8796a0" d="M1579 1054c3.73.41 5.22 2.18 7.7 4.86 1.9 1.66 3.86 1.8 6.3 2.14 1 2 1 2 .94 4.38.07 2.84.47 4.28 2.06 6.62l3 1v7l3 1q.33 2.15.63 4.31l.35 2.43c.02 2.5-.39 3.38-1.98 5.26v-2l-3-1v-10h-3l-1-6h-4l-1-3-2 1c-1-4-1-4-1.25-5.94-.94-2.6-2.61-3.9-4.6-5.76-1.69-1.91-1.94-3.81-2.15-6.3"/><path fill="#09090f" d="M404.89 1054.66q1.83 0 3.65.05l1.94.01q3.08.03 6.14.1 2.1 0 4.17.03l10.21.15c1.55 3.1 1.44 6.37 1.72 9.79.28 2.21.28 2.21 1.28 3.21.32 2.06.51 4.12.72 6.19.28 1.81.28 1.81 1.28 2.81 1.09 5.68 1.1 11.23 1 17h-1l-.37-1.94-.5-2.62-.5-2.57c-.63-2.87-.63-2.87-1.63-6.12-1.26-4.31-1.65-8.67-2.06-13.12q-.23-2.43-.48-4.84l-.2-2.14C430 1059 430 1059 429 1058q-3.94-.15-7.88-.12l-2.22-.02h-2.14l-1.97.01c-1.79.13-1.79.13-4.79 1.13l-3.06-.12c-2.94.12-2.94.12-4.38 1.37-.56 1.75-.56 1.75-.06 3.69.5 2.06.5 2.06-.38 3.65-1.96 4.2-2.29 8.83-2.98 13.39l-.36 2.25-.3 2.05C398 1087 398 1087 396 1089c-.24-5.2.3-9.03 2-14q.01-2.5-.05-5c.4-14.57.4-14.57 6.94-15.34"/><path fill="#5b595e" d="M200 904q5.69 1.12 11.31 2.56l3.24.82 2.45.62v-2h8l1 4h10l-.19 3.19c.24 1.92.24 1.92 1.19 3.81 3.48 2.18 7.14 3.62 11 5-2 1-2 1-5.16.1l-3.9-1.35-4.1-1.42-2-.7c-2.27-.78-4.55-1.48-6.84-2.2a46 46 0 0 1-9.46-4.52 23 23 0 0 0-10.73-3.28c-2.07-.21-2.07-.21-3.81-.63-1.5-2.06-1.5-2.06-2-4"/><path fill="#403f4a" d="m638 1197-3 1v2l4 2-2.48.11-3.27.2-3.23.18c-3.02.51-3.02.51-4.66 2.02-3.58 2.26-6.93 1.86-11.06 1.78l-2.54-.01-8.01-.1-5.44-.03q-6.66-.06-13.31-.15v-1l1.97-.08 8.97-.36 3.1-.12 3.05-.12 2.78-.11c4.06-.27 8.09-.73 12.13-1.21l-1.75-.03a12630 12630 0 0 1-34.68-.65l-3-.05c-19.02-.4-19.02-.4-22.57-3.27l2.88.21c11.6.8 23.2 1 34.82 1.03q4.7.02 9.37.07c18.15.1 18.15.1 26.7-2.64 3.18-.95 5.94-.9 9.23-.67"/><path fill="#403f49" d="M437 1140h1c.85 6.15 1.31 12.24 1.56 18.44l.13 3.13c.62 17.46.4 34.96.31 52.43h-1l-.08-2.41-.42-12.87c-.24-7.6-.6-15.16-1.5-22.72h-1l-1 12h-1a3359 3359 0 0 1-.65-17.57c-.32-8.23-.54-16.25.65-24.43h1l1 5z"/><path fill="#c2c9cf" d="M1486 1079h4l1 3c1.63.73 1.63.73 3.56 1.19l1.94.48 1.5.33v4l2.75-.25c3.25.25 3.25.25 5.56 2.06 1.69 2.19 1.69 2.19 2.69 5.19a24 24 0 0 0 4 2l-1 5-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-4-1v-3h5a34 34 0 0 0-10-12c-2.81-.12-2.81-.12-5 1-1.31 1.56-1.31 1.56-2 3l-7-1v-3h8l-1.37-4.37-.78-2.47c-.85-2.16-.85-2.16-2.85-4.16"/><path fill="#dedddf" d="m1246 106 4 1 1 5h2l1-6h3l.25 3.38c.26 1.93.26 1.93.75 3.62 2.06 1.44 2.06 1.44 4 2-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v-8h3l1 7-1.37.69c-2.2 1.77-2.73 3.68-3.63 6.31-2.37-.19-2.37-.19-5-1-1.19-2.44-1.19-2.44-2-5l-2-2q-.75-2.52-1.43-5.07a43 43 0 0 0-2.13-5.56A31 31 0 0 1 1246 106m7 8 1 4 4 1v-6c-1.72 0-3.34.57-5 1"/><path fill="#69696c" d="m1214 58 1.36 1.2c3.39 2.98 6.66 5.63 10.64 7.8v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.94 2.25-.94 2.25-1 5 2.14 3.17 4.38 4.8 8 6v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1a78 78 0 0 0-2 5h-5v-5h-5v-5l-1.81-.69c-4.06-2.43-5.77-6.93-7.19-11.31-.12-2.81-.12-2.81 0-5"/><path fill="#53515b" d="M467 1630c4.62.44 7.47.75 11 4h-5v2l2 .75c2.35.98 4.5 2.03 6.73 3.22a38 38 0 0 0 11.52 3.72l2.2.36c10.52 1.52 21.25 1.48 31.87 1.66l6.75.12c5.03.1 9.95.47 14.93 1.17v1c-47.1.43-47.1.43-66-2v-2l-1.68-.3a44 44 0 0 1-6.32-1.7c-1.12-1.81-1.12-1.81-2-4a66 66 0 0 0-4.76-3.75C467 1633 467 1633 467 1630"/><path fill="#241810" d="M1261 1348c2.64 2.64 3.2 5.58 4.25 9.06 2.77 8.72 6.61 15.55 13.65 21.55 1.47 1.85 1.18 3.09 1.1 5.39a132 132 0 0 1-21.44-1.19l-2.86-.4c-9.75-1.43-19.2-3.82-28.7-6.41l-4.58-1.21q-5.22-1.38-10.42-2.79v-1q3.85.17 7.69.38l2.18.09c4.12.22 7.34.75 11.13 2.53q3.49 1.07 7 2l2.54.68a222 222 0 0 0 22.71 4.5l3.45.54 8.3 1.28c-1.46-4-2.57-5.5-6-8a88 88 0 0 1-3-5l-2-2.81c-4.07-6.12-5.52-11.89-5-19.19"/><path fill="#0b0b10" d="m928.06 778.94 2.94.06c-2.83 2.43-6.12 2.65-9.69 3.13l-1.84.26-4.47.61v2l-2.05.37q-8.28 1.47-16.5 3.14-2.37.48-4.74.92c-5.15.98-10.2 2.2-15.27 3.5a210 210 0 0 1-19.68 4.03c-3 .49-5.73 1.03-8.62 2.03-4.35 1.4-8.74 1.95-13.26 2.57l-7.69 1.08c-2.1.35-4.12.84-6.19 1.36q-2.12.1-4.25.06l-2.14-.02L813 804c5.57-5.57 18.15-4.75 25.73-5.54q5.65-.61 11.27-1.46v-2l20-2v-2l1.54-.15 2.09-.23 2.03-.2c2.34-.42 2.34-.42 5.13-1.36a83 83 0 0 1 10.96-2.62c7-1.31 7-1.31 9.25-2.44a95 95 0 0 1 5.13-.06l4.87.06v-2l2.8-.4 5.5-.8c3.28-.5 5.58-1.8 8.76-1.86"/><path fill="#7749b9" d="m549 752 2 2c1.73.63 1.73.63 3.63 1.13l3.37.87v2l1.9-.12q4.23-.26 8.48-.5l2.98-.2 2.85-.16 2.64-.16C579 757 579 757 581 759l-13 1 1 2h26l1 2h-50l4-1-1.06-2.12c-1.1-3.38-.74-5.46.06-8.88"/><path fill="#09090f" d="M1382.31 385.88h3.81l10.85.03 11.7.02 11.92.02 23.41.05v3l-18.27.57c-27.73.9-27.73.9-40.1.05l-1.9-.09c-4.49-.29-4.49-.29-6.73-2.53 1.56-1.56 3.23-1.12 5.31-1.12"/><path fill="#55535e" d="M430 1588h1v1.68l-.08 60.03-.03 26.08v10.07l-.01 3.04v5.16c.12 1.94.12 1.94 1.12 3.94q.65 4.65 1.16 9.33A38 38 0 0 0 436 1718c-.37 2.31-.37 2.31-1 4l-1-5h-2c-3.91-13.55-3.15-27.74-3.13-41.71v-55.5c-.02-10.65.04-21.2 1.13-31.79"/><path fill="#101217" d="M349 1456c2 2 2 2 2.25 3.68l-.01 2.05v2.36l-.01 2.58v2.72q0 4.47-.03 8.93l-.01 6.18-.05 16.29-.04 16.6q-.03 16.3-.1 32.61l-2-1-1-83h-2v-4h2z"/><path fill="#14141b" d="M428 1058c2.3 2.3 2.3 2.95 2.56 6.06.34 3.84.34 3.84 1.44 4.94q.16 2.43.24 4.86a103 103 0 0 0 3.39 22.76c1.3 5.2 1.59 10.02 1.37 15.38-4.5-6.36-4.8-14.68-5.8-22.2l-.79-5.7A337 337 0 0 1 428 1060l-2.7.35-6.38.82c-3.41.97-4.14 1.85-5.92 4.83l-2.12 2.56C409 1071 409 1071 408 1074l-1.94 1.75c-2.72 2.97-3.16 5.35-4.06 9.25h-1c-.3-3.05-.45-5.96 0-9 2.5-2.31 2.5-2.31 5-4q.06-2 0-4a90 90 0 0 1 4-4l1.38-2.69c1.62-2.31 1.62-2.31 4.25-3.12 9.49-.53 9.49-.53 12.37-.19"/><path fill="#452871" d="m702.2 758.9 7 .05 5.8.05v1c-6.36.74-12.6 1.14-19 1v2c-26.43 3.07-52.4 4.6-79 4v-1l2.85-.22a38196 38196 0 0 0 58.55-4.56l7.06-.56 2.1-.15c4.92-.4 9.71-1.65 14.65-1.6"/><path fill="#eee" d="M67 730c1.94.38 1.94.38 4 1l1 2q2.46 1.09 5 2l-1 5-3 1a175 175 0 0 0-3 5 95 95 0 0 1-4.31 4.69L63.45 753c-2.85 2.31-4.95 3.04-8.45 3.99-4.43-6.29-4.43-6.29-5-10l4-1v5h4l.68-1.68c1.68-2.95 3.94-5.08 6.38-7.38 4.8-4.54 4.8-4.54 5.94-7.94h-5z"/><path fill="#403f44" d="M981 116a111 111 0 0 1-24 6v2l-1.83.41-8.3 1.9-2.88.65A59 59 0 0 0 930 132a121 121 0 0 1-9.62 2.25l-2.57.56-2.47.52-2.24.46a14 14 0 0 1-6.1-.79l2.68-.95q10-3.56 19.97-7.21 2.94-1.04 5.91-1.97C938 124 938 124 940 122c2.4-.34 2.4-.34 5.25-.5 3.18-.23 5.71-.49 8.75-1.5 2.93-.98 5.25-1.23 8.31-1.44 4.62-.33 8.44-1.3 12.72-3.04 2.34-.62 3.7-.28 5.97.48"/><path fill="#0c0c12" d="m975.02 1599.07 2.79.12 2.94.15 3.06.14 9.63.46 6.55.3 16.01.76v1H844v-1l1.84-.01 65.79-.49a16184 16184 0 0 0 37.92-.28l5.03-.03c6.83-.07 13.65-1.44 20.44-1.12"/><path fill="#4b494f" d="M280 926h20v4h21c-2 2-2 2-3.79 2.14l-4.43-.32-2.4-.2q-1.2-.07-2.43-.17-2.98-.21-5.95-.45v2l2.82.37c6.2.83 12.22 1.64 18.18 3.63v1q-11.57-.99-23.06-2.5l-3.13-.4-2.97-.38-2.68-.35C289 934 289 934 287 933v-2l-2.37-.44C282 930 282 930 280 929z"/><path fill="#919294" d="M838 921c-1.75 2.18-2.65 2.94-5.45 3.44l-2.67.12c-2.7.13-2.7.13-4.88.44l-1 2h15c-3.25 3.25-4.05 3.45-8.37 4.06A42 42 0 0 0 820 934c-2.31.13-2.31.13-4 0v-2h-12v-1l15-1v-2h-15l1-2c2.04-.56 2.04-.56 4.63-1a77 77 0 0 0 9.06-2.06c6.5-1.84 12.6-2.08 19.31-1.94"/><path fill="#9a9a9b" d="M146 800c.13 2.38.13 2.38 0 5l-2 2c-.38 2.6-.5 5.17-.66 7.79L143 817l-2 1c-.6 2.3-.6 2.3-1.06 5.31l-.5 3.26c-.56 4.34-.87 8.67-1.1 13.03C138 842 138 842 136 844c-.2 1.73-.2 1.73-.12 3.63L136 851l-2 1q-.11-6.78-.16-13.56l-.07-4.62q-.05-3.31-.06-6.62l-.05-2.1c0-1.94 0-1.94.34-5.1 1.5-1.3 1.5-1.3 3-2 .42-2.8.44-5.54.5-8.37.5-2.63.5-2.63 2.47-4.29L142 804l1-3c2-1 2-1 3-1"/><path fill="#808082" d="M1276 788a382 382 0 0 1-11.87 4.94 184 184 0 0 0-9.63 4.12c-4.7 2.13-9.54 3.57-14.5 4.94v3l1.54-.44c4.3-1.16 8.01-1.78 12.46-1.56v3l-10 3v-2q-2.91.68-5.81 1.38l-3.27.77A17 17 0 0 0 1229 812c-2.98-.39-5.34-1.65-8-3l4.81-2 2.71-1.12c2.48-.88 2.48-.88 5.48-.88v-2l4.88-2 2.74-1.12c2.38-.88 2.38-.88 4.38-.88l1-3c1.44-.84 1.44-.84 3.28-1.5l2.04-.75 2.18-.75 2.2-.79 4.34-1.52a25948.08 25948.08 0 0 1 7.21-2.62c.32-.13.32-.13 1.95-.72 2.2-.43 3.69-.03 5.8.65"/><path fill="#b37dec" d="M373 641h1q.08 2.65.13 5.31l.07 3c-.2 2.65-.49 3.71-2.2 5.69l4 2 .04 2.93q.1 5.48.22 10.94.05 2.34.09 4.7c.13 8.66.48 16.6 3.02 24.94.66 2.6.74 4.83.63 7.49h-3l2 1c.63 3.06.63 3.06 1 6-3-2-4.75-3.57-6-7-.12-2.25-.12-2.25 0-4l2-1-.44-4.62-.24-2.6q-.31-2.76-.82-5.47c-.69-4.55-.86-9.09-1.06-13.68L373 667l-2 4h-1c-.34-10.52.04-19.86 3-30"/><path fill="#4a2c80" d="M999 519v3l-2.52.11-3.3.2-3.26.18c-3.25.57-3.76 1.2-5.92 3.51-2.26.51-2.26.51-4.69.69l-2.45.2-1.86.11-1-2 8-1v-2c-7.2.52-7.2.52-10.37 2.44-6.5 3.86-15.23 3.96-22.58 5.06-3.16.52-6.2 1.23-9.28 2.07-1.84.45-3.64.7-5.52.93-3.25.5-3.25.5-5.44 1.63-1.81.87-1.81.87-4.06.5L923 534a38 38 0 0 1 12.31-3.87c6.21-.92 12.1-2.55 18.1-4.37a64 64 0 0 1 13.7-2.49c1.89-.27 1.89-.27 4.26-1.7 5.73-3.41 22.7-7.5 27.63-2.57"/><path fill="#b8b0a9" d="M1373.86 1224.68c11.11.16 21.77 3.52 32.14 7.32v1c-4.58.25-7.98.12-12.28-1.49-6.54-2.04-12.97-1.96-19.76-1.9q-3.22.02-6.46-.02l-4.17.01-1.91-.02c-4.7.08-8.25 1.32-12.42 3.42l-2.22 1.1-1.84.96c-4.25 2.15-8.1 3.94-12.94 3.94 7.15-7.9 18.39-10.3 28.41-12.39 4.66-1.1 8.35-1.9 13.45-1.93"/><path fill="#2d2d33" d="M792 514h10l-3 1v2h4v2h-10v2c-7.41 1.92-14.27 3.54-22 3v2c-7.88 2.47-15.4 3.64-23.62 4.19l-3.24.25c-6.06.44-12.07.67-18.14.56v-1l1.79-.26 8.02-1.18 2.82-.4 2.7-.4 2.5-.37C746 527 746 527 749 526q2.77-.22 5.56-.37c5.05-.38 9.38-1.4 14.17-3 3.2-.9 5.96-.85 9.27-.63v-2h-6c2-2 2-2 4.38-2.2l2.75.08 2.75.05 2.12.07-4 2 3.38-.44c4.86-.6 9.74-1.09 14.62-1.56v-2l-6-1z"/><path fill="#030409" d="m789 1672 117 1v1l-116 1zm1 3-1 2-2.75.88c-3.5 1.2-5.57 2.6-8.25 5.12l-1 3-3 3-3 5h-1c-.19-2.31-.19-2.31 0-5 1.38-1.5 1.38-1.5 3-3l.75-2.31c1.8-3.9 4.77-6.24 8.25-8.69 3.29-1.1 4.71-.8 8 0"/><path fill="#7b7b83" d="M391 1587h1l.02 2.77a12465 12465 0 0 0 .4 39.6c.5 56.42.5 56.42 4.58 66.63l3 1a27 27 0 0 1 1.94 4.69c1.18 3.21 2.32 5.94 4.37 8.68 1.69 2.63 1.69 2.63 1.3 4.8L407 1717c-4.6-5.45-7.87-10.21-10-17l-2.06-5.5a61 61 0 0 1-4.17-23.04v-2.78l.03-9.02.01-6.3.05-16.48.04-16.86q.03-16.5.1-33.02"/><path fill="#63626c" d="M436 1230c2.54 3.86 2.27 7.53 2.23 11.98v2.43l-.03 7.92-.01 5.5-.05 14.46-.04 14.76q-.03 14.48-.1 28.95h-1a281 281 0 0 1-1.56-23.06l-.07-1.88a1188 1188 0 0 1-.5-39.87v-10.14c0-3.82.2-7.32 1.13-11.05"/><path fill="#8f8f90" d="m761 942-1 4c-6.75 1.41-13.1 2.3-20 2v2c-8.72 1.32-17.15 2.33-26 2 .81-1.94.81-1.94 2-4 2.62-.87 4.6-1.23 7.3-1.46l2.52-.24 5.22-.46 2.52-.24 2.29-.2c2.35-.44 4.04-1.32 6.15-2.4 2.34-.3 4.59-.5 6.94-.62l1.84-.12q5.1-.31 10.22-.26"/><path fill="#2d1a50" d="M1309 547h4c-.38 2.38-.7 3.7-2.43 5.42l-1.88 1.33-2.16 1.53L1304 557l-1.49 1.02c-13.6 9.3-13.6 9.3-19.51 8.98v3c-2.6 2.6-4.1 3.92-7.81 4.25L1273 574l-1 3-6 1-1-4 2.25.25c3.58-.33 4.38-1.65 6.75-4.25q2-1.02 4-2l1-2c3.84-2.74 7.4-4.1 12-5 1.63-1.5 1.63-1.5 3-3l3-1 1-3c2-1.39 2-1.39 4.44-2.69l2.43-1.32c2.13-.99 2.13-.99 4.13-.99z"/><path fill="#000001" d="M352 1612h2v51l-4 1-.08-24.67-.02-9.02-.01-2.8c0-4.95.18-9.65 1.11-14.51z"/><path fill="#4e4e53" d="m782.13 924.44 2.75.3 2.12.26c-2.24 2.24-2.67 2.28-5.67 2.53l-2.2.21-4.57.38-2.23.22-2 .17c-2.33.49-2.33.49-5.47 1.92-4.49 2-9.06 2.66-13.89 3.4l-2.87.45-5.99.94q-4.56.7-9.1 1.44l-8.57 1.35A86 86 0 0 1 709 939v-1l8.37-1.86a81 81 0 0 1 10.7-1.64c3.65-.32 5.85-1.45 8.93-3.5 3.43-.9 6.78-1.14 10.32-1.3 5.93-.25 11.03-.82 16.61-3 3.03-1.02 6.05-1.25 9.22-1.49 7.88-.9 7.88-.9 8.98-.77"/><path fill="#663fa6" d="M658 755c5.27-.2 5.27-.2 7 0l2 2c1.7.23 1.7.23 3.7.2l2.16-.02 2.26-.05 2.29-.03 5.59-.1 1-2h26l-1 2-18 1v2l-2.24.15c-5.2.38-10.27.88-15.38 1.91-5.18 1.02-10.13 1.15-15.38.94v-1l1.71-.15 2.23-.23 2.21-.2L666 761l1-2h-27l1-2h16z"/><path fill="#2c2d30" d="m1559 648 2 1c-4.96 5.6-9.8 9.18-16.45 12.56-2.98 1.68-5.01 3.62-7.3 6.13-1.82 1.9-3.94 3.04-6.25 4.31q-2.25 1.46-4.46 2.97-1.52 1.02-3.08 2a33 33 0 0 0-6.96 5.65c-2.9 2.76-4.7 3.4-8.5 4.38v2l-1.58.81c-6.92 3.68-14.56 8.05-19.95 13.83-1.9 1.76-3.69 2.3-6.16 3.05l-2.45.76-1.86.55c2.25-3.44 4.43-5.02 8-7q2-1.17 4-2.37l1.94-1.15c2.06-1.48 2.06-1.48 4.32-3.83 3.12-3.21 6.79-5.36 10.62-7.65 4.66-2.82 9.22-5.7 13.62-8.93 2.09-1.5 4.3-2.77 6.5-4.07q2.25-1.46 4.46-2.97a99 99 0 0 1 3.11-2 22 22 0 0 0 6.5-5.65c2.16-2.67 3.75-3.26 6.93-4.38l1-2c1.45-.68 1.45-.68 3.25-1.31 3.86-1.55 6-3.6 8.75-6.69"/><path fill="#14141a" d="M1462 592c1 3 1 3 .33 4.9-4.01 7.57-4.01 7.57-6.33 10.1h-2l-.24 1.7c-1 3.05-2.68 4.67-4.88 6.99a123 123 0 0 0-8.5 10 83 83 0 0 1-8.82 9.68 30 30 0 0 0-3.75 4.82A30 30 0 0 1 1422 647h-2v2h-2l-.8 1.77a26.7 26.7 0 0 1-10.89 10.67c-2.23.54-4.03.63-6.31.56q-2.01.44-4 1l8-8 2 1-2 3q4-3.49 8-7l1.31-1.15 4-3.54 2.5-2.2c2.19-2.11 2.19-2.11 3.86-4.38L1425 639h2l.66-1.63c1.82-3.21 4.23-5.6 6.78-8.25 10.44-11.1 20.7-23.4 27.56-37.12"/><path fill="#36343e" d="M449 1728c1.5 1.31 1.5 1.31 3 3v3l4 1v3l1.94.38 2.06.62 1 2q2.96 1.57 6 3v2h2v2h2v2h5l1 3 7 1 1 3-1 2c-5.63-.46-9.21-1.87-13.87-5l-1.84-1.2c-5.45-3.7-9.85-7.95-14.29-12.8l-3.06-2.94C449 1735 449 1735 449 1733h-2z"/><path fill="#75757d" d="M392 1248c2.55 3.65 3.1 6.91 3.32 11.33l.12 2.42.12 2.5.13 2.55.31 6.2-2-1c-.41-2.5-.41-2.5-.62-5.56l-.23-3.07-.15-2.37c-1.33 2.65-1.14 4.66-1.16 7.63l-.01 1.76-.04 5.87-.03 4.2-.06 11.42-.08 11.92-.14 22.59-.16 25.71-.32 52.9h-1a40968 40968 0 0 1-.15-81.5 10926 10926 0 0 1-.06-34.46 2526 2526 0 0 1-.03-15.35c-.05-8.77.65-17.05 2.24-25.69"/><path fill="#0d0d14" d="m1213 1087 2 1a75 75 0 0 1-10 7q-2.13 1.43-4.25 2.88a139 139 0 0 1-11.6 6.94 80 80 0 0 0-4.53 2.7c-3.82 2.16-7.88 3.24-12.07 4.52-2.55.96-2.55.96-3.55 2.96-2.29.63-2.29.63-5.06 1.13l-2.79.5-2.15.37-1 3c-6.23 3.37-17.24 8.9-24.28 7.57l-1.72-.57 2.2-.88a198 198 0 0 0 15.99-7.24c4.1-2.1 8.06-3.7 12.45-5.03 4-1.44 7.71-3.47 11.47-5.45a60 60 0 0 1 12.89-4.4v-2l1.4-.77a57 57 0 0 0 7.66-4.86c2.49-1.76 5.05-2.43 7.94-3.37a67 67 0 0 0 9-6"/><path fill="#626368" d="M95 706c1.94.38 1.94.38 4 1l1 2q2.46 1.09 5 2c0 2.13 0 2.13-1 5a73 73 0 0 1-6.08 4.52c-2.37 1.83-3.43 3.9-4.92 6.48-4.78 4-4.78 4-8 4l-2 4h-5v5l-6 3 1-3 3-1 .9-2.76c1.27-3.75 2.8-5.37 5.64-8.1l1.32-1.3 4.2-4.03 4.21-4.06 3.81-3.67C98 713 98 713 99 710h-5z"/><path fill="#323335" d="M1594 406h11l2 4h10c3.21 5.9 3.21 5.9 3 10q-3.2-.67-6.37-1.37l-1.83-.39c-3-.66-4.58-1.02-6.8-3.24a78 78 0 0 0-6.62-.62l-1.86-.12q-2.25-.15-4.52-.26v-4l-3-1h5z"/><path fill="#0e0f15" d="m420.94 1056.81 2.25-.03 2.15-.01 1.98-.01c1.68.24 1.68.24 3.68 2.24.2 2.16.2 2.16.13 4.63l-.06 2.47-.07 1.9h-1l-.18-1.71-.26-2.23-.24-2.21C429 1060 429 1060 428 1059c-3-.21-6-.09-9 0h-3.25c-4.33 1.57-5.78 5-7.75 9l-1.06 2.81c-.94 2.19-.94 2.19-2.96 3.44-1.98 1.75-1.98 1.75-2.52 4.57l-.08 3.3A73 73 0 0 1 399 1098h-1q.17-4.94.38-9.87l.09-2.79c.61-14.5.61-14.5 3.53-20.34l-.62-1.94c-.38-2.06-.38-2.06.56-3.81 2.82-1.7 5.08-1.44 8.32-1.26l1.74.01c2.05-2.05 6.18-1.19 8.94-1.19"/><path fill="#1f1e25" d="M1377 738c-.06 1.81-.06 1.81-1 4a36 36 0 0 1-5.4 2.29c-1.6.71-1.6.71-2.6 2.71-1.67.27-1.67.27-3.75.38-4.2.4-6.02 1.88-9.25 4.62a71 71 0 0 1-4.9 1.43c-3.64.99-7.13 2.42-10.65 3.77-2.45.8-2.45.8-5.45.8v-3l2.94-.81q1.5-.6 3.06-1.19l1-3 3.38-.31c1.96-.27 1.96-.27 3.62-.69l1-1.5 1-1.5c1.79-.35 3.52-.5 5.34-.6 1.66-.4 1.66-.4 2.59-1.89 1.07-1.51 1.07-1.51 3.23-2.02l2.46-.18 2.48-.2 1.9-.11 1-3c5.75-1.12 5.75-1.12 8 0"/><path fill="#111318" d="M315 1035q2.69-.08 5.38-.12l3.02-.08c2.6.2 2.6.2 4.6 2.2 2.1.33 2.1.33 4.68.46l2.81.18q2.94.18 5.88.3l2.81.2 2.58.14c2.24.72 2.24.72 3.86 2.92 1.52 3.08 1.75 4.93 1.74 8.35v3.32l-.03 3.6v3.76l-.06 10.07q-.04 4.15-.05 8.3c-.08 21.8-.45 43.6-1.22 65.4h-1v-1.53q.07-18.5.1-36.99l.05-17.88a5907 5907 0 0 0 .06-23.84q.03-4.61.02-9.22l.02-2.76-.01-2.54v-2.2c-.28-2.36-1.07-3.99-2.24-6.04l-10.19-.06-2.9-.03h-2.83l-2.58-.02c-2.5.11-2.5.11-5.15.73-2.35.38-2.35.38-5.04-1.5L317 1038l-2-1z"/><path fill="#a37bd6" d="M543 624h1a4366 4366 0 0 1-.29 57.7l-.34 36.45L543 758h-19v-2q2.55-.55 5.13-1.06l2.88-.6c3.23-.37 5.8.07 8.99.66l.01-1.75a58757 58757 0 0 1 .63-80.8l.07-9.46c.09-13.02.24-26 1.29-38.99"/><path fill="#8e73ba" d="M712 597c-2.86 2.45-6.21 2.7-9.8 3.22l-2.05.31-6.59.97-4.53.68a456 456 0 0 1-26.4 3.15c-5.02.46-9.93 1.18-14.88 2.1-21.42 3.95-42.04 4.99-63.75 4.57v-1l1.68-.01c14.71-.19 29.11-1.52 43.7-3.43l2.55-.33a431 431 0 0 0 24.85-3.86c3.08-.51 6.03-.61 9.16-.68 9.17-.36 18.13-2.2 27.12-3.96 6.38-1.23 12.44-2.1 18.94-1.73"/><path fill="#909093" d="M988 117v2c-3.85 1.97-7.69 3.9-11.69 5.56-3.3 1.39-6.75 2.88-9.31 5.44q-4.31 0-8.63-.21c-4.42-.1-6.58.79-10.37 3.21-2.46.3-2.46.3-4.81.19l-2.4-.08L939 133c.8-1.96.8-1.96 2-4 3.31-.62 3.31-.62 5.05-.73 1.95-.27 1.95-.27 4.34-1.25 2.73-1.07 5.08-1.43 7.99-1.77 7.7-1.18 14.49-4.22 21.62-7.25 2.93-.98 4.96-1.08 8-1"/><path fill="#2e2d33" d="m805 214 1 4-5.18.49c-1.82.51-1.82.51-3.23 2.02-2.66 2.5-5.82 1.96-9.31 2.08L786 223l-.96 1.5L784 226c-2.53.44-2.53.44-5.5.56-5.3.23-5.3.23-7.5 2.44-2.82.2-2.82.2-6.12.13l-3.33-.06L759 229c1-2 1-2 3.25-2.93l2.96-.94 3.21-1.05 3.4-1.08 6.57-2.12 2.97-.95c2.34-.82 4.44-1.8 6.64-2.93q2.04-.6 4.13-1.06c8.81-2.02 8.81-2.02 12.87-1.94"/><path fill="#6e6c78" d="m1316 1009 2 1v44h-1l-1-10-.04 3.44q-.1 6.32-.22 12.63-.05 2.74-.09 5.47l-.14 7.85-.02 2.48-.06 2.3-.03 2.03c-.52 2.34-1.61 3.27-3.4 4.8-1.64-3.29-1.18-6.82-1.19-10.44l-.03-2.35-.01-2.27-.01-2.07c.24-1.87.24-1.87 1.23-3.23 1.25-2.03 1.31-3.38 1.4-5.75l.12-2.5.09-2.7.12-2.8.34-8.89q.23-5.82.48-11.62l.1-2.8c.27-6.22.66-12.4 1.36-18.58"/><path fill="#b9b8b8" d="M1224 930c0 3.36-.66 4.52-2.31 7.38l-1.39 2.39-1.3 2.23-1.69 3.06L1216 947h-2l-.69 2.19c-1.78 3.81-4.08 6.88-8.02 8.52-1.94.54-3.88.96-5.85 1.35l-4.28.97-2.13.48c-5.83 1.41-11.46 3.56-17.1 5.6a20 20 0 0 1-7.3 1.01l-2.1-.05-1.53-.07a24 24 0 0 1 6.8-3.4l2.21-.76 2.3-.78c5.12-1.72 9.89-3.6 14.69-6.06 1.94-.66 1.94-.66 3.56-1.06A68 68 0 0 0 1203 952l3-.81c3.53-1.4 5.58-3.37 8-6.19l2.06-2.31a52 52 0 0 0 5.01-8.92c.93-1.77.93-1.77 2.93-3.77"/><path fill="#3a2259" d="M400 730c4.13.97 7.51 2.18 11.19 4.31 5.38 3.12 11.04 5.39 16.81 7.69l2.16.95c4.8 2.02 9.33 2.54 14.52 2.84C447 746 447 746 449 747v2l2.53.3c6.33.84 11.55 1.88 17.33 4.75 3.56 1.58 7.31 2.26 11.14 2.95v1c-7.03.4-13.28-.92-20-3l-1-1a60 60 0 0 0-3.5-.81q-7.2-1.56-14.37-3.25l-2.28-.53-4.1-.96c-4.48-1.15-4.48-1.15-5.99-2.83-2.61-2.4-5.65-3.33-8.95-4.56l-6.02-2.35c-5.4-2.15-9.95-4.23-13.79-8.71"/><path fill="#030202" d="m1021 1216 2 1c5.73.32 11.38.21 17-1-3.24 2.87-6.27 3.71-10.43 4.4l-5.92 1.05c-11.29 1.97-22.29 4.26-33.23 7.72-4.33 1.35-7.88 2.14-12.42 1.83v-1h5v-3h-6v-1l2.94-.37 3.06-.63 1-2h6l-3 2c3.7.29 7.3.45 11 0 2.5-2.44 2.5-2.44 4-5 3.15-1.05 5.39-1.1 8.69-1.06l3 .02 2.31.04v2a12.7 12.7 0 0 0 5-5"/><path fill="#747375" d="M1147 834c-7.28 4.26-14.97 6.76-22.97 9.33a201 201 0 0 0-16.03 5.92c-6.53 2.72-13.07 4.33-20 5.75l-3.56.88-1.78.44q-2.44.6-4.88 1.23c-2.77.45-4.15.21-6.78-.55l16-5.58c13.33-4.66 13.33-4.66 19.32-6.03 2.85-.66 5.63-1.53 8.43-2.39l3.3-1c2.95-1 2.95-1 4.98-2.04 2.51-1.23 5-1.77 7.72-2.4a76 76 0 0 0 12.73-4.1c1.52-.46 1.52-.46 3.52.54"/><path fill="#4d2f88" d="m877 559-1 2h9c-2 2-2 2-3.84 2.09l-2.1-.15c-2.18-.07-2.18-.07-4.06.06q-1.53 1.47-3 3c-2.03.35-4 .5-6.06.6-1.94.4-1.94.4-3.38 1.9-2.13 2.05-3.64 1.93-6.56 2.06-4.7.23-4.7.23-6.49 1.46-2.22 1.44-4.09 1.17-6.7 1.1l-2.73-.05L838 573a14.7 14.7 0 0 1 6.9-4.6l2.26-.8 2.34-.79 2.34-.82c5.74-1.99 5.74-1.99 9.16-1.99v-2q2.87-1.05 5.75-2.06l3.23-1.16c2.94-.76 4.24-.85 7.02.22"/><path fill="#0e081d" d="M1307 347c4.84 4.84 5.7 13.48 7 20h2l1.29 3.07c1.58 3.7 3.33 7.28 5.15 10.87l.92 1.84q1.97 3.94 4.02 7.83l1.12 2.14.97 1.8.53 1.45-1 2-2-2.87-1.12-1.62C1325 392 1325 392 1325 390h-2c-1.9-3.7-3.63-7.41-5.16-11.29-.84-1.71-.84-1.71-2.31-3.64-5.18-7-7.09-16.78-9.53-25.07-3.1-.32-4.54-.34-7.1 1.54l-2.04 2.15-2.3 2.37-2.37 2.5-4.65 4.82-2.28 2.36c-2.75 2.75-5.63 5.3-8.59 7.82A82 82 0 0 0 1271 379l-3-1 1.22-1.04a502 502 0 0 0 16.13-14.6c2.95-2.76 5.86-5.54 8.63-8.49l1.34-1.42a292 292 0 0 0 3.43-3.75c2.93-2.21 4.67-2.18 8.25-1.7"/><path fill="#4c301a" d="M1208 1367a422 422 0 0 1 18.04 2.9l4.17.73c1.79.37 1.79.37 4.79 1.37l1-2h8l-1 3c10.4 4.31 19.9 6.55 31 8l-2-6c2.5 1.75 2.5 1.75 5 4 .16 2.14.16 2.14 0 4-8.16-.3-16.04-1.42-24.06-2.94l-2.78-.51-2.63-.51-2.35-.46c-2.26-.6-4.11-1.5-6.18-2.58q-2.58-.6-5.19-1.06c-4.7-.84-4.7-.84-5.81-1.94q-1.77-.3-3.56-.46l-2.17-.24-4.54-.46-2.17-.24-2-.2-1.56-.4-1-2c-1.56-1.12-1.56-1.12-3-2"/><path fill="#d0d6da" d="M1443 960h1l-.03 3.05a2415 2415 0 0 0-.12 16.36c-.09 8.57 0 17.02 1.03 25.53l.24 1.99c.46 3.4 1.21 6.07 2.88 9.07q.48 2.8.81 5.63c.96 6.2 3.08 10.96 6.19 16.37l1.14 2.14c2.56 4.71 2.56 4.71 4.86 5.86l-1 4c-5.06-4.8-7.84-9.34-10.18-15.9a64 64 0 0 0-3.86-7.85c-8.09-18.87-7.2-46.55-2.96-66.25"/><path fill="#35333a" d="m951.29 886.9 2.77.04 2.79.02 2.15.04c-3.96 2.92-8.23 4.17-13.03 4.93a237 237 0 0 0-18.47 4.13l-3.24.82c-7.8 1.96-7.8 1.96-11.26 3.12q-2.1.1-4.19.06l-2.17-.02L905 900l1-4h-7v-2h15v2l4.44-1.37 2.5-.78C923 893 923 893 924 891h-6v-1h10v2l2.69-.87c4.78-1.4 9.7-1.99 14.61-2.67 2.8-.48 3.27-1.44 5.99-1.56"/><path fill="#5a595e" d="M1717 470c3.11 1.5 6.05 3.2 9 5v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2v19c-5.4-6.75-7.46-10.51-8-19l-3-1v-6h-2z"/><path fill="#f4f4f5" d="m1381 1767 5 1 .31 1.88c.8 2.44 1.47 2.96 3.69 4.12l-1 4h-3v-5c-4.26 2.3-7.4 5.09-10.75 8.5l-1.5 1.47c-2.31 2.33-3.7 3.87-4.75 7.03h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2l1-5 3-1c1.43-1.45 1.43-1.45 2.88-3.25a77 77 0 0 1 5.8-6.44c2.01-2 3.7-4 5.32-6.31"/><path fill="#2c1e17" d="m976.25 1231.38 1.75.62a46 46 0 0 1-11.62 3.94c-4.07.82-6.26 2.27-9.38 5.06-4.66 2.88-9 3.59-14.38 4.47-4.2.85-7.24 2.45-10.62 5.1-2.51 1.8-5.05 2.56-8 3.43v4l1.5.62 1.94.82 1.93.8c1.63.76 1.63.76 2.63 1.76 2.31.53 4.64.96 6.97 1.4l2.03.6 1 2q2.46 1.09 5 2c-1.75.69-1.75.69-4 1l-4-3a55 55 0 0 0-3.75-.94c-5.29-1.18-10.38-3.2-14.25-7.06-.12-2.62-.12-2.62 0-5l1.98-.7c8-2.99 8-2.99 10.96-5.86 3.33-3.14 7.29-3.74 11.67-4.67 2.68-.86 3.04-1.43 4.39-3.77l2.5-.44 2.5-.56c1.16-2.04 1.16-2.04 2-4l2.7.07 5.33.1c4.07.05 7.55-2.4 11.22-1.8"/><path fill="#16161e" d="m965 1130 10.78 1.2 2.3.26 2.13.24c1.79.3 1.79.3 3.79 1.3v2l3.31.4q4.82.6 9.63 1.35c16.73 2.34 33.42 2.56 50.28 2.56l5.69.01a1558 1558 0 0 0 14.13-.02 75 75 0 0 0 16.85-1.86c2.78-.58 5.28-.59 8.11-.44-1 2-1 2-3.59 3.1-3.67 1.12-7.18 1.19-11 1.17l-2.23.02-7.27.01-5.1.01h-10.7q-6.8 0-13.6.04-5.28.02-10.56.01-2.5 0-5 .02c-11.01.06-21.2-1-31.95-3.38l-4.77-.91-3.6-.71-1.9-.37c-2.4-.48-4.53-.9-6.73-2.01v-2l-9-1z"/><path fill="#321e54" d="M1016 692a107 107 0 0 1-18.69 6.81c-8.6 2.38-17.1 4.98-25.59 7.74-8.03 2.6-16.13 5.15-24.47 6.58l-2.72.46q-4.76.77-9.53 1.41c2.75-2 2.75-2 5-2v-2l2.38-.33c5.3-.8 10.12-1.58 14.96-3.94 2.6-1.14 5.3-1.7 8.06-2.3C967 704 967 704 968 703q2.3-.51 4.63-.94a86 86 0 0 0 9.06-2.12 39 39 0 0 1 6.93-1.38c3.38-.56 3.38-.56 5.67-2 3.12-1.8 5.88-2.45 9.4-3.18 9.96-2.08 9.96-2.08 12.31-1.38"/><path fill="#1c1133" d="m1359 509 1 3c-2.69 2.77-5.4 5.36-8.37 7.81a54 54 0 0 0-6.88 7c-3.15 3.71-6.68 6.74-10.48 9.75a119 119 0 0 0-8.25 7.17c-4.33 4.1-7.28 5.95-13.02 7.27-1.06 1.81-1.06 1.81-2 4-3.27 3.27-7 5.7-11 8l-4.33 2.9q-3.85 2.52-7.78 4.92-2.4 1.5-4.77 3.05L1281 575l-2-1c2.19-2.55 4.47-3.71 7.48-5.13 2-1.14 3.36-2.75 4.92-4.42 2.96-2.68 6.45-4.6 9.83-6.69a87 87 0 0 0 6.9-4.88 51 51 0 0 1 8.24-5.2c4.64-2.47 8.2-5.7 12.02-9.29a133 133 0 0 1 8.3-6.95 72 72 0 0 0 10.75-10.66 45 45 0 0 1 6.35-5.69c1.62-1.46 2.36-3.1 3.21-5.09z"/><path fill="#737376" d="M451 473h1c.38 9.3-.29 18.08-1.79 27.27A412 412 0 0 0 447 527c-5.95.19-11.2-.43-17-1.75l-2.29-.48a49 49 0 0 1-12.9-4.8 69 69 0 0 0-5.75-2.66C406 516 406 516 405 515q-.06-2.5 0-5h2l1 5a195 195 0 0 0 27.13 7.47c1.87.53 1.87.53 3.3 1.53 2.1 1.34 4.14 1.6 6.57 2l-.1-2.32c-.2-7.7.39-15.06 1.48-22.68l.38-2.71c.7-4.65 1.73-8.83 3.24-13.29q.4-3.18.63-6.37l.22-3.22z"/><path fill="#121016" d="M958 366c0 3 0 3-2.27 5.3q-1.47 1.3-2.98 2.57c-2.87 2.47-5.47 4.77-7.8 7.76-2.12 2.58-4.02 3.8-6.95 5.37a160 160 0 0 0-15.56 10.06 96 96 0 0 1-12.52 7.92c-1.92 1.02-1.92 1.02-4.54 2.7-3.01 1.67-6.08 2.47-9.38 3.32l-2.82.82-3 .87-2.82.82C885 414 885 414 883 413l1.7-.63c5.34-2.05 10.5-4.37 15.61-6.93l1.78-.89c3.4-1.72 6.65-3.59 9.91-5.55l5-2c4.52-2.47 4.52-2.47 6.38-4.5 1.69-1.56 2.87-2.06 5-2.87 3.1-1.25 4.93-2.78 7.2-5.27a32 32 0 0 1 5.48-3.99c6.3-4.03 11.55-9.24 16.94-14.37"/><path fill="#eaeaeb" d="m336 1604 2 1v63h-3l-.08-36a4868 4868 0 0 1-.03-21.1l-.01-3.39c.12-2.51.12-2.51 1.12-3.51"/><path fill="#acb5be" d="m1594 1062 1.36 1.2c3.39 2.98 6.66 5.63 10.64 7.8v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2 .1 6.15c-.1 1.85-.1 1.85-1.1 2.85q-3 .06-6 0v-9h-3l-1-6-1.87-.25c-2.13-.75-2.13-.75-3.38-2.5a17 17 0 0 1-.75-7.25z"/><path fill="#111215" d="m319 575 1 2c4.5 1.6 8.18 2.24 13 2v2l-5 1v4h-9v3c-6.43.29-6.43.29-9-2l1-5h-8v-3l7-1 .69 1.44c1.83 2.18 3.56 2.2 6.31 2.56l2-4h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#515053" d="m684.7 73.77 3.4.03 1.8.01q2.87.02 5.72.07l3.88.02q4.75.03 9.5.1l1 6c-3.02 1.5-6.13 1.15-9.44 1.13l-2.16-.01c-5.85-.05-11.6-.43-17.4-1.12-.63-1.87-.63-1.87-1-4 2-2 2-2 4.7-2.23"/><path fill="#1d1d26" d="m817 1413 2 1c-3.73 4.12-8.65 6.37-13.5 8.94-9.57 5.2-9.57 5.2-11.5 9.06l-2.94 1.13c-5.9 2.37-11.3 6.86-15.58 11.53-2.4 2.17-5.26 2.64-8.32 3.5-2.16.84-2.16.84-3.65 2.33-1.91 1.91-3.62 2.41-6.2 3.2l-2.45.76-1.86.55c5.18-5.37 10.6-9.68 17.56-12.44 3.23-1.34 5.03-3 7.44-5.56 3.52-2.89 7.06-5.67 11.19-7.62a28 28 0 0 0 6.18-4.2c3.18-2.3 6.74-4 10.2-5.81C808 1418 808 1418 810 1416q3-1.05 6-2z"/><path fill="#6a0c04" d="M1441 1255c3.12 1.63 4.42 3.92 6 7v3h-8c-2.12.31-2.12.31-4 1l-1 3-1.8-.17c-5.4-.04-8.66 1.07-12.54 4.72A84 84 0 0 0 1407 1290c-1-2-1-2-.19-4.56 1.19-2.44 1.19-2.44 3.19-3.44l1.19-2.62c3.64-7.58 11.95-13.86 18.81-18.38 4.06-1.14 7.82-1.08 12-1z"/><path fill="#ebeff2" d="m1535 1127 5 1v5c-3 1-3 1-6 0v48l-3-1c-1.03-5.11-1.18-10.06-1.2-15.27l-.02-2.65-.02-5.51q-.02-4.24-.07-8.47l-.02-5.37-.04-2.56.01-2.37v-2.08c.36-1.72.36-1.72 1.8-2.8l1.56-.92c1.19-2.62 1.19-2.62 2-5"/><path fill="#9c9c9e" d="M139 758c-1.2 6.22-1.2 6.22-3.33 8.29a50 50 0 0 1-6.98 3.77C127 771 127 771 126 774h-4v4h-4v4h-5l-1 3-2 1-1 3h-3c.59-3.4 1.71-5.12 4.06-7.6l1.9-2.04 2.04-2.11 2.06-2.17C132.46 758 132.46 758 139 758"/><path fill="#020105" d="M518.67 129.8q2.67.03 5.33.2v3c-2 1.37-2 1.37-5 3-5.87 3.3-10.9 7.29-15.96 11.7-3.84 3.3-3.84 3.3-6.04 3.3l-.69 1.69c-2.06 3.63-4.57 6.7-8.62 8.12L486 161c.08-2.86.53-4.53 2.59-6.56q1.88-1.54 3.82-3.01C494 150 494 150 495 147l3-1a181 181 0 0 0 4-4l2-1 1-3h5v-3l2.37-.81c2.63-1.19 2.63-1.19 3.5-2.82 1.13-1.37 1.13-1.37 2.8-1.56"/><path fill="#323236" d="m958.06 112.94 2.94.06v2l3 1v2l-2.74.11-3.57.2-1.8.07c-3.83.24-5.37.8-7.89 3.62-2.05.43-3.98.5-6.06.6-1.94.4-1.94.4-3.3 1.83-1.88 1.8-3.24 2.32-5.7 3.06l-2.42.75-2.52.76-4.96 1.57-7.55 2.36-2.45.77-2.21.68C909 135 909 135 907 136q-3 .06-6 0l2-1v-2l1.9-.37 2.48-.5 2.46-.5C912 131 912 131 914 129q2.71-.73 5.45-1.37 4.64-1.17 9.24-2.57l3.26-.96A40 40 0 0 0 940 120q2.24-1.08 4.5-2.06l2.34-1.03q2.57-1.07 5.16-2.1c4.54-1.84 4.54-1.84 6.06-1.87"/><path fill="#b4bcc3" d="m1525 969 5 2v14l4 2q.08 1.94.13 3.88l.07 2.17c-.2 1.95-.2 1.95-2.2 3.95-2.12.13-2.12.13-4 0l-.84-2.15-1.1-2.79-1.09-2.77C1524 987 1524 987 1523 986q-.2-3.43-.19-6.87l-.03-1.94c-.02-5.8-.02-5.8 2.22-8.19"/><path fill="#88898b" d="M836 920h3l1 2 17 1-2 4c-2.86.76-2.86.76-6.31 1.13-4.29.47-7.72 1.2-11.69 2.87q-2.46.55-4.94 1a49 49 0 0 0-7.9 2c-3.58 1.13-7 1.63-10.72 2.06-4.19.5-8.31 1.05-12.44 1.94l1-2c1.9-.56 1.9-.56 4.38-1l2.72-.5 5.83-1 2.88-.5 2.65-.44c2.67-.59 5-1.54 7.54-2.56q2.64-.63 5.31-1.12L838 928l-14-1 1-3 2.12-.18 2.75-.26 2.75-.24C835 923 835 923 837 922z"/><path fill="#757475" d="m1471 697 2 1-4.25 3.38-2.4 1.9c-2.35 1.72-2.35 1.72-4.58 2.76-1.77.96-1.77.96-2.77 3.96l-1.68.11-2.2.2-2.17.18c-2.43.64-2.7 1.43-3.95 3.51-2.62.69-2.62.69-5 1v3l-2.05.15-2.7.23-2.67.2c-2.58.42-4.28 1.23-6.58 2.42-2.25-.37-2.25-.37-4-1l2.06-1.19a76 76 0 0 0 6.32-4.37 20 20 0 0 1 7.5-3.63l3.12-.81 1-2c1.56-.78 1.56-.78 3.44-1.5 2.52-.97 3.6-1.53 5.56-3.5 2.14-.73 4.26-1.4 6.44-2 7.05-1.99 7.05-1.99 9.56-4"/><path fill="#050408" d="M452 567h5l1 2c1.58.38 1.58.38 3.61.56l2.27.23 2.43.21c5.28.52 10.48 1.07 15.69 2.06 5.93 1.08 11.85 1.33 17.86 1.51l2.23.09 2 .05c2.26.34 3.92 1.18 5.91 2.29a4004 4004 0 0 1-21.68-.68l-1.82-.05c-4.27-.16-4.27-.16-6.5-1.27v3l3 1a61 61 0 0 1-32-7l3-1v-2z"/><path fill="#1c1b1e" d="M629 170c2.88-.19 2.88-.19 6 0l.94 1.44c1.06 1.56 1.06 1.56 2.73 1.98q2.66.35 5.33.58l-1-3 4 2v3c2.7 1.35 5.12 1.34 8.13 1.56l3.32.26 2.55.18v-2l-9-1v-1a433 433 0 0 1 5.96-.1c2.04.1 2.04.1 5.04 1.1v2l3 1h-2l2 4 41 1v1q-9.39.12-18.79.16l-6.38.07-9.2.06-2.86.05c-5.18 0-9.29-.52-14.05-2.6-2.79-1.2-5.63-1.73-8.6-2.37-5.93-1.28-5.93-1.28-8.12-2.37v-2l-1.69-.37A46 46 0 0 1 630 172z"/><path fill="#020204" d="M280 1026c7.29-.25 13.9.28 21 2v2l3.18-.04c7.34-.05 14.55-.1 21.82 1.04l1 3h-27l-1-3-3.14.07-4.11.06-2.07.05c-3.75.03-6.44-.06-9.68-2.18z"/><path fill="#8f8e92" d="M418 441h3q.05 4.65.06 9.31l.03 2.67.02 4.94C421 460 421 460 420 463h-2l-.06 1.54-.31 7.02-.1 2.43c-.24 4.95-.73 9.36-2.53 14.01l-3 1q-.09-2-.12-4l-.08-2.25c.2-2.75.2-2.75 1.11-6.1 1.73-6.54 2.05-13.37 2.65-20.09l.92-10.08c.4-4.37.4-4.37 1.52-5.48"/><path fill="#5c5b60" d="M1419 1682h3v19l-4 2v10q-2.49.57-5 1c-1.59-1.59-1.17-3.25-1.19-5.44l-.04-2.43c.23-2.13.23-2.13 2.23-4.13q.33-3.12.44-6.25c.27-5.24.66-9.85 4.56-13.75"/><path fill="#000001" d="m974.6 1226.8 3.02.07 3.04.06 2.34.07v3l-2.34.62-3.04.82-3.02.8c-2.6.76-2.6.76-4.6 1.76a166 166 0 0 1-9.12.05L956 1234l1-3 2.77-.37 3.6-.5 1.83-.24c3.83-.56 5.87-2.82 9.4-3.09M956 1234l-1 4-2.75.73c-6.46 1.77-12.57 3.6-18.58 6.63l-1.67.64-2-1v-2l1.87-.31c2.13-.69 2.13-.69 3-2.22 1.13-1.47 1.13-1.47 3.08-1.76l2.17.1 2.2.08 1.68.11.5-1.94c1.5-2.06 1.5-2.06 3.34-2.55 2.73-.33 5.41-.51 8.16-.51"/><path fill="#16171f" d="M1008 1184c-5.69 4.06-12.73 5.59-19.44 7.13l-2.12.5a78 78 0 0 1-10.91 1.8c-4.78.49-9.03 1.84-13.53 3.44l-4.5 1.56-2.24.79q-3.47 1.19-6.98 2.32a76 76 0 0 0-9.78 4.02c-4.14 1.94-8 3.2-12.48 4.04a56 56 0 0 0-8.02 2.4c3.5-4.38 7.36-5.35 12.56-6.75l2.33-.67c3.18-.89 5.79-1.58 9.11-1.58v-2l2.13-.77 2.87-1.04 2.81-1.02q2.51-.9 4.98-1.9c5.45-2.15 10.93-3.44 16.65-4.64a220 220 0 0 0 22.93-6.1c4.63-1.48 8.78-1.78 13.63-1.53"/><path fill="#8f8f90" d="M709 948c-1 2-1 2-3.5 2.85L697 953l12 1v1q-14.97 2.19-30 4c2.2-2.2 2.63-2.25 5.56-2.5l3.44-.5 1-2h-21l1-2c12.46-4.07 27.01-4.37 40-4"/><path fill="#2a2a30" d="M868 505c-6.27 2.72-12.46 3.9-19.19 4.94-7.6 1.23-14.77 2.71-22.08 5.19a74 74 0 0 1-10.51 2.38c-2.22.49-2.22.49-4.48 1.54-3.74 1.3-7.13 1.17-11.05 1.08l-2.25-.03-5.44-.1v-1h10v-2c5.53-2.44 10.89-3.57 16.85-4.47 5.9-1 11.66-2.62 17.45-4.13 5.54-1.36 10.99-1.75 16.67-2.11 2.55-.24 4.67-.63 7.1-1.42 3.04-.9 4-.76 6.93.13"/><path fill="#919395" d="M31 786c.57 3.74-.2 5.61-2.37 8.56l-1.38 1.8A27 27 0 0 0 24 802h-4v6l-5 1-.81-2.37c-1.17-2.6-1.72-3.4-4.19-4.63l1-7h3v8l4-2c-.62-4.68-.62-4.68-2.56-6.31L14 794l1-4h3v5c3.93-2.08 6.69-4.56 9.67-7.8C29 786 29 786 31 786"/><path fill="#5f5f63" d="M428 355h2c-.9 10.46-2 20.8-3.96 31.12-.75 4-1.27 7.93-1.54 12-.3 4.43-1 8.67-1.94 13a177 177 0 0 0-2.86 19.36A234 234 0 0 1 417 449h-1c-.37-10.13.38-19.52 2.2-29.48.8-4.4 1.37-8.82 1.86-13.27.83-7.44 1.84-14.84 2.94-22.25h2l.44-5.19.27-3.3q.3-3.52.54-7.06l.25-3.45.22-3.19c.24-2.38.62-4.52 1.28-6.81"/><path fill="#7f7e83" d="M1411 374h34l1 5c-2.5 2.5-9.09 1.28-12.44 1.31l-3.2.09c-7.28.06-12.83-1.17-19.36-4.4z"/><path fill="#3c3b46" d="m582 1564 2 1c-7.22 6.7-7.22 6.7-10.62 8-2.7 1.13-3.47 2.17-5.25 4.44-2.54 3.13-4.58 4.54-8.34 5.88a34 34 0 0 0-5.41 2.74 41 41 0 0 1-6.07 3c-2.31.94-2.31.94-4.43 2.57C542 1593 542 1593 539 1593l-2 4-4-1 3.42-2.64c1.58-1.36 1.58-1.36 3.79-3.71 3.05-3.09 6.56-5.17 10.29-7.34l1.97-1.18A70 70 0 0 1 563 1576c4.47-2.44 8.94-5 12.88-8.25 1.99-1.64 3.7-2.83 6.12-3.75"/><path fill="#413f43" d="m1481 1400 2 1-1.71 1.8-4.44 4.74c-1.85 2.46-1.85 2.46-2.35 5-.66 3.25-2.08 4.38-4.5 6.59-3.9 3.58-3.9 3.58-5 6.87h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.14-.45-2.14-.45-4.81-.69l-2.65-.26a19 19 0 0 0-6.54.95l2-4h5l1-4 1.82-.29c3.28-1.07 4.5-3.33 6.52-6.02 1.66-1.69 1.66-1.69 4.08-2.62 3.2-1.33 4.67-3.06 6.9-5.7l2.1-2.47z"/><path fill="#020407" d="M1290 1289v40l-2-2-1 7h-1c-2.7-9.53-3.37-18.13-3-28h2l.37-3.46.5-4.48.24-2.28.26-2.18.22-2.02.41-1.58c2-1 2-1 3-1"/><path fill="#846650" d="m965 1237 2 1-1 2 1.74-.05c19.33-.46 19.33-.46 28.14 2.25 4.98 1.27 10.05 1.67 15.16 2.15l2.25.22 2.02.18c1.69.25 1.69.25 3.69 1.25a221 221 0 0 0 9.77.7l5.23.3v2l8-1-1 3c-9.74.53-18.86-.91-28.43-2.56q-6.62-1.11-13.26-2.13L991 1245v-2l-3.31.06c-2.66.05-4.07-.19-6.69-1.06a73 73 0 0 0-4.01-.32l-2.3-.12-2.38-.12-8.31-.44z"/><path fill="#020307" d="M298 974a3435 3435 0 0 1 22.5-.15c9.75-.09 19.22-.14 28.84 1.66 4.19.77 8.42 1.13 12.66 1.49v1c-11.72.1-23.33.1-35-1v3h9v1h-10l-1-2c-1.95-.4-1.95-.4-4.48-.6l-2.78-.24-2.93-.22-5.67-.48-2.7-.22A93 93 0 0 1 298 976z"/><path fill="#a493c3" d="m869.13 552.94 3.32.02 2.55.04c-.73 1.47-.73 1.47-2 3-1.95.4-1.95.4-4.25.5-4.22.33-7.02 1.43-10.75 3.5a41 41 0 0 1-12.17 2.66c-1.83.34-1.83.34-3.12 1.36-2.78 1.6-5.78 1.19-8.9 1.1l-1.98-.02-4.83-.1c.59-1.38.59-1.38 2-3 2.21-.79 4.29-1.4 6.56-1.94l1.98-.5a863 863 0 0 1 18-4.25c10.5-2.4 10.5-2.4 13.59-2.37"/><path fill="#06050b" d="m985 156 2 1-5.87 5.5-1.69 1.59-1.62 1.5q-.73.7-1.5 1.4C975 168 975 168 973 168q-.3 1.05-.62 2.1c-1.84 3.86-4.43 6.27-7.48 9.19l-1.8 1.77q-2.85 2.8-5.73 5.56-3.78 3.67-7.53 7.36l-1.77 1.7-1.62 1.58-1.44 1.39C944 200 944 200 944 203c-1.8 1.55-2.6 2-5 2a101 101 0 0 0-4.62 4l-2.48 2.25L930 213c0-3 0-3 2.23-5.52q1.46-1.44 2.96-2.85l4.38-4.24q3.25-3.16 6.43-6.39l2.5-2.44a93 93 0 0 0 6.27-7.1 70 70 0 0 1 4.72-4.9c1.65-1.7 3.2-3.49 4.76-5.27 3.11-3.55 6.02-6.83 10.13-9.22 3.87-2.56 7.2-5.94 10.62-9.07"/><path fill="#0c0c13" d="M386 1557h1v107h2a120 120 0 0 1 2.88 13.69c.9 5.68 2.43 10.95 4.22 16.4a65 65 0 0 1 1.9 7.91c-2.95-1.47-3.43-4.08-4.56-7-2.33-5.89-2.33-5.89-3.44-7q-.22-1.8-.32-3.6l-.12-2.18-.12-2.28-.13-2.3-.31-5.64h-2c-1.03-5.93-1.14-11.7-1.11-17.7v-11.82l.02-9.02q0-8.54.02-17.07l.02-19.43z"/><path fill="#2e2f37" d="M441 1467h1l.01 1.8a28089 28089 0 0 0 .41 64.3 7532 7532 0 0 0 .18 27.96l.08 10.84v3.18c.21 18.4.21 18.4 5.32 24.92-.31 2.25-.31 2.25-1 4-4.75-3.6-5.82-8.4-7-14a117 117 0 0 1-.25-12.43l.03-3.64.12-9.78.1-10.26.23-19.4q.14-11.04.25-22.08.23-22.71.52-45.41"/><path fill="#313035" d="M878 818a35 35 0 0 1-15.06 4.44c-5.29.4-10.21 1.48-15.36 2.74-6.79 1.6-13.7 2.7-20.58 3.82l-1.98.33c-7.02 1.13-13.89 2.01-21.02 1.67 2.78-2.25 5.74-2.79 9.19-3.56l1.76-.4c3.68-.8 7.32-1.44 11.07-1.73 1.98-.31 1.98-.31 5.12-1.26a68 68 0 0 1 11.66-2.09l2.16-.25 6.79-.77 11.06-1.27C877.4 818 877.4 818 878 818"/><path fill="#2a2834" d="M762 571v1c-11.64 2.3-23.2 4.35-35.04 5.32-4.05.34-7.94.85-11.92 1.67-7.9 1.5-15.9 1.7-23.91 2.07l-9.37.48-2.29.1q-6.76.36-13.52.91c-5.1.42-10.15.57-15.26.51l-2.28-.01L643 583v-1l2.06-.13a6199 6199 0 0 0 29.46-1.93c26.02-1.66 26.02-1.66 37.57-4.32 4.22-.9 8.35-1.06 12.66-1.12 7.74-.24 15.2-1.1 22.8-2.48 4.9-.88 9.47-1.15 14.45-1.02"/><path fill="#09090c" d="M397 248h1v15h3l1-2-1 5a85 85 0 0 1-7-2v21h3l1 17-2 1-1-5h-1l-2-13c-1.48 1.48-1.2 2.75-1.32 4.82l-.12 2.31-.12 2.43-.13 2.45L390 303h-3l.48-1.9c.84-5.01.84-10.08.95-15.15.19-4.96.81-7.73 3.57-11.95.51-3.11.51-3.11.69-6.31l.2-3.24.11-2.45-3-1v-2l5 1z"/><path fill="#3f3f46" d="M782 1670c-1.41 3.34-3.05 5.08-5.87 7.31-3.17 2.69-5.38 5.06-7.19 8.8a61 61 0 0 1-1.96 3.64c-3.06 5.42-3.63 10.09-3.73 16.25l-.09 2.64q-.1 3.18-.16 6.36c-2.5-2.5-2.28-4.05-2.33-7.46l.02-2.16v-2.2c.1-5.3.91-9.39 3.31-14.18l-1-1q-.06-3 0-6l2-1 .81-1.75c3.23-6.12 9.11-11.47 16.19-9.25"/><path fill="#313038" d="m471 1628 1.65.84q3.91 1.93 7.91 3.66l2.88 1.28c8.44 3.2 17.45 3.52 26.37 3.54l12.53.08 9.58.07q9.04.07 18.1.12l20.61.14q21.18.15 42.37.27v1q-24.19.14-48.38.2l-22.46.1q-9.8.06-19.59.08-5.17 0-10.36.04-4.89.04-9.78.03-2.62.01-5.25.05A40.7 40.7 0 0 1 478 1635v-2l-1.81-.25c-2.79-.95-3.62-2.32-5.19-4.75"/><path fill="#56555f" d="M1263 1099c2.02 3.35 2.24 5.9 2.23 9.78v1.71q0 2.8-.03 5.59l-.01 3.88-.05 10.2-.04 10.42q-.03 10.2-.1 20.42h-3l-.08-34.86a4563 4563 0 0 1-.03-20.43l-.01-3.27c.12-2.44.12-2.44 1.12-3.44"/><path fill="#929292" d="M932 897h13v2h13v2c-9 4.1-17.24 4.76-27 5v-2h9l1-4-12 1v3h-15c1-2 1-2 3.38-3.06 4.72-1.47 9.71-1.6 14.62-1.94z"/><path fill="#545456" d="M319 644h4c-.5 6.4-.5 6.4-2.87 9.25-2.6 3.36-3.66 6.54-4.88 10.56l-.67 2.1L313 671h-1v-8l-1 2h-2v-3l-2.44 2.25c-3.14 2.63-6.34 4.09-10.11 5.63-3.67 1.68-7 3.86-10.38 6.05L284 677l-2-1c2.57-2.19 4.66-3.84 7.74-5.29 4.55-2.22 8.25-5.56 12.14-8.77 3.27-2.65 6.1-4.53 10.12-5.94q1.53-1.47 3-3l3-1c.69-2.06.69-2.06 1-4l-3-1h2z"/><path fill="#170f29" d="M1356 451c3.9 3.61 3.9 3.61 5 5v3h2l.8 2.12 1.08 2.75 1.05 2.75C1367 469 1367 469 1369 471c2.43 7.05 4.04 14.54 3 22-1.31 2.63-1.31 2.63-3 5q-.7 2.05-1.31 4.13c-1.08 3.07-1.93 4.42-4.88 5.87l-2.81 1 .88-1.72q1.63-3.36 3.12-6.78c.98-2.24 1.97-4.43 3.1-6.6 3.06-6.46-.28-13.94-2.48-20.24-1.3-3.5-2.86-6.86-4.47-10.22A47 47 0 0 1 1356 451"/><path fill="#826147" d="M1207 1206c22.95-.19 22.95-.19 34.06.44l2.14.12c3.04.19 5.99.47 9 .95 5.45.82 10.99.78 16.49.93A397 397 0 0 1 1303 1211l1 5h-2v-3l-1.93.03c-7.6.06-15.12-.34-22.7-.87l-11.76-.81a4061 4061 0 0 0-18.03-1.25l-8.98-.62-3-.2c-2.3-.25-4.37-.68-6.6-1.28q-2.6-.21-5.2-.32l-2.97-.12-13.83-.56z"/><path fill="#16191e" d="M349 1154h1l.09 11.92a119 119 0 0 1-.6 13.2c-.54 5.85-.6 11.65-.55 17.53l.06 13.29.09 16.42v2.86l.02 2.51c-.1 2.22-.5 4.14-1.11 6.27h-2a7238 7238 0 0 1-.15-33.91 2167 2167 0 0 1-.06-14.34q-.03-2.78-.02-5.56l-.01-3.2c.26-3.26 1.12-5.92 2.24-8.99.32-2.33.32-2.33.41-4.52l.12-2.4.1-2.45.11-2.52q.15-3.05.26-6.11"/><path fill="#0f0f15" d="M1487 571c3.65 3.1 5.74 6.42 6.52 11.15.12 3.01.14 5.97.04 8.98q-.08 2.8-.1 5.6c-.2 16-.2 16-4.46 20.27l.49-2.58c.79-5.26.73-10.52.75-15.82q.02-2.94.07-5.88c.12-13.95.12-13.95-3.31-18.72-3.19-1-3.19-1-6-1l-1 3-6 2-1 3h-2l-.25 2.75c-.87 3.78-2.03 4.6-4.75 7.25a65 65 0 0 0-2.27 5.32A23 23 0 0 1 1460 602c1.93-10.6 6.84-20.67 15.09-27.8 3.82-2.6 7.26-4.51 11.91-3.2"/><path fill="#7c7b7f" d="m1331 274 2 1c.59 2.31.74 4.62 1 7l-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l1.96.72c2.04 1.28 2.04 1.28 2.54 3.37l.06 2.41c.07 2.4.07 2.4.44 4.5q1.47 1.05 3 2c.19 3.13.19 3.13 0 6l-5 1c-1.5-3-1.06-5.66-1-9l-1.87-.56c-2.84-1.92-2.98-3.64-3.64-6.88L1326 291l-2-1v-7c3-1 3-1 6 0l-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#8d8c8f" d="M619 139h3c-.87 4.88-.87 4.88-2 6-.35 5.61-.71 11.6 1.06 17 1 3.2.9 4.84-.06 8h-3v-4l-1.81-.81c-2.3-1.25-3.63-2.08-5.19-4.19.1-2.67.1-2.67.75-5.75l.63-3.22.62-3.03q.33-2.12.63-4.25L614 142h5z"/><path fill="#403f49" d="M912 1766h54v1l13 1v1h-72v-1h5z"/><path fill="#a2acb5" d="M1494 863h2v2l3 1-1 2 4-1c1 2 1 2 .65 3.52-3.33 8.64-6.92 17.94-13.65 24.48h-3c-.24-4.82.55-8.46 2-13l-2-1 7-1v-5l4-1v-8l-3-1z"/><path fill="#3c3b3f" d="M413 808c5.37-.34 9.62-.07 14.58 2 8.43 3.47 17.05 6.18 26.05 7.6 3.22.54 6.25 1.44 9.37 2.4q2 .51 4 1v2l2.25.38c2.55.57 4.48 1.36 6.75 2.62-3.84 1.28-6.48.33-10.25-.69l-2.14-.56q-2.22-.58-4.43-1.17l-6.18-1.6a182 182 0 0 1-19.61-6.1c-3.1-1.14-6.2-2.03-9.39-2.88v-2l-3.25.13c-3.08 0-5.1-.44-7.75-2.13z"/><path fill="#18181f" d="M794 1668q24.19-.17 48.38-.26 11.22-.03 22.46-.12 9.8-.07 19.59-.09 5.17 0 10.36-.06 4.89-.04 9.78-.04 2.63 0 5.25-.05c10.89.07 18.44 4.16 26.18 11.62l-1 2c-4.73-1.82-7.45-3.34-11-7a64 64 0 0 0-6-3v-2H794z"/><path fill="#29282c" d="m536.38 976.94 4.57.01L552 977v1l-1.86.08q-4.26.17-8.51.36l-2.93.12c-18.04.78-18.04.78-19.7 2.44q-2.02.14-4.03.12h-8.58l-10.02-.02-23.32-.03-18.66-.02L416 981v-1l33.93-.34 30.94-.31 13.92-.14 2.44-.02 2.23-.03 1.94-.02C503 979 503 979 505 978c10.41-1.24 20.9-1.12 31.38-1.06"/><path fill="#a3a3a5" d="m232.06 692.94 1.94.06-1 4h-3l-.87 2.44C228 702 228 702 226 703l1 2-3.25.81c-2.48.7-3.76 1.2-5.81 2.82-2.68 1.9-4.74 1.59-7.94 1.37l3-1v-3l-4.19 2.31-2.48 1.37a115 115 0 0 0-5.27 3.2L199 714l-2-1 3-1 1-3 3-1 1.85-1.93c2.46-2.36 4.36-3.1 7.59-4.13A44 44 0 0 0 224 697l2.75-1.62c3.92-2.4 3.92-2.4 5.31-2.44"/><path fill="#290505" d="M1446 1259c3.78 1.51 3.78 1.51 4.88 3.94 1.63 3 4.1 3.58 7.12 5.06 2.62 2.72 3.84 5.82 5.25 9.27 1.03 2.38 2.4 4.52 3.75 6.73 2.12 4.3 2.3 7.96 2.3 12.65l.01 2.23v4.68q.01 3.51.05 7.01c.05 11.94-1.07 23.28-6.05 34.3l-1.32 3-.99 2.13h-1c-.38-5.22.86-8.41 3-13q.84-1.95 1.63-3.94l.72-1.77c1.93-6.78 1.83-13.86 1.84-20.85l.03-2.75c.02-5.35-.58-9.6-2.22-14.69-.46-2.3-.79-4.6-1.12-6.94q-.14-.87-.27-1.78l-.61-4.28h-2l-1.25-2.69a31 31 0 0 0-8.87-10.81c-2.73-2.17-3.9-4.1-4.88-7.5"/><path fill="#795a43" d="m1300 1216 2 1-2.68 2.17a135 135 0 0 0-19.92 19.92 86 86 0 0 1-7.4 7.91c1.38-2.98 3-5.38 5.06-7.94 2.3-2.98 3.7-5.2 3.94-9.06h-3v3l-3 1c-.69 2.06-.69 2.06-1 4h-3c-.38-2.61-.4-4.37 1.04-6.63 3.35-4.06 3.35-4.06 5.96-5.37l1-3 3-1c.69-2.06.69-2.06 1-4l8-1c-1.06 3.17-1.46 3.58-4 5.44-2.11 1.67-2.9 2.3-3.87 4.87l-.13 1.69c3.83-2.13 7-4.64 10.19-7.62 4.57-4.26 4.57-4.26 6.81-5.38"/><path fill="#977556" d="M1177.4 1206.87h18.04c9.56-.01 19.05.02 28.56 1.13-2.86 2.55-5.6 2.24-9.3 2.23h-1.93q-3.15 0-6.28-.03l-4.36-.01-11.47-.05-11.7-.04q-11.47-.04-22.96-.1l1-2c6.6-1.4 13.67-1.15 20.4-1.13"/><path fill="#88898a" d="m1077.44 857.31 1.56.69-14 6h10l-1 3h-9v3h9v1c-7.91 3.6-15.5 3.3-24 3l1-3 1.71-.15 4.44-.44 1.85-.41 1-2h-8c2.43-3.64 4.73-4.22 8.69-5.5l3.81-1.29 1.84-.62c1.99-.7 3.88-1.56 5.79-2.47 2.87-1.12 2.87-1.12 5.3-.8"/><path fill="#0a0a0d" d="M1186 42c4.88-.7 8.1-.36 12.13 2.63q1.96 1.65 3.87 3.37l1.84 1.66c2.95 2.82 2.95 2.82 4.16 4.34v2l6 1 1 8-4-2v-4l-3.75-.5-2.1-.28A51 51 0 0 0 1199 58v-4h-1q-2-.48-4-1l1-4-4 1v-4l-5-2z"/><path fill="#a69488" d="M1344 1365c5.14 4.07 9.45 8.64 13.8 13.53 7.29 8.2 12.36 11.81 23.11 14 2.49.56 4.77 1.42 7.09 2.47-8.85 6.03-22.52 3.15-32.6 1.42-6.2-1.2-12-3-17.4-6.42l-1-2 1.4.52c12.95 4.53 24.86 7.2 38.6 6.48l-2.77-.95-3.6-1.3-1.83-.62c-4.51-1.65-4.51-1.65-5.94-4.02l-.86-2.11q-2.09-2.29-4.31-4.44l-2.44-2.38-2.25-2.18-2.81-2.87c-2.19-2.13-2.19-2.13-4.5-3.7L1344 1369c-.1-2.09-.1-2.09 0-4"/><path fill="#0d0e15" d="M1096 1170v1l-2 .34q-4.53.76-9.06 1.54l-3.15.52c-8.68 1.5-8.68 1.5-12.6 2.71-3.42.95-6.56 1.14-10.07 1.2-7.56.3-14.84 1.63-22.24 3.13l-3.27.66c-8.42 1.75-8.42 1.75-11.95 3a49 49 0 0 1-9.16 1.96q-6.26.89-12.5 1.94c2.7-2.7 5.7-3.12 9.31-4 7.18-1.78 7.18-1.78 10.1-3.03 3.83-1.43 7.74-1.9 11.78-2.47 4.9-.72 9.77-1.47 14.62-2.5 5.96-1.22 11.96-1.85 18-2.5a373 373 0 0 0 23.67-3.17c2.86-.38 5.64-.38 8.52-.33"/><path fill="#000001" d="M6 844h4v44H6z"/><path fill="#aa93c7" d="m437 600 2.45.43c4.12.7 8.27 1.16 12.43 1.63l8.12.94 1 3 1.98-.29 2.64-.34 2.6-.35c3.31-.02 5.66.9 8.78 1.98 3.21.56 6.46.87 9.7 1.21 3.32.39 5.62.78 8.3 2.79-27.4 1.76-27.4 1.76-35.8-2.96-3.6-1.7-7.42-2.42-11.28-3.31A77 77 0 0 1 437 601z"/><path fill="#7b7b7d" d="M535.83 134.36c4 .55 7.94.78 11.97.85l1.89.03 10.17.14 10.96.15 11.22.16L604 136v1l-2.1.02q-9.87.09-19.73.22-5.07.06-10.14.1-4.9.04-9.8.12l-3.72.04c-6.61.03-12.4.32-18.57 2.8-2.77 1-5.5 1.2-8.4 1.4l-1.54.3-1 2h-6v-2l5-2-5-1v2l-4-1 1.83-.8 2.42-1.07 2.4-1.06C528 136 528 136 529.9 134.91c2.37-1.03 3.4-.99 5.92-.55"/><path fill="#212026" d="M1293 771h8c-.24 1.9-.24 1.9-1 4-1.82 1.01-1.82 1.01-4.12 1.69l-2.46.73c-2.1.5-4.1.84-6.23 1.08-3.49.55-4.49 1.37-7.19 3.5-2.62 1.14-5.3 2.06-8 3l-4.25 1.56-2.01.74c-1.74.7-1.74.7-3.8 1.89-3 1.25-5.72.93-8.94.81 1.03-1.88 1.6-2.84 3.62-3.66l1.82-.4c1.98-.45 3.37-.8 5.05-1.96 2.61-1.7 5.46-1.63 8.51-1.98l1-3c1.52-.62 1.52-.62 3.38-1 3.46-.84 3.46-.84 5.18-2.56 2.2-2.2 4.41-2.19 7.44-2.44l3 1z"/><path fill="#633d9d" d="m820.26 734.8 3.87.07 2 .03 4.87.1v3l-2.16.11-2.9.2-2.85.18c-2.6.43-4.1.9-6.41 1.99-4.06 1.85-8.02 2.22-12.43 2.58l-4.48.41-1.98.17c-1.79.36-1.79.36-3.3 1.38-1.83 1.2-2.9 1.21-5.06 1.18l-2.03-.02-2.09-.06-2.12-.02-5.19-.1 1-3 3.4-.15 4.41-.23 2.24-.09 2.16-.12 1.98-.1c2.04-.35 3.19-1.06 4.81-2.31l-1-2 1.62-.06q3.63-.15 7.26-.32l2.55-.09 2.44-.12 2.25-.1c3.27-.55 3.4-2.28 7.14-2.5"/><path fill="#e6e6e5" d="m1081 1030 62 1-1 2c-5.5 1.42-11.2 1.16-16.84 1.17l-19.78.05-6.4.01-3.02.02c-5.39-.02-9.87-.47-14.96-2.25z"/><path fill="#575b5c" d="m1 842 3 1 .09 8.76c.01 3.04-.12 5.33-1.09 8.24a107 107 0 0 0-.19 5.88l-.04 3.11C3 872 3 872 4 874.46c1.27 3.23 1.2 5.89 1.13 9.35l-.03 1.81L5 890l-5-1-.08-26.27-.02-9.57-.01-3.03v-2.8l-.01-2.46C0 843 0 843 1 842"/><path fill="#8b8c8d" d="M852 155a29 29 0 0 1-7 3.63l-2.06.78C841 160 841 160 838 160v2l-11 1v2h5v1a102 102 0 0 1-10 3v-3c-5.48-.34-9.7 0-14.75 2.13a78 78 0 0 1-8.5 2.62l-3.02.8c-2.68.44-4.2.34-6.73-.55 6.49-2.86 13-4 20-5v-2l2.77-.62 3.6-.82 1.83-.4c2.4-.55 4.59-1.05 6.8-2.16a234 234 0 0 1 5.63-.87c3.75-.6 7.12-1.27 10.62-2.75 3.95-1.66 7.52-1.68 11.75-1.38"/><path fill="#52515b" d="M1312 1488c6.75.75 6.75.75 9 3 .2 2.11.26 4.09.24 6.2v1.95l-.01 6.49v4.64l-.03 12.63-.01 13.18-.05 24.98-.04 28.43q-.04 29.25-.1 58.5h-1v-156l-8-2z"/><path fill="#61412b" d="M1070 1218c0 3.07-.55 5.2-1.44 8.13l-.8 2.69c-.76 2.18-.76 2.18-1.76 3.18-2.36 7.92-2.21 15.83-2 24l1.91.3c5.97 1 11.72 2.37 17.51 4.09 7.14 2.1 14.36 3.85 21.58 5.61-3.92 1.4-7.42.77-11.44.19l-1.98-.25c-2.88-.4-5.35-.85-8.05-1.93-4.07-1.62-8.4-2.04-12.74-2.6-3.7-.6-5.42-1.07-8.27-3.66-1.72-3.1-1.97-4.86-1.83-8.37l.07-2.86c.24-2.52.24-2.52 1.24-5.52q.28-4.88.43-9.77c.67-3.8 2-5.41 4.57-8.23q1.55-2.47 3-5"/><path fill="#a4a5a6" d="M83 821h2q.08 2.43.13 4.88l.07 2.74C85 831 85 831 83 833c-.9 4.08-1.17 8.1-1.32 12.27l-.12 3.5-.25 7.28L81 865l-4-1q-.04-5.71-.06-11.44l-.03-3.24c-.02-6.18.22-12.2 1.09-18.32l3-1 .18-1.71.26-2.23.24-2.21C82 822 82 822 83 821"/><path fill="#5d5d62" d="m581 163 1 2-2.04-.03-9.34-.1-3.21-.05c-8.98-.05-17.61-.05-24.74 5.96-1.67 1.22-1.67 1.22-4.08 2.09-3.19 1.39-4.9 3.22-7.28 5.75q-2.1 2.26-4.31 4.38h-2l-.69 1.69c-1.4 2.46-3.12 4.1-5.16 6.04-1.66 1.84-2.24 3.97-3.15 6.27-1.46 2.07-3 4.05-4.56 6.04C510 205 510 205 509 207.18c-1 1.82-1 1.82-4 2.82 1-2.7 2.06-5.1 3.56-7.56A18 18 0 0 0 511 196l1.88-.19L515 195l.81-2.06c3.04-7.5 10.14-12.84 16.19-17.94l1.82-1.66c3.23-2.94 6.15-5.1 10.27-6.62 1.91-.72 1.91-.72 3.98-1.79 9.33-4.53 22.87-4.54 32.93-1.93"/><path fill="#898890" d="M424 1654h1l.08 2.94.42 15.82q.42 16.63 1.5 33.24l-4 2c-1.13-2.27-1.13-3.52-1.15-6.03l-.01-2.52v-2.74l-.02-2.81v-5.93q-.01-4.5-.04-9l-.01-5.76-.02-2.69c.02-5.75.77-10.96 2.25-16.52"/><path fill="#07080d" d="M390 986a6440 6440 0 0 1 31.92-.15 1926 1926 0 0 1 13.5-.06q2.6-.03 5.21-.02l3-.01C446 986 446 986 448 988c-1 1-1 1-3.28 1.12h-6.52l-7.6-.03-10.1-.02-10.29-.02L390 989z"/><path fill="#8a8b8b" d="m1049 869-1 3-10 1v2h10l-1 4-1.54.43-7.02 1.95-2.43.67q-7 1.94-14.01 3.95c2.55-3.11 5.26-3.78 9-5l1-2h-9v-2c2.9-1.45 5.8-1.38 9-1.62l2-.38 1-2-4-1c6.16-2.45 11.38-3.42 18-3"/><path fill="#303032" d="m436 801 8 1v2l1.54-.07c4.65-.12 8.14.34 12.46 2.07v2l9 2v3c-8.4.68-15.15-1.34-23-4q-2.06-.58-4.12-1.12A18 18 0 0 1 434 805h2z"/><path fill="#727173" d="m1384.06 738.94 2.94.06c-1.44 2.88-3.48 3.47-6.31 4.94a52 52 0 0 0-7.84 4.7c-2.5 1.84-4.82 2.02-7.85 2.36l-1 3-8 1v2l-1.47.3c-3.7.82-6.85 1.77-10.15 3.64-5.69 3.04-12.1 3.9-18.38 5.06 1.85-3.7 6.5-4.86 10.13-6.56l2.55-1.25c7.03-3.33 7.03-3.33 10.97-3.76 4.1-.53 7.18-1.97 10.85-3.8l3.78-1.84 1.86-.9c8.14-3.89 8.14-3.89 11.86-3.89l.94-1.94c1.57-3.05 1.57-3.05 5.12-3.12"/><path fill="#303133" d="M409 210v4l-2 1c-.56 2.43-.56 2.43-1 5.44-.78 5.34-.78 5.34-3 7.56a89 89 0 0 0-2 4v-6c-1 1-1 1-1.1 2.63l.1 5.37h-2v2h-2v2h-2v-11l4-2v-10l4-2 1-2c2.09-.67 3.8-1 6-1"/><path fill="#100f15" d="M466 1624c3.33.55 6.1 1.24 9 3l1 2a49 49 0 0 0 5.38 2.5l2.96 1.28c8.51 3.11 17.4 3.51 26.37 3.45h3.38l9.05-.03 9.5-.01q8.96-.01 17.93-.05l20.43-.04q21-.04 42-.1v1q-22.12.3-44.26.55-10.28.1-20.55.25l-17.93.21-9.48.12c-16.97.27-31.63.4-46.78-8.13q-1.74-.83-3.5-1.62c-2.34-1.3-3.37-2-4.5-4.38"/><path fill="#9563d0" d="M406 629h1c.19 7.07-.24 13.94-1.02 20.96-1.92 17.35-2.4 34.67-2.67 52.1l-.03 1.88q-.16 11.52-.28 23.06l3 1v2l-10-2v-1h5l-.01-1.44c-.3-32.54 1.14-64.25 5.01-96.56"/><path fill="#4b4a4f" d="m236 714 1 4-1.94 1.25C233 721 233 721 232.62 722.94c-.87 2.9-2.59 3.72-5.05 5.33-1.57.73-1.57.73-4.57.73a71 71 0 0 0-17 12c1.34-3 2.78-4.96 5.19-7.19a69 69 0 0 0 7.37-8.43C223.1 719.43 228.03 714 236 714"/><path fill="#7954b4" d="M635 609c-3.62 2.53-6.67 4.22-11.15 4.38l-2.47.1-2.67.08-2.81.11-5.98.22q-7.81.28-15.63.6l-3.15.13q-14.27.59-28.51 1.5l-2.15.14L549 617v-3l15-.94 2.3-.14c8.51-.51 17.03-.97 25.56-1.2l2.14-.06q5-.14 10.03-.23c7.07-.15 13.95-.68 20.95-1.74 3.38-.51 6.6-.79 10.02-.69"/><path fill="#09080e" d="M1561 443q2.13-.12 4.25-.19l2.4-.1c2.35.29 2.35.29 4.14 1.7 3.23 2.32 6.52 2.53 10.4 3.09 6.9 1.1 13.51 2.35 19.81 5.5v2l1.84.11c6.18.49 12.5 1.2 18.16 3.89 1.79 2.1 1.79 2.1 3 4h-7v-2h-6v-2l-1.9-.11A48 48 0 0 1 1594 455l-3-1v-2l-3.06-.37c-6.76-.99-12.94-3.31-19.3-5.73-2.53-.86-5-1.44-7.64-1.9z"/><path fill="#27262b" d="M924 807v2l-2.74.4-5.38.8c-2.74.42-5.23.92-7.88 1.8-3.03.27-6.06.44-9.09.62-2.87.37-4.38 1.1-6.91 2.38-2.29.45-4.5.82-6.81 1.13l-1.8.26c-4.16.57-8.19.69-12.39.61v2a858 858 0 0 1-27.95 4.03c-4.72.62-9.29 1.15-14.05.97 2.53-2.53 3.8-2.41 7.3-2.78l3.21-.36 3.37-.36q3.28-.35 6.55-.72l2.94-.3c2.6-.47 4.3-1.29 6.63-2.48 2.27-.45 2.27-.45 4.75-.75l2.76-.34 2.93-.35c7.35-.93 14.44-2.08 21.63-3.87 5.25-1.23 10.59-1.92 15.93-2.69l5.34-.85 4.88-.78A40 40 0 0 1 924 807"/><path fill="#3a383f" d="M461 418h1l1 5 3-1q.13 5.16.19 10.31l.07 2.96.08 5.46c-.41 2.76-1.32 3.43-3.34 5.27-.51 2.92-.51 2.92-.69 6.19l-.2 3.3-.11 2.51h-2l-1 2c-.3-14.13.49-27.95 2-42"/><path fill="#7f7e83" d="M1441 1135h1q.09 5.38.13 10.75l.05 3.08q0 1.46.02 2.97l.03 2.73c-.25 2.67-1.04 4.1-2.23 6.47-.36 2.2-.65 4.31-.85 6.52l-.2 1.86-.58 5.87-.4 4-.97 9.75h-1a2931 2931 0 0 1-.15-20.29c-.26-25.79-.26-25.79 5.15-33.71"/><path fill="#696773" d="M1278 1023h1l.08 24.18.02 8.83.01 2.75v2.61l.01 2.28c-.14 2.83-.65 5.55-1.12 8.35-.3 4.27-.42 8.55-.56 12.82l-.06 1.9c-.5 15-.5 30-.45 45.01l.02 10.65.05 20.62h-2c-.2-63.74-.2-63.74.44-85.19l.06-2.1q.34-11.24 1.26-22.44c.43-5.85.6-11.72.8-17.58z"/><path fill="#1c1c20" d="m1058 878-9 2v2c-4.18 2.36-7.95 3.53-12.65 4.46a55 55 0 0 0-6.79 2.1c-4.79 1.68-9.52 2.13-14.56 2.44l-1 3-2.63.15-3.5.23-3.44.2c-3.4.42-6.21 1.28-9.43 2.42q-3.8.8-7.63 1.44c-2.37.56-2.37.56-4.4 1.61-2.26 1.09-3.74 1.16-6.22 1.08l-2.14-.06L973 901v-1c7.26-2.16 14.45-4.2 21.91-5.57C997 894 997 894 999.94 893c2.77-.9 5.31-1.4 8.18-1.87a93 93 0 0 0 20.52-6.25c2.89-1.08 5.3-1.02 8.36-.88v-2q4.12-1.3 8.25-2.56l2.36-.75 2.28-.7q1.04-.3 2.1-.64c2.01-.35 2.01-.35 6.01.65"/><path fill="#8b898c" d="m303 662-6.06 4.75-1.7 1.34c-5.76 4.48-11.85 7.8-18.44 10.88-1.95 1.12-3.25 2.41-4.8 4.03a197 197 0 0 1-5 3q-3.01 1.98-6 4l-1-2 4-1v-3h-4l1-3 9 1v-2h-5v-2h11l-1-4h7v-4l2.88-.31C288 669 288 669 289 667.62l1-1.62q2.96-1.57 6-3l1.63-1.25c2-1.09 3.25-.35 5.37.25"/><path fill="#121116" d="m423 560 1 2c2.63.3 2.63.3 6 .44a47 47 0 0 1 16 3.56v1h-6v3l2.09-.14c6.01-.23 9.98.32 15.3 3.05 5.56 2.32 11.7 3.06 17.61 4.09v1c-5.7.18-10.6-.35-16.12-1.75l-2.2-.54A462 462 0 0 1 436 570v-2l-2.52-.37-3.3-.5-3.26-.5c-2.67-.58-3.8-1.1-5.92-2.63-1.82-.41-1.82-.41-3.69-.62L414 563v-1h9z"/><path fill="#2a2a2f" d="M1263 149c1.88.56 1.88.56 4 2 .97 2.32 1.45 4.53 2 7l1.88.31c2.51.82 2.77 1.53 4.12 3.69l3 1v-12h3l1 11-3 2a69 69 0 0 0-1.69 7.13l-.39 2q-.47 2.43-.92 4.87l3 1-1 4v-3h-4l-.15-1.93-.22-2.5-.22-2.5-.41-2.07-2-1v-7h-5l-.15-2.05-.22-2.7-.22-2.67A18 18 0 0 0 1263 149"/><path fill="#cfbead" d="M1333 1309c.88 3 1.08 5.56.96 8.68-.68 25.6-.68 25.6 6.04 35.32a98 98 0 0 1 1.56 4.63c1.34 4.27 1.34 4.27 2.44 5.37q.06 3 0 6l1.69.69c3.5 1.99 6.48 4.66 8.31 8.31l-7-1-1-3-1.5-.81c-1.5-1.19-1.5-1.19-2.19-4.32l-.31-2.87-3-1c-.88-1.42-.88-1.42-1.56-3.19l-.7-1.73c-4.02-11.22-5.07-22.1-4.95-33.91q.03-2.84.02-5.68c.04-9.2.04-9.2 1.19-11.49"/><path fill="#9c9c9d" d="m649 952-1 2h-8v2c-10.9 3.01-21.55 2.01-32.69 1l-3.32-.29L596 956l1-2 31.84-1.56 2.62-.14q8.76-.39 17.54-.3"/><path fill="#1b1a1e" d="M778 812a19 19 0 0 1-8.67 2.56l-2.7.23-2.82.21c-5.23.4-10.32.8-15.43 2.03-4.43 1.06-8.71 1.33-13.25 1.47-4.97.2-9.7.47-14.57 1.56-8.02 1.7-16.4 1.1-24.56.94v-1l2.61-.35 19.75-2.68 1.88-.25c2.72-.37 5.14-.85 7.76-1.72q1.98-.2 3.98-.28l2.34-.11 2.43-.11c4.85-.21 9.58-.51 14.38-1.26 3.98-.51 8-.62 12-.8l2.66-.13c4.08-.2 8.13-.37 12.21-.31"/><path fill="#010204" d="M418 392h1c-.34 26.52-.34 26.52-4.08 36.42-1.35 3.8-1.71 7.58-1.92 11.58h-2q-.12-5.53-.19-11.06l-.07-3.15c-.05-5.35.11-9.41 2.57-14.2 1.45-3.33 1.16-7.03 1.22-10.6.14-3.92.71-6.15 3.47-8.99"/><path fill="#000103" d="M1050 1240c1.26 2.53.93 3.76.6 6.55-.8 7.87-.95 15.74-1.16 23.64L1049 1286h-2a32 32 0 0 1-2.27-12.18v-5.32q.01-2.7 0-5.4c-.01-7.78.81-14.72 3.27-22.1z"/><path fill="#33333c" d="M1321 1035h1a36340 36340 0 0 1 .15 76.6 9695 9695 0 0 1 .06 32.37 2246 2246 0 0 1 .03 14.42v8.28c-.24 2.33-.24 2.33-2.24 4.33-5.6.46-10.59-.62-16-2v-1h17z"/><path fill="#1d1c20" d="M927 804c-5.53 2.1-10.07 3.38-16 3v2c-4.05 1.14-8 2.06-12.19 2.44a70 70 0 0 0-10.5 1.93c-7.22 1.76-14.51 2.7-21.88 3.63l-2.78.35-2.5.3C859 818 859 818 856 819q-2.6.1-5.19.06l-2.73-.02L846 819v-1l2.65-.47c15.46-2.76 30.89-5.56 46.25-8.84a457 457 0 0 1 20.02-3.79c9.72-1.63 9.72-1.63 12.08-.9"/><path fill="#291a4a" d="M1226 603c-3.14 3.96-6.38 4.98-11.07 6.29-1.93.71-1.93.71-2.93 2.71-1.67.96-1.67.96-3.75 1.94a57 57 0 0 0-6.75 3.68 20 20 0 0 1-6.25 2.25c-4.14.93-7.33 2.77-10.96 4.9-2.93 1.57-5.91 2.5-9.08 3.47-3 1.03-5.71 2.47-8.48 3.99-1.73.77-1.73.77-4.73.77 1.81-2 1.81-2 4-4h3v-2l-4-1c6.63-2 6.63-2 10-2l-1 2 4.94-1.87 2.77-1.06C1184 622 1184 622 1185 620h3v-2c3.86-1.6 7.51-3.07 11.69-3.5 3.49-.53 5.4-1.59 8.31-3.5h2v-2c1.93-1.83 2.99-2 5.69-2.12l2.31.12v-2c2.47-2.34 4.72-2.18 8-2"/><path fill="#76747f" d="M1314 1545h1a1506 1506 0 0 1-.59 59.3l-.03 1.88c-.12 3.42-.58 5.9-2.38 8.82-1.03-12.84-1.12-25.62-1.06-38.5l.01-6 .05-14.5h1v12h2z"/><path fill="#8d8c93" d="M393 1317h1c1.69 10.27 2.24 20.49 2.3 30.88q0 2.05.04 4.11.03 2.93.02 5.85l.01 3.45c-.37 2.71-.37 2.71-1.9 4.1l-1.47.61c-1.18-2.37-1.13-3.8-1.13-6.43v-39.72c.13-1.85.13-1.85 1.13-2.85"/><path fill="#73533b" d="M1072 1257a601 601 0 0 1 19 3v2c19.01 3.06 19.01 3.06 26.56 3.38 7.12.4 14.07 1.7 21.07 3l3.43.63c7.39 1.4 14.66 3.09 21.94 4.99v1c-6.68.25-12.82-.68-19.37-1.94l-3.02-.55c-7.32-1.37-7.32-1.37-9.61-2.51q-2-.29-4-.46l-2.42-.24-2.58-.24a159 159 0 0 1-20-3.06l-4.96-1a244 244 0 0 1-16.73-4.06l-1.78-.5c-2.8-.8-5.22-1.6-7.53-3.44"/><path fill="#0a090e" d="M973 902h-3l-1 3-2.17.3c-7 1.05-13.42 2.67-20.12 4.9-2.71.8-2.71.8-5.71.8v2l2.88-.12C947 913 947 913 949 915l4 2h-9v-2l-7 1v-2c-11.55 1.81-11.55 1.81-16.25 3.13-3.94 1.05-7.7 1.06-11.75.87v-1l3.13-.75q6.18-1.57 12.3-3.31l1.9-.53c4.55-1.3 4.55-1.3 5.67-2.41q1.8-.33 3.62-.54c9.62-1.28 18.49-3.14 27.6-6.57l2.12-.78 1.86-.72c2.2-.48 3.67-.08 5.8.61"/><path fill="#242329" d="m1201.07 806.9 2.5.04 2.5.02 1.93.04c-1 3-1 3-3.48 4.39-10.42 4.3-10.42 4.3-16.52 3.61l-1 3h-9l-1 4h-9l1-3c2.07-.73 2.07-.73 4.56-1.19l2.5-.48 1.94-.33v-3l10-1v-3l2.15-.4 2.79-.54 2.77-.52c2.45-.58 2.9-1.52 5.36-1.64"/><path fill="#613d9e" d="m700.38 750.88 3.33-.01c6.83.27 10.47 1.3 15.29 6.13-14.32 2.38-28.47 4.6-43 5 2.84-2.53 5.52-2.2 9.19-2.12l3.29.05 2.52.07v-2l18-2-27-1v-1h8v-2c3.5-1.07 6.73-1.13 10.38-1.12"/><path fill="#ae9dc6" d="M548 606h47l1 2-6.44.44-1.85.12c-4.6.33-4.6.33-5.71 1.44q-3.88.13-7.75.1l-9.81-.04-5.05-.01L547 610z"/><path fill="#000003" d="M926 527h9v3l-2.87.69a47 47 0 0 0-9.13 3.81c-9.68 4.96-20.27 6.29-31 7.5l1-2h3l1-4 1 2h11v-3h-5v-1l5-1.42q1.9-.55 3.78-1.16c4.22-1.34 7.8-1.6 12.22-1.42z"/><path fill="#413f43" d="m535 132-3.06 1.31-1.94.85a72 72 0 0 1-3.85 1.5c-4.22 1.56-6.22 3.37-8.92 6.93-1.55 1.78-3.33 2.63-5.48 3.55-3.14 1.54-5.93 3.65-8.8 5.65C501 153 501 153 499 153l-.77 2.24c-1.3 2.92-2.7 4.6-4.92 6.88-3.9 4.23-6.76 8.54-9.67 13.5A18 18 0 0 1 478 181c3.39-12.32 13.13-23.24 23-31l5.63-4.69 1.38-1.15c4.36-3.6 9-6.23 14.05-8.72L527 133l2.17-1.18c2.32-1.04 3.48-.66 5.83.18"/><path fill="#df4e14" d="M1438 1253c2 1.81 2 1.81 4 4v3l-2.13.02c-10.92.85-18.08 7.4-25.12 15.4a33 33 0 0 0-4.75 7.58h-2c.95-6.2 2-11.99 6-17 1.63-1.19 1.63-1.19 3-2l1 1h3v-3l3.13-.25a13.4 13.4 0 0 0 7-2.81c3.03-2.05 4.3-2.36 7.87-1.94z"/><path fill="#1e1e21" d="m697.06 820.88 3.85.02q4.54.03 9.09.1v1c-28.85 3.15-57.63 3.26-86.62 3.19l-2.94-.01q-18.23-.05-36.44-.18v-1l2.57-.02a10999 10999 0 0 0 36.78-.4c16.56-.16 33.02-.38 49.52-1.89 8.09-.73 16.08-.93 24.2-.82"/><path fill="#828183" d="m1297 786-1 2h2v2h5a48 48 0 0 1-12.06 7.5l-2 .91c-8.06 3.64-16.41 6.26-24.94 8.59 2.31-2.63 4.42-3.77 7.69-5l2.69-1.03 4.77-1.75a95 95 0 0 0 12.19-5.45c1.66-.77 1.66-.77 3.66-.77v-2l-5.27 1.46c-1.73.54-1.73.54-3.73 1.54-2.34.13-4.65.04-7 0 1.56-3.27 2.52-4.76 5.81-6.4l3.19-.98 3.19-1.02c2.81-.6 2.81-.6 5.81.4"/><path fill="#302f35" d="M878 494h11l1 2 2 1-4 1v2l-2.46.52-3.48.73-1.88.4q-5.3 1.12-10.54 2.44c-7.64 1.92-14.7 3.61-22.64 2.91v-1h6v-2h-8v-2h10v3l1.76-.66c4.61-1.64 8.34-2.72 13.24-2.34v-2h-8v-2h11l-1 3 7-3z"/><path fill="#3b236b" d="M1283 376c4.53.49 4.53.49 6.53 2.66 1.72 2.74 2.7 5.4 3.72 8.46l1.08 3.2c.67 2.68.67 2.68-.33 4.68l-1.25-2.94-.83-1.9a158 158 0 0 1-1.87-4.6l-.99-2.44-.9-2.3c-1.16-1.82-1.16-1.82-3.29-2.58L1283 378l-1 3c-1.34 1.39-1.34 1.39-2.94 2.69l-1.59 1.32c-1.47.99-1.47.99-3.47.99v2c-1.72 1.3-1.72 1.3-4.06 2.75a75 75 0 0 0-10.53 8.15C1258 400 1258 400 1256 400v-4h-2c1.15-2.47 2.05-4.05 4-6 2.63-.12 2.63-.12 5 0l1-4 5-1v2h4v-2c1.68-1.4 1.68-1.4 3.88-2.94 2.6-1.82 4.54-3.28 6.12-6.06"/><path fill="#24242b" d="m790 1670-1.69.85c-8.3 4.36-15.45 9.37-19.31 18.15l-.9 1.8c-3.34 7.9-2.36 16.8-2.1 25.2l3 1c.91 1.67.91 1.67 1.63 3.75l.74 2.17A52 52 0 0 1 773 1730q-2.55-1.92-5-4v-2l-3-1c-4.62-6.95-2.82-19.08-2-27 2.42-10.87 11.9-20.48 21.06-26.31 2.39-.85 3.56-.38 5.94.31"/><path fill="#c7c6c9" d="m1446 1121 4 1-1 52h-3z"/><path fill="#1a181e" d="m1287 650 3 1c-2.47 2.83-5.12 4.2-8.5 5.81l-3.22 1.57-1.65.8a191 191 0 0 0-15.18 8.4c-2.82 1.63-5.61 2.64-8.68 3.67-1.77.75-1.77.75-3.77 2.75q-2.5 1.02-5 2-1.76.86-3.48 1.76l-1.84.95-3.77 1.99a92 92 0 0 1-4.91 2.3l-2-1c7.18-5.34 14.19-9.98 23-12v-2l2.02-.88c4.97-2.21 9.8-4.52 14.54-7.18 6.18-3.44 12.53-6.35 19.44-7.94z"/><path fill="#09090f" d="M1481 573h6c4.18 7.34 4.8 14.35 4.75 22.63v1.9c-.08 19.33-6.76 37.95-19.75 52.47l-1-2q.92-1.5 1.88-3l1.05-1.69L1475 642h2l.25-2.5c.57-3.54 2.1-6.32 3.76-9.46 1.43-2.94 2.2-5.88 2.99-9.04h2l.15-1.86.22-2.45.22-2.43c.41-2.26.41-2.26 1.42-3.65 1.24-2.02 1.25-3.31 1.26-5.67v-2.4l-.02-2.54-.01-2.58c-.21-14.36-.21-14.36-3.24-20.42l-10 1c1-2 1-2 4-3z"/><path fill="#402672" d="M1010 545c5.05 5.05 6.88 13.26 9.25 19.88l.82 2.27.76 2.15.68 1.94c.49 1.76.49 1.76.49 4.76h2a142 142 0 0 1 4.46 17.54 64 64 0 0 0 3.05 9.45c.57 2.35.25 3.74-.51 6.01l-1.5-3.81-.84-2.15C1028 601 1028 601 1028 598h-2l-.75-3.31a105 105 0 0 0-4.45-13.17 16 16 0 0 1-.8-6.52h-2l-.31-3.19a13.5 13.5 0 0 0-2.13-6.18c-1.85-3.12-2.39-5.91-3.03-9.45-.56-2.28-1.48-4.08-2.53-6.18-.12-2.75-.12-2.75 0-5"/><path fill="#0a0a10" d="M408 1717c2 1.13 2 1.13 4 3 .25 3.19.25 3.19 0 6l1.8.77c2.44 1.37 3.7 2.75 5.45 4.92 4.76 5.6 9.63 10.94 16.75 13.31 2.31 1.5 2.31 1.5 4 3v2l2.44.88C445 1752 445 1752 446 1754h-6l-1-4h-4l-1-4-6-1v-2h-5v-2l-1.8-.29c-2.67-.86-3.66-1.94-5.45-4.09l-1.58-1.83c-1.52-2.32-1.17-4.1-1.17-6.79l-3-1c-2.28-3.24-2.18-6.12-2-10"/><path fill="#dddfe2" d="m1450 937 1 2a89 89 0 0 1-1.9 4.13c-9.55 19.43-8.63 43.85-6.1 64.87l-1-3h-3q-.11-8.11-.16-16.22l-.07-5.51q-.05-3.98-.06-7.96l-.05-2.46c0-4.87.7-8.66 2.66-13.14.97-2.43 1.05-4.73 1.12-7.34.33-5.05 1.42-7.45 4.56-11.37l1-3z"/><path fill="#6c6c6f" d="m308 936 4.69.44 2.63.24c2.68.32 2.68.32 4.9.8 2.46.46 4.83.73 7.33.95l2.56.22q2.61.23 5.23.42c4.26.38 8.03.95 12.05 2.43 5.46 1.98 10.91 2.22 16.67 2.56l6.63.47 3.24.23c6.39.5 12.73 1.35 19.07 2.24v1c-13.3.4-26.06-.58-39.22-2.45q-4.5-.64-9-1.24l-5.82-.8-2.68-.37c-5.2-.74-10.2-1.79-15.28-3.14q-3.5-.83-7-1.62l-1.77-.41L308 937z"/><path fill="#af8dd7" d="M391 559h1l.1 6.05C392 567 392 567 391 569q-.18 1.8-.25 3.59l-.1 2.13-.17 4.43-.1 2.13-.08 1.94c-.35 2.05-1.13 3.08-2.3 4.78-.45 1.68-.45 1.68-.75 3.51l-.34 2.04-.35 2.14c-1 5.74-2.21 10.95-4.56 16.31h-1q-.08-2.81-.12-5.62l-.08-3.17c.21-3.4 1.12-5.99 2.2-9.21.36-2.98.41-5.97.48-8.96.18-4.07 1.02-5.92 3.52-9.04.66-2.59.66-2.59 1.06-5.37A57 57 0 0 1 391 559"/><path fill="#84838b" d="M424 1613h1c1.1 8.47 1.13 16.89 1.1 25.41v4.4l-.03 11.47-.02 11.75L426 1689h-1l-1-33-1 7h-1l.5-27.99.23-13.43.06-2.97.05-2.63c.16-1.98.16-1.98 1.16-2.98"/><path fill="#5e3c26" d="m965 1243 2 1-2 1zm-13-1 2 1-1 2h12v1h-12l-2 4c-1.62.6-1.62.6-3.44.75l-1.8.17c-2.69.12-5.27.04-7.95-.17-4.41 0-8.26 1.66-11.81 4.25l-1 3 1.57.08c5.13.41 7.73 1.34 11.43 4.92-4.73.33-7.1-.34-11-3q-2-1.02-4-2c-.06-2.25-.06-2.25 1-5a46 46 0 0 1 6.72-3.1c2.61-1.03 4.88-2.43 7.28-3.9 3.76-1.83 7.21-2.37 11.33-2.75C951 1243 951 1243 952 1242"/><path fill="#fdfcfd" d="m1626 675-7.37 7.5-2.12 2.17-2.03 2.05-1.88 1.9C1611 690 1611 690 1609 690l-2 4h-2v3l5 1v3c-3.53 1.22-3.53 1.22-5.75.19L1603 700v-2l-4-1c.19-2.31.19-2.31 1-5q2.46-1.56 5-3 1.54-2.48 3-5l3-1q.45-.77.94-1.56L1613 680c2.13-.19 2.13-.19 4 0l1-3c4.36-3.21 4.36-3.21 8-2"/><path fill="#8b8595" d="M440 572c5.6-.25 10.37.36 15.75 1.88a157 157 0 0 0 22.26 4.35c3.06.36 5.6.98 8.41 2.2 5.53 2.12 11.23 2.62 17.08 3.26l3.36.39q4.07.48 8.14.92v1q-5.94-.13-11.87-.31l-3.36-.07c-6.23-.2-11.56-.93-17.46-2.91-4.29-1.31-8.7-1.53-13.14-1.88a33 33 0 0 1-11.52-3.13c-3.44-1.45-7.2-1.62-10.88-2.08-2.96-.66-4.5-1.63-6.77-3.62"/><path fill="#19120f" d="M1042 1253h1c1.2 21.4 1.2 21.4 1 32h2c1.86 5.06 3.29 9.83 3.82 15.2.23 2.3.69 4.54 1.18 6.8h2c1.8 3.83 2.2 6.78 2 11l-4.25-.37-2.4-.22c-2.44-.43-4.2-1.2-6.35-2.41v-2c3.72 1.21 7.38 2.5 11 4-1-3.18-2.07-6.18-3.5-9.19-2.09-4.7-2.72-9.75-3.5-14.81h-2c-2.86-12.84-4.14-25.83-3-39z"/><path fill="#2e2d36" d="m397 1128 3 1-.44 2.67-.56 3.52-.29 1.75A54 54 0 0 0 398 1147c0 3.4-.9 6.18-1.94 9.38a72 72 0 0 0-3.56 17.99l-.12 1.8-.76 11.38-.34 5.16c-.3 3.47-.76 6.86-1.28 10.29h-1c-.3-11.44.44-22.6 1.67-33.97q.44-4.1.85-8.2l.73-7.19.52-5.14A108 108 0 0 1 397 1128"/><path fill="#9a9a9c" d="M493 971h57c-10.64 5.32-26.04 3.32-37.75 3.56L492 975z"/><path fill="#d6dbde" d="M1494 833h5c1.47 4.42 1.27 8.74 1.31 13.38l.09 2.86c.07 7.8.07 7.8-2.3 10.92-2.1 1.84-2.1 1.84-4.1 1.84z"/><path fill="#b9abc7" d="M392 556h6l1 2c2.25.94 4.45 1.78 6.75 2.56l1.88.67c2.77.97 5.37 1.77 8.26 2.3q1.05.22 2.11.47l1 2c2.48.75 4.92 1.4 7.44 2l4.12 1 1.85.44C434 570 434 570 436 572l-18-2v2l2 1c-3.82-.53-6.06-1.5-9-4h3v-2l-2.15-.15-5.56-.44L404 566l-1-2q-2.5-1.02-5-2l-2-2c-2.12-.62-2.12-.62-4-1z"/><path fill="#15151b" d="M903 496v2a37 37 0 0 1-10.5 2.88c-4.55.7-8.83 1.75-13.19 3.18A58 58 0 0 1 864 507v2c-9 2.31-17.9 3.75-27.13 4.78-2.97.35-5.92.79-8.87 1.22 4.81-3.74 10.8-4.36 16.64-5.24a155 155 0 0 0 29.04-7.25 125 125 0 0 1 18.7-5c8.12-1.58 8.12-1.58 10.62-1.51"/><path fill="#edeff0" d="M1450 1042c1.75.06 1.75.06 4 1 2.47 3.1 3.91 5.98 4.25 9.94l-.25 2.06 4 2-1 3 3-1q1.8 1.6 3.56 3.25 1 .9 2 1.83c1.44 1.92 1.44 1.92 1.28 4.1-.84 1.82-.84 1.82-2.84 2.82l-1 3-4-1v-3l2-1c.63-2.06.63-2.06 1-4l-4-1v-3l-8-1c-1.02-5.62-1.02-5.62-.87-8.25l-.13-1.75-1.5-.87-1.5-1.13c-.19-3.12-.19-3.12 0-6"/><path fill="#38363c" d="M824 910h18l1 4-3.07.11-4.05.2-2.02.07c-4.72.26-7.73 1.33-11.86 3.62-5.96 1.29-11.92 1.1-18 1 1.07-2.06 1.67-2.88 3.88-3.7l2.25-.47 2.44-.52 2.55-.5 2.58-.53q3.15-.66 6.3-1.28z"/><path fill="#555559" d="M611 970c-7 5.07-19.57 3.12-28 3v2c-33.5 2.39-33.5 2.39-48 2v-1h13v-2c21-2.42 41.84-4.13 63-4"/><path fill="#57575b" d="M4 891h4l1 10 4 1c1.3 4.17 2.27 7.59 2 12-3.11-1.5-6.05-3.2-9-5v5l-4-1c.38-1.94.38-1.94 1-4l2-1c.63-2.56.63-2.56 1-5l-4-2q-.08-1.94-.12-3.87l-.08-2.18C2 893 2 893 4 891"/><path fill="#1d1133" d="m1007.06 508.94 1.94.06-1 3-14 1v2l-1.97.61-8.84 2.76-3.1.97-5.73 1.79A22 22 0 0 0 969 524c-2.32.5-2.32.5-5.12.88-8.07 1.22-15.95 3.19-23.88 5.12v-2l2.09-.55a235 235 0 0 0 15.3-4.58c4-1.33 8.01-2.57 12.03-3.81q5.78-1.8 11.52-3.68l2.52-.82q2.4-.78 4.8-1.6a58 58 0 0 1 14-2.74c2.27-.29 2.4-1.21 4.8-1.28"/><path fill="#919091" d="M436 337h1v24l-3 1-1 17-3 1c-.48-15.13.55-28.74 6-43"/><path fill="#3c3b3f" d="M474 183c.63 1.88.63 1.88 1 4l-2 2q-.66 2.39-1.3 4.78c-1.45 4.58-3.65 8.88-5.7 13.22h-2l-.33 1.55A591 591 0 0 1 459 228h-2l-.33 2.17A292 292 0 0 1 452 253h-2l-1 7h-1v-8h2l.33-3c.83-7.04 1.7-13.5 4.67-20a34 34 0 0 0 2.75-10.19c.9-5.25 2.78-8.84 5.66-13.27a65 65 0 0 0 7.78-17.6C472 185 472 185 474 183"/><path fill="#0c0c12" d="M802.66 1668.55h3.78q5.11 0 10.22.04 5.34.04 10.7.04 10.1.01 20.23.08 11.52.07 23.04.09 23.69.06 47.37.2v1l-1.94.01a31736 31736 0 0 0-69.43.41 8473 8473 0 0 0-30.16.18 1975 1975 0 0 0-13.44.08q-2.46 0-4.92.04l-2.79.02c-2.42.27-3.51.65-5.32 2.26l-1 2c-2.07.41-2.07.41-4.56.62l-2.5.23-1.94.15c4.7-8.11 14.4-7.56 22.66-7.45"/><path fill="#000002" d="m1093.71 1202.8 1.86.01 5.8.06 3.97.03q4.83.04 9.66.1l-1 2c-4.68.96-9.3 1.12-14.07 1.24-20.8.6-20.8.6-27.7 6.2-1.23 1.56-1.23 1.56-2.23 4.56h-4c-.22-2.3-.22-2.3 0-5 2.98-3.5 7.01-6.17 11.63-6.75l3.37-.25 2.8-1c3.48-1.09 6.26-1.23 9.91-1.2"/><path d="M1435 1074h3v45l-4 1c-.16-15.36.32-30.66 1-46"/><path fill="#7e7d7e" d="M1168 838v4l-12 3v-2c-9.08 1.69-9.08 1.69-13.12 3.06-2.47.8-4.4 1.14-6.94 1.38-2.84.29-3.94.56-6.44 2.12-3.5 2.01-6.56 1.7-10.5 1.44.75-1.45.75-1.45 2-3 1.78-.55 1.78-.55 3.94-.87a40 40 0 0 0 10.62-3.7A33 33 0 0 1 1149 840v1c7.07.27 7.07.27 10-2a30 30 0 0 1 9-1"/><path fill="#43296f" d="M935 715v1l-3.03.86q-3.8 1.08-7.56 2.25a281 281 0 0 1-28.57 7.1c-3.92.8-7.3 1.52-10.9 3.34-4.17 2.06-8.35 2.48-12.94 3.08l-5.25.72-2.48.33q-4.15.6-8.27 1.32c6.24-6.24 19.6-7.18 27.97-8.44a80 80 0 0 0 8.24-2.03c2.96-.88 5.94-1.67 8.91-2.47l1.96-.52c5.31-1.4 10.65-2.65 16.02-3.83 2.9-.71 2.9-.71 6.03-1.79 3.56-1.14 6.17-1.07 9.87-.92"/><path fill="#56398d" d="m861 564-22 8c9 0 9 0 12-2 2.69-.12 2.69-.12 5 0-3.45 3.82-6 4.28-11.12 4.56l-3.14.07c-3.16.43-3.65 1.11-5.74 3.37a26 26 0 0 1-14 0c.16-1.84.16-1.84 1-4 2.37-1.41 2.37-1.41 5.44-2.62l1.64-.66a62 62 0 0 1 12.33-3.18c4.12-.86 8-2.37 11.93-3.83C857 563 857 563 861 564"/><path fill="#aaa" d="M750 74a4024 4024 0 0 1 24.1-.15q4.4-.04 8.81-.05l2.74-.03c5.18 0 9.44.54 14.35 2.23l-1 2h-51z"/><path fill="#2e2d37" d="m935.36 1197.9 1.64.1 1 3c-2.14 2.14-3.24 2.56-6.06 3.44l-2.42.77-2.52.79-4.94 1.63-2.21.72c-1.85.65-1.85.65-3.85 1.65q-3 .06-6 0h-2l-2 4h-2v-2h-4v-2l5.27-1.37C907 1208 907 1208 909 1206q2.67-.35 5.34-.65c1.66-.35 1.66-.35 3.16-1.85 2.1-2.1 4-1.9 6.88-2.18 1.62-.32 1.62-.32 3.06-1.88 2.39-2.2 4.79-1.66 7.92-1.55"/><path fill="#444449" d="M820 205c-2.71 3.46-6.15 4.3-10.19 5.56l-2.06.67c-3.27 1.05-6.51 2-9.87 2.7-4.88 1.11-9.29 3.49-13.78 5.61a70 70 0 0 1-19.39 5.92c-2.5.5-4.7 1.2-7.09 2.1-5.7 2.06-11.62 2.17-17.62 2.44 2.55-1.96 5.03-2.69 8.13-3.5l2.69-.72q3.65-.89 7.32-1.66a219 219 0 0 0 16.42-4.5l3-.88a80 80 0 0 0 14.66-5.87c2.61-1.28 5.27-1.96 8.1-2.62l3.04-.73C806 209 806 209 809 209v-2c7.3-3.63 7.3-3.63 11-2"/><path fill="#101318" d="M341 1268h1v54h-3l.04-3.25.02-4.31.03-2.14c.02-3.85-.25-7.44-.86-11.25-.55-4.86-.41-9.79-.42-14.67l-.03-3.4-.01-3.27-.01-2.96c.28-3.2.9-6.4 3.24-8.75"/><path fill="#010104" d="M572 979v2l-1.87.06c-20.87.7-20.87.7-29.94 1.94l-1.85.25c-2.32.35-4.22.7-6.34 1.75q-2.81.1-5.62.06l-3.04-.02L521 985l2-2-3-2a7578 7578 0 0 1 24.64-2.36c9.15-.84 18.29-1.29 27.36.36"/><path fill="#5e5d60" d="M352 943c10.6-.1 21.21-.16 31.81.38l2.11.09c3.5.2 6.72.65 10.1 1.52 5.51 1.25 10.95 1.46 16.58 1.64l3.25.13 10.21.37 6.96.26L450 948v1c-26.03.25-51.7.2-77.55-3.32-6.8-.91-13.61-1.3-20.45-1.68z"/><path fill="#434247" d="M302 931q4.16-.09 8.31-.12l2.38-.06q1.13 0 2.3-.02l2.1-.03c1.91.23 1.91.23 3.26 1.22 2.53 1.55 5.18 1.37 8.07 1.42l1.87.06q2.94.08 5.9.15l4 .12q4.9.14 9.81.26c-2.98 2.2-4.97 2.21-8.69 2.13l-3-.06L336 936v2c-13.15.36-13.15.36-18.18-1.43-3.37-1.06-6.89-1.52-10.37-2.1C305 934 305 934 302 933z"/><path fill="#0a0b10" d="M12 826v3l-1 1c-.24 2.2-.42 4.36-.56 6.56l-.13 1.87L10 843h2c1.13 2.26 1.12 3.48 1.11 6v2.48l-.01 2.68-.04 11.47-.01 5.9L13 886l-3 1v-43l-4-1 3-1-.1-2.74-.09-3.57-.07-1.8c-.05-2.9-.05-4.5 1.77-6.8z"/><path fill="#343338" d="M799 832v1l-2.65.24-12.23 1.13-2.22.21c-8.25.77-16.49 1.57-24.71 2.54-16.17 1.86-31.95 1.95-48.19.88v-1l3.04-.17 23.03-1.3 2.18-.11a55 55 0 0 0 8.14-.92c4.05-.78 8.11-.86 12.22-1.04l2.82-.13 5.88-.26 14.68-.67 2.68-.12q7.65-.34 15.33-.28"/><path fill="#1b1029" d="M370 642c-.57 5.35-1.2 10.68-2 16h-2c-2.05 14.36-2.23 28.5-2 43l-2-1c-1.34-6.14-1.15-12.37-1.12-18.62v-3.22c.04-7.43.31-14.77 1.12-22.16l3 1 .11-2.52.2-3.3.18-3.26c.45-2.55 1.28-5.92 4.51-5.92"/><path fill="#28282e" d="M751 529a13 13 0 0 1-6.7 2.76l-2.24.35-2.42.35-2.54.38a986 986 0 0 1-26.67 3.52l-13.45 1.65-2.42.3c-4.56.55-8.97.79-13.56.69v-1h6v-2l2.5-.35 18.98-2.68 1.8-.25c2.29-.32 4.53-.67 6.78-1.25 2.51-.6 4.97-.8 7.55-1l3.29-.27 3.41-.26 3.38-.28c5.46-.43 10.84-.78 16.31-.66"/><path fill="#362262" d="M1283 370h3v5l3 1-1 2-2.19-.81c-2.81-.19-2.81-.19-5.43 2.06q-1.21 1.35-2.38 2.75c-1.69 1.81-1.69 1.81-3 3h-2v2h-4v-2l-5 1-1 4-4-1c3.01-4.66 6.74-6.82 11.56-9.47 2.54-1.6 4.37-3.38 6.44-5.53q2.47-1.55 5-3z"/><path fill="#444447" d="m1454 908 4 1c.31 2.19.31 2.19 0 5a24 24 0 0 1-8.37 6.81c-3.7 2.01-6 4.62-8.67 7.84a219 219 0 0 1-7.09 7.98A253 253 0 0 0 1424 948c0-3 0-3 2-5l.8-2.25c1.56-3.58 3.83-5.9 6.51-8.69l4.3-4.53q2.09-2.31 4.05-4.73a40 40 0 0 1 5.53-5.67l1.48-1.25c1.33-.88 1.33-.88 3.33-.88z"/><path fill="#43424b" d="M397 1154h1l1 17h-1l-1-6c-2.6 3.95-3.25 7.17-3.57 11.9l-.11 1.72c-.42 6.29-.62 12.58-.82 18.88a616 616 0 0 1-1.1 23.84c-.38 2.53-.77 3.74-2.4 5.66-.24-14.38.18-28.42 1.77-42.72.27-2.7.43-5.4.58-8.1.92-17.45.92-17.45 5.65-22.18"/><path fill="#767578" d="M150 798c1 2 1 2 .3 4.24l-1.11 2.63-1.08 2.62C147 810 147 810 145.9 812.03c-1.03 2.26-1.27 4.12-1.46 6.6-.34 4.18-.34 4.18-1.44 6.37-.3 3.85-.28 7.7-.31 11.56l-.09 3.22c-.06 7.09 1.1 12 4.4 18.22q.8 1.95 1.56 3.94c1.46 3.1 1.8 3.38 4.44 5.06 2.04 2.95 3 4.37 3 8-11.06-11.86-16.76-21.65-16.3-38.02.29-5.81.91-11.58 1.61-17.36l.36-3.04.38-2.85.33-2.55c.72-2.52 1.69-3.46 3.62-5.18.66-1.79.66-1.79 1.06-3.62.41-1.86.41-1.86.94-3.38z"/><path fill="#242428" d="M1283 802a573 573 0 0 1-7 4l-2.81 1.63c-4.83 2.08-9.93 2.82-15.1 3.69-1.59.37-1.59.37-4.09 1.68-1.13 2.36-1.52 4.4-2 7l-1-2c-7.84.99-14.9 3.68-22.22 6.52-4.28 1.65-8.22 2.94-12.78 3.48 3.72-3.33 7.98-4.54 12.63-6.06l6.65-2.21C1237 819 1237 819 1239 817c1.63-.75 1.63-.75 3.55-1.44l2.12-.77 2.2-.79c7.7-2.78 7.7-2.78 10.45-4.19 1.68-.81 1.68-.81 4.68-.81v-2l1.69-.55 2.31-.76 2.48-.82c2.95-1.02 5.83-2.19 8.7-3.42 2.24-.55 3.65-.16 5.82.55"/><path fill="#86838d" d="M1314 1666v14h-2l-.25 3.19a75 75 0 0 1-2.62 11.75l-.5 1.75c-.5 1.65-.5 1.65-1.63 4.31l-3 1c-2.49-2.8-2.16-5.4-2-9l4-1v-11h3l.37-3.03.5-3.9.24-2q.36-2.55.89-5.07c2-1 2-1 3-1"/><path fill="#801309" d="M1408 1363c3.45 1.15 4.1 2.08 6.19 4.94l1.6 2.15c1.21 1.91 1.21 1.91 1.21 3.91h4l1-2 1 5 10-1c-3.3 6.59-8.93 12.41-16 15-2.44-.25-2.44-.25-4-1l5-2v-2l4-1c-1.32-3.91-2.87-6.19-5.87-9-8-7.73-8-7.73-8.38-11.31z"/><path fill="#76737f" d="M1311 1116h1l1 8h1c.98 8.6 1.12 17.06 1.06 25.69l-.01 3.9-.05 9.41-9-1c1-2 1-2 4-3z"/><path fill="#9d9d9e" d="M194 739c-.9 7.31-6.58 13.25-12.19 17.75l-1.44 1.09a27 27 0 0 0-4 4.47c-2.08 2.64-2.08 2.64-4.68 3.07L170 765v4h-2l-1-4h2l.19-1.75c1.14-3.15 3.17-4.21 5.81-6.25a96 96 0 0 0 3.89-4.73C180 751 180 751 182 750l1-3h3l.75-2.31c1.55-3.33 3.38-5.69 7.25-5.69"/><path fill="#767679" d="M464 207c1.1 3.26.91 5-.31 8.19a74 74 0 0 0-3.16 11.86c-.53 1.95-.53 1.95-1.58 3.38-1.37 2.27-1.14 4.14-1.07 6.76l.05 2.73.07 2.08h3c-.74 4.1-1.83 8.01-3 12h-1l-1-5c-3.44 6.09-5.36 12.22-7 19h-1c-.25-5.44.27-9.82 2-15h2l.08-1.4c.66-8.08 2.86-15.8 4.92-23.6h2l.08-1.8c.47-6.93 2.3-12.79 4.92-19.2"/><path fill="#3c3b42" d="M1019 94v2l2 1-1.46.59-1.91.78-1.9.78c-1.73.85-1.73.85-3.73 2.85-2.2.3-2.2.3-4.81.44-4.27.33-6.55 1.3-10.19 3.56-2.4.25-4.58.14-7 0 5.49-5.64 9.66-9.43 17.56-10.75l2-.38c3.18-.58 6.2-1.04 9.44-.87"/><path fill="#09090f" d="M936 1681c4.57 4.08 7.5 8.11 8.3 14.34q.19 3.86.14 7.72l.06 2.65c0 5.81-.9 8.74-4.5 13.29l-.81 2.88c-2.12 5.57-6.24 9.69-11 13.18l-2.19.94-2-1 1.93-1.43 2.5-1.88 2.5-1.87C933 1728 933 1728 934 1725l2-1-.36-1.83c-1.06-6.58-1.06-6.58 1.17-9.73C939 1711 939 1711 941 1711q.17-4.56.25-9.12l.1-2.6c.1-5.86-.71-8.7-4.35-13.28-.75-2.87-.75-2.87-1-5"/><path fill="#de4907" d="m1407 1283 1 2c-.9 2.2-.9 2.2-2.25 4.81l-1.33 2.58c-1.42 2.61-1.42 2.61-2.99 4.9-1.93 3.65-2.5 7.15-3.12 11.21l-.38 2.47c-1.37 9.65-1.21 19.31-.93 29.03-2.82-5.26-3.14-9.69-3.1-15.6l.04-9.15.01-4.71.05-11.54h3v-2l1.4-.9c1.98-1.36 2.42-2.5 3.29-4.72l.76-1.92.55-1.46h2v-4z"/><path fill="#9b7c61" d="M1069 1227h1v5h2l1-2c-.87 4.75-.87 4.75-2 7q-.35 3.3-.56 6.63l-.13 1.85-.31 4.52h8l1 2h-2l-1 3q-2.71.05-5.44.06l-3.06.04c-2.5-.1-2.5-.1-3.5-1.1-.56-6.38 1.15-11.92 3-18h2z"/><path fill="#32313a" d="m867 1226 4 1v3l-3.37 2-1.9 1.13c-1.73.87-1.73.87-3.73.87v2l-3.06 1.19c-5.77 2.48-10.31 5.65-13.94 10.81h-2l-.69 1.63c-1.73 3.13-3.96 5.68-6.31 8.37l-1-2 1.2-1.57c2.83-3.72 5.41-7.42 7.8-11.43 3.6-4.91 6.98-8.6 13-10l1.44-1.5c1.56-1.5 1.56-1.5 4.12-2 2.44-.5 2.44-.5 3.75-2.06z"/><path fill="#726f7b" d="M1278 1067h1a1320 1320 0 0 1-1.07 70.28q-.44 10.86-.93 21.72h-1c-.2-58.44-.2-58.44.44-75.81l.12-3.38c.18-4.4.5-8.5 1.44-12.81"/><path fill="#434248" d="M1076 856c-5.46 2.35-10.7 4.27-16.54 5.41-2.6.62-5.01 1.47-7.51 2.42a350 350 0 0 1-17.64 5.92l-2.97.96c-5.67 1.75-10.4 2.66-16.34 2.29 6-4.09 12.46-5.41 19.58-6.37 2.42-.63 2.42-.63 3.76-2.07 2.12-2 3.86-2.4 6.67-3.1l2.99-.76 3.13-.76 3.05-.78q4.41-1.1 8.82-2.16l2.6-.66 2.4-.59 2.06-.51c2.22-.27 3.82.1 5.94.76"/><path fill="#212025" d="m992 790-5.75 2.14a91 91 0 0 0-4 1.62c-9.15 3.86-19.8 5.23-29.65 6.03-1.6.21-1.6.21-3.6 1.21q-2.06.1-4.12.06l-2.2-.02L941 801v2q-4.95 1.27-9.94 2.5l-2.8.72A57 57 0 0 1 911 808v-1l2.05-.3c4.84-.77 9.26-1.68 13.76-3.64 9.05-3.84 18.7-5.3 28.47-5.75 2.72-.31 2.72-.31 5.54-1.27 3.84-1.26 7.55-1.72 11.55-2.16q1.1-.14 2.24-.27L980 793v-2q2.15-.54 4.31-1.06l2.43-.6C989 789 989 789 992 790"/><path fill="#5a595b" d="m1518 676 2 1a445 445 0 0 1-19.26 13.07l-1.74 1.11-1.53 1c-1.47.82-1.47.82-4.47 1.82l-.95 1.9c-1.26 2.52-2.39 3.22-4.8 4.6l-2.16 1.28L1483 703q-1.57 1.01-3.12 2.06L1477 707l-1.46 1.03a47 47 0 0 1-9.67 5.18c-2.8 1.18-5.48 2.6-8.17 4.02-1.7.77-1.7.77-3.7.77l-1 3c-1.78.98-1.78.98-3.94 1.75l-2.15.8c-1.91.45-1.91.45-3.91-.55a48 48 0 0 1 11-7.06c4.67-2.25 9.07-4.7 13.44-7.5 2.56-1.44 2.56-1.44 5.37-2.25 3.69-1.37 6.18-3.33 9.24-5.74a26 26 0 0 1 5.14-3.01c2.81-1.44 2.81-1.44 4.96-3.68 3.33-3.42 7.26-5.8 11.29-8.32l4.43-2.82 2.12-1.34q4.04-2.6 8.01-5.28"/><path fill="#16151a" d="M1251 668v2l-4.5 2.25-2.78 1.4q-2.4 1.2-4.85 2.33a43 43 0 0 0-10.24 6.64c-3.96 3.2-7.07 4.28-12.06 4.98-4.31.67-8.42 2.07-12.57 3.4 4.86-5.42 10.33-7.24 17.14-9.16l1.86-.84 1-3c2.37-1.25 2.37-1.25 5.44-2.44l1.62-.64 3.32-1.3q3.6-1.4 7.18-2.87c6.87-2.75 6.87-2.75 9.44-2.75"/><path fill="#1a1a20" d="M663 540v1a1471 1471 0 0 1-11.02 1.85c-14 2.28-28 2.29-42.16 2.25h-5.72l-14.93-.03-15.28-.02L544 545v-1l3.1-.02a14017 14017 0 0 0 43.91-.4 4317 4317 0 0 0 19.88-.18l7.7-.08h2.27c4.74-.08 8.63-.88 13.14-2.32 2.7-.32 2.7-.32 5.25-.41l2.84-.12 2.91-.1 2.9-.11q7.55-.31 15.1-.26"/><path fill="#747477" d="M474 302c1.02 3.23.95 5.65.44 8.98l-.42 2.84-.46 2.93L472 327h-2l-.15 1.99c-.74 9.42-1.79 18.8-2.85 28.2L464 384h-1c-.9-21.05 1.72-42.61 6.57-63.07.85-3.8 1.39-7.64 1.94-11.48A36 36 0 0 1 474 302"/><path fill="#b4bcc3" d="m1514 842 2.31 1.94C1519 846 1519 846 1522 847v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.56-.62 2.56-1 5l4 2 .1 6.15c-.1 1.85-.1 1.85-1.1 2.85q-3 .06-6 0v-9l-4-1z"/><path fill="#d8dee0" d="M1493 822h8l.25 2.38c.75 2.62.75 2.62 2.5 3.62l2.25 1q1.32 1.24 2.56 2.56C1511 834 1511 834 1514 835v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.06-.62 2.06-1 4-3.94-.27-5.56-.55-8.37-3.44C1504 834 1504 834 1504 832l-5-1v-3l-2.44.56-2.56.44c-1-1-1-1-1.06-4.06z"/><path fill="#a193bd" d="M792 574h15v1l-4.88 1.37a74 74 0 0 0-4.93 1.7c-3.52 1.03-6.41 1.13-10.07 1.05l-3.49-.05L781 579l-1 3c-1.93.9-1.93.9-4.45 1.6l-2.76.8-2.91.79-2.85.82c-5.17 1.45-9.66 2.32-15.03 1.99v-1h6v-3c4.55-2.2 9.03-2.59 14-3l-4-1v-2h24z"/><path fill="#251744" d="M1362 467c2.35 2.35 2.95 4.2 4.06 7.31l1.04 2.87c2.86 8.96 2.42 14.73-1.66 23.15-1.84 3.4-3.73 6.88-6.44 9.67h-2l-.5 2.06c-1.86 3.64-4.23 5.46-7.34 8.03a66 66 0 0 0-7.52 8.21 15.4 15.4 0 0 1-5.64 3.7v-2c1.6-1.55 1.6-1.55 3.75-3.31A58 58 0 0 0 1348 518q1.54-1.31 3.13-2.56c2.45-1.97 3.53-3.57 4.87-6.44q1.19-1.14 2.44-2.31c3.98-3.87 5.68-8.39 5.92-13.92q0-2.2-.05-4.4l.01-2.23c-.04-4.22-.6-7.24-2.32-11.14-.2-2.24-.2-2.24-.12-4.31l.05-2.12z"/><path fill="#d4c9c2" d="m1361.2 1228.5 2.01.04h2.1q2.2 0 4.39.05 3.34.04 6.68.04l6.28.04c3.86.08 6.85.66 10.34 2.33v2h-41c4.46-4.46 4.46-4.46 9.2-4.5"/><path fill="#eff0f2" d="m1578 1034 4 1v3h-5c2.3 4.26 5.09 7.4 8.5 10.75l1.47 1.5c2.33 2.31 3.87 3.7 7.03 4.75v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.06-.62 2.06-1 4-4.02-.5-5.7-1.26-8.19-4.44-3.28-4.02-6.82-7.6-10.61-11.15a59 59 0 0 1-3.2-3.41v-3l1.88-.31c2.44-.8 2.96-1.47 4.12-3.69"/><path fill="#99989a" d="m455 954 25.4-.08 9.27-.02 2.9-.01h2.73l2.39-.01c2.51.13 4.86.57 7.31 1.12l-1 2h-54v-1h5z"/><path fill="#35343b" d="M711 930q5.69-.12 11.38-.19l3.27-.07 3.12-.03 2.9-.05c2.33.34 2.33.34 3.66 1.86L736 933c-2.12 1.06-3.26 1.16-5.6 1.25l-2.17.1-2.3.09c-16.17.8-16.17.8-18.93 3.56q-3.29.11-6.56.06l-1.87-.01L694 938l1-3 15-2z"/><path fill="#afafaf" d="M1224 916a394 394 0 0 1-19 8h8l1-2h10l-1 3c-2.17-.5-4-1-6-2l-1 3h-2l-1 3-3 1-3-2-5 6-7-2 1-3 5-1 1-3-4-1 2.74-1.02 3.57-1.36 1.8-.67A36 36 0 0 0 1214 917c1.97-.67 1.97-.67 4.06-1.19l2.1-.54c1.84-.27 1.84-.27 3.84.73"/><path fill="#868788" d="M737 186v2c-5.54 1.37-11.2 1.34-16.87 1.52-5.76.24-10.9.87-16.13 3.48q-2.06.1-4.12.06l-2.2-.02L696 193v-1l8-1v-2l-1.96.03-8.79.1-3.08.05q-1.46 0-2.97.02l-2.73.03c-2.6-.24-4.23-.9-6.47-2.23l23.42-.57c11.86-.3 23.71-.5 35.58-.43"/><path fill="#010103" d="M356 1670h1l1 27h2l1 4.69.56 2.63c.43 2.62.52 5.04.44 7.68h-4l-.37-2.77-.5-3.6-.24-1.83c-.66-4.57-.66-4.57-2.89-6.8-.26-2.49-.26-2.49-.27-5.6v-5.12q.02-2.65 0-5.32v-3.37l.01-3.1c.26-2.49.26-2.49 2.26-4.49"/><path fill="#13151a" d="M339 1562h3v50c-3-2-3-2-3.5-4.16-.04-.44-.04-.44-.18-2.67l-.2-3q-.28-7.92-.25-15.86v-3.26c0-5.18.11-10.22.92-15.34.24-1.92.24-3.78.21-5.71"/><path fill="#7941c5" d="m448 697-.2 2.49c-.7 9.29-1.1 18.57-1.36 27.88L446 742h-3a2448 2448 0 0 1-.72-19.44q-.14-3.57-.26-7.12l-.1-2.2c-.13-4.84.09-9.75 2.08-14.24 2.47-2 2.47-2 4-2"/><path fill="#7844c1" d="M446 648h2v8h2l.08 16.73.02 6.12.01 1.9c0 3.85-.38 7.47-1.11 11.25l-3-1z"/><path fill="#bc8bed" d="M376 632h1l1 9h2q.08 2.9.13 5.81l.07 3.27c-.2 2.92-.2 2.92-1.2 4.38-1.2 1.84-1.27 2.91-1.32 5.1l-.06 2.12-.03 2.27-.06 2.34-.15 7.4-.12 5.02q-.15 6.15-.26 12.29h-1l-.04-3.09q-.1-5.66-.22-11.33-.05-2.45-.09-4.9-.05-3.52-.14-7.05l-.02-2.22c-.06-2.06-.06-2.06-.49-5.41-1.53-1.2-1.53-1.2-3-2l1-3q.34-4.68.52-9.37c.2-3.94.76-7.02 2.48-10.63"/><path fill="#432c5e" d="M383 590c1.46 3.82.44 6.77-.75 10.5l-1.13 3.76-.58 1.9c-2 6.81-3.58 13.76-5.01 20.7C375 629 375 629 374 631.8a54 54 0 0 0-2 10.51c-.61 5-1.38 9.85-2.56 14.75A93 93 0 0 0 368 666h-2l-1 3a433 433 0 0 1-.1-5.96c.1-2.04.1-2.04 1.1-5.04h2l-.12-2.69c-.01-4.75.85-9.35 1.62-14.02.44-2.9.72-5.75.94-8.66a26 26 0 0 1 2.74-9.65c1.48-3.57 2.21-7.35 3.07-11.11A61 61 0 0 1 379 604q.54-1.95 1-3.94c.82-3.43 1.83-6.73 3-10.06"/><path fill="#000001" d="m382 328 4 1-1 22-2 1c-1.31 4.47-1.33 9.19-1.56 13.81l-.13 2.4L381 374l-3 1v-23l3-1z"/><path fill="#67686a" d="M645 219c5.63.75 5.63.75 7.45 1.98 1.85 1.22 3.38 1.54 5.55 1.96a147 147 0 0 1 19.19 5.19c6.08 1.89 12.48 2.03 18.81 2.19l13.66.37L721 231v1a3685 3685 0 0 1-23.23.15c-10.18.09-19.82.15-29.77-2.15q-1.85-.17-3.69-.31c-3.77-.79-6.08-2.22-9.24-4.35-3.2-2.08-6.62-3.7-10.07-5.34z"/><path fill="#201f27" d="M782 1736c2.5.42 4.6.78 6.84 2.02 2.63 1.2 4.58 1.24 7.46 1.26l3.26.03 7.3.04q5.1.04 10.2.05l10.64.07q10.07.07 20.15.12l22.95.14q23.6.15 47.2.27v1q-25.07.1-50.15.16-11.64.01-23.28.07-10.15.05-20.3.05-5.37 0-10.75.04-5.05.03-10.12.02-1.85 0-3.7.02c-11.78.1-11.78.1-16.7-3.36z"/><path d="M1345 1398q2.34-.12 4.69-.19l2.63-.1c2.68.29 2.68.29 4.9 1.78 3.34 1.81 6.13 1.98 9.83 2.02l2 .06 6.26.12 4.27.1q5.21.13 10.42.21l-1 3h-33l-1-2c-2.29-.63-2.29-.63-5.06-1.12l-2.79-.51-2.15-.37z"/><path fill="#4c4c4f" d="M109 908c1.88-.19 1.88-.19 4 0l.94 1.43c1.5 2.24 3.56 2.72 6 3.69 2.29.98 4.4 2.15 6.56 3.38a86 86 0 0 0 9.88 4.82 65 65 0 0 1 9.74 5.02c3.81 2.2 7.79 3.9 11.82 5.66a263 263 0 0 1 15.06 7c-2.82 1.16-3.85 1.06-6.73-.1l-3.02-1.65a78 78 0 0 0-10.5-4.87c-4.92-1.74-10.03-3.66-13.75-7.38l-5-1.37a46 46 0 0 1-13.12-6.82c-3.07-1.93-5.3-2.47-8.88-2.81l-1-4h-2z"/><path fill="#828286" d="m1252 799-1 2 6.15-.68C1259 800 1259 800 1260 799q2.5-.06 5 0v3l-7 3 6 1v1l-5.27.59c-1.73.41-1.73.41-3.73 2.41-2.6.41-2.6.41-5.62.63l-3.04.22-2.34.15c.74-1.95.74-1.95 2-4 1.95-.6 1.95-.6 4.13-.75l2.19-.17 1.68-.08v-3l-16 3c2.2-4.4 2.67-4.68 7-6.31l2.69-1.05c2.31-.64 2.31-.64 4.31.36"/><path fill="#07090f" d="m199 649 4 1h-3v4h7c-.19 1.88-.19 1.88-1 4-2.19 1-2.19 1-5 2-2.45 1.47-4.97 2.97-7 5q-3 .06-6 0v-3l1.94-.31L192 661l1-3 6-1v-3h-6l-1 4h-5v3l-1.5.37-1.94.5-1.93.5C180 663 180 663 179 665l-2-1c.69-1.5.69-1.5 2-3 2.63-.19 2.63-.19 5 0l1-7 2.63.13c3.55-.14 6.1-.72 9.37-2.13 1.38-1.62 1.38-1.62 2-3"/><path fill="#7645bf" d="M549 617h1v16h2l1-11 5-1-1.47.71C555 623 555 623 554.6 625.06l-.03 2.57-.06 2.91-.02 3.16-.06 3.22-.12 10.2-.1 6.92q-.13 8.47-.21 16.96h-2v-34c-.63 5.02-1.15 9.68-1.32 14.69l-.06 1.78-.31 9.32L550 672h-1z"/><path fill="#7b7b7e" d="m1295 190 2 1c.59 2.31.74 4.62 1 7l-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2q.05 1.96.06 3.94l.04 2.21c-.1 1.85-.1 1.85-1.1 2.85q-3 .06-6 0v-9l-4-2 1-7 6 1-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#464450" d="M580 1558q2.51.43 5 1v3a70 70 0 0 1-4.02 2.7c-2.47 1.63-4.7 3.48-6.98 5.36-4.72 3.82-8.93 6.77-15 7.94l-1-4 3-1 .88-1.37c1.6-2.31 3.7-3.2 6.12-4.63a318 318 0 0 0 5.75-4.12l1.5-1.1c3.6-2.63 3.6-2.63 4.75-3.78"/><path fill="#25242d" d="m1056 1172-1 2c-1.76.47-1.76.47-4.04.78l-2.5.36-2.65.36a84 84 0 0 0-15.81 3.5c-2.61.43-5.24.68-7.87.94-5.01.55-9.38 1.24-14.02 3.27-4.62 1.73-9.59 2.49-14.41 3.45q-2.33.47-4.65.97l-2.61.56-2.26.48c-2.42.37-4.73.39-7.18.33 3.7-2.76 7.98-3.49 12.38-4.62l2.57-.7c4.09-1.07 7.79-2.01 12.05-1.68l1-2q2.74-.4 5.49-.7c4.97-.6 9.86-1.73 14.76-2.74 22.93-4.71 22.93-4.71 30.75-4.56"/><path fill="#13131a" d="M909 1109a224 224 0 0 1 16.4 5.96c3.63 1.45 7.31 2.78 11 4.1q3.41 1.24 6.79 2.57a58 58 0 0 0 7.68 2.3c4.92 1.15 9.5 3.11 14.13 5.07-4 1.52-7.4.56-11.44-.31l-1.98-.4A53 53 0 0 1 941 1125l-2.75-.87c-4.2-1.46-8.2-3.28-12.25-5.13v-2l-9-1v-2l-2.87-.81c-3.16-1.2-3.6-1.46-5.13-4.19"/><path fill="#9a999f" d="m1450 1073 3 1c.99 2.64 1.13 5.16 1.15 7.95l.01 2.32v2.48l.06 16.14.01 5.23.02 2.47c-.01 4.26-.28 7.52-2.25 11.41l-2-1z"/><path fill="#d7d7d6" d="M1009 1008h5v2l11 1v2l1.68.11 2.2.2 2.17.18c2.44.64 2.63 1.47 3.95 3.51 1.8.4 3.5.44 5.34.5 1.66.5 1.66.5 2.88 2.54l.78 1.96c-8.46.47-14.58-.98-22-5v-2l-2-.19c-4.15-1.12-7.54-3.27-11-5.81z"/><path fill="#333138" d="M910 895v3h-5v2l-1.93.18-2.5.26-2.5.24C896 901 896 901 895 902q-2.55.24-5.12.38c-4.3.33-7.37 1.08-11.18 2.99-3.14 1.16-6.4.86-9.7.63l1-3 10-2 1-3 3.29-.15 4.34-.23 2.16-.09c6.71-.37 12.45-2.8 19.21-2.53"/><path fill="#302e34" d="M1114 844c-3.93 1.6-7.76 3.02-11.94 3.81-4.65.95-8.99 2.56-13.43 4.19-7.74 2.82-15.3 5.2-23.63 5 .31-1.94.31-1.94 1-4l3-1v2l7-1v-2h-10v-1l2.12-.11 2.76-.2 2.74-.18c2.38-.51 2.38-.51 3.47-2.04l.91-1.47c1.73-.3 1.73-.3 3.63-.19l3.37.19-4 1v2a610 610 0 0 0 8.89-.59A29 29 0 0 0 1098 846q3.01-.9 6.06-1.69l3.1-.82c2.75-.47 4.25-.42 6.84.51"/><path fill="#0e071b" d="m525 766 1.8.2a283 283 0 0 0 19 1.33l3.22.12c15.2.55 30.4.7 45.61.88l12.35.16L631 769v1q-19.85.14-39.69.2l-18.43.1q-8.89.07-17.78.08-3.39 0-6.79.04-4.74.04-9.5.03l-2.84.05c-6.38-.04-6.38-.04-9.58-2.53z"/><path fill="#07070d" d="M1207 1601c-5 2.42-8.76 3.44-14.28 3.37l-2.1.01q-3.37.01-6.76-.02h-4.74q-4.93 0-9.87-.02-6.34-.04-12.68-.02a2420 2420 0 0 1-14.42-.02q-3.26 0-6.52-.03l-1.95.01c-4.45-.05-4.45-.05-6.68-2.28l2.62.01 24.45.1q6.29 0 12.57.04a2959 2959 0 0 0 16.76.06q3.25.03 6.5.02l1.91.02c5.32-.03 10.17-3.18 15.19-1.25"/><path fill="#cb6e3e" d="m1349 1366 3.69 3.7a86 86 0 0 0 3.12 2.92c1.19 1.38 1.19 1.38 1.19 4.38l2.13.63c2.7 1.3 3.68 2.1 5.62 4.24 7.16 7.42 17.55 8.44 27.25 10.13-1.12 1.56-1.12 1.56-3 3-2.81-.23-2.81-.23-6-1l-3-.61-3-.7-3-.68c-6.57-2.21-10.94-6.66-15.69-11.51l-1.72-1.72c-7.49-7.57-7.49-7.57-7.78-11.03z"/><path fill="#57585b" d="M1228 978c.69 1.81.69 1.81 1 4-1.25 1.44-1.25 1.44-3 3l-1.5 2.5c-1.5 2.5-1.5 2.5-2.94 3.88-1.56 1.62-1.56 1.62-3.12 4.68-4.74 8.62-14.42 17.38-23.44 20.94l-1.94 2c-2.5 2.42-4.74 3.1-8.06 4 1.65-3.16 3.98-4.23 7-6 4.14-2.9 8.12-5.86 11.6-9.54a107 107 0 0 1 3.42-3.39 93 93 0 0 0 17.36-22.2C1226 979 1226 979 1228 978"/><path fill="#9d9d9e" d="m161 930 12 1v3l2.8.15 3.64.23 1.85.09c1.58.1 3.15.31 4.71.53l1 2c2.07.63 2.07.63 4.56 1.13l4.44.87v4c-5.53-.66-10.71-1.9-16.06-3.44l-2.36-.66q-6.34-1.77-12.58-3.9v-2l-4-2z"/><path fill="#b287e2" d="M455 686c3.12 6.25 3.12 6.25 3.28 9.55l.11 2.12.11 2.2c.2 4.2.44 8.06 1.5 12.13l-1 1a83 83 0 0 0-.06 4.88l.02 3.04.08 6.34c.04 5.37-.08 10.45-1.04 15.74l3.19-.56c3.17-.47 5.7-.29 8.81.56v1l-5 1 1.68.11 2.2.2 2.17.18c2.45.64 2.6 1.48 3.95 3.51 1.95.51 1.95.51 4.13.69l2.19.2 1.68.11v1q-2.62.12-5.25.19l-2.95.1c-3.25-.34-4.29-1.3-6.8-3.29-2.54-.7-2.54-.7-5.19-1.12-4.72-.8-4.72-.8-5.81-1.88a75 75 0 0 1-.2-4.61l-.07-2.97-.05-3.24-.21-11.96-.16-8.85L456 696l-1 5h-1q-.05-3.46-.06-6.94l-.03-2q0-2.52.09-5.06z"/><path fill="#030306" d="M453 223h1v13l-3 1-.37 2.3-.5 3.01-.5 3c-.6 2.53-1.21 3.61-2.63 5.69q-.65 2.57-1.12 5.19l-.51 2.73L445 261h-2q-.08-3.2-.12-6.37l-.06-1.83c-.01-1.6.08-3.2.18-4.8l2-2c.52-2.8.88-5.56 1.21-8.39q.32-2.32.79-4.61l2-1c.56-1.56.56-1.56 1-3.44.64-2.75.9-3.47 3-5.56"/><path fill="#444349" d="m542 174 2 1-2 2zm-5-2 2 1-1.93 1.9-2.5 2.47-1.28 1.25c-1.74 1.73-3.19 3.17-4.29 5.38l1.57-1.53 2.12-1.97 2.07-1.97c2.65-1.8 4.1-1.84 7.24-1.53l-1 4-2.31.44C536 182 536 182 533 183a89 89 0 0 0-2 4l-2 1a78 78 0 0 0-2 5h-3l-1-2-6 5c1.13-8.24 8.15-13.32 14.2-18.46A82 82 0 0 0 537 172m-15 21 2 1-3 3z"/><path fill="#2e2e30" d="M1015 18h8c.36 4.45.36 4.45-1.19 6.81-2.37 1.56-4.03 1.42-6.81 1.19v3h-13l1-7h10z"/><path fill="#0a0a0f" d="M1083 14a5036 5036 0 0 1 26.77-.15q4.87-.04 9.75-.05l3.09-.03h2.85l2.52-.01C1130 14 1130 14 1132 16c-1 1-1 1-2.94 1.12h-5.49l-3.17-.02-13.46-.04-6.93-.01L1083 17z"/><path fill="#020203" d="m1058.34 13.77 2.89.03 3.12.02 6.57.08 8.08.1v3l-23 1v3l-16 1 1-4c3.27-1.63 6.86-1.4 10.47-1.62 3.15-.47 3.74-2.3 6.87-2.6"/><path fill="#88878f" d="M428 1735h5l1 3 6 1v3h9v5h5v3a610 610 0 0 1-8.89-.59c-4.29-.57-7.04-1.28-10.06-4.47l-1.74-2.38-1.76-2.37q-1.82-2.56-3.55-5.19"/><path fill="#0e0e15" d="M1256 1477c2.54.07 5.03.2 7.56.38l2.2.14c4.78.34 9.5.79 14.24 1.48v1l-2-.02-8.94-.04-3.15-.03-5.78-.02c-2.13.11-2.13.11-3.13 1.11q-.15 2.03-.16 4.07l-.03 2.65q0 1.45-.02 2.94l-.03 3.07-.14 17.1-.14 16.56-.16 18.84-.32 38.77h-1a46596 46596 0 0 1-.08-61.2l-.03-25.88-.01-13v-5.13c.12-1.79.12-1.79 1.12-2.79"/><path fill="#000002" d="m773 948 4 1c-1.71 2.31-3.14 3.79-5.99 4.49q-3.56.41-7.13.63l-4.89.39-2.17.14C755 955 755 955 753 957c-1.88.23-1.88.23-4.13.2l-2.44-.02-2.55-.05-2.58-.03-6.3-.1v-2a58 58 0 0 1 18-2v-2c2.3-1.15 3.58-1.12 6.13-1.1l2.44.01 2.55.03 8.88.06z"/><path fill="#010108" d="m1078 674-1 3c-1.82.88-1.82.88-4.12 1.56l-2.47.76q-3.07.86-6.16 1.68c-3.25 1-3.25 1-5.62 2.56-3.74 2.05-7.43 2.17-11.63 2.44l-1 3h-8v-3l9-1 1-3c1.74-.88 1.74-.88 3.88-1.56 3.89-1.27 3.89-1.27 5.6-2.5 2.57-1.59 5.4-1.4 8.36-1.6 4.75-.75 6.87-2.54 12.16-2.34"/><path fill="#440703" d="M1453 1268c3.3 3.12 5.9 5.91 8 10v2h2a43 43 0 0 1 3.06 10.56c.88 5.21.88 5.21 1.46 7.31.53 2.34.61 4.44.61 6.83v8.33c-.01 13.95-.01 13.95-2.7 20.47l-.8 2.03-.63 1.47h-1c.45-4.12.98-8 2.06-12 2.37-10.4 1.42-21.05-1.4-31.23-.64-2.7-.78-5-.66-7.77h-3l-.59-1.46-.79-1.91-.77-1.9c-.85-1.73-.85-1.73-2.85-3.73-1.48-3.12-2.26-5.54-2-9"/><path fill="#5f3d27" d="m942 1250 1 3-2 2h-3l1 2c2.06.63 2.06.63 4 1l-1 5c-6.75-.75-6.75-.75-9-3-1.95-.63-1.95-.63-4.12-1.12l-2.2-.51-1.68-.37c.37-2.47.6-3.67 2.57-5.27 4.98-2.45 8.9-3.26 14.43-2.73"/><path fill="#717175" d="M150 997h3v2l3.19-.12A18.4 18.4 0 0 1 165 1001v2l1.9-.07 2.47-.05 2.47-.08c2.16.2 2.16.2 4.16 2.2.13 2.63.13 2.63 0 5h-9l-2-4h-10l-2-4-2-1c-.62-2.06-.62-2.06-1-4"/><path fill="#624798" d="M835 570c-19.44 8.7-19.44 8.7-27.87 9.65-2.36.39-4 1.28-6.13 2.35-2.04.34-4.04.5-6.1.66-1.9.34-1.9.34-3.28 1.3-3 1.92-6.59 2.14-10.06 2.73l-2.31.43-2.25.4-2.03.35c-2.2.14-3.88-.24-5.97-.87 11.46-3.75 22.97-6.8 35-8v-2l4.25-1 2.4-.56c2.18-.4 4.13-.52 6.35-.44v-2q3.38-1.05 6.75-2.06l1.92-.6 1.88-.56 1.71-.53c2.14-.3 3.7.12 5.74.75"/><path fill="#000003" d="M385 553h1q.09 5.13.13 10.25l.05 2.93q0 1.4.02 2.84l.03 2.6c-.25 2.58-.98 4.13-2.23 6.38q-.64 2.61-1.12 5.25L382 588l-3 1a83 83 0 0 1-1-16l3-1 .15-1.53q.35-3.42.73-6.85l.23-2.4.26-2.3.22-2.13C383 555 383 555 385 553"/><path fill="#454448" d="m602.81 121.88 7.09.02q8.55.03 17.1.1v1l-3.3.04c-36.87.53-36.87.53-54.39 2.4l-1.8.18c-5.78.61-11.06 1.37-16.51 3.38-2.56.38-2.56.38-5 .56-3.72.32-7.33.76-11 1.44a16 16 0 0 1 8.88-4.06c2.71-.45 5.12-.94 7.71-1.9 4.12-1.39 8.3-1.76 12.6-2.16l2.76-.25c11.94-1 23.9-.85 35.86-.75"/><path fill="#88878f" d="M1300 1700h2c.97 3.97 1.57 6.62-.5 10.25q-1.23 1.88-2.5 3.75l-2.79 4.41q-2.09 3.3-4.21 6.59l-2-4-4 1v-7l3 3c1.43-2.35 2.09-3.48 1.63-6.25l-.63-1.75c3 1 3 1 4 3l1-5h3l-.12-2.87c.12-3.13.12-3.13 2.12-5.13"/><path fill="#030204" d="m1040.04 1210.8 3.58.07 1.86.03 4.52.1c-1 3-1 3-2.94 4.06-8.43 2.58-16.26 3.2-25.06 2.94h-2l-1 3h-3v-2h-10v-1l1.86-.15 2.45-.23 2.43-.2c2.26-.42 2.26-.42 3.66-1.41 1.92-1.21 3.28-1.37 5.54-1.57l2.27-.23 6.98-.65 2.07-.18c3.1-.68 3.24-2.35 6.78-2.58"/><path fill="#c5c4c4" d="m1050 974-1 3-2.74.37-3.57.5-1.8.24c-4.55.66-4.55.66-6.4 1.9-2.81 1.87-6.53 1.81-9.84 2.2-4.45.59-4.45.59-6.65 2.79-2.25.14-4.38.19-6.62.13l-1.86-.03-4.52-.1v-2l2.74-.59 3.57-.78 1.8-.39c3.09-.69 5.3-1.5 7.89-3.24q2.8-.6 5.63-1c3.76-.57 7.43-1.21 11.12-2.12 4.17-.98 7.98-1.04 12.25-.88"/><path fill="#98989a" d="M398 971h95v1l-2.28.08a3229 3229 0 0 0-17.15.6l-3.16.11C468 973 468 973 467 974q-2.42.14-4.84.13h-14.93C445 974 445 974 443 973q-2.17-.17-4.36-.2l-2.73-.07-2.95-.05-3.02-.06-16.04-.31L398 972z"/><path fill="#636367" d="M890 920c-6.15 2.97-12.12 4.58-18.81 5.75l-2.21.39c-3.7.6-7.22 1-10.98.86v2a958 958 0 0 1-37 6c4.35-3.2 8.97-4.29 14.25-5.06 5.65-.84 5.65-.84 6.75-1.94q3.71-.75 7.44-1.37a99 99 0 0 0 13.87-3.25c4.38-1.43 8.56-1.82 13.14-2.13 2.32-.23 4.28-.66 6.49-1.37 3.12-.9 4.1-.8 7.06.12"/><path fill="#9a9a9b" d="M179 898h4v2l7 1v2l1.8.55c4.43 1.41 8.28 2.88 12.2 5.45q2 1.02 4 2l-1 3h-9v-4h-8v-4h-5l-1-4-6-1z"/><path fill="#4d2f82" d="m1007.6 686.8 3.02.07 3.04.06 2.34.07-1 3h-10l-2 4c-2.35 1.23-2.35 1.23-5.06 2.19-4.89 1.76-4.89 1.76-5.94 2.81q-3.03.34-6.06.56l-3.35.26-2.59.18c3.75-2.04 6.8-2.52 11-3v-2h-13v-1l2.34-.11 3.03-.2 3.03-.18 2.6-.51q.48-.75.98-1.5L991 690c2.75-.44 2.75-.44 6-.56 4.55-.18 6.62-2.34 10.6-2.64"/><path fill="#828189" d="m428 1707 1 2h2q1.22 4.08 2.31 8.19l.7 2.33c1.47 5.6 1.47 5.6.07 8.15-1.31 1.38-1.31 1.38-4.08 3.33-2.99 0-4.41-.6-6.87-2.25-1.13-1.75-1.13-1.75-.82-4.44l.69-2.31 4 3c.08-3.34-.04-6.46-.62-9.75L426 1712l2-2z"/><path fill="#535359" d="M1509 1355c3.54.54 3.54.54 5 2q.1 2.02.06 4.06l-.02 2.23-.04 1.71-4 2c.62 4.68.62 4.68 2.56 6.31l1.44.69-1 7h-3v-8l-2.44.56-2.56.44c-1-1-1-1-1.19-3.94.19-3.06.19-3.06 2.19-5.06.34-1.87.34-1.87.5-4 .28-3.78.28-3.78 2.5-6"/><path fill="#909095" d="M1526 1291h3a3692 3692 0 0 1 .72 23.68q.15 4.32.26 8.64l.1 2.71c.1 4.47.07 7.9-2.08 11.97l-2-1z"/><path fill="#807d89" d="M1278 1159h20v2l3 .15 3.88.22 1.97.1c4.92.3 4.92.3 7.15 2.53l-5 1v1c-6.63.19-12.94-.54-19.5-1.5l-3.07-.43-7.43-1.07z"/><path fill="#39393d" d="m972.86 983.89 2.4.01 2.58.01 2.72.03 2.73.01 6.71.05c-2.62 2.62-4.49 2.58-8.13 3.12l-3.32.51-2.55.37-1 4 2.25.75c3.01 1.37 4.43 2.93 6.75 5.25a95 95 0 0 0 5.19 3c7.35 4.08 7.35 4.08 8.81 7-5.9-1.53-10.04-3.37-14.4-7.7-1.83-1.49-3.29-1.89-5.6-2.3l-1-4-2.94-.44C971 993 971 993 970 992c-.13-1.6-.13-1.6-.13-3.5v-1.9c.2-2.36.64-2.57 2.99-2.71"/><path fill="#9a9a9a" d="M266 959h23v3l3.29-.07 4.33-.05 2.17-.06c4.53-.04 8 .5 12.21 2.18v2c-19.07.34-19.07.34-25.87-1.98-4.28-1.4-8.54-1.77-13-2.23q-2.58-.32-5.13-.79z"/><path fill="#98989a" d="M619 953v1l-22 1 8 1v1c-23.67.26-47.34-.45-71-1v-1l33.78-1.15c17.08-.59 34.12-1.02 51.22-.85"/><path fill="#202023" d="M460 808c10.62.36 21.51 1.85 31.39 6a38 38 0 0 0 7.98 2l3.15.5 3.36.5 3.45.53A437 437 0 0 0 543 821v1a389 389 0 0 1-14.14-.02c-17.5-.22-34.86-3.09-51.32-9.14-3.66-1.2-7.36-1.8-11.17-2.35-2.4-.5-4.23-1.3-6.37-2.49"/><path fill="#838586" d="m951 39 1 2c2.56.63 2.56.63 5 1l2-4h8l1 5-1.93.59-2.5.78-2.5.78C959 46 959 46 958 48h-7c-1-3-1-3 0-6h-10v-3c3.67-.94 6.35-1.11 10 0"/><path fill="#4a4b4d" d="m1282.94 1822.88 2.07.02 4.99.1-1 7h-20v-2h-2l1-3c5.05-1.94 9.57-2.26 14.94-2.12"/><path fill="#c6c0b5" d="M1331 1291a11 11 0 0 1 3 3c-.31 2.69-.31 2.69-1 5h-2l.04 1.72c.14 12.81.14 12.81-2.08 15.57-1.5 2.68-1.07 5.18-.96 8.21.25 7.04.25 7.04-2 9.5l-.15-2.59-.22-3.35-.22-3.34-.41-2.72-2-1 1.46-7.71c.54-2.29.54-2.29 1.54-3.29q.34-3.03.56-6.06l.26-3.35.18-2.59h2z"/><path fill="#9a7f68" d="m1004 1234-4 3 9 1v5l-9.87-.44-2.84-.12-2.72-.12-2.5-.11C989 1242 989 1242 987 1241q-2.4-.22-4.79-.32l-2.85-.12L966 1240l2-4 4 1-3 1c22.46.46 22.46.46 27.53-3.06 2.37-1.51 4.74-1.06 7.47-.94"/><path fill="#15151c" d="M1323 1093h1a12579 12579 0 0 1 .15 44.52 3746 3746 0 0 1 .06 18.82q.03 3.64.02 7.27l.02 2.2c-.02 4.96-.02 4.96-2.25 7.19-2.71.27-2.71.27-5.94.25l-3.21.02c-2.85-.27-2.85-.27-4.25-1.25-2.05-1.3-3.64-1.43-6.05-1.65l-2.57-.26-2.67-.23-2.7-.27-6.61-.61v-1c16.04-.35 16.04-.35 23.1 1.55 4.12.98 7.89.79 11.9-.55z"/><path fill="#6d6c6f" d="M1161 845h-3v2l-2.55.59-3.33.78-3.3.78c-2.82.85-2.82.85-4.82 2.85-1.86.46-1.86.46-4.19.81a90 90 0 0 0-13.12 3.57l-1.94.66-3.87 1.32c-7.5 2.55-14.98 4.8-22.8 6.2-2.08.44-2.08.44-4.95 1.63-2.5.95-3.67.72-6.13-.19 1.09-1.96 1.09-1.96 3-4 2.82-.5 5.5-.6 8.35-.66 4.48-.17 4.48-.17 6.65-2.34q3-.58 6.06-.94c6.73-.91 12.83-2.37 18.82-5.62 3.03-1.4 5.4-1.91 8.68-2.44 4.2-.68 6.86-1.68 10.44-4 4.3-1.5 7.57-2.38 12-1"/><path fill="#b1b0b2" d="M51 762c.31 1.69.31 1.69 0 4a69 69 0 0 1-3.57 3.57 33 33 0 0 0-4 5.05A24 24 0 0 1 37 781c-1.25 2.19-1.25 2.19-2 4l-5-1-.31-1.87c-.8-2.45-1.47-2.97-3.69-4.13l1-4h3v5c4.94-2.53 8.37-6.21 12.19-10.19a718 718 0 0 1 5.38-5.57C49 762 49 762 51 762"/><path fill="#1f1e25" d="m1405.27 728.35 1.73.65-6.12 3.37-1.73.97A58 58 0 0 1 1388 738q-2.13.7-4.25 1.44l-2.02.68c-2.02 1.03-2.52 2.01-3.73 3.88-1.62.95-1.62.95-3.44 1.69l-1.8.76c-1.76.55-1.76.55-4.76.55.63-1.94.63-1.94 2-4 3.13-.75 3.13-.75 6-1l1-7 7-2v-2l3 1-1 2c6.08-.33 9.82-1.72 14.92-5.04 2.08-.96 2.08-.96 4.35-.6"/><path fill="#6b6b70" d="m1307 218 2 1c.59 2.31.74 4.62 1 7l-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2q.08 1.94.13 3.88l.07 2.17c-.2 1.95-.2 1.95-2.2 3.95-2.62-.37-2.62-.37-5-1l-.15-1.86-.44-4.88c-.42-2.31-1.08-3.37-2.41-5.26-.12-2.69-.12-2.69 0-5 3-1 3-1 6 0l-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#1a191f" d="m978 160 2 1-3.5 3.06-2.2 1.94a425 425 0 0 1-4.44 3.81c-3.18 2.72-6.12 5.36-8.74 8.63L959 181h-2l-.62 2.06c-3.63 7.74-10.88 13.62-17.2 19.19A135 135 0 0 0 926 216c0-3.91 1.77-5.32 4.31-8l1.23-1.33c1.68-1.82 3.38-3.6 5.2-5.3 1.26-1.37 1.26-1.37 2.26-4.37q2.43-2.56 5-5l2.44-2.5a74 74 0 0 1 5.56-5 96 96 0 0 0 11.25-11.22c3.62-4.1 7.02-7.46 11.75-10.28 1.88-1.75 1.88-1.75 3-3"/><path fill="#414146" d="M990 110h8c.33 4.17.46 6.19-1.62 9.88-2.84 2.53-4.67 2.7-8.38 3.12l-1 2c-1.46.64-1.46.64-3.37 1.25-5.86 2.07-12.8 4.82-17.16 9.33-1.9 1.83-3.65 2.34-6.16 3.1l-2.45.77-1.86.55c2.06-3.1 3.7-4.4 7-6l.73-1.41c1.94-2.43 4.61-3 7.46-3.97 4.66-1.66 8.48-3.46 12.4-6.56 1.96-1.47 4.12-2.2 6.41-3.06l4-3h2v-4h-7z"/><path fill="#979899" d="M273 934c10.68.2 20.05.8 30 5v3q-3.31.04-6.62.06l-1.88.03c-3.94.02-7.61-.33-11.5-1.09v-3l-11-1z"/><path fill="#000003" d="m1098 869-1 4-2.34.37-3.04.5-3.02.5c-2.6.63-2.6.63-4.6 2.63-2.31.2-2.31.2-5 .13l-2.69-.06-2.31-.07h-12c6.78-4.9 12.98-5.8 21.19-6.69l3.3-.39c7.9-.92 7.9-.92 11.51-.92"/><path fill="#090b10" d="M352 1039c1.3 3.56 1.14 7.22 1.13 10.96v36.94c.02 18.4-.1 36.73-1.13 55.1h-1v-1.52a19171 19171 0 0 0 .15-54.3 5787 5787 0 0 0 .06-23.6q.03-4.57.02-9.13l.02-2.73-.01-2.52v-2.18c-.28-2.38-1-3.98-2.24-6.02-1.6-.55-1.6-.55-3.48-.6l-2.03-.1-2.12-.05-2.14-.09q-2.6-.1-5.23-.16v-1q3.93-.33 7.88-.62l2.26-.2 2.16-.16 2-.16c1.7.14 1.7.14 3.7 2.14"/><path fill="#e9ebee" d="M1541 1010c3.67 1.54 6.21 3.9 8.98 6.68l1.4 1.39 2.9 2.89q2.21 2.22 4.45 4.4l4.17 4.15c3.1 3.15 3.1 3.15 3.1 6.49l-6-1v-2l-4-1v-4l-2.31-.87c-4.29-1.8-4.29-1.8-5.69-3.13q-.55-2.5-1-5l-2-1-1-3-3-1z"/><path fill="#242328" d="m1192 837-7.06 2.81-2 .8a313 313 0 0 0-9.7 4.03 37 37 0 0 1-7.12 2.36q-4.57 1.08-9.06 2.5l-2.27.72c-1.79.78-1.79.78-2.79 2.78-2.58.34-2.58.34-5.81.5-4.04.2-6.64.65-10.19 2.5-2.14.2-2.14.2-4.25.13l-2.14-.06-1.61-.07c4.58-3.14 8.75-4.24 14.23-4.66C1144 851 1144 851 1146 849c2.48-.72 4.92-1.36 7.44-1.94l4.35-1.05 2.16-.52q4.22-1 8.42-2.12l2.6-.66C1173 842 1173 842 1175 840c2.13-.81 4.2-1.53 6.38-2.19l1.74-.57c3.3-1.03 5.61-1.5 8.88-.24"/><path fill="#35333a" d="M1154 822c2.38-.3 2.38-.3 5.13-.19l2.75.08 2.12.11v6l-3.16.88a226 226 0 0 0-24.48 8.24A24 24 0 0 1 1125 839c3.48-1.86 7.1-3.2 10.81-4.5l1.8-.64c4.09-1.46 8.2-2.7 12.39-3.86v-3h-7v-1l1.9-.11 2.47-.2 2.47-.18c2.16-.51 2.16-.51 3.13-2.04z"/><path fill="#4b4b50" d="M455 471c1.2 3.62.96 7 .66 10.73l-.14 1.94-.46 6.08-.3 4.14L454 504h-2a152 152 0 0 0-1.56 15.94L450 528q-3.09.08-6.19.13l-3.48.07c-3.25-.2-5.42-.8-8.33-2.2 5.11-1.74 9.84-.04 15 1l1-19h2c1.16-9.31 2.2-18.57 2.66-27.95A36 36 0 0 1 455 471"/><path fill="#68676a" d="m928 133-3.32.99-4.37 1.32-2.18.65c-4.1 1.25-7.5 2.7-11.13 5.04-4.3 1.5-8.5 2.25-13 2.94a37 37 0 0 0-11.33 3.46c-3.13 1.12-6.4.83-9.67.6 7.6-5.63 18.1-7.7 27.17-9.66A62 62 0 0 0 911 135q3.27-.9 6.56-1.69l3.38-.82c2.85-.46 4.4-.46 7.06.51"/><path fill="#858689" d="m1110.13 3.75 2.66-.02c4.64.01 7.92.43 12.21 2.27 3.33.2 3.33.2 6.81.13l1.81-.03L1138 6v2h-59l-1-2 3.04.07 6.01.1c3.32.04 6.15-.12 9.31-1.16 4.54-1.45 9.04-1.3 13.77-1.26"/><path fill="#040404" d="M1318 1382c5.18.26 7.67 2.1 11.64 5.32 3.69 2.63 7.77 4.3 11.92 6.09 2.44 1.59 2.44 1.59 3.29 4.27l.15 2.32h-3v-2l-8-1v-3l-7-1v-3l-5-1v-3l-4-1z"/><path fill="#545557" d="M299 1035h16v2l2.38.81C320 1039 320 1039 322 1042h-23c-1-5-1-5 0-7"/><path fill="#c0bfbf" d="M1236 910c-.87 4.75-.87 4.75-2 7h3v2h-2l-1 8-5 1-.25 1.63c-1.05 3.31-2.66 5.58-4.75 8.37h-2l-.75 2.25c-1.34 2.95-2.86 4.6-5.25 6.75 2.66-7.5 6.82-14.08 11.77-20.27C1229 925 1229 925 1230 922h3v-4h-7v-4l3.88-2 2.17-1.12c1.95-.88 1.95-.88 3.95-.88"/><path fill="#838384" d="M1327 770c-4.74 5.1-9.4 7.2-15.93 9.24-2.4.88-4.07 2.2-6.07 3.76-2.75.69-2.75.69-5 1l-2-3c3.2-4.94 6.44-6.35 12-8l1.97-.59q2.5-.73 5.03-1.41c3.6-1.2 6.23-1.07 10-1"/><path fill="#111015" d="M1077 740q-5.62 1.76-11.25 3.5l-3.22 1.01-3.1.96-2.86.89c-2.33.58-4.2.78-6.57.64v2a42 42 0 0 1-12.62 4c-3.2.52-5.12.84-7.88 2.56-4.14 2.38-8.8 2.2-13.5 2.44 2.74-2.01 5.7-3.29 8.81-4.62l3.08-1.36c3.24-1.06 5.73-1.21 9.11-1.02v-2a27 27 0 0 1 9.19-3.06c4.7-.84 4.7-.84 5.81-1.94q3.3-.78 6.63-1.44a107 107 0 0 0 11.5-2.81c2.96-.77 4.04-.6 6.87.25"/><path fill="#1d1c23" d="M1561 631h3l1 3-5 1v3l-1.94.88C1556 640 1556 640 1555 642a89 89 0 0 0 9.56-3.56A32 32 0 0 1 1573 636c-.49 4.28-1.61 6.1-4.87 8.88l-2.12 1.86L1564 648l-3-1 2.44-1.81C1566 643 1566 643 1567 640l-1.69 1.44c-2.31 1.56-2.31 1.56-4.93 2.06-2.38.5-2.38.5-3.7 2.06L1556 647l-3-1 1-3-5-1 1-3 4-1 1-3c2.5-1.62 2.5-1.62 5-3z"/><path fill="#0e0c13" d="M852 345h1c.42 4.34.43 7.68-1.81 11.5-2.58 4.6-3.25 9.31-4.11 14.44a86 86 0 0 1-2.7 11.56c-3.18 11.28-3.64 21.85 1.62 32.5h-2c-5.7-8.7-5.3-20.12-3.42-30.05q.74-2.58 1.58-5.13a66 66 0 0 0 1.82-8.47c1.51-9.29 4.45-17.66 8.02-26.35"/><path fill="#08070d" d="M911 234c0 3 0 3-1 5.94-1 3.06-1 3.06-.87 5.25-.13 1.81-.13 1.81-2 3.5C905 250 905 250 903 251l-.81 1.75c-1.27 2.4-2.83 3.97-4.76 5.84A62 62 0 0 0 886 274l-1-2c1.04-2.07 1.04-2.07 2.63-4.56 2.56-4.1 4.94-8.25 7.2-12.53 1.17-1.91 1.17-1.91 2.67-3.4 1.78-1.8 2.73-3.62 3.88-5.88A33 33 0 0 1 909 236z"/><path fill="#6c6f71" d="m412 162 2 1v8l2.44-.56L419 170c1 1 1 1 1.1 2.63L420 178l-3 1v7h-2v2l-5-1v-8l4-2c-.62-4.68-.62-4.68-2.56-6.31L410 170c.88-6.87.88-6.87 2-8"/><path fill="#717079" d="M1221 1638c-1.06 3.19-2.02 4.4-4.85 6.16-10.22 4.01-24.71 1.96-35.53 1.9l-5.15-.01-12.47-.05v-1l34-1-4-3 3.44-.15 4.56-.22 2.25-.1c4.53-.23 8.66-.86 13.02-2.11 1.73-.42 1.73-.42 4.73-.42"/><path fill="#7d7c84" d="M426 1587h1l.01 1.6a47429 47429 0 0 0 .63 73.3l.07 8.57c.21 27.84.21 27.84 2.29 38.53l-1-2h-2c-.96-6.43-1.14-12.69-1.11-19.19v-12.24l.02-9.35q0-8.85.02-17.68l.02-20.14z"/><path fill="#020208" d="m725.06 1142.94 1.94.06c-.12 2.25-.12 2.25-1 5a64 64 0 0 1-4.4 2.9 17 17 0 0 0-4.1 4.1c-2.45 3.14-5.11 4.92-8.5 7l-1 1q-3 .06-6 0v-4l1.88-.31c2.56-.83 2.69-1.5 4.12-3.69q2.5-1.51 5-3l1-2 3-1c1.35-1.16 1.35-1.16 2.63-2.5 3.32-3.5 3.32-3.5 5.43-3.56"/><path fill="#4d4c57" d="M1266 1035c2 2 2 2 2.23 3.8l-.02 2.24v2.68l-.05 3.03-.03 3.26q-.03 4.86-.1 9.72l-.1 9.93a15628 15628 0 0 1-.57 50.92L1267 1151h-1l-1-111-2 1c1.88-4.87 1.88-4.87 3-6"/><path fill="#98a3ac" d="M1532 1007h3l1 3 1.56 1.5c1.44 1.5 1.44 1.5 1.44 4.5l4 2v2l4 1 .13 1.75c1 2.6 1.61 2.88 3.93 4.25 1.64.98 1.64.98 2.94 2 .38 2.25.38 2.25 0 4l-3-1v-2l-2.19.38c-3.18-.43-3.37-.69-5.25-3.07l-1.23-1.67a23 23 0 0 0-4.58-4.14c-2.51-1.98-2.71-2.35-3.5-5.69l-.25-2.81-2-2c-.12-2.12-.12-2.12 0-4"/><path fill="#4e4e51" d="M821 935v1l-1.59.3a487 487 0 0 0-31.16 7.2 333 333 0 0 1-17 3.69l-2.69.54A68 68 0 0 1 753 949c8.51-4.64 18-6.18 27.56-6.66 7.72-.76 15.36-2.98 22.7-5.43 5.87-1.95 11.63-2.05 17.74-1.91"/><path fill="#37363c" d="M646 942v2h8v1l-28 1v-2h-66v-1l58.1-.81q13.95-.22 27.9-.19"/><path fill="#0b0c0e" d="M1427 936c.37 6.54.37 6.54-1.31 8.63L1424 946a97 97 0 0 0-3 5l-1.16 1.97c-3.17 5.5-5.62 11.1-7.84 17.03-1.1-3.73-1.1-3.73-.12-6.12 1.39-3.57 1.6-6.88 1.87-10.67.25-2.21.25-2.21 1.25-4.21h3v-3h-3v-4h2v3h2v-2h3l1-6c3-1 3-1 4-1"/><path fill="#000004" d="M865 546v2l-1.8.59-2.39.78-2.35.78c-2.46.85-2.46.85-4.78 1.86a28 28 0 0 1-7.98 1.62l-2.96.26-3.05.24-3.11.26-7.58.61c1.54-3.8 1.54-3.8 3.59-4.71l1.85-.3 2.02-.36 2.1-.32c7.36-1.23 7.36-1.23 8.44-2.31a103 103 0 0 1 18-1"/><path fill="#949597" d="M428 386h1c.26 20.18.26 20.18-2.02 23.47-1.2 1.88-1.26 3.08-1.32 5.3l-.1 2.2-.06 2.28c-.17 5.57-.59 10.48-2.5 15.75l-2 1c-.32-7.69-.12-14.7 1.72-22.23 1.56-6.57 2.4-13.23 3.28-19.93l.35-2.64.3-2.38C427 387 427 387 428 386"/><path fill="#8f8e90" d="m559.54 153.7 3.25.04 3.4.07 3.43.04q4.2.06 8.38.15l-1 2c-2.65.6-5.3.74-8 1l10 2v1l-2.14-.03-9.61-.1-3.37-.05q-1.6 0-3.24-.02l-2.98-.03c-2.76.24-4.26.94-6.66 2.23-3.25.13-3.25.13-6 0v-3l2.81-.81c3.19-1.19 3.19-1.19 4.54-2.75 2.37-2.07 4.08-1.78 7.2-1.73"/><path fill="#89898b" d="M937 129c2.06.44 2.06.44 4 1l-2 3h7c-2.73 2.13-5.74 2.94-9 4l-1 1c-2.55.28-5.1.45-7.65.62C926 139 926 139 924 141c-1.95.2-1.95.2-4.12.13l-2.2-.06L916 141v-3l9-2v-3l2.37-.62 3.07-.82 3.06-.8C936 130 936 130 937 129"/><path fill="#858688" d="M599.63 68.76q3.04-.02 6.08-.07l3.9-.02 1.81-.04c4.07.03 4.5.28 7.58 3.37h-60l-1-2 6.69.06 1.84.03c4.2.02 8.33-.22 12.51-.58 6.87-.56 13.7-.73 20.6-.75"/><path fill="#7f8281" d="M120.3 983.77c1.7.23 1.7.23 3.7 2.23q1.76.74 3.56 1.38c3.3 1.34 3.3 1.34 4.44 3.75V993c-1 1-1 1-2.85 1.1l-2.21-.04-2.23-.02L123 994l-2-4c-4.68.62-4.68.62-6.31 2.56L114 994l-7-1v-3h8l-.56-2.44L114 985c1.74-1.75 3.98-1.18 6.3-1.23"/><path fill="#8d8d8e" d="m1001.38 880.75 2.83-.02c2.95.29 5.1 1.08 7.79 2.27q2.49.56 5 1l-1 2h-4v2c-6.35 2.3-12.3 2.2-19 2v-2l10-2-9-1v2h-2c.6-3.64.6-3.64 2.2-5.12 2.46-1.2 4.45-1.14 7.17-1.13"/><path fill="#020207" d="M812 555h15v2l-2.48.59-3.27.78-3.23.78c-3.02.85-3.02.85-5.09 1.91-6.25 3.04-15.1 2.1-21.93 1.94l1-4 1.73-.15q3.86-.35 7.7-.73l2.73-.23 2.59-.26 2.4-.22L811 557z"/><path fill="#38235a" d="M1358 504v5h-2l-.5 2.06c-1.88 3.68-4.32 5.56-7.47 8.16a34 34 0 0 0-4.78 5.22c-2.25 2.56-2.25 2.56-4.56 2.93L1337 527c.65-3.5 1.9-6.13 4-9 2.19-.81 2.19-.81 4-1v-2h2l.75-2.25c1.83-4.04 5.44-8.75 10.25-8.75"/><path fill="#fafaf9" d="m1710 450 4 1v3h-5c4.2 7.4 4.2 7.4 9 9v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1a78 78 0 0 0-2 5c-4.16-.5-6.48-2.97-9.31-5.81l-1.42-1.33c-1.32-1.32-1.32-1.32-3.27-3.86.2-2.32.2-2.32 1-4l1.88-.31c2.44-.8 2.96-1.47 4.12-3.69"/><path fill="#4b4a4f" d="M491 240c.63 1.75.63 1.75 1 4l-2 4q-.85 2.92-1.56 5.88l-.76 3.05c-.67 3.01-1.2 6.02-1.68 9.07h1c-.3 6.13-1.65 10.96-4.09 16.57-1.1 2.94-1.38 5.63-1.63 8.73-.4 2.45-1.6 3.94-3.28 5.7l.48-2c.75-4.3.83-8.6.95-12.94.2-4.35 1.05-7.52 2.9-11.45.98-2.37.97-4.56.98-7.11.48-8.41 3.96-16.03 7.69-23.5"/><path fill="#040405" d="m402 228 4 1-1 14-3 1-1 19h-3v-20l3-1-.04-2.05-.02-2.7-.04-2.67c.1-2.36.43-4.33 1.1-6.58"/><path fill="#fbfbfb" d="M768 82h30l2 4c-2.25 2.25-7.48 1.19-10.5 1.19l-2.65.03c-5.65.02-11.54-.08-16.85-2.22z"/><path fill="#4c4643" d="m1339 1392 2.82.59q6.09 1.24 12.18 2.41l3.33.64c21.03 3.68 21.03 3.68 27.67.36q2.06-.1 4.13-.06l2.19.02 1.68.04c-2.96 2.96-5.74 3.9-9.9 4.24l-3.17-.01h-3.47l-1.78-.02q-2.7-.03-5.42-.02c-8.84-.05-8.84-.05-12.26-1.19q-3.09-.33-6.19-.56l-5.81-.44-1 2-.25-2.37c-.75-2.63-.75-2.63-2.81-3.94l-1.94-.69z"/><path fill="#3b3943" d="m732 1145 1 2h-2l-.81 3.31c-.85 2.97-1.6 4.83-4.19 6.69l-3 1-.87 1.56L721 1161c-2.62.19-2.62.19-5 0v3c-3.98 4.89-3.98 4.89-7.81 5.31L706 1169v-2l-2-1 1.87-1.29c14.73-10.2 14.73-10.2 18.7-14.35C726 1149 726 1149 729 1148c1.69-1.56 1.69-1.56 3-3"/><path fill="#8d8e8f" d="M1233 966c.69 1.63.69 1.63 1 4-1.45 3.55-2.94 6.6-6 9h-2l-.75 2.69c-.95 3.39-3.02 5.63-5.25 8.31l-1.86 2.4a131 131 0 0 1-19.43 20.16q-1.75 1.47-3.46 3c-3.7 3.3-7.57 6.65-12.25 8.44l-2-1c5.03-4.8 9.85-8.65 16.02-11.87a22 22 0 0 0 5.62-4.64 71 71 0 0 1 3.38-3.42 93 93 0 0 0 17.98-23.38c1.64-2.77 3.64-5.27 5.63-7.8A35 35 0 0 0 1233 966"/><path fill="#424246" d="M1128 857a2841 2841 0 0 1-14.68 4.98 137 137 0 0 1-16.32 4.56c-3.1.71-5.99 1.86-8.94 3.02-5 1.9-9.4 2.85-14.76 3.13-3.38.46-6.08 1.96-9.12 3.45-3.48 1.37-6.48 1.13-10.18.86 2.31-1.72 4.42-2.54 7.19-3.31q4.84-1.38 9.64-2.92c6.94-2.26 13.68-4.29 20.94-5.31C1094 865 1094 865 1096 864c2.3-1.15 4.26-1.46 6.81-1.81 5.6-.92 10.76-2.62 16.06-4.6 3.53-1.27 5.57-1.7 9.13-.59"/><path fill="#9a9a9b" d="M162 770c1.63 1.69 1.63 1.69 3 4-.95 4.13-3.37 6.77-6 10q-1.24 1.95-2.44 3.94A63 63 0 0 1 149 798h-2c-.5-3.32-.55-5.1 1.23-8q2.35-3.03 4.77-6a59 59 0 0 0 4-7.19c1.11-2.01 2.11-2.58 4-3.81z"/><path fill="#191a1d" d="m271 599 1 2c4.1 1.42 7.62 2.38 12 2l2-1-2 4h-5v3h6v1h-6l-1 3v-3h-7v4h-8v-1l6-1-.12-2.75c.12-3.13.51-4.64 2.12-7.25h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#2d1951" d="M1230 594h3l-1 5-6 2v2l-8 2v2l-8 2v2c-3.32 1.74-6.29 3.29-10 4l1-3h3v-2h2l.75-1.87c1.48-2.52 2.63-2.98 5.25-4.13l2-2 5-2 2-2 2.38.13c3.5-.17 4.29-1.64 6.62-4.13"/><path fill="#3f3e43" d="M502 534h34c-3.2 2.14-4 2.24-7.67 2.2l-2.7-.02-2.82-.05-2.85-.03-6.96-.1v2l2.61-.07c10.58-.19 20.9.8 31.39 2.07v1c-8.94.12-17.76-.2-26.69-.87l-3.47-.24c-6.74-.5-13.25-1.36-19.84-2.89 3-1 4.17-1 7.19-.56l2.17.3 1.64.26v-2h-6z"/><path fill="#504e54" d="M468 360h1l.06 4.94.04 2.77C469 370 469 370 468 371c-2.1 9.88-2.54 19.94-3 30h-2l.13 2.44c0 2.7-.42 5.07-1.07 7.69-1.34 5.56-1.7 11.17-2.06 16.87h-1c-.82-13.13 1.34-25.9 3.26-38.85q1.23-8.4 2.06-16.85.25-2.16.68-4.3l2-1c.41-1.63.41-1.63.63-3.56z"/><path fill="#727375" d="M200.21 1011.87c1.79.13 1.79.13 4.79 1.13v2l1.83-.1c6.5-.23 6.5-.23 9.73 1.35C218 1018 218 1018 218 1022h-11l-2-4h-10a88 88 0 0 1-1-5c1.73-1.73 3.91-1.12 6.21-1.13"/><path fill="#c3c2c2" d="M1002 985c-1.87 3.88-1.87 3.88-3 5q-3.03.34-6.06.56l-3.35.26-2.59.18v3c-4.41.27-7.83-.7-12-2l1-5 11.88-1 3.41-.29 3.26-.27 3.02-.26c2.43-.18 2.43-.18 4.43-.18"/><path fill="#8c8b90" d="m1283 162 2 1c.59 2.31.74 4.62 1 7l-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l4 2v8l-4 1c-2.54-2.98-3.28-5.2-4-9l-2-1v-7c3-1 3-1 6 0l-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#010107" d="M907 1735h10l-1 3H803v-1l104-1z"/><path fill="#1f1f28" d="m769 1446 2 1a22 22 0 0 1-5.3 4.37 90 90 0 0 0-7.76 5.38 140 140 0 0 1-15.5 9.8C740 1468 740 1468 738 1470c-1.73.5-1.73.5-3.75.88-4.16.94-6.85 2.57-10.25 5.12l-2-1c3.23-3.8 7.79-6.2 12.44-7.87 3.28-1.45 5.83-3.5 8.65-5.68A64 64 0 0 1 750 1457l4-2.56a52 52 0 0 1 9-4.44c2.06-1.26 4.02-2.62 6-4"/><path d="m1511 1293 3 1v41l-4 1z"/><path fill="#a2acb6" d="m1619 1090 2 1c1.06 4.46 1.16 8.85 1.17 13.41l.05 13.07.01 4.24.02 1.99c-.02 3.87-.57 6.8-2.25 10.29l-2-1-.08-23.98-.02-8.73-.01-2.77v-2.55l-.01-2.26c.12-1.71.12-1.71 1.12-2.71"/><path fill="#bcc4cc" d="M1462 1046a18 18 0 0 1 7.25 5.44l1.58 1.8c1.17 1.76 1.17 1.76 1.17 4.76l3 1 .17 2.08c1.14 4.03 3.38 6.53 6.08 9.67l1.45 1.79c2.33 2.74 3.65 4.26 7.16 5.35l2.14.11.38 1.94.62 2.06 2 1-5-1v-3l-2.12-.14c-4.05-1.2-6.14-3.6-9-6.61l-1.6-1.6c-2.94-3-5.27-5.92-7.28-9.65-1.64-2.98-3.18-5.24-5.69-7.5-2.31-2.5-2.31-2.5-2.62-5.31z"/><path fill="#8754cc" d="M450 684h1c.33 21.08-.6 41.97-2 63h-2a2521 2521 0 0 1-.15-18.91c-.1-9.4.21-18.44 1.66-27.73.8-5.43 1.14-10.88 1.49-16.36"/><path fill="#020105" d="m362 657-1 44h-2c-1.5-3-1.14-6.1-1.13-9.39v-20.68c.01-4.4.26-8.6 1.13-12.93 2-1 2-1 3-1"/><path fill="#311d57" d="m1052 654 2 1c.75 2.31 1.4 4.58 2 6.94 1.73 6.8 1.73 6.8 4 9.06-.06 2.44-.06 2.44-1 5-3.22 2.54-6.91 3.81-10.74 5.16a100 100 0 0 0-5.4 2.23c-4.46 1.93-9.05 3.4-13.67 4.86l-2.6.85-2.48.8-2.25.71c-1.86.39-1.86.39-3.86-.61l2.52-1.02 3.3-1.36 1.65-.67A41 41 0 0 0 1033 683c1.81-.57 3.63-1 5.48-1.44C1040 681 1040 681 1041 679c1.58-.69 1.58-.69 3.6-1.29l2.18-.66 2.28-.67 2.3-.7L1057 674a321 321 0 0 0-5-20"/><path fill="#0c0b10" d="m1321 626 3 1-1.8 1.07c-18.27 10.88-18.27 10.88-24.71 16.64-3.18 2.75-6.36 4.52-10.49 5.29v2c-4.08 3.19-8 3.64-13 4 1.49-2.67 2.85-3.6 5.63-4.81a42 42 0 0 0 8.5-5.32c2.35-1.84 4.3-3.21 7.18-4.06 2.87-.86 3.76-1.62 5.69-3.81 3.56-3 3.56-3 7-3v-2l1.57-.77a91 91 0 0 0 9.24-5.04z"/><path fill="#656467" d="m547.88 128.88 3.49.05 2.63.07 1 4h71v1a11627 11627 0 0 1-42.8.15 3462 3462 0 0 1-18.1.06q-3.5.03-6.99.02l-2.1.02c-4.78-.02-4.78-.02-7.01-2.25h2v-2q-.9.46-1.81.94C547 132 547 132 544 133l-1 2-1-2h-10c5.2-3.47 9.68-4.25 15.88-4.12"/><path fill="#393b3d" d="M535 70h19l1 5c-4.48 1.73-8.01 2.22-12.87 2.13l-3.5-.06L536 77c-1.06-1.81-1.06-1.81-2-4z"/><path fill="#2f2f37" d="M1248 1761c-10.15 6.25-19.56 8.03-31.34 8.6l-2.08.12-1.87.1c-1.71.18-1.71.18-4.71 1.18a82 82 0 0 1-4.75.11h-2.9l-3.14-.01h-3.22l-17.07-.05-16.92-.05v-1l1.72-.02a5237 5237 0 0 0 24.44-.4q4.8-.07 9.58-.17l2.96-.03c5.56-.12 10.75-.7 16.18-1.91 2.77-.61 5.56-.94 8.37-1.28a67 67 0 0 0 16.08-4.6c3.27-1.3 5.34-1.93 8.67-.59"/><path fill="#85838c" d="M425 1432h1a4218 4218 0 0 1 .15 24.67q.04 4.51.05 9.02l.03 2.8c0 5.24-.52 9.55-2.23 14.51h-1a4218 4218 0 0 1-.15-24.67q-.04-4.51-.05-9.02l-.03-2.8c0-5.24.52-9.55 2.23-14.51"/><path fill="#22222b" d="M1219 1088v63h-2l.02-1.68q.15-18.96-.28-37.9l-.09-4.46q-.05-3.15-.14-6.3l-.02-1.9c-.1-2.83-.22-4.43-2.02-6.68L1213 1091c2.63-3 2.63-3 6-3"/><path fill="#010105" d="M857 930v2h-6v2l-16 1v2h10v1l-2.34.15-3.03.23-3.03.2c-2.6.42-2.6.42-4.6 2.42-2.98.53-5.94.88-8.95 1.21-4.85.59-4.85.59-7.05 2.79-2.12-.37-2.12-.37-4-1l3-1 .81-1.94c1.4-2.43 2.6-2.94 5.26-3.69 3.94-.76 7.93-1.07 11.93-1.37v-2l9.75-2 2.79-.58 2.7-.54 2.47-.51c2.15-.35 4.12-.44 6.29-.37"/><path fill="#1d1c20" d="m1123 745 1 2c-4.83 2.74-8.4 4.56-14 4v2l-1.59.38c-17.21 4.18-17.21 4.18-24.8 8.42-3.54 1.63-7.15 2.57-10.92 3.51l-2.07.52A36 36 0 0 1 1060 767c7.79-4.07 16.35-6.46 24.73-9 7.05-2.16 14.03-4.5 20.91-7.14 5.74-2.1 11.56-3.95 17.36-5.86"/><path fill="#6e40b6" d="M550 617c7 0 7 0 9.45.5 3.47.68 6.83.6 10.36.56L577 618c-1.2 1.98-1.2 1.98-3 4-2.18.45-2.18.45-4.67.4l-2.7-.05-2.82-.1-2.85-.05a458 458 0 0 1-6.96-.2l-.68 6.84C553 631 553 631 552 633h-2z"/><path fill="#707070" d="m1300 1278 1 2c-.93 2.4-.93 2.4-2.31 5.25-3.07 6.37-5 11.8-5 18.91v6.3q-.01 3.2-.05 6.4c-.05 9.57.46 19.02 5.11 27.64 1.25 2.5 1.25 2.5.94 4.88l-.69 1.62c-7.21-10.94-8.18-24.04-8.19-36.81l-.03-2.83a77 77 0 0 1 4.16-24.8l.96-3a13 13 0 0 1 4.1-5.56"/><path fill="#c4c4c3" d="M1171 1018h7l-4 4 1.86-.44 2.45-.56 2.43-.56c2.26-.44 2.26-.44 5.26-.44-3.28 2.98-5.62 4.32-10.01 5.38-2.37.74-4.33 1.85-6.49 3.06-8.92 4.61-19.58 5.78-29.5 6.56v-1l10.52-2.58c3.9-.94 7.43-1.68 11.48-1.42v-4l10-1v-2l-3.31.06c-2.66.05-4.07-.19-6.69-1.06v-2c5.37-.1 5.37-.1 7 0l1 1z"/><path fill="#b3b1b4" d="m1166 938-1 3c-2.03.91-2.03.91-4.5 1.63-4.43 1.3-4.43 1.3-5.5 2.37-2.31.53-4.64.96-6.97 1.4l-2.03.6-1 2h-13c2-3 2-3 4.1-3.62l4.8-.75c2.1-.63 2.1-.63 3.03-2.11 1.89-2.68 5.26-2.67 8.32-3.2l1.99-.4c3.97-.75 7.7-1.05 11.76-.92"/><path fill="#313039" d="M1314 926c8.41 11.21 6.27 28.6 6 42h-1l-1-8h-1l-1-9-2 2z"/><path fill="#06070c" d="m96 725 1 4-2 1-1 3 3 1h-3l-1 4h-3l-1 3v-3h-4v3l-1.87.81A8.7 8.7 0 0 0 79 746l-2-1 .44-1.69c.59-2.42 1.08-4.86 1.56-7.31l2.69-.69c3.18-1.04 4.86-2.63 7.08-5.07 1.83-1.84 3.98-2.99 6.23-4.24zm-11 16 4 1h-3l-1 2z"/><path fill="#868587" d="M817 166c-5.67 2.45-10.82 4.46-17 5l1 2c3.06.63 3.06.63 6 1l-1 3h-5l-1-2-1.81 1.06C796 177 796 177 793 176l1-2c-11.1.68-11.1.68-15 2.06-4.9 1.53-9.91 1.09-15 .94v-1l12.47-2.5a299 299 0 0 1 13.4-2.44c6.97-1.14 14.25-2.33 20.73-5.22 2.65-.93 3.8-.8 6.4.16"/><path fill="#272730" d="m911 1349 2 1a348 348 0 0 1-4.5 3.9c-1.5 1.1-1.5 1.1-3.5 1.1l-1 3c-2.07.95-2.07.95-4.56 1.69l-2.5.76-1.94.55v2l-5 1-1 3c-1.7 1.13-1.7 1.13-3.81 2.13l-2.08 1c-2.11.87-2.11.87-4.3 1.3l-1.81.57-1 3h-3l-.62 1.75c-1.88 3.06-4.4 4.42-7.38 6.25q-1.34.9-2.73 1.8l-2.96 1.95-2.82 1.86C854 1390 854 1390 851 1390c5.1-4.27 10.65-8.88 17-11l.93-1.89c1.33-2.63 2.64-3.4 5.16-4.89l2.54-1.54 2.75-1.62 5.7-3.44 2.95-1.79c5.68-3.5 11.22-7.22 16.75-10.95l2.39-1.6 2.1-1.4z"/><path fill="#121014" d="M1441 1070h1l-.44 5.06-.24 2.85q-.33 3.17-.83 6.31c-.54 3.79-.65 7.5-.66 11.32l-.05 13.08-.01 4.24-.02 1.99c.02 3.6.42 6.04 2.25 9.15v2h-4l-2 10h-1v-15h3v-49h3z"/><path fill="#000003" d="M874 928c-.87 3.88-.87 3.88-2 5q-1.87.3-3.75.46l-2.28.24-4.8.46-2.3.24-2.1.2c-1.77.4-1.77.4-3.77 2.4-1.88.23-1.88.23-4.13.2l-2.44-.02-2.55-.05-2.58-.03-6.3-.1v-2c2.94-1.47 5.9-1.1 9.13-1.06l2 .01 4.87.05v-2h6v-2l2.34-.18 3.03-.26 3.03-.24c3.15-.39 5.34-1.43 8.6-1.32"/><path fill="#9d9d9f" d="m100.31 796.19 1.69.81-1 4h-3v5l-4 1v6l-4 1v6l-4 1c-1-2-1-2-.55-3.99 1.49-4.33 3.02-8.16 5.55-12.01l.88-2c1.88-3.35 4.25-7.15 8.43-6.81"/><path fill="#2d2d31" d="M253 674c2.63-.19 2.63-.19 5 0-3.44 2.9-7 5.3-10.84 7.65a174 174 0 0 0-6.53 4.35 74 74 0 0 1-9.7 5.5 59 59 0 0 0-11.04 7.07A55 55 0 0 1 211 704l-2-1a53 53 0 0 1 7.5-5.44c2.5-1.56 2.5-1.56 4.56-3.87 2.98-3.2 6.9-4.8 10.84-6.55A57 57 0 0 0 236 685l1-3h6l1-5 3.38.19c1.95 0 1.95 0 3.62-.19 1.13-1.5 1.13-1.5 2-3"/><path fill="#242329" d="M1542 643h12c-.5 3.71-1.03 5.66-4 8a157 157 0 0 1-5 3l-4 2.56a43 43 0 0 1-9 4.44l1-4h4v-3h6v-3l-6 1v-2h2v-3l3-1z"/><path fill="#b8b0cc" d="m697 585-2 4h-36l-1-2c13.04-1.94 25.83-2.24 39-2"/><path fill="#341f5d" d="M1298 471h2l2 8h2c5.82 7.63 9.26 15.97 11.38 25.28.68 3 1.58 5.83 2.62 8.72-3.32-1.64-3.94-3.88-5.19-7.25a88 88 0 0 0-6.87-13.75c-3.69-6.2-7.94-13.62-7.94-21"/><path fill="#303035" d="m851.06 193.94 2.94.06v2c-1.24 1-1.24 1-2.87 2a26 26 0 0 0-5.13 4l-6 2-2 2c-1.95.2-1.95.2-4.12.13l-2.2-.06L830 206c3.75-2 3.75-2 6-2l-1-3-11 3c1.33-2.67 2.65-2.87 5.38-4 4.4-1.84 4.4-1.84 6.09-3.09 2.18-1.3 4.14-1.3 6.65-1.47 3.46-.25 5.67-1.44 8.94-1.5"/><path fill="#040309" d="M987 158h3l-1 3-3-1zm-2 3v3c-2.08 2-2.08 2-4.81 4.13l-2.71 2.13C975 172 975 172 972 173a87 87 0 0 0-2 4c-3.26 3.78-7.04 7.07-10.81 10.31-5 4.3-9.58 9-14.19 13.69l-2-1 2.43-2.4 12.8-12.69q2.8-2.75 5.57-5.52l1.74-1.7A41 41 0 0 0 973 168c1.72-1.77 1.72-1.77 3.5-3.25l1.78-1.52A10.5 10.5 0 0 1 985 161"/><path fill="#17171f" d="M488 1769q1.8-.09 3.63-.12l2.03-.08c2.34.2 2.34.2 4.86 1.2 4.42 1.5 8.9 1.3 13.52 1.32l3.02.03 9.84.06 6.83.06 16.13.12 18.37.14 37.77.27v1q-20.02.1-40.06.16-9.3.01-18.6.07-8.12.05-16.23.05-4.29 0-8.58.04c-22.93.15-22.93.15-32.53-3.32z"/><path fill="#0b0c12" d="M959 1335c4.6.55 7.54 2.37 11.25 4.94l1.64 1.09c3.97 2.7 3.97 2.7 5.11 4.97a88 88 0 0 0 4.81 2.38c3.8 1.82 6.26 3.59 9.19 6.62a70 70 0 0 0 5.18 2.21 37 37 0 0 1 7.82 4.79v2l2 1-6-1v-2q-1.42-.45-2.87-.94C994 1360 994 1360 992 1359v-2l-4-1v-2l-1.81-.44q-2.62-.69-5.19-1.56v-3l-2.25-.31c-2.95-.74-4.4-1.81-6.75-3.69l-3-1v-2l-2.19-.69c-3.46-1.61-5.23-3.51-7.81-6.31"/><path fill="#909da7" d="M1572 1050h2l.38 1.94.62 2.06 2 1-.5 3c-.68 5.17-.53 10.34-.37 15.54q.03 2.73-.13 5.46l-2 2-.4-2.12-1.06-5.5c-.54-2.38-.54-2.38-1.54-4.38-.13-2.34-.04-4.65 0-7h-2c-.75-2.69-.75-2.69-1-6 1.94-2.81 1.94-2.81 4-5z"/><path fill="#cdcccb" d="m1094.83 1026.76 2.3-.01 2.48.02h2.57l5.38.02q4.1.03 8.2.02l7.7.03c4.3.03 8.32.31 12.54 1.16-2 2-2 2-3.76 2.25h-4.59l-2.64-.02h-2.71l-5.7-.02q-4.34-.03-8.68-.02l-5.53-.02h-2.62c-4.05-.04-7.82-.16-11.77-1.17 2.35-1.83 3.87-2.25 6.83-2.24"/><path fill="#616063" d="m1054 878-6.25 2-1.78.58c-3.06.96-5.75 1.68-8.97 1.42v2l-1.58.3c-6.4 1.3-12.33 3.39-18.42 5.7-4.74 1.78-9.41 2.64-14.39 3.5-2.61.5-2.61.5-5.92 1.63-3.02.98-5.53 1.02-8.69.87v-1l2.63-.77 3.5-1.04 1.72-.5a41 41 0 0 0 7.99-3.21c4.11-1.93 8.14-2.58 12.6-3.36 7.73-1.43 15-3.4 22.4-6.01 10.22-3.6 10.22-3.6 15.16-2.11"/><path fill="#201f26" d="m1321 761 1 2 3.25-1.06c2.93-.85 4.84-1.1 7.75.06-5.1 3.4-9.38 4.67-15.45 5.5-2.53.5-4.27 1.34-6.55 2.5-2.29.48-2.29.48-4.62.75l-2.36.3C1302 771 1302 771 1300 769c.71-1.46.71-1.46 2-3 2.16-.51 2.16-.51 4.63-.69l2.47-.2 1.9-.11 1-3z"/><path fill="#07080e" d="m1643 650 3 1-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l-1.83.45c-6.05 1.7-8.07 3.16-11.17 8.55-2.19 1.25-2.19 1.25-4 2l-1 2-2-1v-3l-3-1 1-4h2l2-4h2l1-3h3v4h-3l1 4 2.88-2.31 1.61-1.3c1.51-1.39 1.51-1.39 2.54-3.05l.97-1.34c2.63-.19 2.63-.19 5 0l.44-1.94c.56-2.06.56-2.06 1.56-3.06"/><path fill="#323136" d="m769 225-9 3q2.63.08 5.25.13l2.95.07c2.82-.2 4.4-.75 6.8-2.2-1.12 1.96-1.12 1.96-3 4-2.81.5-2.81.5-6 .56s-3.19.07-6 .44l-1.4 1.52c-2.66 2.46-5.81 1.8-9.29 1.67l-2.12-.04L742 234v-1h8v-2l-4.87.44-2.75.24C740 232 740 232 738 233q-3.06.1-6.12.06l-3.33-.02L726 233v-1l2.15-.18 2.79-.26 2.77-.24C736 231 736 231 737 230q3.66-.45 7.31-.81a67 67 0 0 0 18.24-4.48C765 224 765 224 769 225"/><path fill="#8b8b8c" d="m639 182 6.93-.1c2.07.1 2.07.1 3.07 1.1a243 243 0 0 0 8.63 1.16c3.74.49 6.9 1.26 10.37 2.84v2a63 63 0 0 1-13.37 1.06L648 190l-1-3c-1.85-.73-1.85-.73-4.06-1.19l-2.23-.48L639 185z"/><path fill="#47484a" d="M638 62a2817 2817 0 0 1 19.67-.15q3.6-.04 7.19-.05l2.24-.03c4.1 0 7.15.37 10.9 2.23l-1 2h-41z"/><path fill="#6f6d77" d="M1312 1494h2a4823 4823 0 0 1 .15 26.2q.04 4.77.05 9.54l.03 3.02v2.79l.01 2.46c-.24 1.99-.24 1.99-2.24 3.99a322 322 0 0 1-1.13-29.1v-13.11c.02-4.67.02-4.67 1.13-5.79"/><path fill="#1a1a22" d="M1080 1171c-2.9 2.57-6.38 2.59-10.05 2.96l-2.12.24q-3.35.38-6.7.74c-9.24.99-18.24 2.09-27.25 4.37-4.04.97-8.12 1.57-12.22 2.16-2.66.53-2.66.53-5.23 1.58a15 15 0 0 1-6.74 1.08l-2.12-.06-1.57-.07c4.84-3.56 9.76-4.36 15.56-4.94 6-.66 11.65-1.75 17.47-3.33 4.44-1.09 8.92-1.69 13.45-2.3 2.52-.43 2.52-.43 3.52-1.43q3.78-.36 7.56-.56l2.1-.13c4.79-.28 9.54-.35 14.34-.31"/><path fill="#413f46" d="M452 504h1l-1 17 2-4 .44 2.25q.7 3.39 1.56 6.75h15l1 2-1.93.18-2.5.26-2.5.24C463 529 463 529 462 530q-2.34.01-4.7-.12l-2.85-.15-3.01-.17-3.02-.16-7.42-.4v-1h9l-.07-3.47-.06-4.6-.05-2.27c-.04-4.95.6-8.96 2.18-13.66"/><path fill="#2c2b34" d="M943 1689c3.04 3.7 3.44 6.38 3.31 11.06l-.03 2.01c-.34 12.37-2.96 21.93-11.4 31.16A30 30 0 0 1 926 1739l-2-1 4-1 .13-1.69c1.14-3.01 2.89-4.14 5.38-6.1 3.86-3.14 4.98-6.58 6.49-11.21l1.54-2.33c1.69-3.09 1.8-4.92 1.75-8.4l-.03-3.24-.07-3.34-.04-3.4q-.06-4.15-.15-8.29"/><path fill="#504f59" d="M433 1299h1l.57 40.08c.27 18.64.53 37.28.43 55.92h-3c-.09-20.08.16-40.15.46-60.23l.18-12.16z"/><path fill="#28282c" d="M1410 970c1.13 3.75 1.13 3.75 0 6h-2c-2.53 17.16-2.8 36.59 5.23 52.36.77 1.64.77 1.64.77 3.64l-3-1c-.7-2.05-1.01-4.06-1.37-6.19l-.63-1.81-3-1c-2.69-16.7-2.17-32.25 0-49l4-1z"/><path fill="#d3d3d2" d="M1036 1000v1l-1.5.4c-.32.1-.32.1-1.94.54l-1.93.52c-1.63.54-1.63.54-2.63 1.54-2.34.14-4.66.04-7 0l.63 1.88.37 2.12c-2 2-2 2-5.12 2.13l-2.88-.13v-2l-4 1 2.59 1.24 3.35 1.63 1.7.82c4.25 2.08 4.25 2.08 5.36 4.31q2.97 1.1 6 2c-2.17.95-3.56 1.14-5.82.4q-2.62-1.13-5.18-2.4v-2l-2.12-.19c-3.8-1.07-6.04-3.16-8.88-5.81l1-3c1.6-.7 1.6-.7 3.5-1.12 3.43-.8 3.43-.8 4.5-1.88q2.43-.55 4.88-1c11.12-2.08 11.12-2.08 15.12-2"/><path fill="#7f7e7e" d="m1123 949-2.05.52a121 121 0 0 0-16.15 5.37 169 169 0 0 1-22.42 7.3c-3.38.81-3.38.81-5.73 1.82-2.9 1.08-5.39 1.4-8.46 1.62l-2.96.22-2.23.15c6.63-4.63 13.44-6.5 21.4-7.5 2.53-.49 3.56-1.05 5.6-2.5 1.95-.53 1.95-.53 4.13-.94a90 90 0 0 0 16.56-5.18c4.35-1.65 7.85-2.16 12.31-.88"/><path fill="#b5bec4" d="m1484 866 2 1v8l4-2v-6l7-1v8l-3 1-1 5-6 1-.81-2.37c-1.17-2.6-1.72-3.4-4.19-4.63.88-6.87.88-6.87 2-8"/><path fill="#242429" d="m133 692 2 1a117594.63 117594.63 0 0 0-4.98 6.25c-2.02 1.75-2.02 1.75-4.76 2L123 701v4h6v-3h4v4h-3l-1 3c-2.69 2.54-5.18 3.93-8.87 4.25L118 713v-3l4-1v-3h-5l1.44-1.19c1.91-2.22 2.17-3.94 2.56-6.81.3-.1.3-.1 1.86-.55l2.45-.76 2.43-.74c2.36-1 3.6-2.03 5.26-3.95"/><path fill="#05070d" d="M1644 639h2c-.44 4.93-2.76 7.52-6 11l3 1c-.69 1.94-.69 1.94-2 4l-2.25.31c-3.8.95-5.1 3.15-7.42 6.2L1630 663h-2l-1 2c-1-3-1-3 0-6h3v-4l-2-1h2l1-3c3.24-3.92 3.24-3.92 6.25-4.25l1.75.25v-4l3-1q1.01-1.5 2-3m-9 12v3h5v-4c-2 0-2 0-5 1"/><path fill="#b7a4cf" d="M416.19 586.94 418 588v2l2.48-.1 3.27-.09 3.23-.1c3.4.33 4.44 1.18 7.02 3.29 2.14.51 2.14.51 4.25.69l2.14.2 1.61.11 1 3c-2.76.92-4.63 1.2-7.5 1.25l-2.34.08c-2.7-.41-3.3-1.42-5.16-3.33-2.04-.51-2.04-.51-4.19-.69l-2.17-.2L420 594v-2l-11-2 2-1v-2c3-1 3-1 5.19-.06"/><path fill="#a89fba" d="m771.88 573.81 2.43-.03c4.35-.02 7.73.19 11.69 2.22v1l-1.68.04c-19.26.57-19.26.57-26.35 3.34-2.39.75-4.48.72-6.97.62l3-2-18-1v-1l2.16-.17 13.05-1.03 3.25-.26 3-.24c12.7-1.49 12.7-1.49 14.41-1.49"/><path fill="#eb4c0a" d="M1345 1309h1l.37 16.87.31 13.99.05 2.59.06 2.42.05 2.13c.17 2.1.6 3.98 1.16 6h-3v-11h-2c-1.54-4.61-1.18-9.23-1.19-14.06l-.03-3.09-.01-2.95-.01-2.7c.24-2.2.24-2.2 2.24-4.2q.6-2.99 1-6"/><path fill="#4c4c51" d="M79 818h1c.19 4.1-.16 6.4-2 10-2.16 7.02-2.23 14.29-2.31 21.56l-.06 2.65c.01 7.87 1.56 14.99 5.62 21.79l1.75 3-1 2c-4.42-2.21-6.24-8.78-7.77-13.25-2.04-8.45-3-20.42-.23-28.75a219 219 0 0 0 .7-9.17L75 823l3-1c.69-2.06.69-2.06 1-4"/><path fill="#9e9ea0" d="M235 614c1.94.38 1.94.38 4 1l1 2q2.46 1.09 5 2v5l-2.94 1.44C239 627 239 627 238 628q-2.5.06-5 0l-1 4h-6l1-6h7l1-3 3-2 1-3h-5z"/><path fill="#0e081f" d="m1242 595 2 1a249 249 0 0 1-14.97 8.87q-2.4 1.35-4.72 2.82a30 30 0 0 1-6.43 2.58c-3.64 1.42-7 3.47-10.4 5.39C1205 617 1205 617 1202 618l-1.4 1.52c-2.08 1.93-3.49 1.96-6.29 2.17l-2.45.2-1.86.11v2l-9 3c3.74-5.62 10.88-7.9 17.28-9.47 2.78-.86 5.27-2.25 7.83-3.63 1.89-.9 1.89-.9 4.08-1.4l1.81-.5.75-1.94c1.25-2.06 1.25-2.06 2.87-2.56l2-.37c4.4-.89 6.93-2.9 10.36-5.67 2.61-1.89 5.23-2.87 8.26-3.87 1.76-.59 1.76-.59 4.13-1.84z"/><path fill="#0f1013" d="m309 586 1.63.94c2.77 1.24 5.37 1.65 8.37 2.06v-3h15v3c-3.01.93-3.87 1.04-7 0l-1 4c-3.01.93-3.87 1.04-7 0v-3h-8v3l-2-1zm10 8-1 3c-1.94.75-1.94.75-4 1l-2-1v-3c4.75-1.12 4.75-1.12 7 0"/><path fill="#04020e" d="m1338 532 2 1-2.52 2.23-3.3 2.96-1.65 1.46A37 37 0 0 0 1325 549l-3 1-1 3-1.73-.16c-2.87.2-4.18 1.14-6.52 2.78l-2.37 1.65q-4.38 3.18-8.65 6.53C1300 565 1300 565 1297 566l-.75 2c-1.25 2-1.25 2-2.93 2.48a87 87 0 0 1-5.32.52c1.65-3.14 3.97-4.28 7-6q2.24-1.47 4.46-2.96 1.53-1.04 3.08-2.04c4.14-2.7 8.06-5.51 11.46-9.12 2-1.88 2-1.88 4.25-2.66 3.6-1.6 5.95-3.9 8.75-6.6l3.06-2.9c.25-.22.25-.22 1.48-1.4A105 105 0 0 1 1338 532"/><path fill="#6a6a6e" d="M462 384h1c.16 6.19-.47 11.96-1.44 18.06a209 209 0 0 0-2.22 20.9c-.2 3.56-.63 6.93-1.34 10.42-1.1 5.4-1.34 10.75-1.6 16.25A127 127 0 0 1 454 468h-1c-.1-6.12.26-12.1.81-18.19q.14-1.47.27-2.99l1.24-13.33A560 560 0 0 1 462 384"/><path fill="#ea4703" d="M1345 1300h1c.24 5.77-.07 10.54-2 16l-1 1q-.12 2.78-.1 5.57l.01 3.4.04 7.2.05 8.83h2v5h-1l-1 5c-4.6-6.2-3.25-16.2-3.3-23.66q0-1.97-.06-3.94c-.29-13.1-.29-13.1 4.36-18.4.75-3.37.75-3.37 1-6"/><path fill="#100f17" d="m620.06 1194.94 6.94.06v1c-14 2.72-27.73 3.29-41.94 3.25h-2.54l-7.3-.05-2.2-.01A87 87 0 0 1 560 1198v-1l1.57-.03a9959 9959 0 0 0 31.14-.65l2.71-.05c4.9-.12 9.7-.43 14.57-1.05 3.35-.36 6.7-.33 10.07-.28"/><path fill="#3f3e49" d="M488 1180c6 1.45 11.56 3.02 17 5.95 3.58 1.88 7.3 3.44 11 5.05-1.63 1.96-2.46 2.92-5.04 3.3l-2.15-.11-2.17-.08-1.64-.11 1-5-1.56.5c-3.2.66-6.17.56-9.44.5l-2-5h-3l1-3-3-1z"/><path fill="#060509" d="M363 978q2.8-.08 5.63-.12l3.16-.08c3.21.2 3.21.2 5.98 1.19 3.44 1.08 6.58 1.36 10.16 1.42l1.92.06 6.02.15 4.11.12q5.01.14 10.02.26v1l-26 1 1 2q-2.4.08-4.81.13l-2.7.07c-2.49-.2-2.49-.2-3.9-1.17-2.53-1.64-5.34-1.5-8.28-1.65l-1.84-.12q-2.24-.14-4.47-.26v-1l6-1z"/><path fill="#bfbebe" d="M1165 967v2l-2.16.59-2.9.78-2.85.78q-7 1.9-13.93 4.03a207 207 0 0 1-22.04 5.7l-2.03.4c-3.19.57-4.96.76-8.09-.28a73 73 0 0 1 11.13-3.62l3.13-.8c2.74-.58 2.74-.58 5.74-.58v-2c5.6-1.72 10.94-3.12 16.81-3.5 3.24-.22 5.66-.47 8.5-2.06 3.13-1.67 5.2-1.69 8.69-1.44"/><path fill="#a1a1a7" d="m1474 797 4 2a2655 2655 0 0 1 .15 19.1q.04 3.49.05 6.97l.03 2.18c0 4.04-.4 7.04-2.23 10.75l-2-1z"/><path fill="#333238" d="M509 539a3113 3113 0 0 1 19.43.68l3.16.11C534 540 534 540 535 541q3.68.19 7.37.2l2.33.04 12.99.14 14 .15 14.28.16L614 542v1a12786 12786 0 0 1-45.53.15 3881 3881 0 0 1-19.28.06 260 260 0 0 1-30.1-1.33c-3.73-.42-6.84-.82-10.09-2.88"/><path fill="#dededf" d="m710 70 39 1v3h-40z"/><path fill="#d8d2cb" d="M1310 1362c3.38 1.13 4.67 2.36 7 5l1 3 3 1 1 3q1.99.55 4 1l1 2 2.44.94c2.77 1.15 3.02 1.7 4.56 4.06 2.13.69 2.13.69 4 1v3c-7.96-.64-12.2-4-17.55-9.63-1.45-1.37-1.45-1.37-4.26-2.87-2.19-1.5-2.19-1.5-3-4.69l-.19-2.81-3-1z"/><path fill="#d29a7c" d="m1383.36 1244.3 1.64.7h-3v2c-3.43 2.62-6.89 4.41-10.81 6.19-7.57 3.7-13.06 9.54-17.19 16.81q-1.99 3-4 6l-3.25 4.87-1.58 2.37-1.17 1.76-1-4 1.37-.63c1.63-1.37 1.63-1.37 2.5-4.07a39 39 0 0 1 4.88-9.43l.98-1.55c2.57-3.88 5.31-5.88 9.27-8.32q1.59-1.37 3.12-2.81a70 70 0 0 1 10.07-7.13l1.55-.96c2.4-1.41 3.81-2.16 6.62-1.8"/><path fill="#bbb" d="m987 991 1 3 2.44-.06C993 994 993 994 994 995q3 .06 6 0v1h-5v2l4 1h-6c1.22 2.33 2.19 3.11 4.5 4.5s3.28 2.17 4.5 4.5c-4.2-.55-6.68-1.92-10.06-4.44-2.9-2.14-5.66-4.05-8.94-5.56v-2l-2.44-.87C978 994 978 994 977 992l2.19.44 2.81.56 5 1z"/><path fill="#7d7d7e" d="m1259.56 806.94 2.44.06v2a760 760 0 0 1-8.87 3.94l-2.5 1.13A47 47 0 0 1 1233 818v-3l-3-1c5.8-2.72 10.46-4.34 16.89-4.01 3.32.02 6-.89 9.11-1.99 1-1 1-1 3.56-1.06"/><path fill="#6e5890" d="m924.63 536.94 3.03.02 2.34.04v2l-1.93 1.06q-1.24.67-2.5 1.38l-2.5 1.37C921 544 921 544 920 545q-2.02.1-4.06.06l-2.23-.02L912 545c1-2 1-2 4-3q-2.37-.08-4.75-.12l-2.67-.08c-2.75.21-4.19.9-6.58 2.2a68 68 0 0 1-4.5 1.19l-2.28.54c-2.38.29-3.93-.1-6.22-.73 18.46-6.61 18.46-6.61 26.25-6.9 3.26-.2 5.88-1.2 9.38-1.16"/><path fill="#2b2930" d="m1029.29 128.34 1.71.66c-2.86 3.15-6.1 4.78-9.84 6.72-2.16 1.28-2.16 1.28-3.74 2.88-1.9 1.88-3.93 2.1-6.46 2.74-3.67 1.23-6.75 3.54-9.96 5.66q-1.89 1.2-3.81 2.38l-1.65 1.02c-1.54.6-1.54.6-4.54-.4h2v-4l3.12-1.31a187 187 0 0 0 28.69-15.69c2.19-1 2.19-1 4.48-.66"/><path fill="#2f2e37" d="M1295 1485a110 110 0 0 1 22.38 1.27c2.62.73 2.62.73 3.77 1.85 1.28 2.82 1.11 5.61 1.08 8.65v2.07l-.03 6.77-.01 4.7-.05 12.35-.04 12.6q-.03 12.38-.1 24.74h-1l-1-69c-3.45-1.73-4.86-2.28-8.48-2.56l-2.53-.23-5.26-.42-2.52-.23-2.3-.18c-1.91-.38-1.91-.38-3.91-2.38"/><path fill="#fafbfd" d="m1586 1180 .31 1.88c.8 2.44 1.47 2.96 3.69 4.12l-1 4h-3v-5c-2.24 1-3.88 1.88-5.62 3.63L1579 1190h-2v3l5 1-1 4c-3.15-.35-4.82-.87-7.37-2.81-1.63-2.19-1.63-2.19-1.63-4.13 2.57-5.3 7.49-12.02 14-11.06"/><path fill="#1e1e26" d="M401 1097c2 2 2 2 2.05 4.36-.5 4.79-1.09 9.26-2.59 13.84-.57 2.23-.72 4.38-.9 6.67-.35 4.23-1.36 7.74-2.8 11.7-1.47 4.7-2.06 9.57-2.76 14.43l-.34 2.32-.66 4.68h-1a248 248 0 0 1 2.94-30.75l.51-3.43.51-3.24.46-2.9c.59-2.73 1.5-5.1 2.58-7.68q.63-2.64 1.13-5.31z"/><path fill="#666470" d="M1316 999h2c.8 3.54 1.12 6.8 1.11 10.43v3.22l-.01 3.43v3.55l-.03 9.28-.02 9.5-.05 18.59h-1l-1-47c-1.12 11.25-1.12 11.25-1.32 16l-.12 3.05-.12 3.08-.13 3.18-.31 7.69h-1a1983 1983 0 0 1-.15-16.77c-.11-9.3.4-18.08 2.15-27.23"/><path fill="#5b5b5f" d="M201 707c2.19.31 2.19.31 4 1l-1.69.69c-2.37 1.34-3.9 2.94-5.78 4.89-4.5 4.18-9.89 7.18-15.4 9.8-8.03 3.88-14.87 9.43-21.5 15.32-2.2 1.76-4.58 3.03-7.05 4.35-2.21 1.33-3.87 3.04-5.58 4.95l-2-1a873 873 0 0 1 6.54-5.96A56 56 0 0 1 160 736q2.79-2.2 5.5-4.5A68 68 0 0 1 176 724c7.23-4.37 7.23-4.37 10.44-7 2.98-2.33 6.12-4 9.47-5.75 2.09-1.25 2.09-1.25 3.73-2.92z"/><path fill="#99989b" d="M256 678h6c-1.08 3.25-1.63 3.78-4.31 5.69l-1.8 1.32c-2.15 1.12-3.5 1.18-5.89.99l-1 3-7 1-1 3h-6c.81-1.94.81-1.94 2-4l3-1 2-3c1.52-.88 1.52-.88 3.25-1.56l1.87-.75q2.6-.94 5.25-1.75C255 680 255 680 256 678"/><path fill="#28252f" d="M476 578c8.45-.32 16.48-.08 24.81 1.38 15.23 2.62 30.78 2.9 46.19 3.62v1c-17.1.21-34.02.38-51-2l-2.77-.39Q484.6 580.38 476 579z"/><path fill="#000003" d="M405 475h1c.53 16.84.53 16.84-2.27 24.44-1.53 5.35-1.5 11.03-1.73 16.56l-3 1q-.09-4.2-.12-8.37l-.06-2.41q0-1.13-.02-2.3l-.03-2.13C399 500 399 500 401 498c.38-1.8.38-1.8.56-3.96l.23-2.37.21-2.48c.84-9.75.84-9.75 3-14.19"/><path fill="#858587" d="M510 197c1 3 1 3 .21 4.66l-1.27 1.84c-2.79 4.2-5.16 8.54-7.51 12.98-1.43 2.52-1.43 2.52-3.04 4.63a13 13 0 0 0-2.45 5.39c-.7 2.7-1.57 5.05-2.94 7.5-1.37-3.7-.51-5.47 1-9q.57-3 1-6h3v-5c-2.2 1.1-2.76 1.96-4 4l1-5h2l2-7h2.69c4.4-1.33 5.26-3.4 7.43-7.29z"/><path fill="#7b7b7e" d="m764 178-7.75 2.44-2.21.7-2.15.67-1.97.62c-1.95.58-3.94 1.07-5.92 1.57l13 2v2l2 1c-5.85.41-9.74-.37-15-3l-1 3h-5l1-4h-12v-1l9.44-1.8q7.99-1.5 16-2.67c2.56-.53 2.56-.53 5.14-1.65 2.68-.97 3.8-.85 6.42.12"/><path fill="#19191b" d="M830 86h3l1 2h20l1-2 2 1-1 3-1.91.08q-4.26.17-8.53.36l-3 .12-2.87.12-2.65.11C835 91 835 91 834 92q-3.94.16-7.87.17h-2.4l-5.02.02q-3.85 0-7.7.03l-4.88.01-2.33.02h-4.07C798 92 798 92 795 90l16 1v-2h20z"/><path fill="#2d2f31" d="M1294 1288c1 2 1 2 .13 4.69L1293 1296c-3.03 16.7-2.58 36.84 5.15 52.23a21 21 0 0 1 1.85 6.77l-1-3-3-1c-.88-1.81-.88-1.81-1.56-4-.7-2.19-.7-2.19-1.44-4l-2-1v5h-1c-.81-5.62-1.12-11-.98-16.67l.05-2.44.12-5.07q.09-3.82.14-7.65c.5-23 .5-23 4.67-27.17"/><path fill="#dd5117" d="M1340 1301h1v8h-2c.53 26.62.53 26.62 2.38 36.44q.15.93.34 1.89c.76 3.59 1.99 5.85 4.28 8.67.73 1.82.73 1.82 1.19 3.56l.48 1.76c.33 1.68.33 1.68.33 4.68-11.46-13.83-12.11-32.63-11-50 .63-5.1 1.74-10.02 3-15"/><path fill="#090a0e" d="M1470 1218c5.02 1.4 6.48 3.78 9 8a242 242 0 0 0 3 4l-3 1-1 2c-4.4-.5-6.83-1.92-10-5-.81-2.25-.81-2.25-1-4h-3v-2h6z"/><path fill="#73717c" d="m1311 1083 3 1 .08 22.23.02 8.14.01 2.51c0 5.44-.4 10.73-1.11 16.12h-1c-1.42-16.63-1.1-33.32-1-50"/><path fill="#3f3e43" d="M860 910v1c-8.09 2.22-16.03 4.17-24.37 5.06A83 83 0 0 0 818 920c-10.19 3.09-20.43 4.07-31 5 2.72-2.72 5.55-2.81 9.2-3.4l4.41-.76q3.47-.6 6.95-1.16 3.36-.56 6.7-1.14l2.08-.34c3.24-.56 5.98-1.24 8.9-2.78 2.57-1.32 3.82-1.79 6.63-1.71l1.99.03 2.01.07 2.1.04 5.03.15v-2c5.78-2.01 10.9-2.22 17-2"/><path fill="#000003" d="M536 770a3167 3167 0 0 1 20.81-.15q3.8-.04 7.6-.05 1.19 0 2.38-.03c4.2 0 7.37.3 11.21 2.23v1h-43z"/><path fill="#05020e" d="m1267 585-1.98 1.17q-4.02 2.4-8.02 4.83l-1.9 1.11c-3.5 2.17-5.05 4.38-7.1 7.89h-3l1-1h-5v2a300 300 0 0 1-14 5 26 26 0 0 1 7.44-5.56l2.3-1.26q2.2-1.14 4.44-2.22c1.82-.96 1.82-.96 3.5-2.77 3.36-3.18 7.22-5.1 11.32-7.19l2-1.02c3.49-1.64 5.35-2.14 9-.98"/><path fill="#8a898c" d="M484 245h1a247 247 0 0 1-3.06 17.44l-.57 2.8L480 272h-2v5h-2l-2 1c-.7-9.8-.7-9.8 3.05-14.36 1.68-2.91 1.5-6.17 1.58-9.47.37-2.17.37-2.17 1.82-3.3L482 250c1.19-2.62 1.19-2.62 2-5"/><path fill="#010106" d="m578 114 27.39-.08 10.01-.02 3.11-.01c5.9 0 11.64.3 17.49 1.11v4h-22v-1h19v-2h-1.5l-21.58.08c-10.69.04-21.28.03-31.92-1.08z"/><path fill="#412a19" d="M1112 1337c7.17.87 13.37 2.3 20.06 5.04 3.85 1.56 7.71 2.9 11.69 4.09l3.22.97 5.81 1.7 2.47.76 2.14.63c1.61.81 1.61.81 2.61 3.81-5.53-1.03-10.82-2.68-16.19-4.31l-3-.9A199 199 0 0 1 1122 1342l-3.16-1.27-6.84-2.73z"/><path fill="#1d1c25" d="M684 1177c-2.06 2.06-3.12 2.53-5.81 3.44-2.5.89-4.48 1.73-6.75 3.12a26 26 0 0 1-7.24 2.78c-2.32.7-4.53 1.57-6.78 2.48a73 73 0 0 1-17.28 4.82c-2.14.36-2.14.36-4.14 1.36q-2.06.1-4.12.06l-2.2-.02-1.68-.04c3.58-3.58 8.04-4.02 12.88-4.75 5.04-.85 9.51-2.44 14.25-4.31 5.61-2.23 10.75-3.9 16.87-3.94v-2q2.43-1.05 4.88-2.06l2.74-1.16c2.38-.78 2.38-.78 4.38.22"/><path fill="#8b98a2" d="M1600 1134h2v11h-2l-.11 1.9-.2 2.47-.18 2.47c-.51 2.16-.51 2.16-2.01 3.29l-1.5.87c-.69 2.63-.69 2.63-1 5h-5l2-6h2l-.04-1.9-.02-2.47-.04-2.47c.1-2.16.1-2.16 1.1-4.16h3zm-11 27h1v5h-2z"/><path fill="#343437" d="M228 956c7.09.6 13.93 1.93 20.88 3.44 7.68 1.65 15.37 3.2 23.12 4.56v2l1.72.17 7.84.77 2.72.26c4.93.49 9.82 1.05 14.72 1.8v1q-5.5-.13-11-.31l-3.12-.07c-5.54-.2-10.03-.74-15.12-2.94-2.66-1.03-5.24-1.17-8.07-1.3-3.21-.18-5.63-.36-8.69-1.38v-2l-2.46-.37a1458 1458 0 0 1-13.82-2.14l-2.97-.49-2.7-.44L229 958z"/><path fill="#be9be6" d="M401 638h1q.09 5.6.13 11.19l.05 3.14a90 90 0 0 1-1.15 15.95c-1.3 7.66-1.48 15.28-1.65 23.03l-.12 4.3q-.15 5.2-.26 10.39h-1l-.08-22c-.08-18.88-.08-18.88.96-27.71.12-2.35-.23-4.05-.88-6.29.1-4.3.69-8.3 3-12"/><path fill="#a881da" d="M425 595v2l2 1c-3.06-.43-5.76-.9-8.62-2.06-2.39-.94-3.86-1.02-6.38-.94l-.17 1.62-1.3 12.25-.23 2.25C410 613 410 613 409 615q-.37 4.2-.6 8.39c-.3 4.5-.3 4.5-1.4 5.61q-.34 2.52-.56 5.06L406 639h-1l-.06-5.19-.04-2.92c.1-2.89.1-2.89.59-5.27.49-2.52.7-4.86.8-7.42l.1-2.71.11-2.8c.24-6.29.62-12.46 1.5-18.69 6.08-2.03 11.1-.93 17 1"/><path fill="#201c2b" d="M851 553v1l-9.44 2.25-2.86.68q-6.88 1.64-13.77 3.26l-2.34.54-4.35 1.01c-2.8.66-5.51 1.35-8.24 2.26-2.34.13-4.65.04-7 0v2a39 39 0 0 1-15 2v-1l7-1v-2c4.53-2.04 8.48-2.38 13.38-2.66 2.62-.34 2.62-.34 4.42-1.28 3.44-1.66 7.07-1.95 10.83-2.5a103 103 0 0 0 17.25-4c3.43-.9 6.6-.76 10.12-.56"/><path fill="#636267" d="M996 112c.2 1.84.2 1.84 0 4-1.36 1.41-1.36 1.41-3.19 2.63l-1.79 1.22c-2.02 1.15-2.02 1.15-4.43 2.1-3.75 1.52-6.99 3.73-10.38 5.9L974 129l-2-1c4.66-3.84 9.19-7.34 14.81-9.62C990 117 990 117 991 115c-11.35 2.7-11.35 2.7-16.69 4.56-11.97 4.12-11.97 4.12-17.31 2.44 4.02-1.87 8.03-2.67 12.35-3.5 3.9-.74 7.78-1.62 11.65-2.5v-2a39 39 0 0 1 15-2"/><path fill="#000004" d="m794 93 41 1-1 3h-37l-1-3z"/><path fill="#0b0d12" d="M350 1304h1c1 13.36 1.12 26.67 1.06 40.06l-.01 6.13L352 1365h-1l-.06-2.3-.36-12.33c-.43-16.3-.43-16.3-2.58-23.37l-2-1 1-5h2l-.06-7.37-.03-2.12c-.02-5.28-.02-5.28 1.09-7.51"/><path fill="#000003" d="M327 977c8.55-.13 17.08.02 25.63.31l2.9.07 2.76.1 2.46.09c2.72.52 4.17 1.64 6.25 3.43l-17.08.08c-15.36.09-15.36.09-22.92-1.08z"/><path fill="#29282d" d="m829 824-1 2c-2.62.6-2.62.6-5.87 1.06-3.2.47-6.06.92-9.13 1.94a222 222 0 0 1-9.05.95l-4.35.4-6.58.63c-7.03.67-13.95 1.2-21.02 1.02 5-3.52 11.08-3.34 17-3.87 8.33-.79 16.58-1.86 24.84-3.16 5.12-.79 9.98-1.1 15.16-.97"/><path fill="#663fa6" d="m664 607-4 3 2 4c-10.06.12-19.98-.12-30-1 5.39-2.8 10.02-3.12 16-3v-2a92 92 0 0 1 16-1"/><path fill="#17171d" d="M799 520a35 35 0 0 1-8.23 3.18l-2.44.63-2.52.63a155 155 0 0 0-14.27 4.11c-4.9 1.67-9.8 2.45-14.91 3.14l-2.78.38c-6.33.84-12.47 1.33-18.85.93v-1l2.14-.3a403 403 0 0 0 24.14-4.38l2.5-.52 2.25-.46C768 526 768 526 771 526v-2l1.8-.26 8.08-1.18 2.83-.4 2.72-.4 2.5-.37c7.65-1.44 7.65-1.44 10.07-1.39"/><path fill="#110f16" d="M964 389h1v2.23a204 204 0 0 0 6.29 50.83 212 212 0 0 1 2.59 12.63L975 461c-2-1-2-1-2.97-3.27a171 171 0 0 1-6.1-24.3c-2.68-14.75-2.42-29.5-1.93-44.43"/><path fill="#211f26" d="M849 350c.83 2.36 1.13 3.61.33 6.02l-1.08 2.23c-2.75 6.4-3.72 12.96-4.87 19.79-.39 2-.9 3.93-1.45 5.9-1.11 4.34-1.27 8.52-1.24 13v2.38c.1 5.62.8 10.57 3.31 15.68h-2c-5.67-7.7-4.87-17.15-3.72-26.2 1.04-6.8 2.5-13.27 4.72-19.8q.47-2.12.88-4.25c1.09-5.17 3.04-9.9 5.12-14.75"/><path fill="#000003" d="m784 166 1 3c-2.76 1.38-5.05 1.1-8.12 1.06l-3.33-.02L771 170v2h-6v2h-13l1-4 3.03-.15 3.9-.22 2-.1A74 74 0 0 0 767 169l1-2c5.2-1.18 10.7-1.11 16-1"/><path fill="#929192" d="m502.19 153.81 1.81.19c-1.51 3.78-1.51 3.78-4.06 4.94L498 160c-.75 2.63-.75 2.63-1 5l-3 1c-.69 2.06-.69 2.06-1 4h-3l.25 1.75c-.35 3.16-1.94 4.15-4.25 6.25l-1.12 1.75L484 181h-2c-.47-4.61.8-6.68 3.56-10.31l1.1-1.44q4.88-6.37 10.34-12.25l1.56-1.7c1.44-1.3 1.44-1.3 3.63-1.49"/><path fill="#28282b" d="M710 78q2.69-.12 5.38-.19l3.02-.1 2.6.29c1.06 1.4 1.06 1.4 2 3 2.16.95 2.16.95 4.63 1.69l2.47.76 1.9.55v1h-23l-1-2c-3.06-.62-3.06-.62-6-1v-1l8-1z"/><path fill="#73707c" d="m1278 1489 6 1v2l-4 2 .02 3.17c.06 16.27-.22 32.53-.48 48.8l-.18 11.55-.36 22.48h-1z"/><path fill="#6d6c76" d="M393 1225h1c.75 4 1.12 7.77 1.1 11.84l-.01 3.22-.03 3.32-.01 3.38-.05 8.24-2-4c-1.58 1.58-1.35 3.38-1.56 5.56l-.26 2.5-.18 1.94h-1c-.24-27.86-.24-27.86 1-34z"/><path fill="#8d8c8e" d="M386.36 966.9h2.4l12.69.05L414 967c-.74 1.48-.74 1.48-2 3-1.87.36-1.87.36-4.17.34h-2.61l-2.82-.05-2.89-.01q-4.56-.03-9.14-.1l-6.18-.03q-7.6-.06-15.19-.15c5.78-2.99 10.97-3.14 17.36-3.1"/><path fill="#cbd1d6" d="M1534 908h3c1.11 5.92 1.19 11.71 1.19 17.71l.03 5.55.02 6.79A22 22 0 0 1 1536 946l-2-1z"/><path fill="#989799" d="M137 919c6.81-.26 6.81-.26 10 2q1.77.26 3.56.44c3.44.56 3.44.56 4.56 2q.44.76.88 1.56a38 38 0 0 0 5 2v3c-6.48.56-11.14-1.35-17-4l-1.94-.8c-2.6-1.12-3.86-1.7-4.94-4.39z"/><path fill="#442a75" d="M1044 642c1.5 1.38 1.5 1.38 3 3v2c.3.1.3.1 1.88.56 3.08 2.1 3.24 5 4.12 8.44l1.04 3.6 1.02 3.59.53 1.82c.85 2.99 1.68 5.97 2.41 8.99l-5 1 1-5h-2l-.48-2.42a94 94 0 0 0-6.53-20.12C1044 645 1044 645 1044 642"/><path fill="#a87adc" d="M459 608h1q.3 5.2.54 10.4.13 2.64.28 5.27l.16 3.37.16 3.1c-.15 3.07-.96 5.04-2.14 7.86a85 85 0 0 0-.63 5.96l-.26 3.37-.24 3.48-.26 3.55L457 663h-1l-.08-16.62c-.04-7.54.03-15.04.52-22.57l.11-1.83A70 70 0 0 1 459 608"/><path fill="#afa1ca" d="M743 587c-2.46 3-4.45 3.55-8.23 4.17l-3.13.52-3.26.5-3.2.53A90 90 0 0 1 707 594v-2l2.08-.37 2.73-.5 2.71-.5c2.48-.63 2.48-.63 3.9-1.67 2.54-1.55 5.14-1.37 8.08-1.52 14.4-.8 14.4-.8 16.5-.44"/><path fill="#4b2b83" d="m934 541 1 3a25 25 0 0 1-10 4l-2 2c-2.6-.02-2.6-.02-5.62-.37l-3.04-.34L912 549l-1-4 1.8-.11c6.67-.56 6.67-.56 9.7-2.45 3.72-2.14 7.3-1.69 11.5-1.44"/><path fill="#170f2b" d="M1330 398c3 1 3 1 4.17 3.21l1.02 2.73 1.04 2.71c.77 2.35.77 2.35.77 4.35l3 1c3.24 4.78 5.05 10.6 7 16q1.2 3.31 2.44 6.63l.64 1.71c1.16 3 2.4 5.63 4.3 8.22 1.62 2.44 1.62 2.44 1.37 4.81L1355 451l-1-3-3-3c-2.66-4.43-4.4-9.1-6-14l-2.2-4.29c-2.03-4.35-3.8-8.9-3.8-13.71l-1.83-.2c-3.06-1.13-3.54-2.7-4.92-5.61l-1.3-2.65c-.9-2.42-1.16-4-.95-6.54"/><path fill="#7c7d7e" d="m724.77 226.9 2 .01 4.14.04 5.09.05v1a118 118 0 0 1-29.52 3.34 443 443 0 0 0-6.34-.02c-8.56-.02-16.72-.74-25.14-2.32v-1h1.52l21.59.08 8.43.02 2.64.01c5.3 0 10.36-1.25 15.59-1.2"/><path fill="#49484d" d="M593 172c9.05 4.22 15.13 12.42 21.24 20.03 4.6 5.7 9.47 10.9 14.76 15.97-3 0-3 0-4.91-1.72l-2.15-2.34c-4.57-4.75-9.4-8.34-14.94-11.94l-1 2 1-4-3-1-.22-2.18c-.96-3.48-2.81-5.48-5.28-8-4.41-4.65-4.41-4.65-5.5-6.82"/><path fill="#5f5e61" d="M570 127c-2.89 2.13-5.45 2.53-9 3v1l65 1v1h-71l-1-4-3-1c6.34-1.15 12.57-1.1 19-1"/><path d="M636 78h39l1 3h-39z"/><path fill="#818386" d="M652 70h25l1 4c-2.42 2.42-8.62 1.23-11.87 1.25l-3.1.06-2.96.02-2.73.03c-2.79-.43-3.68-1.11-5.34-3.36z"/><path fill="#181921" d="M478 1476h1l.01 1.64a22883 22883 0 0 0 .41 58.7 6100 6100 0 0 0 .18 25.5l.08 9.86v2.95c.06 4.54.15 8.24 2.32 12.35l-3-1a20 20 0 0 1-1.12-7.42v-8.91l.02-8.58v-8.97l.03-17 .02-19.33z"/><path fill="#000001" d="M1451 869h3v33h-4c-.13-11.05.24-21.98 1-33"/><path fill="#28282b" d="M456 814c6.63-.12 6.63-.12 10 1q4.4.34 8.82.52c3.52.2 6.16.61 9.18 2.48h-7v2l3.19.44q4.43.63 8.81 1.56v1c-6.57.5-12.43-.1-18.75-1.81l-1.86-.5A92 92 0 0 1 456 816z"/><path fill="#05060a" d="M213 642h6v4h-5v3l3 1c-1.05 1.46-1.05 1.46-3 3-2.47.38-4.71.6-7.19.69l-1.98.1q-2.4.12-4.83.21v-4l7-1v-4h6z"/><path fill="#000001" d="M371 609h3c.1 6.1.17 12-1 18l-1 1c-.28 2.51-.45 5.03-.62 7.55-.38 2.44-.67 3.7-2.38 5.45h-3q.17-3.7.38-7.37l.09-2.12.12-2.03.1-1.88c.31-1.6.31-1.6 2.31-3.6.41-1.6.41-1.6.63-3.48l.26-2.03.24-2.12.26-2.14z"/><path fill="#212129" d="M457 1757c8.5 2.72 8.5 2.72 12.38 4.5 9.78 4.26 20.12 5.38 30.62 6.5v2l21 1v1q-4.56.09-9.12.13l-2.6.05c-4.93.03-8.6-.5-13.28-2.18a134 134 0 0 0-8.81-1.06l-4.58-.48-2.03-.2C479 1768 479 1768 478 1767q-2.12-.2-4.25-.31c-4.41-.44-6.45-1.66-9.75-4.69l-2.69-1.06c-2.53-1.03-2.97-1.68-4.31-3.94"/><path fill="#000001" d="M225 1014c5.47-.22 9.9-.09 15 2v2l17 1 1 3h-18l-1-3-14-1z"/><path fill="#868587" d="M237 950h9l1 3 13 1v1l-12 1 8 3c-4.04 1.01-6.98.83-11 .25l-2-.28c-9.57-1.41-18.79-4.1-28-6.97a12.3 12.3 0 0 1 8.13-.12l2.07.55c1.8.57 1.8.57 3.8 1.57q3.56.11 7.13.06l2-.01L243 954v-2l-6-1z"/><path fill="#8d8e90" d="m765.44 932.88 3.08.02 7.48.1c-1 2-1 2-2.57 2.58l-2 .46-2.16.5-2.27.52-6.43 1.5C759 939 759 939 758 940q-3.29.11-6.56.06l-1.87-.01L745 940v-1h8v-2h-6v-2c6.2-2.21 11.9-2.24 18.44-2.12"/><path fill="#7e40c7" d="M408 670h1q.11 8.06.16 16.13l.07 5.48.06 7.88.05 2.49v2.3l.02 2.04c-.36 1.68-.36 1.68-1.88 2.91L406 710c-1.57-1.57-1.13-3.13-1.13-5.3v-5.57l.02-2.91c.06-9 .24-17.6 3.11-26.22"/><path fill="#aa99c7" d="M707 592v2l2.82-.44 3.74-.56 1.85-.29c4.24-.63 8.3-.8 12.59-.71-3.72 2.28-7.38 2.6-11.62 3.06l-2.15.25q-7.2.84-14.41 1.44C698 597 698 597 696 598q-2.06.11-4.13.1l-2.44-.01-2.55-.03L678 598v-3l1.63-.06q3.72-.15 7.43-.32l2.57-.09c5.91-.27 11.62-2.53 17.37-2.53"/><path fill="#000005" d="M1355 518h2c0 3 0 3-1.44 4.56L1354 524l-1 2-3 1-1 2h-3l-1 5h-4l-1 3h-2l-.19 1.81c-1.14 3.07-3.02 3.54-5.81 5.19a72 72 0 0 0-5 6l-3-1c5.18-7.72 11.76-13.6 18.95-19.4l1.48-1.19 4.12-3.27a31 31 0 0 0 4.65-4.9z"/><path fill="#8e9090" d="M368 407c.57 8.96.58 15.46-5 23h-1v-23c3-1 3-1 6 0"/><path fill="#111114" d="m951 50 1 4h7l1 3c-3.29.8-4.71 1.1-8 0l-.75 1.94C950 61 950 61 948 61.8c-2 .19-2 .19-4-.81v-3h7v-4h-8l-1 4h-10l-1 3h-9v3l-6 1 1.38-.69c2.19-1.77 2.72-3.68 3.62-6.31l2 1c1.73.1 1.73.1 3.63.06L930 59l1-2c2-.56 2-.56 4.44-1 4.34-.78 4.34-.78 6.56-3 2.08-.37 4.12-.5 6.22-.66L950 52z"/><path fill="#dae0e4" d="m1512 1215 1.63 1.88c2.68 2.4 4.92 3.18 8.37 4.12l-1 5h-15v-7l5-1z"/><path fill="#a5b1b9" d="M1547 1163c2.2 3.55 2.27 6.5 2.25 10.63l.02 3.47c-.27 2.9-.27 2.9-2.27 4.9l-.37 3.44c-.38 3.35-.38 3.35-2.63 5.25l-2 1.31c-.75 2.19-.75 2.19-1 4-1.81-.12-1.81-.12-4-1-1.29-1.93-2.09-3.85-3-6h6l.59-1.5.79-1.94.77-1.93c.85-1.63.85-1.63 2.85-2.63l.08-1.54.36-6.9.12-2.43.12-2.31.11-2.15c.21-1.67.21-1.67 1.21-2.67"/><path fill="#88898d" d="M1464 847h2v21l-5 1c-1.96-1.96-1.27-5.52-1.31-8.19l-.09-2c-.07-5.14 1.09-7.86 4.4-11.81"/><path fill="#757577" d="M1324 767a311 311 0 0 1-16.39 7.75c-7.2 3.44-7.2 3.44-8.61 6.25l2 2-5 1v-1c-6.86.66-13.34 2.24-20 4 1.26-2.51 2.08-2.66 4.63-3.75l2.45-1.05q6.1-2.5 12.26-4.91c4.76-1.9 9.42-3.97 14.08-6.12q4.04-1.83 8.14-3.54l2.44-1.03c2-.6 2-.6 4 .4"/><path fill="#908f91" d="m183 754 2 1-1.72 1.87c-4.46 4.9-8.7 9.82-12.67 15.12-1.61 2.01-1.61 2.01-3.75 4.1C165 778 165 778 164 780.62c-1.23 2.93-2.22 3.08-5 4.38-1.25 2.13-1.25 2.13-2 4 0-4.58 2.83-7.71 5.81-11 1.71-2.88.98-4.85.19-8l3-1 1-3 1 3h2v-4l2.23-.71c3.23-1.5 4.8-3.2 7.08-5.91l2.12-2.48z"/><path fill="#7f7d7e" d="m1383 741 2 1-2 3 1.94-.62A78 78 0 0 1 1400 741c-2 2-2 2-5 3l-1 3 2 1c-3.33 2.2-6.04 3.43-10 4l-1-5-2.19 1c-2.83 1-4.84 1.17-7.81 1a394 394 0 0 1 3.61-4.5 19 19 0 0 1 4.39-3.5"/><path fill="#000004" d="m672.14 574.89 4.18.01h2.21l11.79.05L702 575c-.8 1.47-.8 1.47-2 3-2.45.4-4.66.6-7.12.69l-2.13.1c-7.58.32-15.17.3-22.75.21 0-4.12.01-3.96 4.14-4.11"/><path fill="#020206" d="m463 192 3 1c.25 2.81.25 2.81 0 6l-1.98 1.41C462 202 462 202 461.53 204.16l-.03 2.47c-.31 5.44-2 9.57-4.5 14.37h-3v-12h4l.11-1.9.2-2.47.18-2.47c.51-2.16.51-2.16 2.01-3.25l1.5-.91c.69-3.12.69-3.12 1-6"/><path fill="#2c2b31" d="M971 134c-1.18 3.35-2.12 5.84-5 8q-3 1.05-6 2l-2 2c-4.33 2.43-7.99 3.37-13 3 1.55-2.84 3.16-3.58 6-5l1.69-1.86c2.9-2.68 5.81-3.93 9.43-5.39l1.92-.81C968.7 134 968.7 134 971 134"/><path fill="#040407" d="m460 119 2 3c-.87 1.5-.87 1.5-2 3h-2l-1 5-3 1-1 2h-3v5h-4l-.31 1.94L445 142l-3 1c-.47-3.01-.6-5.14 1.19-7.71a99 99 0 0 1 5.19-5.16l1.82-1.8c4.5-4.33 4.5-4.33 6.8-4.33v-4z"/><path fill="#191a1d" d="M434 1803h2l1 2c1.85.41 1.85.41 4.06.63l2.23.22 1.71.15 1 3 9 1 1 4h11l1 3 2.27.3c8.14 1.23 8.14 1.23 11.42 3.83L483 1823l-3.37-.44-1.9-.24C476 1822 476 1822 474 1821q-3.5-.07-7 0h-3v-6h-10v-3l-11-1-1-3-7-1z"/><path fill="#ececec" d="m345 1244 2 1v26l-2 1c-3.5-4.58-3.46-8.48-3.37-14l-.04-2.4c.02-4.71.46-7.72 3.41-11.6"/><path fill="#27272f" d="M434 1103h1l.35 1.91 1.59 8.53.55 3 .54 2.87.49 2.65c.48 2.04.48 2.04 1.48 3.04.09 2.38.12 4.72.1 7.1l-.04 8.96-.01 4.61L440 1157h-1l-2.86-25.1-1.05-9.18-.33-2.84c-.63-5.65-.9-11.2-.76-16.88"/><path fill="#989899" d="M669 950c-6.47 6.47-20.33 5.66-29 6v-2l8-1h-28v-1l18.99-1.15c10.02-.61 19.97-1 30.01-.85"/><path fill="#000002" d="M1172 923h10l-1 3-9 1-1 3-1.86.11-2.45.2-2.43.18c-2.26.51-2.26.51-3.73 2.04-1.53 1.47-1.53 1.47-4.01 1.76l-2.7-.1-2.74-.08-2.08-.11 1-3 10-1 1-3 2.15-.37 2.79-.5 2.77-.5 2.29-.63z"/><path fill="#020306" d="m1001 894 2 1c-1.79 2.26-2.7 2.95-5.6 3.48l-2.9.14c-3.14.18-5.5.38-8.5 1.38l-1 2c-2.03.38-2.03.38-4.5.56-4.4.34-4.4.34-5.5 1.44l4 2-5.27.59C972 907 972 907 970 909c-2.82.2-2.82.2-6.12.13l-3.33-.06L958 909c2.23-2.57 3.8-3.43 7.13-4.06 2.13-.41 2.13-.41 3.87-.94l1-2c2.26-.53 2.26-.53 5.13-.94a38 38 0 0 0 11.05-3.33c3.09-1.23 6.32-1.72 9.59-2.3C998 895 998 895 1001 894"/><path fill="#505054" d="M1193 818c-3.36 2.82-6.98 4.37-11 6l-2.84 1.23c-5.67 2.44-11.1 4.58-17.16 5.77l-2.8.6c-8.34 1.67-8.34 1.67-12.2.4a64 64 0 0 1 17-4v-2l3.4-.77 4.41-1.04 2.24-.5c6.39-1.54 6.39-1.54 8.95-4.69 3.69-1.57 6.17-2.47 10-1"/><path fill="#1f2025" d="M39 778c0 3.44-.49 6.6-1 10l-4-1v6h3v5h-4v6l-3 1v-7l2-1v-3l-4 4-2-1 2-2.62c2.15-3.18 2.37-5.53 2-9.38l2.31-.19C35 784 35 784 36.75 781.5L38 779zm-1 11h2v3l-3 1z"/><path fill="#19171d" d="m1329 625 3 1c-4.83 3.73-9.93 6.66-15.3 9.53a110 110 0 0 0-6.7 3.97c-6.36 4.05-12.94 7.82-20 10.5 2.24-3.42 4.48-4.99 8-7l2.34-1.87a76 76 0 0 1 21.5-12.28c2.16-.85 2.16-.85 5.1-2.66z"/><path fill="#6f569e" d="M887 554a505 505 0 0 1-39.96 13.36C845 568 845 568 843 569q-2.06.1-4.12.06l-2.2-.02L835 569c2.3-2.3 3.83-2.75 6.93-3.73l3.07-.99 3.31-1.03 3.38-1.06a1673 1673 0 0 1 27.2-8.26l2.12-.62c2.28-.36 3.82 0 5.99.69"/><path fill="#08070d" d="M906 133h-3v2c-11.53 3.96-11.53 3.96-16.06 5-4.28 1-8.32 2.55-12.42 4.1A55 55 0 0 1 868 146l1-3q2.99-1.05 6-2l1-2h-6v-1h8l2-4h6l-1 2c4.27.2 7.12-.25 11-2 6.27-2.43 6.27-2.43 10-1"/><path fill="#84848c" d="m420 1718 .48 1.8c1.6 5.42 3.17 8.56 7.52 12.2l-1 2-6-1-.25-1.85c-.98-2.81-2.08-3.2-4.69-4.53-2.9-1.66-4.53-2.69-5.56-5.93l-.5-2.69c3.58-1.24 6.34-.75 10 0"/><path fill="#ececec" d="m345 1432 2 1v27h-2c-2.85-4.82-3.47-8.45-3.37-14l-.04-2.08c.03-4.77.99-7.81 3.41-11.92"/><path fill="#8f6a4e" d="M1285 1214h16c-1.24 3.72-3.39 5.18-6.37 7.5-5.41 4.27-5.41 4.27-7.3 6.28-1.33 1.22-1.33 1.22-4.33 1.22.15-2.66.43-4.44 2.37-6.34a90 90 0 0 1 3.26-2.45c1.37-1.21 1.37-1.21 2.37-4.21l-6 1z"/><path fill="#9a9a9b" d="M165 935c5.54.73 10.73 2.35 16.06 4l5.29 1.63 2.34.72c2.42.68 4.84 1.19 7.31 1.65v-3l12 2v1l-9 1 10 4c-5.8 1.45-10.36-.39-16-2l-2.73-.77q-4.6-1.31-9.2-2.67l-3.06-.87c-5.16-1.55-9.14-2.89-13.01-6.69"/><path fill="#c8ced4" d="M1528 924h2v21l-5 1c-2.1-2.1-1.3-6.67-1.33-9.55.08-5.16 1.16-8.29 4.33-12.45"/><path fill="#7a797b" d="M1422 731c-.19 1.81-.19 1.81-1 4-2.19 1.56-2.19 1.56-5 3l-1.6.82q-2.2 1.1-4.4 2.18l-2 1v-3h-5l1-3c3.86-.22 6.5.4 10 2v-2c-4.65-2.4-8.91-2.2-14-2 6.9-4.7 13.98-5.07 22-3"/><path fill="#78737a" d="M1642 659q-3.57 3.7-7.19 7.38l-2.04 2.11-2 2.03-1.83 1.88c-2.2 1.82-4.25 2.66-6.94 3.6-3.86 1.71-3.86 1.71-5 4l-2-1c1.38-2 1.38-2 3-4h2l2-4h2l.75-1.87c1.25-2.13 1.25-2.13 3.19-3.07 2.06-1.06 2.06-1.06 3.5-3.56 2.75-4.4 5.83-4.88 10.56-3.5"/><path fill="#3b393e" d="m618 200 3.06 2.63 1.94 1.64q1.95 1.68 3.85 3.4c3.29 2.93 5.96 4.82 10.15 6.33 2.06 1.26 4.02 2.62 6 4-3.9 1.4-7 .65-11 0h-3l-.59-5.37L628 211l-2-1-1 3-3.71-3.9C620 208 620 208 618 208v-2l-2-1c.88-3.87.88-3.87 2-5"/><path fill="#919194" d="M597 166h3v4l1.75.19c3.15 1.13 4.3 3.15 6.25 5.81l2 2 1.94 2.44c1.6 2 3.2 3.8 5.06 5.56l-1 3c-3.67-.4-5.52-2.08-7.96-4.66A9.6 9.6 0 0 1 606 179h-2v-2h-2l-.25-2.37c-.75-2.63-.75-2.63-2.81-3.94L597 170z"/><path fill="#0a0a0f" d="M530 74c3.56.61 6.68 1.58 10 3l-4 1v4h12v2l4 1c-2.98 1.5-5.9.84-9.12.56l-2-.16L536 85l-1-3h-11v-2a262 262 0 0 1 5-4z"/><path fill="#505152" d="M642 70h10v2l3 1v2l23-1v2c-3.62 1.81-7.89 1.2-11.87 1.19h-2.88c-16.14-.13-16.14-.13-22.25-3.19z"/><path fill="#3b3b45" d="m617 1539 2 1c-11.73 9.53-11.73 9.53-16.37 11.69-2.7 1.34-3.13 1.9-4.63 4.31a394 394 0 0 1-5 3q-1.42 1.26-2.81 2.56c-3.36 2.91-6.89 3.7-11.19 4.44 5.59-5.74 11.64-10.94 18.81-14.56 4.4-2.28 6.75-5.18 9.19-9.44l3 2z"/><path fill="#780b03" d="M1447 1361c-.6 3.3-1.7 5.21-3.87 7.75l-1.56 1.86C1440 1372 1440 1372 1437 1372l-1 4-10 2 2-4v-3l8-2v-2l2.44-.81 2.56-1.19 1-3c3-1 3-1 5-1"/><path fill="#2f3033" d="M1514 1332h1v6l3 1v14l-7 1c-.33-7.9-.2-14.63 3-22"/><path fill="#e85012" d="M1430 1249c3.92 1.3 6.33 2.83 9 6v2l-1.9.59-2.47.79-2.47.77c-2.16.85-2.16.85-4.16 2.85-2.36.27-4.62.09-7 0v3h-3v2l-2-1 1-4 5-1 1-3 2-1-2-1v-2c7.43 1 7.43 1 11 2v-3l-4-1z"/><path fill="#f2f3f4" d="m1438 1054 11 1c.97 3.88 1.11 7.3 1.06 11.25l-.01 1.97-.05 4.78-5 1-1-17-4 1z"/><path fill="#727578" d="m332.63 1039.86 2.25.01h6.33c1.79.13 1.79.13 4.79 1.13v5h-19l-1-5c1.8-1.8 4.23-1.13 6.63-1.14"/><path fill="#c7c6c5" d="m1161 971-3 1-.94 1.5c-1.45 2.06-2.55 2.08-5 2.5-3.44.65-6.67 1.5-10 2.56-7.82 2.22-15.98 2.2-24.06 2.44v2c-7.43 2.43-7.43 2.43-11 1l7-1v-2l2.04-.4q4.68-.91 9.34-1.85l3.21-.62a83 83 0 0 0 17.19-5.22c5.04-2.06 9.93-3.56 15.22-1.91"/><path fill="#060609" d="M648 970v2l-1.71.18-2.23.26-2.21.24C640 973 640 973 639 974q-2.69.35-5.37.56l-2.97.26q-2.82.19-5.66.18v-1h-25v-1c16.08-2.06 31.76-3.52 48-3"/><path fill="#09080d" d="m1314 892-1.54.55A110 110 0 0 0 1300 898a46 46 0 0 1-14.08 4.28c-1.92.72-1.92.72-3.17 2.43-.9 2.77-.98 5.07-.94 7.98l.02 2.95c.17 2.36.17 2.36 1.17 3.36q.12 2.45.1 4.91l-.01 3-.03 3.15L1283 941h-1c-1.36-12.37-2.32-24.55-2-37a58 58 0 0 1 13.95-6.29c2.05-.71 2.05-.71 4.49-2.21 4.64-2.72 10.33-5.24 15.56-3.5"/><path fill="#a2a2a3" d="M12 819v6l-4 1v12l-6 1v-11l3-1 1-8c3-1 3-1 6 0"/><path fill="#0a0a0d" d="m1324 786-2 4h-7l-.94 2.38C1313 795 1313 795 1312 797c-2 .6-2 .6-4.44 1.06-4.4.86-4.4.86-6.56 1.94v-2l-9 1v-2l2.54-.9 20.96-7.46c7.4-2.64 7.4-2.64 8.5-2.64"/><path fill="#605672" d="m859.13 552.94 2.19.02 1.68.04c-3.92 3.92-10.32 4.07-15.62 4.81-7.3 1.06-14.43 2.73-21.6 4.43-6.38 1.5-12.62 2.94-19.22 2.82l-2.06-.02-1.5-.04v-1l2.43-.52q10.67-2.26 21.32-4.6l2.38-.52 2.23-.5 1.98-.43C835 557 835 557 837 556q1.8-.3 3.63-.5l2.14-.25 4.46-.5 2.14-.25 1.97-.22c2.87-.48 4.74-1.4 7.78-1.34"/><path fill="#0d0f14" d="M1661 511h1q-.09 4.25-.25 8.5l-.04 2.4c-.21 5.16-1.2 8.85-3.76 13.35a24 24 0 0 0-2.01 5.19L1655 543l-3 1q-.55 1.99-1 4-1.61 2.85-3.37 5.63l-1.84 2.9c-1.54 2.12-2.52 3.28-4.79 4.47 1.67-4.79 4.26-8.88 6.88-13.19 7.1-11.95 10.36-23.25 13.12-36.81"/><path fill="#38383b" d="M359 473h1v14h-2l.04 3.15c.14 17.95.14 17.95-1.04 22.85l-2 1c-.62 3.06-.62 3.06-1 6h-1q-.09-2.06-.12-4.12l-.08-2.33c.2-2.55.2-2.55 1.2-5.29 1.29-4.2 1.26-7.96 1.19-12.32v-2.4a45 45 0 0 0-.82-9.44L354 482c1.88-2.56 1.88-2.56 4-5 .75-2.31.75-2.31 1-4"/><path fill="#333137" d="M558.42 165.9h1.87l9.9.05 9.81.05 1 3-2.17.06-8.07.26-5.17.15c-5.53.2-10.69.5-16 2.1-2.22.6-4.32.57-6.59.43l1-2h4l1-3c3.25-1.08 6.04-1.13 9.42-1.1"/><path fill="#202122" d="M809.95 77.9h2l10.58.05L833 78l1 4h-36l1-3c3.46-1.45 7.26-1.13 10.95-1.1"/><path fill="#8d8c94" d="M395 1662h1l.33 2.82c1.42 11.26 1.42 11.26 4.07 16.45 1.29 3.73.81 7.84.6 11.73l-4-1-.4-2.15-1.06-5.56C395 1682 395 1682 394 1681c-.13-1.64-.13-1.64-.13-3.75v-11.48c.13-1.77.13-1.77 1.13-3.77"/><path fill="#5a5962" d="M1276 1625h1l.38 7.06.09 1.93c.19 3.34.46 6.6 1.01 9.9 2.12 12.96 1.75 26 1.66 39.1l-.04 9.84q-.03 9.59-.1 19.17h-1l-.02-1.96a5880 5880 0 0 0-.4-27.87l-.14-9.14-.06-5.16c-.1-5.73-.67-10.42-2.38-15.87-.23-3.27-.23-3.27-.2-6.59l.01-1.79q.02-2.77.07-5.56l.02-3.8q.03-4.63.1-9.26"/><path fill="#090c13" d="m1508 1296 2 1v37l-3-1-.08-20.54-.02-7.49-.01-2.37q.01-2.8.11-5.6z"/><path fill="#d36f3f" d="M1345 1283c1.18 3.42.82 5.33-.4 8.7l-.95 2.73-1.03 2.82c-3.33 9.27-4.8 17.24-4.74 27.06v2.23c.05 5.86.42 11.63 1.12 17.46-5.04-5.04-3.33-15.54-3.37-22.37l-.02-2.04c-.03-13.23 2.2-23.8 8.39-35.59z"/><path fill="#5c5c65" d="M393 1187h1l.08 18.25.02 6.65.01 2.1q-.01 2.5-.11 5l-1 1q-.59 4.02-1 8.06l-.26 2.5c-1.06 10.79-1.4 21.61-1.74 32.44h-1l-.08-20.4-.02-7.43-.01-2.34v-2.18l-.01-1.92c.12-1.73.12-1.73 1.12-4.73q.3-3.56.5-7.12l.12-2.1.76-13.29.23-4.12c.2-3.54.6-6.92 1.39-10.37"/><path fill="#9ba8b0" d="m1565 1052 4 1v9h2l1 8.69.29 2.47c.45 3.97.84 7.84.71 11.84-2-2-2-2-2.2-3.73l.2-5.27h-2l-1 2v-10h-2c-1.57-5.73-2.24-10.16-1-16"/><path fill="#37373c" d="M653 938h29l-2 4c-8.09 2.7-17.54 2.17-26 2v-1l11-1v-2h-12z"/><path fill="#7c7c7f" d="m863.44 911.88 2.06.05 1.5.07c-6.4 3.33-12.08 5.46-19.2 6.42l-7.06 1q-3.64.5-7.29 1.04l-4.65.65-2.19.32c-3.46.46-6.25.8-9.61-.43l1.83-.37 2.42-.5 2.4-.5c2.35-.63 2.35-.63 4.19-1.62 2.43-1.14 4.46-1.47 7.12-1.8l2.87-.35 2.98-.36c10.93-1.32 10.93-1.32 15.11-2.59 2.72-.8 4.71-1.13 7.52-1.03"/><path fill="#2f2e32" d="M543 832c20.45-.22 40.64 1.2 61 3v1c-20.58.36-40.6-.1-61-3z"/><path fill="#444248" d="M148 818h1v34h-3c-2.21-7.67-1-16.18-.62-24.06l.09-2.28.12-2.13.1-1.92C146 820 146 820 148 818"/><path fill="#1c1b1f" d="m1009 783-3.31 1.5-1.87.84c-1.82.66-1.82.66-4.82.66v2c-12.49 3.44-12.49 3.44-19 3v2a57 57 0 0 1-13.25 3.06c-6.5.82-6.5.82-8.75 1.94q-2.06.1-4.12.06l-2.2-.02L950 798v-1l5.94-1.37 5.15-1.2q1.96-.44 3.94-.86c6.4-1.36 12.64-3.15 18.9-5l3.1-.92 3.06-.9 3-.87 5.79-1.8 2.69-.87 2.37-.78c2.06-.43 2.06-.43 5.06.57"/><path fill="#bd96ec" d="m380 703 2.19 2.19 1.38 1.37q1.38 1.38 2.7 2.82c2.72 2.9 5.02 4.28 8.73 5.62l1 2h3l-1 3c-1.94-.37-1.94-.37-4-1l-1-2-3-1-1 3c-7.12-2.22-7.12-2.22-9-4-1.12-2.5-1.12-2.5-2-5l-1-2h3z"/><path fill="#4a2b7f" d="m1020.16 681.8 2.46.07 2.48.06 1.9.07-1 4h-2v2c-6.7 3.69-13.46 5.06-21 6 .68-1.96.68-1.96 2-4 2.35-.54 2.35-.54 5.06-.63 2.73-.1 2.73-.1 4.94-.37l1-2h-12v-1l2.55-.37 3.32-.5 3.31-.5c3.1-.7 3.96-2.55 6.98-2.83"/><path fill="#05060d" d="M549 575a17651 17651 0 0 1 53.39-.15 5394 5394 0 0 1 22.57-.06q4.36-.03 8.73-.02l2.6-.02c4.68.02 8.36.48 12.71 2.25v1h-10v-1l-90-1z"/><path fill="#6d6c71" d="M415 452h1c-.37 13.4-1.15 26.57-5.03 39.48a70 70 0 0 0-2.1 10.96l-.26 1.93L408 509h-1c.36-12.59 1.46-24.66 4-37h2l-.07-2.34c-.1-6.3.22-11.64 2.07-17.66"/><path fill="#010203" d="M398 1772q.9.45 1.81.94A29 29 0 0 0 405 1775l1 3c2.33 2.64 3.62 3.87 7 5l1 3 3 1 1 2 2.56 1.5 2.44 1.5v2c-3 .47-5.13.63-7.67-1.2a108 108 0 0 1-5.02-5.24l-3.38-3.62-1.5-1.63C404 1781 404 1781 401 1780c-3-5.22-3-5.22-3-8"/><path fill="#303039" d="m642 1525 2 1c-3.81 3.5-7.04 6.3-12 8l-2 2-2.19.81c-4.49 1.9-8.3 4.68-11.75 8.13-5.39 5.39-10.45 8.89-18.06 10.06 2.3-3.62 4.1-4.44 8-6 3.71-2.53 7.2-5.35 10.69-8.19a65 65 0 0 1 9.15-5.81l1.91-1.06 1.8-.98C631 1532 631 1532 632 1530c2.21-1.32 2.21-1.32 4.94-2.69l2.71-1.38z"/><path fill="#1c1613" d="M1151 1203v1l-2.5.17-18.98 1.3-3.5.23c-2.47.25-4.63.68-7.02 1.3q-2.11.18-4.24.25l-2.46.1c-.43 0-.43 0-2.61.09-7 .3-13.85.87-20.78 1.9-4.36.63-8.5.84-12.91.66 7.29-4.08 14.34-4.31 22.52-4.46 8.56-.17 17.06-.4 25.54-1.6 8.97-1.26 17.9-1.13 26.94-.94"/><path fill="#373641" d="M507 1187c6.5.28 11.84 1.53 17.88 3.89 9.36 3.34 19.36 5.33 29.12 7.11-2.9.86-5.2 1.06-8.2.77l-2.4-.24-2.52-.28-2.6-.28c-8.77-1.04-16.3-3.35-24.3-7.05-2.3-1.06-4.62-2-6.98-2.92z"/><path fill="#22212a" d="M1180 1110c-3.62 3-3.62 3-7 3v2c-2.86 2.74-6.08 3.89-9.74 5.19q-3.54 1.3-7.01 2.75c-9.42 3.75-18.04 6.66-28.25 7.06 2.1-2.1 2.68-2.34 5.44-2.94l2.15-.5 2.41-.56c6.88-1.69 13.81-3.44 20-7l1-2c2.29-.63 2.29-.63 5.06-1.12l2.79-.51 2.15-.37 1-3c1.78-.98 1.78-.98 3.94-1.75l2.15-.8c1.91-.45 1.91-.45 3.91.55"/><path fill="#7e7b87" d="M1316 1083h1c1.16 8.71 1.12 17.36 1.06 26.13l-.01 4.34-.05 10.53h-1l-1 8h-1l-.08-21.58-.02-7.91-.01-2.43c0-5.75.45-11.37 1.11-17.08"/><path fill="#26262f" d="M829.98 1051.59c1.77-.21 1.77-.21 5.02.41 4.2 3.6 7.41 8.13 10 13-3.3-1.1-3.96-1.88-6.06-4.5-2.74-3.4-2.74-3.4-4.94-4.5v2c-3-1-3-1-4-2q-2.09-.04-4.16.09l-5.46.29-2.87.15q-2.88.14-5.77.32-3.67.2-7.36.37l-4 .22-1.85.08c-3.04.18-5.19.43-7.53 2.48.68-1.95.68-1.95 2-4 2.26-.6 2.26-.6 5.06-.76l3.11-.2 3.27-.16c8.7-.51 17.02-1.52 25.54-3.3"/><path fill="#a6a6a6" d="M1061 1031q3.9-.05 7.81-.06l2.24-.03h2.16l1.98-.02c1.81.11 1.81.11 4.81 1.11v2l60 1v1q-10.87.1-21.75.16-5.05.01-10.11.07c-39.42.39-39.42.39-47.14-5.23"/><path fill="#2d2c34" d="M1276 1004c1.18 3.42.8 5.37-.37 8.75a32 32 0 0 0-1.88 9.5c-.23 2.5-.47 3.46-2.22 5.3q-1.86 1.46-3.75 2.88c-2.51 2.21-3.63 4.7-4.96 7.74-.79 1.76-1.7 3.26-2.82 4.83-.47-4.79.47-7.29 2.92-11.21 1.37-2.28 2.35-4.64 3.36-7.1 3.3-7.78 3.3-7.78 5.72-10.69h2l.18-1.93.26-2.5.24-2.5c.32-2.07.32-2.07 1.32-3.07"/><path fill="#8a8b8d" d="m768.13 936.94 2 .01 4.87.05c-4.32 2.95-6.8 3.1-12 3v2q3.56-.17 7.13-.37l2.01-.1c4.29-.25 7.77-1.15 11.86-2.53 3-.2 3-.2 5.88-.12l2.92.05 2.2.07v1a97 97 0 0 1-20.31 4.19l-2.32.25c-4.15.42-8.2.65-12.37.56l1-3h-4c2.53-5.61 5.4-5.14 11.13-5.06"/><path fill="#454349" d="M162 875h4v2l2.44.88C171 879 171 879 172 881l2-1v2h2l1 3 5 2a90 90 0 0 1 4 4l2 1v2c-3.9-.46-6.3-1.67-9.37-3.96-1.63-1.04-1.63-1.04-3.99-1.86-3.36-1.5-5.42-3.56-7.95-6.18l-1.38-1.37C162 877.27 162 877.27 162 875"/><path fill="#888585" d="M1493 691c-1.72 5.16-7.5 7.3-12 10l-1 1c-2.34.14-4.66.04-7 0v4h-5c-.31-1.81-.31-1.81 0-4 2.18-1.45 2.18-1.45 5-3l2.72-1.54 2.78-1.52 2.78-1.54c7.24-3.73 7.24-3.73 11.72-3.4"/><path fill="#0d0d13" d="m1405 660 2 1-2.19 1.69c-3.64 2.9-6.8 6.24-10 9.62C1393 674 1393 674 1391 674l-1 3-2.44 1.81c-3.1 2.66-3.72 4.22-4.56 8.19a81 81 0 0 0 0 8c16.15.38 16.15.38 24-2-4.93 3.78-7.74 4.2-13.81 4.25l-2.16.06c-4.45.04-7.51-.28-11.03-3.31-1.08-3.06-.7-5.86 0-9 3.44-7.02 10.2-12 16-17l5.13-4.56z"/><path fill="#010103" d="m480 574 13.48.56c5.56.24 11 .66 16.52 1.44v4c-6.6.25-12.95-.11-19.5-.94l-2.1-.26a44 44 0 0 1-8.4-1.8z"/><path fill="#0f1016" d="M1401 437c5 6.36 5 6.36 5 11h2l2.25 4.4c.75 1.6.75 1.6 1.75 4.6h2c2.4 4.59 4.51 9.04 6 14h2l2.5 4.75 1.4 2.67a11.6 11.6 0 0 1 1.1 6.58h-2l-.67-1.39-4.13-8.48A54 54 0 0 0 1413 464c-.87-1.77-.87-1.77-1.44-3.37a29 29 0 0 0-4.06-7.13 33 33 0 0 1-6.5-16.5"/><path fill="#27282c" d="m1270.31 1822.88 2.68.05 2.01.07c-1.78 1.26-2.97 2-5.12 2.5l-1.88.5-1 2h2v2h-3v-3h-75v-1l2.59-.02a9620 9620 0 0 0 36.55-.4 2992 2992 0 0 0 16.55-.18l6.4-.08h1.9c5.5-.11 9.84-2.6 15.32-2.44"/><path fill="#5a5862" d="M1235 1630c-2.24 5-5.15 7.46-10 10-2.81.69-2.81.69-5 1l1-3-1.8.66c-8.78 2.81-17.08 2.63-26.2 2.34v-1l2.21-.04a89 89 0 0 0 20.1-3.27l2.24-.61a60 60 0 0 0 12.71-5.3c1.74-.78 1.74-.78 4.74-.78"/><path fill="#202129" d="M1116 1165c-1.53 1.53-3.1 1.32-5.23 1.54l-2.63.26-2.77.26-2.7.28c-4.91.48-9.73.76-14.67.66v2a184 184 0 0 1-32 2v-1l2.4-.35 18.2-2.68 3.35-.49q2.82-.44 5.6-1c4.78-.93 9.66-1.2 14.51-1.6l3.3-.3 3.18-.28 2.88-.25c2.43-.05 4.27.23 6.58.95"/><path fill="#4f4e53" d="m217 725 2 1-2.23 2.34c-9.53 10.14-9.53 10.14-12.87 16.33L203 746h-2l-1 3h-7c1.82-4.5 4.95-6.38 8.8-9.12 2.2-1.88 2.2-1.88 3.66-4.33 1.84-3.05 3.92-4.7 6.73-6.86l2.73-2.12z"/><path fill="#6f6f70" d="M1550 653v3h-3l-1 3-5 1-1 3h-2l-.56 1.81c-2.58 3.93-6.97 4.84-11.38 5.88l-2.06.31v-3a97 97 0 0 1 5-3q1.5-1.23 2.94-2.5c2.8-2.4 5.72-3.9 9.06-5.5q1.6-1.07 3.19-2.19C1547 653 1547 653 1550 653"/><path fill="#010107" d="M327 644h2q-.17 2.16-.37 4.31l-.22 2.43C328 653 328 653 327 654.52c-1.66 2.5-1.4 5.3-1.55 8.23-.44 2.25-.44 2.25-1.93 4.05-2.2 3.2-2.07 6.16-2.2 9.95l-.1 2.11L321 684h-1l-1-10h-1c.47-6.66 1.9-12.67 4-19h2z"/><path fill="#797a7c" d="M346 1675c4.7 5.88 6.34 8.37 6.13 15.75l-.03 1.82-.1 4.43c-3 1-3 1-6 0z"/><path fill="#39261a" d="M947 1269c5.52.84 10.22 1.78 14.95 4.8 3.31 1.94 6.84 3.44 10.34 5a89 89 0 0 1 9.71 5.2q1.89.9 3.81 1.69L989 1287v2l2.88.88C995 1291 995 1291 997 1293c-8.75-.62-8.75-.62-12-4v-2l-2.12-.31c-4.25-1.02-7.78-2.59-10.88-5.69q-2.05-.66-4.1-1.3c-2.35-.87-4.5-1.99-6.71-3.14A81 81 0 0 0 947 1271z"/><path fill="#3b3943" d="m706 1166 2 1c-1.68 4.15-1.68 4.15-3 6-2.56.63-2.56.63-5 1a89 89 0 0 0-2 4q-1.99.55-4 1c-1.25 1.56-1.25 1.56-2 3l-7-1-1-3 1.93-.84 2.5-1.1 2.5-1.09C693 1174 693 1174 694 1173q2.5-.06 5 0v-2c1.53-1.29 1.53-1.29 3.5-2.62l1.97-1.36z"/><path fill="#252529" d="m614.8 969.9 2.15.01 2.24.03 7.81.06v1l-14.48 1.64c-7.55.86-15.07 1.6-22.65 2.1-2.81.25-5.49.73-8.25 1.32-9.71 1.78-19.78 1.1-29.62.94v-1c10.34-1.16 20.61-1.1 31-1v-2l2.25-.17 16.99-1.3 3.12-.23c3.3-.37 6.08-1.42 9.43-1.4"/><path fill="#909091" d="m711.75 942.94 7.25.06c-2.71 2.71-5.61 2.94-9.25 3.63l-1.99.4c-3.97.78-7.69 1.1-11.76.97v-1c-6.2-.2-11.71 0-17.72 1.55-4.07.8-8.16.6-12.28.45l1-2c2.36-.47 4.62-.8 7-1.06l2.14-.25c6.08-.68 12.17-1.08 18.27-1.35 5.22-.24 5.22-.24 7.59-.86 3.22-.77 6.45-.58 9.75-.54"/><path fill="#f9fafa" d="M1461 881h1v25l-5-1q-.08-3.81-.12-7.62l-.06-2.17c-.04-4.52.39-8.03 2.18-12.21z"/><path fill="#aeadb0" d="m1447 370 34 1v3h-35z"/><path fill="#6b6e6e" d="M376 351q.12 3.34.19 6.69l.07 1.9c.07 4.25-.46 7-3.26 10.41h-3v-19c3-1 3-1 6 0"/><path fill="#878789" d="M534 166h2c-.42 4.76-2.6 7.15-5.94 10.31l-1.38 1.38c-3.4 3.31-3.4 3.31-5.68 3.31l-.81 1.81A15 15 0 0 1 518 188h-3c.57-3.88 2.43-6.12 5-9l2-1 1-3 3-1 1-3a99 99 0 0 1 5-3z"/><path fill="#ebedec" d="m1194 26 4 1v3h-5c2.23 4.12 4.85 7.1 8.19 10.31l1.4 1.38L1206 45c-3.73 0-5.86-1.49-8.94-3.37-3.3-2-6.58-3.97-10.06-5.63v-5l2.38-.81c2.59-1.17 3.38-1.72 4.62-4.19"/><path fill="#e54508" d="M1349 1286h1c.48 5.21.24 8.33-2.99 12.5-1.42 2.1-1.9 4.23-2.42 6.7-.59 1.8-.59 1.8-2.1 4.05-1.67 3.08-1.9 5-2 8.49l-.1 3.23-.08 3.34-.1 3.4-.21 8.29h-1v-27h2l-.14-1.8c-.26-5.64.28-7.9 4.14-12.2a75 75 0 0 0 2.39-6.33c.61-1.67.61-1.67 1.61-2.67"/><path fill="#917c6b" d="M1049 1222c.63 1.88.63 1.88 1 4l-2 2c-.9 2.22-1.72 4.42-2.5 6.69l-.64 1.82A197 197 0 0 0 1041 1249h-7v-2h7v-13l-2 2a43 43 0 0 1 3-10h-7v-1l3.81-.06 2.15-.04c2.04.1 2.04.1 5.04 1.1z"/><path fill="#000001" d="M1023 970v3l1.75-1.06c2.6-1.09 3.64-.87 6.25.06-5.19 2.54-10.04 3.68-15.74 4.57-2.26.43-2.26.43-3.26 1.43q-2.02.1-4.06.06l-2.23-.02-1.71-.04 3-1v-3l-2-1c4.21-4.21 12.31-3.12 18-3"/><path fill="#919092" d="M594 958c10.06-.1 19.99-.1 30 1l-1 3h-32l4-2z"/><path fill="#888889" d="M747 936v1l-1.7.24-7.61 1.13-2.67.39-2.57.39-2.37.35A10.4 10.4 0 0 0 725 942v2l-8 1v-1l-8.03-.09A32 32 0 0 0 700 945q-2.12.1-4.25.06L692 945a25 25 0 0 1 8.17-2.87l5.1-.86 2.73-.46 5.75-.95q4.37-.72 8.72-1.47l8.2-1.37A84 84 0 0 1 747 936"/><path fill="#bababc" d="m1466 868 4 2v28l-3 1v3h-2z"/><path fill="#a7a6a8" d="M170 734v2h2l1-2-1 4h-5v4h-5v4h-4l-1 3h-6c.33-3.04.95-3.96 3.25-6.06 2.53-1.78 5.1-3.35 7.75-4.94l2.63-2.19C167 734 167 734 170 734"/><path fill="#111117" d="M1543 651v3h-6v3h-4l-.12 1.75c-1.3 3.34-3.74 4.8-6.88 6.25-2.37.57-4.55.8-7 1v-3h2v-2h2l1-3a67 67 0 0 1 8.69-4.06c2.31-.94 2.31-.94 3.81-2.07 2.16-1.26 4.05-1 6.5-.87"/><path fill="#2c1b4f" d="M1128 463h6c-1.4 2.79-3.16 2.9-6 4l-2 2q-1.42.46-2.87.94c-3.13 1.06-3.13 1.06-5.07 2.69-3.06 2.04-5.43 1.58-9.06 1.37l-2 4-4-1v-3l2.38-.25 2.62-.75.81-1.94c1.19-2.06 1.19-2.06 2.84-2.7q3.15-.73 6.35-1.36 1.91-.54 3.81-1.12c3.19-.88 3.19-.88 6.19-.88z"/><path fill="#19112d" d="m1250 392 2 1c-2.4 2.65-4.93 4.2-8 6q-2.32 1.49-4.62 3l-2.3 1.5a39 39 0 0 0-5.08 4.5c-3.79 2.64-7.88 4.38-12.19 6a20.7 20.7 0 0 0-8.81 6c-2.75.75-2.75.75-5 1 2.8-4.21 5.41-5.88 9.98-7.96 2.01-1.04 3.47-2.16 5.14-3.67 2.75-2.43 5.35-3.32 8.88-4.37 4.87-2.63 9.4-5.91 14-9l3.56-2.37z"/><path fill="#17181b" d="M434 320c.63 1.75.63 1.75 1 4l-.95 1.67c-1.48 3.27-1.43 6.33-1.55 9.9A115 115 0 0 1 430 355h-2l-.15 2.56c-.42 6.35-1.05 12.3-2.85 18.44h-1c-.12-6.4.35-12.63 1-19l1-10h3l.14-1.83q.31-4.15.67-8.3l.22-2.88c.46-5.36 1.14-9.38 3.97-13.99"/><path fill="#09090d" d="M759 94h35l-1 3h-41v-1h7z"/><path fill="#acadae" d="M1319 1812h6c1 3 1 3 0 6h-8l-1 4h-10c1-4 1-4 3-6 1.95-.2 1.95-.2 4.13-.12l2.19.05 1.68.07z"/><path fill="#775f4b" d="M1084 1212v1l-5.18.49c-2.39.67-3.27 1.58-4.82 3.51a44 44 0 0 0-5 19h-2c-1.41 6.75-2.3 13.1-2 20h7v1q-1.96.05-3.94.06l-2.21.04c-1.85-.1-1.85-.1-2.85-1.1-.9-15.18 1.9-29.84 10.75-42.5 3.32-2.21 6.38-1.84 10.25-1.5"/><path fill="#929194" d="M1408 984h1l.03 2.46q.06 4.6.18 9.22.05 1.97.06 3.95c.14 11.92 1.67 22.9 8.78 32.88.95 1.49.95 1.49.95 3.49h2q1.6 2.95 3 6l-1 2c-3-3.75-3-3.75-3-6l-1.84-.68c-4.22-2.58-5.36-7.9-7.16-12.32l-.95-2.05c-4.92-11.22-4.26-27.07-2.05-38.95"/><path fill="#050408" d="m593.19 973.94 3.29.02 2.52.04v1l-2.34.33-3.03.48-3.03.46c-2.95.83-3.1 1.26-4.6 3.73-3.05 1.16-6.01 1.1-9.25 1.06l-2.7-.02L572 981v-2l-3.19.06A36 36 0 0 1 560 978v-1l2.16-.17 13.05-1.03 3.25-.26 3-.24c11.64-1.36 11.64-1.36 11.73-1.36"/><path fill="#8857c7" d="M455 613h1c.13 9.55-.05 19.08-.44 28.63l-.12 3.12-.12 2.98-.11 2.64c-.23 2.92-.72 5.74-1.21 8.63a272 272 0 0 0-.54 6.05l-.26 3.36-.26 3.47L452 684h-1c-.58-23.7 1.81-47.43 4-71"/><path fill="#a79cc0" d="M607 594h35l-3 1-1 2h-39v-1l8-1z"/><path fill="#a297b9" d="m662.65 589.76 1.97.01h2.25l2.42.03 2.48.01q3.93.02 7.85.06l5.33.03q6.53.03 13.05.1c-1.07 1.48-1.07 1.48-3 3-2.39.27-4.51.37-6.9.33h-2.03l-4.27-.05q-3.24-.04-6.49-.05l-4.15-.03-1.94-.01A46 46 0 0 1 659 592c2-2 2-2 3.65-2.24"/><path fill="#593792" d="m781 586-2 2 12 1v1l-3.18.15-4.13.23-2.1.09-2.02.12-1.85.1c-1.72.31-1.72.31-3.15 1.32-2.15 1.35-3.72 1.22-6.24 1.19l-2.7-.02-2.82-.05-2.85-.03-6.96-.1c2.8-2 5.32-2.7 8.69-3.5l3.23-.77L768 588l4.44-1.19c2.9-.73 5.54-1 8.56-.81"/><path fill="#aba3ba" d="m442 574 8.25 1.38 2.36.38c7.11 1.21 7.11 1.21 10.39 3.24v5h-6v1h-14l-1-4-1-3h2l1 2h15c-3.9-1.95-6-2.35-10.25-2.62l-3.27-.23L443 577z"/><path fill="#28272e" d="m973 466 2 1c.31 2.81.31 2.81 0 6-4.2 3.34-9.2 4.27-14.31 5.5a143 143 0 0 0-20.12 6.48l-1.88.77-1.93.8C935 487 935 487 932 486c3.73-2.73 6.61-4.25 11.13-5.46a60 60 0 0 0 5.3-1.83c6.64-2.59 12.3-4.57 19.57-3.71v-2l5-1z"/><path fill="#2f2d33" d="M884 418c-4.22 3.22-6.5 4.52-12 4v2l5 1v1h-11l2-1v-2l-2.34.29C853.2 424.6 853.2 424.6 848 422c-1.21-1.58-1.21-1.58-2-3l3 1c2.83.23 5.66.19 8.5.19h2.44c7.48-.07 17.44-4.18 24.06-2.19"/><path fill="#000001" d="M677 82h31v4c-24.17.28-24.17.28-31-2z"/><path fill="#a2a4a7" d="M406 1789c2.19-.31 2.19-.31 5 0a75 75 0 0 1 3.73 3.82c1.88 1.75 3.77 1.97 6.27 2.18v6l-2.37.81c-2.6 1.17-3.4 1.72-4.63 4.19l-4-1v-3h5c-2.08-3.93-4.56-6.69-7.8-9.67-1.2-1.33-1.2-1.33-1.2-3.33"/><path fill="#302f39" d="m820 1408 2 1a379 379 0 0 1-18.34 12.31 303 303 0 0 0-7.22 4.75c-3.17 2.14-6.4 4.12-9.69 6.07a57 57 0 0 0-7.75 5.24c-2 1.63-2 1.63-4 1.63l-.75 1.94c-1.25 2.06-1.25 2.06-3.37 2.81l-1.88.25c3.08-6 7.55-8.52 13.24-11.92 3.43-2.1 6.77-4.32 10.13-6.52q9.31-6.05 18.8-11.8c3-1.86 5.91-3.8 8.83-5.76"/><path fill="#6c6b6f" d="M548 975v1c-21.37 1.86-42.56 2.25-64 2v-1a698 698 0 0 1 64-2"/><path fill="#a4a3a5" d="m213 947 8.2-.09c4.57-.02 4.57-.02 6.8 1.09v2l15 1v4c-6.68.51-11.37-.3-17.46-3.04-4.08-1.54-8.21-1.78-12.54-1.96z"/><path fill="#b187e0" d="M461 633h1c.87 4.32 1.02 8.1.66 12.5l-.14 1.78q-.22 2.8-.46 5.6l-.3 3.82-.76 9.3h-1l-1-14-1 17h-1c-.3-29.93-.3-29.93 4-36"/><path fill="#c6a8eb" d="M392 570h1q.09 4.19.13 8.38l.05 2.4q0 1.13.02 2.3l.03 2.13C393 587 393 587 391 589a100 100 0 0 0-1.12 7.13q-.14.97-.27 2L389 603h-3c-.22-6.08-.08-11.25 2-17l1-1c.29-3.04.45-6.07.62-9.12.35-2.66.6-3.93 2.38-5.88"/><path fill="#141418" d="M348 542h1c.2 5.27.2 5.27 0 7l-2 2c-.41 2.16-.41 2.16-.62 4.63l-.23 2.47-.15 1.9 4-1-1 4h-3v-2h-4l.04 2.09c.05 6.05-.21 11.91-1.04 17.91h-3v-3l-4-1 1.02-1.18c3.48-4.36 4.71-7.98 5.43-13.49.64-2.72 1.82-4.15 3.55-6.33.75-2.34 1.4-4.62 2-7l1.13-3.94z"/><path fill="#131317" d="M866 146c-3.06 2.17-6.36 3.33-9.87 4.56l-1.86.67c-3.4 1.2-6.7 2.17-10.27 2.77v2c-5.98 2.4-11.47 4.3-17.92 4.79-3.17.32-6.05 1.24-9.08 2.21 2.3-2.65 4.12-3.57 7.5-4.46q1.28-.38 2.63-.74l5.47-1.46c5.1-1.43 8.27-2.93 12.4-6.34a43 43 0 0 1 7.19-1.69c12.62-2.34 12.62-2.34 13.81-2.31"/><path fill="#dfdedd" d="M1161 1023h11v2c-3.6 1.2-6.23 1.07-10 1v4c-4 .96-7.7 1.1-11.81 1.06l-1.81-.01-4.38-.05c1.09-1.93 1.09-1.93 3-4 2.3-.56 4.29-.92 6.63-1.12 2.15-.22 4.23-.5 6.37-.88z"/><path fill="#727274" d="M315 970a5933 5933 0 0 1 25.61 1.11c8.41.37 16.7.95 25.03 2.15 6.1.89 12.2 1.35 18.36 1.74v1c-23.07.65-46.19-1.81-69-5z"/><path fill="#6d6d71" d="M804 923v1l-5.19 1.5-2.92.84c-2.74.63-5.09.78-7.89.66v2a2450 2450 0 0 1-14.29 2.3c-6.6 1.06-13 1.97-19.71 1.7v-1l4.79-.49a26 26 0 0 0 5.44-1.99c3.97-1.76 7.73-2.17 12.02-2.52a87 87 0 0 0 17.37-3.4c3.54-.9 6.74-.8 10.38-.6"/><path fill="#48464c" d="M190 895c2.38-.12 2.38-.12 5 0l2 2c1.95.41 1.95.41 4.13.63l2.19.22 1.68.15 1 5 2-1q3-.06 6 0l1 4c-3.97 1.59-7.15.28-11-1l-3.25-.87c-3.12-1.28-3.8-2.5-5.75-5.13-2.69-.75-2.69-.75-5-1z"/><path fill="#848484" d="M1154 835v2c-3.17 2.51-6.28 3.4-10.19 4.38a46 46 0 0 0-11.06 4.3c-4.24 2.27-7 2.84-11.75 2.32l-1-3 1.57-.41 7.12-1.9 2.47-.65c4.41-1.2 7.99-2.5 11.84-5.04 3.58-1.4 7.13-2.3 11-2"/><path fill="#010104" d="M1438 730h5v3l-2.81 1.13c-4.42 1.86-8.5 4.26-12.64 6.66-2.49 1.18-3.85 1.5-6.55 1.21v3h-6c2.19-5.76 7.7-7.57 13-10 3.3-1.17 6.6-2.16 10-3z"/><path fill="#6b6b6e" d="m221 720 2 1-8 7-1.8 1.58c-3.97 3.42-3.97 3.42-6.2 3.42l-.67 2.22c-1.6 3.34-3.75 5.06-6.64 7.22A58 58 0 0 0 189 753l-3.6 3.56c-2.55 2.62-4.87 5.44-7.21 8.25l-1.52 1.81L173 771c0-3.92 1.81-5.55 4.25-8.37l2.4-2.84 1.19-1.4q1.69-2.03 3.3-4.12c2.06-2.51 4.3-4.27 6.86-6.27a94 94 0 0 0 7.81-8.37c6.38-7.52 14.56-13.44 22.19-19.63"/><path fill="#9b9a9b" d="m210 722 2 1c-.54 3.79-2.12 5.55-5 8h-2l-2 4h-2l-.69 1.75c-1.64 2.82-3.7 4.33-6.31 6.25-.69-2.25-.69-2.25-1-5 1.31-2.31 1.31-2.31 3-4h2l2-4h3l1-3c1.41-1.39 1.41-1.39 3.06-2.69l1.66-1.32z"/><path fill="#d6d6d7" d="M111 694c1.94.38 1.94.38 4 1l1 2q2.46 1.09 5 2c-.33 3.15-.7 4.68-2.9 7.02l-2.35 1.8-2.33 1.82c-2.73 1.53-4.34 1.64-7.42 1.36 1.42-3.7 3.8-5.9 6.66-8.54C114 701 114 701 115 698h-5z"/><path fill="#c3a2e7" d="M395 693c2.5 2.5 2.33 3.61 2.56 7.06.34 4.84.34 4.84 1.44 5.94q.1 2.77.06 5.56l-.02 3.07L399 717h-4l-.31-1.94L394 713l-3-1q-.33-2.2-.62-4.37l-.36-2.47L390 703l2-2 1 8c1.46-2.91 1.37-5.93 1.56-9.12l.13-2z"/><path fill="#5d3f82" d="M364 669h1l.03 2.57q.06 4.74.18 9.49.05 2.05.06 4.1.04 2.96.12 5.91v1.83c.21 6.09 3.22 9.85 7.3 14.17l1.43 1.53 1.44 1.52c3 3.19 5.9 6.3 8.44 9.88a21 21 0 0 1-7.31-5.25 93 93 0 0 0-9.2-8.3c-4.38-4.3-4.52-10.12-4.62-15.95v-7.42c0-4.8.33-9.35 1.13-14.08"/><path fill="#c290f0" d="M381 612h1v32l-1-3h-3c-2.33-7-.82-14.03 1-21l1.13-4.56z"/><path fill="#412e6d" d="M1248 404c2.06.44 2.06.44 4 1l-1.69 1.07A113 113 0 0 0 1235 418l3 2c-4.51 3.82-9.1 5.35-15 5l-1 3-3-1c2.2-3.49 4.05-4.81 8-6h3l.13-1.75c1.1-2.85 2.77-3.64 5.33-5.18 1.54-1.07 1.54-1.07 3.54-3.63 2.46-3 4.49-3.88 8-5.44z"/><path fill="#818088" d="M422 1592h1l1 11 2-4v24h-1l-1-9-1 37h-1z"/><path fill="#2c1d12" d="M1138 1348c14.59 1.59 14.59 1.59 18 5q2.52.59 5.06 1.06l2.79.54 2.15.4v2l3.4.8 6.65 1.6 2.16.53 1.98.48c1.87.61 3.23 1.42 4.81 2.59-3.78.3-6.5-.07-10.06-1.37-3.8-1.4-7.36-1.98-11.38-2.44-5.72-.79-9.44-2.54-14.2-5.73-3.55-2.2-7.34-3.35-11.36-4.46z"/><path fill="#7a1402" d="M1405 1308h1l1 42h-2l1 7-1-4h-2c-3.31-8.45-3.36-16.22-3.19-25.12l.04-4.06q.06-4.91.15-9.82h1l1 33h2z"/><path fill="#020202" d="M1389 1222c5.13-.1 9.96-.03 15 1v3l3.31-.06c2.66-.05 4.07.19 6.69 1.06v3c-5.38.33-9.33.15-14.16-2.27-3.45-1.37-7.16-1.49-10.84-1.73z"/><path fill="#9f7f64" d="m1086.75 1213.75 2.4.05q2.92.08 5.85.2c-.78 1.47-.78 1.47-2 3-1.6.43-1.6.43-3.48.51l-2.03.1-2.12.08-2.14.1q-2.6.12-5.23.21l-.87 2.2a226 226 0 0 1-3.26 7.61l-1.07 2.4-.8 1.79h-2c-.46-5.02-.32-8.46 2.73-12.58l1.27-1.42 1.6-2.16c3.58-2.74 6.77-2.28 11.15-2.09"/><path fill="#191921" d="M835 1051v1c-13.94 2.64-27.85 4-42 5a787 787 0 0 0-5.5 14.88l-.6 1.71c-1.14 3.06-1.94 4.91-4.9 6.41-1.19 1.63-1.19 1.63-2 3l1-5 3-1c.66-1.82.66-1.82 1.06-4.12a90 90 0 0 1 2-8.7c.94-3.18.94-3.18 1.38-5.5.73-2.19 1.56-2.57 3.56-3.68 2.57-.45 5.08-.8 7.67-1.07l2.28-.26 4.79-.53q3.63-.4 7.26-.82l4.67-.52 2.16-.24c4.75-.5 9.4-.63 14.17-.56"/><path fill="#020203" d="m1081 954 1 4-1.57.11c-4.66.44-7.42 1.39-11.43 3.89-4.3 1.5-7.57 2.38-12 1 1-3 1-3 2.63-4.19 3.24-1.1 5.94-.94 9.37-.81l1-3a42 42 0 0 1 11-1"/><path fill="#abaeb1" d="M15 915h4l.88 2.44C21 920 21 920 23 921l.25 1.81c.9 2.61 1.5 2.8 3.81 4.13 1.64.98 1.64.98 2.94 2.06.38 2.75.38 2.75 0 5-4.1-3.42-8-6.9-11.64-10.8C17 922 17 922 14 921v5l-4-1c.38-1.94.38-1.94 1-4l2-1q1.09-2.46 2-5"/><path fill="#9c9b9c" d="M116 907h12l-1 2 2 1 .81 1.94c1.58 2.74 3.24 3.13 6.19 4.06v3c-4.63-.75-4.63-.75-6.46-1.99C128 916 128 916 124.63 915.2c-4.07-1.08-5.97-2.97-8.63-6.19z"/><path fill="#16151c" d="M1319 893h1c.79 5.05 1.28 10.05 1.56 15.15l.13 2.34c.46 9.38.42 18.75.37 28.13l-.01 5.4L1322 957h-1l-.08-2.06-.48-12.62c-.18-4.56-.36-9.07-.97-13.6-.84-6.89-.57-13.86-.53-20.78l.01-4.36z"/><path fill="#0d0d0e" d="M5 848h1v40h7l1 12h-4l-1-9-6 1-1-2h3z"/><path fill="#010105" d="M70 835h1v28l-4-1q-.12-5.62-.19-11.25l-.07-3.22-.08-5.96c.4-3 1.3-4.37 3.34-6.57"/><path fill="#110724" d="M756 756v1a191 191 0 0 1-39 5v2l-25 1c3.1-2.07 4.48-2.38 8.09-2.82l3.05-.37 3.24-.37 3.3-.4c5.44-.64 10.86-1.2 16.32-1.54 5.53-.35 10.86-1.32 16.27-2.48 4.63-.96 9-1.18 13.73-1.02"/><path fill="#b894e7" d="M454 747q3.38-.12 6.75-.19l1.92-.07c4.88-.08 4.88-.08 7.27 1.73 3.18 2.36 6.44 2.1 10.31 2.22l2.26.1 5.49.21v3q-3.09.08-6.19.13l-3.48.07c-3.3-.2-5.32-.93-8.33-2.2q-2.74-.3-5.5-.44c-4.37-.32-7.12-.76-10.5-3.56z"/><path fill="#1a0e2e" d="M392 727a30 30 0 0 1 10.38 4.94 40 40 0 0 0 10.37 5.28c2.92 1.01 5.77 2.17 8.63 3.34l3.08 1.26C427 743 427 743 429 745h-9v-2l-2.15-.37-2.79-.5-2.77-.5L410 741l-1-2c-2.56-.62-2.56-.62-5-1l-1-4-2.69-.19c-4.27-1.04-5.5-2.5-8.31-5.81z"/><path fill="#3c3b3e" d="M319 720c4.51 4.03 6.92 8.75 9.5 14.13 1.55 2.97 3.38 5.28 5.5 7.87 3 4.5 3 4.5 3 8h-3l-1-4h-3l-.31-1.94L329 742l-3-1c-1.69-2.06-1.69-2.06-3-4h3l-1.28-2.45-1.66-3.24-.84-1.6A30 30 0 0 1 319 720"/><path fill="#020208" d="m714.19 570.88 2.73.05 2.08.07v3h8v1a563 563 0 0 1-27 3l1-2-15-1v-1l1.79-.06q4.01-.15 8.02-.32l2.82-.09 2.7-.12 2.5-.1c4.03-.58 5.91-2.55 10.36-2.43"/><path fill="#4b2e81" d="m975.85 521.9 2.21.04 2.23.02 1.71.04v2c-2.7 1.35-5 1.07-8 1l-.63 1.88c-1.96 3.03-3.91 3.26-7.37 4.12l-3.25.5c-2.75.5-2.75.5-4.75 2.5h-3l-2 4-5-1 1-3c2.05-.7 4.06-1.01 6.19-1.37L957 532l1-3 9-1v-2l2.94-1.44c5.13-2.62 5.13-2.62 5.9-2.66"/><path fill="#bcbdbe" d="M364 435c.37 6.75-.2 11.85-3 18l-1.19 3-.81 2h-1v-23c3-1 3-1 6 0"/><path fill="#322256" d="M1215 420c-1 3-1 3-3.21 4.39l-2.73 1.3-2.71 1.32c-2.35.99-2.35.99-4.35.99l-.87 1.88c-1.6 3-2.75 3.97-5.94 5.18-2.73.8-5.4 1.45-8.19 1.94v-4c6.23-3.1 6.23-3.1 9.44-2.69l1.56.69 1.75-2.75c4.63-6.85 4.63-6.85 9.31-8 2.94-.25 2.94-.25 5.94-.25"/><path fill="#2f2e34" d="M860 316h3a99 99 0 0 1-3.37 12.81l-.68 2.15c-1.5 4.74-3.12 9.41-4.95 14.04h-2l-1 5c-.91-2.08-1.2-3.46-.4-5.61a89 89 0 0 1 1.7-3.41c.7-1.98.7-1.98.58-4.65.16-4.56 1.8-8.07 3.62-12.2l1.01-2.38q1.23-2.88 2.49-5.75"/><path fill="#0f0d14" d="m1021.63 135.88 2.37.12-3 3 1 3-2.44.88C1017 144 1017 144 1016 146l-1.8-.32-2.39-.37-2.35-.38c-3.24.1-4.79 1.27-7.46 3.07a608 608 0 0 1-8.38 4.6q-3.29 1.75-6.62 3.4c2.98-4.74 6.52-6.5 11.56-8.79 2.4-1.2 4.34-2.55 6.44-4.21a27 27 0 0 1 8.69-4.06c3.44-.98 4.5-2.9 7.93-3.06"/><path fill="#5d5d5e" d="M678 66a1651 1651 0 0 1 15.09-.15q2.76-.04 5.52-.05l3.34-.03c3.09.23 5.27.92 8.05 2.23l-1 2h-32z"/><path fill="#535355" d="M1051 969a59 59 0 0 1-12.65 3.92 158 158 0 0 0-15.54 4.02A226 226 0 0 1 990 984c4.47-3.3 9.52-4.44 14.88-5.5 5.42-1.12 10.76-2.26 15.97-4.18 5.8-2.1 11.6-3.26 17.65-4.32l2.02-.35 1.81-.3c5.89-1.21 5.89-1.21 8.67-.35"/><path fill="#353439" d="M1220 807v2l-1.68.52-7.57 2.36-2.64.81-2.56.8-2.35.73a52 52 0 0 0-6.2 2.78c-1.93.34-1.93.34-3.87.5A41 41 0 0 0 1183 820h-2l-1 3h-6l3-1c.69-2.06.69-2.06 1-4l1.93-.15 2.5-.23 2.5-.2 2.07-.42 1-2c2.04-.56 2.04-.56 4.63-1 7.3-1.34 13.89-3.5 20.82-6.2 2.37-.74 4.09-.97 6.55-.8"/><path fill="#0d0d11" d="M813 804v1l-2.43.34-12.93 1.8c-12.17 1.68-12.17 1.68-17.36 2.95-5.07 1.2-10.15 1.36-15.34 1.53l-2.91.12q-3.52.14-7.03.26v-1l2.34-.37c9.54-1.53 9.54-1.53 13.6-2.7 4.44-1.17 8.91-1.35 13.49-1.64 3.78-.27 7.5-.8 11.26-1.35A93 93 0 0 1 813 804"/><path fill="#020103" d="M1548 402h16l1 2c2.5.63 2.5.63 5.56 1.13l3.07.5 2.37.37-1 4q-2.72-.17-5.44-.37l-3.06-.22-2.5-.41-1-2c-2.54-.48-5-.84-7.56-1.12l-2.16-.27-5.28-.61z"/><path fill="#5c5b5f" d="m624 166 1.25 1.2A32.3 32.3 0 0 0 639 175v2l11.16 2.4c4.12.9 7.98 1.86 11.84 3.6v1c-4.92.22-8.44 0-13-2-2.2-.55-4.42-1.02-6.64-1.5A275 275 0 0 1 632 178v-2h-2a65 65 0 0 1-6-10"/><path fill="#000002" d="M1258 145c1.81.13 1.81.13 4 1 1.69 3 1.69 3 3 6l1 1q.1 2.54.06 5.06l-.02 2.79-.04 2.15h4v8l-4-1-.37-2.94-.63-3.06-2-1c-.41-2.29-.41-2.29-.62-5.06l-.23-2.79-.15-2.15-3-1z"/><path fill="#434346" d="M1145.13 5.94 1149 6l2 4h2l1 5 7 1-1 2-4.37-.37-2.47-.22C1151 17 1151 17 1149 15a101 101 0 0 0-5.12-2.12l-2.76-1.08-2.12-.8V7c2.29-1.14 3.6-1.1 6.13-1.06"/><path fill="#dadbdb" d="M479 1834h32l-1 4-31-1z"/><path fill="#09080f" d="M1229 1628c-5.62 4-5.62 4-9 4v2c-13.66 3.39-27 3.35-41 3.19l-6.72-.04q-8.14-.06-16.28-.15v-1l3.16-.08 16.9-.42c39.24-.94 39.24-.94 43.51-5l1.43-1.5c5.5-2.25 5.5-2.25 8-1"/><path fill="#43434b" d="m455 1615 1.14 1c5.53 4.77 11.2 9.39 16.86 14-2.5 1.25-3.41.78-6 0v3l4 2v2l3 1-2 1c-1.5-.87-1.5-.87-3-2v-2l-1.75-.25c-3.03-1.01-4.27-2.24-6.25-4.75-1.16-2.92-2-5.83-2-9l-2-2-2-2z"/><path fill="#000005" d="M447 1553h1c.96 8 1.16 16 1.25 24.06l.06 3.1.02 2.94.03 2.64c-.43 2.7-1.26 3.57-3.36 5.26l-.08-21.12-.02-7.69-.01-2.44v-2.24l-.01-1.99c.12-1.52.12-1.52 1.12-2.52"/><path fill="#603e25" d="m951 1262 1 4h7l1 5h6l1 3c-5.96-.4-10.63-1.37-16-4v-2l-8-2v-3c2.7-1.35 5-1.07 8-1"/><path fill="#5d5b66" d="M436 1207h1c.8 4.27 1.16 8.34 1.19 12.69l.04 3.38c-.23 2.93-.23 2.93-1.22 4.34-1.32 2.08-1.29 3.52-1.33 5.97l-.06 2.67-.03 2.87-.06 2.94-.15 9.33-.12 6.32q-.15 7.75-.26 15.49h-1a5301 5301 0 0 1-.15-29.38l-.04-9.02q0-2.52-.04-5.05c0-7.72.87-14.97 2.23-22.55"/><path fill="#fbfcfd" d="M1538 1145c4.5 4.5 4.5 4.5 4.53 8.84l.02 2.78-.05 2.88.05 2.88-.02 2.78-.01 2.52c-.52 2.32-.52 2.32-4.52 6.32z"/><path fill="#96a4ac" d="M1520 969h2l.15 1.53.72 6.85.24 2.4.26 2.3.22 2.13c.41 1.79.41 1.79 2.41 3.79.2 2.16.2 2.16.13 4.63l-.06 2.47-.07 1.9h-3l-1-9-3-1q-.05-4.21-.06-8.44l-.03-2.43v-2.31l-.02-2.15c.11-1.67.11-1.67 1.11-2.67"/><path fill="#000002" d="M625 973v3c-4.85.77-9.56 1.19-14.46 1.32l-12.86.37L587 978l1-2c11.48-3.67 25.08-3.08 37-3"/><path fill="#e1e0de" d="M1236 922c2 2 2 2 2.24 4.1l-.01 2.58v2.81l-.04 2.95v2.93c-.05 7.2-.05 7.2-1.19 10.63h-2l-1-2c-2.3 2.3-2.32 3-2.62 6.13l-.23 2.19-.15 1.68h-1v-12h3a35 35 0 0 0 1.28-8.07l.33-7 .1-2.25.1-2.07c.19-1.61.19-1.61 1.19-2.61"/><path fill="#99999b" d="M82 870c3.77 1.26 4.08 2.66 6 6l2 1v4l4 1 2 9-3 1c-3.47-3.2-5.48-5.44-7-10l-1-1q-.06-2.5 0-5l-3-1z"/><path fill="#323137" d="M1013 867c2.06.44 2.06.44 4 1l-2 1 6 1c-2.32 1.76-2.91 2-6 2v2l-1.51.41-6.93 1.9-2.5.69a147 147 0 0 0-14.33 4.62c-2.18.48-3.64.05-5.73-.62l2.23-.77 2.96-1.04 2.92-1.02c2.89-1.17 2.89-1.17 5.45-2.8 2.71-1.52 4.19-1.7 7.25-1.56l2.4.08 1.79.11v-3l-2-1 2.44-.94C1012 868 1012 868 1013 867"/><path fill="#020206" d="M1126 862v2c-3.84 2.56-7.58 2.2-12 2l-1 3-14 1c7.73-7.73 16.41-8.67 27-8"/><path fill="#0b0b11" d="M25 798h1v6l-4 1v6l3 1 1 6-4 1v-7h-4v8h-4l-1 4v-5l2-1 .06-3.37.04-1.9C15 811 15 811 14 809l5-1 1-6 1.94-.31L24 801z"/><path fill="#838485" d="m1226 810-1 3c-2.19 1.06-2.19 1.06-5 2l-3 1.02-3 .98-2.66.89-4.56 1.5c-1.78.61-1.78.61-3.78 1.61-2.34.13-4.65.04-7 0 3.03-5.56 10.75-7 16.44-8.69l2.5-.7c4.18-1.24 6.56-1.7 11.06-1.61"/><path fill="#161519" d="M805 806v1c-6.32 1.65-12.66 2.53-19.15 3.21-3.2.36-6.15.83-9.22 1.82-4.47 1.36-8.98 1.39-13.63 1.53-9.77.35-9.77.35-13.85.96-2.6.4-4.97.59-7.59.54l-2.06-.02-1.5-.04c4.56-2.36 8.2-3.16 13.34-3.4l5.94-.3c11.22-.6 22.12-1.5 33.12-3.98 5-1.12 9.47-1.5 14.6-1.32"/><path fill="#201339" d="M1181 628c-7.84 4.23-15 7.98-24 9v2q-3.87 1.3-7.75 2.56l-2.21.75-2.15.7-1.97.64c-2.25.41-3.75.04-5.92-.65a279 279 0 0 1 23-9q5.2-2.05 10.19-4.62c4.2-2.06 6.33-2.6 10.81-1.38"/><path fill="#818082" d="M914 151c1 3 1 3 .13 5.38-3.01 3.71-6.75 5.76-11.13 7.62h-3l-1 3c-2.06.69-2.06.69-4 1l4-5h-7l1.75-.81c2.25-1.19 2.25-1.19 4.13-2.75 2.66-1.8 4.96-2.08 8.12-2.44l1-3 7-1z"/><path fill="#323139" d="M1256 1591h1c.63 7.1.63 7.1-1.75 10.5l-2.25 2.5a102 102 0 0 0-2.97 5.94c-2.73 5.46-5.73 8.85-10.4 12.68l-1.72 1.45C1226 1634 1226 1634 1220 1634v-2l1.43-.66c18.38-8.82 27.26-21.95 34.57-40.34"/><path fill="#7a7883" d="M1315 1493h3q.11 7.63.16 15.27l.07 5.2q.04 3.73.06 7.46l.05 2.35v2.19l.02 1.92c-.36 1.61-.36 1.61-3.36 3.61z"/><path fill="#20212a" d="m688 1496 2 1a18 18 0 0 1-5.31 4.25 21 21 0 0 0-5.63 5.25c-2.69 3.27-5.09 4.88-9.06 6.5l-1.72.77c-3.7 1.65-7.44 2.95-11.28 4.23q-2.03.95-4 2v-2c5.42-4.32 11.51-7.45 17.81-10.25 3.73-1.84 6.83-4.52 10.07-7.1A39 39 0 0 1 688 1496"/><path fill="#191a21" d="M916 1348c2.13.38 2.13.38 4 1q-5.7 5.02-11.62 9.75l-2.17 1.73a23.4 23.4 0 0 1-11.52 4.14l-2.12.23-1.57.15v2l-2-1c1.31-1.5 1.31-1.5 3-3h3v-2l1.8-.7c7.24-3 7.24-3 9.95-5.92 2.37-2.5 4.06-3.26 7.25-4.38z"/><path fill="#292932" d="M834 1258h1l1 16h2v2l1.76.85c2.13 1.1 4.1 2.3 6.12 3.59A95 95 0 0 0 856 1286l-2 4h-5l3-4-1.72-.36c-6.96-1.95-12.14-5.82-16.28-11.64-1.37-2.73-1.13-4.96-1.12-8l-.01-3.25c.13-2.75.13-2.75 1.13-4.75"/><path fill="#83828a" d="m392 1260 2 1c1.38 19.4 1.38 19.4 0 27l-1 1a55 55 0 0 0-.56 3.56L392 1296h-1l-.08-18.67-.02-6.82-.01-2.14c0-3.07.13-5.43 1.11-8.37"/><path fill="#0e0e14" d="m1291 902-1.52.52A13 13 0 0 0 1284 907c-.8 4.64-.66 9.16-.47 13.85.13 3.15.18 6.3.24 9.44l.1 4.78c.17 7.28.04 14.53-.24 21.8l-.1 3.22-.12 2.96-.1 2.55a26 26 0 0 1-2.31 7.4c-.93-3.01-1.04-3.87 0-7q.22-2.49.32-4.99l.12-3.05.12-3.27.13-3.35a710 710 0 0 0 .44-28.3v-2.3C1282 919 1282 919 1281 918a76 76 0 0 1-.25-5.31l-.08-2.93c.38-3.17 1.18-4.45 3.33-6.76 3-1.97 3.46-2.18 7-1"/><path fill="#010103" d="m1038.63 885.94 2.47.02 1.9.04-1 3-2.12.37-2.76.5-2.74.5c-2.38.63-2.38.63-4.38 2.63-2.82.41-2.82.41-6.12.63l-3.33.22-2.55.15-1 3-4-1 3-1 1-2-2-2 1.53-.35 6.85-1.59 2.4-.55 2.3-.54 2.13-.49c5.88-1.58 5.88-1.58 8.41-1.54"/><path fill="#79777a" d="m1019.19 872.94 2.17.02 1.64.04c-2.02 1.4-4 2.53-6.25 3.5l-1.64.72c-5.71 2.12-11.69 3.45-17.61 4.84l-2.18.53c-4.43 1.04-8.8 1.83-13.32 2.41 2.32-1.76 2.91-2 6-2v-2l2.16-.63 19.3-5.61c6.3-1.88 6.3-1.88 9.73-1.82"/><path fill="#dcdddf" d="m1470 837 4 1-1 31h-3z"/><path fill="#b28ce2" d="M488 751h6l1 2c3.45 1.24 7.12 1.32 10.75 1.56l1.82.13 4.43.31 1 3a126 126 0 0 1-33-3v-1h8z"/><path fill="#8462ab" d="M462 751c5.58-.31 9.73.4 15.02 2.14 4.36 1.26 8.8 2 13.27 2.77 4.07.7 7.82 1.68 11.71 3.09v1a75 75 0 0 1-16.44-1.37l-2.25-.42L477 757l-2.9-.54A39 39 0 0 1 462 752z"/><path fill="#333337" d="M315 666h1a62 62 0 0 1-1 14h-2c.15 23.6.15 23.6 5.04 33.57 1.06 2.7 1 3.78-.04 6.43l-.52-1.43c-2.21-6.05-2.21-6.05-3.51-8.57-3.26-6.32-3.49-11.8-3.28-18.81l.03-3.1c.12-8.06.12-8.06 1.28-12.09h2z"/><path fill="#dfdee0" d="M1652.13 647.88c2.72 1.63 4.19 3.44 5.87 6.12-.25 2.31-.25 2.31-1 4h-3l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2c.24-3.22 1.5-4.73 3.81-6.81 2.81-2.17 2.81-2.17 5.32-2.32M1650 653l-1 5h5v-5z"/><path fill="#3b2955" d="m976.14 520.41 1.86.59a45 45 0 0 1-19 7l-6.62 1-3.33.5c-3.16.52-6.2 1.23-9.28 2.07-1.84.45-3.64.7-5.52.93-3.25.5-3.25.5-5.44 1.62-1.81.88-1.81.88-4.06.5L923 534a38 38 0 0 1 12.31-3.88c6.21-.91 12.1-2.54 18.1-4.36a63 63 0 0 1 13.66-2.47c3.59-.54 5.45-3.58 9.07-2.88"/><path fill="#000001" d="M375 377h3c.2 10.02-.42 20-1 30h-3l-.08-16.53-.02-6.03-.01-1.9q.01-2.28.11-4.54z"/><path fill="#e0e0e1" d="M359 375h3v32l-4-1z"/><path fill="#000001" d="M481 1818c6.7-.13 13.37.06 20.06.44l2.28.12 2.13.12 1.92.11c1.61.21 1.61.21 3.61 1.21v2h-30z"/><path fill="#d1d0d3" d="M1423 1623c2.64 3.94 2.29 7.93 2.27 12.5v7.71q-.01 3.97 0 7.95v7.47c-.03 4.58-.6 8.1-2.27 12.37-.41 2.12-.41 2.12-.62 3.88l-.38 3.12h-1l-.06-3.56-.04-2c.1-2.44.1-2.44.59-5.5.65-4.45.74-8.88.83-13.37l.37-16.7z"/><path fill="#992504" d="M1405 1294c1.6 3.2.06 6.16-.87 9.44q-.3 1.01-.57 2.06c-.8 2.86-1.62 5.68-2.56 8.5q-.23 3.31-.32 6.64l-.06 1.93-.31 10.27-.31 10.16a15.3 15.3 0 0 1-2.27-8.68v-2.73l.02-2.9.02-2.98c.35-23.49.35-23.49 7.23-31.71"/><path fill="#59595e" d="M1435 1168h1v21h2v2l1.81.69c2.78 1.67 3.75 3.44 5.19 6.31v2h5l2 5-1.62-.87c-2.38-1.13-2.38-1.13-5.57-2L1442 1201c-.87-2.12-.87-2.12-1-4h-6z"/><path fill="#8f8e90" d="M501 967h33v3h-33z"/><path fill="#9f9fa0" d="m636 960 2 1c-1.06 2.33-1.98 3-4.31 4.1-5.17 1.61-10.72 1.1-16.07 1.02l-3.38-.02-8.24-.1c1-2 1-2 2.43-2.6 6.44-1.5 12.87-1.8 19.45-2.06l2.3-.1 2.08-.07C634 961 634 961 636 960"/><path fill="#6f6f72" d="M692 944v1c-20.47 2.6-40.36 4.2-61 4v-1l2.09-.15 9.72-.73 1.8-.13c7.52-.56 15.02-1.24 22.51-2.12 8.32-.95 16.52-1.08 24.88-.87"/><path fill="#bac1c8" d="M1459 925c0 2.61-.22 4.06-.99 6.48l-.64 2.02-.68 2.13-.7 2.18c-4.03 12.65-4.03 12.65-5.8 17.34-2.16 5.92-2.85 11.58-3.19 17.85h-1c-.21-7.65-.04-14.59 2-22l.44-2.01c1.63-7.41 5.1-18.53 10.56-23.99"/><path fill="#f5f6f7" d="M1529 887h2v2l3 1v17l-5 2z"/><path fill="#838384" d="m1286 785-4 1v2l4 1h-4l-2 4 4 2-2.94.94C1278 797 1278 797 1277 798q-2.28.1-4.56.06l-2.5-.02-1.94-.04c1.53-3.79 1.53-3.79 3.7-4.76l1.86-.37c1.88-.38 1.88-.38 3.44-.87l1-2-9 1c1.58-3.17 4.76-3.58 7.94-4.69l1.96-.72c4.85-1.72 4.85-1.72 7.1-.59"/><path fill="#0e0e13" d="m1473 708 2 1a35 35 0 0 1-9 7c-4.89 2.78-4.89 2.78-6 5h2c-1.75 3.88-1.75 3.88-4 5v-4l-1.6.7-2.15.86-2.1.88c-2.45.64-3.79.36-6.15-.44l1.4-.73c2.89-1.59 5.29-2.95 7.6-5.33 2.69-2.6 5.55-3.62 9-4.94 6.7-2.7 6.7-2.7 9-5"/><path fill="#b476f9" d="M369 673h1l.04 1.9q.13 4.35.27 8.66l.07 3c.22 5.87.89 10.22 3.62 15.44l-3 1-1-5h-2c-2.6-2.63-2.3-5.6-2.31-9.12.07-5.8 1-10.57 3.31-15.88"/><path fill="#b4b6b7" d="M356 487c.22 6 .34 11.39-2 17l-4 2v-19c3-1 3-1 6 0"/><path fill="#3a2466" d="M1319 437c3.58 3.09 4.78 5.6 6 10.13l1 3.5.5 1.73a72 72 0 0 0 2.56 6.76 97 97 0 0 1 4.21 13.2c.9 3.33 2.02 6.56 3.18 9.8.55 1.88.55 1.88.55 4.88h-2l-.37-2.08-.5-2.73-.5-2.7a14 14 0 0 0-2.63-5.49q-.71-2.44-1.36-4.9c-1.02-3.36-2.45-6.55-3.84-9.77a14 14 0 0 1-.8-6.33h-2c-4.44-10.47-4.44-10.47-4-16"/><path fill="#7a7c7f" d="M1510 386h19l1 5c-1.79 1.79-4.34 1.21-6.75 1.25l-3.17.08c-3.5-.37-5.29-1.24-8.08-3.33z"/><path fill="#595862" d="M438 1324h1a18686 18686 0 0 1 .08 38.77l.03 16.4c.02 8-.05 15.87-1.11 23.83h-1c-1.01-4.17-1.12-8.05-1.06-12.31l.01-1.96.05-4.73h1z"/><path fill="#97969a" d="M1517 1290c1.26 2.53 1.3 4.62 1.5 7.44.23 3.12.5 5.57 1.5 8.56.67 10.72-.49 20.58-3 31h-1l-.08-26.27-.02-9.57-.01-3.03v-2.8l-.01-2.46c.12-1.87.12-1.87 1.12-2.87"/><path fill="#292931" d="M856 1287c8.47 1.22 16.37 3.1 23.69 7.69 2.65 1.5 4.65 1.69 7.63 2 2.59.47 4.47 1.91 6.68 3.31 4.56 1.93 9.29 3.47 14 5v2c-6.61-.83-12.35-3.23-18.37-6a77 77 0 0 0-12.66-4.29C875 1296 875 1296 874 1294q-2.5-.56-5-1-3.15-.92-6.25-1.94l-3.2-1.02C857 1289 857 1289 856 1287"/><path fill="#838385" d="M666 948v1c-25.68 3.18-51.15 3.35-77 3v-1l16.98-.37 5.78-.13 8.3-.18 2.61-.05 2.43-.06 2.14-.05C629 950 629 950 631 949q2.02-.22 4.04-.32l2.49-.12 2.66-.12 2.7-.13q11.55-.5 23.11-.31"/><path fill="#17161c" d="M1149 717c-5.6 2.3-11.18 4.43-16.93 6.29a58 58 0 0 0-5.32 2.15c-4.37 1.95-8.98 3.18-13.57 4.5a41 41 0 0 0-6.43 2.7c-2.75 1.36-2.75 1.36-5.12 1.05L1100 733l5-1v-2l9.38-3 2.69-.87 2.58-.82 2.38-.76c1.97-.55 1.97-.55 3.97-.55v-2l2.59-.84 3.35-1.1 1.7-.55c4.25-1.4 4.25-1.4 5.36-2.51a58 58 0 0 1 3.94-.62l2.15-.3c1.91-.08 1.91-.08 3.91.92"/><path fill="#131317" d="M412 448h1l-.37 7.56-.1 2.14a44 44 0 0 1-2.01 11.2c-1.38 5.58-1.64 11.44-2.2 17.15l-.54 5.22-.3 3.11c-.45 2.46-.9 3.73-2.48 5.62v-27l3-1 .26-1.99 1.18-8.88.4-3.13.4-3 .37-2.75C411 450 411 450 412 448"/><path fill="#03010a" d="M1172 435c2.13.38 2.13.38 4 1-2.82 3.13-5.08 4.56-9 6l-5 3-3 1-1 2c-2.29.85-2.29.85-5.06 1.63l-2.79.78-2.15.59 1-3c1.63-.73 1.63-.73 3.56-1.19l1.94-.48 1.5-.33c0-1 0-1-1-4 4.41-2.96 7.96-4.45 13.3-4.73 1.7-.27 1.7-.27 3.7-2.27"/><path fill="#0e0e14" d="M1463 429c12.13-.43 23.6.7 35.58 2.64 3.12.46 6.26.77 9.4 1.04 3.31.52 6.03 1.86 9.02 3.32-2.25 1.37-3.52 2.05-6.18 1.8l-2.26-.61-2.36-.6q-1.09-.3-2.2-.59-2.5-.51-5-1v-2l-1.7.05c-7.4.13-14.6-.64-21.92-1.55l-3.63-.43q-4.37-.52-8.75-1.07z"/><path fill="#535357" d="M946 144c-3.05 2.18-5.41 3.82-9 4.94-3.07 1.08-4.59 2.48-6.96 4.63-3.4 2.38-7.25 3.78-11.1 5.29-3.32 1.32-5.49 2.58-7.94 5.14-2.04.73-2.04.73-4.19 1.19l-2.17.48-1.64.33c5.22-5.65 12.12-9.14 19.38-11.56 3.21-1.1 4.67-2.52 6.93-5.1 2.52-2 5.4-3.07 8.38-4.22l1.77-.68c4.28-1.57 4.28-1.57 6.54-.44"/><path fill="#0a0a10" d="M1120 1424c3.95-.18 6.6-.1 10 2v2l2.65.38c3.15.58 5.88 1.42 8.87 2.55l3.1 1.16 3.2 1.22q1.55.6 3.15 1.19c6.1 2.3 12.1 4.77 18.03 7.5-3.85 1.29-5.4.4-9.19-.94l-3.29-1.15-2.52-.91v-2l-2.12.75c-3.42.3-4.48-.64-7.15-2.63-3.27-2.11-7.02-3.04-10.73-4.12l-2.13-.63q-2.43-.7-4.87-1.37v-2l-1.81-.37c-2.1-.6-3.45-1.33-5.19-2.63"/><path fill="#55351e" d="m1258 1345 1 3h2l.33 1.94c.76 4.12 1.47 7.96 3.3 11.75 1.68 3.54 1.6 6.42 1.37 10.31h-3l-1-7h-4z"/><path fill="#88827e" d="m1371.69 1224.56 2.27.02c11.05.3 21.72 3.63 32.04 7.42v1c-6.34.49-11.04-.8-16.97-3.03-6.47-2.08-12.74-2.1-19.47-2.03l-2.5.01-6.06.05c3.16-3.62 6.1-3.5 10.69-3.44"/><path fill="#fafafb" d="M1445 1097h1v24l-5 1c-.28-7.78.03-15.43 2-23z"/><path fill="#000001" d="M1407 962h4l-1 10-4 1 .07 2.27.06 2.98.07 2.95c-.2 2.82-.75 4.4-2.2 6.8h-2v-14l4-2c.6-2.38.6-2.38.75-5.12l.17-2.76z"/><path fill="#bfbebd" d="m1069.29 965.9 2.77.04 2.79.02 2.15.04-1 3-10 1v3c-4.23 2.11-9.55 1.08-14 0l1-3c2.2-.7 4.24-1.2 6.5-1.62 3.36-.68 6.63-2.34 9.79-2.48"/><path fill="#010105" d="m932.69 913.94 2.45.02 1.86.04v3l-3 .37-3.87.5-1.98.24c-4.92.66-4.92.66-7.15 2.89-2.24.3-4.38.51-6.62.63l-1.86.11q-2.25.14-4.52.26l1-4 3.4-.62 6.65-1.23 2.16-.4 1.98-.36c6.93-1.49 6.93-1.49 9.5-1.45"/><path fill="#414145" d="M1226 912c-5.32 3.04-9.87 4.67-16 5v2q-4.45 1.73-8.94 3.44l-2.52.98A54 54 0 0 1 1182 927c6.54-5.43 15.57-7.88 23.8-9.47a54 54 0 0 0 14.24-5.72c2.4-1 3.55-.67 5.96.19"/><path fill="#000002" d="M1227 903h6c-1.27 3.82-2.63 4.25-6.02 6.09a35 35 0 0 1-5.8 2.03c-3.18.88-3.18.88-5.18 2.88-1.74.29-3.49.57-5.25.75-1.75.25-1.75.25-4.75 1.25l2-5 9-1v-3l9-1z"/><path fill="#29282c" d="M740 832h30v1l-1.94.17q-4.37.37-8.75.77l-3.05.26-2.96.26-2.71.24c-2.59.3-2.59.3-5 .83-3.15.57-6.23.6-9.42.57l-7.98-.04-4.13-.01L714 836v-1c8.7-.9 17.26-1.12 26-1z"/><path fill="#7d7b7d" d="M1428 722q2.79.14 5.56.44l5.44.56c-1 3-1 3-4 4.63l-3 1.37-1 1c-4.05.32-8.12.3-12-1 1-3 1-3 2.6-3.91l1.9-.72c3.43-1.3 3.43-1.3 4.5-2.37"/><path fill="#47464c" d="m298 670-2 2 2 2c-3.03 2.6-5.16 4.07-9 5l-.94 1.44c-1.55 2.28-3.5 2.67-6.06 3.56l-2 1 1-4h4v-3l-1.25 1.06c-1.75.94-1.75.94-3.94.57L278 679c2.17-2.17 4.18-2.93 7-4.07 2.94-1.37 5.54-3.19 8.2-5.04 1.8-.89 1.8-.89 4.8.11"/><path fill="#e7e6e7" d="M1678 615h3c2.49 3.22 4.37 5.92 5 10l-4 1v-5h-4l-.69 1.69c-1.73 3.04-3.7 4.96-6.31 7.31h-2l1-5h2l1-5 3-1c1.19-2.06 1.19-2.06 2-4"/><path fill="#000001" d="M1448 386h30v4a4004 4004 0 0 1-21.68-.68l-1.82-.05c-4.27-.16-4.27-.16-6.5-1.27z"/><path fill="#05040a" d="m901 254 2 1-.8 1.57c-1.53 3.01-3.02 5.98-4.26 9.12A31 31 0 0 1 894 273c-2.69.81-2.69.81-5 1 .41-2.38.68-3.7 2.46-5.4 1.73-1.8 2.2-3.1 2.91-5.48 1.44-4.14 3.33-6.27 6.63-9.12m-13 20h1l-1 7h-2c.88-5.87.88-5.87 2-7"/><path fill="#838485" d="M616 194c6.6 5.56 6.6 5.56 9.06 8.25 2.45 2.2 4.8 2.9 7.94 3.75l1-2v2l4 1h-3c1.68 3.45 2.63 4.82 6.13 6.56C644 215 644 215 644.88 217.2L645 219a32 32 0 0 1-9-5l-2.28-.73c-3.64-1.7-5.68-4.14-8.28-7.15l-2.82-3.17-1.34-1.53a76 76 0 0 0-3.62-3.68C616 196 616 196 616 194"/><path fill="#2b2930" d="M1069 84h2c1.5 3 1.55 5.75 1 9-4.16 9.58-12.57 17.2-21 23l-2.52 1.82c-4.36 3.13-8.58 5.96-13.48 8.18l-2.37 1.19-1.63.81v-2c3.06-2.25 5.4-3.8 9-5l1-2c1.6-.86 1.6-.86 3.63-1.75a39 39 0 0 0 9.72-6.76 64 64 0 0 1 4.65-3.74c4.8-4.06 9.47-9.28 10.44-15.62 0-2.42-.12-4.73-.44-7.13"/><path fill="#d3d4d4" d="M340 1667h2v30h-3c-1.16-5.56-1.18-11.02-1.19-16.69l-.03-2.87-.01-2.76-.01-2.5c.26-2.34.66-3.46 2.24-5.18"/><path fill="#77767e" d="M1115 1641a12308 12308 0 0 1 44.22-.15 3703 3703 0 0 1 18.7-.06q3.62-.03 7.24-.02l2.14-.02c4.32.02 7.67.67 11.7 2.25-2 2-2 2-4.62 2.13l-2.38-.13-1-2-76-1z"/><path fill="#52505a" d="M1259 1588h2q.12 2.85.19 5.69l.1 3.2c-.33 3.5-1.3 5.25-3.29 8.11l-1.5 2.56a50 50 0 0 1-4.5 6.44h-2c.72-4.58 2.3-8.7 4-13h2l.4-2.08 1.06-5.44c.54-2.48.54-2.48 1.54-5.48"/><path fill="#55545f" d="M437 1423h1c.63 19 1.2 37.98 1 57l-3 1c-.16-19.36.3-38.66 1-58"/><path fill="#323439" d="m346 1242 3 1c1.1 3.3 1.11 5.73 1.1 9.2l-.04 7.49-.01 3.86-.05 9.45-4 1z"/><path fill="#8f6a4b" d="m1271 1211 31 2-1 3v-2l-16 1c1 1 1 1 3.56 1.06l2.44-.06c-3.21 1.9-5.54 2.22-9.25 2.13l-2.7-.06-2.05-.07-1-4h-5z"/><path fill="#937054" d="m1171.25 1209.88 4.03.02q4.86.03 9.72.1v1l-1.59.06q-3.64.14-7.29.32l-2.49.09c-4.16.2-8.04.57-12.1 1.56-7.91 1.7-16.1 1.18-24.15 1.1l-5.07-.03q-6.15-.03-12.31-.1v-1l2.01-.06q4.62-.14 9.24-.32l3.16-.09c5.25-.2 10.27-.58 15.43-1.56 7.15-1.3 14.18-1.2 21.41-1.1"/><path fill="#525255" d="M1002 1008h4l1 2q2.71 1.34 5.5 2.5c5.39 2.28 5.39 2.28 6.5 4.5 1.97.71 1.97.71 4.5 1.38a81 81 0 0 1 12.18 4.57c4.05 1.83 8.19 3.43 12.32 5.05-3.15 1.04-4.78 1-7.81-.31a59 59 0 0 0-7.57-2.63c-6.1-1.69-12.17-3.45-16.62-8.06-1.58-.77-1.58-.77-3.25-1.37-4-1.6-7.46-3.85-10.75-6.63z"/><path fill="#000002" d="M1224 990h1v5h-3l-.06 2.13c-1.4 4.28-4.5 6.14-7.94 8.87-3.85 3.54-3.85 3.54-5 7h-5c0-3 0-3 1.54-4.58l2.09-1.67a68 68 0 0 0 8.99-9.1A92 92 0 0 1 1223 991z"/><path fill="#8f8d8f" d="M1061 967q-6.3 2.35-12.64 4.64l-4.29 1.58c-7.57 2.83-14.56 5.37-22.74 5.56-2.73.26-4.82 1.15-7.33 2.22-2.24.2-2.24.2-4.31.13l-2.12-.06-1.57-.07v-1l2.1-.52q9.69-2.4 19.32-5 5.86-1.56 11.77-2.98c5.2-1.27 10.2-2.96 15.22-4.83 2.76-.71 3.92-.46 6.59.33"/><path fill="#929294" d="M351 968c4.11-.24 7.15.6 11 2q2 .54 4 1v2h5v1q-1.78.04-3.56.06l-2 .04c-2.44-.1-2.44-.1-5.6-.57-3.9-.57-7.8-.84-11.74-1.07l-2.2-.13-11.51-.67L323 971v-1h28z"/><path fill="#050506" d="m1057 959 3 1h-2l-.25 1.81c-.99 2.88-2.2 3.58-4.75 5.19-2.5.66-2.5.66-5.12 1.06-4.67.73-4.67.73-6.88 2.94-2.16.2-2.16.2-4.62.13l-2.48-.06-1.9-.07v-1l8-1 1-3-2-1 1-2c1.57-.38 1.57-.38 3.56-.56l2.17-.23 4.54-.42 2.17-.23 2-.18 1.56-.38z"/><path fill="#8d8e8e" d="m757 940-1 3-2.92.15-3.83.23-1.92.09c-2.7.17-4.91.31-7.33 1.56-3 1.45-6 1.18-9.25 1.1l-1.97-.03-4.78-.1c1-2 1-2 3.88-3q2.05-.52 4.12-1l2.42-.57C742 939.84 749.3 939.8 757 940"/><path fill="#434347" d="M356 938h37c-2.9 1.94-3.52 2.25-6.76 2.38l-2.14.1-2.23.08-4.38.2c-.32 0-.32 0-1.96.07C374 941 374 941 373 942c-1.67.02-1.67.02-3.82-.12l-2.31-.15-2.43-.17L356 941z"/><path fill="#8a8b8d" d="m859 915 1 2h14c-3.16 2.1-4.5 2.34-8.19 2.63l-2.73.22-2.08.15-1 3 4 1-8 2 2-6h-17c2.17-2.17 2.8-2.35 5.7-2.85l2.16-.4 2.26-.37 2.29-.41z"/><path fill="#47454b" d="m247 914 1 4h13l-2 5-5.19-.87-2.92-.5A44 44 0 0 1 243 919q-1.89-.6-3.81-1.12L236 917c2.74-4.1 6.58-3.28 11-3"/><path fill="#0d0e12" d="m1142.25 853.38 1.75.62c-3.31 3.17-6.71 3.76-11.07 4.57-1.93.43-1.93.43-2.93 1.43l3 2c-5.44.99-10.65 1.02-16.18.97A41 41 0 0 0 1108 864c3-3 6.65-3.75 10.63-5.06l2.37-.82a56 56 0 0 1 13.36-2.9c1.64-.22 1.64-.22 3.89-1.35 1.75-.87 1.75-.87 4-.5"/><path fill="#111014" d="M952 776v1l-1.9.4-2.48.54-2.46.52C943 779 943 779 941 780q-2.65.44-5.31.81c-4.65.74-8.98 1.8-13.44 3.25a55 55 0 0 1-12.63 2.73c-1.62.21-1.62.21-4.06 1.27-3.81 1.4-7.54 1.1-11.56.94v-1l2.08-.4c.45-.1.45-.1 2.73-.54l2.71-.52C904 786 904 786 907 785q2.1-.1 4.19-.06l2.17.02 1.64.04v-2l3.18-.77 4.26-1.04 2.23-.55a91 91 0 0 0 11.08-3.39c5.28-1.9 10.71-1.5 16.25-1.25"/><path fill="#120b23" d="M964 710h-3v2l-14 2v2c-7.38 2.01-14.76 4-22.19 5.81l-3.1.77c-2.66.41-4.2.3-6.71-.58l3.03-.84 3.9-1.1 2-.55c4.96-1.4 4.96-1.4 6.07-2.51q2.27-.34 4.56-.56l2.5-.26L939 716v-2c4.99-1.94 10.01-2.73 15.3-3.54 7.2-1.23 7.2-1.23 9.7-.46"/><path fill="#dedcdf" d="M1594 701c-1.42 3.7-3.8 5.9-6.66 8.54C1586 711 1586 711 1585 714h-7l-1 3h-2l-1-4h5l.25-1.87c.99-2.8 2.15-2.87 4.75-4.13l1-2c1.9-3.78 5.02-4.28 9-4"/><path fill="#140a29" d="M1077 670a127 127 0 0 1-16.12 7.25c-2.61 1.04-4.94 2.46-7.34 3.9-1.8.99-3.56 1.4-5.54 1.85l-1 2c-5.5 2.05-11.12 3.48-17 3 2.9-1.5 5.83-2.76 8.88-3.94a95 95 0 0 0 8.43-3.68A85 85 0 0 1 1055 677c11.74-4.79 11.74-4.79 15.5-6.87 2.86-1.3 3.62-1.02 6.5-.13"/><path fill="#8b8b8d" d="M456 248h1q.08 3.19.13 6.38l.05 1.82c.01 1.6-.08 3.2-.18 4.8l-2 2c-.26 1.66-.26 1.66-.27 3.63l-.07 2.14-.05 4.46c-.07 2-.14 3.83-.61 5.77-2.04 1.5-2.04 1.5-4 2q.48-1.02 1-2.07c1.17-3.42 1.2-6.14 1.13-9.74l-.03-1.81-.1-4.38-2 5c-1.7-4.11-.18-7.2 1.31-11.12l.69-1.92c1.04-2.84 1.84-4.8 4-6.96"/><path fill="#403e45" d="m523 190 1 3-2 1a49 49 0 0 0-5 10q-1.5 3.5-3 7l-5-1c-.47-5.46 1.64-7.7 4.98-11.77A70 70 0 0 1 523 190m-13 22 2 1-2 1z"/><path fill="#c0c2c2" d="M482.5 1823.81c4.67.04 9 1.02 13.5 2.19l1 4h-18l-1-5c1.44-1.44 2.48-1.17 4.5-1.19"/><path fill="#272a2d" d="M343 1040c2.31-.31 2.31-.31 5 0 2.76 3.1 3.19 5.53 2.96 9.54q-.27 2.55-.58 5.09l-.26 2.6c-.73 6.38-.73 6.38-4.12 9.77v-26z"/><path fill="#000001" d="M219 958q2.63-.16 5.25-.25l2.95-.14c2.8.39 2.8.39 4.74 2.39 2.06 2 2.06 2 5.3 2.4q1.75-.05 3.51-.15l1.82-.05q2.22-.08 4.43-.2v3l2 1c-5.78.12-11.28-.17-17-1v-3l-2.34-.18-3.03-.26-3.03-.24C221 961 221 961 219 960z"/><path fill="#f5f5f7" d="M1482 781a25 25 0 0 1-5.12 7.44c-2.16 2.43-2.83 3.18-3.26 6.5l.38 2.06-5 2c-1.08-3.14-1.54-5.7-1-9a34 34 0 0 1 4.69-5.06l1.35-1.2c2.67-2.16 4.5-3.25 7.96-2.74"/><path fill="#2d2c32" d="m1322 767-1.8.7q-7.02 2.74-13.89 5.86l-2.8 1.28q-2.18 1-4.34 2.07c-5.43 2.61-11.25 3.26-17.17 4.09.8-1.46.8-1.46 2-3 1.7-.3 3.4-.58 5.13-.75 2.74-.37 5.25-1.36 7.87-2.25h3l1-4q-1.97.17-3.94.38l-2.21.2-1.85.42-1 2-2-1c.56-1.44.56-1.44 2-3 3.75-.86 6.22-1.15 10-.44 4.85.7 8.68-1.07 13.19-2.75 2.95-.85 3.97-.67 6.81.19"/><path fill="#583b79" d="M436 746q2.69-.04 5.38-.06l3.02-.04c2.6.1 2.6.1 4.6 1.1v2l2.53.3c6.33.84 11.55 1.88 17.33 4.75 3.56 1.58 7.31 2.26 11.14 2.95v1c-7.03.4-13.28-.92-20-3l-1-1c-2.25-.53-4.5-.96-6.77-1.4-2.17-.58-3.55-1.13-5.23-2.6v-2l-1.64.07-2.17.05-2.15.08c-2.25-.22-3.3-.8-5.04-2.2"/><path fill="#010104" d="M410 444h1c.22 9.83.14 18.59-3 28h-2q.13-5.6.31-11.19l.07-3.2.1-3.1.09-2.83c.5-3.14 1.6-5.1 3.43-7.68"/><path fill="#3c3a41" d="M468 370c1.28 3.85.67 6.03 0 10h2v25l-2 1-1-15-1 9h-1c-.23-9.83-.17-19.36 2-29z"/><path fill="#06040b" d="M894 261c1 2 1 2 .25 4.44-1.25 2.56-1.25 2.56-2.7 3.9-1.96 2.1-2.72 4.16-3.74 6.85l-1.04 2.73L886 281l2 1-1.37 3.88-.78 2.17C885 290 885 290 883 292a89 89 0 0 0-2 4c-.19-2.31-.19-2.31 0-5q1.47-1.53 3-3c.69-2.69.69-2.69 1-5l-4 1q.45-.8.94-1.62C883 280 883 280 883.37 277c.76-3.64 1.97-4.49 4.63-7a85 85 0 0 0 3.07-5.41C892 263 892 263 894 261m-5 13 3 1-3 6c-1.33-2.67-.67-4.17 0-7"/><path fill="#3e3c41" d="M503 220h2c.17 3.85.17 5.87-2 9-.57 1.78-1 3.56-1.44 5.38C501 236 501 236 499 238c-.41 1.73-.41 1.73-.62 3.63L498 245l-2-2-2 1c-.35-4.95.62-7.69 3-12q.76-1.77 1.5-3.56A46 46 0 0 1 503 220"/><path fill="#08090d" d="M1218 70c4.92 4.3 4.92 4.3 5.31 7.81L1223 80h5v5h6l-1 7h2l-1 2-4-1 1-6-4-1-1-4h-3l-1 5-3-2 1-4h2v-5h-3v5l-2 1-2-1v-5l3-1z"/><path fill="#3c3c45" d="m1270 1717 2 1q.55 1.99 1 4l1 1c-.04 2.52-.14 4.99-.31 7.5l-.11 2.13q-.2 2.7-.58 5.37l-2 1c-1.12 2.06-1.12 2.06-2 4h-4v-4h2v-4l2-1z"/><path fill="#231b16" d="m1292 1224 2 1-1.46 1.36c-3.52 3.36-6.05 5.91-7.54 10.64q-1.45 2.52-3 5l2 1-2 1c-1.12 1.56-1.12 1.56-2 3l-3-1-4 7h-2c1.77-11.4 13.44-20.84 21-29"/><path fill="#84919d" d="M1603 1092h3v39h-3z"/><path fill="#d9d9da" d="M1444 925c0 3 0 3-2.44 5.5l-1.37 1.22C1439 933 1439 933 1438 936l2 1h-2v6l-4 1v-2h3v-4l-1.87 2c-2.13 2-2.13 2-4.13 2l-1 4 3 1-3.31.19c-1.94.26-1.94.26-3.69.81-1.81 2.5-1.81 2.5-3 5l-2 1c1.33-6.48 5.6-10.29 10-15l3.63-4.19c2.98-3.42 6.1-6.65 9.37-9.81"/><path fill="#1b1b1e" d="m13 906 4 2v6l1.6-.04 2.15-.02 2.1-.04C25 914 25 914 29 915v11l-2.25-1.87C24 922 24 922 21.75 921.06 20 920 20 920 19.25 917.38L19 915h-5l.06-2.81C14 909 14 909 13 906"/><path fill="#353538" d="m408 804 7.44 1.44 2.14.4c5.3 1.05 5.3 1.05 6.42 2.16q2.09.51 4.19.94c2.73.57 5.23 1.2 7.87 2.12 3.11 1 5.97 1.35 9.2 1.6C447 813 447 813 449 815c1.73.63 1.73.63 3.63 1.13l3.37.87v1c-9.45-.04-17.37-3-26.1-6.29a46 46 0 0 0-15.53-3.17c-3.09-.7-4.34-2.19-6.37-4.54"/><path fill="#232127" d="M1242 791c1.95-.3 1.95-.3 4.13-.19l2.19.08 1.68.11-1 3q-2.55.77-5.12 1.4c-2.36.75-4.08 2.14-6.07 3.58A33.4 33.4 0 0 1 1222 803l1-4 1.93-.37 2.5-.5 2.5-.5 2.07-.63 1-2c1.77-.22 3.53-.44 5.3-.56 1.7-.44 1.7-.44 2.66-1.96z"/><path fill="#7c7b7b" d="m1490 694 2 1a73 73 0 0 1-10 7l-2.87 2c-4.08 2.75-8.49 4.45-13.13 6v-3h-6c1.4-2.8 3.3-3.42 6-5q2.65-2.03 5.25-4.1c1.75-.9 1.75-.9 3.96-.54l1.79.64c-2.97 1.9-5.51 3.35-9 4v4h5v-4l3.31-.31c3.3-.47 4.47-1.18 6.69-3.69 1.96-1.1 3.97-2.03 6-3z"/><path fill="#614397" d="m768 589-5.81 1.5-3.27.84C756 592 756 592 753 592v2c-2.53 1.26-4.62 1.29-7.44 1.5-3.12.23-5.57.5-8.56 1.5q-2 .11-4.01.1l-2.3-.01-2.38-.03-2.41-.01-5.9-.05v-1l2.86-.43 15.1-2.28 6.55-1 2.04-.3A40 40 0 0 0 757 589c3.86-.92 7.16-1.12 11 0"/><path fill="#111016" d="m1423 537 3 1c-6.46 11.91-13.5 21.27-24 30 0-3 0-3 1.36-4.58l1.83-1.67c4.91-4.7 8.34-9.93 11.81-15.75q2.98-4.5 6-9"/><path fill="#312f36" d="M936.62 477.63c4.77-.18 4.77-.18 7.1 1.9L945 481l-1.57.55c-6.05 2.18-6.05 2.18-8.8 4.14-4.08 2.03-8.16 1.54-12.63 1.31.75-1.97.75-1.97 2-4 1.84-.47 1.84-.47 3.94-.5 2.18-.15 2.18-.15 4.06-.5 3.04-4 3.04-4 4.62-4.37"/><path fill="#020108" d="M1296 346h2c.3 3.03.35 4.52-1.47 7.04l-2.03 1.95-2.19 2.14-2.31 2.18-2.31 2.25A662 662 0 0 1 1282 367l-2-4 2-1c1.13-2.06 1.13-2.06 2-4h2l.63-1.75c1.64-2.69 3.34-3.62 5.97-5.23 1.88-1.37 2.63-2.86 3.4-5.02"/><path fill="#6c6c6f" d="M859 184c2.06.44 2.06.44 4 1-2.63 3.3-6 4.25-9.87 5.5-4.41 1.44-8.18 3-12.13 5.5q-2.47 1.07-4.97 2.1c-3.33 1.47-6.5 3.26-9.7 5-2.63 1.01-3.74.88-6.33-.1 2.65-1.46 3.9-2 7-2v-2l1.94-.87C831 197 831 197 832 195c1.68-.42 1.68-.42 3.88-.75 5.12-1.01 9.1-3.27 13.6-5.86A40 40 0 0 1 858 185z"/><path fill="#3e3d42" d="M886 172c2.13.38 2.13.38 4 1-12.08 10-12.08 10-19 10v2l-5.12 2.2q-2.3.99-4.57 2.05c-2.58.84-3.8.68-6.31-.25l4.5-2.15C861 186 861 186 863 184q2.5-1.03 5-2 4.12-1.95 8.19-4l2.2-1.08 2.1-1.04 1.9-.95C884 174 884 174 886 172"/><path fill="#312f35" d="M903 166v2c-4.38 3-4.38 3-7 3v2a77 77 0 0 1-12 6l-1 1 5 1v1l-2.87.88C882 184 882 184 880 186h-3v-2c-1.5-1.12-1.5-1.12-3-2l3.19-1.25c4.21-1.82 7.32-4.34 10.65-7.5 4.71-4.35 8.47-7.25 15.16-7.25"/><path fill="#080a10" d="M450 134h3c.52 4.65-1.09 7.24-3.81 10.81l-1.08 1.52c-1.07 1.44-1.07 1.44-3.11 3.67h-3v-6h-4l-1 3c.38-2.44.38-2.44 1-5l2-1c1.13-1.56 1.13-1.56 2-3v5c2.68-1.52 2.96-1.87 4-5h4z"/><path fill="#4b4c4e" d="M991 26q1.94-.08 3.88-.12l2.17-.08C999 26 999 26 1001 28c-.37 2.63-.37 2.63-1 5l-1.6.11c-4.5.42-6.8 1.06-10.4 3.89-2.25-.31-2.25-.31-4-1h2l.31-2.37C987 31 987 31 988.5 29.87L990 29z"/><path fill="#e4e3e6" d="M1424 1615h1c.85 7.77 1.14 15.42 1.1 23.23l-.03 12.17-.02 9-.05 17.6-4 1c-.26-6.38-.26-6.38 1-9.41 1.66-4.88 1.24-10.02 1.2-15.11l-.01-3.34-.05-8.73-.04-8.93q-.03-8.74-.1-17.48"/><path fill="#474750" d="M447 1609c3 1 3 1 4 3l1.88.81c2.8 1.57 3.6 3.4 5.12 6.19q1.47 2.02 3 4h-2l1 1.31c1 1.69 1 1.69 1 4.69h-2l-.69-1.69c-1.37-2.41-3-4.1-5-6a41 41 0 0 1-3.5-4.06C448 1615 448 1615 446 1614z"/><path fill="#1f1813" d="M1062 1237c.93 3.01 1.04 3.87 0 7a129 129 0 0 0-.11 8.68c.1 10.17.28 20.4 2.8 30.3.35 2.29 0 3.84-.69 6.02a457 457 0 0 1-3.03-9.14c-1.22-4.32-1.42-8.58-1.6-13.05l-.1-2.53-.2-5q-.06-2.1-.16-4.2c-.3-6.68.1-12.02 3.09-18.08"/><path fill="#ac917f" d="m1002.13 1229.94 3.32.02 2.55.04v3l-2.2.11c-8.15.56-8.15.56-11.74 2.39-4.78 2.34-9.8 2.25-15.06 2.5l1-3a65 65 0 0 1 6.94-2.12c11.4-2.98 11.4-2.98 15.18-2.94"/><path fill="#35343e" d="m770 1101 1 2-1 2 3 1-1.2 1.57a101 101 0 0 0-6.55 9.37C764 1119 764 1119 762 1120l-1-2-6 5c0-4 1.35-5.07 4-8l2.56-2c2.63-2.15 3.84-4.03 5.44-7z"/><path fill="#040509" d="M689 963v1l6 1v1l-1.9.15-2.48.23-2.46.2C686 967 686 967 684 969c-2.6.2-2.6.2-5.62.13l-3.04-.06L673 969l1-3h-8c6.85-4.11 15.32-3.11 23-3"/><path fill="#868b8e" d="M23 921a176 176 0 0 1 22 22h-3v-2l-2.06.94c-3.57.07-4.1-.56-6.63-2.94-2.33-2.5-3.69-4.4-4.44-7.75-.87-3.25-.87-3.25-3.5-4.94L23 925c-.15-2.08-.15-2.08 0-4"/><path fill="#48464a" d="M306 674h2v7h2q.05 3.46.06 6.94l.03 2q0 2.52-.09 5.06l-1 1c-.28 2.29-.45 4.57-.62 6.87C308 705 308 705 306 707z"/><path fill="#9973cf" d="M546 615h1a6094 6094 0 0 1-.68 26.17l-.05 2.2c-.1 3.37-.33 6.37-1.27 9.63a72 72 0 0 0-.32 3.67l-.12 2.01-.12 2.07-.13 2.11L544 668h-1c-.11-11.12 0-22.2.44-33.31l.12-3.2c.6-14.66.6-14.66 2.44-16.49"/><path fill="#b6afc9" d="M539 589a1153 1153 0 0 1 44.42.64L596 590v1l-20.4.57q-17.8.52-35.6.43z"/><path fill="#838384" d="M894 169a23 23 0 0 1-6.34 3.92 48 48 0 0 0-7.53 4.02c-5.5 3.35-10.68 6.08-17.13 7.06a59 59 0 0 1 7.75-5.62C873 177 873 177 874 175h-7v-1l1.5-.37 1.94-.5 1.93-.5C874 172 874 172 875 170h5l-4 4 8-1v-3q1.93-.55 3.88-1.06l2.17-.6C892 168 892 168 894 169"/><path fill="#1c1b1f" d="m531 132-3.37 1.69-2.22 1.12q-2.4 1.19-4.82 2.25c-6.16 2.81-10.49 6.25-15.27 10.95-2.38 2.04-4.41 2.9-7.32 3.99-1 1.81-1 1.81-2 4a133 133 0 0 1-3.5 3.5c-3 3-5.73 6.28-8.5 9.5 0-3.86 1.69-5.5 4.06-8.31l1.25-1.5A73 73 0 0 1 500 149l5.63-4.69 1.38-1.15A68 68 0 0 1 516 137l2.44-2.06c4.17-3.16 7.53-4.44 12.56-2.94"/><path fill="#2c2b30" d="m937 121 2 1c-1.25 1.5-1.25 1.5-3 3-2.19.19-2.19.19-4 0v2l-7.64 2.78a16234.08 16234.08 0 0 1-10.67 3.97l-1.74.67A25 25 0 0 1 901 136l2-1v-2l1.9-.37 2.48-.5 2.46-.5C912 131 912 131 914 129q2.7-.72 5.43-1.37c3.18-.78 6.31-1.69 9.45-2.63l1.73-.5c2.77-.85 4.3-1.41 6.39-3.5"/><path fill="#646468" d="M519 74h11c-2.72 5.45-5.44 6.9-11 9l-1 1q-3 .06-6 0c.75-2.94.75-2.94 2-6 2.06-.69 2.06-.69 4-1z"/><path fill="#212125" d="m493 1823 8.44.44 2.43.12 2.31.12 2.15.11c1.67.21 1.67.21 2.67 1.21 1.52.16 1.52.16 3.51.2l7.17.18 13.22.31 13.1.31v1h-37l-1 3a15 15 0 0 1-7.94-3c-2.9-2.09-5.47-2.66-9.06-3z"/><path fill="#44444b" d="M391 1673h1l.38 3.06a58 58 0 0 0 2.09 9.03c.83 2.99 1.34 6.04 1.87 9.09l.66 1.82 3 1a27 27 0 0 1 1.94 4.69c1.18 3.21 2.32 5.94 4.37 8.68 1.69 2.63 1.69 2.63 1.3 4.8L407 1717c-4.6-5.45-7.87-10.21-10-17l-2.06-5.5c-2.65-7.35-4.32-13.65-3.94-21.5"/><path fill="#05060c" d="M380 1465c2 2 2 2 2.24 4.56l-.01 3.27v1.8q0 2.96-.03 5.9l-.01 4.1-.05 10.78-.04 11q-.03 10.8-.1 21.59h-1l-1-47h-1q-.05-3.15-.06-6.31l-.03-1.8c-.01-2.94.15-5.08 1.09-7.89"/><path fill="#6d4d36" d="M1148 1273c9.99 1.07 19.9 2.55 29.82 4.17l4.12.66 7.67 1.23c2.96.5 4.2.76 6.39 2.94-16.44-1-32.04-3-48-7z"/><path fill="#9b2508" d="m1432.15 1261.41 1.85.59-1.17 1c-19.1 16.14-19.1 16.14-25.83 27-1-2-1-2-.19-4.56 1.19-2.44 1.19-2.44 3.19-3.44l1.19-2.62c2.8-5.84 14.38-19.23 20.96-17.97"/><path fill="#16161e" d="m912.38 1212.31 1.62.69h-3v2l-4.31 2-2.43 1.13c-2.26.87-2.26.87-5.26.87v2a45 45 0 0 1-14 6v2q-1.87.8-3.75 1.56l-2.1.88c-2.45.64-3.79.36-6.15-.44a14 14 0 0 1 6.81-4.5A73 73 0 0 0 890 1222l3.44-1.72 1.77-.89 7.09-3.53q1.71-.87 3.39-1.76c4.06-2.13 4.06-2.13 6.68-1.79"/><path fill="#aeb8c0" d="M1545 1142h1q.12 8.28.16 16.55l.07 5.64.06 8.08.05 2.56v2.36l.02 2.09c-.36 1.72-.36 1.72-1.8 2.76l-1.56.96c-1.19 3.13-1.19 3.13-2 6l-5 1-2-3h5l.11-1.68.2-2.2.18-2.17c.51-1.95.51-1.95 1.99-2.83 1.52-1.12 1.52-1.12 2.1-3.76.43-3.43.64-6.83.74-10.29l.06-1.92.31-10.13z"/><path fill="#000001" d="M301 1030c17.34-.2 17.34-.2 25 1l1 3h-25z"/><path fill="#2e2d36" d="m1287 940 1 2-1 2 2 1-.56 1.94c-.4 2.84-.26 4.59.18 7.37.53 4.12-.07 6.45-1.73 10.19-1.47 4.11-2.08 8.4-2.8 12.69A67 67 0 0 1 1281 989c-.99-3.12-.96-5.2-.26-8.39l.54-2.57.6-2.73c1.56-7.6 2.61-14.96 2.8-22.71.12-4.52.93-8.3 2.32-12.6"/><path fill="#818082" d="M295 936c7.99-.35 14.84.16 22.63 2.14 10.99 2.8 22.12 4.5 33.37 5.86v1c-17.08.33-17.08.33-23.94-2-3.08-1-5.58-1.24-8.81-1.44a55 55 0 0 1-13.98-2.86A48 48 0 0 0 295 937z"/><path fill="#3b3a3e" d="M467 822c8.82.87 17.7 1.9 26.31 4 6.78 1.6 13.78 2.2 20.69 3v1c-13.05.37-25.23-.19-38-3v-2l-2.25-.37A26 26 0 0 1 467 822"/><path fill="#656569" d="M1276 788a382 382 0 0 1-11.87 4.94q-4.8 1.9-9.5 4l-2.2.97-5.52 2.49-3.22 1.41-3 1.33c-2.8.9-3.99.9-6.69-.14l4.88-2 2.74-1.12c2.38-.88 2.38-.88 4.38-.88l1-3c1.44-.84 1.44-.84 3.28-1.5l2.04-.75 2.18-.75 2.2-.79 4.34-1.52a25948.08 25948.08 0 0 1 7.21-2.62c.32-.13.32-.13 1.95-.72 2.2-.43 3.69-.03 5.8.65"/><path fill="#98989a" d="M112 778c0 3 0 3-2.44 5.5l-1.37 1.22C107 786 107 786 106 789h3l1-3c.38 2.6.4 4.37-1 6.63-2.3 2.83-3.46 4.19-7 5.37q-2-.49-4-1l-2 1c1.58-3.58 3.83-6.45 6.31-9.44l1.15-1.4c2.67-3.25 5.53-6.22 8.54-9.16"/><path fill="#21143b" d="M864 735c-3.12 2.28-6.33 2.82-10.06 3.5l-4.1.78-2.02.37q-3.36.66-6.7 1.41c-7.29 1.48-14.73 2.13-22.12 2.94 4.31-2.97 8.14-4.19 13.38-4.44 4.2-.3 7.27-1.05 11.03-2.86 6.25-2.75 13.95-1.8 20.59-1.7"/><path fill="#464548" d="m256 698 1 3h-3l-.19 2.25c-1.52 5.14-6.4 7.17-10.81 9.75q-3 1.98-6 4c.08-3.28.23-5.14 2.45-7.63A68 68 0 0 1 250 702l1.63-1.62C253 699 253 699 256 698"/><path fill="#686769" d="m1529 663 2 1c-1.81 2-1.81 2-4 4h-3v3l2.31-1c2.69-1 2.69-1 5.69-1-3.32 3.67-7.1 6.1-12 7l1-3-2.31 1c-2.69 1-2.69 1-5.69 1l-1 3-4-1c5.36-5.33 10.79-8.67 17.56-11.84C1528 664 1528 664 1529 663"/><path fill="#a08ac2" d="M692 599c-8.97 5.04-21.56 4.43-31.6 5.25-6.11.5-12.05 1.25-18.07 2.38-3.16.5-6.14.52-9.33.37v-1l10-1v-2c3.55-.8 6.9-1.15 10.54-1.25l3.2-.1 6.56-.17c6.5-.2 12.72-.84 19.11-2.05 3.27-.54 6.3-.58 9.59-.43"/><path fill="#f9f8f9" d="M1694 595c2.06.44 2.06.44 4 1l.31 1.88c.8 2.44 1.47 2.96 3.69 4.12l-1 4h-3v-5c-4.51 2.46-8.18 5.6-12 9-.36-4.23.4-6.63 3-10h3l.44-1.94c.56-2.06.56-2.06 1.56-3.06"/><path fill="#1a0f33" d="M1310 361a91 91 0 0 1 4 4v2h2l1.29 3.07c1.58 3.7 3.33 7.28 5.15 10.87l.92 1.84q1.97 3.94 4.02 7.83l1.12 2.14.97 1.8.53 1.45-1 2-2-2.87-1.12-1.62C1325 392 1325 392 1325 390h-2c-1.9-3.7-3.63-7.41-5.16-11.29-.84-1.71-.84-1.71-2.3-3.6a30 30 0 0 1-3.73-7.24l-1.04-2.63C1310 363 1310 363 1310 361"/><path fill="#7f8283" d="M754 82q3.35-.12 6.69-.19l1.9-.07c4.42-.07 6.62.83 10.41 3.26 5.04 1.2 10.22 1.31 15.38 1.56L797 87v1a1862 1862 0 0 1-16.08.15q-2.95.04-5.9.05l-1.8.03c-7 0-12.92-2.4-19.22-5.23z"/><path fill="#515059" d="m444 1733 1.36.99c5.27 3.81 5.27 3.81 8.08 5.32 2.5 1.65 4.2 3.33 6.25 5.5 4.24 4.4 8.97 7.3 14.31 10.19-4.52 1.61-7.51.04-11.75-1.77-2.95-1.61-3.75-3.24-5.25-6.23a82 82 0 0 0-11-10.37l-2-1.63z"/><path fill="#aaaaae" d="M1411 1718h3q.12 2.19.19 4.38l.1 2.46-.29 2.16q-1.47 1.05-3 2c-.51 1.95-.51 1.95-.69 4.13l-.2 2.19-.11 1.68q-2.49.57-5 1c-1-1-1-1-1.06-4.06l.06-2.94 5-1-.07-2.12-.06-2.76-.07-2.74c.2-2.38.2-2.38 2.2-4.38"/><path fill="#838289" d="m394 1681 2 1c.41 2.29.41 2.29.63 5.06l.22 2.79.15 2.15 4 1v-12c3.87 4 3.87 4 4.23 6.82q-.07 2.59-.23 5.18l1 2c.13 2.34.04 4.65 0 7h-1l-.37-1.5-.5-1.94-.5-1.93C403 1695 403 1695 401 1694l1 9c-2-3-2-3-3-6l-2-1c-2.25-4.9-3.41-9.63-3-15"/><path fill="#151517" d="M1418 1620h1l1 58 2 1v3l-1.87.69c-2.74 1.69-3.18 3.3-4.13 6.31-1.1-2.77-1.02-3.95-.05-6.82 1.7-5.7 1.41-11.66 1.46-17.55l.06-3.87q.08-5.07.13-10.13l.14-10.35q.14-10.14.26-20.28"/><path fill="#24242d" d="m817 1413 2 1c-4.44 4.91-10.8 7.53-16.57 10.6-2.43 1.4-2.43 1.4-4.43 3.4q-2.5 1.01-5 2c-4.53 2.48-9.04 5.03-12.94 8.44L778 1440l-3-1q2.55-1.92 5.13-3.81l1.43-1.08c2.48-1.82 4.9-3.41 7.67-4.76a27 27 0 0 0 6.14-4.17c3.18-2.3 6.74-4 10.2-5.81C808 1418 808 1418 810 1416q3-1.05 6-2z"/><path fill="#d5cfc5" d="M1353 1391c18.72-.77 18.72-.77 25 4v1c-17.34.57-17.34.57-25-3z"/><path fill="#e54803" d="M1390 1314c3 2 3 2 3.69 4.88.82 8.2 1.32 17.3-1.69 25.12l-2 1z"/><path fill="#010103" d="M1066 1219c-.75 8.24-2.86 15.8-7 23l-2 1c-1.12 1.56-1.12 1.56-2 3 3.45-27 3.45-27 11-27"/><path fill="#9a9a9c" d="m575.71 966.9 11.63.05 9.66.05c-1 2-1 2-2.63 2.62-3.11.5-6.18.5-9.32.48h-2l-10.58-.05L562 970l1-2c4.25-1 8.36-1.12 12.71-1.1"/><path fill="#aaa9ab" d="M1141 942c2.06.44 2.06.44 4 1-3.93 2.62-7.48 3.85-12 5l13 2-1 3h-10v-2h-4v-2l-2 1q-2.06.1-4.12.06l-2.2-.02-1.68-.04c2.13-2.13 3.06-2.47 5.88-3.25l2.29-.65 2.4-.66 4.68-1.32 2.1-.58C1140 943 1140 943 1141 942"/><path fill="#fbfbfb" d="M39 941c3 0 3 0 4.38 1.25L45 944c2.72 2.3 4.6 3.87 8 5l-1 5-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-4-1v-3h5c-2.46-4.51-5.6-8.18-9-12z"/><path fill="#b8b7b7" d="M1224 930c0 3.36-.66 4.52-2.31 7.38l-1.39 2.39-1.3 2.23-1.69 3.06L1216 947h-2l-.87 2.38c-1.27 2.94-2.17 4.23-5.16 5.44-6 1.8-11.72 2.8-17.97 3.18l1-2q2.56-.81 5.17-1.5c2.36-.64 4.57-1.57 6.83-2.5l3-.81c3.53-1.4 5.58-3.37 8-6.19l2.06-2.31a52 52 0 0 0 5.01-8.92c.93-1.77.93-1.77 2.93-3.77"/><path fill="#09090d" d="M1110 939h10l1 2 5-2-1 3h8a24 24 0 0 1-6.36 3.25l-1.94.65-2.01.66-1.98.67c-3.9 1.3-7.64 2.2-11.71 2.77l1-4 2.15-.15 2.79-.23 2.77-.2 2.29-.42 1-2h-7l1-2-5-1z"/><path fill="#7c7c7f" d="M171 888a76 76 0 0 1 12.25 7.38 73 73 0 0 0 15.75 8.8c2 .82 2 .82 3 2.82 1.81.34 1.81.34 4 .5s2.19.16 4 .5l1 2c-5.83.42-9.36-.71-14.44-3.5l-1.93-1.01q-2.32-1.23-4.63-2.49v-2l-1.87-.37C186 900 186 900 184 898l-3-.44c-3-.56-3-.56-4.12-2q-.44-.76-.88-1.56l-2.06-.94C172 892 172 892 171.25 889.88z"/><path fill="#59585c" d="M153 869c2.85-.1 5.3.01 8 1a25 25 0 0 1 2 3 25 25 0 0 0 3 2h-4l1.4 1.57c3.28 3.75 6.53 7.5 9.6 11.43-5.07-.4-7.3-2.74-10.62-6.25l-1.45-1.45c-3.35-3.44-5.78-6.99-7.93-11.3"/><path fill="#a2a2a3" d="M125 770v3h-3v5h-4v4h-5l-1 3-2 1-1 3h-3c.9-5.17 4.33-8.04 7.81-11.75l1.91-2.11 1.86-2 1.7-1.83c2.07-1.58 3.17-1.59 5.72-1.31"/><path fill="#1b1b20" d="m1530 661 2 1c-3.52 3-6.92 5.11-11.12 7.13-5.67 2.72-5.67 2.72-7.55 4.04-2.28 1.42-4.73.95-7.33.83v-3l2.31-.81A14 14 0 0 0 1514 666l4-4 3 1h-2v3l3.81-1.37 2.15-.78A24 24 0 0 0 1530 661"/><path fill="#040409" d="M295 598h7v4h-6l-1 4h-9v3h-7v-3l2.94-.87C285 604 285 604 286 602h9z"/><path fill="#323137" d="M792 514h10l-3 1v2h4v2h-10v2a41 41 0 0 1-14.94 2.13l-2.07-.03-4.99-.1v-1h7v-2h-6c2-2 2-2 4.38-2.2l2.75.08 2.75.05 2.12.07-4 2 3.38-.44c4.86-.6 9.74-1.09 14.62-1.56v-2l-6-1z"/><path fill="#616363" d="m352 434 2 1v28l-4-1q-.09-5.6-.12-11.19l-.06-3.2q0-1.52-.02-3.1l-.03-2.83c.25-2.9 1.02-5.05 2.23-7.68"/><path fill="#2c1a50" d="M1182 438v3l-5.27 1.37c-1.73.63-1.73.63-3.73 2.63l-2 .44c-2.5.7-2.62 1.44-4 3.56q-2.45 1.57-5 3l-1 1q-3 .06-6 0l-1-3c2.9-1.26 4.8-2 8-2v-2l2.88-1.19a159 159 0 0 0 11.89-5.82c2.23-.99 2.23-.99 5.23-.99"/><path fill="#535456" d="m356 406 2 1v28l-4-1q-.09-5.6-.12-11.19l-.06-3.2q0-1.52-.02-3.1l-.03-2.83c.25-2.9 1.02-5.05 2.23-7.68"/><path fill="#333137" d="M958 370c0 3.39-.36 3.78-2.5 6.19l-1.4 1.6L953 379l4-1c-3.59 4.53-6.89 8.22-12 11-2.37.25-2.37.25-4 0-.31-1.81-.31-1.81 0-4q2.48-1.54 5-3c1.2-1.3 1.2-1.3 2.38-2.81 2.79-3.57 6.12-6.34 9.62-9.19"/><path fill="#040406" d="m480.5 101.5 2.5.5v3l-5 1v3l-1.94.31-2.06.69-1 3-3 1-1 3h-4l-1 3-2-1c1.37-5.59 1.37-5.59 4.13-7.37L469 110q2-2.06 3.94-4.19c4.2-4.35 4.2-4.35 7.56-4.31"/><path fill="#494852" d="M1239 1630h2c-1.09 5.3-1.09 5.3-3.52 7.15q-1.83 1-3.7 1.91c-1.78.94-1.78.94-4.15 3.07-3.14 2.24-5.74 2.74-9.49 3.43-2.14.44-2.14.44-4.95 1.63-2.53.94-3.7.73-6.19-.19l7-1 1-3c1.97-1 1.97-1 4.5-1.87 6.99-2.63 12.99-5.06 17.5-11.13"/><path fill="#1b1c25" d="m572 1574 2 1-1.46 1.2-1.91 1.61-1.9 1.58A19 19 0 0 0 565 1584v-2l-16 10 4 2c-6.07 3.1-6.07 3.1-9.94 2.69L541 1596c1.52-2.68 1.87-2.96 5-4l2-2.19c3.2-3.37 7.19-5.25 11.28-7.38 3.7-1.95 7.3-3.94 10.53-6.62zm-10 11 2 1Z"/><path fill="#1f2029" d="m601 1553 2 1c-1.52 2.68-1.87 2.96-5 4l-1.25 1.81c-2.08 2.6-4.16 3.7-7.05 5.25-1.7.94-1.7.94-3.83 2.57-2.17 1.59-4.37 2.37-6.87 3.37l-1.94 1.5c-2.54 1.85-5.07 2.62-8.06 3.5 1.56-2.46 2.5-3.8 5.25-4.87 3.32-1.36 5.4-3.31 8.06-5.68 2.08-1.78 4.34-3.06 6.69-4.45q1.49-1.23 2.94-2.5a32 32 0 0 1 9.06-5.5"/><path fill="#1b1c23" d="m887 1367 2 1a49 49 0 0 1-9.56 6.5 31 31 0 0 0-8.44 6.5l-2.27 1.32a37 37 0 0 0-5.41 3.94l-1.38 1.2q-1.38 1.23-2.75 2.49c-2.63 2.27-4.02 3.04-7.54 3.32L849 1393c1.42-2.85 3.38-3.37 6.19-4.81A58 58 0 0 0 867 1380l5-3 1-2 3-1 1-2c2.21-1.32 2.21-1.32 4.94-2.69l2.71-1.38z"/><path fill="#5d493a" d="M1046 1232h1q-.64 7.26-1.87 14.44c-.08.4-.08.4-.43 2.5-.7 2.06-.7 2.06-1.88 3.33-5.83 2.35-13.88 1.4-19.63-.77a69 69 0 0 1-3.19-1.5c3.38-1.13 5.55-.74 9 0q3.15.1 6.31.06l3.24-.02 2.45-.04.33-2.16c.95-5.72 2.12-10.63 4.67-15.84"/><path fill="#838386" d="m1424 1044 2 1v2h3l1 3 3 1c3.92 3.24 3.92 3.24 4.25 6.25l-.25 1.75h7c1.05 3.15 1.1 5.39 1.06 8.69l-.06 5.31h-1l-.04-1.64-.15-2.17-.1-2.15c-1.04-3-2.98-3.5-5.71-5.04a89 89 0 0 1-5.62-5.44l-1.45-1.44c-6.93-7.02-6.93-7.02-6.93-11.12"/><path fill="#9a999b" d="M366 971h32v1l-9 1 27 1v1c-28.83.19-28.83.19-38.5-.44l-1.84-.12A68 68 0 0 1 366 973z"/><path fill="#5c5b5f" d="M148 805c1.18 3.44.8 5.41-.37 8.81-2.95 8.65-2.75 17.12-2.7 26.13l.02 3.8.05 9.26c-2.89-3.22-3.37-5.28-3.33-9.57l.02-3.22.06-3.34.02-3.36c.08-8.2.08-8.2 1.23-10.51q.46-2.87.88-5.75c.74-4.67 1.66-8.22 4.12-12.25"/><path fill="#929194" d="m241 688-2.44 1.38C236 691 236 691 235 693c5.75-.75 5.75-.75 8-3v4l-6 1-1 3h-6l1.44-.81C233 696 233 696 234 693a56 56 0 0 0-10 5q-2.34 1.39-4.69 2.75l-2.32 1.36-1.99.89-2-1 3.69-2.81 2.07-1.58A90 90 0 0 1 226 693l2.75-1.64 2.81-1.61 2.82-1.64c2.82-1.2 3.83-1.24 6.62-.11"/><path fill="#2f1958" d="M1084 658c3.13-.19 3.13-.19 6 0-.31 1.94-.31 1.94-1 4l-3 1-1.44 1.94c-2.8 3.7-6.39 4.37-10.81 5.18l-2.7.51-2.05.37 4-6 7 1v-3h-8v-1l2.12-.11 2.76-.2 2.74-.18c2.38-.51 2.38-.51 3.5-2.04z"/><path fill="#7945c3" d="M449 609h5c1 1 1 1 1.09 2.63q-.12 3.38-.28 6.75l-.05 2.36-.12 2.28-.08 2.1C454 627 454 627 450 630z"/><path fill="#020109" d="m1211 614 3 1-3.31 2.88-1.87 1.61c-1.82 1.51-1.82 1.51-3.47 2.6-1.35.91-1.35.91-2.6 2.6-1.75 1.31-1.75 1.31-3.5 1.28q-2.63-.4-5.25-.97l-2 4h-5c1.54-2.83 3.15-3.62 6-5l1.22-1.4c2.46-2.2 5.27-3.1 8.34-4.16 6.18-2.18 6.18-2.18 8.44-4.44"/><path fill="#b7b0c4" d="M491 586q5.94-.09 11.88-.12l3.35-.06A77 77 0 0 1 526 588v1h-34z"/><path fill="#c0b0d5" d="M393 559c4 1 4 1 6 3 1.73.63 1.73.63 3.63 1.13l3.37.87v1l8 2v2h-11l-1-3-5.27 1.46C395 568 395 568 393 569z"/><path fill="#000001" d="M359 488h3c.11 8.4-.04 16.63-1 25l-3 1-.08-14.24-.03-8.35C358 489 358 489 359 488M370 408h4l-1 26-3 1z"/><path fill="#3c3b41" d="M473 345h1c.76 9.81 1.23 19.2 0 29-2.22-3.95-2.17-6.54-2-11h-1l-1 6h-1c-.38-8.14.2-15.3 3-23z"/><path fill="#010102" d="m524.04 81.8 3.58.07 1.86.03 4.52.1v2c-9.41 2.3-9.41 2.3-14 2v3l-12 1 1-4 2.12-.37 2.75-.5 2.75-.5c3.35-.89 3.78-2.6 7.42-2.83"/><path fill="#c8c5bd" d="m1359 1386 3 1v2l5 1v1h-14v2l3 1c-4.99-.5-9.67-1.32-14-4-1.2-1.6-1.2-1.6-2-3 6.4-.2 11.93-.16 18 2z"/><path fill="#caa48c" d="M1406 1233c4.88.61 8.74 2.7 13 5l3 1.56 2 1.44v3c-5.87-.87-5.87-.87-7-2-8.75-1.49-18.12-2.59-26.28 1.44-2.21.72-3.55.28-5.72-.44 10.03-4.67 20.18-5.53 31-3v-2l-1.62-.31c-3.04-.88-5.58-2.23-8.38-3.69z"/><path fill="#916d50" d="m1261 1210 10 1v3h-58l-1-3 2.86.5c4.81.68 9.6.62 14.45.6h2.93l15.46-.05 15.3-.05z"/><path fill="#55545e" d="M1264 1129h1v32h-3a1803 1803 0 0 1-.15-15.74q-.04-2.87-.05-5.74l-.03-3.5c.22-2.92.7-4.56 2.23-7.02"/><path fill="#727375" d="M299 1046h28l-1 4-27-1z"/><path fill="#08080a" d="m29 922 8 1 1 7 3 1v3l4 1h-4l2 5c-3.8-1.52-6.08-4.03-8.69-7.06l-2.38-2.73L30 928l-1-1q-.06-2.5 0-5"/><path fill="#9faab3" d="M1530 914c.63 9.2.63 9.2-1.94 12.44L1526 928c-1.08 3.25-1.34 6.3-1.56 9.69L1524 944h-1l-.06-8.94-.03-2.52c-.01-4.19.07-8.36.4-12.54l.18-2.19c.51-1.81.51-1.81 1.87-3.06 1.64-.75 1.64-.75 4.64-.75"/><path fill="#000004" d="M461 758q2.34-.08 4.69-.12l2.63-.08c2.68.2 2.68.2 4.93 1.11 4.64 1.84 9.59 2.25 14.5 2.95l2.4.36 2.16.3 1.69.48 1 2h-16l-1-2c-2.5-.45-4.92-.8-7.44-1.06-7.33-.83-7.33-.83-9.56-1.94z"/><path fill="#7a797b" d="m1376 745 2 1-2 2 9-1 .38 2.44.62 2.56 2 1c-5.1 2.29-8.53 3.5-14 2l3-1-1-2-6 1 2-1q1.02-2.5 2-5z"/><path fill="#ad74e9" d="M373 662h1l.06 2.12.31 9.7.1 3.33c.22 6.29.8 12.26 2.11 18.42.44 2.52.49 4.88.42 7.43l-3 1 2 7c-3.29-3.11-5.95-5.9-8-10v-3h2l.31 1.94.69 2.06 3 1-.22-2.37c-1.07-12.86-.88-25.74-.78-38.63"/><path fill="#a99abe" d="m486 606 1.8.22c6.42.71 12.72.9 19.17.84H510l9.49-.06 12.6-.06 2.92-.03h2.69l2.37-.02C542 607 542 607 544 608c2.13-.44 2.13-.44 4-1l-1 3a5982 5982 0 0 1-29.75-.42l-9.13-.14q-2.56-.04-5.13-.06c-6-.11-11.25-.65-16.99-2.38z"/><path fill="#2e1852" d="M1288 562v4l-5 1v3c-2.6 2.6-4.1 3.92-7.81 4.25L1273 574l-1 3-6 1-1-4 2.25.25c3.58-.33 4.38-1.65 6.75-4.25q2-1.02 4-2l1-2c1.56-1.07 1.56-1.07 3.44-2.12l1.87-1.08c1.69-.8 1.69-.8 3.69-.8"/><path fill="#28292c" d="m379 345 1 4-3 1 .03 2.17.1 9.7.05 3.42q0 1.62.02 3.26l.03 3.02C377 374 377 374 375 376a90 90 0 0 0-1 6h-1l-.59-5.37L372 375l-2-1v-4l1.94-.81C374 368 374 368 375 365q.15-3.44.13-6.87v-1.86c0-3.55-.3-6.82-1.13-10.27z"/><path fill="#020203" d="M410 204h4l-1 10-3 1 .06 3.19A36 36 0 0 1 409 227h-3v-12l3-1z"/><path fill="#0e0d12" d="m786 163 4 1v2h8v1l-1.76.33-2.37.48-2.32.46c-2.5.72-4.6 1.79-6.9 3-2.98 1.33-6.1 1.88-9.27 2.48l-1.99.42c-3.4.67-6.1 1.07-9.39-.17l1-2h6v-2l14-1-1-3c.94-1.69.94-1.69 2-3"/><path fill="#1f1e22" d="M644 129a78 78 0 0 1-4.8 3.04c-6.12 3.59-12.58 8.1-15.2 14.96-.3 2.28-.5 4.47-.62 6.75l-.12 1.82q-.14 2.22-.26 4.43h2l.88 2.75A48 48 0 0 0 629 170c-2.5-1.75-2.5-1.75-5-4-.31-2.25-.31-2.25 0-4l-3-1c-1.03-13.62-1.03-13.62 2.5-18.37A84 84 0 0 1 627 139l2.07-2.19c7.86-8.12 7.86-8.12 14.93-7.81"/><path fill="#292931" d="M773 1730c2.82.08 4.51.5 6.5 2.55q1.47 1.86 2.88 3.76c2.55 2.66 5.45 3.56 9.08 3.97l2.6.04c.5 0 .5 0 2.98.06l3.17.03 3.27.06q4.29.08 8.58.13l8.77.14q8.59.14 17.17.26v1q-9.62.1-19.25.16-4.47.01-8.94.07-5.14.05-10.29.06l-3.22.05c-8.51 0-12.81-1.66-19.17-7.46l-2.37-2.14L773 1731z"/><path fill="#6c6e6f" d="M356 1700c1.2 4.67 1.1 9.21 1 14l-7-1v-12c2.22-1.11 3.56-1.08 6-1"/><path fill="#5f5e65" d="M473 1634q1.79.14 3.56.31l2 .18c2.17.45 3.7 1.05 5.69 1.98 4 1.78 7.86 2.29 12.19 2.72l2.21.25q2.68.3 5.35.56v1h-14v2a40.4 40.4 0 0 1-17-7z"/><path fill="#17171f" d="m947 1324 3 1c.26 2.27.26 2.27 0 5-1.8 1.91-1.8 1.91-4.19 3.63l-2.5 1.8A97 97 0 0 1 936 1340a60 60 0 0 0-6.73 4.34l-1.83 1.41-1.7 1.36c-1.74.89-1.74.89-3.95.53L920 1347c2.32-2.86 4.8-3.86 8.18-5.06 2.4-1.24 3.92-3.03 5.82-4.94q2.74-1.91 5.56-3.69l2.88-1.82C945 1330 945 1330 948 1329z"/><path fill="#20212a" d="M862 1237a344 344 0 0 1-11.69 8.56A53 53 0 0 0 842 1253l-1.42 1.26c-2.44 2.4-3.48 3.98-3.93 7.43-.04 4.27.17 8.2 1.35 12.31h-2c-1.77-5.32-1.86-10.5-1-16 3.67-6.9 9.93-12.2 16-17l1.92-1.57c4.96-3.86 4.96-3.86 9.08-2.43"/><path fill="#df4408" d="M1391 1250c-.7 1.96-.7 1.96-2 4-2.28.5-2.28.5-4.94.56-4.72.12-4.72.12-6.68 1.96L1376 1258c-3.19.19-3.19.19-6 0l-1 3-3 1-1 3h-3a20 20 0 0 1 6-8h3l1-3c2.28-.9 4.4-1.58 6.75-2.19l1.88-.53A31 31 0 0 1 1391 1250"/><path d="M1358 1219h31l-2 1v2h-29z"/><path fill="#26262f" d="M975 1136c8.77.34 17.22 1.07 25.82 2.84 8 1.6 16.08 2.36 24.18 3.16v1c-11.84.21-23.05-.49-34.52-3.48L988 1139l-1 1 2 2h-9l4-3-9-2z"/><path fill="#bebdbd" d="m1067 966-1 2a78 78 0 0 1-6.56 1.13l-1.87.26-4.57.61v2l4 2c-2.5 2.66-3.87 3.88-7.56 4.27q-3.73-.02-7.44-.27v-1l5.37-.59 1.63-.41 1-2-12 1c4.02-2.68 8.27-4.03 12.81-5.62l2.7-.99c4.67-1.65 8.53-2.83 13.49-2.39"/><path fill="#c7c6c5" d="M1217 946c0 3.7-.83 4.31-3.25 7l-1.83 2.06c-3.27 3.3-5.74 5.2-10.36 5.94-3.01.52-6 1.08-8.96 1.82l-1.75.44-4.97 1.25c-2.84.48-4.19.27-6.88-.51l10.5-3.37 2.98-.97a128 128 0 0 1 13.06-3.63c2.66-.64 3.3-.86 5.23-2.94l1.04-2.15c1.93-3.85 1.93-3.85 5.19-4.94"/><path fill="#16171a" d="M773 947v3l-20 1v2q-5.37.8-10.75 1.56l-3.08.46-2.97.42-2.73.4c-2.43.16-4.16-.1-6.47-.84a64 64 0 0 1 19.34-3.7c2.74-.3 4.24-1.05 6.66-2.3a75 75 0 0 1 20-2"/><path fill="#48484c" d="M187 944c4.7.72 9.27 1.72 13.88 2.88l2.15.53c4.2 1.07 8.22 2.3 12.23 3.94 2.6.97 5.27 1.55 7.98 2.12 1.88.57 3.19 1.37 4.76 2.53q-2.4.08-4.81.13l-2.7.07c-2.68-.22-3.38-.7-5.49-2.2a97 97 0 0 0-5.37-1.37c-4.6-1.11-9.16-2.29-13.7-3.63l-3.22-.94c-2.64-1.03-3.93-1.9-5.71-4.06"/><path fill="#45444a" d="M217 906h8l1 4h10v6l-3.81-.94-2.15-.52q-2.54-.69-5.04-1.54v-2h-6v-2h-2z"/><path fill="#999898" d="M230 698h6l-4 2 6 2 1-3h3c0 2.13 0 2.13-1 5a33 33 0 0 1-8 6l-3-1v-3h3v-2l-5-1z"/><path fill="#7d64aa" d="m774.19 583.94 2.17.02 1.64.04c-5.77 4.42-14.55 5.2-21.54 6.53q-2.46.47-4.92 1A133 133 0 0 1 733 594c3.16-2.29 6.5-3.04 10.25-3.94l2.13-.52a352 352 0 0 1 16.83-3.59c10.68-2.03 10.68-2.03 11.98-2.01"/><path fill="#555262" d="m782.19 567.94 3.29.02 2.52.04v1l-1.72.37-2.34.5-2.29.5a98 98 0 0 0-5.93 1.63c-4.03 1.17-8.06 1.63-12.22 2.06l-4.34.48-2.12.22q-2.9.35-5.8.8a49 49 0 0 1-7.68.5l-2.06-.02-1.5-.04v-1l2.98-.53 24.65-4.44c11.67-2.12 11.67-2.12 14.56-2.1"/><path fill="#21143c" d="M1176 435c2.19.31 2.19.31 4 1l-2.37 1.31c-2.63 1.69-2.63 1.69-3.55 3.16-1.4 1.99-2.84 2.66-5.02 3.72l-2.09 1.04c-1.97.77-1.97.77-4.97.77v2l-1.42.5-6.4 2.31-2.23.8-2.16.78-1.98.72c-2.04 1-3.24 2.26-4.81 3.89-2.34.66-4.56.77-7 1a91 91 0 0 1 13.63-7.69l2.85-1.32c2.52-.99 2.52-.99 5.52-.99l1-3c1.56-.88 1.56-.88 3.44-1.56 2.55-.94 3.57-1.45 5.56-3.44l2.44-.94c2.56-1.06 2.56-1.06 4.12-2.75z"/><path fill="#020206" d="M1210 411h3c-.24 1.84-.24 1.84-1 4-5.17 3.94-12.37 7.51-19 7l-1 4h-3l-2-4 2.38-.31c2.62-.69 2.62-.69 3.58-2.17 1.54-2.25 3.57-2.35 6.1-2.99 4.11-1.12 7.9-2.5 10.94-5.53"/><path fill="#4c4c50" d="M428 355h2a316 316 0 0 1-5.5 38.55l-1.43 7.1q-1.02 5.18-2.07 10.35h-1a90 90 0 0 1 1.5-18l1.5-9h2l.44-5.19.27-3.3q.3-3.52.54-7.06l.25-3.45.22-3.19c.24-2.38.62-4.52 1.28-6.81"/><path fill="#747478" d="M484 255c2.06 4.12.66 9.57 0 14a42 42 0 0 1-2.31 5.37c-.8 1.87-1 3.48-1.19 5.5-.54 5.69-2.1 11.47-5.5 16.13-.24-4.46-.2-7.91 1.38-12.1 2.12-6.07 3.09-12.3 4.17-18.61l.49-2.72.41-2.44c.54-2.11 1.23-3.42 2.55-5.13"/><path fill="#06050b" d="M672 175h29l-1 3h-29z"/><path fill="#434247" d="M809 165c-2.52 1.93-4.94 2.65-8 3.44l-2.96.77-3.04.79-5.56 1.56a165 165 0 0 1-12.95 2.96q-2.6.51-5.2 1.08l-2.66.59-2.4.54c-2.37.29-3.97 0-6.23-.73l7.56-1.94 2.14-.55c3.35-.85 6.62-1.6 10.05-2.02a23 23 0 0 0 7.63-2.43c7.14-3.33 13.79-4.34 21.62-4.06"/><path fill="#29282e" d="m989 151 2 1c-7.56 7-7.56 7-11.12 8.63-3.5 1.67-6 3.82-8.88 6.37l-1.98 1.67c-3.24 2.75-6.24 5.44-8.9 8.77L958 180h-2l-1 3c-2.06 1.19-2.06 1.19-4 2v-3h3l1-4 1.88-.25c2.12-.75 2.12-.75 3.25-2.62L961 173c1-1.75 1-1.75 2-3h2l.69-1.81c1.68-2.8 3.48-3.61 6.31-5.19l2.44-2c2.56-2 2.56-2 5.5-3.44 3.54-1.8 6.08-3.94 9.06-6.56"/><path fill="#101015" d="m1235 98 4 1 1 7 3 1q1.05 2.98 2 6l2 1q1.08 2.97 2 6l1 2h-4v-7h-4v6h-3v-7h3v-6h-4v6l-3-1v-5l3-1v-7h-3z"/><path fill="#fefeff" d="M729 78h19l1 5q-3.6.12-7.19.19l-2.04.07c-4.4.07-7.2-.4-10.77-3.26z"/><path fill="#d2440e" d="M1405.25 1244.81h2.9c4.72.03 9.21.22 13.85 1.19v3c-6.44.13-6.44.13-9.2-.5-3.84-.69-7.59-.65-11.49-.62h-2.41c-5.68.04-11.27.33-16.9 1.12 5.4-5.4 16.1-4.28 23.25-4.19"/><path fill="#40332a" d="M1119 1207v1h-19v2l-5.37.68c-1.63.32-1.63.32-2.63 1.32q-4.95.27-9.92.34c-5.05.2-7.47 1.1-11.08 4.66 1.3-2.9 2.55-4.96 5-7 2.13-.62 2.13-.62 4.62-.95l2.8-.38 3.02-.36 3.07-.38c9.84-1.14 19.6-1.25 29.49-.93"/><path fill="#e5eaed" d="m1476 1073 4.81 2.88 2.71 1.61 4.27 2.59c1.21.92 1.21.92 1.77 2.8-.79 2.97-2.4 3.75-4.96 5.3a18 18 0 0 1-5.6 1.82l-1-4h5v-4l-1.81-.12c-2.19-.88-2.19-.88-3.94-3.82-1.25-3.06-1.25-3.06-1.25-5.06"/><path fill="#1d1d20" d="M1414 1032c2.55 1.27 2.75 2.5 4 5l1.34 1.29c2.13 2.2 3.6 4.61 5.22 7.21l1.82 2.9 1.62 2.6q.99 1.5 2 3l-3 1q-1.01 1.5-2 3h-2v-5h-4v-3h3v-6h-2a34 34 0 0 1-6-12"/><path fill="#b2b1b0" d="M1116 954v3l3 1h-2l1 3a340 340 0 0 1-6.74-.59A10 10 0 0 1 1106 958v3l-3-1h2v-3l-9 1c5.21-5.21 13.17-4.22 20-4"/><path fill="#dbe0e6" d="M1477 894c.6 1.83.6 1.83 1 4-2.87 5.26-7 9.57-11 14l-4-1c-.23-1.79-.23-1.79 0-4 1.54-1.71 1.54-1.71 3.63-3.37 3.67-3.03 7.06-6.2 10.37-9.63"/><path fill="#89898b" d="M951 894v1l8 1-1 3h-13v-2h-13v2c-2.75.92-4.36 1.1-7.19 1.06l-2.17-.02L921 900c5.82-4.13 12.41-4.32 19.33-4.75 3.74-.35 6.84-1.35 10.67-1.25"/><path fill="#39275d" d="m969.63 702.9 5.37.1c-2.31 2.31-3.48 2.5-6.62 3.13l-2.48.5-1.9.37v2a196 196 0 0 1-29 6c2.75-2 2.75-2 5-2v-2l2.38-.33c5.3-.8 10.12-1.58 14.96-3.94 2.6-1.14 5.3-1.7 8.06-2.3 1.9-.5 2.26-1.4 4.23-1.53"/><path fill="#7759aa" d="M694 602c-4.96 1.87-9.77 2.65-15.02 3.29l-2.6.33-5.43.67q-4.12.5-8.23 1.03l-5.3.66-2.46.3c-5.34.65-10.58.84-15.96.72v-1a472 472 0 0 1 41.94-5.8q3-.3 5.97-.78c2.82-.38 4.45-.37 7.09.58"/><path fill="#000002" d="M895 535h14v3q-2.71.04-5.44.06l-3.06.04c-2.5-.1-2.5-.1-3.5-1.1l-1 3c-3.11 2.1-6.06 2.36-9.75 2.63l-2.98.22-2.27.15c1-3 1-3 2.56-4.12 2.92-1.05 5.65-1.3 8.73-1.54L894 537z"/><path fill="#0c0c11" d="m1659 491 2 1v4h2c2.03 4.22 2.35 7.73 2.31 12.31v2A30 30 0 0 1 1662 524h-1l.07-3.16A180 180 0 0 0 1659 491"/><path fill="#000001" d="m366 436 4 1a3213 3213 0 0 1-.68 19.34l-.11 3.14C369 462 369 462 368 464h-2z"/><path fill="#aaa8ac" d="M1446 367v3h-28v-3q5.02-.3 10.06-.56l2.86-.17c5.2-.27 9.95-.23 15.08.73"/><path fill="#313135" d="M673 234q4.63-.09 9.25-.12l2.64-.06q1.27 0 2.56-.02l2.35-.03c2.46.26 4.07 1 6.2 2.23v2c-5.45.98-10.43.97-15.94.56l-2.36-.16q-2.85-.19-5.7-.4z"/><path fill="#0d0c11" d="M831 151h4v2h7c-2.27 2.27-3.66 2.69-6.7 3.62l-2.86.9-3 .92-5.82 1.8q-1.4.45-2.86.88c-3.3 1.05-6.56 2.23-9.8 3.45-2.3.5-3.74.16-5.96-.57h3v-2h8v-2l4.88-1.5 2.74-.84C826 157 826 157 828 157v-2l-4-1 2.94-.37L830 153z"/><path fill="#99989b" d="M616 145h2l.11 3.1.2 4.09.07 2.04c.24 4.43 1.13 6.99 3.62 10.77-.31 2.81-.31 2.81-1 5h-3v-4l-2-1c-1.52-1.52-1.13-2.88-1.13-5v-12.25C615 146 615 146 616 145"/><path fill="#67666f" d="M1280 1679h1a2863 2863 0 0 1 .15 19.94q.04 3.66.05 7.3l.03 2.25c0 5.34-.69 10.38-2.23 15.51l-1 1c-.24 2.22-.38 4.44-.52 6.68-.57 2.74-1.67 4.21-3.48 6.32a123 123 0 0 1 2.9-20.5c.97-4.37.97-4.37 2.1-5.5q.24-3.66.32-7.32l.06-2.21.31-11.79z"/><path fill="#573923" d="M1064 1264c2.94.38 2.94.38 6 1 1.55 3.1.83 6.26.56 9.63l-.16 2.14-.4 5.23c-3.76-1.51-3.76-1.51-4.83-3.66A54 54 0 0 1 1064 1264"/><path fill="#644c3d" d="M1271 1247c.68 1.67.68 1.67 1 4-1.18 2.55-1.18 2.55-2.87 5.31-3.48 6.01-3.48 6.01-3.82 9.5-.3 3.05-1.02 4.8-2.49 7.44-1.44 3.06-2.2 6.33-3.07 9.58-.75 2.17-.75 2.17-2.75 4.17.95-6.11 2.45-12.02 4-18l.46-1.95c1.81-7.13 4.93-14.3 9.54-20.05"/><path fill="#26252e" d="m721 1152 2 1-1.8 1.4-2.39 1.85-2.49 1.93a245 245 0 0 0-5.13 4.13c-2.19 1.69-2.19 1.69-4.25 2.63-2.68 1.47-4.07 3.69-5.94 6.06h-2v2l-2.81.88C693 1175 693 1175 691.56 1176c-1.88 1.2-3.58 1.63-5.75 2.13l-2.17.5-1.64.37c2.22-3.51 4.1-4.66 8-6l1-1q3-.06 6 0l.63-1.69c2.33-3.92 5.82-6.39 9.7-8.7 2.96-1.78 5.71-3.75 8.48-5.8l1.52-1.11z"/><path fill="#34333c" d="m784 1082 2 1c-.75 4.75-.75 4.75-3 7a52 52 0 0 0-1.31 3.25c-1.6 3.92-3.86 6.62-6.69 9.75v-3l-1-1-2 1c1.5-3.11 3.22-6.04 5-9q1.02-2 2-4h2l1-3z"/><path fill="#0b0a11" d="m413.85 1055.76 2.21.01h2.42l2.52.04h2.52c6.2.05 6.2.05 8.48 1.19.38 2.25.38 2.25.56 5 .34 4.9.34 4.9 1.44 6 .32 2.06.51 4.12.72 6.19.28 1.81.28 1.81 1.28 2.81 1.09 5.68 1.1 11.23 1 17h-1l-.37-1.94-.5-2.62-.5-2.57c-.63-2.87-.63-2.87-1.63-6.12-1.26-4.31-1.65-8.67-2.06-13.13l-.48-4.83-.2-2.14C430 1059 430 1059 429 1058q-3.93-.11-7.88-.06l-2.22.01-4.1.03q-2.4.03-4.8.02c2-2 2-2 3.85-2.24"/><path fill="#0d0d14" d="M819 1052v1l-2.43.26-18.37 1.99-3.38.36c-2.82.39-2.82.39-5.82 1.39l-.08 1.84c-.43 6.42-1.54 12.58-4.92 18.16-2.14 1.28-2.14 1.28-4 2a46 46 0 0 1 4-10.94c1.8-3.39 2.7-6.52 3.31-10.3 1.06-2.7 2.1-3.4 4.69-4.76 3.08-.5 6.14-.6 9.25-.69l2.49-.1c5.09-.2 10.17-.24 15.26-.21"/><path fill="#5b5864" d="M1278 1006h3c.33 4.86.18 7.96-2.27 12.29-3.36 7.91-2.03 19.31-2.3 27.9L1276 1060h-1c-.62-46.77-.62-46.77 3-54"/><path fill="#cfced0" d="M1413 972c1.15 3.37.82 5.15-.41 8.45-1.98 6.16-2.07 12-2.03 18.42l-.06 3.32c0 7.79 1.05 14.75 4.5 21.81 1.62 1.22 1.62 1.22 3 2 .4 2.39.14 4.56 0 7-2.83-1.42-3.2-3.2-4.5-6.06l-1.28-2.79c-3.03-7.82-3.6-15.16-3.6-23.48q0-2.85-.07-5.7A55.5 55.5 0 0 1 1413 972"/><path fill="#39393d" d="M339 974c9.73-.11 19.3.07 29 1v2l2.13-.04c5.73-.05 11.2.18 16.87 1.04v1c-8.86.21-17.54-.33-26.36-1.17l-6.5-.59-6.12-.56c-3.4-.34-6.07-.9-9.02-2.68"/><path fill="#010105" d="m897.81 921.81 2.4.08 1.79.11v3l-2.12.37-2.75.5-2.75.5C892 927 892 927 890 929c-2.58.14-5.05.19-7.62.13l-2.15-.03-5.23-.1 1-2c2.96-.61 5.92-.96 8.92-1.34 3.08-.66 3.08-.66 5.67-2.21 2.7-1.63 4.12-1.78 7.22-1.64"/><path fill="#151619" d="M432 799c4.14-.3 7.7-.32 11.56 1.31 3.83 1.6 7.63 2.42 11.69 3.19 4.9.94 9.77 1.91 14.63 3.06l2.92.7c2.2.74 2.2.74 3.2 2.74l-18-2v-2c-4.65-.81-9.31-1.44-14-2v-2l-12-2z"/><path fill="#4e4d51" d="m1328 783-1.64.61-7.3 2.76-2.57.97-2.45.93-2.27.86c-1.77.87-1.77.87-2.77 2.87-2.29.97-4.53 1.82-6.87 2.63l-1.94.69c-2.86 1-5.13 1.68-8.19 1.68v2c-3.53 1.67-5.28 2.14-9 1 17.38-8.23 17.38-8.23 23.88-11.06 3.57-1.58 7.1-3.24 10.64-4.89 4.05-1.71 6.23-2.22 10.48-1.05"/><path fill="#513086" d="m702.2 758.9 7 .05 5.8.05v1c-6.36.74-12.6 1.14-19 1v2c-13.34.63-26.64 1.18-40 1v-1l3.32-.26 25.1-1.99 2.37-.18c5.17-.42 10.24-1.7 15.42-1.67"/><path fill="#1b1b20" d="M1436 707h7c-.92 3.1-1.64 4.71-4.19 6.75a24 24 0 0 1-8.1 2c-2.4.35-4.48 1.32-6.71 2.25h-3c1-3 1-3 2.45-3.91l1.8-.72 1.9-.76c1.85-.61 1.85-.61 4.66-1.05 2.19-.56 2.19-.56 3.5-2.62z"/><path fill="#191a1e" d="M315 674h1l-.05 2.2-.11 8.25-.1 5.25c-.08 8.79.98 16.84 4.38 25 1 2.6.83 3.73-.12 6.3l-.52-1.4-.73-1.98-.79-2.15a75 75 0 0 0-2-4.68c-3.08-6.77-3.45-12.55-3.21-19.91l.05-3.2q.08-3.84.2-7.68h2z"/><path fill="#0d0d13" d="M1470 652c0 3.86-1.67 5.22-4.19 7.88l-1.26 1.35L1461 665l-1.43 1.55A62 62 0 0 1 1443 679l-2-1 1.88-.75c2.12-1.25 2.12-1.25 3.06-3.25 1.46-2.75 3.17-3.09 6.06-4v-2l2.25-1.31c2.54-1.56 3.77-2.87 5.5-5.22 2.7-3.2 6.07-5.77 9.25-8.47z"/><path fill="#a69dbc" d="M541 594h20v1l26 1v1a5049 5049 0 0 1-20.59.65l-7.53.23-2.33.08c-4.97.14-9.64-.24-14.55-.96z"/><path fill="#0a090e" d="m1414 548 1 3c-8.87 15.12-25.2 29.8-40 39l-1-2 2.54-1.61c3.86-2.58 7.34-5.54 10.84-8.58l2-1.74c4.98-4.39 9.38-8.96 13.62-14.07l3.81-4.25A48 48 0 0 0 1413 549z"/><path fill="#000005" d="M525 546c10.4-.1 20.65-.02 31 1v3l-31-1z"/><path fill="#130b25" d="m1359 509 1 3c-2.69 2.77-5.4 5.36-8.37 7.81a54 54 0 0 0-6.88 7c-3.15 3.71-6.67 6.73-10.47 9.74A268 268 0 0 0 1320 549c0-3 0-3 2.27-5.42l3.1-2.77q.82-.73 1.65-1.48c3.34-2.97 6.76-5.78 10.3-8.52a64 64 0 0 0 10.08-10 46 46 0 0 1 6.39-5.72c1.62-1.46 2.36-3.1 3.21-5.09z"/><path fill="#0c0819" d="m1091 474 2 1c-3.85 4.4-7.26 6.26-12.75 8.13l-2.15.76q-6.6 2.37-13.3 4.47c-1.8.64-1.8.64-3.9 1.74-2.38 1.12-3.47.77-5.9-.1 2.4-2.4 3.86-2.6 7.13-3.37A58 58 0 0 0 1076 481c4.92-2.53 9.83-5.03 15-7"/><path fill="#352162" d="m1280 434 2 1v2h2c3 4.95 5.2 9.8 7.13 15.25l1.5 4.2.66 1.85a26 26 0 0 0 2.71 4.7c.63 2.04.63 2.04 1.13 4.19l.5 2.17.37 1.64c-1.46-.84-1.46-.84-3-2-.69-2.62-.69-2.62-1-5h-2l-3-12h-2l-3.5-6.75-1.01-1.92-.96-1.88-.89-1.71c-.74-2-.75-3.62-.64-5.74"/><path fill="#462e7c" d="m1283 378 5 1c.23 2.3.23 2.3 0 5-3.37 3.78-7.87 6.68-13 7 1.17-5.53 2.2-7.68 7-11z"/><path fill="#18181c" d="M580 120v1l-2.15.32-8.04 1.22q-2.56.4-5.13.77c-5.83.91-11.3 2.03-16.84 4.03-2.15.77-4.3 1.32-6.53 1.78l-2.22.47q-4.04.8-8.09 1.41l2-1 1-3 1.98-.37 2.64-.5 2.6-.5c2.78-.63 2.78-.63 5.44-1.6 4.67-1.44 9.37-2.02 14.22-2.66l2.89-.4c5.46-.74 10.72-1.11 16.23-.97"/><path fill="#3e3d48" d="M637 1523c2.06.44 2.06.44 4 1-3.08 3.33-4.6 4.94-9 6l-.87 1.88c-1.64 3.08-4.03 3.7-7.13 5.12l-2 2-3-1c2.8-4.85 4.77-6.67 10-9q1.53-1.47 3-3 1.98-1.05 4-2z"/><path fill="#1c0303" d="M1446 1259c3.78 1.51 3.78 1.51 4.88 3.94 1.63 3 4.1 3.58 7.12 5.06 2.62 2.72 3.84 5.82 5.25 9.27 1.03 2.38 2.4 4.52 3.75 6.73 2.25 4.38 2.21 8.2 2 13h-1v-5h-2l-1.5-4.31-.84-2.43c-.66-2.26-.66-2.26-.66-5.26h-2l-1.25-2.69a31 31 0 0 0-8.87-10.81c-2.73-2.17-3.9-4.1-4.88-7.5"/><path fill="#d2d9de" d="M1461 1050c2 1.38 2 1.38 4 3v2l1.81.69c2.82 1.69 3.62 3.44 5.19 6.31 3.65 6.28 8.81 10.99 14 16-3.8 0-4.95-.73-8-3-1.59-1.97-3.05-4-4.5-6.08a17 17 0 0 0-4.37-4.05c-2.13-1.87-2.13-1.87-2.44-5.12l.31-2.75h-3v-3h-3z"/><path fill="#010103" d="M1038 1030c9.53-.37 9.53-.37 14 2l1 1q2.27.34 4.56.56l2.5.26 1.94.18 1 3-5.87.06-3.31.04c-2.82-.1-2.82-.1-4.82-1.1v-2l-3.25.13c-3.08 0-5.1-.44-7.75-2.13z"/><path fill="#000002" d="M166 941c6.4-.5 6.4-.5 9.31 1.94 3.93 3 7.86 2.87 12.69 3.06l1 3-1 1q-2.79-.14-5.56-.44l-3.07-.3L177 949l-1-3-9-1z"/><path fill="#94a2ad" d="m1502 835 4 2-.25 1.63c-1.07 8.2-.41 16.17.25 24.37h-3l-1 3z"/><path fill="#7f7e7f" d="M1401 737h2v2h5c-.36 2.55-.58 3.68-2.67 5.27-6.93 3.28-6.93 3.28-11.33 2.73.31-1.94.31-1.94 1-4l3-1q-4.53.87-9 2c2.35-2.77 4.15-3.52 7.69-4.19l2.45-.48 1.86-.33z"/><path fill="#a882da" d="M540 724h1c1.06 6.86 1.12 13.57 1.06 20.5L542 756l-3-1-.08-17.1-.02-6.24-.01-1.97q.01-2.35.11-4.69z"/><path fill="#b585ed" d="M375 671h1l.04 1.65c.57 18.95.57 18.95 3.34 27.94.66 2.56.73 4.78.62 7.41h-3l2 1c.63 3.06.63 3.06 1 6-3-2-4.75-3.57-6-7-.12-2.25-.12-2.25 0-4l2-1-.22-1.78c-.74-6.64-.9-13.17-.84-19.85l.01-3.04z"/><path fill="#17161d" d="m1511 666 3 1h-2l-2 4h-4v3l2 1q-2.17 1.47-4.36 2.91-2.1 1.4-4.14 2.84c-3.01 1.5-5.18 1.48-8.5 1.25a91 91 0 0 1 4-4h2v-4a81 81 0 0 1 10.38-4l2.62-1z"/><path fill="#180e2c" d="m1145 644-2.85.95-3.78 1.3-1.87.62a34 34 0 0 0-8.63 4.17c-6.72 4.59-14.96 6.47-22.87 7.96v-2l2.05-.55 2.7-.76 2.67-.74c2.66-.98 4.39-2.18 6.58-3.95a42 42 0 0 1 9.69-4.06c3.31-.94 3.31-.94 4.83-1.96 3.6-2.4 7.5-2.12 11.48-.98"/><path fill="#25262a" d="m255 617 4 1h-3v3l-8 1v4h8l-2 3c-2.62.19-2.62.19-5 0v-2q-1.94-.08-3.87-.12l-2.18-.08c-1.95.2-1.95.2-3.95 2.2-2.62.13-2.62.13-5 0l-1 4h-6v3l-3-1 1-5 1 2 6-1 1-3c1.39-.47 2.78-.94 4.2-1.33 2.14-.8 3.8-1.94 5.68-3.23 3.96-2.6 7.42-3.3 12.12-3.44z"/><path fill="#6c6b6e" d="M626 132h4c-.62 3.7-.62 3.7-2.56 5.25L626 138v-3h-67v-1h67z"/><path fill="#333238" d="m984 105 1 2h9v3l-1.93.4-2.5.54-2.5.52C985 112 985 112 984 113q-2.02.1-4.06.06l-2.23-.02L976 113l2-4-2-1z"/><path fill="#4f4e58" d="M429 1662h1l.08 3.09.42 16.23.23 9.27c.16 5.18.16 5.18 1.27 7.41q.65 4.65 1.16 9.33A38 38 0 0 0 436 1718c-.37 2.31-.37 2.31-1 4l-1-5h-2c-3.6-12.5-3.13-25.37-3.06-38.25l.01-4.9z"/><path fill="#494852" d="m1261 1595 2 1c-1.16 10.04-6.38 19.28-14 26v-5l1.94-.69c2.06-1.31 2.06-1.31 2.62-3.62l.44-2.69c1.42-2.59 3.01-5.02 4.63-7.5a20 20 0 0 0 2.37-7.5m-14 27 2 1-7 6v-3c2.5-2.19 2.5-2.19 5-4"/><path fill="#492e1d" d="M1043 1287h1v6h2c2.12 7 3.54 13.7 4 21l-6-2 3-1-1.44-2.12c-2.73-5.04-2.68-10.12-2.62-15.76l.02-3.49z"/><path fill="#0f0b09" d="M1047 1240c.97 3.05.97 4.85.07 7.9-1.36 5.62-1.34 11.12-1.28 16.88q.03 2.67.02 5.36c.03 6.04.27 11.89 1.19 17.86l-1-3h-2c-3.81-32.28-3.81-32.28 3-45"/><path fill="#08090f" d="M1356 1215a1962 1962 0 0 1 16.38-.15q3-.04 5.98-.05l1.88-.03c3.16 0 5.36.04 7.76 2.23-1 1-1 1-3.6 1.11l-24.4-.11v-2z"/><path fill="#52515c" d="m436 1189 2 1v23l-2-2-1 9h-1q-.09-5.77-.12-11.56l-.06-3.3c-.03-5.78.2-10.68 2.18-16.14"/><path fill="#191921" d="m965 1130 10.78 1.2 2.3.26 2.13.24c1.79.3 1.79.3 3.79 1.3v2l3.47.43 2.21.28 2.32.29 8.5 1.06 7.5.94v2h8v1c-7.85.59-15.23-1-22.87-2.57l-6.43-1.26-4.08-.8-1.92-.37c-2.4-.47-4.5-.9-6.7-2v-2l-9-1z"/><path fill="#9e9fa2" d="M268.05 1028.57c1.89.74 1.89.74 4.95 2.43l1 3h-16l-2-5c3.73-1.87 8.08-1.53 12.05-.43"/><path fill="#09090e" d="m711.19 957.94 2.17.02 1.64.04a42 42 0 0 1-5 3v3c-1 1-1 1-2.63 1.1L702 965v-3l-21 1v-1l2.06-.26 12.5-1.59 3.12-.4 2.87-.36c9.2-1.46 9.2-1.46 9.64-1.45"/><path fill="#404044" d="m706.9 955.9 2.14.01 2.21.03 2.26.01 5.49.05c-7.65 4.19-16.68 4.2-25.2 4.6-8.31.42-16.55 1.4-24.8 2.4 2.75-1.96 4.89-2.49 8.25-3l3.2-.5q6.74-.96 13.47-1.8c2.39-.3 4.72-.6 7.04-1.22 2.06-.48 3.82-.6 5.93-.58"/><path fill="#58575a" d="M1118 844c-19.6 8-19.6 8-28 8v2a61 61 0 0 1-19 4c2.47-2.47 4.5-3 7.82-4.1l1.75-.58 3.68-1.2 5.55-1.84c6.02-2 12.03-3.81 18.2-5.28l3.25-1.19c2.91-.86 3.94-.66 6.75.19"/><path fill="#27272b" d="M351 756c3.05 1.32 4.7 2.6 6.69 5.25 4.85 5.9 11.45 9.58 18.31 12.75v2l5 2c-2 1-2 1-5.06.19C373 777 373 777 372 775q-2.5-1.03-5-2-2.45-1.43-4.81-3l-2.4-1.56L358 767v-2h-2l-1.37-2.81A44 44 0 0 0 351 756"/><path fill="#5c3693" d="m856 727 3 1-1 1c7.03.27 7.03.27 10-2 2.19-.12 2.19-.12 4 0l-1 3a56 56 0 0 1-6.81 2.69l-2.03.67c-6.16 1.9-11.72 2.18-18.16 1.64v-1l7-2-4-2 5.27-.59C854 729 854 729 856 727"/><path fill="#1a191f" d="m1459 667 2 1c-6.37 5.53-12.67 10.25-20.06 14.36-3.72 2.08-7.3 4.36-10.88 6.67-2.06.97-2.06.97-5.06-.03 4.16-3.93 8.46-6.35 13.61-8.84A67 67 0 0 0 1449 674z"/><path fill="#937db9" d="m745.85 587.9 2.21.04 2.23.02 1.71.04c-2.17 1.6-4 2.4-6.62 3l-2.09.5-2.17.5c-7.6 1.77-7.6 1.77-10.6 3.09-3.58 1.3-7.06 1.37-10.83 1.54l-2.25.11q-2.72.14-5.44.26c2.63-1.4 5.1-2.41 8-3.12l2-.51c2.05-.38 3.93-.43 6-.37v-2l3.25-.4 6.33-.8c2.98-.39 5.57-1.76 8.27-1.9"/><path fill="#abadae" d="m344 486 2 1v26l-3-1a70 70 0 0 1-1.19-13.56l-.03-1.96c-.02-3.95.46-6.86 2.22-10.48"/><path fill="#403f44" d="M447 260h1c.24 4.38.24 7.8-1.35 11.9-2.2 6.28-3.13 12.8-4.21 19.35l-.95 5.66-.85 5.1A40 40 0 0 1 438 310c-.98-3.09-.96-5.01-.12-8.12a58 58 0 0 0 1.56-9.75c.4-4.68 1.5-8.96 2.8-13.46q1.04-3.68 1.82-7.42c.82-3.8 1.83-7.52 2.94-11.25"/><path fill="#29282d" d="m924.56 157.94 2.44.06v4h-6l-1 3-2.87.31-3.13.69q-.45.77-.94 1.56L912 169c-2.39.28-4.59.13-7 0 4.86-5.13 9.2-7.83 16-10 1-1 1-1 3.56-1.06"/><path fill="#201e24" d="m1000.25 95.38 1.75.62-6 1v2l-2.54.81a105 105 0 0 0-19.21 8.44c-5.7 3.23-10.55 5-17.25 4.75a23 23 0 0 1 9.06-4.69l2.6-.76C971 107 971 107 974 107v-2h3v-2c4.8-2.2 9.58-3.83 14.67-5.25C994 97 994 97 996.16 95.89c1.84-.89 1.84-.89 4.09-.52"/><path fill="#f3f4f4" d="M838.38 81.9q2.1 0 4.18.04l2.16.01L850 82c-.34 1.92-.34 1.92-1 4-4.62 2.25-8.94 2.23-14 2a88 88 0 0 1-1-5c1-1 1-1 4.38-1.1"/><path fill="#08090e" d="M623 78h10c-4.13 3.1-5.8 3.43-10.6 3.34h-1.99q-3.22 0-6.46-.05l-4.49-.01q-5.9-.02-11.8-.07-6-.05-12.04-.06-11.81-.06-23.62-.15v-1l61-1z"/><path fill="#c5c7ca" d="M1079 2h20l-3 3h-3v2q-3.2.08-6.37.13l-1.83.05c-1.6.01-3.2-.08-4.8-.18l-2-2z"/><path fill="#4c4e4e" d="m448 1811 6 1v3h10q.06 3 0 6c-1.5 1.5-2.8 1.28-4.87 1.38-2.18-.03-2.18-.03-4.13-.38-2.2-2.48-2.92-4.02-3.19-7.31l.19-1.69z"/><path fill="#0c0c12" d="M1006 1365c5.57 1.6 9.52 3.23 14 7a68 68 0 0 0 3.56 1.5c3.23 1.28 5.05 2.95 7.44 5.5l5 3 1 2c-2.87-.31-2.87-.31-6-1-1.12-1.5-1.12-1.5-2-3-2.12-.69-2.12-.69-4-1v-2l-2.22-.32c-3-.73-4.77-1.78-7.28-3.56a86 86 0 0 0-7.56-4.87L1006 1367z"/><path fill="#b3b2ae" d="M1296 1288c1.49 3.86.3 6.2-1 10-.26 2.99-.26 2.99-.27 6.1v5.19q.01 2.66 0 5.32v3.43l.01 3.1c.27 3 1.09 5.12 2.26 7.86.32 1.94.32 1.94.41 3.77l.12 2 .1 2.04.11 2.1q.14 2.55.26 5.09c-6-7.85-5.48-19.68-5.4-29.08q.02-3.18-.01-6.34c-.01-7.34.31-13.77 3.41-20.58"/><path fill="#8b8a91" d="M393 1288h1c1.23 11.66 1.1 23.3 1 35h-1l-1-5h-1l-.08-16.53-.02-6.03-.01-1.9q.01-2.28.11-4.54z"/><path fill="#cac0b6" d="m1353 1230 2 1-1 1 26 1v1h-2.14q-4.8.02-9.61.13l-3.37-.01c-8.1.2-8.1.2-10.43 2.24-1.03 1.63-1.03 1.63-2.45 4.64-3.29 1.1-4.71.8-8 0l2-4h5v-4l-2-1c1.88-1.06 1.88-1.06 4-2"/><path fill="#9eabb5" d="m1555 1194 3 1-3 3 3 1h-4v5l-6-1-1 4h-6v-5h6l1-4 1.81-.31c2.47-.78 3.51-1.76 5.19-3.69"/><path fill="#bebebd" d="M1061 992v1l-1.54.34-7.02 1.53c-.4.1-.4.1-2.43.53-3.62.8-7.2 1.62-10.76 2.67-7.58 2.18-15.41 3.2-23.25 3.93 3.04-2.62 5.17-3.41 9.13-3.87 5.5-.79 10.62-2.16 15.93-3.75 6.85-2.02 12.76-3.1 19.94-2.38"/><path fill="#343338" d="m832.04 824.95 2.22-.01q4.87 0 9.74.06v1c-30.12 5.47-30.12 5.47-40 5 6.66-5.4 19.68-6.02 28.04-6.05"/><path fill="#98989a" d="m132 767 3 1c-1.52 3.7-3.89 6.07-6.81 8.69l-1.3 1.22c-2.85 2.58-5.06 3.68-8.89 4.09v-4h4v-4l1.69-.19c3.34-1.17 4.9-3.21 7.31-5.81zm-14 15-1 2-2-1-1 3-4-1c2.47-3.12 4.1-3.32 8-3"/><path fill="#56338c" d="M907 714c2.06-.4 2.06-.4 4.44-.37l2.37-.03c2.63.48 3.52 1.37 5.19 3.4-1 1-1 1-4.06 1.06L912 718v2l-8 2v-3h-13v-1l3-.11 3.88-.2 1.97-.07c1.9-.1 1.9-.1 5.15-.62 1.17-1.52 1.17-1.52 2-3"/><path fill="#2f185c" d="M1030 592c2.92 2.26 3.53 4.55 4.4 8.06a29 29 0 0 0 2.6 5.69c2.17 4.12 2.87 8.15 3.5 12.73.5 2.52.5 2.52 1.62 4.6 1.1 2.39.75 3.5-.12 5.92q-3.03-6.99-6-14l-.96-2.25c-2.5-6.24-5.04-14-5.04-20.75"/><path fill="#ac9bc8" d="M647 599h20l-2 3c-2.25.43-2.25.43-5 .51l-3 .1-3.12.08-3.16.1q-3.86.12-7.72.21v2l-9 1 4-2 2.5-1.62c2.51-1.39 3.7-1.69 6.5-1.38z"/><path fill="#b99ddc" d="M397 569h2c.2 11.5.2 11.5-1 17l-2 2c-2.12-.37-2.12-.37-4-1q.17-2.91.38-5.81l.2-3.27c.58-4.03.58-4.03 2.42-5.92h2z"/><path fill="#6c6c6e" d="M339 566c.68 1.74.68 1.74 1 4-1.14 2.07-1.14 2.07-2.81 4.13l-1.65 2.07C334 578 334 578 332 580c-2.62.13-2.62.13-5 0-.23-1.74-.23-1.74 0-4 1.6-2.07 1.6-2.07 3.75-4.12q1.05-1.03 2.1-2.08A24 24 0 0 1 339 566"/><path fill="#1a181f" d="m1070 81 1 3-2.6-.98c-7.45-2.47-15.6-1.73-22.8 1.11-5.99 2-12.38 2.8-18.6 3.87 1-2 1-2 2.47-2.71C1061 74.98 1061 74.98 1070 81"/><path fill="#8b8b8f" d="M1419 1686h3v15l-5 1c-1.64-1.64-1.21-3.5-1.25-5.75.04-4.2.34-7.01 3.25-10.25"/><path fill="#726f7a" d="M1311 1528h1l1 12h1v28h-2a243 243 0 0 1-1.06-26.75l.01-3.88z"/><path fill="#c4bcb2" d="M1336 1239v2h5l1 5-2.44.94C1337 1248 1337 1248 1336 1249q-3 .06-6 0v-4l3-1 1-3-2.87 1.56-3.13 1.44-2-1c2.82-3.4 5.67-4 10-4"/><path fill="#322c2a" d="M1054 1215c.9 2.4 1.34 4.2.32 6.62l-2.14 3.86c-1.76 3.75-2.75 7.68-3.78 11.68-.8 2.99-1.6 5.34-3.4 7.84 1.36-18.02 1.36-18.02 5.88-22.58 1.57-1.99 1.75-3.95 2.12-6.42l-2.7.7-3.55.86-1.78.46c-3.31.8-5.74 1.19-8.97-.02 6.1-2.86 11.3-3.37 18-3"/><path fill="#6d5e55" d="M1053 1216c-.59 5.37-.59 5.37-1 7l-2 1 1-6-1.62.55c-6 1.93-12 3.21-18.19 4.32q-5.11.88-10.19 2.02l-1.91.44q-2.6.6-5.2 1.22c-2.75.43-4.3.38-6.89-.55a611 611 0 0 1 19.31-4.69l3.37-.76a52 52 0 0 1 10.32-.55v-2c4.4-1.22 8.4-2.29 13-2"/><path fill="#17161e" d="M779 1083v3l-2 2-.75 2.69c-1.1 3.6-3.3 5.9-5.8 8.65A38 38 0 0 0 766 1106a51 51 0 0 1-9 11c1.63-4.65 1.63-4.65 3.5-6.44 1.5-1.56 1.5-1.56 2.36-4.32 1.2-3.43 2.7-5.73 4.89-8.62l1.07-1.42 3.18-4.2 3.44-4.62c2.42-3.24 2.42-3.24 3.56-4.38"/><path fill="#1b1a22" d="M429 1069c2.33 3.17 2.8 6.44 3.44 10.25l.76 4.33.4 2.23q.58 3.14 1.26 6.26c1.44 6.68 2.64 13.05 2.14 19.93-3.54-4.92-4.3-11-5.07-16.89l-.26-1.89-.53-3.92-1.34-9.81-.24-1.8a53 53 0 0 1-.56-8.69"/><path fill="#121118" d="M1266 1023c0 3.14-.56 4.7-1.75 7.56l-1.1 2.68-2.27 5.43-1.07 2.58-.98 2.33c-1.71 5-2 10.1-2.15 15.35l-.06 1.92-.31 10.13-.31 10.02h-1q-.12-6.5-.16-13.01l-.07-4.41c-.2-11.28-.01-22.23 6.32-32.05.96-1.6 1.5-3.11 2.04-4.9l.87-2.63z"/><path fill="#b4b5b6" d="M1043 1019h3v3l2 1-1 3 1.8.37c4.24.9 8.22 1.83 12.2 3.63-7.25 2.56-16.37-1.83-23-5l-4.94-2.19-4.06-1.81c1.94-.56 1.94-.56 4-1l1 1q2.52.34 5.06.56l2.79.26 2.15.18z"/><path fill="#cdcccb" d="M1069 995c-3.27 1.88-6.21 2.47-9.94 3a44 44 0 0 0-11.06 3q-2.8.6-5.62 1.06c-2.94.49-5.55 1-8.38 1.94-2.69-.44-2.69-.44-5-1 1-2 1-2 3.81-3 4.28-1.12 8.63-1.8 13-2.5q3.18-.5 6.35-1.07c5.71-1.03 11.03-1.65 16.84-1.43"/><path fill="#030408" d="m664.31 965.94 2.12.02 1.57.04v1l-7 1 1 2-1.5.4c-.32.1-.32.1-1.94.54l-1.93.52C655 972 655 972 654 973q-3.53.11-7.06.06l-2.01-.01L640 973v-1h8v-2h-13v-1l1.76-.17 7.93-.77 2.76-.26 2.69-.26 2.46-.24c11.08-1.37 11.08-1.37 11.71-1.36"/><path fill="#9ba7af" d="m1530 945 4 1v23l-4 1z"/><path fill="#97989a" d="m801.58 931.89 2.02.01 4.46.04 2.3.01 5.64.05v2c-3.15.94-5.91 1.08-9.19 1a92 92 0 0 0-4.81 0l-1 1c-2 2-2 2-5.26 2.2q-1.94-.02-3.87-.08l-2-.02-4.87-.1c2.56-2.56 4.55-2.76 8.06-3.56 2.84-.65 5.89-2.36 8.52-2.55"/><path fill="#9f9e9f" d="M97 891h3q1.02 1.5 2 3a30 30 0 0 0 4 2v2h4l.25 1.88c.75 2.12.75 2.12 2.81 3.37l1.94.75v2c-6.34-.16-9.67-3.64-14-8-4-4.3-4-4.3-4-7"/><path fill="#7c7b7d" d="M982 889q1.94-.04 3.88-.06l2.17-.04c1.95.1 1.95.1 3.95 1.1q2.3.32 4.63.56l2.47.26 1.9.18c-3.42 3.42-8.29 3.95-12.87 5.19l-3.06.86-2.94.8q-1.32.38-2.69.74c-2.53.42-4.04.23-6.44-.59h3v-3l12-2-6-3z"/><path fill="#1d1d22" d="M74 833c.93 3.01 1.04 3.87 0 7a79 79 0 0 0 2.34 28.5C77 871 77 871 77 874c-6.16-7.6-6.3-16.47-6.37-25.87l-.05-2.78c.04-4.94.23-8.32 3.42-12.35"/><path fill="#fefeff" d="M1469 817h1v20l-5 1c-.28-6.48.09-12.76 2-19z"/><path fill="#424246" d="m1466 801 4 1c.19 2.31.19 2.31 0 5l-1.44 1.37c-5.53 5.78-4.16 19.87-4.56 27.63h-1q-.11-5.55-.16-11.1l-.07-3.76c-.14-7.3.04-13.44 3.23-20.14"/><path fill="#07080c" d="M161 674h6v3l-5 1v3l3 1h-3l-1 3c-3 1-3 1-6 0v-3h-5l4-1v-3l6-1z"/><path fill="#2f2d34" d="M968 457c2.42 2.8 2.4 5.58 2.63 9.19l.22 3.3.15 2.51-3 1v2h-6l3-1c-2.14-2.14-3.13-2.43-6-3v-1l6-1 .4-2.12 1.06-5.5C967 459 967 459 968 457"/><path fill="#0d0d0f" d="m1666 437 4 3c1.77.35 1.77.35 3.69.56l3.31.44 1 2a31 31 0 0 0 3 2h-5l-1-3h-7l-1 2c-1 1-1 1-3.56 1.06L1661 445v-3l-3-1v-3c2.8-1.4 4.89-1.25 8-1"/><path fill="#090514" d="m1266 379 2 1c-11.53 11.89-11.53 11.89-19 14q-1.59.98-3.12 2.06c-2.48 1.67-4 2.5-6.88 2.94l1-4-2-1 4-2v2a32 32 0 0 0 11.44-5.81l2.86-2.15 2.7-2.04 4.19-3z"/><path fill="#040407" d="M504 145c-.69 1.88-.69 1.88-2 4l-5 2c-1 1.81-1 1.81-2 4-2.52 3.1-4.87 5.54-9 6 .08-2.86.53-4.53 2.59-6.56q1.88-1.54 3.82-3.01C494 150 494 150 495 147c5.3-3.23 5.3-3.23 9-2"/><path fill="#0a090f" d="M660 82h15v3c-6.06.9-12.01 1.11-18.12 1.13h-2.32c-4.62 0-9-.37-13.56-1.13v-1h19z"/><path fill="#14141c" d="M443 1531h1l.02 2.12a6744 6744 0 0 0 .4 30.07 2052 2052 0 0 0 .18 13.62l.08 5.28.04 3.02c.31 3.2 1.2 5.87 2.28 8.89q.56 3 1 6c-2.88-2.6-4.35-4.17-5-8q-.13-3.03-.11-6.05v-1.8l.01-5.88v-4.09l.03-10.73.02-10.96z"/><path fill="#1d1716" d="M1413 1391v2l-2.16.59-10.85 2.95c-4.5 1.24-4.5 1.24-6.6 2.2-6.36 2.88-12.53 2.6-19.39 2.45l-3.5-.04q-4.26-.06-8.5-.15v-1l2.03-.01c22.75-.25 22.75-.25 30.58-4.71A37 37 0 0 1 1413 1391"/><path fill="#730f00" d="M1401 1311h1l1 17 2 1v19l-3-1c-1-7.89-1.12-15.68-1.06-23.62l.01-3.63z"/><path fill="#d2b39d" d="m1400.63 1237.94 6.37.06v1l-1.88.04c-17.8.96-32.83 9.5-46.12 20.96 1.43-3.36 3.18-5.32 5.94-7.69l2.09-1.82c1.97-1.49 1.97-1.49 4.97-2.49l1-2c5.2-3.59 10.28-5 16.43-6.39 7.26-1.73 7.26-1.73 11.2-1.67"/><path fill="#c5ced3" d="M1544 1137c.82 4.3 1.12 8.34 1.1 12.71l-.04 7.66-.01 3.97-.05 9.66h-2l-.03-2.61q-.06-4.8-.18-9.62-.05-2.08-.06-4.15-.04-3-.12-5.98v-1.88c-.06-1.76-.06-1.76-.61-4.76-2.04-1.79-2.04-1.79-4-3v-2c2.5-1.25 3.41-.78 6 0"/><path fill="#121119" d="m1213 1087 2 1a75 75 0 0 1-10 7q-2.13 1.43-4.25 2.88c-3.8 2.55-7.7 4.84-11.74 7.01a88 88 0 0 0-4.95 2.98L1182 1109l-2-1c4.75-4 4.75-4 7-4v-2l1.4-.77a57 57 0 0 0 7.66-4.86c2.49-1.76 5.05-2.43 7.94-3.37a67 67 0 0 0 9-6"/><path fill="#252629" d="M315 1035q2.69-.08 5.38-.12l3.02-.08c2.6.2 2.6.2 4.6 2.2 1.6.41 1.6.41 3.48.63l2.03.26 2.12.23 2.14.27 5.23.61v1l-3.32.18-4.37.26-2.18.12c-3.1.18-5.95.4-8.98 1.1-2.15.34-2.15.34-4.84-1.54L317 1038l-2-1z"/><path fill="#000101" d="m1041 966-1 4-2.63.37-6.94 1A57 57 0 0 0 1021 974l3-7c5.71-.74 11.24-1.1 17-1"/><path fill="#1c1b1f" d="M827 805v1a471 471 0 0 1-48 6l1-2c2.52-.43 4.96-.75 7.5-1 4.54-.5 9-1 13.44-2.06 8.64-2.07 17.22-2.06 26.06-1.94"/><path fill="#17161b" d="M1180 701c-.23 1.88-.23 1.88-1 4-1.86 1.09-1.86 1.09-4.31 1.94-4.02 1.45-7.95 3-11.88 4.68A88 88 0 0 1 1150 716l-1-2 7.31-2.94 2.1-.84 2.02-.81 1.85-.75q2.35-.88 4.72-1.66v-2l5.38-2 3.02-1.12c2.6-.88 2.6-.88 4.6-.88"/><path fill="#05060a" d="m148 683 1 2h4c.22 2.68.35 4.42-1.06 6.75-2.9 1.86-5.53 1.93-8.94 2.25v-4l-4-1 3-1 1-3c2.56-1.19 2.56-1.19 5-2"/><path fill="#c39aed" d="m378 658 3 2-1 1a54 54 0 0 0-.11 3.97v2.55l.01 2.77.04 11.77.01 6.06L380 703h-1c-2.27-6.14-2.27-11.95-2.2-18.46l.01-5.32c.04-7.12.4-14.14 1.19-21.22"/><path fill="#bcb5d0" d="M728 581v3a44 44 0 0 1-10.52 1.1l-3.1-.01-3.2-.03-3.25-.01L700 585c1-2 1-2 4-3q1.87-.21 3.77-.32l2.2-.12 2.28-.12 2.24-.13q6.75-.37 13.51-.31"/><path fill="#52338b" d="m908.06 544.94 2.94.06a24 24 0 0 1-7.56 4.38c-3.51 1.4-4.84 3.14-6.44 6.62l-1-2-2.63.62-3.5.82-1.72.4Q882.6 557.14 877 558c3.14-2.64 6.1-3.89 10-5.12l3.13-1.01A115 115 0 0 1 897 550v-2l1.5-.4 1.94-.54 1.93-.52c4.8-1.58 4.8-1.58 5.7-1.6"/><path fill="#937db2" d="M889 549c-1 2.74-1.57 3.8-4.24 5.08l-2.63.73-2.62.77c-2.57.43-4.07.25-6.51-.58l1-1-11-1c3.9-1.95 7.08-2.61 11.31-3.12q.96-.14 1.94-.27c4.28-.54 8.43-.69 12.75-.61"/><path fill="#050409" d="m865.7 542.77 2 .03 2.16.02 4.55.08 5.59.1-1 3c-6.77 3.32-14.6 3.63-22 4 2.77-2.06 4.54-2.19 8-2v-2l-3-1c2-2 2-2 3.7-2.23"/><path fill="#89878b" d="M1717 470c3.11 1.5 6.05 3.2 9 5v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1-.75 2.5-1.25 2.5c-3.12.81-3.12.81-6 1v-6h-2z"/><path fill="#595a5b" d="M1581 402h12l1 7c-8.05.46-8.05.46-11.56-1.25C1581 406 1581 406 1581 402"/><path fill="#0b0a0f" d="M1546 398q1.27.46 2.56.94c3.45 1.16 6.83 1.63 10.44 2.06v1h-11l-1 2h-2l-3.25 1a25 25 0 0 1-9.75 1l-2-2c3.38-3.38 7.23-3.06 11.81-3.06q2.1.01 4.19.06z"/><path fill="#abacaf" d="M1482 374q4.59-.09 9.19-.12l2.62-.06c4.51-.03 8.1.02 12.19 2.18l-1 2h-25z"/><path fill="#989799" d="M1353 342c3.11 1.5 6.05 3.2 9 5v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1a78 78 0 0 0-2 5c-3.62-.4-5.65-2.04-8.1-4.59-.9-1.41-.9-1.41-.71-4.22z"/><path fill="#b9b9ba" d="m396 243-.37 6.38-.1 1.82c-.3 4.57-.3 4.57-2.53 6.8h-3v-15c3-1 3-1 6 0"/><path fill="#b3b2b5" d="m1291 178 2 1c.41 2.5.41 2.5.63 5.56l.22 3.07.15 2.37-1.44.84c-1.56 1.16-1.56 1.16-2.5 3.72-1.06 2.44-1.06 2.44-3.12 3.31l-1.94.13c-1-1-1-1-1.06-4.06l.06-2.94c3-1 3-1 6 0l-.04-2.59-.02-3.35-.04-3.34c.1-2.72.1-2.72 1.1-3.72"/><path fill="#333137" d="M996 142q-.45.87-.94 1.75C994 146 994 146 993.75 148c-.99 2.63-2.64 3.5-4.98 4.92a90 90 0 0 0-5.64 3.83l-1.81 1.3-1.32.95-2-1c.38-2.45.66-3.7 2.52-5.38l1.86-1.18A36 36 0 0 0 989 146c4.69-4 4.69-4 7-4"/><path fill="#09090d" d="M1202 59h4l1 5h3v-5l3 6h-2v4h3v6l-4-1v-4h-3l-.87-1.94C1205 66 1205 66 1203 65c-.62-3.06-.62-3.06-1-6"/><path fill="#191d22" d="M346 1391h2c1.35 11.82 2 23.39-1 35h-1z"/><path fill="#c7c4b9" d="m1330 1308 3 1-1 24-3-1a38 38 0 0 1-1.69-9.06l-.32-3.23c.01-2.71.01-2.71 2.01-4.71.41-1.73.41-1.73.63-3.62z"/><path fill="#08060a" d="M1270 1253c.69 1.69.69 1.69 1 4l-1.37 2.06c-2.45 4.42-2.38 8.97-2.63 13.94l2-2c-.25 2.38-.25 2.38-1 5q-1.99 1.02-4 2c-1.25 2.63-1.25 2.63-2 5h-1c-.39-5.66.3-8.91 3-14q.52-2.2.94-4.44c.89-4.5 2.6-7.7 5.06-11.56"/><path fill="#7f5f47" d="M1117 1266c6.68-.3 12.94.7 19.48 1.97q2.62.5 5.26.99c7.5 1.4 14.88 3.1 22.26 5.04v1c-6.6.23-12.65-.64-19.12-1.87l-2.92-.53a93 93 0 0 1-13.96-3.6q-2.92-.62-5.87-1.12l-5.13-.88z"/><path fill="#45434f" d="M533 1197c6.75-.43 12.81.43 19.37 1.86 5.51 1.18 11 1.8 16.63 2.14v1c-16.15.38-16.15.38-24-2l-1 2h-10l2-3z"/><path fill="#3e3d48" d="m663 1190-1 3q-3 1.05-6 2l-1.4 1.55c-2.15 1.95-3.44 1.76-6.29 1.64l-2.45-.08-1.86-.11-1-2c2.63-3.45 5.95-3.93 10-4.69l1.9-.39c2.76-.55 5.28-.92 8.1-.92"/><path fill="#d4d8dd" d="M1592 1163h5l.81 2.38c1.17 2.59 1.72 3.38 4.19 4.62l-1 4h-3v-5l-2.25 1.38a89 89 0 0 1-6.75 3.62c-.69-1.75-.69-1.75-1-4 1.38-2.06 1.38-2.06 3-4z"/><path fill="#666769" d="M1181 1024c-13.1 9.5-29.22 12.6-45 13 6.72-3.68 14.55-4.84 22.06-5.81 5.58-.84 9.64-2.93 14.37-5.95 3.04-1.47 5.23-1.46 8.57-1.24"/><path fill="#504f53" d="m932 911-9 3-2.83 1.05c-3.62 1.09-7.05 1.47-10.8 1.83a66 66 0 0 0-17.92 4.41c-2.64.76-3.9.64-6.45-.29a184 184 0 0 1 23.08-6.58q3.3-.73 6.6-1.53l4.88-1.14 2.35-.57 2.22-.5 1.97-.47c2.2-.24 3.8.14 5.9.79"/><path fill="#403f44" d="m898.07 899.9 2.5.04 2.5.02 1.93.04c-3.85 3.94-8.56 3.77-13.75 4.19l-2.7.25q-3.27.3-6.55.56v2a32 32 0 0 1-8.25 1.5c-4.28.36-8.5.84-12.75 1.5a16 16 0 0 1 6.9-3l2.26-.5 4.68-1 2.25-.5 2.06-.44c1.93-.58 3.2-1.44 4.85-2.56 2.66-.38 2.66-.38 5.63-.56 3.22-.21 5.56-1.4 8.44-1.54"/><path fill="#96a3ac" d="M1515 856h3l.4 2.12 1.06 5.5c.54 2.38.54 2.38 1.54 4.38h-2l.44 8.06.12 2.28c.26 4.6.72 9.1 1.44 13.66h-3l-.06-1.85q-.15-4.17-.32-8.34l-.09-2.9-.12-2.83-.1-2.58c-.34-2.77-1.24-4.94-2.31-7.5-.2-2.68-.2-2.68-.12-5.31l.05-2.68z"/><path fill="#010104" d="M275 659h6c-1.09 3.27-1.24 3.44-4.06 4.94l-1.9 1q-1 .53-2.04 1.06l-2.1 1.14c-3.5 1.86-5.86 3.11-9.9 2.86l1-4h4l1-3 7-1z"/><path fill="#0e0f13" d="M227 633h5l-4 1v4h-8v4h7c-6.2 6.38-6.2 6.38-9 8-2.25-.31-2.25-.31-4-1v-3h5v-4h-5c2.92-3.56 4.65-5.27 9.31-5.81q1.84-.14 3.69-.19z"/><path fill="#a69eb6" d="M489 590h27c-3.22 3.22-3.53 3.39-7.75 3.58l-2.78.14-2.9.1-2.9.15c-7.77.32-7.77.32-11.67-1.97z"/><path fill="#030307" d="m341 577 3 2-3 2zm-8 1h5v3l3 1c-2.82 3.61-2.82 3.61-5 5a26 26 0 0 1-8-1l-1-5h6z"/><path fill="#503389" d="M868 562a64 64 0 0 1-15 6l8 1v1l-2.08.15-2.73.23-2.71.2c-2.48.42-2.48.42-3.98 1.45-2.22 1.43-4.09 1.16-6.69 1.1l-2.73-.06L838 573a14.7 14.7 0 0 1 6.9-4.6l2.26-.8 2.34-.79 2.34-.82c5.74-1.99 5.74-1.99 9.16-1.99v-2c3.13-1.04 3.99-.93 7 0"/><path d="m1715 536 3 1q.12 2.94.19 5.88l.1 3.3-.29 2.82c-1.47 1.19-1.47 1.19-3 2-.69 3.13-.69 3.13-1 6l-4-1v-6l1.95-.62c2.05-1.38 2.05-1.38 2.66-4.2l.14-3.3.17-3.33z"/><path fill="#030306" d="M432 530c8.01-.49 13.95.01 21 4l-1 3h-8v-3l-2.12-.18-2.75-.26-2.75-.24C434 533 434 533 432 532z"/><path fill="#808484" d="m364 350 2 1v25l-4-2q-.09-4.59-.12-9.19l-.06-2.62c-.03-4.51.02-8.1 2.18-12.19"/><path fill="#8c8a8e" d="M1264 1834h25v3l-23 1z"/><path fill="#d4cdc6" d="M1297 1304h1l.06 1.94q.14 4.38.32 8.75l.09 3.05.12 2.96.1 2.71c.34 2.81 1.23 4.99 2.31 7.59.2 3.33.2 3.33.13 6.81l-.03 1.81-.1 4.38h-3a86 86 0 0 1-1.1-14.9l.04-9.04.01-4.67z"/><path fill="#020304" d="M1063 1295h2c1.61 2.82 2.23 4.75 2 8l3 1 .4 2.12 1.06 5.5c.54 2.38.54 2.38 1.54 4.38-.94 2.13-.94 2.13-2 4l-1-6h-2l-1-6h-2v-6h-2z"/><path fill="#d9dee4" d="M1562 1196c2.13.38 2.13.38 4 1-1.5 3.11-3.2 6.05-5 9h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2v-5l2.38-.87c2.62-1.13 2.62-1.13 4.62-3.13"/><path fill="#f0f3f5" d="M1523 1190h3v5l2.25-1.37a89 89 0 0 1 6.75-3.63c.4 4.59-.82 7.09-3 11h-5l-.81-2.37c-1.17-2.6-1.72-3.4-4.19-4.63z"/><path fill="#f1f1f2" d="M1410 986h3v27l-3-1z"/><path fill="#010105" d="M190 950q2.13-.04 4.25-.06l2.4-.04c2.29.1 4.18.34 6.35 1.1l1 2c2.07.41 2.07.41 4.56.63l2.5.22 1.94.15v3h-12v-3l-10-1z"/><path fill="#212025" d="M855 819v1l-2.04.24-9.34 1.13-3.21.39a205 205 0 0 0-17.3 2.81q-3.59.72-7.24 1.05l-2.25.23-1.62.15v-2q5-1.04 10-2l1.88-.39c10.5-2.16 20.4-3 31.12-2.61"/><path fill="#35353a" d="m101 786 2 1-1.57 1.9c-4.21 5.17-8 10.38-11.43 16.1a290 290 0 0 1-9 13c-.66-1.73-.66-1.73-1-4 1.04-2.08 1.04-2.08 2.63-4.25 2.1-2.95 4.09-5.89 5.93-9 2.14-3.46 4.5-5.95 7.44-8.75 1.71-1.97 3.35-3.98 5-6"/><path fill="#150a23" d="M448 751a675 675 0 0 1 8.2 1.37c2.6.59 4.46 1.4 6.8 2.63a76 76 0 0 0 15.86 2.66c2.14.34 2.14.34 4.51 1.3 2.98 1.18 5.78 1.68 8.94 2.16l5.69.88v1c-6.12.33-11.8-.33-17.81-1.37l-2.25-.39c-3.67-.67-6.64-1.45-9.94-3.24q-2.33-.6-4.69-1.06c-4.2-.84-4.2-.84-5.31-1.94q-2.52-.59-5.06-1.06l-2.79-.54-2.15-.4z"/><path fill="#7b7a7e" d="m303 662-6.06 4.75-1.7 1.34C285.06 676 285.06 676 280 676l2-6 2.88-.31C288 669 288 669 289 667.62l1-1.62q2.96-1.57 6-3l1.63-1.25c2-1.09 3.25-.35 5.37.25"/><path fill="#bfbfc0" d="M295 590h4l-1 5h-8v4q-1.87.8-3.75 1.56l-2.1.88c-2.45.64-3.79.36-6.15-.44 1.38-1.5 1.38-1.5 3-3h2l1-4h10z"/><path fill="#020206" d="M401 555h10v3l1.6-.07c7.03-.18 7.03-.18 10.4 2.07v2c-8.14.7-14.15-.35-21-5z"/><path fill="#565758" d="m348 462 2 1v24l-4-1q-.09-4.59-.12-9.19l-.06-2.62c-.03-4.51.02-8.1 2.18-12.19"/><path fill="#8b8b8d" d="m446 453 2 1c.29 2.2.47 4.3.56 6.5.32 6.38.32 6.38 1.44 7.5q.1 2.77.06 5.56l-.02 3.07L450 479l-4 2z"/><path fill="#040405" d="m1707 466 4 1v3l4 1v5l3 1v7h-3l-1-7h-3l-1-6-4-1z"/><path fill="#1f1e25" d="M964 420h1l4.88 26.23c.83 4.37 1.75 8.7 2.89 13 .28 2.14-.14 3.72-.77 5.77-4.56-14.85-8.77-29.31-8-45"/><path fill="#585a5c" d="M1506 378q4.59-.09 9.19-.12l2.62-.06c4.51-.03 8.1.02 12.19 2.18l-1 2h-24z"/><path fill="#5b5e5f" d="m368 326 2 1v24l-4-1q-.09-4.59-.12-9.19l-.06-2.62c-.03-4.51.02-8.1 2.18-12.19"/><path fill="#575859" d="m372 302 2 1v24l-4-1q-.09-4.59-.12-9.19l-.06-2.62c-.03-4.51.02-8.1 2.18-12.19"/><path fill="#afaeb1" d="m1279 150 2 1c.41 2.5.41 2.5.63 5.56l.22 3.07.15 2.37-1.43.84c-2.22 1.64-2.7 3.6-3.57 6.16h-5v-6c3-1 3-1 6 0l-.04-2.59-.02-3.35-.04-3.34c.1-2.72.1-2.72 1.1-3.72"/><path fill="#050508" d="M625 143h1l1 6 2-3v13l-3-1v7h-1v-5h-2q-.08-3.12-.12-6.25l-.06-1.78c-.04-3.53.29-5.96 2.18-8.97"/><path fill="#4f5154" d="m543.55 61.9 2.56.01 5.33.04L558 62l1 4h-24v-3c2.92-.84 5.5-1.12 8.55-1.1"/><path fill="#292932" d="m719 1476 2 1c-2.39 2.64-4.95 4.2-8 6q-1.57 1.15-3.12 2.31c-3.95 2.88-8.2 5.1-12.52 7.36C695 1494 695 1494 693 1496l-3-1h2l.16-1.8c1.24-3.25 3.28-3.82 6.34-5.33 5.4-2.68 5.4-2.68 6.5-4.87l5.01-1.3c3.44-1.21 6.16-3.45 8.99-5.7"/><path fill="#edeced" d="m336 1432 2 1v26l-2 1c-1.3-2.59-1.13-4.48-1.13-7.37v-16.23c.13-2.4.13-2.4 1.13-4.4"/><path fill="#ededee" d="m336 1244 2 1v26l-2 1c-1.3-2.59-1.13-4.48-1.13-7.37v-16.23c.13-2.4.13-2.4 1.13-4.4"/><path fill="#342620" d="M1264 1207c29.07-.52 29.07-.52 42 3 1 1 1 1 1.06 3.56l-.06 2.44h-3l-1-4-39-4z"/><path fill="#14141b" d="M432 1077c4.06 5.82 4.7 12.49 5.63 19.38l.4 2.92c1.05 8.27 1.3 16.38.97 24.7h-1c-.8-3.42-1.12-6.7-1.24-10.21a104 104 0 0 0-3.39-22.5A49 49 0 0 1 432 1077"/><path fill="#06070c" d="M1217 1002h4c-.6 3.34-1.68 5.5-4 8h-3l-.25 1.81c-.97 2.84-2.2 3.69-4.75 5.19h-3v-4h3l.19-1.69c1.19-3.39 3.11-4.94 5.81-7.31h2z"/><path fill="#9c9c9d" d="M377 972h43v3l-16.85-.5c-8.75-.25-17.43-.66-26.15-1.5z"/><path fill="#cdcccb" d="m1235 919 3 1-1 4-1-1-.26 2.12-.43 2.75-.38 2.75-.93 2.38q-2.48 1.06-5 2a54 54 0 0 0-5.31 5.63l-1.38 1.56L1219 946c0-3.6.99-5.06 3-8l2-1a45 45 0 0 0 3.94-7.25C1229 928 1229 928 1233 927z"/><path fill="#57565a" d="M1200 832c-7.53 3.4-14.8 6.5-22.9 8.36-2.1.64-2.1.64-4.1 2.64-2.17.78-4.3 1.48-6.5 2.13l-1.8.55c-4.41 1.32-4.41 1.32-6.7 1.32v-2h3v-2l5.69-1.75 1.77-.55q4.47-1.38 8.95-2.72a144 144 0 0 0 17.57-6.34c2.02-.64 2.02-.64 5.02.36"/><path fill="#2c2c2e" d="M427 805c4.48-.17 7.04-.16 11 2 2.65.76 5.32 1.39 8 2v1h-6v2l3 1a340 340 0 0 1-6.74-.59c-2.32-.42-3.39-1.05-5.26-2.41a75 75 0 0 0-5-1z"/><path fill="#212025" d="M1037 778c-10.76 4.31-22.29 8-34 8v2l-7 1 3-1v-2a66 66 0 0 1 18.9-5.5c4.26-.69 8.36-1.84 12.5-3.01 2.64-.5 4.1-.37 6.6.51"/><path fill="#121216" d="M358 762c4.17.55 6.64 1.89 10 4.38a80 80 0 0 0 7.81 5.22L378 773v2l1.64.33c7.11 1.58 7.11 1.58 9.36 4.67-5-.36-8.92-.81-13-4v-2l-2.64-.75c-3.52-1.18-6.23-3.02-9.17-5.25l-1.48-1.08c-3.57-2.64-3.57-2.64-4.71-4.92"/><path fill="#603e9d" d="M753 746h20l1 3-6.12 1-3.45.56c-3.2.41-6.22.52-9.43.44z"/><path fill="#b889eb" d="m380 715 9 3 1-2c1.94.38 1.94.38 4 1l1 2h4v7l-5-1v-2l-2.12-.31c-5.23-1.25-9-3.05-11.88-7.69"/><path fill="#88878a" d="M309 654c-1.16 3.48-2.17 4.69-5 7-2.31.88-4.58 1.42-7 2l-2 1v-2l-7 1v-2q4.13-1.76 8.25-3.5l2.36-1.01 2.28-.96 2.1-.89c2.12-.67 3.8-.76 6.01-.64"/><path fill="#f2f3f3" d="M187 642c1.94.38 1.94.38 4 1l1 2q2.46 1.09 5 2c-.19 2.31-.19 2.31-1 5-3.33 2.68-5.76 3.36-10 3 1.5-3.11 3.2-6.05 5-9h-5z"/><path fill="#1c1c21" d="M321 642.88c3 .12 3 .12 4 1.12-.48 6.5-.48 6.5-2.45 8.41-2.01 2.07-2.4 3.88-3.11 6.65-1.23 4.73-1.23 4.73-3.44 6.94-.56-6.12.18-9.17 4-14 1.9-2.62 2.48-4.8 3-8-2.83.4-3.85.83-5.81 3-3.03 2.77-5.16 2.69-9.19 3 1.47-2.65 2.75-3.56 5.56-4.62 6.35-2.46 6.35-2.46 7.44-2.5"/><path fill="#2d1755" d="M1318 515c3.9 3.9 4.67 7.26 5.41 12.7.68 2.65 1.61 3.49 3.59 5.3a29 29 0 0 1 1 5l-4 1v-6l-2 6h-3l-1 4-3-1 2-1 1-3 3-1-.4-2.53A339 339 0 0 1 1318 515"/><path fill="#77767a" d="m1735 482 2 1 1 23-4 2-.08-14.24-.02-5.2-.01-3.15c.11-2.41.11-2.41 1.11-3.41"/><path fill="#f4f4f4" d="m1686 434 4 1v3h-5q.67 1.11 1.38 2.25A89 89 0 0 1 1690 447c-4.96.58-7.16-1.14-11-4l1-5 1.88-.31c2.44-.8 2.96-1.47 4.12-3.69"/><path fill="#a1a2a4" d="M1534 390h14l2 6h-12v-2l-4-2z"/><path fill="#141417" d="M1447 378c4.82.44 6.82 1.38 10 5l3 2h-13l-1-2-21-1v-1l21-1z"/><path fill="#9e9da1" d="m1315 234 2 1c.41 2.5.41 2.5.63 5.56l.22 3.07.15 2.37-1.43.84c-2.22 1.64-2.7 3.6-3.57 6.16l-5-1v-5c3-1 3-1 6 0l-.04-2.59-.02-3.35-.04-3.34c.1-2.72.1-2.72 1.1-3.72"/><path fill="#b3b3b4" d="m1303 206 2 1c.41 2.5.41 2.5.63 5.56l.22 3.07.15 2.37-1.43.84c-2.22 1.64-2.7 3.6-3.57 6.16l-5-1v-5c3-1 3-1 6 0l-.04-2.59-.02-3.35-.04-3.34c.1-2.72.1-2.72 1.1-3.72"/><path fill="#8d8d8e" d="M822 166v3l-1.64.37-2.17.5-2.15.5c-2.04.63-2.04.63-3.45 1.7-2.4 1.4-4.52 1.12-7.28 1.06l-3-.06L800 173v-3l1.8-.37c6.74-1.43 6.74-1.43 9.76-2.75 3.48-1.25 6.78-1.04 10.44-.88"/><path fill="#47474b" d="M907 136c-1 2-1 2-2.68 2.6l-2.2.48-2.46.57-2.66.6a192 192 0 0 0-30.83 9.95c-2.51.93-3.7.72-6.17-.2 4.46-2 8.9-3.68 13.54-5.18 2.7-.9 5.36-1.89 8.02-2.88a94 94 0 0 1 11.67-3.5c1.77-.44 1.77-.44 4.9-1.56 3.1-.95 5.65-1.03 8.87-.88"/><path fill="#f2f1f2" d="M1233 86c3.11 1.5 6.05 3.2 9 5v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1a78 78 0 0 0-2 5l-5-1-1-4h-2z"/><path fill="#a09fa1" d="M942 46c2.63-.19 2.63-.19 5 0l1 5h-10l-1 5h-6c-1-3-1-3 0-6l1.9-.11 2.48-.2 2.46-.18C940 49 940 49 941.12 47.47z"/><path fill="#a4a3a6" d="M422 1801c5.06-.59 5.06-.59 7.55.97 1.39 1.28 1.39 1.28 3.45 4.03-.19 2.31-.19 2.31-1 4l-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-4-1v-3h5l-1.37-2.25A89 89 0 0 1 422 1801"/><path fill="#83818b" d="M1283 1678h1c.76 31.56.76 31.56-1.81 43.38l-.5 2.31c-1.24 4.97-1.24 4.97-3.26 6.51l-1.43.8c-.2-4.32.38-7.05 2-11 2.8-9.45 3.52-18.51 3.69-28.31l.1-4.02q.12-4.84.21-9.67"/><path fill="#55535e" d="M436 1525h1l1 22h1v23l-2 1c-.84-10.5-1.13-20.9-1.06-31.44l.01-4.27z"/><path fill="#716f7a" d="M1288 1487q2.72-.05 5.44-.06l3.06-.04c2.5.1 2.5.1 3.5 1.1q2.02.34 4.06.56l2.23.26 1.71.18v1h-12v2l12 1-1 3v-2l-3.18.1-4.13.09-2.1.07c-5.08.08-5.08.08-7.5-1.52a15 15 0 0 1-2.09-5.74"/><path fill="#88868e" d="m394 1284 .94 1.94C396 1288 396 1288 397 1289q.14 1.99.11 3.97v2.55l-.01 2.77v2.82l-.05 15L397 1331h-1l-.08-1.83c-.83-19.84-.83-19.84-1.48-26.73-.58-6.16-.53-12.27-.44-18.44"/><path fill="#100d0d" d="M984 1223h6l-3 2q2.6.08 5.19.13c.48 0 .48 0 2.92.07 3.04-.21 5.1-1.04 7.89-2.2 2.24-.2 2.24-.2 4.31-.12l2.12.05 1.57.07v1l-1.83.43-8.3 1.94-2.88.68c-7.94 1.87-7.94 1.87-11.46 3.1-2.95.99-5.44 1-8.53.85v-1h5v-3h-6v-1l2.94-.37 3.06-.63z"/><path fill="#000003" d="m1288.56 1169.75 2.63.08c13.03.78 13.03.78 15.81 2.17v2q-4.78.05-9.56.06l-2.72.03c-5.02.02-9.77-.23-14.72-1.09 2.94-2.55 4.67-3.38 8.56-3.25"/><path fill="#bac2c9" d="m1609 1090 5 1v14l-6-3q-.05-2.71-.06-5.44l-.04-3.06c.1-2.5.1-2.5 1.1-3.5"/><path fill="#9d9d9f" d="M493 972h34v2l-35 1z"/><path fill="#474650" d="m1316 963 2 1c.5 2.22.85 4.32 1.13 6.56l.25 2c.88 7.47.8 14.93.62 22.44h-1l-.15-2.09A106 106 0 0 0 1316 975l-1 4-2-1q.17-2.62.38-5.25l.2-2.95a17 17 0 0 1 2.42-6.8"/><path fill="#f0f2f2" d="M1454 921v5l-1.87.31c-2.57.83-2.7 1.5-4.13 3.69l-5 3-1.12 1.69-.88 1.31h-3c.58-3.83 2.3-5.8 4.88-8.62l2.11-2.36c3.05-3.06 4.64-4.32 9.01-4.02"/><path fill="#8e8e91" d="M877 916h2v2l7 1v1c-5.69 1.9-10.96 2.4-16.94 2.63l-2.65.11q-3.2.14-6.41.26l1-4h16z"/><path fill="#313035" d="m803.94 828.94 3.06.06c-3.78 2.58-6.33 3.2-10.94 3.54l-1.8.13-5.82.4-1.99.13c-10.16.69-20.26 1.07-30.45.8v-1l3.29-.26 24.93-1.99 2.35-.18c5.76-.47 11.6-1.75 17.37-1.63"/><path fill="#2c2b31" d="M1368 747a52 52 0 0 1-6.94 4l-4.06 2-2 1.07c-2.09.97-3.92 1.4-6.19 1.8-4.33.89-8.02 2.45-11.96 4.42l-1.85.71-2-1a62 62 0 0 1 19.75-9c3.25-1 3.25-1 5.19-2.62 3.17-2.12 6.36-1.62 10.06-1.38"/><path fill="#4b494e" d="m1552 727 2 1c-1.5 3.77-1.5 3.77-4 4.94-2.75 1.46-2.89 3.21-4 6.06-3.35 3.6-7.34 3.67-12 4a19 19 0 0 1 6-8c2.75-.25 2.75-.25 5 0l1-4 1.88-.31c2.44-.8 2.96-1.47 4.12-3.69"/><path fill="#1d0d32" d="M364 701c2 1 2 1 3.27 3.22 2.09 3.35 4.55 5.67 7.48 8.28l1.61 1.46a87 87 0 0 0 8.89 7.1L388 723v2l4 1c-2 1-2 1-4.31.38-2.62-1.34-4.23-2.8-6.22-4.94-2.87-2.82-6.21-5.09-9.47-7.44l-2 1-1-6-4-1z"/><path fill="#f1f2f2" d="M351 511a22.5 22.5 0 0 1 1 11c-1.1 2.19-2.18 3.28-4 5h-2q-.12-3.44-.19-6.87l-.07-1.98c-.08-4.8-.08-4.8 1.65-6.6C349 511 349 511 351 511"/><path fill="#959598" d="M412 491h1v24l-5-1q.42-4.07.88-8.12l.23-2.31c.5-4.45 1.3-8.38 2.89-12.57"/><path fill="#311e5b" d="m1323 392 1 2h2c2.58 4.73 4.13 8.65 5 14l1.56 1.5c1.44 1.5 1.44 1.5 1.63 4.69L1334 417c-3.97-2.38-4.7-4.66-6-9l-2-2c-.12-2.12-.12-2.12 0-4h-2c-1.19-1.06-1.19-1.06-2-3 .13-2.4.55-4.63 1-7"/><path fill="#b4b3b5" d="m1327 262 2 1c.41 2.5.41 2.5.63 5.56l.22 3.07.15 2.37-1.43.84c-2.22 1.64-2.7 3.6-3.57 6.16l-5-1v-5c3-1 3-1 6 0l-.04-2.59-.02-3.35-.04-3.34c.1-2.72.1-2.72 1.1-3.72"/><path fill="#858484" d="m516 143 5 4-4 5-4-1 2-5c-2.88 1.06-5.52 2.18-8 4-1.04 2.51-1 4.22-1 7l-3-1 1-2h-4l-1 3-2-1a48 48 0 0 1 19-13"/><path fill="#8f8e91" d="M954 131h6v3l-6 1zm-1 3-1 4h-6l-1 4h-6v-4c8.87-4 8.87-4 14-4"/><path fill="#000004" d="M1321 1675h2q-.42 3.19-.87 6.38l-.24 1.82c-.66 4.57-.66 4.57-2.89 6.8-.63 1.95-.63 1.95-1.12 4.13l-.51 2.19-.37 1.68h-3c.41-8.5 3.16-15.48 7-23"/><path fill="#000108" d="M1260 1294h2q-.1 5.25-.26 10.52l-.09 3.57q-.05 2.58-.14 5.15l-.08 3.12c-.48 2.94-1.12 3.85-3.43 5.64q-.05-4.78-.06-9.56l-.03-2.72c-.02-5.01.14-9.79 1.09-14.72z"/><path fill="#85858b" d="M1444 1089h2q.08 1.94.13 3.88l.07 2.17c-.2 1.95-.2 1.95-2.2 3.95-.5 2.25-.5 2.25-.85 5l-.4 3-.37 3.13-.41 3.15-.97 7.72h-1q-.17-5.48-.25-10.94l-.1-3.1c-.09-6.9.96-11.92 4.35-17.96"/><path fill="#e2e6ea" d="M1475 882h3v5l2.25-1.37A89 89 0 0 1 1487 882c.58 4.96-1.14 7.16-4 11l-5-1-.31-1.87c-.8-2.45-1.47-2.97-3.69-4.13z"/><path fill="#3e3d42" d="M950 882h34a17 17 0 0 1-7.5 3.06A61 61 0 0 0 969 887q-2.6.1-5.19.06l-2.73-.02L959 887v-1h5v-3h-14z"/><path fill="#808184" d="M79 824c1.15 3.44.8 5.81.07 9.33-1.3 7.19-1.3 14.14-1.2 21.42l.03 3.88q.03 4.7.1 9.37c-5.8-8.26-3.95-23.93-2.36-33.46C77.4 825.6 77.4 825.6 79 824"/><path fill="#757577" d="m1155.25 831.94 3.75.06c-3.73 2.38-6.71 2.16-11 2v2c-6.85 3.47-13.64 6.43-21.06 8.44l-1.91.53c-3.42.88-5.67 1.03-9.03.03l1.98-.67 9.02-3.08 3.12-1.05c5.26-1.81 10.28-3.7 15.16-6.38 3.3-1.58 6.35-1.94 9.97-1.88"/><path fill="#f3f2f4" d="M1506 765c-1.5 3.11-3.2 6.05-5 9h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2v-5c7.3-3.44 7.3-3.44 11-3"/><path fill="#939294" d="m259 681 2 1-1 2h4v3l-4 1v2l-1.94.31-2.06.69-1 3-1-4h-3v-4l1.5-.84 1.94-1.1 1.93-1.09C258 682 258 682 259 681"/><path fill="#f2f2f2" d="M167 654c1.94.38 1.94.38 4 1l1 2q2.46 1.09 5 2c-.19 2.31-.19 2.31-1 5-3.33 2.68-5.76 3.36-10 3 1.5-3.11 3.2-6.05 5-9h-5zM207 630c1.94.38 1.94.38 4 1l1 2q2.46 1.09 5 2c-.19 2.31-.19 2.31-1 5-3.33 2.68-5.76 3.36-10 3 1.5-3.11 3.2-6.05 5-9h-5z"/><path fill="#6d46ac" d="m641.94 608.81 3.46.08 2.6.11v1l-12 2 10 1v1c-13.69 1.47-27.29.69-41 0v-1l2-.04 9.06-.27 3.15-.07c5.31-.19 9.46-.57 14.21-2.96 2.84-1.19 5.46-.94 8.52-.85"/><path fill="#0d0d13" d="m1615 595 2 1-2.19 1.63a31 31 0 0 0-5.12 5.18c-3.67 4.56-8.08 9.32-13.69 11.19a89 89 0 0 0-2 4c-1.5 1.75-1.5 1.75-3 3h-2v2h-2c1.02-3.07 1.34-3.53 3.75-5.37a89 89 0 0 0 16.25-17.38c2.5-2.82 4.5-3.88 8-5.25"/><path fill="#b49cd0" d="M420 594h8l1 3 2.63.15 3.5.23 3.44.2c3.4.42 6.21 1.27 9.43 2.42q2.43.6 4.88 1.13l2.36.5 1.76.37v1l-6.87-.44-1.98-.12c-4.92-.33-4.92-.33-7.15-1.44q-3-.3-6-.5c-5.78-.39-5.78-.39-8-1.5v-2h-2v-2h-5z"/><path fill="#aca4be" d="m704 583-3 1 20 1v1l-3.4.15c-15.42.74-15.42.74-22.6 2.85l1-3-20-1v-1l8.06-.44 2.28-.12c3.55-.2 7.05-.46 10.57-1 2.83-.4 4.44-.4 7.09.56"/><path fill="#361e65" d="M1020 567h2c2.3 5.1 4.04 10.18 5.56 15.56l1.86 6.48q.8 2.63 1.72 5.21c.8 2.57 1.08 4.46.98 7.13l-.05 2.08-.07 1.54c-5.8-7.98-6.76-19.47-8-29h-2c-1.4-3.14-2.26-5.55-2-9"/><path fill="#4c4c51" d="M415 444h1c.2 4.08.18 7.8-.94 11.75-1.5 5.31-1.71 10.76-2.06 16.25h-2l-.18 2.2c-.44 4.97-.9 9.89-1.82 14.8h-1q.17-4.27.38-8.56l.09-2.43c.21-4.31.7-8.15 2-12.27.98-3.25 1.5-6.6 2.1-9.93 1.5-8.4 1.5-8.4 2.43-11.81"/><path fill="#c1c1c1" d="M1633 418c2.38-.19 2.38-.19 5 0l.88 1.47c1.12 1.53 1.12 1.53 3.28 2.04l2.46.18 2.48.2 1.9.11q.57 2.49 1 5c-1 1-1 1-3.44 1.19C1644 428 1644 428 1642 426v-3h-10z"/><path fill="#060609" d="M423 372c.94 3.05 1 5.27.48 8.4l-.41 2.51q-.23 1.26-.44 2.59l-.43 2.59c-1.03 6.25-1.03 6.25-1.73 8.97-.73 3.02-.84 6.1-1.03 9.19l-.13 1.97L419 413h-1v-20l-3 3v-5l1.9-.53c2.83-1.98 2.88-3.59 3.47-6.88q.14-1.03.32-2.09c1.24-7.37 1.24-7.37 2.31-9.5"/><path fill="#151519" d="m1335 318 4 1 1 7 3 1 1 7 3 1a42 42 0 0 1 2 6h5l-.56 2.88c-.44 3.12-.44 3.12.56 5.12l-3-1v-5h-5v-7h-4v6l-3-1v-5l3-1v-7l-4-1v-7h-3z"/><path fill="#000001" d="M387 305h3c.1 7.08 0 13.98-1 21l-3 1-.06-10.44-.03-3v-2.87l-.02-2.65C386 306 386 306 387 305"/><path fill="#3b3b3f" d="M474 183c.63 1.88.63 1.88 1 4l-2 2q-.66 2.39-1.3 4.78c-1.45 4.58-3.65 8.88-5.7 13.22h-2l-.33 1.76c-1 4.88-2.22 8.9-4.67 13.24-1.45-3.77-.44-6.42 1-10 1.26-2.74 2.62-5.44 4-8.12l1.08-2.13q2.02-4.02 4.1-8.02c1.1-2.31 1.65-4.67 2.26-7.14C472 185 472 185 474 183"/><path fill="#2d2c32" d="M871 183h7l-1 3q-2.48 1.05-5 2l-1 2c-1.6.6-1.6.6-3.5 1.06-3.4.85-3.4.85-4.5 1.94q-3 .06-6 0c1.04-2.83 1.6-3.81 4.38-5.12q1.29-.44 2.62-.88l2.13-1.12C868 185 868 185 871 185z"/><path fill="#0f0e14" d="m985 156 2 1-5.87 5.5-1.69 1.59-1.62 1.5-1.5 1.4C975 168 975 168 973 168l-.66 1.67c-1.8 3.12-4.16 5.2-6.84 7.58l-1.51 1.4C960.26 182 960.26 182 958 182l-1 3-1-2 1.94-1.87c2.21-2.2 4.28-4.5 6.33-6.86 3.1-3.54 6-6.8 10.11-9.2 3.87-2.56 7.2-5.94 10.62-9.07"/><path fill="#515055" d="M564.38 161.81h2.17c10.27.09 10.27.09 14.45 1.19l1 2-2.04-.03-9.34-.1-3.21-.05c-10.07-.06-18.58.38-26.41 7.18l-2-1a21 21 0 0 1 6.81-5.06l1.96-.98c5.75-2.48 10.42-3.25 16.6-3.15"/><path fill="#f6f7f7" d="M423 142h3v5l2.25-1.37A89 89 0 0 1 435 142c.58 4.96-1.14 7.16-4 11l-5-1-.31-1.87c-.8-2.45-1.47-2.97-3.69-4.13z"/><path fill="#69686b" d="M624 136q.08 2.19.13 4.38l.07 2.46C624 145 624 145 622 147a82 82 0 0 0-.12 7.13l.02 2 .1 4.87 2 1-1 4c-3.89-4.73-4.17-8.5-4.25-14.56l-.08-3.44c.37-3.38.88-3.87 3.33-6l1-4h-19v-1l8.88-.5 2.55-.14 2.44-.14 2.25-.13q1.95-.09 3.88-.09"/><path fill="#f4f5f4" d="M431 130h3v5l2.25-1.37A89 89 0 0 1 443 130c.58 4.96-1.14 7.16-4 11l-5-1-.31-1.87c-.8-2.45-1.47-2.97-3.69-4.13z"/><path fill="#b2b2b4" d="M615.06 67.94C622.2 69.76 629.7 69.68 637 70c-2.27 2.27-2.56 2.25-5.59 2.27h-2.13l-2.22-.02-2.2.02h-2.14l-1.94-.01C619 72 619 72 617.63 71c-2.42-1.5-4.57-1.34-7.4-1.42l-3.38-.12-7.11-.21q-4.37-.15-8.74-.26v-1c16.62-1.98 16.62-1.98 24.06-.06"/><path fill="#dcdede" d="M559 66h22c-5.46 4.1-11.5 5.2-18.34 4.9-1.91-.28-1.91-.28-4.66-.9z"/><path fill="#000001" d="M1287 1818v4h-23v-2c2.95-1.47 6.01-1.37 9.25-1.56l1.99-.13q5.87-.37 11.76-.31"/><path fill="#727277" d="m1426 1678 4 1q.09 4.35.13 8.69l.05 2.47c.03 4.41-.1 7.83-2.18 11.84l-2-1z"/><path fill="#6f7074" d="m346 1302 4 2-1 17-3 1c-1.08-2.17-1.13-3.24-1.13-5.62v-10.72c.13-1.66.13-1.66 1.13-3.66"/><path fill="#76747e" d="M426 1262c2 2 2 2 2.24 3.87l-.01 2.3v2.61l-.03 2.82-.01 2.89-.06 9.13-.03 6.2q-.03 7.59-.1 15.18h-1l-.08-1.63-.36-7.56-.13-2.77q-.36-7.53-1.06-15.04c-.5-6.08-.08-11.95.63-18"/><path fill="#c33a14" d="M1438 1253c2 1.81 2 1.81 4 4v3l-2.13.02c-10.13.79-15.48 5.94-22.55 12.8-1.32 1.18-1.32 1.18-3.32 2.18 2.58-5.8 5.87-9.18 11.4-12.1 1.86-1.05 3.13-2.36 4.6-3.9 3.09-1.96 5.37-2.2 9-2z"/><path fill="#40352e" d="M1070 1218c0 3.07-.55 5.2-1.44 8.13l-.8 2.69c-.76 2.18-.76 2.18-1.76 3.18-2.06 7.53-2.54 15.24-3 23h-2l-.06-5.31-.04-3c.1-2.69.1-2.69 1.1-5.69q.28-4.88.43-9.77c.67-3.8 2-5.41 4.57-8.23q1.55-2.47 3-5"/><path fill="#d4c6b4" d="M1397 1234c13.82.6 13.82.6 19 4v2l-26-2 2-2c2.13.38 2.13.38 4 1z"/><path fill="#3c3b46" d="m682 1182 1 4-2.87.88C677 1188 677 1188 675 1190c-1.95-.02-1.95-.02-4.12-.37l-2.2-.34-1.68-.29-1-3 1.9-.62 2.48-.82 2.46-.8c3.41-1.2 5.46-1.86 9.16-1.76"/><path fill="#f3f5f7" d="M1502 1093c4.39.51 7.34 1.52 11 4l-1 5-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-4-1v-3h5l-1.37-2.25a89 89 0 0 1-3.63-6.75"/><path fill="#bbbaba" d="M1088 987c-10.21 5.6-21.71 5.4-33 5v-1c11.14-2.69 21.5-4.68 33-4"/><path fill="#9b9a9c" d="M220 917c6.5.42 11.33 2.91 17 6v3h-9v-4h-9l3-1v-2z"/><path fill="#090a10" d="M1447 871h1c2.88 9.78 2.2 19.91 2 30l-3 1z"/><path fill="#232227" d="M1340 780c-5 4.57-8.56 6.47-15.33 6.72-4.09.43-7.64 1.98-11.42 3.54-2.25.74-2.25.74-4.25-.26a232 232 0 0 1 20.56-8.94l1.77-.67c3.25-1.17 5.4-1.67 8.67-.39"/><path fill="#313134" d="M332 739c13.4 11.12 13.4 11.12 14.94 15.94L347 757h-3l-1-3-2.37-.44C338 753 338 753 336 752l.25-2.12c-.34-3.93-1.98-5.69-4.25-8.88z"/><path fill="#08090f" d="M85 738h4v3h-4zm-3 3h3v4h-4l-1 4h-5v-3h-5c1.13-2 1.13-2 3-4 3.19-.25 3.19-.25 6 0v3l1.31-2z"/><path fill="#090a0e" d="m166 671 2 2q2.97 1.08 6 2l-1 2h-6v-3h-6l-1 4h-6v3l-3-1h2v-6l6 2 1-3h6zm1 6v4h-5v-3c2-1 2-1 5-1"/><path fill="#18171c" d="m1257 665 2 1-4 2v2c-2.96 2-6.11 3.56-9.31 5.15q-2.64 1.34-5.27 2.7l-3.67 1.9-1.9 1A88 88 0 0 1 1230 683l-2-1c7.18-5.34 14.19-9.98 23-12v-2q2.95-1.6 6-3"/><path fill="#a77fd8" d="M541 657h1q.11 5.92.16 11.85l.07 4.04.06 5.79.05 1.82c0 1.7 0 1.7-.34 4.5-1.52 1.4-1.52 1.4-3 2a1859 1859 0 0 1-.15-15.89q-.04-2.9-.05-5.79l-.03-1.82q.02-2.25.23-4.5z"/><path fill="#bcb2cc" d="M418 570h8l1 3 11 1 1 3h-12l-1-2c-1.85-.41-1.85-.41-4.06-.62l-2.23-.23L418 574z"/><path fill="#e1e1e2" d="m1729 506 5 2v11l-5 2c-1.3-2.6-1.13-4.59-1.12-7.5l-.01-2.97c.13-2.53.13-2.53 1.13-4.53"/><path fill="#ecebec" d="m1698 442 4 1v3h-5q.67 1.11 1.38 2.25A89 89 0 0 1 1702 455h-6v-3l-5-1 1-5 1.88-.31c2.44-.8 2.96-1.47 4.12-3.69"/><path fill="#8b8b8d" d="M450 415h1l1.06 6.88.32 1.97c.73 4.92.73 4.92-.38 7.15h2l-1 17c-3-3-3-3-3.34-6.1q0-1.87.05-3.76l.01-2q.03-3.17.1-6.33l.03-4.3z"/><path fill="#000001" d="M1505 394h23v4l-22-1z"/><path fill="#f7f8f8" d="M371 375q.12 2.85.19 5.69l.1 3.2c-.33 3.5-1.17 5.34-3.29 8.11h-2v-16c2-1 2-1 5-1"/><path fill="#faf9f9" d="M1361 354c1.88.31 1.88.31 4 1q1.05 1.47 2 3l3 1v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1a78 78 0 0 0-2 5c-3.05-.33-4.03-1.03-6.25-3.25-1.88-2.96-2.14-4.33-1.75-7.75"/><path fill="#8b8c8e" d="M665 186q3.19.1 6.38.25l1.82.04c1.76.1 1.76.1 4.8.71l.9 2 1.1 2c2.68.69 5.37.7 8.13.75l2.28.09q2.8.1 5.59.16v1h-22v-2l-12-1v-1h6v-2z"/><path fill="#000005" d="M664 178h55v3l-1.7-.49a35 35 0 0 0-7.16-.72l-2.91-.06-3.12-.05-3.21-.06-17.03-.31L667 179v2h6v1h-8z"/><path fill="#7a7b7c" d="M880 148c-2.84 2.44-4.5 3.38-8.25 3.19l-2.14-.08L868 151l1 4-3 2v-4l-2.05.47-2.7.6-2.67.59c-2.6.34-4.1.04-6.58-.66 18.03-7.16 18.03-7.16 28-6"/><path fill="#fefefe" d="M617 66h19l1 4-8.31.06-2.38.03H624l-2.1.02C620 70 620 70 617 69z"/><path fill="#c8c8c9" d="M1154 10h6l1 3 8 1 1 6h-7l-1-5h-7z"/><path fill="#f1f1f2" d="M1402 1738c1.94.75 1.94.75 4 2q.55 1.99 1 4a13 13 0 0 0 3 2l-1 4h-3v-5l-2.25 1.38a89 89 0 0 1-6.75 3.62l1-5h2l-.12-2.37c.12-2.63.12-2.63 2.12-4.63"/><path fill="#010207" d="m789 1675-3.81 2.88-2.15 1.61c-2.04 1.51-2.04 1.51-3.74 2.52-1.3.99-1.3.99-2.3 3.99l-3 3-3 5h-1c-.19-2.31-.19-2.31 0-5 1.38-1.5 1.38-1.5 3-3l.75-2.31c1.8-3.9 4.77-6.24 8.25-8.69 3.13-1.04 3.99-.93 7 0"/><path fill="#000001" d="M1412 1677h2v22h-4c-.33-17.34-.33-17.34 2-22"/><path fill="#84818a" d="M1314 1653h1l1 6 2-3a137 137 0 0 1-3 17h-1l-1-6h-2v9h-1v-16h3z"/><path fill="#372417" d="M1272 1375c2.5 1.75 2.5 1.75 5 4 .16 2.14.16 2.14 0 4-8.18-.3-16.08-1.43-24.12-2.94l-2.8-.51-2.65-.51-2.37-.46a14 14 0 0 1-5.06-2.58c6-.35 10.9.64 16.68 2.2 5.7 1.37 11.5 2.07 17.32 2.8z"/><path fill="#c0bfc3" d="M1520 1314h1a86 86 0 0 1 1.06 15.44l-.01 2.21-.05 5.35-5 1c.5-8.14 1.35-16 3-24"/><path fill="#63442d" d="M1109 1267q5.5.42 11 .88l3.12.23c5.2.43 10.14 1 15.16 2.43 2.87.77 5.78 1.1 8.72 1.46v1q-4.73.13-9.44.19l-2.66.07c-5.47.06-9.82-.63-14.95-2.55a26 26 0 0 0-7.01-1.4l-2.29-.2-1.65-.11z"/><path fill="#271b13" d="M1042 1253h1l1.56 24.22.14 2c.28 4.6.35 9.17.3 13.78h-1c-2.86-12.84-4.14-25.83-3-39z"/><path fill="#422d1e" d="M947 1245q-4.86 2.5-9.83 4.72l-1.98.89q-2.02.9-4.06 1.78l-1.96.9-1.77.77c-1.4.94-1.4.94-2.4 3.94l1.57.08c5.13.41 7.73 1.34 11.43 4.92-4.73.33-7.1-.34-11-3q-2-1.02-4-2c-.06-2.25-.06-2.25 1-5a46 46 0 0 1 6.72-3.1c2.61-1.03 4.88-2.43 7.28-3.9 3.45-1.68 5.34-2.36 9-1"/><path fill="#c2734e" d="m1417 1241 3 3h-31c6.2-6.2 20.09-5.5 28-3"/><path fill="#11121a" d="m962 1197-1.48.47c-8.61 2.77-16.97 5.58-25.13 9.5-3.07 1.32-6.1 2.01-9.37 2.63a56 56 0 0 0-8.02 2.4c3.5-4.38 7.36-5.35 12.56-6.75l2.33-.67c3.18-.89 5.79-1.58 9.11-1.58v-2q3.87-1.3 7.75-2.56l2.21-.75 2.15-.7q.97-.3 1.97-.64c2.25-.41 3.75-.04 5.92.65"/><path fill="#bfc6c9" d="M1519 1104c4.21.5 6.05 1.55 8.81 4.75l1.83 2.05c1.61 2.6 1.63 4.19 1.36 7.2-3.68-1.54-5.99-3.78-8.75-6.62l-2.42-2.48-1.83-1.9z"/><path fill="#b0b5b4" d="M54 953c2.66-.4 4.4-.4 6.67 1.11A89 89 0 0 1 65 958l-1 4-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-4-1v-3h5l-1.37-2.25A89 89 0 0 1 54 953"/><path fill="#232428" d="m827.44 934.94 5.56.06c-2.54 2-4.84 2.5-8 3.06-2.8.5-5.3 1.04-8 1.94q-2.22.22-4.45.32l-2.57.12-2.67.12-2.7.13-6.61.31v-1l7.56-1.94 2.14-.55a75 75 0 0 1 19.74-2.57"/><path fill="#3a383e" d="M803 914h10l-8 4 5 1v1l-13 2.49c-4.66.89-9.32 1.76-14 2.51 4.99-4.25 9.74-4.12 16-4v-2h-6v-1l10-1z"/><path fill="#07070c" d="M1206 833a119 119 0 0 1-16 7l4 2h-6l-1 3-2-1 1-2-9 1a27 27 0 0 1 9.13-4.94c4.66-1.6 9.17-3.42 13.68-5.42 2.49-.73 3.78-.5 6.19.36"/><path fill="#363439" d="M760 836v1c-17.1 1.4-33.85 2.46-51 1v-1c16.98-1.34 33.98-1.1 51-1"/><path fill="#999a9f" d="M1470 807q.12 2.34.19 4.69l.1 2.63c-.29 2.68-.29 2.68-1.73 5.02-2.03 3.46-2.34 6.54-2.75 10.47l-.25 2.1q-.3 2.55-.56 5.09h-1c-.54-23.99-.54-23.99 3.51-28.8 1.49-1.2 1.49-1.2 2.49-1.2"/><path fill="#28272b" d="M992 789c2.06.44 2.06.44 4 1-5.36 2.61-10.03 4.5-16 5v2l-3.21.4-6.28.8-2.03.26-1.88.24c-1.6.3-1.6.3-3.6 1.3q-3.56.35-7.12.56l-2 .13-4.88.31c2.5-2.5 3.85-2.49 7.31-3l3.28-.51q3.28-.47 6.57-.88c21.02-2.79 21.02-2.79 25.84-7.61"/><path fill="#3b3b3f" d="M1291 780c-20.05 10-20.05 10-30 10 2.73-3.27 4.95-4.5 9.08-5.5a52 52 0 0 0 5.92-2c10.33-3.92 10.33-3.92 15-2.5"/><path fill="#d8d5d9" d="M1494 773c-.31 1.88-.31 1.88-1 4q-1.47 1.05-3 2l-1 3h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2c.16-2.26.57-3.6 2.26-5.16 2.9-2.05 5.06-3.4 8.74-2.84"/><path fill="#eceded" d="M1518 757c-1.5 3.11-3.2 6.05-5 9h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2c.13-2.31.13-2.31 1-5 3.32-2.14 6.01-3.46 10-3"/><path fill="#030406" d="m136 693 1 4-2 1-1 3 3 1-4 3v-3h-4v3h-6v-4h5l.81-1.81c1.9-3.5 3.39-4.92 7.19-6.19"/><path fill="#121218" d="m1602 605 2 1a516 516 0 0 1-5.27 5.47C1597 613 1597 613 1595 613l-.69 1.75c-1.31 2.25-1.31 2.25-3.68 4.06-2.83 2.36-4.04 4.4-5.72 7.59-1.06 1.86-2.37 3.13-3.91 4.6-1.19 2.19-1.19 2.19-2 4-1.25-3.76-.54-4.97 1.04-8.43 1.28-2.1 2.73-2.64 4.96-3.57l.88-1.81c1.4-2.72 3.17-4.02 5.54-5.9 2.37-1.94 4.44-4.1 6.58-6.29z"/><path fill="#a594c5" d="M806 569c2.06 2 2.06 2 3 4-1 1-1 1-3.22 1.13-5.68-.01-11.19-.1-16.78-1.13l-1-2c5.84-1.55 11.95-3.08 18-2"/><path fill="#6f677a" d="M392 556h6l1 2c2.25.94 4.45 1.78 6.75 2.56l1.88.67c2.77.97 5.37 1.77 8.26 2.3q1.05.22 2.11.47l1 2c2.48.75 4.92 1.4 7.44 2l4.12 1 1.85.44C434 570 434 570 436 572a133 133 0 0 1-28.62-8.06l-2.7-1.07c-6.42-2.61-6.42-2.61-8.68-4.87a89 89 0 0 0-4-2"/><path d="M1481 390h23v4l-10.37-.44-2.99-.12-2.85-.12-2.64-.11C1483 393 1483 393 1481 392z"/><path fill="#3a383d" d="m859 190-1 2 5 1v1l-5.27 1.37C856 196 856 196 854 198c-2.12-.37-2.12-.37-4-1l4-1v-2l-3.12 1a29 29 0 0 1-9.88 1c1.58-3.15 4.36-3.82 7.5-5.18 3.66-1.2 6.67-1.1 10.5-.82"/><path fill="#3d3c43" d="M1010 98c-.56 1.88-.56 1.88-2 4a33 33 0 0 1-5.27 1.56c-1.73.44-1.73.44-3.73 1.56-3.03 1.33-5.72 1-9 .88 5.85-6.56 11.23-8.61 20-8"/><path fill="#3b393f" d="M1066 85h4c.66 5.31.47 9.04-2.69 13.5-2.1 2.52-3.02 3.4-6.18 4.5H1059l1-3h2l.88-2.94C1064 94 1064 94 1066 93z"/><path fill="#131314" d="m1138 6 3 1h-2c.62 3.7.62 3.7 2.56 5.25l1.44.75c-2.87.13-2.87.13-6 0l-2-2c-2.18-.28-2.18-.28-4.9-.32l-3.1-.06-3.35-.03-3.42-.06q-4.5-.08-9-.13l-9.2-.14q-9-.14-18.03-.26V9h54z"/><path fill="#62616a" d="M1317 1665h1c.32 8.7-.81 16.27-5 24q-1.56 3.9-3.04 7.83c-.96 2.17-.96 2.17-2.96 4.17.48-5.3 1.48-9.9 3.28-14.92.7-2.01 1.24-4.01 1.72-6.08h3l.18-2.52.26-3.3.24-3.26c.32-2.92.32-2.92 1.32-5.92"/><path fill="#77747f" d="M1311 1575h1l1 12h1q.09 5.88.13 11.75l.05 3.37q0 1.6.02 3.24l.03 2.98a13.6 13.6 0 0 1-2.23 6.66c-1.1-13.34-1.1-26.63-1-40"/><path fill="#0d0d13" d="M1307 1485c5.43-.25 9.87.15 15 2 2 2 2 2 2.24 4.14l-.01 2.68v3.03l-.03 3.28-.01 3.36-.05 8.83-.04 9q-.03 8.85-.1 17.68h-1v-49l-3-1v-2l-13-1z"/><path fill="#4b4b4d" d="m1426 1436 2 1-1 5-7-1v49h-1v-50l6-3z"/><path fill="#c5bfba" d="M1310 1260c1.25 2.5.78 3.41 0 6h3l1-2c.31 1.75.31 1.75 0 4-2.5 2.25-2.5 2.25-5 4v-3c-4.87 2.44-6.26 9.08-8.04 13.9-.96 2.1-.96 2.1-2.96 3.1 1.89-8.97 5.34-19.34 12-26"/><path fill="#442e1f" d="M1042 1251q0 3.86-.04 7.72c-.1 19.73-.1 19.73 1.04 28.28-4.45-5.81-4.22-10.42-4.24-17.52q-.02-2.85-.07-5.72l-.02-3.63-.03-3.34c.36-2.79.36-2.79 1.88-4.67z"/><path fill="#474650" d="M436 1161c4.48 4.48 3.05 14.2 3.06 20.25q-.01 2.38-.06 4.75h-1l-1-10h-1l-1 12h-1q-.09-5.34-.12-10.69l-.06-3.05-.02-2.96-.03-2.71c.25-2.85 1.05-5 2.23-7.59"/><path fill="#000003" d="M572 979v2l-29 1-1-2c10.06-2.22 19.83-2.85 30-1"/><path fill="#07080c" d="M203 951c12.94 1.94 12.94 1.94 15 4 2.58.28 5.16.45 7.75.62l2.25.38 1 2h-10v2l2 1h-4v-3h-8l4-1v-3h-9z"/><path fill="#424145" d="M109 908c1.88-.19 1.88-.19 4 0l.94 1.43c1.5 2.24 3.56 2.72 6 3.7 6.05 2.57 11.66 6.13 17.06 9.87-1.75.69-1.75.69-4 1-1.94-1.31-1.94-1.31-4-3l-2.37-.94a24 24 0 0 1-5.57-3.12c-2.9-2.03-5.5-2.6-9.06-2.94l-1-4h-2z"/><path fill="#949494" d="m943 898-1 2c-2.22.31-4.33.51-6.56.63l-1.87.11q-2.29.14-4.57.26v3h-15c1-2 1-2 3.38-3 8.47-2.55 16.8-3.3 25.62-3"/><path fill="#92a1aa" d="M1523 885c1.93 1.84 2.92 3.35 3.25 6.02q.06 3.6-.06 7.23l-.04 2.55q-.06 3.1-.15 6.2l-3 1c-1.74-1.74-1.13-3.96-1.14-6.27l.02-2.1-.02-2.08.01-3.85c.13-1.7.13-1.7 1.13-4.7a64 64 0 0 0 0-4"/><path fill="#8a8b8c" d="m1091 857 1 2h9l-1 3a19 19 0 0 1-7.23 1.1l-2.43-.01-2.53-.03-2.56-.01-6.25-.05v-2l1.86-.37 2.45-.5 2.43-.5c2.14-.6 3.51-1.28 5.26-2.63"/><path fill="#3d3c3f" d="m383 795 2.56.88c4.06 1.35 8.19 2.48 12.32 3.62 6.42 1.8 10.17 2.94 14.12 8.5q-2.38-.39-4.75-.81l-2.67-.46c-2.8-.79-4.32-1.96-6.58-3.73-3.25-.69-3.25-.69-6-1v-2l-2.81-.81c-2.95-1.1-4.25-1.81-6.19-4.19"/><path fill="#000001" d="M499 766q4-.08 8-.12l2.25-.06c5.2-.04 9.77.68 14.75 2.18l-1 2c-8.05.17-15.99-.35-24-1z"/><path fill="#17161a" d="m1031 756-3.25 1.44-1.83.8A46 46 0 0 1 1020 760q-3.91 1.19-7.81 2.44c-15.35 4.85-15.35 4.85-20.19 4.56 5.95-4.41 13.94-6.13 21.09-7.57 1.91-.43 1.91-.43 4.41-1.43 9.72-3.89 9.72-3.89 13.5-2"/><path fill="#e0dee2" d="M1542 741c-1.5 3.11-3.2 6.05-5 9h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2c.16-2.26.57-3.6 2.26-5.16 2.9-2.05 5.06-3.4 8.74-2.84"/><path fill="#212024" d="M1187 724c-2.02 2.02-3.02 2.52-5.62 3.44l-2.17.77-2.21.79-4 1.5-1.98.73q-2.1.81-4.2 1.66l-2.13.86-1.93.8c-1.76.45-1.76.45-4.76-.55q1.62-1.01 3.25-2l1.83-1.12c2.14-.98 3.6-1.04 5.92-.88l1-3c4.22-2.31 12.5-5.25 17-3"/><path fill="#f8f8f8" d="M1702 583c2.06.44 2.06.44 4 1l.31 1.88c.8 2.44 1.47 2.96 3.69 4.12l-1 4h-3v-5l-2.25 1.38A89 89 0 0 1 1697 594c.6-4.24 1.9-7.9 5-11"/><path fill="#020109" d="m1318 551 2 3c-2.86 3.15-6.1 4.78-9.84 6.72-2.16 1.28-2.16 1.28-3.75 2.87C1305 565 1305 565 1302 566l-1 2-4-1c3.43-3.64 6.6-6.52 11-9l2.5-2a57 57 0 0 1 7.5-5"/><path fill="#3b2664" d="m1337 487 2 1c2.19 8.18 3.47 21.62-.34 29.38A32 32 0 0 1 1334 523l-1-2 .93-1.32c4.05-6.36 3.3-13.9 3.2-21.12l-.03-3.37z"/><path fill="#242128" d="m934 388 2 1-1.76 1.36c-7.13 5.52-7.13 5.52-10.24 8.26-3.03 2.57-6.4 4.38-9.9 6.24-2.1 1.14-2.1 1.14-4.91 3.01-2.65 1.36-3.44.97-6.19.13l1.87-1.1c5.84-3.47 11.51-7.1 17.13-10.9l6.56-4.37 3.13-2.1q1.14-.74 2.31-1.53"/><path fill="#68676b" d="M809 209c-4.32 1.5-8.6 2.95-13.06 3.94a27 27 0 0 0-7.38 2.87c-4.25 2.25-8.72 3.62-13.31 5l-2.26.7-2.16.63-1.94.58c-2.22.33-3.77-.05-5.89-.72l3.3-1.14c6.8-2.36 13.54-4.72 20.14-7.61a56 56 0 0 1 13.5-3.9c6.54-1.13 6.54-1.13 9.06-.35"/><path fill="#e4e4e5" d="M375 1755c1.88.25 1.88.25 4 1 1.13 2 1.13 2 2 4l2 1v5c-3.11-1.5-6.05-3.2-9-5v5l-4-1c.38-1.94.38-1.94 1-4l2-1q1.09-2.46 2-5"/><path fill="#48474f" d="m442 1596 .25 1.88.75 2.12 1.94.88c2.06 1.12 2.06 1.12 3.31 4.24l.75 2.88h2l2 5c-3-1-3.95-1.75-6-4v2h-2v-2l-3-1v-6h-3v-2h3z"/><path fill="#13161a" d="M349 1549h1q.08 4.03.13 8.06l.05 2.28c.04 4.94-.45 9.01-2.18 13.66l-2 1c-2.2-5.73-2.24-10.2 0-16l2-1c.41-1.85.41-1.85.63-4.06l.22-2.23z"/><path fill="#d78e66" d="M1336 1336h1l.55 2.53c4.16 18.63 4.16 18.63 10.34 25.65 1.11 1.82 1.11 1.82.78 4.05l-.67 1.77a49 49 0 0 1-10-18l-1.05-1.79c-1.25-2.92-1.14-5.3-1.08-8.46l.06-3.27z"/><path fill="#926d51" d="M1256 1268h2q.08 2.65.13 5.31l.07 3c-.2 2.74-.56 3.6-2.2 5.69a75 75 0 0 0-1 5h-4c.88-6.87.88-6.87 2-8q.55-2.5 1-5c.89-4.89.89-4.89 2-6"/><path fill="#e1dee2" d="m1494 1238 2.31 1.94c2.69 2.06 2.69 2.06 5.69 3.06v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1a78 78 0 0 0-2 5h-5c-1.8-3.83-2.2-6.78-2-11"/><path fill="#836e61" d="M1005 1228c-10.66 4.35-21.29 8.68-33 8 4.36-3 8.93-4.28 14.02-5.5 1.98-.5 1.98-.5 5.04-1.56 4.8-1.53 9.01-2.37 13.94-.94"/><path fill="#d3cac0" d="m1365.25 1228.75 2.55.05q3.1.08 6.2.2l1 3c-7.66 1.25-15.26 1.1-23 1 3.64-5.08 7.54-4.48 13.25-4.25"/><path fill="#7a7882" d="M1314 1135h1v28l-9-1c1-2 1-2 4-3l1-10h2z"/><path fill="#d2d8de" d="M1597 1066c3.11 1.5 6.05 3.2 9 5v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1a78 78 0 0 0-2 5h-4c-1.98-3.7-3.38-6.8-4-11"/><path fill="#8b97a2" d="M1570 1046c6.06.71 10.07 3.7 13.88 8.25l2.12 2.75q-.9-.45-1.81-.94C1582 1055 1582 1055 1579 1054l1 4h-3v-3l-3-1v-4h-4z"/><path fill="#4d4d51" d="M257 1038h22l-1 4q-3.52.08-7.06.13l-2 .05c-4.68.04-8.48-.7-12.94-2.18z"/><path fill="#9c9d9f" d="M242.75 1023.75c3.83.05 6.25.72 9.25 3.25v3h-13l-1-5c1.48-1.48 2.7-1.2 4.75-1.25"/><path fill="#77787b" d="m225.5 1019.9 3.06.04 3.07.02 2.37.04 1 6h-12l-1-5c1-1 1-1 3.5-1.1"/><path fill="#e4e7eb" d="M1533 998c2.44.81 2.44.81 5 2l1 3 3 1v-6h3c.93 3.01 1.04 3.87 0 7-2.06.69-2.06.69-4 1v3c-1.94-.31-1.94-.31-4-1l-1-3-1.56-.87-1.44-1.13c-.19-2.62-.19-2.62 0-5"/><path fill="#c5c5c4" d="M1061 995v1a219 219 0 0 1-26.28 4.27A34 34 0 0 0 1024 1003c-2.13.41-2.13.41-4.12.63l-2.01.22c-1.62.13-3.25.15-4.87.15 2.56-2.56 4.42-2.73 7.94-3.5l1.76-.39q8.11-1.75 16.3-3.11l3.26-.57c6.3-1.04 12.34-1.73 18.74-1.43"/><path fill="#9a9a9b" d="M309 963c5.12.64 10.01 1.67 15 3l-1 3a486 486 0 0 1-31-3v-1l19 1v-2z"/><path fill="#b1b1b0" d="M1238 940h1c.57 5.85-.31 11.06-1.81 16.69l-.5 1.9A55 55 0 0 1 1232 970h-1l-1-9 3-1 2-12h2z"/><path fill="#1d1d21" d="m1165.69 929.81 3.31.19v2l-1.64.53-7.3 2.4c-.42.15-.42.15-2.57.85l-2.45.81-2.27.75c-1.77.66-1.77.66-2.77 1.66q-2.65.55-5.31 1a109 109 0 0 0-9.69 2l1-3 2.23-.33c5.32-.87 9.2-1.75 13.77-4.67 1.74-.3 1.74-.3 3.63-.5 3.37-.5 3.37-.5 4.87-2.06 1.88-1.8 2.64-1.64 5.19-1.63"/><path fill="#2d2c30" d="m1058 878-9 2v2c-5.87 3.69-12.24 4.1-19 5l-6 1c3.75-3.75 7.98-3.69 13-4v-2q4.12-1.3 8.25-2.56l2.36-.75 2.28-.7q1.04-.3 2.1-.64c2.01-.35 2.01-.35 6.01.65"/><path fill="#a7b1b8" d="m1535 866 2 1c.98 4.23 1.16 8.36 1.19 12.69l.04 3.38c-.32 4.03-.32 4.03-2.23 5.93h-2l-.06-10.94-.03-3.15v-3l-.02-2.78c.11-2.13.11-2.13 1.11-3.13"/><path fill="#37373c" d="M1283 802a573 573 0 0 1-7 4l-2.81 1.63c-3.59 1.54-6.96 2.15-10.8 2.83a20 20 0 0 0-5.77 2.23c-2.98 1.49-3.6 1.27-6.62.31l4.31-2 2.43-1.12c2.26-.88 2.26-.88 5.26-.88v-2l1.69-.55 2.31-.76 2.48-.82c2.95-1.02 5.83-2.19 8.7-3.42 2.24-.55 3.65-.16 5.82.55"/><path fill="#232227" d="M1130 748a98 98 0 0 1-16.12 6.25l-1.74.53c-4.15 1.18-7.83 1.37-12.14 1.22 2.3-2.3 3-2.32 6.13-2.62l2.19-.23 1.68-.15v-2l1.72-.37c3.75-.81 7.44-1.65 11.1-2.82 3.16-.8 4.2-.72 7.18.19"/><path fill="#bababb" d="M1553 394h12l1 5c-1.48 1.48-2.69 1.17-4.75 1.19-3.49-.03-6.1-.61-9.25-2.19z"/><path fill="#8b8b8d" d="M462 327c2 4 2 4 3 7l2-3q-.17 3.9-.37 7.81l-.1 2.24-.12 2.16-.1 1.98c-.35 2.04-1.06 3.19-2.31 4.81l-2-1z"/><path fill="#58585b" d="M436 310h1c.59 11.93-1 22.46-4 34h-1q-.08-3.87-.12-7.75l-.06-2.21c-.03-4.02.23-6.48 2.18-10.04.52-2.4.82-4.82 1.13-7.25l.26-1.97z"/><path fill="#9c9b9e" d="m1343 300 2 1c.41 2.07.41 2.07.63 4.56l.22 2.5.15 1.94-1.43.84c-2.22 1.64-2.7 3.6-3.57 6.16l-5-1v-5c3-1 3-1 6 0l-.04-2.15-.02-2.79-.04-2.77c.1-2.29.1-2.29 1.1-3.29"/><path fill="#5a5d5e" d="m376 280 2 1v22l-4-1q-.08-3.52-.12-7.06l-.06-2c-.04-4.68.7-8.48 2.18-12.94"/><path fill="#7d7c7f" d="M762 218c2.82-.3 2.82-.3 6.13-.19l3.32.08 2.55.11c-3.78 3.78-9.91 4.56-15 6l-3.28.93c-5.38 1.43-10.15 2.45-15.72 2.07v-1l1.68-.37 2.2-.5 2.17-.5C748 224 748 224 750 222c2.57-.34 5.1-.44 7.69-.56L760 221l.93-1.52z"/><path fill="#89888b" d="m770.13 214.94 2.75.02 2.12.04-1 3-11 1v-3c2.52-1.26 4.31-1.1 7.13-1.06M762 218c-.65 1.95-.65 1.95-2 4-2.6.6-2.6.6-5.62.75l-3.04.17-2.34.08 1-3c4.14-1.51 7.57-2.27 12-2"/><path fill="#888789" d="m549 157 1 2h-5v3l3.88-.94 2.17-.52C553 160 553 160 555 159q2.17-.11 4.35-.1l2.58.01 2.7.03 2.71.01 6.66.05v1l-1.48.06-6.7.31-2.34.1c-4.26.22-7.55.74-11.48 2.53-1.95.34-1.95.34-3.69.5-3.31.5-3.31.5-5.31 2.5-2.62.13-2.62.13-5 0v-3l2.44-.81L543 161l1-3z"/><path fill="#2e2d33" d="M1069 83c-2.49 1.25-3.97 1-6.75.88-8.05-.09-15.88 1.64-23.49 4.21-2.77.91-4.87 1.08-7.76.91 10.52-5.63 26.66-12.68 38-6"/><path fill="#f8f7f8" d="M1217 62c3.11 1.5 6.05 3.2 9 5v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1a78 78 0 0 0-2 5c-2.26-.16-3.6-.57-5.16-2.26-2.05-2.9-3.4-5.06-2.84-8.74"/><path fill="#f1f1f2" d="m1394 1751 4 1 .31 1.88c.8 2.44 1.47 2.96 3.69 4.12l-1 4h-3v-5l-2.25 1.38a89 89 0 0 1-6.75 3.62c.55-4.64 2.33-7.3 5-11"/><path fill="#52515c" d="M1218 1641c2.25.31 2.25.31 4 1l-2.44.88C1217 1644 1217 1644 1216 1646c-11.12 3.85-22.78 3.36-34.37 3.19l-5.45-.04q-6.6-.06-13.18-.15v-1l3.28-.04q6.12-.1 12.24-.22l5.26-.09c26-.35 26-.35 34.22-6.65"/><path fill="#bfbcc1" d="M1470 1417c-1.5 3.11-3.2 6.05-5 9h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2c.16-2.26.57-3.6 2.26-5.16 2.9-2.05 5.06-3.4 8.74-2.84"/><path fill="#654a38" d="M1272 1209c10.4-.43 20.67.86 31 2l1 5h-2v-3l-1.84.07c-5.97.12-11.74-.24-17.66-.94l-2.1-.24c-3.34-.43-5.7-.88-8.4-2.89"/><path fill="#000002" d="M1120 1199h53v1l-48 1 4 2h-10z"/><path fill="#1a1a21" d="M391 1158h1l-.44 11.13-.12 3.17c-.2 5-.55 9.77-1.44 14.7-.25 2.79-.4 5.58-.56 8.38l-.13 2.23-.31 5.39h-1a1947 1947 0 0 1-.15-16.5c-.1-7.8.04-14.93 2.15-22.5q.56-3 1-6"/><path fill="#bab9bd" d="M1441 1145h1q.08 2.88.13 5.75l.07 3.23c-.2 3.02-.2 3.02-1.17 4.94-1.1 2.24-1.42 3.93-1.66 6.4l-.26 2.43-.23 2.5q-.14 1.25-.27 2.55l-.61 6.2h-1c-.52-25.66-.52-25.66 4-34"/><path fill="#2b2b35" d="M819 1056v1l-18 2v2l2.05-.29 2.7-.34 2.67-.35c2.8-.02 4.21.53 6.58 1.98l-1.5.04-6.75.27-2.36.07c-4.33.2-7.17.4-10.39 3.62.38-1.94.38-1.94 1-4l2-1v-2c-1.5-1.12-1.5-1.12-3-2 8.34-.62 16.63-1.18 25-1"/><path fill="#000001" d="M328 1034c6.73-.15 13.31.3 20 1l1 3h-21z"/><path fill="#151519" d="M323 974c8.86-.2 17.25-.05 26 1.49 4.31.73 8.64 1.14 13 1.51v1c-13.37.39-25.89-.31-39-3z"/><path fill="#919091" d="m333.67 962.7 2.01.04 2.07.07 2.11.04q2.58.07 5.14.15l-2 1v2h18v1l-14.5.09c-6.9.02-13.65-.33-20.5-1.09 2.62-2.19 4.22-3.38 7.67-3.3"/><path fill="#818184" d="M1194 821c-1 3-1 3-3.51 4.39l-3.18 1.3-3.13 1.32a24 24 0 0 1-9.18.99c1.4-4.18 3.66-4.97 7.43-7 3.9-1.52 7.46-1.44 11.57-1"/><path fill="#1d1d21" d="m885 812-4 1v2l-1.97.26-8.84 1.18-3.1.4-2.98.4-2.75.37C859 818 859 818 856 819q-2.6.1-5.19.06l-2.73-.02L846 819v-1l2.98-.53 24.65-4.44c9.35-1.7 9.35-1.7 11.37-1.03"/><path fill="#111014" d="M890 789v2a209 209 0 0 1-18.52 4.53q-2.55.48-5.12 1.04c-4.55.97-8.7 1.68-13.36 1.43v2h-8a27 27 0 0 1 7.91-2.93l2.38-.5L858 796a2635 2635 0 0 0 15.24-3.47l5.2-1.19 3.09-.7c6.62-1.7 6.62-1.7 8.47-1.64"/><path fill="#ecedee" d="M1530 749c-1.5 3.11-3.2 6.05-5 9h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2c.16-2.26.57-3.6 2.26-5.16 2.9-2.05 5.06-3.4 8.74-2.84"/><path fill="#d0ced0" d="M1558 729c-1.5 3.11-3.2 6.05-5 9h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2c.16-2.26.57-3.6 2.26-5.16 2.9-2.05 5.06-3.4 8.74-2.84"/><path fill="#cecdd0" d="M1574 717c-1.5 3.11-3.2 6.05-5 9h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2a78 78 0 0 0-5-2c.16-2.26.57-3.6 2.26-5.16 2.9-2.05 5.06-3.4 8.74-2.84"/><path fill="#010102" d="M185 711h6v3l-2.12.99-2.75 1.32-2.75 1.3c-2.34 1.37-3 2.19-4.38 4.39-2.62.19-2.62.19-5 0l1-4h3v-3l7-1z"/><path fill="#513087" d="m982.6 694.8 3.02.07 3.04.06 2.34.07v2c-7.43 2.29-7.43 2.29-11 2v2l-4 1 1-3q-1.94.17-3.88.37l-2.17.22C969 700 969 700 967 702l-3-1 1-3 2.77-.15 3.6-.23 1.83-.09c4-.27 5.77-2.45 9.4-2.73"/><path fill="#717075" d="M272 670c-1 3-1 3-3.62 4.39l-3.25 1.3-1.65.68c-2.67 1.07-4.57 1.63-7.48 1.63l-1 3c-1.38.5-2.76 1-4.18 1.4-2.86.95-5.44 2.44-8.09 3.9l-1.73.7-2-1c4.42-3.64 8.47-6.46 13.92-8.29 4.7-1.6 9.42-3.45 13.4-6.46 2.03-1.51 3.2-1.52 5.68-1.25"/><path fill="#7b5ab2" d="M635 609c-3.84 2.69-6.83 4.25-11.59 4.23h-2.72l-2.92-.03-3.01-.01q-4.75-.02-9.51-.07l-6.45-.02q-7.9-.03-15.8-.1v-1l2.7-.06a2453 2453 0 0 0 14.5-.36c8.38-.19 16.6-.63 24.89-1.9 3.34-.5 6.53-.78 9.91-.68"/><path fill="#09090f" d="m1474 578 2 1-1.31 1.56a37 37 0 0 0-3.86 7.56c-1.36 3.1-3.1 5.98-4.83 8.88h-2l-.62 2.56c-1.39 4.26-4.36 7.23-7.38 10.44l-1-2c1.25-2.56 1.25-2.56 3-5l3-1q1.1-2.97 2-6a45 45 0 0 1 3-6l3-1c.63-1.74.63-1.74 1-3.87.68-3.22 1.23-5.12 4-7.13"/><path fill="#392562" d="m1230 427-2.31 1.31c-2.69 1.69-2.69 1.69-4 3.22-2.38 2.06-4.47 2.2-7.57 2.66l-3.11.48c-2.7.3-5.3.4-8.01.33 1.23-3.69 2.94-4.72 6-7l2 1-2 3a49 49 0 0 0 13.1-5.3c2.35-.87 3.57-.51 5.9.3"/><path fill="#8c8c8e" d="M472 298c1.26 2.53.83 3.3.13 6a96 96 0 0 0-2.13 15h-1l-1-6-1 8h-1v-19l4-2z"/><path fill="#313035" d="m805 214 1 4-5.18.49c-1.82.51-1.82.51-3.26 2.04C796 222 796 222 793.74 222.3l-2.43-.1-2.45-.08L787 222v-1l2.94-.37L793 220l1-2-8 1c2.19-2.19 3.07-2.42 6-3.06 9.04-2.01 9.04-2.01 13-1.94"/><path fill="#8a8a8c" d="M618 191c1.81.19 1.81.19 4 1q1.53 2.49 3 5 1.98 1.05 4 2l1 3 3 1v3c-5.1-.24-7.37-1.76-10.68-5.38-2.62-3.2-4.32-5.42-4.32-9.62"/><path fill="#525556" d="M754 83c3.6.48 6.04.94 9 3 3.9 1 7.69 1.15 11.7 1.13h17.55c3.07 0 5.81-.22 8.75-1.13v2c-3.46 1.15-6.56 1.16-10.16 1.17h-2.13l-4.49.02-6.76.03c-7.7.03-14.96-.19-22.46-2.22z"/><path fill="#090a0e" d="M1374 1765v5l-3 1-1 3h2c-.81 2.44-.81 2.44-2 5l-3 1-1-2h-3l1-5 3-1v-2c1.28-1.29 1.28-1.29 2.94-2.62l1.65-1.36c1.41-1.02 1.41-1.02 2.41-1.02"/><path fill="#eaeaeb" d="M364 1734c2.06.44 2.06.44 4 1v7h2l-1 4-1.81-1.94C365 1742 365 1742 362 1741v5l-4-1c.38-1.94.38-1.94 1-4l2-1q1.05-2.48 2-5z"/><path fill="#33323d" d="m492 1597 2.13.44 2.87.56 2.66.53c3.92.55 7.7.62 11.65.6l2.28-.01c6.62-.06 12.99-.43 19.41-2.12-5.9 6.17-14.97 5.03-23.06 5.25A45 45 0 0 1 493 1599z"/><path fill="#e0480c" d="m1397 1249 14.65-.09h3.12l2.9-.02c2.33.11 2.33.11 4.33 1.11v3q-2.85.08-5.69.13l-3.2.07c-3.16-.2-5.23-.96-8.11-2.2-3.31-.12-3.31-.12-6 0v2l-3-1z"/><path fill="#61422d" d="M983 1244c6.95-.26 13.66.6 20.51 1.6q2.93.41 5.84.8l3.7.53 3.4.48 2.55.59 1 2c-7.89.34-14.42-.1-22.06-2.27-3.94-.98-7.81-1.23-11.86-1.42L984 1246z"/><path fill="#b8bec4" d="M1567 1035c3.9 2.83 6.84 6.04 9.95 9.7 2.8 3.15 5.8 6.07 8.85 8.97q1.64 1.63 3.2 3.33v2l4 1v2c-2.12 0-2.12 0-5-1-1.58-1.79-2.89-3.7-4.25-5.66-3.58-4.8-7.98-10.42-13.75-12.34v-4h-5l2-1z"/><path fill="#e3e4e4" d="m1430 1039 3 1-.5 2.25c-.79 4.33-.79 4.33-.5 6.75a17 17 0 0 0 3.31 3c2.68 2.13 4.8 4.1 6.69 7h-5v-4l-4-2v-2l-4-1v-3h-3l-4-7h6l-2 1 1 3 3 1z"/><path fill="#404043" d="M85 885c5.16 3.14 9.17 6.93 13.38 11.25l1.94 1.97L105 903c-4.45 0-6.77-1.83-9.96-4.87a349 349 0 0 1-4.08-4.2C89 892 89 892 86.77 890.4L85 889c-.25-2.19-.25-2.19 0-4"/><path fill="#99a5af" d="M1510 849h5l1 5 3 1-1 4v-3h-3l.44 4.88.24 2.74c.32 2.38.32 2.38 1.32 4.38l-3-1-.37-2.44-.63-2.56-2-1c.5-2.17 1-4 2-6l-3 1c-1.33-2.67-.67-4.17 0-7"/><path fill="#838284" d="m1206.29 822.9 2.77.04 2.79.02 2.15.04v3l-1.83.37-2.42.5-2.4.5c-2.35.63-2.35.63-4.41 1.7-3.04 1.46-5.6 1.06-8.94.93 1-3 1-3 2.81-3.91l2.19-.71c2.63-.88 4.64-2.36 7.29-2.48"/><path fill="#1d1d21" d="M383 779c8.87 2.4 16.84 5.77 25 10v1c-5.49-.35-9.37-.83-14-4-1.77-.82-3.55-1.56-5.35-2.3A78 78 0 0 1 383 781z"/><path fill="#19181c" d="M1068 745c-3.43 1.9-6.99 2.93-10.75 3.94a89 89 0 0 0-11.44 3.75c-4.74 1.9-9.75 2.04-14.81 2.31l1-2c2.36-.56 2.36-.56 5.31-1 3.68-.55 6.4-1.32 9.69-3h3v-2l5.63-1 3.16-.56a57 57 0 0 1 9.21-.44"/><path fill="#4a4a4d" d="M1463 702c-2.96 3.36-5.37 4.74-9.62 6.06-5.4 1.69-5.4 1.69-7.38 4.94-1.8.98-1.8.98-3.87 1.75l-2.06.8c-2.37.52-3.8.2-6.07-.55 3.64-2.2 6.83-4.04 11-5l1-2c1.56-.78 1.56-.78 3.44-1.5 2.52-.97 3.6-1.53 5.56-3.5 5.58-2.2 5.58-2.2 8-1"/><path fill="#392360" d="m1322 524 2 1c.63 2.06.63 2.06 1 4 3.27-.56 4.83-1.5 7-4v3h3c-.3 3.59-.77 4.78-3.44 7.31C1329 537 1329 537 1327 537l-.87-2.37C1325 532 1325 532 1323 530a90 90 0 0 1-1-6"/><path fill="#8e9090" d="m340 511 2 1v22l-4-1 .44-10.44.12-3 .12-2.87.11-2.65C339 512 339 512 340 511"/><path fill="#919195" d="M416 465h1q.08 3.78.13 7.56l.05 2.14c.04 4.8-.43 8.8-2.18 13.3l-3 1c-.82-8.44 1.64-16 4-24"/><path fill="#727175" d="M477 181h1v8l2 1h-2l-1 4v-3h-3v5l-4 2 .19 2.38L470 203c-1.51 1.16-1.51 1.16-3 2 6.78-20.78 6.78-20.78 10-24"/><path fill="#08080c" d="M571.8 117.88h5.02l2.9.02h2.96l15.73.05L614 118v1a4965 4965 0 0 0-31.45.59l-2.62.03c-4.75.12-8.5.67-12.93 2.38q-2.92.4-5.88.63l-2.92.22-2.2.15c2.57-2.03 4.93-2.5 8.12-3.06 2.76-.49 5.15-1.9 7.67-2.06"/><path fill="#b6b5b7" d="m1247 90 2 1c.59 2.31.74 4.62 1 7l-1.37.69c-1.63 1.31-1.63 1.31-2.57 3.87-1.06 2.44-1.06 2.44-3.12 3.31l-1.94.13c-1-1-1-1-1.1-2.63l.1-5.37 6 1-.04-1.71-.06-4.44c.1-1.85.1-1.85 1.1-2.85"/><path fill="#1e1f21" d="m638 70 4 1-1 3 14 2v1q-4.37.09-8.75.13l-2.5.05q-1.2 0-2.42.02l-2.22.03c-2.42-.26-3.98-1.1-6.11-2.23-2.8-.41-2.8-.41-5.75-.62l-2.98-.23L622 74v-1l16-1z"/><path fill="#bfc0c1" d="M846.81 73.94 854 74l1 4h-21v-3c4.32-1.07 8.4-1.12 12.81-1.06"/><path fill="#404049" d="m485 1762 19 1v1h-9v2h6v1q-4.06.09-8.12.13l-2.31.05c-4.65.04-8.25-.43-12.57-2.18v-1h7z"/><path fill="#13131a" d="M928 1735v2c-5.78 3.83-11.42 3.39-18.18 3.3l-3.54-.02q-4.63-.02-9.26-.07l-9.47-.06q-9.28-.06-18.55-.15v-1l1.92-.02a5381 5381 0 0 0 27.14-.4q5.3-.07 10.59-.17l3.35-.03 3.1-.06 2.72-.04C920 1738 920 1738 922 1736c2.1-.66 3.8-1 6-1"/><path fill="#4a4856" d="M485 1581q.9.45 1.81.94A29 29 0 0 0 492 1584l-1 2 2 2.31c2 2.69 2 2.69 2 5.69-3.8-.53-6.34-1.15-9-4a33 33 0 0 1-2-8z"/><path fill="#c7cdd0" d="M1531 1119h6l-2 7-5-1v8h-3l-1-7 1.38-.69c2.19-1.77 2.72-3.68 3.62-6.31"/><path fill="#c2c9d0" d="M1448 1021c4.43 5.84 4.43 5.84 5.83 8.7q1.38 2.71 2.9 5.36l.93 1.67 1.95 3.4c1.9 3.44 2.8 5.83 2.39 9.87l-.25-1.87-.75-2.13-1.87-.87c-3.02-1.6-3.75-4.16-5.13-7.13l-1.6-3.29-1.59-3.34-.82-1.69c-1.41-3-2.36-5.35-1.99-8.68"/><path fill="#101116" d="M253 1023h4l1 2 22 1 1 3a18 18 0 0 0 3 2h-5l-1 3-4-1v-6l-1.5.06q-3.37.13-6.75.19l-2.36.1c-5.82.1-5.82.1-8.94-2.37z"/><path fill="#06070b" d="M989 1006c5.4-.42 10.09.7 15 3 2.06 2.19 2.06 2.19 3 4-4.87-.87-4.87-.87-6-2-4.5-.26-7-.14-11 2l-3-1z"/><path fill="#000003" d="M1111 943h10l-1 3h-10zm-1 3-1 4h-10l-1-3c4.06-.77 7.86-1.1 12-1"/><path fill="#8d8d8d" d="m936.13 899.94 2.75.02 2.12.04-1 4h-9v2l13-1c-4.1 2.05-5.8 2.22-10.25 2.13l-3.27-.06L928 907l-1-3h2v-3c2.52-1.26 4.31-1.1 7.13-1.06"/><path fill="#2c2b31" d="m1004.19 870.88 2.73.05 2.08.07v3l-1.69.11a43 43 0 0 0-13.45 3.26c-2.73.93-5 .84-7.86.63l1-3c1.96-.66 1.96-.66 4.38-1.06 4.35-.75 4.35-.75 6.08-2 2.25-1.38 4.12-1.13 6.73-1.07"/><path fill="#4a494e" d="m1042.85 863.9 2.21.04 2.23.02 1.71.04c-4.13 3.1-9.31 4.13-14.19 5.63l-3.08.98c-5.8 1.78-10.67 2.78-16.73 2.39 6.16-4.18 13.13-5.59 20.34-7 2.68-.53 5.01-1.97 7.5-2.1"/><path fill="#8b8b8d" d="M1278 790c-1 3-1 3-3.26 4.13-5.26 1.83-10.18 3.38-15.74 3.87 1.14-2.82 2.2-4.6 4.96-6a33 33 0 0 1 14.04-2"/><path fill="#000005" d="m693.81 765.94 9.19.06-1 3h-26c5.56-3.7 11.35-3.13 17.81-3.06"/><path fill="#61399b" d="m820.26 734.8 3.87.07 2 .03 4.87.1v3l-2.27.18-2.98.26-2.95.24c-2.8.32-2.8.32-4.7.85-2.33.52-4.26.5-6.65.35l-2.56-.15-5.33-.33-6.56-.4v-1l1.62-.06q3.63-.15 7.26-.32l2.55-.09 2.44-.12 2.25-.1c3.27-.55 3.4-2.28 7.14-2.5"/><path fill="#804fbb" d="M384 720c3.56.61 6.81 1.28 10 3l.69 2.5c1.31 2.5 1.31 2.5 3.65 3.27q3.8.75 7.66 1.23l-1-3h4c.9 2.67 1.1 4.26 1 7l-1.87-.81a91 91 0 0 0-8-2.57A46 46 0 0 1 388 725v-2l-4-2z"/><path fill="#05070b" d="m258.81 621.44 2.96.3 2.23.26c-1.83 3.05-3.92 4.23-7 6l-2 2c-2.12-.37-2.12-.37-4-1l4-2-7-1v-4c3.8-.94 6.94-.98 10.81-.56"/><path fill="#090a0f" d="M271 610h7c.93 3.01 1.04 3.87 0 7h-7zm8 0h6l-1 3h-5zm-10 7 2 1Z"/><path d="m1718 485 4 1v20h-3z"/><path fill="#222226" d="M417 418h1c.1 5.74-.22 11.3-.87 17l-.24 2.29A93 93 0 0 1 414 452h-1q.13-5.97.31-11.94l.07-3.38c.22-6.73 1.01-12.45 3.62-18.68"/><path fill="#110f15" d="m912 399 2 1c-1.31 1.5-1.31 1.5-3 3h-3l-.25 1.88c-.75 2.12-.75 2.12-2.37 3.25a84 84 0 0 1-7.24 2.3q-2.5.66-4.97 1.4l-2.98.86-2.83.82C885 414 885 414 883 413l1.7-.63c5.34-2.05 10.5-4.37 15.61-6.93l1.78-.89a89 89 0 0 0 7.7-4.33z"/><path fill="#323036" d="M938 390c0 3 0 3-1.75 5.13-2.28 1.9-3.46 2.22-6.25 2.87l-.94 1.56L928 401c-2.62.19-2.62.19-5 0 1.37-3.15 2.86-5 5.5-7.19l1.84-1.54c2.69-2.06 4.34-2.38 7.66-2.27"/><path fill="#cdcdd0" d="M1464 378h16l1 5c-6.36.4-11.2-.34-17-3z"/><path fill="#a3a3a9" d="m1319 246 2 1c.59 2.31.74 4.62 1 7l-1.37.69c-2.2 1.77-2.73 3.68-3.63 6.31q-2 .06-4 0c-1-1-1-1-1.06-4.06l.06-2.94 6 1-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#8c8c90" d="M959 126v3l-5.08 1.37c-1.92.63-1.92.63-3.86 1.7-2.43 1.1-4.15 1.13-6.81 1.06l-2.42-.06L939 133c.8-1.96.8-1.96 2-4 3.31-.62 3.31-.62 5.2-.79 1.8-.21 1.8-.21 4.36-1.27 2.93-1.13 5.33-1.08 8.44-.94"/><path fill="#717577" d="m383 1761 2 1v2l1.86.27c2.14.73 2.14.73 3.37 2.4 1.73 3.76 3.25 7.18 3.77 11.33-9.77-7.94-9.77-7.94-12-12 .19-2.87.19-2.87 1-5"/><path fill="#817f89" d="m1309 1678 1 4h-2l-1 10 2 1q-.38 1.98-.81 3.94l-.46 2.21-.73 1.85-3 1c-2.49-2.8-2.16-5.4-2-9l4-1v-11h3z"/><path fill="#2c2b35" d="m865 1378 3 1c-9.74 6.85-9.74 6.85-13.31 9.19-2.69 1.81-2.69 1.81-5.19 3.87-4.1 3.18-8.68 5.1-13.5 6.94v-2a87 87 0 0 1 5-3q5.05-3.22 10.06-6.5l2.72-1.76 2.63-1.7 2.38-1.55c2.21-1.49 2.21-1.49 4.3-3.24z"/><path fill="#bfbbb2" d="m1348 1379 2 1v2l4 2v2l-13 2 1 2q-2.2-.39-4.37-.81l-2.47-.46c-2.43-.82-3.03-1.47-4.16-3.73l1.64.22 4.32.56c2.04.22 2.04.22 5.04.22v-3l5-1z"/><path fill="#50331f" d="M1064 1259q2.7.39 5.38.81l3.02.46c2.6.73 2.6.73 3.95 2.3l.65 1.43c-1 1-1 1-2.7 1.13h-4.19c-2.11-.13-2.11-.13-6.11-1.13l1 12c-2.25-3.37-2.25-4.56-2.19-8.5l.02-2.9c.17-2.6.17-2.6 1.17-5.6"/><path fill="#d3c3b2" d="M1370 1243c2.47.82 3.95 1.53 6 3h-3l-1 3c-1.42.92-1.42.92-3.19 1.75-4.27 2.26-6.9 5.46-9.81 9.25l-2-4q2.15-2.23 4.31-4.44l2.43-2.5c2.26-2.06 2.26-2.06 5.26-3.06z"/><path fill="#5e5d67" d="m436 1224 1 2h2v48h-1l-1-40-2 1z"/><path fill="#4a4a53" d="M391 1195h1c.32 27.44.32 27.44-3 32l-.09-14.85c-.01-5.49.01-10.76 1.09-16.15z"/><path fill="#3b3a46" d="M663 1185c2.06.44 2.06.44 4 1v3l3 1-2.44 1.44C665 1193 665 1193 664 1194q-3 .06-6 0l1.94-.87C662 1192 662 1192 663 1190l-2.05.47-2.7.6-2.67.59a13 13 0 0 1-6.58-.66l2.8-1.06 5.5-2.07c4.59-1.76 4.59-1.76 5.7-2.87"/><path fill="#4d4c56" d="M436 1174c4.28 4.28 3.05 12.29 3.07 18.06q0 4.47-.07 8.94h-1l-1-11-2 6h-1l.44-9.87.12-2.84.12-2.72.11-2.5c.21-2.07.21-2.07 1.21-4.07"/><path fill="#676670" d="M1280 1164c11.5-.28 22.6.52 34 2v2a138 138 0 0 1-34-3z"/><path fill="#9ca9b2" d="M1576 1093c6.45 6.45 4.1 26.27 4.13 35v2.64c0 5.2-.38 10.2-1.13 15.36h-1l.02-2.13c.15-23.72.15-23.72-.46-33.37l-.12-1.95c-.17-2.46-.33-4.33-1.44-6.55a78 78 0 0 1-.06-4.62l.02-2.48z"/><path fill="#000001" d="M259 1022c6.4-.14 12.64.26 19 1l1 3h-20z"/><path fill="#4f4e52" d="m1146 940-5 1-1 3c-7.97 3.58-16.34 4.69-25 5 2.5-2.5 4.48-2.89 7.88-3.87 3.53-1.04 7.04-2.08 10.5-3.31l3.18-1.13 2.88-1.05c2.67-.67 4-.57 6.56.36"/><path fill="#1b1b1f" d="m911.38 915.94 2.08.02 1.54.04v1l-3.37.8q-4.82 1.22-9.58 2.7-1.26.4-2.57.8-2.63.83-5.23 1.66c-4.59 1.42-8.45 2.4-13.25 2.04 3.58-3.17 7.39-4.3 11.88-5.62l2.1-.66c5.42-1.66 10.69-2.88 16.4-2.78"/><path fill="#6d6c70" d="m909.13 899.94 2.19.02 1.68.04c-3.89 3.59-6.96 4.8-12.12 5.69-6.75 1.27-6.75 1.27-9.88 2.31q-2.1.1-4.19.06l-2.17-.02L883 908a20 20 0 0 1 6.94-3.25l2.27-.65 2.35-.66 4.63-1.32 2.07-.58c5.3-1.64 5.3-1.64 7.87-1.6"/><path fill="#2a2930" d="M1038 863c-.8 1.47-.8 1.47-2 3-1.73.26-3.47.52-5.22.68-1.78.32-1.78.32-4.1 1.88-4.08 2.2-8.13 1.68-12.68 1.44 2.6-2.96 4.32-3.3 8.25-3.68 1.75-.32 1.75-.32 4.13-1.88 3.81-2.1 7.36-1.7 11.62-1.44"/><path fill="#29282f" d="M1142 827h8v3c-6.17 3.27-11.02 4.6-18 4v-3l1.93-.37 2.5-.5 2.5-.5 2.07-.63z"/><path fill="#56398d" d="m807.31 741.94 3.24.02 2.45.04v1c-12.28 3.28-24.33 5.03-37 6 2.22-2.52 2.74-2.96 6.25-3.44q1.88-.09 3.75-.12c7.02-.2 7.02-.2 10-2.44 3.8-1.03 7.39-1.1 11.31-1.06"/><path fill="#a7a7a8" d="M189 722v2h-2v2c-1.88 2.61-2.98 3-6.19 3.69L178 730v3h-6c.19-1.81.19-1.81 1-4 2.13-1.5 2.13-1.5 5-3l3.56-2.19 1.7-1.04c2.03-.9 3.54-.91 5.74-.77"/><path fill="#565459" d="m1606 685 1 2c-3.62 4.88-3.62 4.88-7 6l-.94 2.88c-1.2 3.54-1.75 4.46-5.06 6.12-2.66.52-5.3.81-8 1 .75-1.94.75-1.94 2-4 2.06-.62 2.06-.62 4-1l1-2 1.94-.37c2.06-.63 2.06-.63 3.31-2.7l.75-1.93h3l.31-1.87c.8-2.45 1.47-2.97 3.69-4.13"/><path fill="#e9ebea" d="M155 662c1.94.38 1.94.38 4 1l1 2q2.46 1.09 5 2v5c-7.43 2.43-7.43 2.43-11 1l1.94-1.81C158 669 158 669 159 666h-5z"/><path fill="#0b0c11" d="M807 521c-3.86 3.91-8.7 3.93-13.87 4.56-10 1.28-10 1.28-13.49 2.52a17 17 0 0 1-7.01 1.04l-2.1-.05L769 529c2.54-2.54 4.32-2.72 7.81-3.5l1.73-.39a220 220 0 0 1 15.59-2.68q2.8-.41 5.57-.98c2.48-.49 4.78-.52 7.3-.45"/><path fill="#20133b" d="M1072 486v2c-6.6 2.3-13.25 4.22-20 6v2l-7.25 2.56-2.07.75c-6.26 2.16-6.26 2.16-9.68.69l3.81-1.5 2.15-.84c2.04-.66 2.04-.66 5.04-.66v-2c6.33-2.74 12.7-4.88 19.35-6.7 8.45-2.3 8.45-2.3 8.65-2.3"/><path fill="#7a7b7e" d="M454 447h1l-2 26h-2l.07 2.48.06 3.27.07 3.23c-.2 3-.8 4.44-2.2 7.02l.44-11.94.12-3.44.12-3.27.11-3.03C450 465 450 465 451 464q.56-3.15 1-6.31a84 84 0 0 1 2-10.69"/><path fill="#a8a9ac" d="M1491 382h14l1 4c-1 1-1 1-3.94 1.1q-1.81 0-3.62-.04l-1.87-.01-4.57-.05z"/><path fill="#000002" d="M429 318c2 2 2 2 2.14 3.97l-.16 2.38-.16 2.58-.2 2.7q-.07 1.34-.17 2.71L430 339l-3 1q-.09-4.94-.12-9.87l-.06-2.84-.02-2.72-.03-2.5C427 320 427 320 429 318"/><path fill="#a5a3a6" d="m1307 218 2 1c.59 2.31.74 4.62 1 7l-1.37.69c-2.2 1.77-2.73 3.68-3.63 6.31q-2 .06-4 0c-1-1-1-1-1.06-3.56l.06-2.44c3-1 3-1 6 0l-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#07080d" d="m483 100 1 2h7c-1.43 3.64-2.99 4.87-6.44 6.63A65 65 0 0 0 477 113v-3l-3-1h4v-3l5-1-.56-1.94L482 101z"/><path d="m843.01 89.9 2.3.01 2.38.03 2.41.01 5.9.05v3c-6.7.93-13.25 1.11-20 1v-3c2.7-.9 4.24-1.12 7.01-1.1"/><path fill="#cacaca" d="M799 75v3h-22v-3q3.52-.3 7.06-.56l2-.17c4.49-.33 8.5-.05 12.94.73"/><path fill="#525356" d="M883 70q1.94-.08 3.88-.12l2.17-.08C891 70 891 70 893 72c.13 2.13.13 2.13 0 4-2.75.92-4.36 1.1-7.19 1.06l-2.17-.02L882 77z"/><path fill="#a09da1" d="M1327 1808h6c1 3 1 3 0 6h8v3l-7 1-.69-1.37c-1.77-2.2-3.68-2.73-6.31-3.63z"/><path fill="#030305" d="M1379 1759h3v6l-3 1a104 104 0 0 0-2 5 70 70 0 0 1-3 4l-4-1 1-4h3l.31-2.31c.75-2.93 1.52-3.7 3.69-5.69z"/><path fill="#36373f" d="M1236 1763c-5.12 3.98-11.47 4.45-17.69 5.06l-4.5.48-2 .2c-1.81.26-1.81.26-4.81 1.26-2.34.13-4.65.04-7 0v-1a250 250 0 0 1 15.5-3.5c2.37-.47 4.7-1.04 7.06-1.62 4.5-1.03 8.84-1.08 13.44-.88"/><path fill="#404049" d="M966 1766h27l-1 3c-9.06.14-17.97-.24-27-1z"/><path fill="#1d1c24" d="M782 1736c2.51.42 4.63.8 6.87 2.02 2.46 1.13 4.2 1.26 6.9 1.3l2.85.06 6.2.1 9.93.14 6.74.12q8.25.14 16.51.26v1q-10.3.12-20.6.16l-7.01.07q-5.04.05-10.09.06l-3.15.05c-5.54 0-9.5-.17-14.15-3.34z"/><path fill="#565859" d="M359 1716v6l-5-1v12h-3l-1-11 3-2 1-3c2-1 2-1 5-1"/><path fill="#46444d" d="m786.95 1661.7 2.18.11 2.19.08 1.68.11 1 3c-2.8 2.49-5.4 2.16-9 2l-1 3h-5v-4l1.88-.31c2.12-.69 2.12-.69 3-2.22 1.12-1.47 1.12-1.47 3.07-1.76"/><path fill="#3f3f40" d="M1300 1353a29 29 0 0 1 8 11.13c1.09 2.03 2.34 3.29 4 4.87q1.53 2.49 3 5 1.47 2.02 3 4c-7.45-3.33-12.82-10.29-16.19-17.56a50 50 0 0 1-1.81-7.44"/><path fill="#c6c0ba" d="m1301 1340 1 4Zm-3 4h3l1 8h2l2-8h2c1.37 2.26 2.08 3.46 1.69 6.13-.69 1.87-.69 1.87-1.69 3.87h-3l-1 4c-4.1-4.23-5.05-8.26-6-14"/><path fill="#cfd8df" d="M1535 1212h6c1 3 1 3 0 6h8v3l-7 1-.69-1.37c-1.77-2.2-3.68-2.73-6.31-3.63z"/><path fill="#302f33" d="m1446 1121 4 1-1 20h-3z"/><path fill="#808f9a" d="M1579 1054c3.8.54 5.54 2.11 8 5l1 3 3 1-1 5c-4.52-1.99-7.21-4.98-10-9-.87-2.94-.87-2.94-1-5"/><path fill="#616164" d="M279 1042h20l-1 4q-3.6.08-7.19.13l-2.04.05c-4.06.04-7.05-.34-10.77-2.18z"/><path fill="#2d2e31" d="M1218 994c0 4.96-3.66 7.57-7 11a252 252 0 0 1-7 6.67 25 25 0 0 1-9 5.33l-1.94 2c-2.5 2.42-4.74 3.1-8.06 4 1.65-3.16 3.98-4.23 7-6q2.98-2.15 5.9-4.4a211 211 0 0 1 4.15-3.08c6.06-4.4 11.7-9.28 15.95-15.52"/><path fill="#000001" d="M1242 904h3q.58 3.87 1.13 7.75l.33 2.21c.92 6.7.92 6.7-1.46 10.04-3.66-6.55-3.3-12.68-3-20"/><path fill="#908f90" d="M996 881q2.6-.08 5.19-.12l2.92-.08c3.04.21 5.12.97 7.89 2.2q2.49.56 5 1l-1 2c-2 .48-2 .48-4.44.75l-2.43.3C1007 887 1007 887 1005 885c-2.38-.41-2.38-.41-5.12-.62l-2.76-.23L995 884z"/><path fill="#929296" d="M1460 874h2c.37 6.58.37 6.58-1.44 8.63L1459 884c-1.13 4.2-1.31 8.49-1.56 12.81l-.13 2.1-.31 5.09h-1q-.12-5.85-.19-11.69l-.07-3.34-.03-3.23-.05-2.97A14 14 0 0 1 1460 874"/><path fill="#020207" d="m1073 875 6 2v1h-5l-1 3-15 1c4-5 4-5 6.54-5.67l2.65-.08c4.75-.2 4.75-.2 5.81-1.25"/><path fill="#26252b" d="M1199 807c2.16-.3 2.16-.3 4.63-.19l2.47.08 1.9.11c-1 3-1 3-2.75 4.17-5.6 2.54-10.11 3.35-16.25 2.83l1-3 5.27-.49c1.73-.51 1.73-.51 2.67-2.04z"/><path fill="#868688" d="M1267 799h9c-1 3-1 3-3 4.17-5.63 2.24-9.94 3.28-16 2.83 3.32-3.94 3.32-3.94 6.75-4.25l2.25.25z"/><path fill="#9f9e9e" d="m181 753 1 3q-1.86 2.26-3.75 4.5-1.05 1.25-2.1 2.53C174 765 174 765 171.72 765.28L170 765v4h-2l-1-4h2l.15-1.73c1.22-3.26 3.63-4.65 6.41-6.52l1.58-1.1q1.92-1.34 3.86-2.65"/><path fill="#0d0c11" d="M1077 740c-32.97 10.21-32.97 10.21-40 10 3-2.56 5.3-3.5 9.19-4.12 4.72-.8 4.72-.8 5.81-1.88q3.3-.78 6.63-1.44a107 107 0 0 0 11.5-2.81c2.96-.77 4.04-.6 6.87.25"/><path fill="#1a1920" d="m1399 663 2 1-3.63 3.62q-1.8 1.82-3.56 3.7C1392 673 1392 673 1390 673l-1 3-2.31 1.81c-4.43 3.6-6.71 6.84-7.44 12.57q-.15 1.8-.25 3.62h-2c-.8-5.21.16-7.73 3.19-12.06q1.88-2.48 3.81-4.94l1.3-1.76c2.81-3.68 6.17-6.27 9.93-8.94 1.77-1.3 1.77-1.3 3.77-3.3"/><path fill="#abaeae" d="m255 607 1 2q2.46 1.09 5 2l-1 5h-6l1-6h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#04050a" d="M311 590h8v3l-7 1-1 4h-7l-1-4 8-1z"/><path fill="#7e7d80" d="M1716 551h5l.81 2.38c1.17 2.59 1.72 3.38 4.19 4.62l-1 7h-3v-8l-2.44.56-2.56.44c-1-1-1-1-1.06-4.06z"/><path fill="#909193" d="M427 519q3.4-.12 6.81-.19l1.95-.07c2.87-.05 4.78-.05 7.19 1.58C444 522 444 522 444 525c-5.93.29-10.42-1.14-16-3z"/><path fill="#5d5c5f" d="m1658 422 11 1v3h-12l1 5h-6l1-5 3-1z"/><path fill="#0d0d11" d="m1616 418 2 .94c3.16 1.12 5.9 1.42 9.23 1.62 2.3.57 2.51 1.52 3.77 3.44 1.66.4 1.66.4 3.5.5 3.34.33 3.34.33 4.94 2.06l.56 1.44h-3v-2h-8l-1 3v-7h-11v2c-2.29 1.14-3.6 1.1-6.12 1.06l-2.2-.02-1.68-.04 3-1v-2h6z"/><path fill="#000002" d="M422 366c1.48 4.05.78 7.89.25 12.06l-.23 2.18c-.64 5.28-.64 5.28-2.6 7.76l-1.42 1c-.2-16.2-.2-16.2 1-21 1.56-1.44 1.56-1.44 3-2"/><path fill="#c9c6ca" d="m1347 310 2 1c.59 2.31.74 4.62 1 7l-1.37.69c-2.2 1.77-2.73 3.68-3.63 6.31l-5-1v-6l6 1-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#28272e" d="m906 236 1 2-1.2 1.76c-2.93 4.3-5.62 8.51-7.8 13.24h-2l-.71 1.98c-1.69 3.95-3.78 7.6-5.91 11.33l-1.28 2.25L885 274c-1-3-1-3 .2-5.8q.88-1.6 1.8-3.2l.89-1.54q1.4-2.37 2.86-4.71l1.05-1.7c2.74-4.4 5.65-8.66 8.66-12.88A269 269 0 0 0 906 236"/><path fill="#adaeaf" d="m384 242 2 1v20l-4-1q-.08-3.6-.12-7.19l-.06-2.04c-.04-4.06.34-7.05 2.18-10.77"/><path fill="#848486" d="m510 190 1 2 3 1-3.31 4.81-1.87 2.7c-1.5 2.06-2.85 3.87-4.82 5.49-2.19-.31-2.19-.31-4-1l1.81-2.69c1.35-2.01 2.6-4.02 3.81-6.12C507 194 507 194 509 193z"/><path fill="#6d6c70" d="M581 163c7.98 1.32 13.9 5.08 18.71 11.55C601 176 601 176 604 177v2h2a25 25 0 0 1 3 5l-1 2-1.81-2.44c-5.57-6.67-13.02-12.2-20.53-16.49A24 24 0 0 1 581 163"/><path fill="#87878a" d="M937 129c2.06.44 2.06.44 4 1l-2 3h7c-2.73 2.13-5.74 2.94-9 4l-1 1q-3 .06-6 0l4-1v-3h-9c2.7-1.8 4.08-2.4 7.13-3.06 3.77-.84 3.77-.84 4.87-1.94"/><path fill="#09090e" d="m724.95 89.89 2.52.01 8.47.05L743 90l-2 1v2h-28v-1l1.93-.18 2.5-.26 2.5-.24c2.34-.36 2.69-1.3 5.02-1.43"/><path fill="#c8cac9" d="m483 87 1 2q2.46 1.09 5 2l-1 5h-6l1-6h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#4e4f52" d="m1334 1803 2 1-1 6-8-2v6l-2-1c-3-.25-3-.25-6 0l-.95 1.49c-1.05 1.51-1.05 1.51-2.85 1.88l-2.08.07c-3.26.27-3.97.43-6.62 2.62l-1.5 1.94c.57-2.87.86-3.86 3-6 2.03-.6 4.05-1.01 6.13-1.44 1.87-.56 1.87-.56 3.87-2.56a55 55 0 0 1 3.56-1.56c3.34-1.34 3.34-1.34 4.44-2.44 2.34-.14 4.66-.04 7 0z"/><path fill="#3f3f48" d="M912 1766h21l-1 3h-25v-1h5z"/><path fill="#23232a" d="M388 1664c2.18 3.66 2.7 7.3 3.38 11.44 1.12 6.46 2.7 12.57 4.75 18.79A63 63 0 0 1 398 1702c-2.95-1.47-3.43-4.08-4.56-7-2.33-5.89-2.33-5.89-3.44-7q-.6-3.77-1.06-7.56l-.28-2.1c-.6-4.81-.74-9.49-.66-14.34"/><path fill="#12121a" d="M843 1251c0 3.6-.96 5.1-3 8h-2a520 520 0 0 0 .59 8.1c.53 3.77 1.06 6.83 4.14 9.26l1.9.95 1.9 1q.73.33 1.47.69c-1.62.75-1.62.75-4 1-3.67-1.83-5.44-2.95-7.37-6.56-2.18-11.97-2.18-11.97.94-16.93A63 63 0 0 1 843 1251"/><path fill="#5a3923" d="M963 1270h6v4h8v5a34 34 0 0 1-17-5c2.67-1.33 4.17-.67 7 0-1.1-2.2-1.96-2.76-4-4"/><path fill="#0a0b11" d="m1494 1244 2 1v4h6l-3 9h4v6h-1v-5l-3 1-1 6-3-1v-6h3v-8l-4-1z"/><path fill="#31303a" d="m867 1226 4 1v3l-3.37 2-1.9 1.13c-1.73.87-1.73.87-3.73.87v2l-6 2c.36-2.65.68-4.56 2.25-6.75 1.75-1.25 1.75-1.25 4.31-1.75 2.44-.5 2.44-.5 3.75-2.06z"/><path fill="#8b684d" d="M1248 1214h16l1 3h-35v-1h18z"/><path fill="#e1e7ea" d="M1538 1139c3.88 1.88 3.88 1.88 5 3a120 120 0 0 1 .22 8.22l.02 4.03q.02 3.07.07 6.14l.02 3.91.04 1.85c-.02 3.9-.7 6-3.37 8.85l-2-1 1.48-1.7c1.75-2.65 1.9-4 1.92-7.14l.01-2.78-.04-2.88.04-2.88-.01-2.78-.01-2.52c-.47-2.8-1.66-4.1-3.39-6.32-.19-3.25-.19-3.25 0-6"/><path fill="#14141c" d="M1130 1129a81 81 0 0 1-15 5.13l-2.04.5c-5.74 1.42-11.45 2.54-17.4 2.43l-2.06-.02-1.5-.04v-1l5.27-.59c1.73-.41 1.73-.41 3.73-2.41 1.98-.47 1.98-.47 4.4-.78l2.64-.36 2.77-.36c5.84-.76 13.86-4.48 19.19-2.5"/><path fill="#949594" d="M106 981h6l-1 5-3 1-2 3-11-1v-3h12z"/><path fill="#030304" d="M1440 919h3c-.5 4.3-1.75 6.3-4.81 9.31l-2.08 2.12c-2.32 1.73-3.3 1.94-6.11 1.57l1-5 3-1 1-3 3-1 1-1.56z"/><path fill="#38353c" d="M786.73 917.77c4.2.09 8.15.4 12.27 1.23v2l-2.55.4-3.33.54-3.3.52C787 923 787 923 785 924a65 65 0 0 1-4.63-.44l-2.47-.3-1.9-.26v-1l5.27-1.37c2.28-.83 3.03-2.54 5.46-2.86"/><path fill="#7e7e80" d="M980 886a69 69 0 0 1-12.62 4 23 23 0 0 0-7.25 2.56c-3.91 1.8-7.38 1.98-11.64 2.23C946 895 946 895 943 896q-2.1.1-4.19.06l-2.17-.02L935 896v-1l1.7-.32 7.74-1.5 2.66-.5c5.14-1 10.02-2.29 14.96-4.02a54 54 0 0 1 6.82-1.78c8.4-1.72 8.4-1.72 11.12-.88"/><path fill="#403f44" d="m951.29 886.9 2.77.04 2.79.02 2.15.04c-4.06 2.99-8.4 4.2-13.31 5-4.05.69-7.98 1.52-11.94 2.63-1.95.41-3.76.43-5.75.37v-2l3.25-.81 2.18-.55q2.86-.72 5.74-1.4l3.27-.8 3.12-.76c2.62-.73 3.05-1.66 5.73-1.78"/><path fill="#b8b8b9" d="m8 802 2 1v8l2.44-.56L15 810c1 1 1 1 1.06 3.56L16 816l-5 1-.81-2.37c-1.17-2.6-1.72-3.4-4.19-4.63.88-6.87.88-6.87 2-8"/><path fill="#222125" d="m1075 765-4 2-1.77.99c-2.69 1.22-5.27 1.74-8.17 2.32-6.08 1.28-11.58 3.35-17.28 5.8-2.86.91-4.04.97-6.78-.11l7.06-2.87 2-.82c4.26-1.73 8.5-3.1 12.94-4.31l3.19-1.06c4.5-1.5 8.03-2.05 12.81-1.94"/><path fill="#808081" d="M1354 758c-1.25 3.76-2.53 4.25-5.87 6.25l-2.68 1.64C1343 767 1343 767 1340 766v-4c4.83-2.74 8.4-4.56 14-4"/><path fill="#2b2a2f" d="m1406 745 2 1c-2.47 2.9-4.97 3.96-8.5 5.31a98 98 0 0 0-14.93 7.55c-3.03 1.34-5.3 1.35-8.57 1.14 1.34-2.69 3.1-3 5.79-4.09 2.54-1.05 5.03-2.2 7.52-3.35 4.54-2.08 9.13-3.97 13.8-5.76 1.89-.8 1.89-.8 2.89-1.8"/><path fill="#44276f" d="M887 728c-2.18 2.18-2.73 2.32-5.63 2.78l-2.2.36-2.3.36-4.44.72-2.22.36a84 84 0 0 0-8.38 2.13c-2.2.35-3.73-.05-5.83-.71 10.88-4.12 19.3-6.35 31-6"/><path fill="#010104" d="M230 683h5c-.2 1.84-.2 1.84-1 4-2.21 1.41-2.21 1.41-4.94 2.63-4.9 2.21-4.9 2.21-7.06 4.37-2.62-.37-2.62-.37-5-1v-2l6-1v-3l2.94-.87C229 685 229 685 230 683"/><path fill="#0f0e15" d="M1567 630h4v4h-5l-.19 2.31c-.81 2.69-.81 2.69-3.06 4.5-2.86 1.24-4.68 1.45-7.75 1.19l1-3c2.06-.69 2.06-.69 4-1v-3l2.38-.25c2.62-.75 2.62-.75 3.93-2.81z"/><path fill="#866cb1" d="M712 597c-3.17 2.34-6.4 2.73-10.23 3.29l-2.15.33-4.5.67q-3.42.5-6.83 1.03l-4.4.66-2.03.3c-4.65.68-9.17.83-13.86.72v-1l2.03-.34q4.65-.76 9.28-1.53c.53-.1.53-.1 3.2-.53q9.03-1.49 18.02-3.23c3.83-.58 7.61-.54 11.47-.37"/><path fill="#a2a1a3" d="m271 599 1 2q2.46 1.09 5 2l-1 5h-5c-1-3-1-3 0-6h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#1e1138" d="M1275 575c2.06.44 2.06.44 4 1l-2.12 1.24-2.76 1.63-2.74 1.62C1269 582 1269 582 1267 584c-2.29.9-4.55 1.72-6.87 2.5-7.6 2.58-7.6 2.58-11.13 4.5 1.13-2.37 1.13-2.37 3-5q2.1-.75 4.2-1.4c3.53-1.19 6.8-2.98 10.11-4.66l2.2-1.1c5.35-2.7 5.35-2.7 6.49-3.84"/><path fill="#9ea0a1" d="m319 575 1 2q2.46 1.09 5 2l-1 5h-5c-1-3-1-3 0-6h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#b4a4c9" d="M404 570q2.38-.12 4.75-.19l2.67-.1c3.07.35 4.2 1.4 6.58 3.29 2.14.51 2.14.51 4.25.69l2.14.2 1.61.11-1 3c-8 .15-8 .15-11-1l-1-2c-2.29-.63-2.29-.63-5.06-1.12l-2.79-.51L403 572z"/><path fill="#2f1a53" d="m1323 532 2 1-1 6a69 69 0 0 0 10-7l2 1c-4.27 3.95-8.37 7.5-13.55 10.18-1.45.82-1.45.82-3.45 2.82-2.12.13-2.12.13-4 0v-3l3-1 1-3 3-1z"/><path fill="#08080a" d="m1720 510 2 1v7h3l1-2a85 85 0 0 1-2 7 61 61 0 0 0-.32 3.57l-.12 2.03-.12 2.09-.13 2.12-.31 5.19 3 1-7 3 1-5h2v-18h-3z"/><path fill="#1a1032" d="M1356 451c3.36 2.82 5.18 4.9 6.47 9.13q1.48 4.74 3.14 9.4l1.58 4.53.82 2.28A36 36 0 0 1 1370 491h-1l-.45-2.54a109 109 0 0 0-10.2-29.72A24 24 0 0 1 1356 451"/><path fill="#000002" d="M425 342h3v5h-2l.07 3.21.06 4.17.05 2.11c.01 1.84-.08 3.68-.18 5.51l-2 2c-1.95-1.95-1.19-5.54-1.19-8.19l-.03-2c-.02-4.37.62-7.74 2.22-11.81"/><path fill="#0e0d14" d="M865 314c1.36 3.69.6 5.79-.81 9.38l-1.15 2.95L862 329l-.94 3.13c-1.55 4.2-3.8 8-6.06 11.87-1-2-1-2-.33-4.5l1.08-3.06 1.14-3.27L858 330l1.5-4.81c1.35-4.08 3.25-7.54 5.5-11.19"/><path fill="#19191d" d="M442 277c1.49 3.87.32 6.2-1 10-.44 2.53-.68 5.07-.94 7.63-.78 7-2.13 13.59-4.06 20.37h-1q-.12-3.31-.19-6.62l-.07-1.88c-.08-4.6 1.06-7.38 3.26-11.5.4-2.5.4-2.5.5-4.87.34-4.8 1.55-8.75 3.5-13.13"/><path fill="#d0cfd1" d="m1329 290 5 1 .11 1.9.2 2.48.18 2.46c.51 2.16.51 2.16 2.04 3.29l1.47.87c.19 2.63.19 2.63 0 5h-4c-1.5-3-1.06-5.66-1-9l-4-1z"/><path fill="#b2b5b5" d="M387 279c.69 11.82.69 11.82-1.94 15.75L383 297h-1q-.16-3.65-.25-7.31l-.1-2.1c-.04-1.94-.03-3.68.35-5.59 2.96-3 2.96-3 5-3"/><path fill="#333137" d="M906 232c.75 1.6.75 1.6 1 4a57 57 0 0 1-3.94 6.25l-1.17 1.68q-1.43 2.04-2.89 4.07l-1-3-3 1q.98-2 2-4l1.25-2.44c2.16-3.16 4.72-5.26 7.75-7.56"/><path fill="#8c8c8d" d="M464 219h1v11l-2 1c-.63 2.5-.63 2.5-1.12 5.56l-.51 3.07L461 242h-3c-.39-5.63.23-8.97 3-14 1.09-2.97 2.05-5.98 3-9"/><path fill="#9b9a9c" d="m416 154 2 1v8l2.44-.56L423 162c1 1 1 1 1.06 3.56L424 168l-5 1-.81-2.37c-1.17-2.6-1.72-3.4-4.19-4.63.88-6.87.88-6.87 2-8"/><path fill="#535358" d="M981 116a111 111 0 0 1-24 6v2q-3.87 1.05-7.75 2.06l-2.21.6-2.15.56-1.97.53c-2.22.29-3.8-.09-5.92-.75q4.85-1.66 9.7-3.3l4.89-1.68a116 116 0 0 1 17.34-4.61c1.92-.38 3.62-.91 5.45-1.6 2.84-.88 3.86-.64 6.62.19"/><path fill="#b2b3b2" d="m491 83 1 2q2.46 1.09 5 2l-1 5h-5c-1-3-1-3 0-6h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#c4c4c5" d="M768 82q1.8-.09 3.63-.12l2.03-.08c2.34.2 2.34.2 5.06 1.14 4.04 1.3 7.95 1.75 12.15 2.19l2.38.26L799 86v1q-4.73.09-9.44.13l-2.66.05c-5.67.04-11.57-.02-16.9-2.18-1.25-1.57-1.25-1.57-2-3"/><path fill="#c8c7ca" d="m903 59 1 2q2.46 1.09 5 2l-1 5h-5c-1-3-1-3 0-6h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#000001" d="M913 66c2.38-.3 2.38-.3 5.13-.19l2.75.08L923 66v3l-11 1v3l-8 1v-4l5.27-.49C911 69 911 69 911.91 67.47z"/><path fill="#383740" d="M469 1748h2v2h5l1 3 7 1 1 3-1 2c-5.43-.43-8.02-1.26-12-5h2v-3h-5z"/><path fill="#080a0f" d="M1491 1374h3v6h-3v6l3 1c-3.62 1.2-4.64.54-8-1v4h-3c-.19-1.87-.19-1.87 0-4q1.5-1.01 3-2 1.04-2.49 2-5l2-1c.63-2.06.63-2.06 1-4"/><path fill="#5f5d68" d="M396 1189h1v6h2l.09 11.8c.01 4.2-.01 8.1-1.09 12.2-2-2-2-2-2.23-4.8q0-1.76.03-3.52l.01-1.87q.02-2.97.06-5.93l.03-4.02q.03-4.93.1-9.86"/><path fill="#2d2d37" d="m935.36 1197.9 1.64.1 1 3c-2.35 2.35-3.96 2.73-7.13 3.63l-2.75.78-2.12.59v-3h-7v-1l3.31-.31c3.5-.48 3.5-.48 5.13-2.25 2.39-2.2 4.79-1.66 7.92-1.55"/><path fill="#94a2ac" d="m1582 1121 2 1c.34 2.3.56 4.5.69 6.81l.14 1.91c.21 3.33.13 6.1-.83 9.28a156 156 0 0 0-.82 7.17l-.18 1.83h-2l-1 3z"/><path fill="#ebebeb" d="m1413 1010 1 3 4 1v11l-4-1c-1.08-1.96-1.08-1.96-1.81-4.37l-.77-2.4c-.55-2.9.28-4.64 1.58-7.23"/><path fill="#b9b9b8" d="m1089 962-1 4-5.37.59-1.63.41-1 2-4-1 1-2h-10v-1l7.63-1.5 2.16-.43A52 52 0 0 1 1089 962"/><path fill="#929293" d="M335 944q3.85.17 7.69.38l2.18.09c4.04.22 7.53.5 11.13 2.53l1 3h-25v-1l17-1c-2.11-2.11-6.57-1.45-9.43-1.62C337 946 337 946 335 944"/><path fill="#b8b7b9" d="m1166 938-1 3c-2.03.91-2.03.91-4.5 1.63-4.43 1.3-4.43 1.3-5.5 2.37q-2.53.1-5.06.06l-2.79-.02-2.15-.04c1.06-1.99 1.63-2.86 3.77-3.66l2.04-.4a35 35 0 0 0 6.82-2.07c2.89-1.06 5.31-1.01 8.37-.87"/><path fill="#020204" d="M41 935h5v3l4 1v3l5 1 1 3-7-1v-2l-1.75-.25c-2.93-.98-4.3-2.39-6.25-4.75zM138 929c6.47.4 10.84 1.17 15.81 5.63C155 936 155 936 155 937h-8v-2l-9-3z"/><path fill="#828182" d="M1027 877c6.73-.15 13.31.3 20 1v1l-1.54.43-7.02 1.95-2.43.67q-7 1.94-14.01 3.95c2.55-3.11 5.26-3.78 9-5l1-2h-5z"/><path fill="#06060b" d="m1456 809 .44 1.94c.56 2.06.56 2.06 1.56 3.06q.12 2.24.1 4.47l-.01 2.73-.03 2.86-.01 2.88-.05 7.06h-2c-1.24-2.49-1.13-4.19-1.13-6.97V812.1c.13-2.1.13-2.1 1.13-3.1"/><path fill="#b391df" d="M524 759h20v4l-20-1z"/><path fill="#18191d" d="M325 728c1.86.25 1.86.25 4 1 1.17 1.75 1.17 1.75 2.2 4 2.94 5.68 7.02 9.67 11.61 14.06l2.4 2.34q2.88 2.8 5.79 5.6c-3 0-3 0-4.64-1.31l-1.76-1.83-1.95-2-2.02-2.11c-7.52-7.77-7.52-7.77-10.53-10.07-2.52-2.02-3.16-3.63-4.1-6.68z"/><path fill="#47474c" d="m1430 716 2 1c-1.31 1.5-1.31 1.5-3 3h-3v2c-12.06 6.45-12.06 6.45-17.44 5.63L1407 727c5.63-3 5.63-3 9-3l1-3c2.46-1.7 4.71-2.15 7.63-2.5 3.37-.5 3.37-.5 5.37-2.5"/><path fill="#fdfefd" d="M95 706c1.94.38 1.94.38 4 1l1 2c2.06.63 2.06.63 4 1-.3 3.5-.78 4.78-3.31 7.31-2.64 1.66-3.68 2.14-6.69 1.69 1.5-3.11 3.2-6.05 5-9h-5z"/><path fill="#676668" d="M263 688h2c-1.4 3.31-2.9 4.98-5.81 7.06l-2.02 1.48a80 80 0 0 1-4.44 2.79c-3.67 2.25-7.09 4.8-10.54 7.36q-1.04.75-2.1 1.55L235 712l-2-1 2.38-1.76c9.56-7.1 9.56-7.1 13.5-10.62 2.8-2.49 5.76-3.94 9.12-5.62q1.42-1.3 2.75-2.69z"/><path fill="#131319" d="M1379 686h1l.08 1.68.17 2.2.14 2.17c.61 1.95.61 1.95 2.16 3.19 3.47 1.08 6.72 1.09 10.33 1.07l2.1.05c3.55 0 6.14-.21 9.28-1.94 2.74-1.42 2.74-1.42 5.12-1.1l1.62.68c-9.16 5-18.36 6.57-28.62 4l-3.38-1z"/><path fill="#010103" d="M1586 695h5c-.17 2.34-.66 3.68-2.37 5.3q-1.6 1.3-3.26 2.55C1584 704 1584 704 1583 706h-6l1-4h4l1-4h3z"/><path fill="#d9d9db" d="M131 678c1.94.38 1.94.38 4 1l1 2c2.06.63 2.06.63 4 1-.3 3.5-.78 4.78-3.31 7.31-2.64 1.66-3.68 2.14-6.69 1.69 1.5-3.11 3.2-6.05 5-9h-5z"/><path fill="#0a090e" d="M1251 668v2c-8.57 4.62-17.39 8.25-27 10v-2c23.85-10 23.85-10 27-10"/><path fill="#b38ae1" d="m460 609 1 2 3 1c2.25 2.42 2.22 4.47 2.13 7.69l-.06 2.45L466 624l-2-1-1-3-2 8h-1z"/><path fill="#252429" d="m1676 603 3 1v3l4-1v2l-2 1c-.62 2.56-.62 2.56-1 5l-3 1-.43 1.83c-.78 2.97-2.3 3.99-4.63 5.92l-2.23 1.86L1668 626v-3q1.89-1.85 3.82-3.66c1.7-1.94 1.96-3.82 2.18-6.34l-2-1h6v-6h-3z"/><path fill="#090a0f" d="M1640 471q2.51.43 5 1v3l1.77.82a31 31 0 0 1 5.86 4.06l1.78 1.49A15.4 15.4 0 0 1 1658 487c-.37 2.25-.37 2.25-1 4v-3h-2a255 255 0 0 1-3.5-4.28c-2.08-2.38-4.43-4.32-6.85-6.33A75 75 0 0 1 1639 472z"/><path fill="#828386" d="M425 386h1c.29 9.07.37 17.34-2.2 26.13-1.36 4.87-2.05 9.87-2.8 14.87h-1c-.32-5.18.28-9.82 1.38-14.87 1.87-8.64 2.76-17.34 3.62-26.13"/><path fill="#b3b3b3" d="m420 146 2 1v8l5-1 1 6-5 1-.81-2.37c-1.17-2.6-1.72-3.4-4.19-4.63.88-6.87.88-6.87 2-8"/><path fill="#1c1c24" d="M1248 1761c-9.37 5.77-17.97 8.25-29 8 4.65-2.44 9.54-4 14.5-5.69l2.88-1 2.78-.95 2.52-.86c2.5-.54 3.92-.33 6.32.5"/><path fill="#372518" d="M1185 1363c7.75.58 15.35 1.62 23 3v2l4 1v2c-3.1-.44-5.92-.97-8.87-2a31 31 0 0 0-7.07-1.44c-4.13-.43-7.32-1.77-11.06-3.56z"/><path fill="#1f1510" d="M1014 1301c19.2 6.26 19.2 6.26 28 11-3 .97-4.5 1.1-7.31-.31a43 43 0 0 0-7.07-2.69l-1.71-.48-4.32-1.17-2.47-.72-2.3-.65c-1.82-.98-1.82-.98-2.58-3.08z"/><path fill="#36343f" d="m732 1145 1 2h-2l-.81 3.31c-.85 2.97-1.6 4.83-4.19 6.69l-3 1-2 2v-2l-4-1 4.31-3.94 2.43-2.21C726 1149 726 1149 729 1148c1.69-1.56 1.69-1.56 3-3"/><path fill="#0f0e16" d="M984 1134c11.07.57 22 1.6 33 3v2a102 102 0 0 1-33-4z"/><path fill="#2c2a34" d="m782 1079 2 3c-2.75 4.88-2.75 4.88-5 6l-1.25 3.13c-1.5 3.73-3.66 6.29-6.34 9.25A38 38 0 0 0 767 1107c-1.98 3.24-3.69 6.01-7 8a57 57 0 0 1 4.8-8.22q1.35-2.01 2.69-4.06l1.57-2.4 1.48-2.27c1.46-2.05 1.46-2.05 3.08-3.64 2.06-2.1 2.48-4.64 3.38-7.41q1.44-2.05 3-4 1.05-1.98 2-4"/><path fill="#56575a" d="M265 963c6.17-.21 11.61.55 17.63 1.88 8.1 1.7 16.16 3.09 24.37 4.12v1c-6.88.2-13.5-.55-20.31-1.44l-3.29-.4c-8-1.02-8-1.02-11.4-2.16v-2h-7z"/><path fill="#464649" d="m749.13 948.94 2.19.02 1.68.04c-3.72 3.62-7.52 3.66-12.44 4.06-4.93.43-8.96.99-13.56 2.94-2.24.2-2.24.2-4.31.13l-2.12-.06L719 956c5.08-5.08 15.14-5.32 22.04-5.79 2.94-.32 5-1.32 8.09-1.27"/><path fill="#95a2ac" d="m1525 908 1 2h4v4l-6 2-1 24h-1c-.25-23.38-.25-23.38 1-30z"/><path fill="#020206" d="m1144.85 853.9 2.21.04 2.23.02 1.71.04-1 3-10 1-1 3h-10c2.42-3.63 3.45-3.65 7.56-4.62 2.8-.68 5.65-2.34 8.29-2.48"/><path fill="#515154" d="m1363 749 2 1c-4.38 3.68-9.2 5.66-14.75 6.87-4.28 1.04-8.01 2.73-11.94 4.7-4.09 2.02-7.73 3.8-12.31 4.43 1.85-3.7 6.5-4.86 10.13-6.56l2.55-1.25c3.68-1.75 7.03-3.34 11.1-3.8 3.43-.41 5.54-1.35 8.53-3.01 2.55-1.42 2.55-1.42 4.69-2.38"/><path fill="#8144c9" d="M409 667a21 21 0 0 1 3 4 58 58 0 0 1-1 4q-.15 2.02-.2 4.03l-.07 2.37-.05 2.52-.06 2.6-.31 13.81L410 714h-1c-1.37-15.71-.71-31.27 0-47"/><path fill="#cfcfd0" d="M1706 575c2.13.38 2.13.38 4 1l.31 1.88c.8 2.44 1.47 2.96 3.69 4.12l-1 7h-3v-8l-5 1c-.62-2.37-.62-2.37-1-5z"/><path fill="#543390" d="M823 575v2c1.5 1.13 1.5 1.13 3 2-1.9 1.98-3.39 2.93-6.13 3.23-3.8.03-7.2-.2-10.87-1.23v-2l5.88-2 3.3-1.12C821 575 821 575 823 575"/><path fill="#403f43" d="m1682 445 9 1v5l5 1v3l7-1 .94 2.38A43 43 0 0 0 1706 461l-5-1v-3h-6l-1-3c-2.06-.69-2.06-.69-4-1l-1-4-2.37-.31C1684 448 1684 448 1682 445"/><path fill="#4a3478" d="M1251 408c-.57 2.87-.86 3.86-3 6-3.12.13-3.12.13-6 0l-.62 1.94c-1.38 2.06-1.38 2.06-4.5 2.81l-2.88.25c4.74-6.05 8.86-11 17-11"/><path fill="#707174" d="m1530 382 19 1v3h-20z"/><path fill="#000001" d="M399 244h3l-1 19h-3a2017 2017 0 0 1-.1-13.96l-.01-2.27C398 245 398 245 399 244"/><path fill="#7e7e80" d="m824.06 199.94 2.94.06c-4.33 3.59-9.11 4.97-14.4 6.7q-2.5.83-5 1.7l-3.2 1.05-2.92.98C799 211 799 211 796 210l2-1 .78-1.53c1.22-1.47 1.22-1.47 3.75-1.91l2.97-.12c5.3-.23 5.3-.23 7.5-2.44 1.7-.6 1.7-.6 3.56-1.06 3-.76 4.4-1.94 7.5-2"/><path fill="#2e2d33" d="m837 201-1 3q-3 1.02-6 2l-.93 1.55L828 209c-1.95.3-1.95.3-4.12.19l-2.2-.08L820 209v-2c4.08-3.24 11.84-8.58 17-6"/><path fill="#5a595e" d="m495 157 2 1-1.5 1.58a90 90 0 0 0-11.86 16.04A18 18 0 0 1 478 181c2.67-9.72 8.96-18.03 17-24"/><path fill="#000002" d="M640 134c-1 3-1 3-2.94 4l-2.06 1-1.06 1.63L633 142h-3l-.37 1.75A30 30 0 0 1 627 150h-1c-.75-6.76-.75-6.76.94-9.54 3.57-3.64 7.74-7.05 13.06-6.46"/><path fill="#3f3f45" d="M990 110h8c.33 4.17.46 6.19-1.62 9.88-2.84 2.53-4.67 2.7-8.38 3.12l-1 2h-2c1.48-4.45 5.08-6.75 9-9h2v-4h-7z"/><path fill="#a1a2a5" d="M855 78h11l-1 5c-2.29 1.14-3.6 1.1-6.12 1.06l-2.2-.02L855 84c-1-3-1-3 0-6"/><path fill="#5c5b60" d="m1377 1770 2 1a43 43 0 0 1-8.63 8.79c-1.37 1.21-1.37 1.21-2.36 2.8-1.01 1.41-1.01 1.41-4.01 2.41l-.94 2.88c-1.19 3.5-1.84 4.42-5.06 6.12-3.25.69-3.25.69-6 1 .98-2.45 1.65-3.77 3.88-5.25 2.2-.78 3.8-.92 6.12-.75v-5l1.82-.8c2.31-1.27 3.48-2.46 5.12-4.51 2.54-3.1 5.2-5.9 8.06-8.69"/><path fill="#000003" d="M458 1762c2.69-.17 5.32-.27 8 0a34 34 0 0 1 3 3c2.34.57 4.59.77 7 1l1 3c-3.81 1.47-6.29.43-10-1v-2l-9-1z"/><path fill="#31313b" d="m769 1441 2 1a69 69 0 0 1-11 8c-3.2 1.94-5.97 3.86-8.5 6.63-3.11 2.95-6.16 4.37-10.5 4.37v-2l2.09-1.07a92 92 0 0 0 15.83-10.32c2.87-2.22 5.9-3.88 9.08-5.61z"/><path fill="#e54d0a" d="m1399 1359 4 2v2h2l.81 2.44 1.19 2.56 3 1c.95 2.07.95 2.07 1.69 4.56l.76 2.5.55 1.94h-3l-1-3-2.06-2c-1.94-2-1.94-2-1.94-5h-3z"/><path fill="#dfdad3" d="M1302 1276h3c.17 2.69.27 5.32 0 8q-1.47 1.53-3 3c-.57 2.65-.81 5.3-1 8h-3c-.33-5.66-.33-9.2 3-14 .69-2.81.69-2.81 1-5"/><path fill="#5c5957" d="M1376 1224v1l-2.52.15-3.3.22-3.26.22c-2.86.4-3.68.82-5.92 2.41q-2.67.63-5.37 1.06l-2.84.48-2.79.46-5 1c7.01-9.24 20.72-7.32 31-7"/><path fill="#c3cbd2" d="M1614 1121v12l-5 1c-1-1-1-1-1.31-4.25.03-3.14.38-3.86 2.18-6.69 2.13-2.06 2.13-2.06 4.13-2.06"/><path fill="#303033" d="M1050 1029c4.17.51 8 1.23 12.02 2.46 3.94 1.08 7.97 1.78 11.98 2.54v1c-16.96.26-16.96.26-21-1-2.06-2.56-2.06-2.56-3-5"/><path fill="#dfdedd" d="m1081 1030 43 1v1l-28 1v1c-5.44.25-9.82-.27-15-2z"/><path fill="#98999a" d="M1210 999c0 3 0 3-1.57 4.65l-2.12 1.73c-2.38 1.99-4.72 3.95-6.87 6.18-4.78 4.78-10.02 9.98-16.44 12.44l-2-1c5.03-4.8 9.84-8.65 16-11.86 2.93-1.67 4.74-3.64 7-6.14l3.31-3.31 1.55-1.55z"/><path fill="#404243" d="m100 975 5 1 1 3h8v4l1.8-.14c7.25-.33 7.25-.33 10.26 2.14l1.94 2c-4.87-.87-4.87-.87-6-2-3.88-.15-7.27-.2-11 1l1-5-7 1-1.37-2.37C102 977 102 977 100 975"/><path fill="#555658" d="M650 965v1c-13.1 2.59-25.63 4.7-39 4v-1a280 280 0 0 1 21.75-3.12l2.4-.27c4.98-.52 9.84-.69 14.85-.61"/><path fill="#000001" d="M1245 936h1q.09 4.13.13 8.25l.05 2.36c.06 7.11.06 7.11-2.18 10.39h-2c-.26-6.88.16-13.33 2-20z"/><path fill="#b5b4b6" d="m1189 930-1 3c-1.81.66-1.81.66-4 1.06s-2.19.41-4 .94l-1 2h-9l1-4 2.12-.4c.45-.1.45-.1 2.76-.54l2.74-.52c7.15-1.61 7.15-1.61 10.38-1.54"/><path fill="#d3d5d8" d="m1459.63 913.88 2.37.12a32 32 0 0 1-3.06 8.25l-1.1 2.14-.84 1.61h-1v-6a41 41 0 0 0-11 5c1.21-3.64 2.26-4.08 5.44-6.06 7.93-5 7.93-5 9.18-5.07"/><path fill="#bcc4ca" d="M1487 882h1c.33 4.7-.32 7.14-3 11l-1 2h-3l.44 2.13c-.6 3.97-2.61 5.08-5.71 7.44A15 15 0 0 0 1472 909l-2-1q1.14-1.19 2.31-2.44c3.17-3.75 4.41-7.88 5.69-12.56l2.31-.19c2.69-.81 2.69-.81 4.44-3.06 1.2-2.63 1.84-4.9 2.25-7.75"/><path fill="#ebedef" d="m1539 887 2 1v20l-3 1-.06-10.44-.03-3v-2.87l-.02-2.65c.11-2.04.11-2.04 1.11-3.04"/><path fill="#3b3b3e" d="M334 758h3l1 2 3 1c3.28 3.48 4.74 6.5 6 11-2.31-.17-3.66-.65-5.27-2.33a121 121 0 0 1-2.48-3.2c-1.25-1.47-1.25-1.47-2.9-2.54L335 763v-2h-2z"/><path fill="#1b1336" d="M819 744c-2.43 1.87-4.54 2.47-7.54 3.04l-2.7.5c-.47.1-.47.1-2.82.52l-2.74.53a153 153 0 0 1-16.3 2.16c-1.9.25-1.9.25-2.9 1.25q-2.02.1-4.06.06l-2.23-.02L776 752v-1a73 73 0 0 1 14.63-2.94c6.35-.7 12.36-1.92 18.57-3.46 3.4-.73 6.35-.84 9.8-.6"/><path fill="#1e1d23" d="M1377 738c-.17 1.86-.17 1.86-1 4-2.36 1.27-2.36 1.27-5.31 2.25l-2.93 1.02c-2.42.64-4.28.9-6.76.73v-3h5v-2h2l1-3c5.75-1.12 5.75-1.12 8 0"/><path fill="#f2f3f3" d="M67 730c1.94.38 1.94.38 4 1l1 2c2.06.63 2.06.63 4 1-.39 2.54-.7 3.73-2.63 5.48l-1.93 1.27-1.94 1.3-1.5.95-2-2 1.94-1.81C70 737 70 737 71 734h-5z"/><path fill="#100f16" d="M1542 643h12c-.36 2.62-.83 3.82-2.69 5.75-2.91 1.58-5.04 1.47-8.31 1.25z"/><path fill="#422875" d="M1020 575c2.85 2.85 2.56 5.05 3 9h2l2.06 6.21q1 2.97 2.07 5.92l1.12 3.18 1.08 3c.68 2.74.64 4.07-.33 6.69l-1.5-3.81-.84-2.15C1028 601 1028 601 1028 598h-2l-.75-3.31a105 105 0 0 0-4.45-13.17 16 16 0 0 1-.8-6.52"/><path fill="#a688cc" d="m437 600 2.45.44c4.88.81 9.8 1.29 14.71 1.81l2.23.25 2.03.22C460 603 460 603 461 604q.06 2.5 0 5l-1.64-.91a40 40 0 0 0-8.11-2.53A94 94 0 0 1 437 601z"/><path fill="#07060d" d="m669.92 577.7 2.6.02q4.12.03 8.23.1l5.58.03q6.84.06 13.67.15v1l-15.55 1.15c-9.17.69-18.26.98-27.45.85 3.81-3.92 7.82-3.39 12.92-3.3"/><path fill="#302f36" d="M455 531c6.17-.44 11.78.47 17.76 1.86 4.35 1 8.6 1.53 13.05 1.9 4.48.5 8.49 1.6 12.19 4.24-8.9-.4-17.43-2.21-26.14-4.01A342 342 0 0 0 455 532z"/><path fill="#604392" d="M954 529c-1 3-1 3-2.78 3.91l-2.16.72A46 46 0 0 0 943 536q-2.56.1-5.12.06l-2.76-.02L933 536l1-3 2.48-.37 3.27-.5 3.23-.5c4.06-.85 6.8-2.93 11.02-2.63"/><path fill="#010007" d="m979.26 510.7 2.43.11 2.45.08 1.86.11-1 3c-2.07.9-3.97 1.58-6.12 2.19l-1.79.53c-4.2 1.18-7.77 1.68-12.09 1.28l1-3 1.64-.11 2.17-.2 2.15-.18c2.04-.51 2.04-.51 3.5-2.04 1.54-1.47 1.54-1.47 3.8-1.76"/><path fill="#3f3e43" d="M407 490h1c.1 6.77.03 13.3-1 20h-2c.44 3.88.44 3.88 2 6a62 62 0 0 0 11 4c-2.52.91-3.73 1.09-6.32.26l-2.37-1.13-2.38-1.12C405 517 405 517 404 516c-1.19-8.57 1.38-17.62 3-26"/><path fill="#161618" d="M1717 476h2v6l4 1 .08 1.76c.41 5.7 1.53 8.68 4.92 13.24a18 18 0 0 1 1 4l-5-2-1 6h-1v-22h-4z"/><path fill="#09080e" d="M1455 427a2558 2558 0 0 1 17.88.42q3.26.07 6.53.17l2.03.03c4.2.13 7.6.89 11.56 2.38 2.12.41 2.12.41 3.88.63l3.12.37v1c-4.64.18-8.81-.08-13.37-.94-6.07-1.1-12.07-1.46-18.22-1.72-4.54-.2-8.93-.62-13.41-1.34z"/><path fill="#101015" d="M1394 425h29v1a261 261 0 0 1-26 1v5h2l1 6c-5.07-3.89-4.4-5.77-6-13"/><path fill="#8e8e90" d="M851 158v2l-2.08.33-2.73.48-2.71.46c-2.48.73-2.48.73-3.95 2.3C838 165 838 165 835.3 165.28l-2.99-.1-3-.08L827 165v-2c3.72-1.19 7.13-1.07 11-1v-2c4.58-1.79 8.08-2.2 13-2"/><path fill="#f9f9f9" d="M695 74h14l1 4c-1.56 1.56-3.09 1.2-5.25 1.25-4.03-.04-6.58-.59-9.75-3.25z"/><path fill="#65636e" d="M1221 1638c-1.12 3.36-2.36 4.34-5.3 6.21-2.59 1.2-4.91 1.06-7.7.79l3-1v-2h-18v-1l3.44-.15 4.56-.22 2.25-.1c4.53-.23 8.66-.86 13.02-2.11 1.73-.42 1.73-.42 4.73-.42"/><path fill="#88878f" d="M423 1503h3q.13 4.65.19 9.31l.07 2.67.08 4.94C426 1522 426 1522 423 1525z"/><path fill="#4a311e" d="M1096 1326h6l-1 4a64 64 0 0 0 15 6l2-2c2.36-.27 4.62-.09 7 0-3.96 3.96-3.96 3.96-7.02 4.08A40 40 0 0 1 1104 1334l-2.21-1.09q-2.91-1.43-5.79-2.91z"/><path fill="#5e3b23" d="M935 1254h6l-3 1 1 2c2.06.63 2.06.63 4 1l-1 5c-6.75-.75-6.75-.75-9-3-2.62-.62-2.62-.62-5-1v-1h7z"/><path fill="#ddd6cb" d="M1334 1241c-.31 1.94-.31 1.94-1 4l-3 1-1 2-1.75.81a17 17 0 0 0-4.87 3.75C1320 1255 1320 1255 1318 1256c1.66-6.5 5-9.93 10.69-13.31 3-1.69 3-1.69 5.31-1.69"/><path fill="#dbe0e2" d="m1535 1127 5 1v5c-3 1-3 1-6 0v7h-3l-1-6 1.38-.69c2.19-1.77 2.72-3.68 3.62-6.31"/><path fill="#000001" d="M1066 1037q4.06.17 8.13.38l2.3.09c4.55.23 8.32.86 12.57 2.53v1q-5.15.05-10.31.06l-2.96.03h-2.84l-2.62.02c-2.27-.11-2.27-.11-5.27-1.11z"/><path fill="#c7cdd2" d="M1548 1022c3.34.58 4.7 1.6 7 4l3 1v3l2.31-.19c2.69.19 2.69.19 4.5 1.5 1.19 1.69 1.19 1.69 1.19 4.69l-6-1v-2l-4-1v-4l-2.31-.87c-3.93-1.65-3.93-1.65-5.69-3.13z"/><path fill="#e1e1e2" d="M1415 985c1.72 3 2.24 5.1 2.2 8.55l-.02 2.56-.06 2.64-.02 2.7-.1 6.55h-3l-.09-13.36v-2.85l-.02-2.64c.11-2.15.11-2.15 1.11-4.15"/><path fill="#939395" d="M83 836h1c1 9.54 1.38 18.5 0 28-2-2-2-2-2.24-4.5v-3.13l.02-3.38.01-1.77q.02-2.67.02-5.34c.05-8.74.05-8.74 1.19-9.88"/><path fill="#7a7b7c" d="M1244 807v3l-1.8.59c-6.82 2.24-6.82 2.24-9.95 3.6-2.54.92-4.57.96-7.25.81 5.27-5.88 11.25-8.22 19-8"/><path fill="#747477" d="m1484.38 775.25 1.62.75-2 2-.81 2.06L1482 782l-2.3.43c-4.63.98-7.83 3.9-10.7 7.57-.64 2.8-.53 5.19 0 8l-2-1c-.5-3.64-.86-6.73 1.27-9.87A32 32 0 0 1 1473 783h2l.13-1.75c1.14-2.94 2.67-3.94 5.3-5.57 1.57-.68 1.57-.68 3.94-.43"/><path fill="#7e7d7e" d="M1373 750c-1.25 1.5-1.25 1.5-3 3-2.19.19-2.19.19-4 0v2l-5 1v2a101 101 0 0 1-6 3l-2-1 1-2-4-1h6v-2l1.71-.37 2.23-.5 2.21-.5C1364 753 1364 753 1365 751c5.53-2.24 5.53-2.24 8-1"/><path fill="#1f1e23" d="M1203 716c-8.07 5.7-16.22 8.1-26 9 1.23-2.46 2-2.58 4.5-3.56 3.41-1.35 3.41-1.35 4.5-2.44l3.13-.37c3.46-.43 6.2-1.53 9.3-3.1 1.57-.53 1.57-.53 4.57.47"/><path fill="#18171e" d="m1459 695 2 1-2 1zm-2 2 2 1-2 4h-4l-1 3c-3.66.75-6.42 1.24-10 0 2.7-3.34 6.12-4.47 10-6l2.81-1.25z"/><path fill="#424145" d="M315 662c.99 3.48.86 5.7.06 9.25l-.59 2.7L314 676h-2l-1 28h-1q-.2-5.84-.27-11.68l-.1-3.95c-.33-9.87 1.17-17.42 5.37-26.37"/><path fill="#08080f" d="M676 571h23v3h-19v-2z"/><path fill="#a591bd" d="M875 553c-.72 1.46-.72 1.46-2 3-2.06.44-2.06.44-4.44.56-3.27.18-4.9.6-7.56 2.44h-3v-2h-5c5.5-5.5 14.83-4.22 22-4"/><path d="M355 515h2c1.15 2.3 1.05 3.37.88 5.91l-.15 2.3-.17 2.41-.16 2.43-.4 5.95h-3a2017 2017 0 0 1-.1-13.96l-.01-2.27C354 516 354 516 355 515"/><path fill="#5a5a5f" d="M453 468h1c-1.34 33.95-1.34 33.95-4 40h-1l.44-7.75.12-2.21.12-2.15.11-1.97c.22-2.04.7-3.93 1.21-5.92q.2-1.74.28-3.5l.11-1.94.11-2c.23-4.28.53-8.37 1.5-12.56"/><path fill="#0a0a10" d="M371 437h2l1 25-2-1c-.41-1.85-.41-1.85-.62-4.06l-.23-2.23L371 453h-1l-.06-7.44-.03-2.14q0-2.7.09-5.42z"/><path fill="#30195e" d="M1288 370h2l.52 2.2c1.56 6.51 3.4 12.79 5.61 19.1.83 2.56 1.4 5.06 1.87 7.7-1.86-1.02-2.84-1.67-3.77-3.62l-.55-1.9-.62-2.1-.62-2.2c-2.92-9.98-2.92-9.98-4.44-13.18l-2-1c.88-3.87.88-3.87 2-5"/><path fill="#030208" d="m867 319 3 3-2 2.13c-1.9 2.3-2 2.67-1.87 5.87-.13 3-.13 3-1.45 4.5A60 60 0 0 1 860 338c-1.6-4.8.9-8.57 3-13a66 66 0 0 1 4-6"/><path fill="#8b8c8e" d="M441 301h1a433 433 0 0 1 .1 5.96C442 309 442 309 441 312l3-1 1-8h1q.12 2.4.19 4.81l.1 2.7c-.29 2.49-.29 2.49-1.78 3.85-1.98 2.15-1.93 3.42-2.02 6.3l-.1 2.71-.08 2.82-.1 2.85L442 336h-1v-21h-2z"/><path fill="#535257" d="M820 205c-2.71 3.46-6.15 4.3-10.19 5.56l-2.04.66a145 145 0 0 1-10.46 2.97c-3.31.81-3.31.81-5.56 2-2.28 1.06-3.4.5-5.75-.19 6.93-4.01 14.92-7 23-7v-2c7.3-3.63 7.3-3.63 11-2"/><path fill="#8e8f90" d="M737 186v2c-3.5.8-6.71 1.12-10.3 1.1l-2.96-.01-3.05-.03-3.11-.01L710 189c6.9-5.1 18.78-3.09 27-3"/><path fill="#757477" d="m915 151 2 1-1 2 3.25-1.06c2.97-.97 4.77-.88 7.75.06l-1.58.66c-8.56 3.69-16.8 7.98-24.42 13.34l-2-1c1.26-2.53 2.7-2.86 5.2-3.95 8.3-3.64 8.3-3.64 10.05-7.93z"/><path fill="#7f7e7f" d="m540.85 136.9 2.21.04 2.23.02 1.71.04v1c-5.17 1.9-9.95 3.33-15.47 3.7l-1.53.3-1 2h-6v-2l5-2-5-1v2l-4-1c2.34-1.83 3.8-2.24 6.76-2.16l2.14.03 2.22.07q3.94.09 7.88.06c1-1 1-1 2.85-1.1"/><path fill="#151516" d="M739 85h10l1 2c6.69 1.45 13.62 1.34 20.44 1.56L783 89v1h-35l-1-3-8-1z"/><path fill="#b3b5ba" d="M1055 6h14l-2 4a20 20 0 0 1-7.06 1.19l-2.1.04C1056 11 1056 11 1054 9z"/><path fill="#010105" d="M1291 1731h2c-.32 2.97-.85 3.87-3.19 5.81l-1.33.98c-2.87 2.36-5.13 5.34-7.48 8.21l-3-1 1-3 2-1 1.25-2.31c2.2-3.38 4.33-4.6 7.75-6.69z"/><path fill="#44434e" d="m582.81 1558.25 2.19.75v3q-2.08 1.52-4.27 2.88c-2.78 1.8-5.21 3.96-7.73 6.12l-2-1 3-5 3 1v-3l-2-1c4.3-4.06 4.3-4.06 7.81-3.75"/><path fill="#d8d8da" d="M1439 1433h7l-1 5h8v3l-7 1-.69-1.37c-1.77-2.2-3.68-2.73-6.31-3.63z"/><path fill="#17171f" d="M819 1413c2.06.44 2.06.44 4 1l-1.98 1.25-2.77 1.75-1.49.94q-4.02 2.55-7.95 5.24l-1.58 1.08-4.4 3.04a35 35 0 0 1-8.83 3.7c3.89-4.25 8.04-7.1 13-10 9.88-5.88 9.88-5.88 12-8"/><path fill="#000001" d="M1345 1398c11.02-.58 11.02-.58 15 3v3l2 1h-6l-1-2c-2.29-.63-2.29-.63-5.06-1.12l-2.79-.51-2.15-.37z"/><path fill="#78563e" d="M1258 1275h1c.64 6.05-1.61 10.47-4 16h-12v-2h-5v-1q2.88-.05 5.75-.06l3.23-.04a25 25 0 0 1 7.02 1.1l.31-3.06c.56-4.03 1.97-7.27 3.69-10.94"/><path fill="#5c3b25" d="M1034 1253h6l-1 2q-.16 1.8-.21 3.63l-.09 2.14q-.08 2.23-.13 4.46l-.1 2.14-.05 1.97c-.42 1.66-.42 1.66-3.42 3.66z"/><path fill="#dddde0" d="M1446 1174h3q.3 3.72.56 7.44l.17 2.14q.18 2.7.27 5.42c-1 1-1 1-2.63 1.1l-5.37-.1v-2h4z"/><path fill="#1f1f28" d="M1219 1151v9h-23c4.12-2.06 6.82-2.24 11.25-2.12l1.97.02 4.78.1v-3c3.54-4 3.54-4 5-4"/><path fill="#515354" d="M239 1034h19l-1 3c-6.68 1.09-12.68 2.06-19-1z"/><path fill="#131316" d="m1026 1022 1.56.88c4.1 1.89 8.35 2.88 12.75 3.84l1.77.4 3.56.74q2.2.5 4.36 1.14l1 2-13-1 1 3h-2l-1-2-2-1 1-3h-9l-1-2z"/><path fill="#cbcac9" d="M1132 980c-2.55 2.55-4.97 2.8-8.37 3.44q-7.06 1.4-14 3.25A42 42 0 0 1 1096 988c3.68-2.16 7.43-2.84 11.56-3.62l2.04-.41c2.85-.56 5.49-.97 8.4-.97v-2c4.75-.78 9.19-1.1 14-1"/><path fill="#38363e" d="M899 894h15v2l4-1c-.81 1.94-.81 1.94-2 4-2.75.92-4.36 1.1-7.19 1.06l-2.17-.02L905 900l1-4h-7z"/><path fill="#010105" d="m1234.85 821.9 2.21.04 2.23.02 1.71.04v3l-8 1-1 3h-8v-2l1.71-.84 2.23-1.1 2.21-1.09c3.85-2.02 3.85-2.02 4.7-2.07"/><path fill="#4c4c51" d="M1246 798c-2.91 2.91-6.34 3.73-10.19 5.06l-2.25.82A58 58 0 0 1 1220 807c2.32-1.76 2.91-2 6-2v-2l5.14-2.2q2.17-.94 4.3-1.99c3.54-1.12 6.87-.97 10.56-.81"/><path fill="#28272c" d="M1075 767c-3.25 3.11-6.5 3.7-10.78 4.47-2.22.53-2.22.53-4.16 1.51-2.5 1.23-4.82 1.7-7.56 2.2l-2.84.55c-2.57.26-4.23.08-6.66-.73a155 155 0 0 1 19.38-6.25l2.05-.53c3.6-.89 6.83-1.42 10.57-1.22"/><path fill="#696a6f" d="m59 755 1 2-5 1 .2 2.17c-.25 3.56-1.43 5.2-3.51 8.08l-1.87 2.64C48 773 48 773 45 774l-1 2-2-1a623 623 0 0 1 5.27-7.03L49 766h2l-.75-2.25C50 761 50 761 51.63 758.81A23 23 0 0 1 59 755"/><path fill="#747375" d="m1376 751 2 4 7-1c-3.32 3.94-3.32 3.94-6.75 4.25L1376 758v-2l-3.06.94A69 69 0 0 1 1362 759c3.93-4.34 8.52-6.34 14-8"/><path fill="#844ace" d="m428 734 1 4 7 1v3c-4.78.33-7.03-.42-11-3a79 79 0 0 0-6-2v-2a34 34 0 0 1 9-1"/><path fill="#31195c" d="M1080 663v3h-7l-.87 1.94C1071 670 1071 670 1069 671q-3 .06-6 0l1-3h2v-2c4.84-2.9 8.4-3.38 14-3"/><path fill="#7a757d" d="m1671 623 1 2-2 1 .34 2.14c-.5 4.19-2.83 5.9-5.84 8.67l-1.59 1.52q-1.95 1.86-3.91 3.67l-1-3c2.27-3.94 4.79-6.79 8-10l3-5z"/><path fill="#a78dca" d="M494 612h53c-3 3-3 3-5.62 3.25C539 615 539 615 538 614a61 61 0 0 0-4.15-.2l-2.67-.07-5.86-.1-15.73-.32L494 613z"/><path fill="#905fd0" d="M412 594q2.51.43 5 1l-1.96.58L413 597a38 38 0 0 0-.56 6.69c-.26 8-1.68 15.52-3.44 23.31h-1l-.06-5.87-.04-3.31c.1-2.82.1-2.82 1.1-4.82q.3-2.04.5-4.1l.25-2.43.5-5.08.25-2.43.22-2.23C411 595 411 595 412 594"/><path fill="#06070c" d="M1671 607h3q.12 2.69.19 5.38l.1 3.02c-.29 2.6-.29 2.6-1.8 3.95l-1.49.65-4-3c.38-2.94.38-2.94 1-6l2-1z"/><path fill="#020107" d="M1247 599c-5.92 5.88-5.92 5.88-10.44 6.38L1234 605l-2 4h-4c.19-1.81.19-1.81 1-4 3.83-3.04 7.28-3.54 12-4v-2c2.5-1.25 3.41-.78 6 0"/><path fill="#111016" d="M1431 496c2 1 2 1 2.61 2.81 1.56 8.82 2.08 21.3-2.61 29.19l-2 1c.88-6.75.88-6.75 2-9q.11-2.72.1-5.45l-.01-3.26-.03-3.42-.01-3.44z"/><path fill="#442972" d="M1003 515v11h-1l-.33-1.5-.48-1.94-.46-1.93-.73-1.63c-3.32-1.1-5.3-.95-8.75-.62l-3.22.3Q983 519.2 978 520c6.85-5.33 16.71-5.22 25-5"/><path fill="#030109" d="M1372 497c.7 1.71.7 1.71 1 4-1.3 2.29-1.3 2.29-3.19 4.63l-1.85 2.35c-1.95 2-3.36 3.05-5.96 4.02h-3c2.46-4.8 2.46-4.8 5.19-5.81l1.81-.19 1-4h2l.38-1.94.62-2.06z"/><path fill="#0a0a10" d="M1413 464c4.08 3.7 6.47 7.24 8.81 12.19l.95 1.9c2.24 4.61 2.24 4.61 2.24 6.91h2l1 8c-2.77-1.38-2.95-2.9-4.06-5.75l-1.04-2.6c-.87-2.55-1.44-5-1.9-7.65h-2c-3.27-4.2-4.76-7.85-6-13"/><path fill="#8f9091" d="M429 367h1l1 12h2a90 90 0 0 1-3 13h-1l-1-5-1 5h-1q-.08-1.84-.12-3.69l-.08-2.07c.2-2.24.2-2.24 1.17-4.62 1.2-3.04 1.44-5.67 1.65-8.93l.23-3.24z"/><path fill="#6a6a70" d="M1396 374h15l1 2c1.85.63 1.85.63 4.06 1.13l2.23.5 1.71.37v1q-4.03-.14-8.06-.31l-2.28-.07c-5.2-.25-8.98-1.3-13.66-3.62z"/><path fill="#656766" d="m380 262 2 1v19l-3-1c-1.09-6.68-2.06-12.68 1-19"/><path fill="#000001" d="m394 264 4 1-1 18h-3z"/><path fill="#0e0d10" d="M397 248h1v15h3l1-2-1 5a85 85 0 0 1-7-2l-1 4v-6l-3-1v-2l5 1z"/><path fill="#2c2c2f" d="M427 160v3h-2v8h-3v9l-3 1-1 6h-1v-8l2-1 .06-3.37.04-1.9C419 171 419 171 418 169l5-1c.13-4.75.13-4.75-1-7z"/><path fill="#8f9091" d="M623 170c2.38.19 2.38.19 5 1a18 18 0 0 1 2 5h2v2l5 1v2h-6l-1-3c-2.06-.69-2.06-.69-4-1v-2l-3-1z"/><path fill="#ebeaea" d="m1271 134 2 1c.59 2.31.74 4.62 1 7l-1.37.69c-2.2 1.77-2.73 3.68-3.63 6.31h-4v-7l5 1-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#e9e9ea" d="m1267 126 2 1c.59 2.31.74 4.62 1 7l-1.37.69c-2.2 1.77-2.73 3.68-3.63 6.31h-4v-7l5 1-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#8b8b8d" d="m973 125 2 1c-2.5 2.98-5.05 3.95-8.69 5.19l-3 1.04-2.31.77-1-2c-4.18.46-6.18.9-9 4h-3c1.26-2.9 2.1-4.65 5-6 3-.08 5.92 0 8.9.17 4.72.14 4.72.14 6.85-1.61L970 126z"/><path fill="#f5f7f6" d="M471 90c1.94.38 1.94.38 4 1l1 2c2.06.63 2.06.63 4 1-1.29 5.51-1.29 5.51-4.37 7.63C473 103 473 103 470 103c1.5-3.11 3.2-6.05 5-9h-5z"/><path d="M536 78h22v2c-4.62 1.18-9.08 1.11-13.81 1.06l-2.4-.01L536 81z"/><path fill="#59585b" d="m710 70 18 1v3h-19z"/><path fill="#e1e0e2" d="M1358 1793q-.98 2-2 4l-1.19 2.75c-1.81 2.25-1.81 2.25-3.56 2.57-1.94 0-1.94 0-5.25-.32l-1 3h-2l1-4h4l.19-1.75c1.57-4.37 5.15-6.94 9.81-6.25"/><path fill="#515158" d="m412 1724 10 4-2 1c3.23 5.81 6.68 9.05 12.51 12.07 1.49.93 1.49.93 2.49 2.93-8.62-1.2-13.97-8.87-19.34-15.18C414 1727 414 1727 412 1726z"/><path fill="#37373f" d="M440 1570h1l.11 2.9c.91 19.13.91 19.13 5.51 25.26 1.38 1.84 1.38 1.84 1.07 4.09L447 1604c-4.8-3.65-6.11-8.25-7-14-.34-6.66-.19-13.34 0-20"/><path fill="#1a1b23" d="m703 1488 2 1c-4.52 3.77-4.52 3.77-7.06 5-1.94 1-1.94 1-3.63 2.75A46 46 0 0 1 678 1507c3.33-4.18 6.2-7.48 11-10l4.32-2.88L695 1493l2.09-1.4 2.16-1.41 2.02-1.33z"/><path fill="#281b13" d="M1086 1329c8.94 2.09 17.6 4.2 26 8l3.05 1.36q3.98 1.8 7.95 3.64c-2 1-2 1-4.64.33l-3.17-1.08-3.15-1.05c-3.04-1.2-3.04-1.2-5.41-2.72-4.08-2.3-8.46-2.99-13.01-3.94l-2.1-.48-1.9-.4c-2.01-.82-2.6-1.78-3.62-3.66"/><path fill="#1f1f28" d="m927 1314 6.8 2.72q2.99 1.24 5.95 2.53l2.07.83c1.92.85 3.54 1.62 5.18 2.92.95 3.23.95 3.23 1 6l-2-1v-2l-2.23-.7c-5.92-1.98-9.7-3.6-13.77-8.3l-3-1z"/><path fill="#8e8e8c" d="m1300 1278 1 2c-.93 2.4-.93 2.4-2.31 5.25-2.96 6.13-4.9 11.38-5.1 18.24l-.06 1.8q-.08 2.79-.15 5.59l-.12 3.82q-.14 4.65-.26 9.3h-1c-1.23-36.02-1.23-36.02 6.67-44.87z"/><path fill="#bcb5b0" d="m1324 1244 2 1c-1.37 1.5-1.37 1.5-3 3h-2l-2 7 1.38-1.5c1.62-1.5 1.62-1.5 3.62-1.5l2-4 2 1c-2.36 5.3-5.91 6.84-11 9v-2l-6 4a43 43 0 0 1 5.38-8.56l1.77-2.32c1.8-2.06 3.6-3.57 5.85-5.12"/><path fill="#020105" d="m946 1235 2 1-1 2 3 1-10 4-2.37.97-2.13.84-1.9.77c-1.6.42-1.6.42-3.6-.58v-2l1.88-.31c2.12-.69 2.12-.69 3-2.22 1.12-1.47 1.12-1.47 3.07-1.76l2.17.1 2.2.08 1.68.11z"/><path fill="#d1c9be" d="M1351 1233v4l-8 1v3h-7l-1-3 4.81-1.94 2.71-1.09c3.01-1.18 5.22-1.97 8.48-1.97"/><path fill="#888791" d="m1316 1147 2 1v19c-9-1-9-1-11-3l5.37-.68c1.63-.32 1.63-.32 2.63-1.32a200 200 0 0 0 .7-9.72z"/><path fill="#a5afb8" d="M1609 1102c2.46 1 2.46 1 5 3 .8 2.63.74 5.15.69 7.88l.08 2.14c-.01 3.12-.08 5.17-2.15 7.6a42 42 0 0 1-4.62 3.38v-6h2c1.71-6.04 1.93-12.32-1-18"/><path fill="#1c1c1f" d="M1066 1035c7.1-.2 13.96.6 21 1.5l3.5.43 8.5 1.07v1c-10.72.13-21.31-.2-32-1z"/><path fill="#e4e3e2" d="M1182 1014h6v4c-4.86 3.31-8.1 4.48-14 4l1-3 6-1z"/><path fill="#8a8a8d" d="m555 969 1 2h21v1c-9.72 1.29-19.17 2.34-29 2l4-1v-3z"/><path fill="#9f9fa0" d="M664 958v2c-5.77 1.74-11 2.26-17 2.13l-2.35-.03-5.65-.1 1-2c4.1-1.16 8.33-1.3 12.56-1.56l2-.13c3.15-.2 6.28-.37 9.44-.31"/><path fill="#9b9c9d" d="m161 930 12 1v3h5l-3 1v3l-10-3v-2l-4-2z"/><path fill="#8c8d8f" d="M780 931v3l-2 1 1 3c-6.4 1.35-6.4 1.35-9.44-.44L768 936l7-2-13-1v-1c6.03-1.07 11.89-1.1 18-1"/><path fill="#b2bac1" d="M1469 909h1c.5 5.28-.94 7.93-4 12l-3.12 4.56-1.51 2.2A40 40 0 0 0 1458 935c-.8-2.3-1.17-3.54-.33-5.86l1.08-1.89a45 45 0 0 0 3.69-8.69c.7-2 .7-2 1.56-3.56 2.69-1 2.69-1 5-1z"/><path fill="#6b6a6d" d="M896 904q-5.1 1.73-10.24 3.42l-3.48 1.16c-6.52 2.21-12.32 3.97-19.28 3.42 5.86-4.08 12.04-4.48 19-5v-2q2.37-.55 4.75-1.06l2.67-.6c2.6-.34 4.1-.04 6.58.66"/><path fill="#a6b0b8" d="m1481 895 4 1v5h-3l-.25 1.75c-.98 2.93-2.39 4.3-4.75 6.25h-3c.48-4.29 1.66-6.27 5-9h2z"/><path fill="#737375" d="m1053 865-6 1v2q-3.75 1.27-7.5 2.5l-2.1.72c-5 1.62-9.17 2.02-14.4 1.78 4.18-3.16 9.5-4.18 14.44-5.69l3.18-1 5.86-1.81c2.6-.52 4.05-.37 6.52.5"/><path fill="#39383c" d="M545 833a3253 3253 0 0 1 19.43 1.46l3.16.24c2.41.3 2.41.3 3.41 1.3q2.02.34 4.06.56l2.23.26 1.71.18v1c-6.65.3-13.2-.21-19.81-.87l-2.5-.24c-4.21-.45-7.82-1.02-11.69-2.89z"/><path fill="#6b6c6f" d="M1234 805c-23.79 10.33-23.79 10.33-34 11 1-2 1-2 2.53-2.69l1.96-.6 2.14-.66 2.24-.67 2.23-.7c3.13-.96 5.6-1.68 8.9-1.68v-2q2.37-.8 4.75-1.56l2.67-.88c2.71-.59 3.98-.32 6.58.44"/><path fill="#9a9b9b" d="M162 770c1.63 1.69 1.63 1.69 3 4-.93 4.04-3.13 7.05-6 10-2.25.31-2.25.31-4 0 .55-3.33 1.21-6.12 3-9q1.5-1.01 3-2z"/><path fill="#a7a6aa" d="m104 711 1 4-1.93 1.46-2.5 1.91-2.5 1.9C96 722 96 722 95 724h-4l-1 3h-4c1.53-4.13 4.38-6.65 8-9 2.63-.62 2.63-.62 5-1 3.06-1.87 3.84-2.53 5-6"/><path fill="#4d2f84" d="m1007.6 686.8 3.02.07 3.04.06 2.34.07-1 3h-10l-2 4h-2v-3h-10v-1l2.55-.15 3.32-.23 3.31-.2c3.43-.51 4.01-2.36 7.42-2.62"/><path fill="#000001" d="M1543 666v3l-5 1v3c-3.7 1.98-6.8 3.38-11 4l1-3 3-1 .75-1.87c2.33-3.97 6.84-5.13 11.25-5.13"/><path fill="#07080e" d="m179 663 1 3h6l1 2c-1.4 2.79-3.16 2.93-6 4l-1 1q-3 .06-6 0v-3l5-1v-3h-5v-1h5z"/><path fill="#5a585d" d="m302 653 2 1c-1.46 2.92-3.99 3.46-6.87 4.63l-1.65.69c-2.68 1.1-4.55 1.68-7.48 1.68v2c-2.9 1.26-4.8 2-8 2v2l-10 3c1.2-2.41 1.97-2.66 4.35-3.8l2.08-1.02 2.2-1.05c4.12-2 8.2-4 12.15-6.31a25 25 0 0 1 7.1-3C301 654 301 654 302 653"/><path fill="#bea1e1" d="M405 595h1q.09 4.63.13 9.25l.05 2.64q0 1.27.02 2.56l.03 2.35c-.26 2.46-1 4.07-2.23 6.2-1.77-3.54-1.18-7.6-1.19-11.5l-.03-2.67-.01-2.55-.01-2.34C403 597 403 597 405 595"/><path fill="#a695c4" d="M758 584v3l-2.92.4-3.83.54-1.92.26c-3.16.45-6.25.94-9.33 1.8v-3l-1-2c6.36-.65 12.6-1.12 19-1"/><path fill="#27183e" d="M974 521c-3.47 2.3-6.13 3.41-10.25 3.94-5.41.79-10.68 1.91-16 3.18l-2.31.56L940 530v-2l2.27-.59 2.98-.78 2.95-.78c2.8-.85 2.8-.85 4.75-1.89 3.32-1.56 6.84-1.78 10.45-2.26 8.85-1.23 8.85-1.23 10.6-.7"/><path fill="#cacbcb" d="m1678 430 7 1v3h-8l1 5h-6l-1-4 2.38-.81c2.59-1.17 3.38-1.72 4.62-4.19"/><path fill="#7c7b80" d="M1457 378h7l1 2c2.37.64 4.66 1.17 7.06 1.63l2.01.4 4.93.97v1q-3.81.12-7.62.19l-2.17.07c-4.93.07-7.97-.71-12.21-3.26z"/><path fill="#5d5b5f" d="M828 161c-33.25 11.89-33.25 11.89-39 10 6.49-2.86 13-4 20-5v-2l2.77-.62 3.6-.82 1.83-.4c8.33-1.9 8.33-1.9 10.8-1.16"/><path fill="#666669" d="M901 140c-4.62 2.22-7.77 3.38-13 3v2c-5.26 2.63-9.15 3.41-15 3 2.8-2.12 5.94-3.27 9.19-4.5l1.75-.67c11.83-4.4 11.83-4.4 17.06-2.83"/><path fill="#12151a" d="M346 1578h3v19l-3 1z"/><path fill="#d2ccc5" d="m1328.5 1377.75 1.84.48c2 .93 2.51 1.93 3.66 3.77 2.13.69 2.13.69 4 1v3c-5.57-.45-8.6-1.52-13-5-1.17-1.63-1.17-1.63-2-3 2.4-1.2 2.99-.95 5.5-.25"/><path fill="#c0bbb9" d="m1309 1358 1 2 3 1 1-2h2l2 5c-2.31.69-2.31.69-5 1-1.81-1.44-1.81-1.44-3-3l1 3 2 1a39 39 0 0 1 1 5c-2 0-2 0-3.25-1.14-2.72-3.21-4.37-5.52-4.75-9.86h2z"/><path fill="#751001" d="M1414 1357q.57 3 1 6l-1 1q.43 3 1 6a91 91 0 0 1-4-4v-2l-3-1c-1.19-2.56-1.19-2.56-2-5 2.76-2.46 4.57-1.93 8-1"/><path fill="#6e0e01" d="M1403 1308h2v21l-2 1c-1.07-2.15-1.13-3.2-1.13-5.55v-6.51c0-3.46.03-6.62 1.13-9.94"/><path fill="#13161b" d="m346 1278 3 1v19h-3z"/><path fill="#e6480a" d="m1358 1271 3 1-1.17 1.62A70 70 0 0 0 1351 1291h-1c-1.34-6.22-1.34-6.22.13-9.56q2.78-3.65 5.78-7.1c1.09-1.34 1.09-1.34 2.09-3.34"/><path fill="#aab6c1" d="m1535 1191 2 1a39 39 0 0 1 1 5h3l-1 4h-2l-1-3-3-1v5l5 2c-.75 1.5-.75 1.5-2 3-2.12.19-2.12.19-4 0v-4l-3-1h3l-.12-2.69c.12-3.28.73-5.36 2.12-8.31"/><path fill="#9a9b9c" d="M134 994h5v4l-3 1-2 3-11-1v-3h12z"/><path fill="#b5b6b5" d="m1039.8 977.8 2.15.02 2.24.05 2.27.03 5.54.1-1 3c-9 0-9 0-11.23-.06-1.77.06-1.77.06-2.77 1.06a139 139 0 0 1-8.43.05L1024 982v-1l1.86-.15 2.45-.23 2.43-.2c2.26-.42 2.26-.42 3.66-1.43 1.96-1.21 3.11-1.22 5.4-1.19"/><path fill="#000001" d="M731 957v2c-4.84 1.88-9.21 2.4-14.37 2.63l-2.24.11q-2.7.14-5.39.26c4.68-5.85 15.03-5.32 22-5"/><path fill="#48474c" d="M826 918c-12.94 3.2-25.71 5.7-39 7 2.72-2.72 5.61-2.9 9.27-3.54l2.19-.4 4.57-.83q3.5-.62 6.99-1.28l4.44-.8 2.1-.4c3.42-.6 6.15-.98 9.44.25"/><path fill="#535459" d="M1463 842h3c.19 2.38.19 2.38 0 5-1.37 1.06-1.37 1.06-3 2-2.54 3.68-2.27 7.33-2.19 11.63v2.06c.05 5.03.05 5.03 1.19 7.31l-3-1q-.09-4.27-.12-8.56l-.06-2.43c-.04-5.07.42-9.23 2.18-14.01z"/><path fill="#514f55" d="M171 771v3l2 1-1 4h-2v2l3 1-1 3-3-1v-4l-5 2c.31-5.3 3.26-7.67 7-11"/><path fill="#7c4dc1" d="m553 759 8.2-.09c4.57-.02 4.57-.02 6.8 1.09l-1 3h-14z"/><path fill="#472d75" d="M751 754c-2.63 2.13-4.97 2.45-8.3 2.85l-3.15.4q-1.63.18-3.3.38l-3.25.4c-5.37.65-10.6 1.14-16 .97 7.8-6.28 24.33-5.22 34-5"/><path fill="#583591" d="M863 723c3.82.53 6.06 1.5 9 4-5.26 2.63-9.15 3.41-15 3l1-2-5-1v-1l1.93-.15 2.5-.23 2.5-.2L862 725z"/><path fill="#838181" d="M1459 707h7c-.19 1.81-.19 1.81-1 4-3.65 2.92-6.4 3.41-11 3l-2-1v-3h7z"/><path fill="#0a0517" d="m1316 549 2 1-5.69 5.31-1.6 1.52c-3.67 3.4-7.26 6.12-11.9 8.1-1.81 1.07-1.81 1.07-2.62 3.13-1.19 1.94-1.19 1.94-2.87 2.42q-2.64.34-5.32.52c1.65-3.14 3.97-4.28 7-6q2.24-1.47 4.46-2.96 1.53-1.04 3.08-2.04a77 77 0 0 0 13.46-11"/><path fill="#35215a" d="M1358 504v5h-2l-.69 2.25c-1.55 3.26-3.4 4.68-6.31 6.75l1-5h3v-2l-5 2c.96-3.47 2.07-5.32 5-7.31 3-1.69 3-1.69 5-1.69"/><path fill="#222227" d="m927 489-8.85 2.58C913.23 493 913.23 493 911 493v2l-7.56 1.44-2.14.4q-6.62 1.26-13.3 2.16c3.84-3.6 6.02-4.47 11.44-4.56 5.06-.25 8.12-.93 12.56-3.44 10.52-3.56 10.52-3.56 15-2"/><path fill="#323137" d="M893 414c3 1 3 1 4 3l-2.44 1.38C892 420 892 420 891 422c-5.27.2-5.27.2-7 0l-2-2v-2l1.9-.37 2.48-.5 2.46-.5C891 416 891 416 893 414"/><path fill="#0a0a0e" d="m1576 406 1 2c2.72.41 2.72.41 6.06.63l3.35.22 2.59.15v1h-12l1 3q-2.37.33-4.75.63l-2.67.35c-2.8.02-4.21-.53-6.58-1.98v-2l11-1z"/><path fill="#0b0b10" d="M1459 390c1.97-.34 1.97-.34 4.35-.3l2.58.04 2.7.07 2.71.04 6.66.15-1 3h-20z"/><path fill="#3b256d" d="M1283 376c2.93.23 4.5.46 6.52 2.67 1.74 2.73 2.72 5.38 3.73 8.45l1.08 3.2c.67 2.68.67 2.68-.33 4.68l-1.25-2.94-.83-1.9a158 158 0 0 1-1.87-4.6l-.99-2.44-.9-2.3c-1.16-1.82-1.16-1.82-3.29-2.58L1283 378l-1 3c-1.34 1.39-1.34 1.39-2.94 2.69l-1.59 1.32c-1.47.99-1.47.99-3.47.99v2l-2-1z"/><path fill="#f3f1f3" d="M1428 374h17l1 4c-6.82.33-11.92.24-18-3z"/><path fill="#828285" d="M928 138c1.95-.3 1.95-.3 4.13-.19l2.19.08 1.68.11v4l-6 1v3l-3-1-3.69-1.06L920 143v-1l2.88-.31L926 141l.94-1.53z"/><path fill="#141318" d="M959 112c-7.27 5.27-14.77 7.3-23.46 9.35C933 122 933 122 931 123c1-3 1-3 2.82-4.1a89 89 0 0 1 12.49-4.4c1.9-.56 3.6-1.35 5.38-2.19 2.78-1.25 4.4-1.59 7.31-.31"/><path fill="#8c8b8f" d="M721 78h8l1 2c3.49 2.35 7.17 2.43 11.25 2.63l1.97.11q2.4.15 4.78.26v1c-20.38.63-20.38.63-26-3z"/><path fill="#ebebec" d="M1182 15v3h-6l1 3 2 1 2 1 1 3c-3.9-.78-7.39-2.39-11-4 0-3 0-3 1.4-4.42l1.85-1.33 1.83-1.36c2.4-1.1 3.49-.77 5.92.11"/><path fill="#151518" d="m1148 14 1.21.97c2.78 1.6 5.59 1.43 8.73 1.59 2.06.44 2.06.44 3.14 1.96L1162 20l4.98.5c3.95.99 7.07 3.79 10.02 6.5l1 2q-2.2-.6-4.37-1.25l-2.47-.7c-2.62-1.27-3.15-2.37-4.16-5.05h-6l-2-4h-10l-1 2z"/><path fill="#7b7a82" d="M426 1665h1l.08 2.2.48 13.5c.71 19.42.71 19.42 2.44 28.3l-1-2h-2c-.8-5.44-1.14-10.73-1.1-16.22l.04-9.28.01-4.8z"/><path fill="#090a10" d="m805 1668.75 4.08.05q4.95.08 9.92.2v1l-2.34.06a1714 1714 0 0 0-12.33.36q-2.67.07-5.35.17l-3.25.1c-2.63.3-3.82.54-5.73 2.31l-1 2c-2.07.41-2.07.41-4.56.63l-2.5.22-1.94.15c5.19-8.84 16.11-7.48 25-7.25"/><path fill="#44434f" d="M494 1590c3 1 3 1 4 3q2.97 1.1 6 2l-1 3c-9.73-.03-9.73-.03-12.84-2.4-1.35-1.66-1.35-1.66-3.16-4.6l1.75.94c2.1.99 4 1.58 6.25 2.06z"/><path fill="#d33e0c" d="m1410 1369 3.42 3.44q1.71 1.68 3.47 3.31l1.92 1.81 1.83 1.7c1.63 2.08 1.65 3.16 1.36 5.74l-3 1v-4h-5c-1-3-1-3-.87-5.06l-.13-1.94-1.5-1-1.5-1c-.19-2.12-.19-2.12 0-4"/><path fill="#4e4c57" d="M433 1376h1l1 29h-1l-1 16h-1c-.11-15.04-.01-29.99 1-45"/><path fill="#4f0b05" d="m1462 1337 1 3-.87 1.69c-1.14 2.34-1.25 3.52-1.38 6.06-.76 7.26-6.67 12.46-11.75 17.25.96-2.87 1.64-4.68 3.94-6.7 2.75-3.07 3.35-5.97 4.25-9.92 1.75-7.26 1.75-7.26 2.81-9.38h2z"/><path fill="#000002" d="M1050 1240c2.21 4.43.62 10.91-.74 15.5a89 89 0 0 1-2.26 5.5h-1q-.08-3.6-.12-7.19l-.06-2.04c-.03-4.02.22-7.14 2.18-10.77z"/><path fill="#9e806a" d="m1021.77 1242.89 2.27.01 7.61.05 6.35.05v4q-4.19-.17-8.38-.38l-2.4-.09-2.3-.12-2.13-.1c-1.79-.31-1.79-.31-3.79-2.31 1-1 1-1 2.77-1.11"/><path fill="#17181c" d="M1482 1230c4.78 2.64 4.78 2.64 6 4.63 1 1.37 1 1.37 3.63 2.06l2.37.31v7l-4-1v-3l-5 1-1-2h2v-5h-4z"/><path fill="#a18d7b" d="M1051 1218c.31 1.69.31 1.69 0 4-1.52 1.89-3.19 3.37-5 5q-.8-.45-1.62-.94c-2.78-1.24-5.38-1.65-8.38-2.06v-2l10-1v-2c2-1 2-1 5-1"/><path fill="#36343e" d="m761 1117 2 1c-2.37 5.57-5.91 9.59-10 14l-1-2 4-6-3 3-3-1zm-11 15 2 1-2 1zm-2 2 2 1-2 1z"/><path fill="#292831" d="M893 1101c6.92 2.15 6.92 2.15 9 4v2l2.19.25c3.39.9 5.12 2.53 7.81 4.75a41 41 0 0 0 5 2v2c-5.56-1.6-9.55-3.2-14-7q-3.96-2.37-7.99-4.63L893 1103z"/><path fill="#f5f5f5" d="M300 1037q2.69.1 5.38.25l3.02.14 2.6.61 2 4h-14z"/><path fill="#d5d5d5" d="M1032 1015h2l1 2q2.65.32 5.3.46c1.7.54 1.7.54 2.92 2.58l.78 1.96c-6.7.37-11.9-.14-18-3v-1h6z"/><path fill="#a6a5a6" d="m1019 978 2 1a25 25 0 0 1-10.6 4.2c-.52.1-.52.1-3.18.53l-3.28.52-3.3.56c-8.83 1.44-8.83 1.44-12.64.19a64 64 0 0 1 22.87-4.73c3.1-.27 5.28-1.1 8.13-2.27"/><path fill="#e0e4e9" d="m1535 969 2 1c1.11 5.33 1.11 10.57 1 16l-4 1q-.05-4.21-.06-8.44l-.03-2.43v-2.31l-.02-2.15c.11-1.67.11-1.67 1.11-2.67"/><path fill="#9f9fa1" d="m137 919 3.38-.19 1.9-.1 1.72.29a94 94 0 0 1 2 3c1.73.73 1.73.73 3.63 1.19l3.37.81v3c-5.09-.53-10.6-1.22-15-4-.73-2.1-.73-2.1-1-4"/><path fill="#403f44" d="M1114 844c-3.93 1.6-7.76 3.02-11.94 3.81-5.57 1.12-10.83 3.2-16.12 5.25l-2.15.8-1.92.73c-2.25.5-3.7.11-5.87-.59 3.99-2.93 7.7-3.61 12.53-4.47a25 25 0 0 0 5.85-1.97c5.74-2.67 13.42-5.75 19.62-3.56"/><path fill="#9d9d9e" d="M138 822v20l-3 1v-20c2-1 2-1 3-1"/><path fill="#222224" d="M460 808c7.38.25 14.38 1.1 21.56 2.81q1.01.23 2.06.47c4.17 1.05 6.55 2.29 9.38 5.72a95 95 0 0 1-13.37-3.5c-4.32-1.51-8.63-2.29-13.15-2.97-2.45-.52-4.3-1.3-6.48-2.53"/><path fill="#010103" d="M1282 806v3l-1.93.62-2.5.82-2.5.8c-2.07.76-2.07.76-3.07 1.76-2.34.14-4.66.04-7 0l1-3 2.31-.31c2.69-.69 2.69-.69 4-2.25 2.8-2.38 6.17-1.67 9.69-1.44"/><path fill="#858687" d="m1257 803 2 1-1 1 6 1v1l-5.27.59c-1.73.41-1.73.41-3.73 2.41-2.6.41-2.6.41-5.62.63l-3.04.22-2.34.15c.72-1.93.72-1.93 2-4 2.1-.76 2.1-.76 4.5-1.12 4.31-.7 4.31-.7 6.5-2.88"/><path fill="#f9fafa" d="M15 790h3v5c2.7-1.61 5.4-3.22 8-5-.56 3.93-1.56 6.83-4 10-2.19.31-2.19.31-4 0l-.31-1.87c-.8-2.45-1.47-2.97-3.69-4.13z"/><path fill="#030306" d="M1398 754v3l-7 1-1 3-7 1c.94-3.47 1.66-4.77 4.69-6.81 3.48-1.53 6.55-1.42 10.31-1.19"/><path fill="#603a9d" d="m700.38 750.88 3.33-.01c3.18.13 6.17.54 9.29 1.13l-1 2h-22v-2c3.5-1.07 6.73-1.13 10.38-1.12"/><path fill="#a3a5a6" d="m76 735 1 4-1.81.69C73 741 73 741 71.66 743.29c-1.82 2.97-3.85 5.1-6.35 7.52q-1.2 1.18-2.45 2.4L61 755c0-4.59 2.2-7.1 5.2-10.46l2.36-2.35 2.38-2.4C73 738 73 738 75 738z"/><path fill="#cbcdce" d="M143 670c1.94.38 1.94.38 4 1l1 2c2.06.63 2.06.63 4 1-1.3 5.54-1.3 5.54-3.81 7.19-2.54.94-3.68.7-6.19-.19l1.94-1.81C146 677 146 677 147 674h-5z"/><path fill="#23143f" d="M1226 603c-3.14 3.96-6.38 4.98-11.07 6.29-1.93.71-1.93.71-2.93 2.71-1.67.96-1.67.96-3.75 1.94-2.5 1.19-4.77 2.39-7.12 3.87L1199 619l-2-1a22 22 0 0 1 7.06-5.19l2.1-1.04c1.84-.77 1.84-.77 3.84-.77v-2c1.93-1.83 2.99-2 5.69-2.12l2.31.12v-2c2.47-2.34 4.72-2.18 8-2"/><path fill="#9e78d2" d="M411 593c5.17-.21 9.08.4 14 2v2l2 1c-3.06-.43-5.76-.9-8.62-2.06-2.39-.94-3.86-1.02-6.38-.94l-.15 1.62q-.35 3.63-.73 7.25l-.23 2.56-.26 2.44-.22 2.25C410 613 410 613 408 615q.18-3.53.38-7.06l.09-2A51 51 0 0 1 411 593"/><path fill="#818083" d="m303 583 1 2 3 1 1 5h-6l1-5h-9v-3c3.31-1.1 5.66-1 9 0"/><path fill="#aca3c1" d="m773.88 573.81 1.85-.03c3.87-.02 6.78.54 10.27 2.22v1h-23c2.05-4.1 6.85-3.18 10.88-3.19"/><path fill="#99989c" d="M1727 526h3v11l-5 1c-1-1-1-1-1.1-2.63l.1-5.37h2z"/><path fill="#515053" d="m1734 519 3 1c1.4 5.75.67 11.16 0 17h-3z"/><path fill="#28184b" d="M1084 482c-.75 1.47-.75 1.47-2 3-2.07.4-4.02.44-6.12.5l-1.88.5-.82 1.9c-1.53 2.72-3.2 3.1-6.14 3.92l-1.91.5-1.9.54c-3.38.92-5.91 1.43-9.23.14 6.05-2.67 11.39-4.51 18-5v-3q2.43-1.04 4.88-2.06l2.74-1.16c2.38-.78 2.38-.78 4.38.22"/><path fill="#271847" d="m1106.38 472.31 1.62.69-5 1v2a23 23 0 0 1-9 4l-1 1q-2.02.1-4.06.06l-2.23-.02-1.71-.04 2.44-1.94C1090 477 1090 477 1091 476l3.19-.37c2.7-.34 4.63-.93 7-2.25 2.81-1.38 2.81-1.38 5.18-1.07"/><path fill="#0d0c11" d="M1513 435c11.86.45 23.37 2.75 35 5v1a320 320 0 0 1-31-3v-2z"/><path fill="#88888a" d="M433 370h1l.13 7.88.05 2.26q0 1.06.02 2.16l.03 2C434 386 434 386 432 388q.39 3.01 1 6 .32 3.43.56 6.88l.13 1.8.31 4.32-2 1-1-10h-1q-.08-2.62-.12-5.25l-.08-2.95c.2-2.92.92-4.24 2.2-6.8.3-2.28.5-4.47.63-6.75l.11-1.82q.15-2.21.26-4.43"/><path fill="#000001" d="M419 392q-.13 4.41-.31 8.81l-.07 2.53-.1 2.43-.09 2.24c-.53 2.46-1.53 3.4-3.43 4.99q-.12-4.12-.19-8.25l-.07-2.36c-.08-5.8-.08-5.8 1.7-8.94C418 392 418 392 419 392"/><path fill="#030207" d="M955 362h3c.44 2.06.44 2.06 0 5-2.07 2.63-4.5 4.8-7 7l-1.47 1.37c-1.28 1.13-1.28 1.13-3.53 2.63l-3-1c2.57-3.25 4.97-6.08 8.25-8.62 2.04-1.62 2.68-2.19 3.63-4.7z"/><path fill="#0f0e14" d="M968 359c.1 5.37.1 5.37 0 7l-1 1q-.22 1.8-.32 3.6l-.12 2.18-.12 2.28-.13 2.3L966 383h-1v-20c-4 1-4 1-6 3v-5c3.14-1.4 5.55-2.26 9-2"/><path fill="#3e3c42" d="M480 293c1.25 2.5.78 3.41 0 6h2v18h-1l-2-7h-1q-.08-3.12-.12-6.25l-.06-1.78c-.04-3.53.3-5.96 2.18-8.97"/><path fill="#636366" d="M504 210c.75 1.56.75 1.56 1 4a46 46 0 0 1-2.9 5.16c-1.68 2.81-2.87 5.81-4.1 8.84l-1.99 4.77L493 240h-1c-.34-4.8.46-7 3-11l1.34-5.03c1.68-5 4.7-9.63 7.66-13.97"/><path fill="#88888a" d="M865 154v3l-11 1-1 3-4-1h2v-2h-5c5.86-4.17 12.06-4.28 19-4"/><path fill="#040307" d="M532 123h9l-1 3q-2.99 1.05-6 2l-1 2h-7v-4h6z"/><path fill="#1c1c1e" d="M801 86h5v3h5v2c-9.38.16-18.65-.34-28-1v-1l18-1z"/><path fill="#9fa0a2" d="m895 63 1 2 3 1-1 4-4-1 1-3h-12v-3c7.76-1.37 7.76-1.37 12 0"/><path fill="#adaeaf" d="m923 51 1 2 3 1-1 4-4-1 1-3h-12v-3c7.76-1.37 7.76-1.37 12 0"/><path fill="#08070c" d="M971 42h10l-1 4h-8v4h-7v-1h6v-3h-8v-1l5.37-.59L970 44z"/><path fill="#0a0a0e" d="m1001 26 1 3h6l-4 1 1 3 6 1 1 3h-8l-1-2c-2.5-.41-2.5-.41-5.56-.62l-3.07-.23L992 34v-1h8l-.06-2.94C1000 27 1000 27 1001 26"/><path fill="#b3b5b5" d="m971 31 1 2 3 1-1 4-4-1 1-3h-12v-3c7.76-1.37 7.76-1.37 12 0"/><path fill="#a4a5a8" d="M1039 10h11c-2 4-2 4-4 6-2.36.27-4.62.09-7 0-1-3-1-3 0-6"/><path fill="#babbbb" d="M434 1809h6v5l-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-7-1v-3h8z"/><path fill="#b4b4b5" d="M370 1748h5v6l-5-1v8h-3l-1-7 1.44-.75c1.98-1.59 2.15-2.8 2.56-5.25"/><path fill="#88868e" d="M424 1654h1v23h-3c-.2-7.93-.28-15.31 2-23"/><path fill="#54535b" d="M467 1630c4.62.44 7.47.75 11 4h-5v2l2 .75a156 156 0 0 1 11 5.25c-2 1-2 1-4.14.43-5.64-2.2-10.97-4.6-14.86-9.43z"/><path fill="#504e58" d="M436 1556h1l1 14h1v20c-4.72-4.72-3.05-15.6-3.07-22q0-6 .07-12"/><path fill="#1a1a23" d="m621 1545-6.42 4.27a335 335 0 0 0-4.97 3.4c-4.13 2.85-7.73 5.08-12.61 6.33 1-2 1-2 4-3l.81-1.37c1.51-2.07 3.32-2.75 5.6-3.81 3.05-1.57 5.54-3.94 8.15-6.13 2.05-.98 3.32-.3 5.44.31m-10 6 2 3-3-1z"/><path fill="#ac3b11" d="M1418 1387c-4.33 4.14-8.7 5.34-14.44 6.75l-2.52.67c-4.47 1.13-8.44 1.85-13.04 1.58 2.35-3.27 4.91-3.57 8.66-4.18l2.15-.32c6.45-.96 12.93-5.05 19.19-4.5"/><path fill="#ca7a50" d="m1349 1366 3.69 3.7a86 86 0 0 0 3.12 2.92c1.19 1.38 1.19 1.38 1.19 4.38h2c1.62 1.6 1.62 1.6 3.44 3.75l1.8 2.1q2.46 3.02 4.76 6.15a22 22 0 0 1-7.13-4.9l-1.7-1.7-1.73-1.78-1.75-1.74c-7.59-7.67-7.59-7.67-7.88-11.13z"/><path fill="#826248" d="m1164 1275 6.75-.06 1.92-.03c3.27-.01 6.2.09 9.33 1.09v2l2.67.15 7 .44c3.13.39 5.48 1.1 8.33 2.41-3.03.95-4.94 1-8 .2-5.64-1.38-11.34-2.35-17.06-3.33l-3.2-.55-7.74-1.32z"/><path fill="#886b55" d="m965 1237 2 1-1 2 1.74-.05c21.06-.5 21.06-.5 31.26 3.05v1c-4.36.16-8.19.05-12.44-.94-7.4-1.64-15-1.73-22.56-2.06z"/><path fill="#04050a" d="M1479 1230h3v4h4v5l-6 1-1-3-3-1v-3h2z"/><path fill="#151110" d="m976.25 1231.38 1.75.62c-5.1 2.49-9.76 3.56-15.35 4.46-2.43.5-4.36 1.17-6.59 2.23-3.06 1.31-3.06 1.31-5.5 1L949 1239l2.88-.75c3.12-1.25 3.12-1.25 4.32-3.33l.8-1.92 2.7.07 5.33.1c4.07.05 7.55-2.4 11.22-1.8"/><path fill="#24242d" d="m889 1221 2 1a54 54 0 0 1-8.5 4.5 63 63 0 0 0-8.5 4.5c-9.46 5-9.46 5-12 5v-2c6.6-4.98 14.36-8.02 22-11l2.73-1.2z"/><path fill="#010103" d="m1079 1206 1 3-1.43.48c-4.6 1.73-6.1 3.17-8.57 7.52-2.19.31-2.19.31-4 0-.23-2.3-.23-2.3 0-5 1.57-1.8 1.57-1.8 3.69-3.31l2.07-1.55c2.6-1.33 4.36-1.36 7.24-1.14"/><path fill="#494854" d="m565 1202 5.69-.06 3.2-.04c2.83.1 5.35.45 8.11 1.1l-1 2h-29v-1l13-1z"/><path fill="#95a1ac" d="m1573 1179 5 4c-1.19 1.5-1.19 1.5-3 3-2.69.19-2.69.19-5 0l-1 5h-5c.38-1.94.38-1.94 1-4l2-1 1.44-2.56 1.56-2.44h3z"/><path fill="#07080c" d="m1192 1019 1 3h7c-1.8 2.57-3.61 4.95-6 7-3.37-.06-3.37-.06-6-1v-2l4-1v-3l-5 1c1.26-2.51 2.5-2.87 5-4"/><path fill="#e2e3e4" d="M1446 930h3l-.56 2.44-.44 2.56 1 1 2-4 2 1c-1.37 2.5-1.37 2.5-3 5h-2l-.31 2.31c-.73 2.86-1.46 3.85-3.69 5.69l1-8h-2v-4h3z"/><path fill="#444448" d="M1457 871c1.94.38 1.94.38 4 1l1 2-1.94 1.31C1458 877 1458 877 1457 880q-.14 2.73-.13 5.47v16.15c.13 2.38.13 2.38 1.13 4.38l-3-1a1755 1755 0 0 1-.15-15.58q-.04-2.85-.05-5.7l-.03-3.47c.25-3.5.99-6 2.23-9.25"/><path fill="#464449" d="M154 857v6h2v2h2l1 3-3 3c-5.98-5.82-5.98-5.82-6.06-10.12L150 858c3-1 3-1 4-1"/><path fill="#07080d" d="M42 782h3c.58 4.96-1.14 7.16-4 11l-3-1h2v-3l-2 1-1 3h-3v-6l2.88-.25c3.12-.75 3.12-.75 4.5-2.81z"/><path fill="#000004" d="M739 762c-.69 1.48-.69 1.48-2 3-2.25.34-2.25.34-5 .3l-3-.04-3.12-.07-3.16-.04q-3.86-.06-7.72-.15c5.1-5.1 17.1-3.09 24-3"/><path fill="#6840aa" d="M658 755c6.27-.5 6.44-.45 12 4h-30l1-2h16z"/><path fill="#b688e4" d="M456 695h1v21l-3 1q-.08-3.81-.12-7.62l-.06-2.17c-.04-4.53.5-8 2.18-12.21"/><path fill="#8f63c5" d="M366 666h1l-.03 2.57a1717 1717 0 0 0-.12 13.59q-.04 2.96-.05 5.91l-.03 1.83c0 4.08.91 7.27 2.23 11.1l-1 2c-2.8-3.93-3.43-7.01-3.33-11.73v-1.9l.05-3.97q.04-3.04.05-6.07 0-1.94.03-3.87l.01-1.83A23 23 0 0 1 366 666"/><path fill="#643fa2" d="m671 606-2 2 21 1v1l-10.35.43-5.24.21-3.36.14-3.07.13c-3 .1-5.98.1-8.98.09v-3a27 27 0 0 1 12-2"/><path fill="#090516" d="m1286 571 2 1c-6.11 5.72-12.58 10.63-21 12 4.3-4.87 9.35-7.87 15-11l2.18-1.24z"/><path d="M1719 519h3v17h-4z"/><path fill="#cbcbcb" d="M1550 386q2.6-.08 5.19-.12l2.92-.08c3.02.2 5.16.93 7.89 2.2l-1 2h-17z"/><path fill="#010006" d="M1254 384v2l3 1a358 358 0 0 1-4.69 3.5l-2.63 1.97c-2.89 1.65-4.42 1.86-7.68 1.53v-3l1.94-.31 2.06-.69 1-3c4.33-3 4.33-3 7-3"/><path fill="#010107" d="M1322 374h2c3.9 4.94 3.9 4.94 4.25 8.88L1328 385l2-2c1.14 2.29 1.1 3.6 1.06 6.13l-.02 2.19-.04 1.68-2-1v-4l-3-1q-1.05-2.99-2-6l-2-1z"/><path d="M392 284h1v20h-3q-.09-4.44-.12-8.87l-.06-2.56q0-1.19-.02-2.44l-.03-2.25C390 286 390 286 392 284"/><path fill="#58585d" d="M593 172c7.53 3.51 12.75 9.72 18 16l1.55 1.84q1.73 2.07 3.45 4.16l-1 2c-3.96-3-6.5-5.65-9-10a134 134 0 0 0-5-5l-4.31-4.25-2.12-2.08C593 173 593 173 593 172"/><path fill="#909090" d="m489 166 1 2h3v2h-3l.25 1.75c-.35 3.16-1.94 4.15-4.25 6.25l-1.12 1.75L484 181h-2c-.46-4.58.79-6.68 3.5-10.31l1.97-2.68z"/><path fill="#89898b" d="M911 141v4l-1.8.15c-6.65.63-6.65.63-9.83 1.91-3.25 1.29-5.89 1.08-9.37.94.74-1.49.74-1.49 2-3 1.95-.3 1.95-.3 4.13-.19l2.19.08 1.68.11v-2l4.38-1 2.46-.56C909 141 909 141 911 141"/><path fill="#7f7f82" d="m991 115-2 4-1-2c-13.52 3.62-13.52 3.62-18.75 6.19-4.62 2.22-9.17 2.6-14.25 2.81 2.3-1.7 4.37-2.5 7.13-3.25a142 142 0 0 0 12.35-4.1q2.23-.82 4.45-1.67c4.23-1.55 7.56-2.3 12.07-1.98"/><path fill="#2b2c30" d="m1178 29 9 1 .19 2.81c.81 3.19.81 3.19 3.31 4.94L1193 39l1 2q-.74-.45-1.5-.94c-3.43-1.45-6.82-1.72-10.5-2.06l2-1v-3h-4z"/><path fill="#1e1d25" d="M1256 1571h1c.76 12.74.53 23.73-6 35-1.7 1.4-1.7 1.4-3 2l1.31-5.12.74-2.89c.95-2.99.95-2.99 2.48-5.7 2.23-4.27 2.3-8.95 2.66-13.66l.25-2.81q.3-3.41.56-6.82"/><path fill="#000001" d="M1467 1407h4l-1 4h-2l-.81 1.81c-1.25 2.3-2.07 3.67-4.19 5.19-3.19.19-3.19.19-6 0l2-4h4v-3l4-1z"/><path fill="#959698" d="M1476 1223c2.28-.23 2.28-.23 5 0 1.84 1.54 1.84 1.54 3.5 3.63l1.79 2.19 1.71 2.18q1.46 1.76 2.94 3.5l2.06 2.5-5-1-1-3q-1.98-1.05-4-2c-1.92-1.7-1.92-1.7-3.75-3.56l-1.86-1.88C1476 1224 1476 1224 1476 1223"/><path fill="#191c22" d="M348 1151h1q.09 5.06.13 10.13l.05 2.88c.03 5.12-.16 9.26-2.18 13.99h-1v-18h2z"/><path fill="#1d1d20" d="M1438 1114h1c1.17 3.99 2.26 7.9 3 12h-4l-2 10h-1v-15h3z"/><path fill="#716e7a" d="m1311 1083 3 1v20l-2 1c-.97-4.84-1.12-9.45-1.06-14.37z"/><path fill="#08090e" d="M330 1031h18v3h-18z"/><path fill="#4d4c50" d="M323 938q5.06-.04 10.13-.06l2.88-.03c12.54-.04 12.54-.04 16.99 3.09 2.04.41 2.04.41 4.19.63l3.81.37v1c-6.45.4-12.26-.37-18.51-1.86-6.45-1.47-12.9-1.85-19.49-2.14z"/><path fill="#07070b" d="M889 925c-1.81 1.81-4.61 1.68-7.06 2.06l-3.35.54-2.59.4 8 1v1h-10v-2l-2.44 1c-4.13 1.4-8.25 1.17-12.56 1v-1l1.76-.37 2.37-.5 2.32-.5c2.55-.63 2.55-.63 5.43-1.66a41 41 0 0 1 9.87-1.66l3.42-.26C887 924 887 924 889 925"/><path fill="#9a9a9c" d="m239 924 13 2-2 4h-10c-1-4.87-1-4.87-1-6"/><path fill="#b9b9b9" d="m1219.06 917.94 2.94.06-1 4h-7l-1 3h-9c1.41-2.82 3.09-2.97 6-4.06 7.9-2.98 7.9-2.98 9.06-3"/><path fill="#838384" d="M1233 815v3h-7l-1 3-10 2c2-4 2-4 3.88-5.01l2.12-.68 2.13-.7 1.87-.61c2.7-1.35 5-1.07 8-1"/><path fill="#6f7073" d="M1294 796a175 175 0 0 1-30 11c2.31-2.63 4.42-3.77 7.69-5l2.68-1.02 2.63-.98 6.25-2.34a77 77 0 0 0 4.5-1.91c2.6-.86 3.69-.5 6.25.25"/><path fill="#58585b" d="M1324 767a312 312 0 0 1-25.94 11.88l-3.21 1.3c-2.98.86-4 .68-6.85-.18l2.03-.78 15.45-5.96 2.84-1.1A63 63 0 0 0 1316 768c5.6-2.2 5.6-2.2 8-1"/><path fill="#1f1e25" d="M1395 726h5l-4 3 4 2c-4.84 2.9-8.4 3.38-14 3l-1-4 9-1z"/><path fill="#ad73ed" d="M369 660h1l1 10 2-3v12h-3l-1-4-2 3c-.23-6.28.6-11.88 2-18"/><path fill="#060608" d="M1549 659v2h4v4l-1.81-.62c-2.19-.38-2.19-.38-3.57.56-1.98 1.3-3.46 1.45-5.8 1.68l-2.18.23-1.64.15c2.77-3.8 5.85-8 11-8"/><path fill="#b8b9b9" d="m219 627 1 2c2.06.63 2.06.63 4 1l-1 5h-5l1-5h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#04020e" d="m1219.24 609.36 1.76.64-1.94 2.94-1.09 1.65C1217 616 1217 616 1216 617q-2.5.06-5 0v-2l-2.19 1.44A20 20 0 0 1 1201 619c1.37-2.73 3.23-2.98 5.96-4.07 2.94-1.34 5.51-3.17 8.15-5.04 1.89-.89 1.89-.89 4.13-.53"/><path fill="#b7b9b9" d="m247 611 1 2c2.06.63 2.06.63 4 1l-1 5h-5l1-5h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#9d9f9f" d="m263 603 1 2c2.06.63 2.06.63 4 1l-1 5h-5l1-5h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#735a95" d="M387 576h1c.24 5.17-.16 9.1-2 14q-.52 2-1 4a105 105 0 0 1-3 10c-1.06-2.75-1.05-3.87-.05-6.7 1.32-4.15 1.43-7.95 1.53-12.26.17-3.92.76-6.2 3.52-9.04"/><path fill="#2e2a34" d="m476 578 7.56-.06 2.14-.03c6.58-.03 12.15.63 18.3 3.09 2.33.44 4.64.72 7 1v1c-7.48.3-14.64-.85-22-2l-3.8-.58L476 579z"/><path fill="#686372" d="M440 572c5.8-.2 10.72.39 16.31 1.88l2.13.52c3.26.84 5.81 1.6 8.56 3.6-5.44.33-10.43-.3-15.75-1.31l-1.97-.36c-3.78-.76-6.34-1.68-9.28-4.33"/><path fill="#121218" d="M1408 561c0 4.93-3.68 7.6-7 11q-3.05 2.79-6.2 5.46a82 82 0 0 0-5.8 5.54l-2-1c13.87-15.27 13.87-15.27 21-21"/><path fill="#2a2438" d="M907 540q-5.25 1.78-10.5 3.5l-2.98 1.01c-5.75 1.88-10.47 2.9-16.52 2.49 3.6-3.6 9.05-4 13.88-5.19l3.33-.86 3.22-.8q1.45-.38 2.94-.74c2.62-.4 4.16-.27 6.63.59"/><path fill="#09060f" d="m917.25 534.88 2.14.05 1.61.07c-7.8 5.57-19.73 5.95-29 7l1-2h3l1-4 1 2q2.63-.17 5.25-.37l2.95-.22c4.09-.6 6.8-2.68 11.05-2.53"/><path fill="#1a1a20" d="M903 496v2c-4.27 2-8.25 2.53-12.87 3-4.1.42-7.96.88-11.89 2.13-3.06.82-4.36 1.02-7.24-.13a175 175 0 0 1 19.26-5c10.14-2.07 10.14-2.07 12.74-2"/><path fill="#3b2567" d="M1291 452c2.72 2.37 3.89 4.65 5.19 8l1.04 2.63c.77 2.37.77 2.37.77 5.37l3 1c.95 1.78.95 1.78 1.69 3.94l.76 2.15c.55 1.91.55 1.91.55 3.91h-2c-1.83-1.93-2-2.99-2.12-5.69l.12-2.31h-2c-2.26-2.54-3.03-5.81-4-9l-.8-2.61z"/><path fill="#1a0f31" d="M1342 423c4.66 3.6 6.27 7.79 8.18 13.19 1.1 3.08 2.23 5.74 4.2 8.37 1.62 2.44 1.62 2.44 1.37 4.81L1355 451l-1-3-3-3c-2.66-4.43-4.4-9.1-6-14q-.79-1.65-1.62-3.25C1342 425 1342 425 1342 423"/><path fill="#232225" d="M367 421h1v13h-2v6l-2-4-1.25-1.94c-.75-2.06-.75-2.06-.06-4.44L364 427l.8-1.65q1.08-2.18 2.2-4.35"/><path fill="#868387" d="m1570.85 397.9 6.15.1q.57 2.48 1 5c-1.73 1.73-3.91 1.12-6.21 1.13C1570 404 1570 404 1567 403c1.12-4.95 1.12-4.95 3.85-5.1"/><path fill="#8d8d8f" d="M429 392h1q.13 4.63.19 9.25l.07 2.64c.06 4.81.04 7.4-3.26 11.11-.51 2.58-.51 2.58-.69 5.25l-.2 2.7L426 425h-1q-.08-3.4-.12-6.81l-.06-1.95c-.03-3.55.25-5.36 2.18-8.24.41-1.8.41-1.8.63-3.8l.26-2.15.24-2.24.26-2.27z"/><path fill="#010106" d="M1310 347h2v5l2-2c1.76 4.98 3.33 9.75 4 15-1.86-.18-1.86-.18-4-1-2.87-5.2-4.64-11.05-4-17"/><path fill="#aaa7ab" d="m1351 318 2 1c.59 2.31.74 4.62 1 7l-1.44.75c-1.98 1.59-2.15 2.8-2.56 5.25l-5-1v-5l5 1-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#5e5e62" d="M491 240c.63 1.75.63 1.75 1 4l-2 4q-.82 2.82-1.5 5.69l-.77 3.18q-1.42 6.06-2.73 12.13h-1c-1.1-11.02 2.13-19.26 7-29"/><path fill="#a8a9aa" d="M400 227v7h-2v2h-2v2h-2v-11c3-1 3-1 6 0"/><path fill="#838285" d="M464 211h1v6h4l-2 5h-2l-1-2-.55 1.98c-1.43 4.96-2.95 9.5-5.45 14.02-.69-1.62-.69-1.62-1-4q1-2.03 2.1-4.02c1.63-3.59 2.36-7.45 3.25-11.27C463 214 463 214 464 211"/><path fill="#8b8a8d" d="M583.7 157.8q2.64.03 5.3.2l-1 4h6v5c-6.78-.9-6.78-.9-9-3v-2l-5-1c.8-2.12 1.4-2.93 3.7-3.2"/><path fill="#020305" d="M438 144h4v6l2 1-3 2v-3h-3l-1 5h-3c-.19-2.87-.19-2.87 0-6 1.44-1.19 1.44-1.19 3-2z"/><path fill="#3f3f43" d="m953.3 140.32 1.7.68-3 1-.92 1.96C950 146 950 146 948.05 146.94l-2.3.62a36 36 0 0 0-8.06 3.19c-2.69 1.25-2.69 1.25-5.07.94L931 151a20 20 0 0 1 8.16-4.26c3.41-1.37 6.41-3.56 9.47-5.6 2.37-1.14 2.37-1.14 4.67-.82"/><path fill="#2b2a2f" d="m963 138 1 3-2.37.88C959 143 959 143 957 145h-3v2c-2.04 2.04-3.24 2-6.19 2.13L945 149c1.54-2.82 3.17-3.63 6-5l1-2a83 83 0 0 1 5.56-2.12l3.07-1.08z"/><path fill="#c5c4c6" d="m1263 118 2 1c.59 2.31.74 4.62 1 7l-1.44.75c-1.98 1.59-2.15 2.8-2.56 5.25l-5-1v-5l5 1-.04-1.71-.06-4.44c.1-1.85.1-1.85 1.1-2.85"/><path fill="#39373e" d="m1060.63 102.88 2.37.12c-3.32 4.33-6.91 8.71-12 11-2.21-.37-2.21-.37-4-1v-2l3-1 1-2q2.49-1.53 5-3c2-2 2-2 4.63-2.12"/><path fill="#34333c" d="M1259 1752c2.06.44 2.06.44 4 1-2.34 2.7-4.42 3.73-7.78 4.86l-2.8.94-2.92.95-2.92 1-2.8.92-2.56.85c-2.22.48-2.22.48-5.22-.52 6.7-3.2 13.27-6.31 20.4-8.46 1.6-.54 1.6-.54 2.6-1.54"/><path fill="#54555c" d="M1306 1701c1 3 1 3 .44 4.62l-.95 1.74-1.05 1.94-1.13 2.01-1.07 1.98c-6.1 11.14-6.1 11.14-9.24 12.71 2.58-9.38 7.55-17.04 13-25"/><path fill="#c8cac9" d="M343 1697h3v16h-3a55 55 0 0 1-1-15z"/><path fill="#23232b" d="M448 1604c1.75.13 1.75.13 4 1a68 68 0 0 1 4 6c3 4.69 6.3 8.84 10 13-4.06-1.63-6.72-4.39-9.75-7.44l-1.54-1.48A74 74 0 0 1 451 1611v-3h-2z"/><path fill="#44444d" d="M1312 1488c6.75.75 6.75.75 9 3 .24 1.72.24 1.72.23 3.8v2.37l-.03 2.56-.01 2.61q-.02 4.14-.07 8.29l-.02 5.6q-.03 6.9-.1 13.77h-1v-38l-8-2z"/><path fill="#462d1a" d="M1208 1367a391 391 0 0 1 15.94 2.56l2.36.42 5.7 1.02 1 3-1.98-.44a95 95 0 0 0-6.97-1.02l-2.13-.24-4.39-.46c-1.85-.21-3.7-.52-5.53-.84l-1-2c-1.56-1.12-1.56-1.12-3-2"/><path fill="#96959a" d="M1518 1342v11l-5 1c-1-1-1-1-1.1-2.63l.1-5.37h2v-2c2.56-2 2.56-2 4-2"/><path fill="#5e5e62" d="m1522 1337 4 1q.08 2.6.13 5.19l.07 2.92c-.2 3.02-.93 5.16-2.2 7.89l-2-1z"/><path fill="#18191d" d="M1458 1209q.86.45 1.75.94a13 13 0 0 0 4.44 1.5l1.81.56 1 3a34 34 0 0 0 3 3h-5l-1-4h-5l-1 4-4-1v-3l5-1z"/><path fill="#9ca9b3" d="M1578 1149c2.6 7.49-2.2 16.98-4.84 24.04-1.4 2.36-2.64 2.98-5.16 3.96l.91-1.75a48 48 0 0 0 2.46-6.19l.77-2.34q2.04-6.48 4-13c.86-2.72.86-2.72 1.86-4.72"/><path fill="#bebfbf" d="M223 1030h16l-1 4q-2.6.08-5.19.13l-2.92.07c-3.02-.2-5.16-.93-7.89-2.2z"/><path fill="#cdcccc" d="M207 1026h16l-1 4q-2.6.08-5.19.13l-2.92.07c-3.02-.2-5.16-.93-7.89-2.2z"/><path fill="#c0bfbf" d="M1020 982c2.13.38 2.13.38 4 1-.97 1.46-.97 1.46-3 3-2.8.49-5.6.51-8.44.56-9.2.21-9.2.21-12.56 2.44l2-4-2 1q-2.06.1-4.12.06l-2.2-.02L992 986v-1l13-2v2q3.19-.17 6.38-.37l1.82-.1c4.57-.3 4.57-.3 6.8-2.53"/><path fill="#1e1d21" d="M1410 970c1.13 3.75 1.13 3.75 0 6h-2a388 388 0 0 0-3 31h-1c-.28-11.5.52-22.6 2-34l4-1z"/><path fill="#bab9ba" d="M1165 967v2a112 112 0 0 1-21.13 4.35c-4.78.56-4.78.56-5.87 1.65-2.34.14-4.66.04-7 0 5.35-3 10.53-4.03 16.56-4.44 3.3-.24 5.86-.5 8.75-2.12 3.13-1.67 5.2-1.69 8.69-1.44"/><path fill="#878888" d="m640.01 962.9 2.3.01 2.38.03 2.41.01 5.9.05v1c-10.13 2.04-19.65 3.49-30 3v-1l2.08-.4c.45-.1.45-.1 2.73-.54l2.71-.52c7.63-1.65 7.63-1.65 9.5-1.64"/><path fill="#27262a" d="M1111 950c-7.24 5.21-18.22 7-27 7v-2l1.98-.33c7.45-1.35 7.45-1.35 10.96-3.3 4.6-2.06 9.1-1.6 14.06-1.37"/><path fill="#757477" d="M764 946a526 526 0 0 1-11 3l-2.32.6c-8.42 1.95-17.08 2.05-25.68 2.4v-1a78 78 0 0 1 15-1v-2l9.75-1.56 2.79-.46 2.7-.42 2.47-.4c2.35-.16 4.05.14 6.29.84"/><path fill="#323038" d="M910 895v3h-5v2l-10 1v-2h-6v-1l1.54-.15c3.37-.36 6.3-.8 9.52-1.91 3.54-1.13 6.25-1.09 9.94-.94"/><path fill="#020206" d="M87 892c10.49 6.72 10.49 6.72 13 10 .25 2.25.25 2.25 0 4-5.48-2.44-9.6-6.08-13-11z"/><path fill="#323136" d="m984.56 874.94 2.44.06-1 3 9-1c-3.4 3.4-8.39 4.77-13.15 5.2q-2.43-.03-4.85-.2l2-1q1.05-2.48 2-5c1-1 1-1 3.56-1.06"/><path fill="#555659" d="M74 841h1l.04 1.59c.69 22.01.69 22.01 6.21 31.41l1.75 3-1 2c-2.73-1.37-3.2-2.98-4.56-5.69l-1.32-2.57c-2.6-6.36-2.24-12.86-2.18-19.62l.01-2.95z"/><path fill="#333236" d="M509 828c10.5-.65 20.68 1.29 31 3v1c-10.58.57-20.62-1.16-31-3z"/><path fill="#909092" d="M155 790c0 3.64-.86 5.1-3 8-2.19.81-2.19.81-4 1l-.25 3.31c-.4 2.94-.55 3.5-2.81 5.63L143 809l1-1.75c1.03-2.32 1.18-3.75 1-6.25h-2l-1 3v-5l4-1v-5h1v5l2.31-2.31z"/><path fill="#88888a" d="m1292.06 790.94 2.94.06v2c-5.81 3.28-10.25 5.73-17 5l1-3c2.03-.91 2.03-.91 4.5-1.62 8.26-2.44 8.26-2.44 8.56-2.44"/><path fill="#5c5a5f" d="M163 782c0 3.92-1.82 5.94-4 9-2.9 4.21-5.51 8.53-8 13h-2a42.7 42.7 0 0 1 13-21z"/><path fill="#5f5d60" d="m1375 758 2 1c-16.25 11-16.25 11-24 11a22 22 0 0 1 5.63-4.56C1361 764 1361 764 1362 762q2.55-.36 5.09-.64c2-.38 3.43-1.01 5.22-1.99z"/><path fill="#0c0b10" d="M1023 755c-2.65 1.46-3.9 2-7 2l-1 3a50 50 0 0 1-6.37 1.69l-1.8.39c-2.67.57-5.09.92-7.83.92v-2l6.38-2 1.82-.58c4.57-1.42 4.57-1.42 6.8-1.42v-2c5.63-1.12 5.63-1.12 9 0"/><path fill="#613999" d="M836 731h15l1 2a49 49 0 0 1-17 2z"/><path fill="#4a484e" d="m226 722-1 3-2 1-1 3-1.75.38c-2.7.75-3.93 2.13-5.81 4.14-2.63 2.7-5.56 5.06-8.44 7.48a21 21 0 0 1 5.38-7.44c2.09-2.02 4.05-4 5.8-6.31 2.7-3.35 4.43-5.25 8.82-5.25"/><path fill="#000003" d="M258 670v4h-5l-2 4h-6l-1-3 7-1v-3c2.67-.9 4.26-1.1 7-1"/><path fill="#242126" d="m1643 650 3 1-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l-1.83.45c-7.34 2.06-7.34 2.06-9.73 5.74L1629 667l-3-1q2.98-3.52 6-7l2.06-2.44c1.94-1.56 1.94-1.56 4.69-1.81l2.25.25.44-1.94c.56-2.06.56-2.06 1.56-3.06"/><path fill="#252329" d="M308 651c-4.92 2.86-9.55 5.35-15 7l-2.06 1.13c-2.41 1.08-4.32 1.02-6.94.87v-2l2.27-.77 2.98-1.04 2.95-1.02c2.67-1.12 4.51-2.44 6.8-4.17 3.1-.91 6.03-1.48 9 0"/><path fill="#141218" d="m1341 615 2 1-2.19 1.69c-1.95 1.54-3.43 3-5.12 4.87-2.59 2.69-5.4 3.67-8.92 4.76-1.77.68-1.77.68-3.77 2.68-2.3.84-4.63 1.38-7 2 3.72-4 7.51-7.28 12.81-8.81 4.76-1.78 8.31-4.97 12.19-8.19"/><path fill="#a886d4" d="m514 615 14.94-.09h3.22l2.96-.02c3.03.12 5.9.55 8.88 1.11v2l-6 2v-3l-10.7-.09h-2.29l-2.1-.02C521 617 521 617 518 618c-2.19-.44-2.19-.44-4-1z"/><path fill="#080314" d="M379 594c3.7 7.4-2.97 23.34-5 31h-1l1-18h2l.4-2.59c.1-.55.1-.55.54-3.35l.52-3.34C378 595 378 595 379 594"/><path fill="#9c8abf" d="m773 580 3 1c-2.32 1.76-2.91 2-6 2v2c-6.11 1.86-11.59 3.43-18 3v-1h6v-3c4.02-1.94 7.72-2.4 12.1-2.75C772 581 772 581 773 580"/><path fill="#737477" d="m311 579 1 2 3 1 1 5h-6l1-5h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#0f0e17" d="M759 570v1a165 165 0 0 1-32 4v-1c10.82-2.42 20.86-4.2 32-4"/><path fill="#24242a" d="M837 511c-5.79 4.32-13.88 5.24-20.81 6.53-2.19.47-2.19.47-5 1.6-2.53 1-4.5 1.02-7.19.87l1.94-.31L808 519l1-3 2.3-.37c8.27-1.34 17.5-4.85 25.7-4.63"/><path fill="#616162" d="M1578 394q2.6-.08 5.19-.12l2.92-.08c3.02.2 5.16.93 7.89 2.2l-1 2h-16z"/><path fill="#7e7f80" d="m388 226 2 1v16l-4-1q-.08-2.6-.12-5.19l-.08-2.92c.2-3.02.93-5.16 2.2-7.89"/><path fill="#9a999b" d="M687 74q1.94-.12 3.88-.19l2.17-.1c1.95.29 1.95.29 3.02 1.74L697 77c3.01 1 5.04 1.1 8.19 1.06l2.73-.02L710 78v2c-16.8.64-16.8.64-22-3z"/><path fill="#ceced0" d="m864.75 69.94 2.98.02L870 70l1 4h-16v-3c3.31-1 6.3-1.1 9.75-1.06"/><path fill="#5a5c5f" d="m528.75 65.94 2.98.02L534 66l1 4h-16v-3c3.31-1 6.3-1.1 9.75-1.06"/><path fill="#040405" d="m1039 18 2 1-2 6-11 1v-4l1.9-.37 2.47-.5 2.47-.5C1037 20 1037 20 1039 18"/><path fill="#f6f6f7" d="m1286.19 1824.94 2.17.02 1.64.04-1 5h-12c2.48-4.97 4.04-5.15 9.19-5.06"/><path fill="#9d9ca3" d="M1282 1727h5c-.58 3.54-1.84 5.25-4.37 7.75l-1.84 1.86C1279 1738 1279 1738 1276 1738c1.48-5.66 1.48-5.66 4.13-7.37l1.87-.63z"/><path fill="#0b0809" d="M1257 1295h1l.08 3.07.42 16.18.18 7.02.05 2.2c.1 3.1.29 5.58 1.27 8.53q.1 2.1.06 4.19l-.02 2.17-.04 1.64c-2.56-3.42-3.13-6.73-3.11-10.94v-2.3l.01-2.46.04-10.55.01-5.44z"/><path fill="#070a10" d="m1508 1296 2 1v7h-1v25h1v5l-3-1-.08-20.54-.02-7.49-.01-2.37q.01-2.8.11-5.6z"/><path fill="#cfc5b5" d="M1388 1238v2h-4v2c-4.1 1.98-7.34 3.42-12 3 1.26-3.78 2.58-4.38 5.95-6.21 3.34-1.28 6.53-1.12 10.05-.79"/><path fill="#000001" d="M999 1219h6c-7 7-7 7-9.82 7.2l-3.3-.08-3.33-.05-2.55-.07c1-2 1-2 3.19-3.06 2.81-.94 2.81-.94 6.06-1.44l2.75-.5z"/><path fill="#100d0c" d="m1096.94 1205.88 2.65.02 6.41.1v1l-10.5 1.5-2.98.43A97 97 0 0 1 1076 1210c6.86-3.84 13.25-4.28 20.94-4.12"/><path fill="#dee5e8" d="M1531 1164h3v17l-3-1c-1.3-5.48-.71-10.45 0-16"/><path fill="#d3d9df" d="m1604.13 1148.81 1.87.19v7l-4 1-.31 1.94-.69 2.06-3 1a527 527 0 0 1 1.37-7.52c1.37-5.37 1.37-5.37 4.76-5.67"/><path fill="#e0dfe2" d="M1450 1104h3q.33 3.09.63 6.19l.35 3.48c.02 3.36-.5 5.34-1.98 8.33l-2-1z"/><path fill="#191d21" d="M349 1068h1c.31 30.43.31 30.43-2.54 33.84L346 1103l-.06-4.44-.04-2.5c.1-2.06.1-2.06 1.1-3.06q.34-2.77.54-5.57l.8-10.6z"/><path fill="#bac3c9" d="M1462 1046a19 19 0 0 1 7.31 5.38l1.62 1.77c1.07 1.85 1.07 1.85.74 4.08l-.67 1.77c-7.02-4.37-7.02-4.37-9-8-.19-2.75-.19-2.75 0-5"/><path fill="#151619" d="m1170 1031-4.31 1.5-2.43.84c-2.26.66-2.26.66-5.26.66v3h7v1h-7l-1 3h-2v-3l-4-1c2.64-3.4 6.08-4.18 10.06-5.25l1.93-.56c4.75-1.32 4.75-1.32 7.01-.19"/><path fill="#54525d" d="M1316 974h1c1.4 6.12 2.23 11.72 2 18h-1v-5h-3v5l-2-1q.45-3.98.94-7.94l.26-2.28.26-2.18.24-2.02c.3-1.58.3-1.58 1.3-2.58"/><path fill="#131316" d="M92 971h4l1 2h11v5h8v4l4 1h-6v-4l-1.68.1-2.2.09-2.17.1L106 979l-.97-1.47C104 976 104 976 101.83 975.5l-2.45-.18-2.48-.2L95 975l-1-3z"/><path fill="#020305" d="M1239 960h2v9h-3l-1 7h-3c-.33-4.76.38-7.07 3-11q1.06-2.48 2-5"/><path fill="#424047" d="M761 930q-4.5 1.01-9 2l-2.54.58A60 60 0 0 1 734 934c2.81-3.4 5.47-3.73 9.75-4.19q2.9-.25 5.81-.43l2-.16c3.4-.23 6.18-.14 9.44.78"/><path fill="#0d0c11" d="M1195 921c-2.9 2.27-6.31 3.3-9.75 4.5l-1.8.64c-4.11 1.45-8.25 2.7-12.45 3.86l1-3c2.03-.66 2.03-.66 4.5-1.06s2.47-.41 4.5-.94l1-2h-6v-1c6.37-.96 12.56-1.1 19-1"/><path fill="#1d1c20" d="M973 902h-3l-1 3-2.3.33c-4.98.77-9.72 1.55-14.45 3.3-4.24 1.54-7.76 1.62-12.25 1.37v-1l2.28-.48c7.27-1.6 14.3-3.54 21.34-5.98 5.94-1.98 5.94-1.98 9.38-.54"/><path fill="#b6b9b7" d="M0 873c4 4 4.04 6.87 4.06 12.31L4 889H0z"/><path fill="#626367" d="M1087 868c-5.03 2.35-10 3.85-15.36 5.24a38 38 0 0 0-6.33 2.45c-3.72 1.73-7.27 1.6-11.31 1.31 2.31-1.72 4.42-2.54 7.19-3.31q4.97-1.41 9.88-3.03l2.15-.7 4.25-1.42c6.1-1.98 6.1-1.98 9.53-.54"/><path fill="#f1f3f5" d="m1506 830 2.31 1.94C1511 834 1511 834 1514 835v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.06-.62 2.06-1 4-3 0-3 0-4.39-1.17-3.54-4.3-3.54-4.3-3.92-7.2z"/><path fill="#0e0e12" d="M1232 824c-5.75 3-5.75 3-8 3v2l2 1-6 2v-2l-2.05.47-2.7.6-2.67.59a13 13 0 0 1-6.58-.66c4.43-2.56 8.44-3.57 13.46-4.46 3.15-.67 6-1.78 8.95-3.05 1.59-.49 1.59-.49 3.59.51"/><path fill="#636366" d="m1259.56 806.94 2.44.06v2q-4.62 2.05-9.25 4.06-1.3.59-2.64 1.18l-2.56 1.1-2.35 1.04c-2.49.7-3.8.47-6.2-.38l1.5-.33 1.94-.48 1.93-.46 1.63-.73 1-3c2.03-.91 2.03-.91 4.5-1.62 2.92-.87 5.16-2.37 8.06-2.44"/><path fill="#000002" d="M1301 798v3c-5.34 1.92-10.35 3.4-16 4 1.14-3.43 1.55-3.64 4.63-5.19 4.02-1.9 6.97-2.18 11.37-1.81"/><path fill="#f4f5f5" d="M27 774h3v5c2.7-1.61 5.4-3.22 8-5l-1 4h-2v5l-5 1-.31-1.87c-.8-2.45-1.47-2.97-3.69-4.13z"/><path fill="#6541a2" d="m732.9 750.9 2.14.01 2.21.03 2.26.01 5.49.05-1 3h-19v-2c2.7-.76 5.09-1.12 7.9-1.1"/><path fill="#ad88e2" d="M470 743v1l-5 1 1.68.11 2.2.2 2.17.18c2.45.64 2.6 1.48 3.95 3.51 1.95.51 1.95.51 4.13.69l2.19.2 1.68.11v1q-2.62.12-5.25.19l-2.95.1c-3.26-.34-4.29-1.3-6.8-3.29-2.58-.73-2.58-.73-5.25-1.19l-2.7-.48L458 746v-3a26 26 0 0 1 12 0"/><path fill="#919193" d="m181 723 2 1c-6.29 4.43-6.29 4.43-10 5l1 7h3v2l-7-2v-2l-6 2 3.7-3.7q1.44-1.44 2.8-2.92c1.99-1.83 4.15-3.06 6.5-4.38l2.19-1.25z"/><path fill="#9f9da2" d="m140 683 1 4-5.25 4.38-1.49 1.25c-5.25 4.34-5.25 4.34-8.26 5.37-2.25-.37-2.25-.37-4-1 6.14-6.94 6.14-6.94 9.63-7.19l2.37.19c3.29-2.17 4.74-3.23 6-7"/><path fill="#010105" d="m1514 686-1 4h-3l-1 3c-3.21 1.89-5.25 3-9 3 1.28-2.86 2.53-5.02 5-7h3v-2c2.08-.55 3.84-1 6-1"/><path fill="#1b1a20" d="m1497 678-2.5 1.31-2.5 1.69v3c-1.84 2.66-2.97 3-6.19 3.69l-2.81.31 1-2h4v-3c-2.92 1.07-4.78 1.78-7 4-3.12.13-3.12.13-6 0 3.75-2 3.75-2 6-2v-2l1.93-.59 2.5-.78 2.5-.78C1490 680 1490 680 1491 678c3-1 3-1 6 0"/><path fill="#0c071b" d="m1087 666 2 1-4 1v2l-1.94.52c-4.2 1.17-8 2.27-11.68 4.67-3.72 2.33-7.1 2.2-11.38 1.81 2.79-2.12 5.94-3.23 9.19-4.44l1.75-.66 7.59-2.86q4.2-1.6 8.47-3.04"/><path fill="#525155" d="M307 663c-3.02 3.68-6.2 5.08-10.48 6.85-3.7 1.69-7.04 3.87-10.45 6.08L284 677l-2-1a36 36 0 0 1 7.94-5.44 27 27 0 0 0 6.12-4.12c3.55-3.08 6.24-5 10.94-3.44"/><path fill="#1e1c20" d="m1666 626 1 3-3.87 3.44-2.18 1.93C1659 636 1659 636 1657 637c-1.12 2.06-1.12 2.06-2 4h-3v4l-1.87.81A8.7 8.7 0 0 0 1646 650l1-5 3-1c1.19-2.56 1.19-2.56 2-5h3l.25-1.8c.97-2.84 2.23-3.6 4.69-5.26 2.6-1.78 4.5-3.18 6.06-5.94"/><path fill="#36373c" d="M1573 636c-.49 4.28-1.61 6.1-4.87 8.88l-2.12 1.86L1564 648l-3-1 2.44-1.81C1566 643 1566 643 1567 640l-4 3-2-1c3.3-3.66 6.87-6.57 12-6"/><path fill="#08080d" d="M1484 621c1.71 5.58.26 10.47-2.3 15.6a81 81 0 0 1-9.7 13.4l-1-2q.92-1.5 1.88-3l1.05-1.69L1475 642h2l.25-2.5c.57-3.54 2.1-6.32 3.76-9.46 1.43-2.94 2.2-5.88 2.99-9.04"/><path fill="#080a0e" d="M263 614h8l-1 7h-6l-1-3c-2.06-.69-2.06-.69-4-1h4z"/><path fill="#bb9de3" d="M399 602h1c.2 7.99-.36 15.7-1.37 23.63l-.39 3.29-.39 3.12-.35 2.82-.5 2.14-2 1q-.08-3.37-.12-6.75l-.06-1.92c-.04-3.78.47-6.04 2.18-9.33.27-2.36.46-4.64.56-7 .37-7.46.37-7.46 1.44-11"/><path fill="#261746" d="M1200 616c-2.1 2.33-3.7 3.44-6.69 4.38-3.98 1.36-7.2 3.34-10.65 5.73l-1.66.89-2-1q1.19-.93 2.44-1.87C1184 622 1184 622 1185 620h3v-2q1.87-.8 3.75-1.56l2.1-.88c2.45-.64 3.79-.36 6.15.44"/><path fill="#ad94cf" d="m461 606 2.15.44a202 202 0 0 0 8.48 1.5l2.85.46c2.33.55 3.67 1.12 5.52 2.6a25.8 25.8 0 0 1-17.3-1.04L461 609z"/><path fill="#281947" d="M1244 591h5c-6.48 5.83-14.8 9.27-23 12v-2l2.94-1.31C1232 598 1232 598 1233 595l2.15-.37 2.79-.5 2.77-.5 2.29-.63z"/><path fill="#100821" d="M1267 585c-6.28 4-12.74 8.07-20 10l-3-1c5.48-3.92 10.61-6.87 17-9 2.5-1.25 3.41-.78 6 0"/><path fill="#877e9a" d="M807 566c-1.66 2-2.62 2.91-5.19 3.5l-2.52.2-2.77.23-2.9.2-5.64.44-2.53.18c-2.53.26-4.97.71-7.45 1.25 2.35-2.8 4-3.45 7.6-3.91l2.68-.37 2.78-.35q2.74-.35 5.48-.72l2.44-.3c5.86-1 5.86-1 8.02-.35"/><path fill="#474649" d="m1550 394 3 1-1 3c4.67 1.2 9.21 1.1 14 1v2c-14.22.53-14.22.53-21-3-2-.48-3.95-.75-6-1v-1h11z"/><path fill="#020306" d="M423 343h1l-.02 1.76-.08 13.38-.01 2.46c.11 2.4.11 2.4.67 5 .6 3.23-.37 5.39-1.56 8.4-.69-2.5-1-4.38-1-7l-3 1a85 85 0 0 1 2-7q.3-3.47.5-6.94c.42-7.46.42-7.46 1.5-11.06"/><path fill="#939396" d="M448 278h1q.12 3.65.19 7.31l.07 2.1c.08 5.1.08 5.1-1.65 7.57C446 296 446 296 444 296q.42-3.1.88-6.19l.23-1.75c.53-3.6 1.4-6.74 2.89-10.06"/><path fill="#717174" d="m848.19 154.94 2.17.02 1.64.04c-7.59 5.55-16.87 6.68-26 7 2.51-2.51 5.12-3.1 8.44-4.06l3.5-1.04 3.06-.9c2.75-.92 4.36-1.1 7.19-1.06"/><path fill="#878687" d="M523 139h6c-.75 1.5-.75 1.5-2 3-2.12.19-2.12.19-4 0v2l4 1c-2.25 1.63-2.25 1.63-5 3-2.25-.56-2.25-.56-4-2-.81-2.06-.81-2.06-1-4 1-1 1-1 3.56-1.06l2.44.06z"/><path fill="#242429" d="m1029.33 128.32 1.67.68c-2.86 3.15-6.1 4.78-9.84 6.72-2.16 1.28-2.16 1.28-3.8 2.96L1016 140c-2.19-.31-2.19-.31-4-1 1.38-1.5 1.38-1.5 3-3h2v-2c2.05-1.54 2.05-1.54 4.75-3.19l2.67-1.67c2.58-1.14 2.58-1.14 4.9-.82"/><path fill="#333336" d="m489 90 7 2v1h-5v5h-7l-2 4-4-1 2-2c.63-2.62.63-2.62 1-5l1 2 6-1z"/><path fill="#09090e" d="m839.77 93.89 2.27.01 7.61.05L856 94l-1 3c-3.32 1.4-6.6.98-10.13.69l-3.19-.24C839 97 839 97 837 95c1-1 1-1 2.77-1.11"/><path fill="#0a090f" d="M694 86h12l1 3q-5.02.3-10.06.56l-2.86.17c-5.2.27-9.95.23-15.08-.73v-1h15z"/><path fill="#565557" d="M938 51h9v1h-7l-2 5h-7l-1 3h-7l-1-2 1.88-.31C926 57 926 57 928 54l9 1z"/><path fill="#646467" d="M1301 1820c1 3 1 3 0 6h-8l-1-5c3-1.5 5.66-1.06 9-1"/><path fill="#100f14" d="M427 1741c10.15 4.27 10.15 4.27 13 7v2l2.44.88C445 1752 445 1752 446 1754h-6l-1-4h-4l-1-4-6-1z"/><path fill="#86868d" d="M411 1704a16 16 0 0 1 3 7h2c.69 1.81.69 1.81 1 4-1.19 1.75-1.19 1.75-3 3-2.19.19-2.19.19-4 0v-7l-.06-3.06c.06-2.94.06-2.94 1.06-3.94"/><path fill="#15151c" d="M765 1694a28 28 0 0 1 1.1 8.33l-.01 2.42-.03 2.5-.06 8.75 3 1c.95 1.74.95 1.74 1.69 3.88l.76 2.11c.55 2.01.55 2.01.55 5.01-2.9-2.4-3.5-4.31-4-8l-3-1c-1.18-2.37-1.13-3.83-1.13-6.46v-13.4c.13-2.14.13-2.14 1.13-5.14"/><path fill="#f6f7f6" d="M346 1685c3 1 3 1 4.19 2.56 1.16 3.49.97 6.8.81 10.44l-5-1z"/><path fill="#3b3a45" d="m667 1503 3 1v3c-1.2 1.13-1.2 1.13-2.81 2.13l-1.58 1c-1.61.87-1.61.87-4.61 1.87l-1 2h-3v-2l-2-1 2.31-1.37q2.24-1.34 4.38-2.82c1.31-.81 1.31-.81 3.31-.81 1.13-1.5 1.13-1.5 2-3"/><path fill="#14171c" d="M349 1422h1v34h-1l-.06-1.86-.31-8.39-.1-2.93-.12-2.84-.1-2.6c-.33-2.52-1.13-4.15-2.31-6.38-.12-2.75-.12-2.75 0-5h2z"/><path fill="#f5f5f8" d="M1499 1384h3l.31 1.88c.8 2.44 1.47 2.96 3.69 4.12l-1 4h-3v-5c-2.7 1.61-5.4 3.22-8 5 .63-4.08 2.51-6.78 5-10"/><path fill="#482e1a" d="M1267 1369c3.8 2.36 5.3 4.9 7 9v3l-8.5-.56c-3.89-.26-7.66-.74-11.5-1.44v-1c5.79-.1 11.3 0 17 1l-1.44-2.19a20 20 0 0 1-2.56-7.81"/><path fill="#671009" d="M1450 1360c0 5.93-6.51 10.56-10.5 14.63-3.35 3.3-6.88 6.37-10.5 9.37l-2-1 2.81-3.44 1.58-1.93c1.61-1.63 1.61-1.63 4.61-2.63l1-3 2.13-1.19c4.09-2.58 6.8-6.12 9.87-9.81z"/><path fill="#8c8c90" d="M1513 1278q2.51.43 5 1v8l-5 1c-1.52-3.03-1.12-5.62-1-9z"/><path fill="#5f0903" d="M1453 1268c5.87 5.55 8.42 9.87 9 18h-2l-.59-1.46-.79-1.91-.77-1.9c-.85-1.73-.85-1.73-2.85-3.73-1.48-3.12-2.26-5.54-2-9"/><path fill="#7d7c85" d="M392 1248c2.63 3.51 3.1 7.02 3.32 11.33l.12 2.42.12 2.5.13 2.55.31 6.2-2-1c-.41-2.5-.41-2.5-.62-5.56l-.23-3.07-.15-2.37-2 2-.06-5.25-.04-2.95c.09-2.46.4-4.46 1.1-6.8"/><path fill="#957458" d="M1069 1243h1v7h8l1 2h-2l-1 3h-11l4-1z"/><path fill="#0d0c0d" d="m1387 1220 4 2h-2v3l-3-.5a102 102 0 0 0-29 .5v-2c6.33-1.14 12.52-1.11 18.94-1.06l3.23.01 7.83.05z"/><path fill="#120f0f" d="m1021 1216 2 1c5.73.32 11.38.21 17-1-3.32 2.95-6.62 3.86-10.87 4.69l-1.82.39c-3.82.78-7.4 1.05-11.31.92q.67-.65 1.38-1.31a27 27 0 0 0 3.62-4.69"/><path fill="#f2f3f2" d="m1458 1194 4 1v3h-5c1.61 2.7 3.22 5.4 5 8h-4l-1-2-2-.87c-2-1.13-2-1.13-2.75-3.26l-.25-1.87 1.88-.31c2.44-.8 2.96-1.47 4.12-3.69"/><path fill="#000005" d="M695 1163h5c-.07 1.76-.07 1.76-1 4-2.16 1.59-4.52 2.68-6.94 3.81l-1.96.98q-2.5 1.2-5.1 2.21l-2-1c3.55-3.96 5.86-5.36 11-7z"/><path fill="#b1bac0" d="M1532 1118h7l-1 6h3l1-4c1.3 5.18.56 8.93-1 14l-2-1v-5l-5-1 3-8h-5z"/><path fill="#000001" d="M971 979h18l-1 3h-17z"/><path fill="#000002" d="m253 966 8.85-.09c4.92-.02 4.92-.02 7.15 1.09l-1 3c-5.44.25-9.82-.27-15-2z"/><path fill="#a2a1a2" d="M246 955h16v4c-5.09.23-9.23.05-14-2z"/><path fill="#989799" d="m464 954 20.24-.08 7.4-.02 2.3-.01c3.83 0 7.32.18 11.06 1.11l-1 2h-9v-1l-31-1z"/><path fill="#adacac" d="M1121 950v3l3 1h-4v3h-4v-3h-8c4.39-3.48 7.42-4.12 13-4"/><path fill="#dadde0" d="m1450 937 1 2a122 122 0 0 1-2.81 6.25c-3.28 7.14-4.5 13.97-5.19 21.75h-1c-.43-19.22-.43-19.22 5-26l1-3z"/><path fill="#29292d" d="M173 940c5.98.32 10.96 1.67 16 5l1 2c-12-.38-12-.38-15.25-2.94C173 942 173 942 173 940"/><path fill="#07080b" d="m1442 915 1 2 1-2 5 1-5 5-1-2c-3 1-3 1-4 3h-4c-.25-1.81-.25-1.81 0-4 2.5-2.42 3.46-3 7-3"/><path fill="#969598" d="M94 886h4v4h3l1-2 1 3c-.94 1.69-.94 1.69-2 3l-1-3h-3a101 101 0 0 0 10 13c-3.63-1.53-5.99-3.72-8.75-6.5l-2.42-2.4C94 893 94 893 94 891h2z"/><path fill="#878788" d="M1011 881h11v2l3 1c-4.62 2.22-7.77 3.38-13 3l4-2-5-2z"/><path fill="#e6e9ea" d="M1494 835h2c1.01 4.17 1.12 8.05 1.06 12.31L1497 854l-3 1z"/><path fill="#2d2b31" d="m1105 838 4 1-3 1v3c8.67-1.81 8.67-1.81 12.31-3.19 2.79-.84 4.02-.84 6.69.19-7.92 3.87-16.34 4.85-25 6l1.94-1.75c2.04-2.23 2.64-3.32 3.06-6.25"/><path fill="#1a1a20" d="M1411 722h-2l-1 4h-4l-1 3c-2.7 1.35-5 1.07-8 1 1.64-3.12 4.03-4.25 7-6l1-2c3.28-.95 4.7-1.1 8 0"/><path fill="#ababac" d="M213 706v3l-1.94.88C209 711 209 711 208 713c-3.06.63-3.06.63-6 1v3h-5c1.48-3.9 3.4-5.04 7-7l3.13-2.19C210 706 210 706 213 706"/><path fill="#55328a" d="M938 706h13l1 2c-5.22 2.28-9.3 3.36-15 3z"/><path fill="#000004" d="M1494 698h3c-.81 1.94-.81 1.94-2 4l-3 1-1 2-2 .44-2 .56-1 3h-7a18 18 0 0 1 8-7c3.31-.69 3.31-.69 6-1z"/><path fill="#aaa1ba" d="m465.95 585.89 2.52.01 2.73.01 2.86.03 2.88.01 7.06.05c-3.1 3.02-5.45 3.37-9.59 3.36l-2.04-.05-2.06.01c-5.04-.05-5.04-.05-7.31-2.32 1-1 1-1 2.95-1.11"/><path fill="#b4abc8" d="M756 578c-2.31 2.31-3.92 2.94-7.18 3.11l-2.49-.01-2.7-.01-2.82-.03L731 581v-1c8.24-2.56 16.48-2.19 25-2"/><path fill="#000001" d="M702 575c-.73 1.48-.73 1.48-2 3-1.97.34-1.97.34-4.35.3l-2.58-.04-2.7-.07-2.71-.04L681 578v-2c6.97-1.33 13.93-1.09 21-1"/><path fill="#937db8" d="m866.07 555.9 2.5.04 2.5.02 1.93.04c-4.9 2.61-10.15 4.01-15.44 5.63l-3.14.98-3.04.93-2.76.85c-2.64.61-4.93.72-7.62.61 2.36-2.8 4.04-3.45 7.63-4 5.68-1 12.18-4.85 17.44-5.1"/><path fill="#000001" d="M792 559h15v3a83 83 0 0 1-16 1z"/><path fill="#1a1a20" d="M506 541c20.87-.23 20.87-.23 26 1l1 2c-9.27.2-17.93.15-27-2z"/><path fill="#333238" d="M878 494h11l1 2 2 1c-3.84 2.56-7.58 2.2-12 2z"/><path fill="#34215d" d="M1202 432c2.13.38 2.13.38 4 1l-1 2 9-1c-4.9 4.14-9.65 5.3-16 5 1.07-2.92 1.78-4.78 4-7"/><path fill="#f6f6f6" d="M365 406c2 2 2 2 2.12 4.06l-.25 2.38-.19 2.37C366 417 366 417 362 420v-13z"/><path fill="#46474b" d="M1450 378h7l1 3c4.24 2.43 8.4 2.27 13.13 2.19h2.16c3.25-.03 5.58-.15 8.71-1.19v2c-3.59 1.8-7.73 1.27-11.69 1.31l-2.68.09c-6.1.06-9.6-.91-14.63-4.4v-2z"/><path fill="#362168" d="M1286 370v5l3 1-1 2-2.19-.81c-2.81-.19-2.81-.19-4.18.82q-1.86 1.96-3.63 3.99l-2 1-1-3a41 41 0 0 1 4-5l1.31-1.43c3.4-3.57 3.4-3.57 5.69-3.57"/><path fill="#77787a" d="M404 215c.13 6.75.13 6.75-1 9l-5-1v-8c3-1 3-1 6 0"/><path fill="#39383d" d="M629 211c5.67 1.27 9.47 3.4 14 7-2.74.81-4.75 1.07-7.56.56C632 218 632 218 629 218c-1.12-4.75-1.12-4.75 0-7"/><path fill="#2f2d33" d="m952 178 2 1v3h-3l-.12 2.06c-1.25 4.2-3.92 6.8-6.88 9.94l-1-4h3c-1-2-1-2-3-3l3-1q1.02-1.99 2-4a90 90 0 0 1 4-4"/><path fill="#030304" d="m452 129 1 4h-3v5h-4l-.31 1.94L445 142l-3 1c-.5-2.62-.5-2.62 0-6 2.43-2.37 5.19-4.12 8-6z"/><path fill="#58585a" d="m585 128 4 1v2h5v1h-33v-3h4v2h20z"/><path fill="#8d8c8f" d="M988 117v2c-4.27 2.18-8.5 4.31-13 6l1-3-7 1c2.6-1.94 5.34-3 8.38-4.12l2.83-1.08c2.76-.79 4.94-.96 7.79-.8"/><path fill="#66666a" d="M1055 3v3h-16V3q2.6-.3 5.19-.56l2.92-.32c2.83-.12 5.14.2 7.89.88"/><path fill="#cccccd" d="M1301 1826h16v3l-15 1z"/><path fill="#37373e" d="M765 1688h2l-.94 2.31c-1.38 3.5-2.2 6.4-2.38 10.14l-.12 2.57-.12 2.67-.13 2.7-.31 6.61c-2.5-2.5-2.28-4.05-2.33-7.46l.02-2.16v-2.2c.1-5.24.36-11.23 4.31-15.18"/><path fill="#15141c" d="M936 1681c4.59 4.1 7.63 8.14 8.24 14.44q.07 4-.05 8l-.04 2.8-.15 6.76h-1l-.11-3.18c-.82-18.77-.82-18.77-6.89-24.82-.19-2.19-.19-2.19 0-4"/><path fill="#28282f" d="M1320 1664h1c.58 5.73-.5 10.24-2.31 15.63l-.65 1.91c-1.4 4-3.07 7.71-5.04 11.46-.69-1.62-.69-1.62-1-4q1.04-2.1 2.16-4.14c1.68-3.73 2.17-7.85 2.84-11.86h2z"/><path fill="#3e3c47" d="M432 1669h1q.37 5.68.69 11.38l.22 3.27.16 3.12.18 2.9c-.25 2.33-.25 2.33-1.75 3.66-.25.1-.25.1-1.5.67l-.09-15.38v-3.27l-.02-3.03c.11-2.32.11-2.32 1.11-3.32"/><path fill="#030307" d="M1252 1594c0 2.94-.45 4.75-1.37 7.5l-.78 2.34a17 17 0 0 1-2.85 5.16c-2.12.19-2.12.19-4 0 .79-3.41 1.87-6.69 3-10h4l.44-1.94c.56-2.06.56-2.06 1.56-3.06"/><path fill="#191a22" d="m769 1446 2 1c-2.39 2.64-4.95 4.18-8 6a445 445 0 0 0-6.31 4.19l-1.67 1.11-4.02 2.7-2-1c3.23-4.43 6.22-6.93 11.44-8.94 3.19-1.32 5.73-3.08 8.56-5.06"/><path fill="#06080d" d="m1365.58 1405.89 2.02.01 2.18.01 2.28.03 2.3.01 5.64.05v2l4 1q-4.68.09-9.38.12l-2.69.06q-1.27 0-2.58.02l-2.38.03c-1.97-.23-1.97-.23-3.97-2.23 1-1 1-1 2.58-1.11"/><path fill="#120c0a" d="M964 1278c5.06 1.55 9.5 3.16 14 6 2.31 1.07 4.65 2.03 7 3v1a46 46 0 0 1-9.25-.56c-2.75-.44-2.75-.44-4.75.56v-6l-6-1z"/><path fill="#60140c" d="m1435 1248 3.81 2.81 2.15 1.58c4.03 3.17 5.87 6.04 7.98 10.67a18 18 0 0 0 4.06 4.94l-1 2-5-1v-3l-2-1h2c-2.78-5.76-5.5-9.95-10.64-13.8l-1.36-1.2z"/><path fill="#05050a" d="M1316 1243h4c-.37 2.6-1.08 4.12-2.56 6.31-2.8 1.94-3.45 1.85-6.69 1.44q-1.87-.34-3.75-.75l1-4h2l1-2v2h7v-2z"/><path fill="#a68b73" d="m994 1234 2 1c-1 2-1 2-2.43 2.63-8.38 2.06-17 1.55-25.57 1.37 1-2 1-2 4-3 2.34-.13 4.65-.04 7 0v2q2.91-.43 5.81-.87l3.27-.5c2.56-.55 3.95-.98 5.92-2.63"/><path fill="#030405" d="M1337 1227h7c-.12 1.88-.12 1.88-1 4-2.69 1.06-2.69 1.06-6 2-2.04.93-4.01 1.96-6 3v-5l6-1z"/><path fill="#020102" d="M972 1227h11v3l-5.25 1.44-2.95.8q-3.4.92-6.8 1.76z"/><path fill="#dcdbde" d="M1441 1157h1q.08 1.88.13 3.75l.07 2.1c-.2 2.15-.2 2.15-1.18 3.88-1.85 4.11-1.46 8.7-1.59 13.16l-.09 2.24-.05 2.03c-.33 2.08-1.01 3.2-2.29 4.84-.3-6.51.19-12.9.88-19.37l.23-2.38c.43-3.79.8-6.96 2.89-10.25"/><path fill="#4b4a53" d="m396 1164 2 1c.41 2.77.41 2.77.63 6.25l.22 3.45q.2 4.16.15 8.3c-2.3-2.3-2.32-3-2.62-6.12l-.23-2.2-.15-1.68h-1l-1 6h-1c-.41-5.85.37-9.74 3-15"/><path fill="#525156" d="M1441 1126h1q.12 2.1.19 4.19l.1 2.35c-.36 3.08-1.7 4.82-3.29 7.46-.73 3.11-.73 3.11-1.19 6.31l-.48 3.24-.33 2.45h-1q-.12-3.57-.19-7.12l-.07-2.02c-.07-4.83.83-7.68 3.26-11.86a203 203 0 0 0 2-5"/><path fill="#16161e" d="M1159 1119c-4.4 2.96-9.05 4.58-14.06 6.25l-2.43.85-2.36.8-2.12.71c-2.33.45-3.8.12-6.03-.61l2.48-1.02 3.27-1.36 3.23-1.33a76 76 0 0 0 5.12-2.38 29 29 0 0 1 6.53-2.1l2.35-.54c2.02-.27 2.02-.27 4.02.73"/><path fill="#94a1ab" d="m1565 1052 4 1v9h2v7l-1-2h-2l-.31-2.31c-.69-2.69-.69-2.69-2.32-4.07-1.37-1.62-1.37-1.62-1.34-3.36q.4-2.64.97-5.26"/><path fill="#f0f1f3" d="m1594 1050 4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.06-.62 2.06-1 4-2.25-.12-2.25-.12-5-1-1.65-1.89-2.74-3.8-4-6l9 2z"/><path fill="#0b0c10" d="M352 1039c1.18 3.34 1.11 6.56 1.1 10.08l-.04 7.17-.01 3.7-.05 9.05h-1l-.06-2.23a1613 1613 0 0 0-.36-11.79q-.07-2.55-.17-5.12l-.1-3.11c-.31-2.77-.7-4.48-2.31-6.75-1.6-.55-1.6-.55-3.48-.6l-2.03-.1-2.12-.05-2.14-.09q-2.6-.1-5.23-.16v-1q3.93-.33 7.88-.62l2.26-.2 2.16-.16 2-.16c1.7.14 1.7.14 3.7 2.14"/><path fill="#77797a" d="M179 1018h16l-1 4-15-1z"/><path fill="#000001" d="m209 1010 14 1 1 3h-15z"/><path fill="#030305" d="M374 981h36v1l-26 1v1q-1.94.12-3.87.19l-2.18.1L376 984z"/><path fill="#000001" d="M272 969c11.52.96 11.52.96 17 2l-1 2h-17z"/><path fill="#06070b" d="M735 955v2h6v1a97 97 0 0 1-16 3c3.75-2 3.75-2 6-2v-2l-2.94.5c-4.35.63-8.67.59-13.06.5 3.34-1.97 6.15-2.37 10-2.62l3.13-.23q3.43-.19 6.87-.15"/><path fill="#49484b" d="M959 904c-7.38 5.55-18.1 6.2-27 6 2.35-2.35 2.93-2.25 6.13-2.44 3.28-.28 5.9-.84 8.93-2.12 4.04-1.68 7.62-1.75 11.94-1.44"/><path fill="#0e0f12" d="m1001 894 2 1c-1.79 2.26-2.7 2.95-5.6 3.48l-2.9.14c-3.14.18-5.5.38-8.5 1.38l-1 2c-2.55.31-5 .51-7.56.63l-2.16.11q-2.64.14-5.28.26c2.38-1.68 3.83-2.25 6.75-2.44 2.84-.29 5.05-.78 7.63-2 3.68-1.74 7.4-2.42 11.39-3.13C998 895 998 895 1001 894"/><path fill="#d3d7dd" d="M1525 866q2.51.43 5 1v10l-6-3c-.1-5.37-.1-5.37 0-7z"/><path fill="#818082" d="m1044.13 871.94 2.19.02 1.68.04 1 3 12-1a32 32 0 0 1-8.75 3.13l-2.42.5-1.83.37v-3h-10v-2c2.29-1.14 3.6-1.1 6.13-1.06"/><path fill="#202125" d="M1086 871c-2.87 2.15-5.6 2.7-9.06 3.44-1.94.56-1.94.56-3.37 1.61-2.27 1.37-4.14 1.14-6.76 1.08l-2.73-.06-2.08-.07c5.94-3.99 16.9-9.55 24-6"/><path fill="#878688" d="m1098.13 855.94 2.19.02 1.68.04v2h2v-2l9 2v1l-1.68.15-4.37.44c-1.95.41-1.95.41-3.95 2.41l-3-1 1-2h-9v-2c2.29-1.14 3.6-1.1 6.13-1.06"/><path fill="#59585b" d="m1145 833 2 1c-8.92 4.91-18.63 9.42-29 9 3.48-3.09 6.84-3.96 11.25-5a93 93 0 0 0 12.56-3.81z"/><path fill="#7d7d7f" d="M1176 826v2c1.5 1.13 1.5 1.13 3 2a30 30 0 0 1-10.81 2.63l-2.96.22-2.23.15 1-4 1.68-.4c.36-.1.36-.1 2.2-.54l2.17-.52c5.59-1.54 5.59-1.54 5.95-1.54"/><path fill="#0a0b0e" d="m1324 786-2 4h-7l-.94 2.38C1313 795 1313 795 1312 797l-2-1 1-2h-7c2-2 3.05-2.55 5.63-3.5q1.04-.4 2.14-.8l2.23-.83 2.23-.83c5.5-2.04 5.5-2.04 7.77-2.04"/><path fill="#0b0a0f" d="m928.06 778.94 2.94.06c-2.83 2.43-6.12 2.65-9.69 3.13l-1.84.26-4.47.61v2a353 353 0 0 1-18 3c4.48-3.3 8.45-4.26 14-4v-2l2.8-.4 3.64-.54 1.85-.26c3.29-.5 5.59-1.8 8.77-1.86"/><path fill="#5f5e61" d="M1352 772a49 49 0 0 1-10.8 5.17c-3.2 1.2-6.25 2.68-9.32 4.16l-1.88.67-2-1 6-2v-2c2.24-3.35 5.35-3.77 9.06-4.57 6.02-1.34 6.02-1.34 8.94-.43"/><path fill="#2e1a4f" d="M775 752v1c-9.16 2.1-17.56 3.47-27 3 7.77-5.55 17.94-4.2 27-4"/><path fill="#5d5d61" d="M1416 727c-1 2-1 2-3.62 3.07l-3.26 1.05-3.24 1.08c-2.88.8-2.88.8-5.88.8v2l-4.81 1.5-2.71.84c-2.48.66-2.48.66-5.48.66 2.16-2.16 3.2-2.49 6.06-3.31a45 45 0 0 0 9.57-4.25C1412 725 1412 725 1416 727"/><path fill="#010104" d="m322 717 1.38 1c1.62 1 1.62 1 3.62 1 3 5.75 3 5.75 3 8h2l1 6-4-1v-3h-2l-1.27-5.27c-.73-1.73-.73-1.73-2.3-2.77L322 720z"/><path fill="#2b2a30" d="M1489 688a64 64 0 0 1-15 6v2h-3l-1 3c-1.63.73-1.63.73-3.56 1.19l-1.94.48-1.5.33c3.04-3.42 6.38-5.57 10.3-7.85 1.7-1.15 1.7-1.15 2.7-3.15 1.93-.89 1.93-.89 4.31-1.69l2.37-.82c2.5-.53 3.92-.3 6.32.51"/><path fill="#0d0c12" d="M1441 680a56 56 0 0 1-17 7v2q-2.12 1.05-4.25 2.06l-2.4 1.16c-2.61.87-3.8.71-6.35-.22l1.71-.59 2.23-.78 2.21-.78C1419 689 1419 689 1420 687c4.74-3.02 15.82-9.6 21-7"/><path fill="#48474a" d="M317 654c1 2 1 2 .65 3.6l-.65 1.88-.69 2.03-.75 2.12-.73 2.14L313 671h-1v-8l-1 2h-2l-1-4c4.75-3 4.75-3 7-3v-3z"/><path fill="#2d1a51" d="M1145 635h10c-2.24 3.35-2.88 3.55-6.56 4.63-4.36 1.3-4.36 1.3-5.44 2.37q-3 .06-6 0v-3h7z"/><path fill="#7e3ec5" d="M411 626h1q.13 5.4.19 10.81l.07 3.1.08 5.73c-.4 2.78-1.18 3.64-3.34 5.36l.56-13.92.12-2.97.11-2.75C410 629 410 629 411 626"/><path fill="#5f3e99" d="M728 597v1l-9 1v2l2 1c-5.45 1.82-11.5 1.37-17 0v-1l7.5-2 2.1-.58c4.94-1.28 9.3-1.6 14.4-1.42"/><path fill="#5a3a94" d="M743 596q1.53.95 3 2l-2.55.18-3.33.26-3.3.24C734 599 734 599 732 600q-2.81.1-5.62.06l-3.04-.02L721 600v-2l3.4-.4 8.8-1.06 2-.24c5.82-.96 5.82-.96 7.8-.3"/><path fill="#bab2c8" d="M465 582c14.78-.37 14.78-.37 21 2v1l-11.34.09h-2.43l-2.24.02C468 585 468 585 465 584z"/><path fill="#adacaf" d="M340 559c.2 5.27.2 5.27 0 7l-2 2c-2.12-.37-2.12-.37-4-1v-8c3-1 3-1 6 0"/><path fill="#242429" d="M416 521c22.24 4.24 22.24 4.24 27 9l-12-1v-2l-2.27-.3c-8.14-1.23-8.14-1.23-11.42-3.83z"/><path fill="#030307" d="m1368 507 2 1-4.25 5.25-1.2 1.49A71 71 0 0 1 1357 523h-2l-1 3-1-2q1.43-1.76 2.88-3.5l1.61-1.97L1359 517h2l.69-1.62c1.73-3.14 3.96-5.7 6.31-8.38m-18 19 3 1c-1.25 1.56-1.25 1.56-3 3-2.19-.31-2.19-.31-4-1h3z"/><path fill="#8d8d90" d="m414 513 12 1v5a37 37 0 0 1-9 0c-3-3.55-3-3.55-3-6"/><path fill="#392462" d="M1307 485c2.8 2.4 3.81 4.65 5 8.13l1.02 2.95.98 2.92.89 2.55c1.3 3.79 2.47 7.48 3.11 11.45-3.73-2.15-4.77-5-6-9-.3-2.07-.5-4.14-.69-6.23-.43-2.45-1.8-3.82-3.31-5.77-.55-2.34-.77-4.6-1-7"/><path fill="#331f5d" d="M1330 460c3.36 1.68 3.78 5.1 4.98 8.52 1.98 6.33 2.94 12.95 4.02 19.48-2-1-2-1-2.75-3.19l-.69-2.81c-.93-3.65-1.93-7.2-3.18-10.75A50 50 0 0 1 1330 460"/><path fill="#160e29" d="M1148 452c-8.18 5-8.18 5-12 5v2q-2.37 1.05-4.75 2.06l-2.67 1.16c-2.73.83-3.95.76-6.58-.22 2.5-2.3 4.61-3.24 7.88-3.94 4.15-.92 6.7-2.57 10.12-5.06 3.1-1.92 4.53-2.24 8-1"/><path fill="#010106" d="M1179 430c-1.12 3.26-1.96 3.98-5.06 5.69L1171 437l-1 1q-2.5.06-5 0l2.38-2.31a19 19 0 0 0 3.62-4.44c1-1.25 1-1.25 3.5-2 2.5-.25 2.5-.25 4.5.75"/><path fill="#33225e" d="m1251 416-4 1v2l-1.83.77c-6.77 2.92-6.77 2.92-9.48 5.04L1234 426c-2.1-.43-2.1-.43-4-1 8.26-6.7 8.26-6.7 13-7v-2c3.05-.98 4.95-.98 8 0"/><path fill="#f8f9f8" d="M377 326c2 2 2 2 2.31 5.38-.09 3.36-.83 5.23-3.31 7.62h-2v-12z"/><path fill="#8e8e90" d="M474 191h3v8l-4 1-1 6h-2q-.12-1.94-.19-3.87l-.1-2.18c.29-1.95.29-1.95 1.76-3.04L473 196c.69-2.62.69-2.62 1-5"/><path fill="#37363b" d="M844 155c-5.54 3.78-11.19 5.2-17.81 5.75-3.21.37-6.12 1.27-9.19 2.25 2.76-3.6 6.53-4.42 10.69-5.69l2.07-.68c4.9-1.52 9.11-1.84 14.24-1.63"/><path fill="#39373d" d="M1042 115h2v4h-3l-1 3a101 101 0 0 1-6 3l-1 1q-2.5.06-5 0c1.52-2.68 1.87-2.96 5-4l.88-1.44c1.6-2.23 3.59-2.61 6.12-3.56z"/><path fill="#f6f6f6" d="m1206 46 2.31 1.94C1211 50 1211 50 1214 51v-5l4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.06-.62 2.06-1 4-1.94-.81-1.94-.81-4-2l-1-3-3-1z"/><path fill="#b6b5b7" d="m951 39 1 2 3 1-1 4-4-1 1-3h-10v-3c3.67-.94 6.35-1.11 10 0"/><path fill="#2e2d35" d="M941 1720c.75 1.66.75 1.66 1 4a40 40 0 0 1-4 5.5l-1.24 1.48c-3.2 3.67-6.2 6.16-10.76 8.02l-2-1 4-1 .2-1.75c1.42-3.96 5.14-5.76 8.45-8.15 2.53-2.26 3.38-3.9 4.35-7.1"/><path fill="#68666d" d="M490 1641h15l-1 4a62 62 0 0 1-14-1z"/><path fill="#020304" d="M1457 1419v4l-2.44.38-2.56.62-1 2h-6l1-4h5v-3c2.5-1.25 3.41-.78 6 0"/><path fill="#020204" d="M1298 1359c2.47.4 3.73.73 5.52 2.52l1.3 1.86 1.32 1.83c.86 1.79.86 1.79-.14 4.79l-4-1v-3l-4-2z"/><path fill="#1f140e" d="M1138 1348c5.76.63 11.68 1.62 17 4l1 3c1.81.66 1.81.66 4 1.06s2.19.41 4 .94l1 2c-6.42-.24-10.1-2.04-15.46-5.5-3.65-2.16-7.46-3.38-11.54-4.5z"/><path fill="#ec4a09" d="M1344 1331c2.19 3.54 2.39 6.95 2.56 11 .37 7.46.37 7.46 1.44 11h-3v-11h-2c-1.2-3.6-1.07-6.23-1-10z"/><path fill="#342319" d="M1034 1308c3.95.63 7.2 1.86 10.81 3.56l5.19 2.44-1-6c3.94 3.32 3.94 3.32 4.25 6.75l-.25 2.25c-4.62-.63-8.23-2.44-12.31-4.56l-1.96-1q-2.37-1.2-4.73-2.44z"/><path fill="#0a090b" d="M1305 1262c-3 9.78-3 9.78-6 14h-1v-5l-3 1v-2l3-1q1.05-2.99 2-6c2-1 2-1 5-1"/><path fill="#41414d" d="M627 1203c-2.52 2.56-4.46 3.22-7.94 3.34q-2.39 0-4.76-.05l-2.54-.01-8.01-.1-5.44-.03q-6.66-.06-13.31-.15v-1l2.95-.06a2450 2450 0 0 0 15.57-.36q3.4-.07 6.77-.17l2.11-.03c5.12-.16 9.8-3.15 14.6-1.38"/><path fill="#817e8a" d="M1316 1103h1a65 65 0 0 1 1.06 13.88l-.06 7.12h-1l-1 8h-1c-.11-9.73.07-19.3 1-29"/><path fill="#9ca7b0" d="M1548 1024c6.29 2.38 6.29 2.38 8 4 .19 2.13.19 2.13 0 4l2 1h-3l1 6h-2a264 264 0 0 1-3-5l-2-1v-2l-2-1c1.81-.19 1.81-.19 4 0 1.75 1.5 1.75 1.5 3 3v-4l-1.81-.87a44 44 0 0 1-5.19-3.13z"/><path fill="#b6b5b7" d="M86 974h6v4l-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-7-1v-3h8z"/><path fill="#dee1e7" d="m1526 970 4 1v11l-4-1a699 699 0 0 1-.88-7.71L1525 971z"/><path fill="#b6b6b6" d="M1080 969v1l-12 1v3h-3l-1 3c-5.27.2-5.27.2-7 0l-2-2c1.66-1.66 3.78-1.36 6.06-1.56l2.79-.26 2.15-.18v-3a62 62 0 0 1 14-1"/><path fill="#08080a" d="M1415 949h3c-.67 7.4-1.8 15.31-7 21l.72-5q.27-1.89.5-3.78A72 72 0 0 1 1415 949"/><path fill="#09090f" d="m1282 904 2 1-2 2c-.23 2.57-.23 2.57-.19 5.56l.02 3c.17 2.44.17 2.44 1.17 3.44q.12 2.45.1 4.91l-.01 3-.03 3.15L1283 941h-1c-2.87-26.7-2.87-26.7-2-35z"/><path fill="#848488" d="m1455 914 2 1a23 23 0 0 1-7.31 5.75c-3.83 2.07-6.17 4.89-8.91 8.21-2.64 3.03-5.65 5.53-8.78 8.04 1.5-3.54 3.56-5.77 6.31-8.44l2.44-2.38L1443 924l2.88-2.87L1448 919l1.56-1.69A16 16 0 0 1 1455 914"/><path fill="#454349" d="M162 875h4v2l2.44.88C171 879 171 879 172 881l2-1v2h2l-1 3-4-2-1 2-4-3.87-2.25-2.18C162 877 162 877 162 875"/><path fill="#b3bcc3" d="M1500 837h1l.08 14.74.02 5.38.01 3.27C1501 863 1501 863 1500 865h-4v-2l-2-1 2.45-1.86c3.8-3.8 3.33-8.36 3.36-13.45l.08-2.82q.1-3.44.11-6.87"/><path fill="#9c9d9f" d="M83 821h2q.08 2.43.13 4.88l.07 2.74C85 831 85 831 83 833a90 90 0 0 0-1 6h-1q-.08-3.37-.12-6.75l-.06-1.92c-.03-3.64.2-6.24 2.18-9.33m-4 9 2 1-3 4z"/><path fill="#020205" d="M95 786h2v4h-3zm-3 4h2v6l-3 1c-.69 2.06-.69 2.06-1 4h-3c-.19-2.37-.19-2.37 0-5 1.38-1.06 1.38-1.06 3-2 1.19-2.12 1.19-2.12 2-4"/><path fill="#0a0b11" d="M102 722h4c-.54 3.79-2.08 5.6-5 8h-3v3h-4l1-4h2v-3h5z"/><path fill="#503072" d="M364 699c7.66 6.06 13.96 13.36 20 21a21 21 0 0 1-7.31-5.25 73 73 0 0 0-7.5-6.81c-3.26-2.71-4.3-4.83-5.19-8.94"/><path fill="#151318" d="m1295 644 2 1c-6.29 4.43-6.29 4.43-10 5v2c-4.08 3.19-8 3.64-13 4 1.59-3.08 3.36-3.74 6.56-4.87a73 73 0 0 0 12.63-6.27z"/><path fill="#17161c" d="m1329 625 3 1c-6.35 4.94-13.65 10.52-22 10 3.18-4.05 6.92-5.31 11.6-7.05 2.4-.95 2.4-.95 5.34-2.76z"/><path fill="#aa87d6" d="M481 614c10.76-.4 21.32.8 32 2-2.57 2.12-4.33 2.2-7.6 2.05l-2.74-.13-2.85-.17-2.79-.12A70 70 0 0 1 481 615z"/><path fill="#2d1659" d="M1022 567h2l.99 2.96 2 6.05a493 493 0 0 0 3.05 8.92l.84 2.38q.36 1.02.74 2.05c.38 1.64.38 1.64-.62 3.64-3.13-4.55-4.58-9.43-6.19-14.69l-.82-2.62c-1.99-6.4-1.99-6.4-1.99-8.69"/><path fill="#2a282e" d="M884 418c-5.7 5.16-11.49 4.47-18.87 4.25l-3.54-.05q-4.3-.08-8.59-.2v-1l1.94-.15q4.38-.35 8.75-.73l3.05-.23 2.96-.26 2.71-.22C883.2 417.7 883.2 417.7 884 418"/><path fill="#bdc1bf" d="M373 350c2 2 2 2 2.31 4.81-.34 3.54-1.16 5.38-3.31 8.19h-2v-12z"/><path fill="#989898" d="M436 337h1q.08 3.84.13 7.69l.05 2.18c.03 4.18-.26 7.3-2.18 11.13h-2c.78-14.78.78-14.78 3-21"/><path fill="#8d8d8f" d="M470 284h4v12l-4 2z"/><path fill="#2f2e34" d="m794 218-1 3c-1.56.63-1.56.63-3.44 1-3.33.67-3.33.67-4.62 2.55L784 226c-2.62.19-2.62.19-5 0l2-1v-3l-3-1c5.32-1.95 10.29-3 16-3"/><path fill="#878789" d="m481 178 1 3 3-1-.87 1.56c-.98 2.1-1.52 3.9-2.07 6.13L481 191l-3 1q-.12-2.66-.19-5.31l-.1-3c.29-2.69.29-2.69 1.8-4.55z"/><path fill="#403e45" d="M577 165c6.3-.66 10.08 1.04 15 5l1 2-2.16-.47c-8.79-1.79-8.79-1.79-12.84-1.03-3 .5-3 .5-6-1.5h9c-1.1-2.2-1.96-2.76-4-4"/><path fill="#36353c" d="M555 167q2.1.21 4.23.32l2.43.12 2.53.12 8.81.44v1l-3.44.15-4.56.22-2.25.1c-4.53.23-8.66.86-13.02 2.1-2.27.56-4.4.58-6.73.43l1-2h4l1-3c3-1 3-1 6 0"/><path fill="#000003" d="M814 158v4l-13 1 1-4c4.06-.64 7.88-1.12 12-1"/><path fill="#010104" d="m850.63 146.94 4.37.06c-2.5 2.88-2.5 2.88-4.75 3.44l-1.81.12C845 151 845 151 843 153c-1.95.2-1.95.2-4.12.13l-2.2-.06L835 153v-2l1.9-.62 2.48-.82 2.46-.8c5.35-1.87 5.35-1.87 8.78-1.82"/><path fill="#303135" d="M530 74c3.56.61 6.68 1.58 10 3v1q-1.42.45-2.87.94C534 80 534 80 532 81c-2.67.13-5.32.04-8 0z"/><path fill="#b9babb" d="m569.25 58.44 3.27.3L575 59v3h-16v-3c3.55-1.16 6.56-.92 10.25-.56"/><path fill="#000001" d="M1159 22q1.94-.12 3.88-.19l2.17-.1c1.95.29 1.95.29 3.04 1.76L1169 25c2.63.69 2.63.69 5 1v3h-6l-1-3-7-1z"/><path d="M1041 18h14v3l-15 1z"/><path fill="#7f8088" d="M455 1751q1.9.17 3.81.38l2.15.2c2.16.44 3.39.97 5.04 2.42v2l3 1v1c-5.25.66-8.06-.9-12.46-3.71L455 1753z"/><path fill="#605f63" d="M1410 1717c2.13.38 2.13.38 4 1l-1.95.68c-2.05 1.32-2.05 1.32-2.66 3.7l-.14 2.74-.17 2.76-.08 2.12-4 1c-.12 5.75-.12 5.75 1 8l-6 1 1-3h2l-.19-3.31c0-3.49 0-3.49 1.57-5.13l1.62-1.56a61 61 0 0 0 1.47-6.16c.53-1.84.53-1.84 2.53-3.84"/><path fill="#cacbcd" d="m1418 1713 4 1-1 14h-3z"/><path fill="#010006" d="M939 1693h2c1.13 3.38 1.13 6 1.13 9.56v1.79c0 4.4 0 4.4-1.13 6.65h-2z"/><path fill="#4b4b53" d="m462 1626 4.38.81 2.46.46c2.43.82 3.03 1.47 4.16 3.73l-6-1v3l4 2v2l3 1-2 1c-1.5-.87-1.5-.87-3-2v-2l-1.81-.12c-2.19-.88-2.19-.88-3.94-3.82C462 1628 462 1628 462 1626"/><path fill="#a2a2a6" d="m1498 1383 3 1-1.31.69c-3 2.33-4.4 5.47-5.63 9-1.06 2.31-1.06 2.31-3.18 3.62l-1.88.69-1-5 4-1 1-4h3l.44-1.94c.56-2.06.56-2.06 1.56-3.06"/><path fill="#5d0700" d="M1465 1301c2.44 3.65 2.3 6.4 2.25 10.63l-.01 2.16A53 53 0 0 1 1465 1329h-1z"/><path d="M339 1302h3v20h-3z"/><path fill="#593a23" d="M1072 1288h1l.31 3.06c.38 2.82 1.1 5.06 2.32 7.63 1.1 2.36 1.4 3.08 1.06 5.75l-.69 1.56c-2.78-1.51-3.66-2.93-4.75-5.87l-.8-2.06c-.52-2.4-.2-3.76.55-6.07l.56-2.25q.23-.86.44-1.75"/><path fill="#9c958f" d="M1352 1230c-4.53 5.25-10.49 7.08-17 9h-3c3.78-4.18 14.41-11.8 20-9"/><path fill="#8c6b51" d="M1121 1214h19v2h6v1c-8.71.16-17.32-.34-26-1z"/><path fill="#947458" d="m1104.9 1212.66 2.11.05 2.3.03c.39 0 .39 0 2.38.07l2.41.04q2.95.06 5.9.15v1l-3.18.11-4.13.2-2.1.07-2.02.1-1.85.09c-1.72.43-1.72.43-3.1 1.94-2.34 2.15-4.03 1.83-7.16 1.78l-3.25-.03-3.4-.07-3.43-.04q-4.2-.06-8.38-.15v-1l1.97-.04q4.43-.13 8.84-.27l3.1-.07 2.98-.1 2.75-.09c3.3-.6 4.1-3.2 7.26-3.77"/><path fill="#000004" d="M1265 1202c9.04-.17 17.99.37 27 1v1c-9.02.69-17.95 1.15-27 1z"/><path fill="#b0bbc4" d="M1570 1193c-1.75 3.88-1.75 3.88-4 5l-1.94-.62-2.06-.38q-1.53 1.47-3 3c-2.19.19-2.19.19-4 0v6l-7-1v-2l6 1v-5l2.38-.87c2.62-1.13 2.62-1.13 4.62-3.13 3.12-1.48 5.54-2.26 9-2"/><path fill="#46454e" d="M1266 1151h1l1 5h4l1 7h-13v-1l6-1z"/><path fill="#191921" d="M1279 990c1.17 3.41.81 5.42-.31 8.81l-.86 2.65-.83 2.54q-.55 1.96-1.06 3.94a18 18 0 0 1-2.94 6.06c-.28-7.79.77-14.84 4-22h2z"/><path fill="#f7f7f7" d="M1435 938h2v4h-3l.38 2.75c-.38 3.25-.38 3.25-1.65 4.54-4.01 2.71-4.01 2.71-6.73 2.71l1-5h4l-1-3 1-2h2z"/><path fill="#454249" d="M265 923h13v4h-12z"/><path fill="#878687" d="M114 903h3l1 2a40 40 0 0 0 4 2h-6l3 5-5-1-1-3-5-1-1-3 8 2z"/><path fill="#c8cfd3" d="m1484 866 2 1v8l4-1v5l-4 1-.31-1.87c-.8-2.45-1.47-2.97-3.69-4.13.88-6.87.88-6.87 2-8"/><path fill="#6e6d70" d="M143 850a32 32 0 0 1 3.5 6.38l.78 1.84a115 115 0 0 1 1.72 4.6c1.11 2.43 1.82 2.78 4 4.18 2.04 2.95 3 4.37 3 8-13.27-14.23-13.27-14.23-13.31-21.5q.11-1.76.31-3.5"/><path fill="#f4f4f6" d="M1465 854h1v14l-4 1c-.56-10.73-.56-10.73 3-15"/><path fill="#08090e" d="m1451 844 2 1c1.4 6.26 1.1 12.62 1 19h-3z"/><path fill="#48454b" d="M145 848a37 37 0 0 1 4 6h2l1 4h-2l1 8c-3.51-1.76-4.74-5.4-6-9-.3-3.04-.25-5.96 0-9"/><path fill="#5c5c60" d="M1174 827c-6.43 3.75-18.07 6.95-25.33 5.55L1147 832c5.35-2 10.75-3.5 16.31-4.75l1.82-.42c3.26-.7 5.7-.99 8.87.17"/><path fill="#7b7c7e" d="m1191 825 3 1 1 3c5.06-.42 9.29-1.04 14-3 2.81-.12 2.81-.12 5 0l-4 2-2.5 1.5c-4.76 2.43-10.27 2.78-15.5 3.5z"/><path fill="#010005" d="M772 754h16l-1 3h-18l3-1z"/><path fill="#613c9e" d="M723 750a1727 1727 0 0 1 15.31-.15q2.8-.04 5.59-.05l3.4-.03c2.7.23 2.7.23 4.7 2.23-2.82 1.61-4.75 2.23-8 2v-2h-20z"/><path fill="#010103" d="M1523 742h5v4l-1.94.88C1524 748 1524 748 1523 750h-6l1-4h4z"/><path fill="#010104" d="M173 722c.38 1.75.38 1.75 0 4a17 17 0 0 1-6 4c-2.87-.25-2.87-.25-5-1v-2l4-1v-3c2.46-1.23 4.28-1.07 7-1"/><path fill="#1b1b20" d="M1411 696c-4.55 4.55-14.2 5.19-20.5 5.38-3.88-.12-6.4-.9-9.5-3.38 3-1 3-1 6 0 5.66.43 10.74.01 16.17-1.55 2.67-.66 5.1-.59 7.83-.45"/><path fill="#010103" d="M216 694v4l-4.37 2.5-2.47 1.4C207 703 207 703 205 703l-1-4 6-1v-3c2.22-1.11 3.56-1.08 6-1"/><path fill="#000002" d="M362 657c.24 7.62-.16 14.59-2 22h-1v-21c2-1 2-1 3-1"/><path fill="#17171e" d="M1416 648c0 3 0 3-1.83 5.16l-2.42 2.34-2.4 2.34A50 50 0 0 1 1403 663c-2.06-.31-2.06-.31-4-1h-3l8-7 2 1-2 3c8.45-7.3 8.45-7.3 12-11"/><path fill="#010105" d="M290 651h6c-.06 1.81-.06 1.81-1 4a18 18 0 0 1-12 3v-3l7-1z"/><path fill="#241337" d="M370 642c-.57 5.35-1.2 10.68-2 16h-2l-1 3c-.3-14.33-.3-14.33 2.5-17.94C369 642 369 642 370 642"/><path fill="#b8b9b8" d="m199 639 1 2c2.06.63 2.06.63 4 1l-1 4h-5l1-4h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#010103" d="m1659 627 4 1-2 4h-2l-1 3-2 1-1 3h-4v-4l3-1 1-3 3-1z"/><path fill="#b4acc7" d="M675 585h22l-2 4h-12l4-2-12-1z"/><path fill="#030208" d="M1295 570c-1.61 4.84-5.96 6-10.31 8.19L1281 580c0-3 0-3 1.8-4.82l2.39-1.87 2.35-1.88c2.76-1.6 4.33-1.72 7.46-1.43m-17 10 3 1Z"/><path fill="#302f35" d="M509 539q5.17.15 10.35.32l5.24.15 3.36.12 3.07.1c3.3.34 5.89 1.16 8.98 2.31v1c-10.46-.45-20.69-1.12-31-3z"/><path fill="#bcbdbd" d="m336 533 2 1v16l-3-1c-.97-5.2-1.14-9.8 0-15z"/><path fill="#212027" d="m704 534.94 2.35.01 5.65.05v1l-11.56 1.5-3.3.43c-5.42.7-10.67 1.26-16.14 1.07v-1h6v-2c5.72-.83 11.23-1.13 17-1.06"/><path fill="#422a69" d="m976.14 520.41 1.86.59c-6.68 4.47-12.06 5.67-20 6q-2.6.26-5.19.56l-2.23.26-1.58.18v-1c5.27-1.57 10.38-3.06 15.88-3.5 4.14-.34 7.48-3.82 11.26-3.09"/><path fill="#010105" d="M1133 450c2.19-.25 2.19-.25 4 0v4c-5.23 3.04-8.93 4.5-15 4l1-3 3.31-.25c3.46-.46 3.46-.46 5.38-2.81z"/><path fill="#000001" d="M1577 410h15v4l-14-1z"/><path d="M1529 398h17v3h-16z"/><path fill="#8c8e8e" d="M371 376h1c.47 15.44.47 15.44-2 21l-3 1c-.69 2.06-.69 2.06-1 4q-.12-2.44-.19-4.87l-.1-2.75c.29-2.38.29-2.38 1.74-3.51L369 390c1-3 1.3-5.54 1.56-8.69l.26-3z"/><path fill="#2e2f32" d="M434 320c.63 1.75.63 1.75 1 4l-.95 1.67c-1.48 3.27-1.43 6.33-1.55 9.9A115 115 0 0 1 430 355h-1l-.06-5.87-.04-3.31c.1-2.82.1-2.82 1.1-4.82q.3-4.61.43-9.24c.22-4.65 1.23-7.72 3.57-11.76"/><path fill="#1f1e24" d="M859 325c1.3 3.58.7 5.44-.81 8.88A120 120 0 0 0 854 345h-2l-1 5c-1.04-2.69-1.07-3.8-.07-6.55l1.38-2.76A63 63 0 0 0 857 328h2z"/><path fill="#000001" d="M1335 320h3l1 7 3 1v7h-3l-1-7-3-1z"/><path fill="#f4f5f4" d="M381 302c2 2 2 2 2.05 3.84l-.3 2.1-.27 2.09A10.5 10.5 0 0 1 380 315h-2v-12z"/><path fill="#08090f" d="M402 245h3l.78 8.79c.21 3.08.26 6.12.22 9.21h-2c-2.33-3.64-2.24-7.08-2.12-11.25l.02-1.97z"/><path fill="#000003" d="m463 192 3 1c.25 2.81.25 2.81 0 6l-4 3c-.25 3.19-.25 3.19 0 6l-4 1q.14-2.2.31-4.37l.18-2.47c.51-2.16.51-2.16 2.01-3.25l1.5-.91c.69-3.12.69-3.12 1-6"/><path fill="#48474c" d="m1235 94 2 2c2.63.63 2.63.63 5 1l-2 1 1 7h5v8h2v6h-1v-5l-4-2v-5l-4-1-1-7-3-1z"/><path fill="#f7f7f6" d="m1194 26 4 1v3h-5c1.61 2.7 3.22 5.4 5 8q-2.5-.98-5-2l-2.25-.87c-1.75-1.13-1.75-1.13-2.5-3.26L1188 30l1.88-.31c2.44-.8 2.96-1.47 4.12-3.69"/><path fill="#09090f" d="m1027 22 1 4h10v3l-12 1 1-4h-7v-1l2.94-.94C1026 23 1026 23 1027 22"/><path fill="#8e9092" d="M1054 0h25v2h-25z"/><path fill="#37383b" d="M389 1766c2 1.38 2 1.38 4 3v2l4 2v2l4 2v3h-3v-2l-4 1-.81-2.69c-1-3.05-2.52-5.57-4.19-8.31z"/><path fill="#3f3f48" d="M1047 1766h17l-1 3h-16z"/><path fill="#050507" d="M382 1753h3l1 5h3a30 30 0 0 1 1 5l-2 2-3-1c-1.12-2.5-1.12-2.5-2-5l-1-1q-.06-2.5 0-5"/><path fill="#3f3e47" d="m937 1729 1 2-4 5 3 1c-4.48 4.73-8.33 8.05-15 9l2.25-1.75A70 70 0 0 0 931 1738l-2-1 1.24-.99 3.25-2.62A19 19 0 0 0 937 1729"/><path fill="#030208" d="M936 1719h1v5l-3 1-.37 1.81c-.96 3.35-2.7 4.43-5.63 6.19-3.31-.25-3.31-.25-6-1v-1l2.27-.26c3.29-.9 4.29-2 6.42-4.62l1.76-2.11c1.55-2.01 1.55-2.01 2.7-3.77z"/><path fill="#3a3842" d="M439 1714h3v6l3 1 1 5-3 1-2-1v-5l-3-1q-.06-2.5 0-5z"/><path fill="#3a3a3e" d="M1414 1694h1q.08 1.94.13 3.88l.07 2.17c-.2 1.95-.2 1.95-2.2 3.95-.23 2.1-.23 2.1-.19 4.5l.02 2.4c.17 2.1.17 2.1 1.17 4.1-1.4 2.78-3.16 2.98-6 4 .88-4.87.88-4.87 2-6 .28-3.07.45-6.14.62-9.21.38-2.79.38-2.79 2.38-4.79.63-2.62.63-2.62 1-5"/><path fill="#110508" d="m1424 1386 2 1c-3.1 5.6-3.1 5.6-6 7-2.7.2-2.7.2-5.69.13l-3-.06-2.31-.07 4-1v-2l1.4-.59 1.85-.79 1.93-.82c1.82-.8 1.82-.8 4.13-2.05z"/><path fill="#d3bfad" d="m1342 1362 2 1v6l1.69.69c3.5 1.99 6.48 4.66 8.31 8.31l-7-1-1-3-1.52-.87c-1.48-1.13-1.48-1.13-2-3.51l-.17-2.74-.2-2.76z"/><path fill="#d4521a" d="m1381 1250-1.93 1.02-2.5 1.36-2.5 1.33C1372 1255 1372 1255 1371 1257l-2.06 1.13c-4.23 2.7-7.48 6.26-10.94 9.87 0-3 0-3 2.15-5.41q1.38-1.35 2.79-2.65c4.95-4.73 4.95-4.73 6.06-6.94 2.21-1.1 2.21-1.1 4.94-2.19l2.71-1.1c2.35-.71 2.35-.71 4.35.29"/><path fill="#74563f" d="m964 1236 4 1-3 1-1 3 3.37-.07c8.1-.12 15.74-.1 23.63 2.07v1a433 433 0 0 1-5.96.1c-2.04-.1-2.04-.1-5.04-1.1q-2.66-.11-5.32-.1l-3.12.01-3.25.03-3.28.01-8.03.05a42 42 0 0 1 5-3c1.13-2.06 1.13-2.06 2-4"/><path fill="#030304" d="M1054 1229q-.17 2.4-.37 4.81l-.22 2.71a10 10 0 0 1-2.41 5.48l-3-1c.35-8.33.35-8.33 3-11.06 2-.94 2-.94 3-.94"/><path fill="#15141c" d="M656 1188a942 942 0 0 1-9.5 3.5l-2.69 1.01c-5.53 2-9.93 2.87-15.81 2.49 4.24-4.24 10.62-4.53 16.32-5.44 2.68-.56 2.68-.56 5.27-1.68 2.73-1 3.7-.7 6.41.12"/><path fill="#14141c" d="M943 1123c8.3.35 15.14 2.2 22 7a601 601 0 0 1-19-3l-1-3z"/><path fill="#2a2932" d="m917 1116 9 1v2l1.93.59 2.5.79 2.5.77c2.07.85 2.07.85 3.07 2.85-2.35 1.43-3.48 2.09-6.25 1.63L928 1125l2-2-13-6z"/><path fill="#08090e" d="M1067 1042c2.63-.22 5-.28 7.63-.19l2.14.04 5.23.15 1 3h-18z"/><path fill="#f8f9f9" d="M335 1041c1.94 1.38 1.94 1.38 3 3v2h-11l-1-4c2.91-1.46 5.83-1.43 9-1"/><path fill="#000001" d="m1148.19 1037.94 6.81.06v3h-18c2.1-4.2 7.08-3.12 11.19-3.06"/><path fill="#c7c7c7" d="M206 1017c4.98-.3 7.9-.22 12 3v2h-11z"/><path fill="#95a1ab" d="M1532 1007h3l1 3 1.56 1.5c1.44 1.5 1.44 1.5 1.44 4.5l3 1v3c-3 0-3 0-5-1.69-4.86-5.62-4.86-5.62-5.19-9.62z"/><path fill="#000002" d="M1402 1008h2v3h2l1 12-4-1c-.94-3.16-1.1-5.97-1.06-9.25l.02-2.7z"/><path fill="#040406" d="M1229 979h1v9l-4 2c-.75 2.13-.75 2.13-1 4l-1-3-3 3-2-1 2.31-2.19c3.46-3.45 5.58-7.43 7.69-11.81"/><path fill="#000001" d="M298 974h18v3c-12.67.18-12.67.18-18-1z"/><path fill="#7b7c7e" d="M623 967v1q-4.69.76-9.37 1.5l-2.64.43A97 97 0 0 1 593 971v-1c10.14-2.21 19.62-3.44 30-3"/><path fill="#a1a0a1" d="M290 963q4.69-.04 9.38-.06l2.69-.03h2.58l2.38-.02C309 963 309 963 311 964v2c-7.29.25-13.9-.28-21-2z"/><path fill="#141418" d="M230 958q4.06.43 8.13.88l2.3.23c4.45.5 8.38 1.3 12.57 2.89-3.94 1.37-7.67.9-11.75.56L234 962l-1 2-.37-1.87C232 960 232 960 230 958"/><path fill="#eeeff0" d="M1423 954h2v6l-2 1q-1.1 2.97-2 6h-3c-.63-5.18.74-7.67 3.71-11.68z"/><path fill="#929294" d="M612 959h12l-1 3h-28v-1l17-1z"/><path fill="#e6e5e3" d="M1235 945c.12 5.13-.16 9.94-1 15l-3 1q-.12-3.15-.19-6.31l-.07-1.8c-.05-2.85-.03-4.5 1.7-6.8C1234 945 1234 945 1235 945"/><path fill="#3f3e43" d="m724.88 933.94 7.12.06v1l-2.75.37-3.69.5-1.93.27c-4.33.62-8.54 1.45-12.76 2.6-2.2.3-3.76-.08-5.87-.74 6.55-3.59 12.5-4.16 19.88-4.06"/><path fill="#8d9ba4" d="M1522 926v18l-3-1q-.08-3.4-.12-6.81l-.06-1.95c-.05-4.78-.05-4.78 1.13-7.19C1521 926 1521 926 1522 926"/><path fill="#000103" d="M125 922h5l1 3h5l2 4h-7l-1-3-5-1z"/><path fill="#a3a2a3" d="M123 911c2.88-.19 2.88-.19 6 0a18 18 0 0 1 2 3 38 38 0 0 0 5 2v3c-4.86-.6-8.74-2.73-13-5z"/><path fill="#868687" d="m922.63 903.94 2.47.02 1.9.04a24 24 0 0 1 2 4l-1 3-9 1c2.92-1.95 4.63-2.45 8-3v-2h-11v-2c2.4-1.2 3.95-1.1 6.63-1.06"/><path fill="#878688" d="m1120.29 851.9 2.77.04 2.79.02 2.15.04c-1 3-1 3-2.9 3.95-7.86 2.46-7.86 2.46-12.1 2.05l2-1a90 90 0 0 0 2-4c1-1 1-1 3.29-1.1"/><path fill="#050409" d="m1272 809-2.94.88C1266 811 1266 811 1265 813l3 1h-5l-1 2v-2h-8c4.03-4.03 12.56-7.72 18-5m-12 7 2 1Z"/><path fill="#b9babb" d="m12 794 2 1v8l4-1v5l-4 1-.31-1.87c-.8-2.45-1.47-2.97-3.69-4.13.88-6.87.88-6.87 2-8"/><path fill="#878687" d="M1317 780h7c-2.62 2.2-4.65 2.18-8 2zm-6.37 1.9 5.37.1v4l-10 1c2.48-4.97 2.48-4.97 4.63-5.1"/><path fill="#38383b" d="M346 769c1.81-.19 1.81-.19 4 0 1.5 1.38 1.5 1.38 3 3a40 40 0 0 0 4 2v2l1.81.88c3.59 1.84 3.59 1.84 5.19 3.12v2c-1.87-.25-1.87-.25-4-1l-.94-2-1.06-2a88 88 0 0 0-5-1c-1.56-1.44-1.56-1.44-3-3-2.19-1.19-2.19-1.19-4-2z"/><path fill="#040408" d="M1382 762v3l-8 3v-2c-5.75 1.75-5.75 1.75-8 4-2.12-.37-2.12-.37-4-1 6.13-3.96 12.52-7.77 20-7"/><path fill="#020205" d="M137 747h3v2h-3l-.19 2.25c-1.07 3.64-2.63 4.75-5.81 6.75-2.31.25-2.31.25-4 0v-4h4l1-3 4-1z"/><path fill="#242329" d="m1158 736-1.62.7a186 186 0 0 0-12.38 5.92 46 46 0 0 1-14 4.38c1.52-2.7 1.8-2.94 5-3.87l1.87-.47c2.56-.8 4.8-1.93 7.2-3.16 9.61-4.94 9.61-4.94 13.93-3.5"/><path fill="#090316" d="M917 724c-2.68 2.68-4.59 2.53-8.31 3a41 41 0 0 0-11.69 3q-2.26.66-4.56 1.19l-2.26.54c-2.35.3-3.94-.01-6.18-.73 3.68-1.66 7.27-2.54 11.2-3.36q3.3-.75 6.58-1.7.99-.26 2.01-.56 2-.56 3.98-1.15c3.4-.95 5.88-1.55 9.23-.23"/><path fill="#1e1f22" d="m1498 692 2 1a21 21 0 0 1-5.25 4.31 35 35 0 0 0-6 4.69c-3.82 3.4-7.86 4.74-12.75 6 2.25-3.44 4.43-5.02 8-7q7.1-4.35 14-9"/><path fill="#4b2d80" d="M1050 670h4c-.19 2.88-.19 2.88-1 6-3.57 2.24-6.87 2.2-11 2v-2l1.5-.8 1.94-1.08 1.93-1.05C1049 672 1049 672 1050 670"/><path fill="#16141a" d="M1354 606c2.12.4 2.12.4 4 1-4.16 3.7-8.31 7-13 10l-3.37 2.25L1339 621l-2-1c4.16-3.77 8.14-7.16 13-10l2.06-2.25z"/><path fill="#af9dc8" d="M678 595h17v2c-3.07 1.53-6.27 1.1-9.62 1.06L678 598z"/><path fill="#000001" d="M350 536h4l-1 14h-3z"/><path fill="#e7e7e7" d="M357 462c2 2 2 2 2.31 5.44-.07 2.55-.17 3.36-1.69 5.5C356 474 356 474 354 474v-11z"/><path fill="#0d0c12" d="M1561 444c4.95.38 9.12 1.3 13.71 3.18 3.83 1.37 7.79 2.19 11.75 3.07 2.54.75 2.54.75 4.54 2.75l-9-1v-2l-2.34-.11c-12.22-.77-12.22-.77-17.66-3.89z"/><path fill="#030108" d="M1221 407c-1.14 3.41-2.04 4.12-5 6h-2l-1 3a99 99 0 0 1-5 3l-2 2c-2.12-.37-2.12-.37-4-1l3.31-2.5 1.87-1.4c1.82-1.1 1.82-1.1 4.82-1.1l.81-2.31c1.88-4.26 3.37-6.19 8.19-5.69"/><path fill="#4b4c4e" d="M359 392h3v15l-4-1z"/><path fill="#0a0a10" d="M379 378c1.85 2.38 2.29 4.07 2.45 7.05l.13 2.37.1 2.45.15 2.44c.2 4.07.24 7.71-.83 11.69h-1l-1-15h-1l-.06-4.94-.04-2.77c.1-2.29.1-2.29 1.1-3.29"/><path fill="#030209" d="M882 283h3c.19 2.31.19 2.31 0 5q-1.47 1.53-3 3l-.94 3.13L880 297c-2.07.68-2.07.68-4 1a263 263 0 0 1 6-15"/><path fill="#a9a6ab" d="M1317 262q2.51.43 5 1v8q-2.5.06-5 0c-1-1-1-1-1.13-2.6v-3.8c.13-1.6.13-1.6 1.13-2.6"/><path fill="#0c0b12" d="M924 216c.76 1.64.76 1.64 1 4a44 44 0 0 1-4.19 5.69l-1.21 1.5c-2.3 2.76-3.95 4.45-7.6 4.81 1.6-3.7 4-6.62 6.56-9.69l1.25-1.5c2.97-3.6 2.97-3.6 4.19-4.81"/><path fill="#8e9092" d="M408 203c.1 5.37.1 5.37 0 7-1 1-1 1-3.56 1.06L402 211v-8c3-1 3-1 6 0"/><path fill="#36333a" d="M585 170c3.07-.28 4.6-.25 7.24 1.43l2.07 1.88 2.12 1.87C598 177 598 177 598 180h-3l-1-2-3-1c-.69-2.06-.69-2.06-1-4h-5z"/><path fill="#f3f2f3" d="m1258 113 4 2c-.37 1.94-.37 1.94-1 4l-2 1a78 78 0 0 0-2 5l-4-2v-5l5 1z"/><path fill="#000001" d="M857 86h14l-1 3-14 1z"/><path fill="#b8b9bc" d="M871 74h8q.06 2.5 0 5c-1 1-1 1-2.63 1.1L871 80c-1-3-1-3 0-6"/><path fill="#090a0f" d="M1057 18h14v2h7v1h-21z"/><path fill="#a7a6aa" d="m1381 1767 4 1-1.69.56c-2.37 1.48-3.16 2.48-4.68 4.75a30 30 0 0 1-7.07 7.36c-2.87 2.45-5.19 5.41-7.56 8.33l-1-4 1.82-.8c2.31-1.27 3.48-2.46 5.12-4.51a75 75 0 0 1 5.75-6.38c2-2 3.68-4 5.31-6.31"/><path fill="#0f0f16" d="M488 1769q1.8-.09 3.63-.12l2.03-.08c2.34.2 2.34.2 4.9 1.17 4.75 1.7 9.83 1.5 14.81 1.65l3.1.12q3.76.14 7.53.26v1c-26.18.62-26.18.62-36-3z"/><path fill="#15151b" d="M1311 1693c1.46 4.6.62 8.53-1 13-2.06 1.88-2.06 1.88-4 3l-1 3h-2c1.56-6.85 4.77-12.82 8-19"/><path fill="#000001" d="M358 1697h2l1 4.69.56 2.63c.43 2.62.52 5.04.44 7.68h-4z"/><path fill="#353639" d="M342 1667v15h-3q-.33-2.9-.62-5.81l-.36-3.27c-.02-2.92-.02-2.92.83-4.8C340 1667 340 1667 342 1667"/><path fill="#3a3a40" d="m471 1628 1.6.84c2.85 1.38 5.73 2.58 8.65 3.79L487 1635v2l9 1v1c-6.9.51-11.75-1.13-18-4v-2l-1.81-.25c-2.79-.95-3.62-2.32-5.19-4.75"/><path fill="#84838b" d="M425 1432h1v25h-1l-1-8h-1c-.22-6.1-.01-11.22 2-17"/><path fill="#d14308" d="M1397 1305h1l-.22 2.32c-.91 10.9-.88 21.75-.78 32.68-2.04-3.38-2.26-5.99-2.27-9.9v-5.24q.01-2.68 0-5.37v-3.45l.01-3.15c.27-2.99 1-5.18 2.26-7.89"/><path fill="#b3b2b5" d="M1526 1291h3v16h-3z"/><path fill="#b6bfc7" d="m1585 1173 4 2c-2.73 3.74-4.97 5.64-9 8a80 80 0 0 0-9 10c.5-4.4 2.02-6.75 5-10l2-1c1.13-2.06 1.13-2.06 2-4h5z"/><path fill="#34323d" d="M685 1179v2l2 1-5 3v-3l-1.83.7-2.42.86-2.4.88c-2.54.6-3.9.4-6.35-.44 5.5-3.5 9.4-5.7 16-5"/><path fill="#16171f" d="m1104.44 1166.94 2.5.01 6.06.05v1l-1.47.17-6.65.77-2.31.26c-3.3.38-6.55.8-9.82 1.36-2.6.42-5 .54-7.62.5l-4.13-.06v-1h7v-2a90 90 0 0 1 16.44-1.06"/><path fill="#908e94" d="M1446 1142h3v16h-3z"/><path fill="#464949" d="M214 1015h9v4h7v1l-3.19.44q-4.43.63-8.81 1.56l-.31-1.81C217 1018 217 1018 214 1015"/><path fill="#989899" d="M983 996a94 94 0 0 1 10.63 4.88l2.85 1.49a16.6 16.6 0 0 1 5.52 5.63c-4.2-.55-6.68-1.92-10.06-4.44-2.9-2.14-5.66-4.05-8.94-5.56z"/><path fill="#cfcece" d="M1213 952c0 3 0 3-2.31 5.5l-1.4 1.3c-1.29 1.2-1.29 1.2-2.66 3.01-2.25 1.64-4.17 1.3-6.86 1.16-2.5.04-4.47 1.1-6.77 2.03-2.14-.44-2.14-.44-4-1 9.56-4.14 9.56-4.14 13.81-4.44 4.3-.76 5.39-2.28 8.19-5.56z"/><path fill="#dadad9" d="m1222 944 2 1-1.6 1.76c-5.25 5.8-9.44 11.5-13.4 18.24l-2-1c7.99-15 7.99-15 15-20"/><path fill="#6f6e70" d="m1123 949-2.05.52a99 99 0 0 0-14.26 4.67c-3.83 1.6-6.57 2.24-10.69 1.81 3.24-2.77 6.3-3.92 10.38-5.12l3.33-1.01c8.97-2.37 8.97-2.37 13.29-.87"/><path fill="#8b8a8c" d="M351 945c8.82.42 17.4.87 26 3v1c-8.99.28-17.9.38-26-4"/><path fill="#7d7e80" d="M165 935c4.59.67 8.93 1.75 13.38 3.06l1.94.57L185 940v2l5 1c-4.2 1.78-7.83.14-11.94-1.19l-2.13-.63c-4.43-1.4-7.5-3.04-10.93-6.18"/><path fill="#5b5c60" d="m817.44 934.94 2.06.02 1.5.04v1l-1.73.33q-7.49 1.47-14.9 3.3A48 48 0 0 1 790 941v-1l7.56-1.94 2.14-.55c5.87-1.49 11.64-2.68 17.74-2.57"/><path fill="#535157" d="M260 925c18.34 1.62 18.34 1.62 27 6v1h-7v-2l-2.6.09a42 42 0 0 1-9.65-1.47l-3.14-.77C262 927 262 927 260 925"/><path fill="#353339" d="m814.69 914.94 3 .02 2.31.04v2c-3.77 1.75-6.67 2.22-10.81 2.13l-2.96-.06L804 919c2.51-4.44 5.96-4.12 10.69-4.06"/><path fill="#464549" d="M1019 889c-5.4 2.32-10.66 3.58-16.46 4.53-2.54.47-2.54.47-5.85 1.6-3.02.98-5.53 1.02-8.69.87v-1l10.13-2.94 2.88-.84c13.32-3.84 13.32-3.84 17.99-2.22"/><path fill="#c6c6c9" d="M1461 881h1c.3 3.87-.11 6.65-1.44 10.31-1.63 4.54-2.23 8.9-2.56 13.69h-1q-.08-3.81-.12-7.62l-.06-2.17c-.04-4.52.39-8.03 2.18-12.21z"/><path fill="#6b6a6c" d="M1008 877h-3v2l-2.63.8-3.5 1.08-1.72.52-1.72.53-1.73.53q-2.5.8-4.98 1.7c-2.81.87-4.02.88-6.72-.16l6-1v-2q3.87-1.3 7.75-2.56l2.21-.75 2.15-.7 1.97-.64c2.25-.41 3.75-.04 5.92.65"/><path fill="#babcbc" d="m1 842 3 1q-.17 2.63-.37 5.25l-.22 2.95A17 17 0 0 1 1 858H0l-.06-7.44-.03-2.14q0-2.7.09-5.42z"/><path fill="#1b1c1f" d="M1462 830h1c1.17 3.99 2.26 7.9 3 12l-2.44 1.13c-2.56 1.87-2.56 1.87-3.37 5.06L1460 851h-1c-.1-4.47-.1-8.63 1-13h2z"/><path fill="#1c1c1f" d="M726 818v1c-9.95 2.5-19.82 2.2-30 2v-1c10.06-1.35 19.84-2.32 30-2"/><path fill="#4c4c50" d="M1220 807v2l-1.68.52-7.57 2.36-2.64.81-2.56.8-2.35.73c-2.2.78-2.2.78-4.23 1.94-2.42 1.03-3.54.7-5.97-.16l8.69-3.37 2.47-.97 2.41-.93 2.34-.9q2.52-1.01 5.03-2.05c2.17-.82 3.76-.95 6.06-.78"/><path fill="#89888a" d="M1297 782c-1 3-1 3-2.38 3.95-4.43 1.88-7.81 2.34-12.62 2.05v-2c9.23-4.6 9.23-4.6 15-4"/><path fill="#06070c" d="M502 770h16l-1 3h-15z"/><path fill="#949496" d="m141 752 2 1-2 4c3-1 3-1 5-3v4h-4l-1 4h-3l1-4-2.37 1.56L134 761l-2-1z"/><path fill="#613f9d" d="M779 743h16v2c-3.07 1.53-6.27 1.1-9.62 1.06L778 746z"/><path fill="#422a6b" d="M852 735v1l-1.8.37-2.39.5-2.35.5c-2.46.63-2.46.63-4.92 1.67-2.73 1.03-5 1.33-7.91 1.52-4.25.32-8.42.79-12.63 1.44 3.86-2.93 8.55-3.53 13.19-4.62l2.79-.7A56 56 0 0 1 852 735"/><path fill="#2c2c30" d="m1465 712 2 1c-2.4 2.65-4.93 4.2-8 6l-2.18 1.56-2.38 1.69-2.26 1.64c-2.18 1.11-2.18 1.11-4.46.78L1446 724l1.4-.73c2.89-1.59 5.29-2.95 7.6-5.33 2.33-2.26 4.51-3.18 7.51-4.33C1464 713 1464 713 1465 712"/><path fill="#3b2368" d="M1048 647c2.7 1.64 3.85 2.58 4.94 5.6l.62 3.02c1.02 4.65 2.27 9 3.99 13.44.54 2.3.18 3.72-.55 5.94h-2l-.33-2.27c-1.05-6.8-2.5-13.21-4.67-19.73l-.64-1.95q-.66-2.03-1.36-4.05"/><path fill="#603e86" d="M371 640c1.49 3.92.52 7.37-.37 11.31l-1.25 5.77c-.59 2.96-.99 5.93-1.38 8.92h-2l-1 3a433 433 0 0 1-.1-5.96c.1-2.04.1-2.04 1.1-5.04h2l-.19-2.81c-.03-5.27 1.67-10.19 3.19-15.19"/><path fill="#08080e" d="m1425 644 2 1a258 258 0 0 1-21 19l-2-1 1.18-1.07 5.26-4.8 1.85-1.69 1.77-1.62q.8-.73 1.63-1.5C1417 651 1417 651 1418 649h2v-2c2.38-1.69 2.38-1.69 5-3"/><path fill="#0b0619" d="m1190 622 4 1q-4.5 2.46-9 4.88l-2.54 1.39A54 54 0 0 1 1167 635c1.3-2.6 2.53-2.92 5.15-4.16l2.55-1.22 2.67-1.25 2.66-1.27c6.59-3.1 6.59-3.1 9.97-3.1z"/><path fill="#000001" d="M719 571h18c-4.28 3.2-6.25 3.39-11.25 3.19l-1.97-.04q-2.4-.07-4.78-.15z"/><path fill="#3a216a" d="M1005 524c4.91 4.91 6.19 13.36 7.5 20 .5 1.99 1.2 3.52 2.19 5.31 1.47 3.02 1.28 3.64.31 6.69a86 86 0 0 1-7.32-20.27A57 57 0 0 0 1005 528c-.12-2.31-.12-2.31 0-4"/><path fill="#444248" d="m455.67 525.89 6.9.05 2.44.01 5.99.05 1 2-1.93.18-2.5.26-2.5.24C463 529 463 529 462 530c-3.17.34-4.6.26-7.31-1.5L453 527c1-1 1-1 2.67-1.11"/><path fill="#646368" d="M405 510h2l1 5 1.55.48A221 221 0 0 1 429 523c-3.62 1.14-5.34.66-8.79-.97-2.82-1.32-5.7-2.48-8.58-3.65-5.5-2.26-5.5-2.26-6.63-3.38q-.06-2.5 0-5"/><path fill="#232527" d="M357 492h1c.26 15.22.26 15.22-1 21l-2 1c-.62 3.06-.62 3.06-1 6h-1q-.09-2.06-.12-4.12l-.08-2.33c.24-3.08 1.2-5.62 2.2-8.55q.63-3.43 1.13-6.87l.26-1.8z"/><path fill="#0f0f11" d="m1723 496 .3 1.92.7 2.08a31 31 0 0 0 6 2l-1 5h-2l-1 2c-1.49-.65-1.49-.65-3-2-.3-2.6-.3-2.6-.19-5.62l.08-3.04z"/><path fill="#120a24" d="m1054 492-3.87 1.5-2.18.84c-1.95.66-1.95.66-3.95.66v2q-2.62 1.05-5.25 2.06l-2.95 1.16c-2.84.8-4.1.8-6.8-.22 19.04-9.77 19.04-9.77 25-8"/><path fill="#222128" d="m973 466 2 1c.31 2.81.31 2.81 0 6-3.53 2.65-7.05 3.7-11.31 4.69l-3.24.76-2.45.55c1.27-1.98 1.27-1.98 3-4 2.44-.52 4.47-.2 7 0v-2l5-1z"/><path fill="#352061" d="M1308 417c2 0 2 0 3.88 1.38 4.08 5.04 6 11.28 7.12 17.62-2.47-2.12-3.73-4.32-5.1-7.25-1.43-2.8-3.19-5.39-4.91-8.02-.99-1.73-.99-1.73-.99-3.73"/><path fill="#979798" d="M432 362h1v17l-3 1q-.08-3.66-.12-7.31l-.06-2.1q0-.99-.02-2.02l-.03-1.85c.27-2.05.93-3.14 2.23-4.72"/><path fill="#9b999d" d="M1369 366c3.89 1.58 6.16 4 9 7l-1 3h-5c-3.43-6.29-3.43-6.29-3-10"/><path fill="#08090f" d="M387 329h2q.34 4.19.63 8.38l.19 2.4.16 2.3.16 2.13C390 346 390 346 388 348l-1-6h-1l-.06-5.94-.04-3.34c.1-2.72.1-2.72 1.1-3.72"/><path fill="#f5f6f5" d="M389 262c2 2 2 2 2.31 4.88-.34 3.4-.92 4.73-3.31 7.12h-2v-11z"/><path fill="#36353a" d="M1292 216h5v8l5 1-.62 3.31c-.52 3.46-.52 3.46.62 5.38l1 1.31c-.37 2.19-.37 2.19-1 4-3.1-4.26-3.3-6.92-3-12l-4-1v-8l-3-1z"/><path fill="#797a7d" d="M510 197c1 3 1 3 .18 4.73l-1.3 1.9c-2.88 4.33-5.4 8.8-7.88 13.37-.98-3.38-.86-5.57 0-9h-2v-2h2.69c4.4-1.33 5.26-3.4 7.43-7.29z"/><path fill="#040407" d="m651 130-1 3-7 1-1 3-3-1 1-2-2.31 1c-2.69 1-2.69 1-5.69 1 1.35-1.82 2.4-2.87 4.65-3.35q1.86-.22 3.72-.4c1.63-.25 1.63-.25 3.2-1.31 2.33-1.53 4.72-1.06 7.43-.94"/><path fill="#fbfbfb" d="M664 70h13l1 4q-2.65.08-5.31.13c-.5 0-.5 0-3 .07-2.64-.2-3.8-.36-5.69-2.2z"/><path fill="#7c7a84" d="M1311 1625h1l1 8h1q.13 4.19.19 8.38l.07 2.4.08 4.43-.34 1.79-3 2z"/><path fill="#292933" d="M479 1557h1v23h2l1.5 4.38.84 2.46c.66 2.16.66 2.16.66 4.16h-2c-4.21-7.34-4.5-13.73-4.25-22.06l.05-3.5q.08-4.2.2-8.44"/><path fill="#0d0d14" d="M1256 1477c2.54.07 5.03.2 7.56.38l2.2.14c4.78.34 9.5.79 14.24 1.48v1l-2-.02-8.94-.04-3.15-.03-5.78-.02c-2.13.11-2.13.11-3.13 1.11a55 55 0 0 0-.56 3.56l-.44 3.44h-1l-.1-7.71c.1-2.29.1-2.29 1.1-3.29"/><path fill="#000005" d="m1265 1474 16 1 1 3c-6.11.1-11.97.07-18-1z"/><path fill="#86868a" d="M1506 1369h4l1 3a18 18 0 0 0 3 2l-1 7h-3v-8l-4 1z"/><path fill="#2b1c11" d="M1212 1371q3.85.17 7.69.38l2.18.09c4.13.23 7.32.79 11.13 2.53q3 .58 6 1v1c-9.73.65-17.77-1.18-27-4z"/><path fill="#b33509" d="M1405 1290c.64 1.8.64 1.8 1 4-.93 1.61-.93 1.61-2.31 3.31-4.02 5.86-4.09 12.8-4.69 19.69h-1c-.48-10.57.63-18.43 7-27"/><path fill="#000001" d="M1290 1289c.2 5.77.26 10.64-2 16h-2c-.2-10.19-.2-10.19 1-15 2-1 2-1 3-1"/><path fill="#101018" d="m871.31 1232.31 1.69.69c-2.4 1.92-3.96 2.21-7 2l-.69 1.81c-2.7 4.5-7.54 5.75-12.31 7.19 2.66-3.84 5.13-5.92 9.27-8.08 1.73-.92 1.73-.92 4.42-2.73 2.31-1.19 2.31-1.19 4.62-.88"/><path fill="#916c50" d="M1278 1230h3c.4 2.66.4 4.4-1.11 6.67a89 89 0 0 1-3.89 4.33l-1-2c-2.06-.62-2.06-.62-4-1h3l1-5h3z"/><path fill="#977150" d="M1285 1214h16l-1 3c-5.2 1.2-9.74.76-15 0z"/><path fill="#bcbcbc" d="m1450 1190 7 1v3h-8l1 4-5-1-1-3 1.88-.31c2.44-.8 2.96-1.47 4.12-3.69"/><path fill="#0f0f16" d="m701 1165 2 1-2.44 1.88C698 1170 698 1170 697 1172a90 90 0 0 1-12 3v-2q3.93-2.05 7.88-4.06 1.1-.59 2.26-1.18c.35-.19.35-.19 2.16-1.1l2-1.04z"/><path fill="#1d2125" d="M348 1072h1c.26 15.22.26 15.22-1 21l-2 1q-.08-3.81-.12-7.62l-.06-2.17c-.04-4.53.5-8 2.18-12.21"/><path fill="#040407" d="m1178 1030 1 3-21 2c6.3-4.2 12.53-5.4 20-5"/><path fill="#f7f8f8" d="M179 1009c7.43-.29 7.43-.29 11 2v3h-10z"/><path fill="#9ea9b2" d="M1521 988h1l1 9h3v-5c2 2.75 2 2.75 2 5l5-1c0 3.2-.74 5.1-2 8h-1v-5l-1.75.63c-2.82.47-3.92-.02-6.25-1.63-1.2-3.6-1.07-6.23-1-10"/><path fill="#504e59" d="M1284 985h2l1 13-4-3-2 6c-1.3-3.63-.69-6.04.38-9.69l.83-2.95c.79-2.36.79-2.36 1.79-3.36"/><path fill="#848584" d="M114 986h5v4l-3 1c-1.19 1.56-1.19 1.56-2 3l-7-1v-3h8z"/><path fill="#000001" d="M995 974h12v3c-3.01 1-5.04 1.1-8.19 1.06l-2.73-.02L994 978z"/><path fill="#adafb0" d="m66 962 5 1 1 3-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-7-1v-3h8z"/><path fill="#000001" d="M234 962h13v3l2 1c-5.47.22-9.9.09-15-2z"/><path fill="#38373e" d="M711 930q5.69-.12 11.38-.19l3.27-.07 3.12-.03 2.9-.05c2.33.34 2.33.34 3.66 1.86L736 933c-2.7 1.35-5 1.07-8 1v-3l-16 1z"/><path fill="#818081" d="m1152.13 842.94 2.19.02 1.68.04v2l-5.25 1.94-2.95 1.09a62 62 0 0 1-6.8 1.97l1-3-2-1 2.88-.94c6.38-2.17 6.38-2.17 9.24-2.12"/><path fill="#7e7d7f" d="M1161 833c-1.28 3.85-2.79 4.42-6.21 6.28-1.79.72-1.79.72-4.23.47L1149 839l5-2v-2l-2.25 1.06c-2.88.99-3.96.97-6.75-.06h3v-2c4.4-.7 8.53-1.1 13-1"/><path fill="#767678" d="m1177 831 2 2h3l-1 1 6 1c-2.74 2.28-5.54 2.54-9 3v-3h-9v-2q1.93-.58 3.88-1.12l2.17-.64z"/><path fill="#807f82" d="M1193 830c-.81 1.94-.81 1.94-2 4-2.75.92-4.36 1.1-7.19 1.06l-2.17-.02-1.64-.04c1.72-2.86 2.84-3.93 5.94-5.31 2.93-.66 4.3-.75 7.06.31"/><path fill="#403f43" d="M448 820c19.81 1.18 19.81 1.18 28 5-2.51 2.03-2.51 2.03-4.67 1.9l-1.8-.5-2.03-.55-2.12-.6-4.33-1.16-2.1-.57c-3.62-.97-7.28-1.75-10.95-2.52z"/><path fill="#05070b" d="M532 774h21v2l-21 1z"/><path fill="#4d4a50" d="m197 749 1 3 2 1c-2.64 3.62-4.85 6.04-9 8l-2-1v-3l1.81-.69c2.76-1.65 3.68-3.51 5.19-6.31z"/><path fill="#ad87df" d="M488 751h6l1 2a40 40 0 0 0 4 2c-4.62 1.9-7.94 2.1-12.87 1.13l-1.8-.32q-2.17-.39-4.33-.81v-1h8z"/><path fill="#020204" d="M1565 710h6c-.35 2.63-.72 4.58-2.19 6.81-2.6 1.71-4.77 1.4-7.81 1.19v-3l5-1z"/><path fill="#a1a0a3" d="m248 686-2 4h-4l-1 3h-6c1.07-3.1 1.98-3.98 4.81-5.75 3.06-1.2 4.96-1.53 8.19-1.25"/><path fill="#2b2b2f" d="m192 653 2 1-2 4h-5v3l-1.5.37-1.94.5-1.93.5C180 663 180 663 179 665l-2-1c.69-1.5.69-1.5 2-3 2.63-.19 2.63-.19 5 0l1-7 2.94.06C191 654 191 654 192 653"/><path fill="#b5b5b5" d="m179 651 1 2a40 40 0 0 0 4 2l-2 3-4-1 1-3h-8v-3c3.3-1.1 4.72-.95 8 0"/><path fill="#b996e5" d="M386 607h2c2.06 4.11 2.93 8.45 2 13-1.52 1.24-1.52 1.24-3 2-1.1-3.29-1.1-5.74-1.06-9.19l.02-3.29z"/><path fill="#09080d" d="m1362 598 2 1-6.62 5.75-1.88 1.63c-3.67 3.17-7.27 6.22-11.5 8.62l-3-1 1.83-1.14c4.6-2.94 7.77-5.58 11.17-9.86a43 43 0 0 1 8-5"/><path fill="#2d194e" d="M1218 606v1l-8 2v2c-3.32 1.74-6.29 3.29-10 4l1-3h3v-2h2l.81-1.94c2.57-4.46 6.91-2.79 11.19-2.06"/><path fill="#5a3b92" d="M778 587c-4.06 2.98-7.96 3.74-12.9 4.57-2.1.43-2.1.43-3.1 1.43q-2.28.1-4.56.06l-2.5-.02L753 593c2.68-2.12 5.4-2.72 8.69-3.5 3.66-.87 7.3-1.76 10.93-2.75C776 586 776 586 778 587"/><path fill="#a09fa2" d="M1710 569h4l1 3a18 18 0 0 0 3 2l-1 7h-3v-8l-4 1z"/><path fill="#858487" d="M1714 561h4l1 3a18 18 0 0 0 3 2l-1 7h-3v-8l-4 1z"/><path fill="#98989c" d="M1722 541h4v8q-2.49.57-5 1c-1-1-1-1-1.19-3.94.19-3.06.19-3.06 2.19-5.06"/><path fill="#261745" d="M1337 413h2l.99 3.14 1.32 4.11.65 2.07A28 28 0 0 0 1347 432c-.31 2.25-.31 2.25-1 4l-1-3h-2l-.55-1.9c-1.45-4.93-3-9.74-4.9-14.5-.55-1.6-.55-1.6-.55-3.6"/><path fill="#0b0b10" d="M1527 398v3c-6.25.83-11.77 1.04-18 0v-1c5.86-2.6 11.68-2.18 18-2"/><path fill="#382363" d="m1253 393 2 1-1 2h2c-.37 2.72-.73 4.61-2.31 6.88-1.69 1.12-1.69 1.12-3.88.8L1248 403v-3h2l-1-3 3-1z"/><path fill="#a6a5a9" d="m1355 326 2 1c.59 2.31.74 4.62 1 7l-1.37.75c-1.97 1.51-2.65 3-3.63 5.25l-3-2 1-4 3 1-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#949294" d="m1331 274 2 1c.59 2.31.74 4.62 1 7l-1.44.81C1331 284 1331 284 1330 287h-4v-5l4 1-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#131216" d="M451 237c1.2 3.53.8 6.13.06 9.75l-.59 2.98q-.22 1.12-.47 2.27h-2l.07 1.64.06 2.17.07 2.15c-.22 2.25-.8 3.3-2.2 5.04-.54-12.13-.54-12.13 2.2-17.4 1.37-2.73 2.02-5.65 2.8-8.6"/><path fill="#858486" d="M473 200h3l-1 7-1.87.38C471 208 471 208 469 210l-3-1-1 2c.25-2.87.25-2.87 1-6 2.06-1.37 2.06-1.37 4-2v3h2z"/><path fill="#949295" d="m1295 190 2 1c.59 2.31.74 4.62 1 7l-1.44.81C1295 200 1295 200 1294 203h-4v-5l4 1-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#6b6b6f" d="m963 131 2 1c-3.83 5.84-10.5 8.8-17 11l-2-1c1.36-2.72 3.17-2.92 5.88-4 3.98-1.87 7.52-4.48 11.12-7"/><path fill="#030208" d="M899 131v3c-5.22 2.28-9.3 3.36-15 3 3.13-5.47 9.08-6.43 15-6"/><path fill="#3a3b3e" d="M497 1834h14l-1 4-13-1z"/><path fill="#010203" d="M1367 1777v5h-6v4h-5l-1-3 2.94-.81 3.06-1.19 1-3c1.71-.59 3.18-1 5-1"/><path fill="#414048" d="m941 1724 1 4h2l-2 4h-2l-.69 1.94c-1.31 2.06-1.31 2.06-3.93 2.81l-2.38.25 1.75-2.56A149 149 0 0 0 941 1724"/><path fill="#84828b" d="M424 1613h1c1.04 8.02 1.1 15.93 1 24-2.03-1.66-2.92-2.53-3.36-5.16l.03-2.47.02-2.7.06-2.8.02-2.82c.08-6.9.08-6.9 1.23-8.05"/><path fill="#0e1115" d="M349 1579h1v32h-1l-.15-1.71-.23-2.23-.2-2.21-.42-1.85-2-1v-4l2-1z"/><path fill="#db5112" d="M1383 1390h24c-5.14 3.43-11.74 3.45-17.79 2.61q-3.13-.68-6.21-1.61z"/><path fill="#ababaf" d="M1503 1377h3l1 3a18 18 0 0 0 3 2l-1 7h-3v-8l-4 1z"/><path fill="#a12505" d="M1408 1363c5.64 1.88 9.35 8.85 12 14v3c-11.85-11.59-11.85-11.59-12.25-15.31z"/><path fill="#322116" d="M1261 1348c2.98 2.98 3.54 6.52 4.69 10.5l.72 2.38 1.28 4.36c.31 1.76.31 1.76-.69 3.76a571 571 0 0 1-4-8l-1.25-2.5c-1.05-3.51-.91-6.86-.75-10.5"/><path fill="#da6129" d="M1339 1345c4.83 2.41 6.6 11.39 8.48 16.27.52 1.73.52 1.73.52 4.73-5.07-6.12-9.67-12.67-9-21"/><path fill="#312016" d="M973 1281a153 153 0 0 1 16 6v2l2.88.88C995 1291 995 1291 997 1293c-8.75-.62-8.75-.62-12-4v-2l-2.12-.31A32 32 0 0 1 974 1283z"/><path fill="#6d1005" d="M1435 1265h5l2 4c-3.29.8-4.71 1.1-8 0zm-3.81 3.75 2.81.25-1.81.81a18 18 0 0 0-5.19 4.19l-2-1c1.82-3.24 2.36-3.95 6.19-4.25"/><path fill="#9e9ca0" d="m1511 1250 2 1c.59 2.31.74 4.62 1 7l-1.44.81C1511 1260 1511 1260 1510 1263l-4-1 1-4 3 1-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#afacb1" d="m1507 1242 2 1c.59 2.31.74 4.62 1 7l-1.44.81C1507 1252 1507 1252 1506 1255l-4-1 1-4 3 1-.04-1.71-.02-2.23-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#c4b8ad" d="M1373 1234h17l-1 3c-11.52.18-11.52.18-17-1z"/><path fill="#d9dfe5" d="M1507 1230h14v3c-10.56 1.52-10.56 1.52-15-1z"/><path fill="#a58d7c" d="m1021.13 1225.94 3.32.02 2.55.04v3l-15 1 1-3c2.76-1.38 5.05-1.1 8.13-1.06"/><path fill="#8a6548" d="M1291 1217c-1.05 3.14-1.5 3.65-4 5.56-2.9 2.24-2.9 2.24-4 4.44h-3c.7-3.16 1.52-6.11 3-9 2.64-1.14 5.15-1.1 8-1"/><path fill="#14131a" d="M497 1182c7.64-.73 13.2 1.83 20 5-6.87 1.99-12.4.15-18.53-3.16L497 1183z"/><path fill="#3d3b45" d="M1271 1022h2c.2 1.83.2 1.83 0 4-1.4 1.42-1.4 1.42-3.25 2.75-3.47 2.76-5.17 5.38-6.93 9.42-.79 1.76-1.7 3.26-2.82 4.83-.53-5.4.82-8.76 4-13a100 100 0 0 1 4-3z"/><path fill="#000001" d="m182 1002 13 1v3h-13z"/><path fill="#010103" d="m1081 954 1 4-5.96.78c-2.71.3-4.56.46-7.04-.78l1-3a42 42 0 0 1 11-1"/><path fill="#979798" d="m706.25 950.81 1.97.04q2.4.07 4.78.15l-1 3h-17c4.28-3.2 6.25-3.39 11.25-3.19"/><path fill="#989899" d="M716 948h17c-2 2-2 2-4.38 2.2l-2.75-.08-2.75-.05L721 950v2h-7z"/><path fill="#000001" d="M795 947c-2 2-2 2-5.48 2.2q-2.07-.02-4.14-.08l-2.15-.02-5.23-.1c1.85-2.39 2.84-2.97 5.85-3.58l3.21-.23 3.23-.27C793 945 793 945 795 947"/><path fill="#949697" d="m769.38 932.81 1.94.04 4.68.15-1 2c-2.46.5-4.83.89-7.31 1.19l-2.06.28c-3.45.45-6.28.74-9.63-.47 4.68-2.74 8-3.4 13.38-3.19"/><path fill="#9faab3" d="M1534 908h3c1.1 4.92.82 9.04 0 14h-3z"/><path fill="#bdc4c9" d="M1462 906h1v5l6-1v4l-5 1-.31 3.38c-.47 3.42-.47 3.42-2.25 5.06l-1.44.56 1-3q.2-1.78.32-3.57l.12-2.03.12-2.09z"/><path fill="#e9eaeb" d="M6 904h5v6l-5-1v5l-4-1c.38-1.94.38-1.94 1-4l2-1c.63-2.06.63-2.06 1-4"/><path fill="#58565b" d="M183 895c4.64.57 8.96 1.55 13 4a15 15 0 0 1 2 4l2 1c-4.8-.51-7.86-1.85-11.87-4.5l-2.93-1.9-2.2-1.6z"/><path fill="#0e0d11" d="m1062 878-1.44 1.81C1059 882 1059 882 1058 885l-3-1 1-2h-7v-2q2.12-.8 4.25-1.56l2.4-.88c2.54-.6 3.9-.4 6.35.44"/><path fill="#989899" d="M146 800c.13 2.38.13 2.38 0 5l-2 2c-.41 2.38-.41 2.38-.62 5.13l-.23 2.75L143 817h-1l-1-8-3 4c-.25-2.81-.25-2.81 0-6 1.94-1.69 1.94-1.69 4-3l1-3c2-1 2-1 3-1"/><path fill="#252528" d="m436 801 8 1v2l1.54-.07c4.65-.12 8.14.34 12.46 2.07v2q-2.5.06-5 0l-1-1q-2.34-.47-4.69-.87A33 33 0 0 1 436 802z"/><path fill="#6e6e72" d="m95 796 1 4-3 1q-.55 1.99-1 4c-1.3 1.96-1.3 1.96-2.87 3.94A81 81 0 0 0 83 818q-1.47 2.02-3 4c.72-6.13 4.39-10.2 8-14.94A84 84 0 0 0 95 796"/><path fill="#818183" d="m1286 785-4 1v2l4 1-8 3v-2l-9 1c1.58-3.17 4.76-3.58 7.94-4.69l1.96-.72c4.85-1.72 4.85-1.72 7.1-.59"/><path fill="#bababd" d="M1482 781c-.62 1.88-.62 1.88-2 4q-2.97 1.07-6 2c-2.16 1.66-2.9 2.58-3.51 5.26l-.18 2.43-.2 2.45-.11 1.86c-1.75-1.96-2.03-3.24-2.42-5.88.53-3.96 2.23-5.56 5.17-8l1.34-1.21c2.65-2.29 4.38-3.45 7.91-2.91"/><path fill="#020204" d="M1475 774h5v4l-1.94.88C1476 780 1476 780 1475 782h-5l1-4h3z"/><path fill="#050509" d="M58 760h3v5h-3v4l3 1h-3v3h-4v-4h3v-4l-2-1c1.31-2 1.31-2 3-4"/><path fill="#020203" d="M1551 722h4c-.35 2.63-.72 4.58-2.19 6.81-2.6 1.71-4.77 1.4-7.81 1.19l1-4h4z"/><path fill="#5b3995" d="M876 723h13l-1 3-14 1z"/><path fill="#a4a1a6" d="M1594 701c-1.04 3.03-1.84 3.91-4.69 5.5l-1.58.7a67 67 0 0 0-5.6 2.99l-1.81 1.04-1.32.77c.81-1.94.81-1.94 2-4l3-1 .94-1.94c1.96-3.8 5.01-4.35 9.06-4.06"/><path fill="#16151a" d="M1195 696v2c-5.04 2.82-9.23 4.52-15 5v-3c9.23-4.6 9.23-4.6 15-4"/><path fill="#38393b" d="m1559 648 2 1c-6.21 7.01-13.58 13.09-23 15 2.24-3.54 4.12-4.57 8-6l1-2c1.45-.68 1.45-.68 3.25-1.31 3.86-1.55 6-3.6 8.75-6.69"/><path fill="#0c0718" d="M1154 641h-3v3l-7.69 2.44-2.18.7A84 84 0 0 1 1130 650c1.6-3.2 4.77-3.92 8-5 2.34-.48 4.61-.76 7-1v-2c3.43-1.62 5.38-2.34 9-1"/><path fill="#7443b9" d="M577 619c-.99 1.49-.99 1.49-3 3-2.73.39-5.43.28-8.19.19l-2.27-.04q-2.78-.07-5.54-.15l1-2c2.3-.5 4.49-.89 6.81-1.19l1.91-.28c3.34-.46 6.08-.67 9.28.47"/><path fill="#6941a9" d="M648 612q3.56.35 7.13.56l2 .13 4.87.31v1c-10.06.12-19.98-.12-30-1 5.55-2.87 10.19-3.9 16-1"/><path fill="#a89ac9" d="M809 566h15l-1 3h-15z"/><path fill="#3e334d" d="m396 555-4 1a245 245 0 0 0-3.44 17.81L388 576l-2 1c.83-14.37.83-14.37 3-21 2.46-1.23 4.28-1.07 7-1"/><path fill="#000002" d="m416 525 11 1v4c-7.43.29-7.43.29-11-2z"/><path fill="#09080f" d="M362 490h3v17h-3z"/><path fill="#09090f" d="m367 466 2 1 1 20-2 1-1-10h-1q-.05-2.71-.06-5.44l-.04-3.06c.1-2.5.1-2.5 1.1-3.5"/><path fill="#8f9090" d="m358 462 2 1c.35 6.47.21 11.29-3 17-1.6 1.19-1.6 1.19-3 2-.23-2.7-.37-5.3 0-8l2-1.44 2-1.56c.37-3.02.23-5.97 0-9"/><path fill="#07060c" d="M1609 456c4.87.6 9.63 1.7 14 4 1.23 1.6 1.23 1.6 2 3h-7v-2h-6l-1-3z"/><path fill="#09080e" d="M1536 438c15.91-.61 15.91-.61 23 3l2 2c-8.5-.58-16.69-2.24-25-4z"/><path fill="#19102d" d="m1202 421 2 1-4.69 3.31-2.63 1.87c-5.23 3.56-10.16 6.82-16.68 6.82 2.3-2.56 4.53-3.91 7.63-5.37 4.31-2.08 8.44-4.36 12.56-6.81z"/><path fill="#160f26" d="m1226.38 407.19 1.62.81-1.87.81c-2.13 1.19-2.13 1.19-3.02 2.64-1.43 2-2.82 2.56-5.05 3.55a21 21 0 0 0-7.06 5c-2.69.69-2.69.69-5 1 2.8-4.2 5.4-5.88 9.98-7.95a23 23 0 0 0 5.27-3.86c2.47-2.21 2.47-2.21 5.13-2"/><path fill="#37353c" d="M465 407a22 22 0 0 1 .88 8.33l-.15 2.42-.17 2.5L465 429h-1l-1-5h-1c-.35-6.52-.04-11.18 3-17"/><path fill="#e5e6e6" d="M1609 410h8q.57 2.49 1 5l-1 1c-2.36-.18-4.66-.62-7-1z"/><path fill="#6d6e70" d="M1486 382h5l1 5 14-1v2c-2.82 1.4-5.39.9-8.44.63l-1.77-.14c-2.8-.23-5.12-.6-7.79-1.49-1.19-2.56-1.19-2.56-2-5"/><path fill="#bab8bc" d="M1422 374c4.36-.32 7.01.4 10.92 2.27 4.18 1.47 8.68 1.5 13.08 1.73v1q-4.31.17-8.62.25l-2.45.1c-5.63.1-8.6-.69-12.93-4.35z"/><path fill="#8a8a8d" d="M458 354c2.3 2.3 2.3 2.95 2.56 6.06.34 3.84.34 3.84 1.44 4.94q.1 2.02.06 4.06l-.02 2.23L462 373h-1l-1-5-1 9h-1z"/><path fill="#9b9c9d" d="m382 302 2 1c.35 6.47.21 11.29-3 17-1.6 1.19-1.6 1.19-3 2-.26-4.67-.26-4.67 0-7l3-3c.74-3.3 1-6.63 1-10"/><path fill="#131317" d="M469 190h2c-2.04 7.24-5.25 13.5-9 20-1.43-2.86-.6-4.93 0-8l-2-1 2.25-.44c4.19-2.38 5.13-6.17 6.75-10.56"/><path fill="#7f7f82" d="M721 190h16v3h-15z"/><path fill="#8c8d90" d="m610 178 1.81 2.31A59 59 0 0 0 617 186l-1 3c-4.35-.48-6.19-2.88-9-6l-1-2h4z"/><path fill="#b2b4b4" d="m412 162 2 1v8l4-1-1 5h-3l-1-3c-1.56-1.19-1.56-1.19-3-2 .88-6.87.88-6.87 2-8"/><path fill="#949394" d="m559.06 154.66 2.51.05 2.72.03c.46 0 .46 0 2.83.07l2.87.04q3.5.06 7.01.15v1l-1.71.18-2.23.26-2.21.24C569 157 569 157 568 158q-3.29.11-6.56.06l-1.87-.01L555 158c1.1-2.26 1.52-2.92 4.06-3.34"/><path fill="#545356" d="M936 128c-3.44 3.03-6.78 4.03-11.12 5.13l-2 .52A80 80 0 0 1 907 136c2.6-2.6 4.54-2.7 8.13-3.44 3.89-.82 7.62-1.73 11.3-3.25 3.38-1.37 5.95-1.58 9.57-1.31"/><path fill="#0c0b11" d="M1040 82c-7.38 5.34-18.9 9.32-28 9l1.88-.87C1016 89 1016 89 1018 87c1.88-.53 1.88-.53 4.13-.94 3.2-.64 6.1-1.46 9.12-2.69A17 17 0 0 1 1040 82"/><path fill="#5e5d62" d="m714 78 3.38-.19 1.9-.1L721 78l2 3c1.95.73 1.95.73 4.13 1.19l2.19.48L731 83v1c-6.33.35-11.1-.7-17-3z"/><path fill="#191a1e" d="M979 37h8v1l-6 1-1 3h-9l-1 3h-6l1.38-.69c2.19-1.77 2.72-3.68 3.62-6.31l1 3a294 294 0 0 0 6.15-1.37C978 39 978 39 979 37"/><path fill="#9f9da2" d="M1027 14h8l-1 5-7 1c-1-3-1-3 0-6"/><path fill="#010101" d="M1395 1736h3v7l-4 1v4h-3v-6h3z"/><path fill="#92929a" d="M395 1662h1c1.15 6.34 1.1 12.57 1 19-1.48-.79-1.48-.79-3-2a58 58 0 0 1-.25-7.5l.02-2.09c.08-5.1.08-5.1 1.23-7.41"/><path fill="#000003" d="M458 1611h3l1 2 3 1 1 3 1.56 1 1.44 1v3c-3.56-.4-5.47-2.03-7.9-4.59-1.54-1.97-2.35-4.03-3.1-6.41"/><path fill="#010105" d="M1241 1612c.7 1.77.7 1.77 1 4-1.3 1.85-1.3 1.85-3.19 3.63l-1.85 1.78a26 26 0 0 1-5.96 3.59c.9-6.01 5.4-9.55 10-13"/><path fill="#393842" d="M1259 1480c7.48-.3 14.63.84 22 2v1q-2.71.3-5.44.56l-3.06.32-2.5.12-1-1a86 86 0 0 0-4.56-.56l-2.5-.26-1.94-.18v8h-1z"/><path fill="#16171e" d="m884 1371 2 1c-4.22 3.24-8.4 6.3-13 9q-4.5 2.98-9 6l-2-1a74 74 0 0 1 11-8q3.09-2.3 6.13-4.7c1.87-1.3 1.87-1.3 4.87-2.3"/><path fill="#000001" d="M1507 1337h3v15h-3z"/><path fill="#871c04" d="M1405 1294c1.6 3.2.1 6.16-.81 9.44l-.54 2.06c-.88 3.22-1.67 5.78-3.65 8.5-.61-8.14-.3-13.57 5-20"/><path fill="#6f0c01" d="m1426.2 1269.34 1.8.66-2.87 3.5-1.62 1.97-1.51 1.53h-2v2l-4 1c1.3-4.4 4.85-11.48 10.2-10.66"/><path fill="#2e2d36" d="M891 1214h5v5a174 174 0 0 1-13.31 6.31q-1.22.53-2.5 1.05c-2.19.64-2.19.64-5.19-.36l8.38-4 2.4-1.16 2.3-1.09 2.13-1.02c1.79-.73 1.79-.73 3.79-.73v-2h-3z"/><path fill="#f0f4f7" d="M1545 1209h5l-1 5h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.06-.62-2.06-.62-4-1z"/><path fill="#5b5c61" d="m1454 1205 1.68.88c3.3 1.6 6.73 2.78 10.18 4.02 2.14 1.1 2.14 1.1 3.14 4.1 1.41 1.39 1.41 1.39 3.06 2.69l1.66 1.32 1.28.99c-3.07 0-3.75-.2-6.19-1.81l-1.6-1.02-1.21-1.17v-3l-2.12-.25c-3.69-.96-5.84-2.47-8.88-4.75z"/><path fill="#090a11" d="M1028 1181c-4.46 3.47-10.05 4.29-15.5 5.06q-6.26.89-12.5 1.94c2.7-2.7 5.67-3.16 9.25-4.06l1.99-.53c5.57-1.44 10.97-2.58 16.76-2.41"/><path fill="#9ea9b2" d="m1589 1167 2 1-1.06 1.81c-.94 2.19-.94 2.19.06 5.19-3.87-.87-3.87-.87-5-2v5l-2.37.31c-2.63.69-2.63.69-3.94 2.25l-.69 1.44c-1.23-4.55-1.23-4.55-.06-6.81l1.06-1.19v3h4v-5l3-1c1.69-2.06 1.69-2.06 3-4"/><path fill="#1d1c24" d="M1180 1110c-3.62 3-3.62 3-7 3v2c-2.2 2.12-3.52 2.95-6.58 3.3l-2.67-.11-2.7-.08-2.05-.11c3.23-2.44 6.04-2.55 10-3l1-3c1.78-.98 1.78-.98 3.94-1.75l2.15-.8c1.91-.45 1.91-.45 3.91.55"/><path fill="#cecdd1" d="M1445 1097h1c.38 5.26-.22 9.06-2.27 13.9-1.26 3.6-1.48 7.32-1.73 11.1h-1c-.28-7.78.03-15.43 2-23z"/><path fill="#b1bac1" d="m1619 1090 2 1c1.3 4.86.8 9.13 0 14h-3q-.05-3.46-.06-6.94l-.03-2q0-2.52.09-5.06z"/><path fill="#e0e5e9" d="M1490 1086h4l2 4-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-7-1v-3h8z"/><path fill="#cfcfce" d="M1109 1027q5.07-.05 10.13-.06l2.88-.03c4.79-.01 9.3.09 13.99 1.09-2 2-2 2-4.82 2.2l-3.3-.08-3.33-.05-2.55-.07v-1l-13-1z"/><path fill="#000001" d="M169 998c8.45-.37 8.45-.37 12 2v2h-12z"/><path fill="#908f91" d="m559 963 15.09-.08 5.52-.02 3.34-.01c2.8.1 5.32.47 8.05 1.11l-1 2h-9v-1l-22-1z"/><path fill="#050508" d="M817 940c-.74 1.95-.74 1.95-2 4-1.95.6-1.95.6-4.12.75l-2.2.17-1.68.08 3-1v-2h-12v-1c6.37-.96 12.56-1.1 19-1"/><path fill="#c6c4c5" d="M1236 910c-.87 4.75-.87 4.75-2 7h-9c1-3 1-3 2.78-4.17l2.16-1.02 2.15-1.04c1.91-.77 1.91-.77 3.91-.77"/><path fill="#ebedf1" d="m1522 842 4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.06-.62 2.06-1 4l-5-1v-5l5 1z"/><path fill="#2c2c2f" d="M520 828q4.3-.09 8.63-.12l2.44-.06c4.81-.03 8.46.4 12.93 2.18q2.5.55 5 1v1a99 99 0 0 1-19.56-1.5l-2.77-.43q-3.34-.52-6.67-1.07z"/><path fill="#949597" d="M86 812c1.44 2.87.53 4.85 0 8-.09 2.67-.06 5.33 0 8h-1v-7c-2 1-2 1-2.63 2.85l-.5 2.21-.5 2.23L81 830h-2l.81-5.31.46-3c.78-2.87 1.51-3.8 3.73-5.69 1.19-2.19 1.19-2.19 2-4"/><path fill="#7e7d81" d="M150 798c1 2 1 2 .33 4.07l-1.08 2.5a129 129 0 0 0-4.54 12.62C144 819 144 819 142 821q.14-2.91.31-5.81l.18-3.27c.56-3.22 1.15-3.84 3.51-5.92.66-1.79.66-1.79 1.06-3.62.41-1.86.41-1.86.94-3.38z"/><path fill="#48464c" d="M166 780h3c-.32 5.3-1.47 8.19-4.73 12.36C163 794 163 794 161.3 796.87 160 799 160 799 158 800l1-4h2l.38-2.94.62-3.06 2-1c.85-1.63.85-1.63 1.63-3.56l.78-1.94.59-1.5-3 2z"/><path fill="#222126" d="M1100 757a135 135 0 0 1-13.93 5.3c-2.07.7-2.07.7-4.88 1.95-2.56.88-3.66.5-6.19-.25l9.14-3.7c11.96-4.8 11.96-4.8 15.86-3.3"/><path fill="#a17cd5" d="M539 755v3h-15v-2q2.63-.54 5.25-1.06l2.95-.6a13.3 13.3 0 0 1 6.8.66"/><path fill="#404144" d="m1445 723 2 1q-4.99 3.02-10 6l-2.53 1.55-2.35 1.39-2.11 1.27c-2.43.95-3.6.66-6.01-.21 2.11-2.11 4-2.9 6.72-4 3.48-1.53 6.84-3.31 10.2-5.1 1.33-.7 2.7-1.3 4.08-1.9"/><path fill="#2c2c30" d="M315 666h1a62 62 0 0 1-1 14h-2v21h-1v-25h2z"/><path fill="#000004" d="M307 643h7v3l-2.81.88C308 648 308 648 306.44 649.13c-2.12 1.29-4.02 1-6.44.87.75-1.94.75-1.94 2-4 2.06-.62 2.06-.62 4-1z"/><path fill="#eff0f0" d="M235 614c1.94.38 1.94.38 4 1l1 2c2.06.63 2.06.63 4 1l-1 5h-5l1-5h-5z"/><path fill="#3f2571" d="M1032 605c4.05 3.57 5.67 8.6 6.19 13.92q-.02 2.04-.19 4.08c-3.48-1.74-3.94-5.67-5.18-9.19a20.5 20.5 0 0 1-.82-8.81"/><path fill="#b5a5c9" d="M423 590c5.17-.3 6.87-.13 11 3 2.65.55 5.3.81 8 1l1 3-8.87-1.94-2.56-.55-2.44-.54-2.25-.49C425 593 425 593 423 592z"/><path fill="#6a686c" d="m299 592 1 3-3.25.75c-3.56 1-5.25 2.5-7.75 5.25-3.5 2.5-5.7 3.37-10 3l-1-2 4.88-1.5 2.74-.84C288 599 288 599 290 599v-4l1.71-.15 2.23-.23 2.21-.2L298 594z"/><path fill="#020105" d="M719 571v3h8v1h-18v-3c3.38-1.04 6.48-1.08 10-1"/><path fill="#07070d" d="m1645 558 2 1c-2.75 5.88-2.75 5.88-5 7q.45-1.73.94-3.44l.52-1.93c.54-1.63.54-1.63 1.54-2.63m-5 8 2 1-4.81 6.94-1.37 2-1.34 1.9-1.22 1.76C1632 581 1632 581 1629 582c1.2-3.8 2.79-5.96 5.7-8.66 2.15-2.2 3.66-4.74 5.3-7.34"/><path fill="#121316" d="M353 520h1v14l3 1-7 1-1 4c-.31-5.31.2-7.42 3-12 .54-2.65.8-5.3 1-8"/><path fill="#131118" d="M972 457c3.1 4.05 4.18 6.29 4.31 11.44l.12 2.93c-.43 2.63-.43 2.63-2.18 4.4a23 23 0 0 1-6.5 2.42l-2.14.48-1.61.33c1.54-2.9 3.06-3.65 6.13-4.69l2.19-.76L974 473c.21-4.5-.53-7.76-2-12-.12-2.37-.12-2.37 0-4"/><path fill="#b1b2b3" d="m352 434 2 1v14h-3c-1.52-10.56-1.52-10.56 1-15"/><path fill="#0c061a" d="M1345 425h2c2.99 3.1 3.35 5.84 4 10l2 1c.85 2.5.85 2.5 1.63 5.56l.78 3.07q.3 1.16.59 2.37c-5.73-5.52-7.65-11.57-10-19z"/><path fill="#2a1d44" d="m1227 410 1 2c-3 2.97-5.37 5.21-9.69 5.63-3.65.4-3.94.89-6.31 3.37-2.19-.31-2.19-.31-4-1l2.13-1.25c2-1.22 3.9-2.46 5.8-3.81 2.87-1.94 5.75-2.96 9.07-3.94z"/><path fill="#432f70" d="M1253 409c2.31.25 2.31.25 4 1l-2.44.81L1252 412l-1 3-8 1v2l-5 1c2.28-4.21 2.28-4.21 4-6 2.94-.12 2.94-.12 6 0a350 350 0 0 0 5-4"/><path fill="#0a0618" d="m1320 376 2 1v3l3 1q1.05 2.99 2 6l2 1a20 20 0 0 1 1.69 3.31l.82 1.8c.6 2.3.22 3.67-.51 5.89l-3.81-7.12-1.08-2.02L1323 384l-.93-1.76c-2.07-4-2.07-4-2.07-6.24"/><path fill="#18171e" d="M898 252c0 5.86-2.73 8.99-6.47 13.15-2.28 2.75-3.86 5.7-5.53 8.85l-1-2c1.04-2.07 1.04-2.07 2.63-4.56 2.6-4.17 5-8.38 7.3-12.73C896 253 896 253 898 252"/><path fill="#3f3e43" d="M501 230h1c.19 2.31.19 2.31 0 5l-1.44 1.38c-2.44 2.54-2.23 5.16-2.56 8.62l-2-2-2 1c-.36-5.05.56-7.73 3-12l3 2z"/><path fill="#89898b" d="M640 211h6v3l6 2v2c-5.75.13-5.75.13-8-1v-2l-4-1z"/><path fill="#2d2b31" d="M854 194v2c-2.65 1.46-3.9 2-7 2l-1 3h-6v-3h5v-2c2.62-2.62 5.5-2.16 9-2"/><path fill="#69696c" d="M616 194c5.3 4.38 10.18 9.09 15 14l3 3 2 2c-4.5-.29-6.31-1.96-9.15-5.16l-1.41-1.65-4.16-4.76a72 72 0 0 0-3.62-3.7C616 196 616 196 616 194"/><path fill="#161619" d="m911 65 4 1c-3.01 3.86-3.01 3.86-5.82 4.27A76 76 0 0 1 904 70v4l-1.93.15-2.5.22-2.5.22L895 75l-1 2-4-1h3l-1-5 2-1v3c4.78-.44 4.78-.44 7-1l1-1.5c1-1.5 1-1.5 2.66-1.92q2.67-.34 5.34-.58z"/><path fill="#ebeced" d="M414 1797h5l1 5-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-4-1v-3h5z"/><path fill="#41414a" d="m928.69 1737.81 2.31.19c-1.27 3.82-2.61 4.86-6 7-2.44.33-4.52.2-7 0 1.52-2.68 1.87-2.96 5-4l1.38-1.56c1.62-1.44 1.62-1.44 4.3-1.63"/><path fill="#8b8b92" d="M424 1693h2l1 13h-3c-1.25-2.5-1.13-4.27-1.12-7.06l-.01-2.73c.13-2.21.13-2.21 1.13-3.21"/><path fill="#09090f" d="M360 1675h1v19l-3 1-.06-5.94-.04-3.34c.1-2.72.1-2.72 1.1-3.72q.34-1.77.56-3.56z"/><path fill="#16161c" d="M466 1624c3.33.55 6.1 1.24 9 3l1 2c1.49.78 1.49.78 3.38 1.5l2.04.8 2.14.83 2.16.83L491 1635c-2.9 1.22-4 1-7.02-.1q-1.69-.82-3.36-1.65l-1.67-.8-3.3-1.63q-1.67-.83-3.35-1.63c-4.97-2.4-4.97-2.4-6.3-5.19"/><path fill="#828189" d="M425 1599h1v24h-1l-1-9h-1c-.37-10.56-.37-10.56 2-15"/><path fill="#292933" d="M565 1576c2.13.38 2.13.38 4 1-2.78 3.22-5.26 4.7-9.12 6.2a52 52 0 0 0-7.4 4.13l-1.48.67-2-1c3.73-3.24 7.65-5.55 12.01-7.85C563 1578 563 1578 565 1576"/><path fill="#2e2d37" d="m820 1408 2 1c-18.4 13.13-18.4 13.13-24 15 1.1-3.29 1.35-3.5 4.17-5.12l1.96-1.15 2.06-1.17 3.98-2.3 1.94-1.13c2.7-1.62 5.3-3.36 7.89-5.13"/><path fill="#dc4503" d="M1380 1386c8.2-.47 8.2-.47 11.56 1.5l1.44 1.5c-2.48 1.24-4.24 1.17-7 1.19l-2.69.04c-2.31-.23-2.31-.23-4.31-2.23z"/><path fill="#8b8784" d="M1312 1371c5.38 2.59 9.45 5.34 13.57 9.64a15 15 0 0 0 5.43 3.36l-2 1c-6.9-2.62-12.25-7.46-17-13z"/><path fill="#9b2404" d="M1400 1343h1c1.25 3.43 2.28 6.32 2 10h2l3 10-3-2v-2h-2c-3.36-5.32-3.33-9.8-3-16"/><path fill="#251a11" d="M1068 1299c4.5 2.53 5.38 5.9 6.81 10.56l.7 2.08c1.63 5.08 1.63 5.08.49 7.36l-6-12-1.19-2.25c-.94-2.04-.97-3.53-.81-5.75"/><path fill="#d3460a" d="m1407 1283 1 2c-.9 2.2-.9 2.2-2.25 4.81l-1.4 2.72a105 105 0 0 1-3.1 5.28c-1.37 2.4-1.86 4.47-2.25 7.19h-1c-.35-5.3-.21-8.55 3-13q1.02-2 2-4h2v-4z"/><path fill="#8c694d" d="M1250 1280h2l-1 7h3v2h-4v-2h-22v-1l20-1z"/><path fill="#d04811" d="m1415.19 1270.31 1.81.69-1.02 1.6-1.36 2.15-1.33 2.1c-1.2 2.02-2.27 4.04-3.29 6.15h-2q.39-2.4.81-4.81l.46-2.71c1.02-3.45 2.04-5.72 5.92-5.17"/><path fill="#5e5d5b" d="M1306 1263c1 3 1 3-.33 5.96q-.95 1.71-1.92 3.41l-.95 1.72c-2.45 4.39-4.96 8.76-7.8 12.91.31-6.98 3.5-12.07 7-18h2l.44-1.87c.56-2.13.56-2.13 1.56-4.13"/><path fill="#7a5b46" d="M991 1244q3.88-.08 7.75-.12l2.21-.06c3.95-.03 6.58.1 10.04 2.18 1.92.41 1.92.41 3.75.63l3.25.37v1a90 90 0 0 1-18-1.5l-2.64-.43L991 1245z"/><path fill="#000105" d="M1256 1166c4.92-.2 8.42.21 13 2l-1 2h-12z"/><path fill="#817f89" d="m1306 1161.81 3.25-.04c2.75.23 2.75.23 4.75 2.23l-5 1v1c-7.37.47-7.37.47-10-1.5l-1-1.5c2.73-1.36 4.96-1.17 8-1.19"/><path fill="#413e44" d="m1450 1073 3 1c1.3 3.13.95 5.94.56 9.25l-.3 2.7-.26 2.05h-3z"/><path fill="#88959f" d="m1587 1060 7 1v8h-3v4l3 1h-4l-1-3-2 1-1-6 4 1 1-4-4-1z"/><path fill="#cecdcc" d="M1064 1023h18v1c-5.86 2.6-11.68 2.18-18 2z"/><path fill="#e0dfde" d="m1194.69 1009.94 2.45.02 1.86.04a46 46 0 0 1-11 7v-3l-2-1c1.8-3.6 5.09-3.11 8.69-3.06"/><path fill="#656769" d="m175 1004 1.68.93a29 29 0 0 0 7.44 2.13c4.69.84 4.69.84 6.88 1.94v5l-.81-1.44C189 1011 189 1011 186 1010a99 99 0 0 0-5.19-.06l-2.73.02-2.08.04z"/><path fill="#07080d" d="m558.07 981.9 2.5.04 2.5.02 1.93.04v1l18 1v1a1594 1594 0 0 1-14.74.15q-2.69.04-5.38.05l-3.27.03C557 985 557 985 555 983c1-1 1-1 3.07-1.1"/><path fill="#dcddde" d="M1422 953c0 3 0 3-1.44 5.31-1.71 2.95-2.17 5.32-2.56 8.69l2 1-2 6-1-3h-2l-1 3c.63-8.34 3.2-14.22 8-21"/><path fill="#8b8b8d" d="M743 940c2.47-.22 4.67-.28 7.13-.19l2 .04 4.87.15-1 3h-15z"/><path fill="#a7b2b9" d="M1530 921c0 3 0 3-1.94 5l-2.06 2c-1.08 3.25-1.34 6.3-1.56 9.69L1524 944h-1a531 531 0 0 1-.25-9.25l-.1-2.64c-.1-6.47-.1-6.47 2.18-9.72C1527 921 1527 921 1530 921"/><path fill="#222327" d="M1454 898h1l.59 5.37.41 1.63 2 1-1 3h-3l-2 6h-1c-.35-6.52-.04-11.18 3-17"/><path fill="#1b1b1f" d="m1142.25 853.38 1.75.62c-3.62 3.36-7.3 3.94-12 4.75l-2.16.42c-6.4 1.16-6.4 1.16-9.84-.17a46 46 0 0 1 14.25-3.75c1.75-.25 1.75-.25 4-1.37 1.75-.88 1.75-.88 4-.5"/><path fill="#b5bdc3" d="M1504 834c2 1.38 2 1.38 4 3v2l5 1c2.13 5.63 2.13 5.63 1 9l-3-1 1-6h-5c-1.08-2.65-2.1-5.28-3-8"/><path fill="#7c7c7e" d="M1227 813v3c-2.38 2.33-4.98 2.38-8.19 2.63l-2.73.22-2.08.15 1-2h2v-2q1.93-.8 3.88-1.56 1.06-.44 2.17-.88c1.95-.56 1.95-.56 3.95.44"/><path fill="#434147" d="M167 781c0 3.85-1.2 5.63-3 9h-2l-.31 1.94L161 794l-3 1c.65-5.5 3.04-9.37 7.25-12.87z"/><path fill="#17171b" d="m1374 760 2 1c-8.08 6.7-17 10.94-27 14 1.36-2.73 3.25-3.04 5.96-4.15 5.61-2.34 10.36-4.95 15.04-8.85 2.19-1.31 2.19-1.31 4-2"/><path fill="#7c7a7c" d="M1376 756c-1 3-1 3-3.62 4.69-3.54 1.64-6.53 1.54-10.38 1.31 4.54-4.86 7.29-6.3 14-6"/><path fill="#000004" d="m803.75 749.81 1.82.04q2.22.07 4.43.15l-1 3h-16c4.17-3.13 5.9-3.4 10.75-3.19"/><path fill="#7a787a" d="m1436 718 .75 1.44c1.25 1.56 1.25 1.56 4.38 2.25l2.87.31-5 5-3-1 3-3-9-1v-2c3.75-2 3.75-2 6-2"/><path fill="#1e1534" d="M964 710h-3v2c-14.6 3.44-14.6 3.44-22 3 3.6-2.69 7.62-3.24 11.94-4.19l2.46-.57 2.38-.53 2.16-.49c2.27-.24 3.9.1 6.06.78"/><path fill="#111116" d="m1164 709-7.47 3.22c-2.7 1.15-4.55 1.78-7.53 1.78v2c-2.7 1.35-5 1.07-8 1l1.56-.69a39 39 0 0 0 5.69-3.81c4.87-3.54 9.88-5.3 15.75-3.5"/><path fill="#7f7e80" d="m258 690 2 1c-3.12 3.3-5.91 5.9-10 8h-2l-1 3c-1.34 1.13-1.34 1.13-2.94 2.13l-1.59 1q-1.7 1-3.47 1.87c2.34-6.3 8.65-9.55 14.17-12.79A26 26 0 0 0 258 690"/><path fill="#010106" d="m1402 666 2 1-2 1zm-3 3h2c-.46 4.18-2.93 6.04-6 8.56-4.87 4.06-4.87 4.06-6 7.44l-2-3 4.88-4.81 1.39-1.37c3.55-3.52 3.55-3.52 4.75-5.48z"/><path fill="#c9c7cb" d="m1678 615 3 1-1.31.69c-3 2.33-4.4 5.47-5.63 9-1.06 2.31-1.06 2.31-3.18 3.62l-1.88.69 1-5h2l1-5 3-1c1.19-2.06 1.19-2.06 2-4"/><path fill="#2c1950" d="m1182.56 618.94 2.44.06c-.12 1.81-.12 1.81-1 4-3.34 2.08-6.02 3.46-10 3v-4l2.44-.94c5.06-2.1 5.06-2.1 6.12-2.12"/><path fill="#030306" d="m1667 618 4 1c-.54 3.77-2 5.68-5 8-2.25-.25-2.25-.25-4-1l1-4h3z"/><path fill="#b09fc7" d="M447 596h2v2h10l1 4c2 1.13 2 1.13 4 2l1 2c-1.81.31-1.81.31-4 0l-1.25-2.44C458 601 458 601 455.08 600.07l-3.27-.38-3.3-.43-2.51-.26z"/><path fill="#bc96e6" d="M390 589c3.39 3.39 3.42 4.13 3.69 8.69l.2 3L394 603l-2 1-1-4-1 5h-1z"/><path fill="#6d519f" d="M751 592v1l-3.18.62-4.13.82-2.1.4-2.02.4-1.85.37C736 596 736 596 733 597q-3.35.11-6.69.06l-1.84-.01L720 597v-1l14.85-2.58c5.46-.93 10.61-1.67 16.15-1.42"/><path fill="#aba0c0" d="m652 590-2 4h-20v-1l2.2-.15c8.87-.68 8.87-.68 12.17-1.91 2.73-.97 4.76-1.1 7.63-.94"/><path fill="#625d6a" d="M484 580c5.58-.21 10.5.4 15.97 1.49 5.3.9 10.66 1.19 16.03 1.51v1c-23.83.54-23.83.54-32-4"/><path fill="#000001" d="M865 546v2a46 46 0 0 1-16 2v-3a92 92 0 0 1 16-1"/><path fill="#010006" d="m1333 538 1 2 4-2c-1 3-1 3-3.25 4.31-3.37 2.07-5.38 4.58-7.75 7.69l-3-1 2.81-3.81 1.58-2.15A47 47 0 0 1 1333 538"/><path fill="#0a061a" d="M1361 459h2l.8 2.12 1.08 2.75 1.05 2.75C1367 469 1367 469 1369 471c.67 1.59.67 1.59 1.19 3.38l.54 1.77c.32 2.2-.08 3.75-.73 5.85a680 680 0 0 1-6.75-15.44l-1.3-3.06c-.95-2.5-.95-2.5-.95-4.5"/><path fill="#0a0a0f" d="M1492 394h11v3q-3.65.33-7.31.63l-2.1.19-2.02.16-1.85.16c-2.08-.17-3.1-.87-4.72-2.14h7z"/><path fill="#0d0e12" d="M1354 349h1l1 5 3 1a35 35 0 0 1 2 5l1 2-4 1v-7h-4v6l-3-1v-6h3z"/><path fill="#97999a" d="m378 326 2 1c.35 6.47.21 11.29-3 17-1.6 1.19-1.6 1.19-3 2-.35-4.61-.35-4.61 0-7l2-1.87c2.43-2.58 2.27-3.44 2.25-6.88A135 135 0 0 0 378 326"/><path fill="#08090e" d="M390 306h3v21h-1l-1-9h-1z"/><path fill="#040209" d="M894 263h2l1 3-1.37 1.19c-1.9 2.12-2.7 4.14-3.63 6.81h-3c.58-3.37 1.63-4.63 4-7 .69-2.19.69-2.19 1-4m-6 11h1l-1 7h-2c.88-5.87.88-5.87 2-7"/><path fill="#69686b" d="M453 244h1c.27 6.07-.54 10.78-2.5 16.5l-.72 2.2q-.87 2.66-1.78 5.3h-1c-.25-5.44.27-9.82 2-15h2z"/><path fill="#313035" d="M809 165c-2.32 1.75-4.28 2.43-7.12 3-2.46.5-4.9 1-7.33 1.6l-2.36.59-2.15.54c-2.28.3-3.86-.04-6.04-.73 8.32-3.4 15.98-5.33 25-5"/><path fill="#848486" d="M922 146h5l-1.44.81C924 148 924 148 923 151l5-1c-1 2-1 2-3.75 3.13a24 24 0 0 1-8.25.87l-1-4 7-1z"/><path fill="#848485" d="m914 137-1 5 2 1c-1.19 1.5-1.19 1.5-3 3-2.69.19-2.69.19-5 0l4-1v-4l-3.25 1.06c-2.97.97-4.77.88-7.75-.06l1.86-.59 2.45-.78 2.43-.78c2.67-1 4.41-2.85 7.26-2.85"/><path fill="#030307" d="m547 119 2 1-2 2h9v1c-6.66 1.76-13.14 3.34-20 4l1.94-.87C540 125 540 125 541 123l-2-1 1.5-.4 1.94-.54 1.93-.52C546 120 546 120 547 119"/><path fill="#ededed" d="m1234 74 4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.06-.62 2.06-1 4l-5-1v-5l5 1z"/><path fill="#080a0f" d="M1267 1815h19l1 3h-12v-2h-8z"/><path fill="#e4e0e4" d="m1340.06 1804.94 1.94.06-1 5h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.06-.62-2.06-.62-4-1 1.12-4.97 1.12-4.97 4.06-5.06"/><path fill="#a3a5a6" d="M358 1726q2.5-.06 5 0c1 1 1 1 1.06 4.06L364 1733c-3 1-3 1-6 0z"/><path fill="#121319" d="M918 1670c7.47 1.54 12.7 3.4 18 9l-1 2c-4.73-1.82-7.45-3.34-11-7a64 64 0 0 0-6-3z"/><path fill="#22222c" d="m547 1588 2 1c-2.06 3.1-3.7 4.4-7 6l-.87 1.5c-1.86 2.48-4.2 2.8-7.13 3.5-2.2-.45-2.2-.45-4-1l1.71-.8 2.23-1.08 2.21-1.05C538 1595 538 1595 539 1593c1.6-1.04 1.6-1.04 3.5-2.06 3.42-1.86 3.42-1.86 4.5-2.94"/><path fill="#f3f3f4" d="m1452.06 1428.94 1.94.06-1 5h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-2.06-.62-2.06-.62-4-1 1.12-4.97 1.12-4.97 4.06-5.06"/><path fill="#d2ae96" d="M1364 1389c16.92 1.92 16.92 1.92 21 6-1 1-1 1-3.56 1.31-3.85-.35-6.13-1.47-9.47-3.29a44 44 0 0 0-7.97-3.02z"/><path fill="#cdcac1" d="M1340 1387q2.65-.12 5.31-.19l3-.1c2.69.29 2.69.29 4.55 1.8l1.14 1.49c-2.31 1.16-3.86 1.23-6.44 1.31l-2.37.12c-2.19-.43-2.19-.43-3.98-2.45z"/><path fill="#7e0e07" d="M1439 1367h4c-.75 1.94-.75 1.94-2 4-2.12.75-2.12.75-4 1l-1 4-10 2 1-3c1.6-.66 1.6-.66 3.5-1.06s1.9-.41 3.5-.94l1-2 3-1z"/><path fill="#6e0f03" d="m1420 1277 2 1-2 1zm-2 2 2 1-1 1-.31 3.44c-.24 1.9-.24 1.9-.69 3.56-2.06 1.44-2.06 1.44-4 2l-1-8c2.38-1.56 2.38-1.56 5-3"/><path fill="#d1b7a1" d="m1346 1276 1 3-1.46.76c-1.54 1.24-1.54 1.24-1.98 3.04l-.12 2.08c-.28 3.13-.74 5.4-2.44 8.12h-2c.51-5.21 1.44-10 3-15h4z"/><path fill="#73727c" d="M392 1235h1c1.87 6.75 2.23 13.02 2 20l-2-4-2 1c-.12-5.78.17-11.28 1-17"/><path fill="#1a1c20" d="M1470 1218c2.55.69 4.47 1.22 6 3.47.73 1.48 1.37 3 2 4.53-2.6.93-3.64 1.15-6.25.06L1470 1225z"/><path fill="#6b5849" d="M1084 1212v1l-5.18.49c-2.4.67-3.28 1.56-4.82 3.51-1.06 2.34-1.06 2.34-2 4.94a29 29 0 0 1-5 9.06c.57-6.84 2.83-11.9 6.75-17.5 3.32-2.21 6.38-1.84 10.25-1.5"/><path fill="#e3e8ed" d="m1530.06 1215.94 2.94.06c1 3 1 3 0 6h-7q-.06-2.5 0-5c1-1 1-1 4.06-1.06"/><path fill="#a1adb7" d="M1547 1163c2.14 3.48 2.21 6.21 2.13 10.25l-.06 3.27-.07 2.48h-3l-.06-7.44-.03-2.14q0-2.7.09-5.42z"/><path fill="#97a5b0" d="M1579 1154c1.52 4.56.42 8.48-1 13a42 42 0 0 1-5 7 22 22 0 0 1 .88-6.57l.54-2.03.58-2.09 2-7.31h2z"/><path fill="#d0d7df" d="m1607.56 1137.94 2.44.06v7q-2.49.57-5 1c-1-1-1-1-1.12-4 .16-3.98.16-3.98 3.68-4.06"/><path fill="#27262f" d="m1208 1093 3 1-1 1 2 2a41 41 0 0 1-6 5c-2.37-.19-2.37-.19-4-1 3.75-5.75 3.75-5.75 6-8"/><path fill="#35343e" d="m784 1082 2 1c-.25 1.88-.25 1.88-1 4l-2 .94-2 1.06c-.75 2.63-.75 2.63-1 5h-3l1-6 3-1 1-3z"/><path fill="#121219" d="M410 1064c1.24 2.94 1.01 3.97-.19 7a17 17 0 0 1-3.87 4.94c-2.67 2.83-3.07 5.3-3.94 9.06h-1c-.3-3.05-.45-5.96 0-9 2.5-2.31 2.5-2.31 5-4q.06-2 0-4c2-2.25 2-2.25 4-4"/><path fill="#cccbce" d="M283.5 1033.19q.9.07 1.84.17c1.66.64 1.66.64 3.66 4.64h-10l-1-4c2.23-1.11 3.08-1.08 5.5-.81"/><path fill="#dbdbdb" d="M167 1014h13l-2 4-11-1z"/><path fill="#b7c0c8" d="M1451 945c1.46 3.84.46 6.28-.87 10.06A62 62 0 0 0 1447 973h-1c-.43-10.01.2-18.95 5-28"/><path fill="#07070b" d="m247 962 1.55.4a90 90 0 0 0 16.85 2.26c2.6.34 2.6.34 5.6 2.34l-18-1v2h-2v-2l-4-1z"/><path fill="#010102" d="M1407 962h4l-1 10-6 1 2-1c.41-2.29.41-2.29.63-5.06l.22-2.79z"/><path fill="#a0a0a0" d="M266 959q3.94-.04 7.88-.06l2.26-.03h2.16l2-.02c1.7.11 1.7.11 3.7 1.11v2c-5.75.1-11.35.2-17-1z"/><path fill="#ebebed" d="m14 916 4 1 1 5-5-1v5l-4-1c.38-1.94.38-1.94 1-4l2-1c.63-2.06.63-2.06 1-4"/><path fill="#7a797d" d="M211 910c4.49.52 7.18 1.69 11 4 2.97 1.1 5.97 2.07 9 3v1c-6.89-.28-12.07-1.44-18-5v-2z"/><path fill="#353339" d="m842 911 1 3h-15c2-3 2-3 4.06-3.55l2.38-.2 2.37-.23C839 910 839 910 842 911"/><path fill="#8e8d8e" d="M151 870c4.14 3.39 7.82 6.68 11 11l-5-1-1 2h-2l-.4-1.86-.54-2.45-.52-2.43C152 873 152 873 151 870"/><path fill="#c6cdd1" d="M1494 833h5c1.47 4.42 1.27 8.74 1.31 13.38l.09 2.86c.07 7.8.07 7.8-2.42 10.92L1496 862l-2-1 1.98-2.42c2.75-4.13 2.5-8.02 2.33-12.83l-.03-2.47c-.11-5.94-.11-5.94-1.28-8.28-1.56-1.12-1.56-1.12-3-2"/><path fill="#4f5054" d="m1470 837 4 1-1 12h-3z"/><path fill="#26262c" d="M1198 810c-1.69 1.69-4.1 1.4-6.4 1.62l-1.6.38-1 2h6v1h-7l-1 3h-2v-3h-7v-1l10-1v-3q1.94-.3 3.88-.56l2.17-.32c1.95-.12 1.95-.12 3.95.88"/><path fill="#29292d" d="m1471 783 2 1-2.5 2c-2.35 2.33-2.88 3.89-2.94 7.19.1 3.06.18 3.55 2.44 5.81l-1 3-3-1-2 4q-.06-2.5 0-5l1-1q.15-2.62.25-5.25c.3-3.83.51-6.21 3.31-8.94z"/><path fill="#38383b" d="M379 791c4.67.56 7.94 1.77 12 4v2l8 2c-5.64 1.88-11.45-1.64-16.56-4.19L379 793z"/><path d="M536 770q3.19-.08 6.38-.12l1.82-.06c1.6-.01 3.2.08 4.8.18l2 2c-2.94 1.47-5.9 1.1-9.12 1.06l-2-.01L535 773z"/><path fill="#1b1a21" d="M1341 751h7v3l-5.31 2-3 1.13c-2.69.87-2.69.87-5.69.87v-3l2.94-.87C1340 753 1340 753 1341 751"/><path fill="#010007" d="M390 730h5v2l8 2-1 4-5-1v-3h-5z"/><path fill="#a6a5a7" d="m232.06 692.94 1.94.06-1 4-5 1-1 3h-5c1.46-3.85 3.44-5.08 7-7 1-1 1-1 3.06-1.06"/><path fill="#505053" d="m1489 687 2 1c-7.3 4.58-7.3 4.58-10.94 6.75C1478 696 1478 696 1477 697q-3 .06-6 0l3-1v-2l1.76-.77c3.61-1.6 7.2-3.18 10.68-5.04z"/><path fill="#ad7fde" d="M456 663h1q.09 4.3.13 8.63l.05 2.44c.03 4.78-.36 8.5-2.18 12.93h-1l.44-9.75.12-2.79.12-2.7.11-2.47c.2-2.2.61-4.17 1.21-6.29"/><path fill="#45444a" d="m298 670-2 2 2 2h-4l-1 3h-5l-1-3q1.9-1.3 3.81-2.56l2.15-1.44c2.04-1 2.04-1 5.04 0"/><path fill="#000102" d="M1644 644h2v6h-6c1.15-2.47 2.05-4.05 4-6m-4 6v4h-5v-3c3-1 3-1 5-1"/><path fill="#020206" d="m1584 632 1 3c-3.71 3.9-3.71 3.9-5 5h-2l-2 5h-5v-3l2.19-.69c3.5-1.63 5.18-3.47 7.81-6.31z"/><path fill="#aba3bd" d="M516 590h10v3h25v1h-27l-1-3h-7z"/><path fill="#ab9dc7" d="M743 587c-1.38 1.87-2.47 2.87-4.76 3.42q-2.61.34-5.24.58v-1l-13-1v-1l2.74-.18 3.57-.26 1.8-.12c12.26-.89 12.26-.89 14.89-.44"/><path fill="#ada6bf" d="M675 585v1l-16 2v1h-13v-1a97 97 0 0 1 29-3"/><path fill="#9d8cc1" d="M792 574h15v1l-3.12.88a103 103 0 0 0-7 2.25 21 21 0 0 1-7.88.87l3-1z"/><path fill="#05060c" d="M488 571h20v1h-9v2h-11z"/><path fill="#846ca9" d="M889 546c2.06.44 2.06.44 4 1l-1 3 5-1c-3.2 2.83-5.78 3.52-10 4l1-3h-10c2.77-1.85 3.55-2.25 6.63-2.56C888 547 888 547 889 546"/><path fill="#0e0e13" d="M739 533c-7.9 4.36-17.2 4.3-26 4v-1l5.37-.68C720 535 720 535 721 534c6.03-.99 11.9-1.11 18-1"/><path fill="#151517" d="M365 440h1v24l-4 1 1-3q.31-2.55.54-5.1l.26-2.98.26-3.1.28-3.15z"/><path fill="#000001" d="M1676 446h7l1 3c2.06.69 2.06.69 4 1v3h-5l-1-2q-2.97-1.1-6-2z"/><path fill="#090a10" d="M375 409c2 2 2 2 2.23 4.15l-.03 2.64-.02 2.85-.06 2.99-.02 3-.1 7.37h-1l-1-11h-1q-.05-2.71-.06-5.44l-.04-3.06c.1-2.5.1-2.5 1.1-3.5"/><path fill="#2f1c59" d="M1315 379c1.88.13 1.88.13 4 1 1.96 2.52 2.9 4.4 3.44 7.56-.58 3.2-2 4.38-4.44 6.44v-7h2l-3-5-3 3q-.06-2.5 0-5z"/><path fill="#0f1011" d="M392 274c.63 1.75.63 1.75 1 4l-.95 1.83c-1.22 2.52-1.36 4.36-1.46 7.15l-.12 2.84-.1 2.93-.11 2.98q-.15 3.64-.26 7.27h-3l.48-1.9c.84-5.01.84-10.08.95-15.15.18-4.8.55-8 3.57-11.95"/><path fill="#090a0d" d="m453 219 1 2 4-1c-.6 4.23-1.57 8-3 12h-1l-1-8-3 3 1-6h2z"/><path fill="#403f45" d="m542 174 2 1-2 2zm-2.44 2.94 2.44.06-1 4-2.31.44C536 182 536 182 533 183c-1.19 2.06-1.19 2.06-2 4l-2-1 3.44-3.94 1.93-2.21c2.5-2.85 2.5-2.85 5.2-2.91"/><path fill="#000001" d="m646 174 8.2.68c2.36.27 4.52.67 6.8 1.32v2q-2.9.08-5.81.13l-3.27.07c-4-.27-4-.27-5.92-2.2z"/><path fill="#000002" d="M631 166c2.92 1.07 4.78 1.78 7 4q2.97 1.07 6 2v2c-4.71.26-4.71.26-7 0-1.12-1.37-1.12-1.37-2-3-2.62-1.19-2.62-1.19-5-2z"/><path fill="#090a10" d="M438 150h3c.63 5.18-.74 7.67-3.71 11.68L436 163h-2v-6l3-1z"/><path fill="#39393d" d="M974 116c-4.13 2.95-8.45 3.4-13.37 4-4.25.53-8.44 1.1-12.63 2 1.06-1.96 1.06-1.96 3-4 2.46-.43 4.53-.65 7-.69 5.48-.18 10.52-1.43 16-1.31"/><path fill="#9a9c9d" d="M575 59h15l-1 3h-14z"/><path d="M1135 14h13v4l-12-1z"/><path fill="#7a7c7f" d="M1069 6h5l-1 4c-6.07 2.16-11.6 2.2-18 2l-1-2 2.05.07 2.7.05 2.67.08c2.73-.21 4.3-.73 6.58-2.2z"/><path fill="#333436" d="M464 1816h2l1 2c3 .32 6 .38 9.02.46 3.55.64 4.72 1.8 6.98 4.54l-3.37-.44-1.9-.24C476 1822 476 1822 474 1821q-3.5-.07-7 0h-3z"/><path fill="#3b3a42" d="M770 1728q1.73 1.43 3.44 2.88l1.93 1.61C777 1734 777 1734 778 1736c-2.65 1.46-3.9 2-7 2-1.48-3.84-2.22-6.06-1-10"/><path fill="#5f5e66" d="M1278 1713h1q.05 2.72.06 5.44l.04 3.06c-.1 2.5-.1 2.5-1.1 3.5-.24 2.22-.38 4.44-.52 6.68-.57 2.74-1.67 4.21-3.48 6.32.5-8.56 2.07-16.67 4-25"/><path fill="#000001" d="M1404 1713h2v13l-4-1 .44-5.44.24-3.06c.32-2.5.32-2.5 1.32-3.5"/><path fill="#010106" d="M921 1674h5l.69 1.94c1.31 2.06 1.31 2.06 3.93 2.81l2.38.25 1 5a16 16 0 0 1-5.94-4c-2.24-2.17-4.12-3.02-7.06-4z"/><path fill="#eeeeef" d="m336 1304 2 1v14l-2 1c-1.37-2.73-1.13-4.96-1.12-8l-.01-3.25c.13-2.75.13-2.75 1.13-4.75"/><path fill="#8a8a8d" d="m1523 1278 2 1c.41 2.5.41 2.5.63 5.56l.22 3.07.15 2.37-4 2-.06-6.44-.03-1.85q0-2.36.09-4.71z"/><path fill="#1e1e26" d="M846 1280c5.45 1.72 10.7 3.85 16 6l5 2v1c-5.67.39-9-.53-13.87-3.37l-1.76-.97c-4.22-2.36-4.22-2.36-5.37-4.66"/><path fill="#48362b" d="M1264 1265c.69 1.63.69 1.63 1 4q-.99 2.11-2.1 4.15c-1.5 3.08-2.27 6.38-3.15 9.68-.75 2.17-.75 2.17-2.75 4.17q.96-6.1 2.38-12.12l.7-3.08c.92-2.8 2-4.57 3.92-6.8"/><path fill="#d94a17" d="M1430 1245c4.03 2.07 5.93 3.97 8 8q-.86-.45-1.75-.94c-3.89-1.83-7.22-2.18-11.5-2.12l-2.7.02-2.05.04 2-1v-3l8 1z"/><path fill="#171a1f" d="M347 1221a14.4 14.4 0 0 1 2.19 8.5l-.02 2.28A31 31 0 0 1 348 1238h-2a1892 1892 0 0 1-.1-12.4l-.01-2.02c.11-1.58.11-1.58 1.11-2.58"/><path fill="#000103" d="M1031 1214v1l-2.12.3-2.76.45-2.74.42c-2.38.83-2.38.83-3.7 2.9l-.68 1.93h-3v-2h-10v-1l1.86-.15 2.45-.22 2.43-.22c2.33-.42 3.37-1.07 5.26-2.41 4.4-.83 8.51-1.1 13-1"/><path fill="#cfd5dc" d="M1523 1206c.31 2.25.31 2.25 0 5-2.37 2.43-3.89 3.88-7.31 4.25l-1.69-.25c1.5-3.69 4.65-9 9-9"/><path fill="#a3b0b8" d="m1546 1136 3 1v14l-2 1c-1.07-3.46-1.1-6.64-1.06-10.25l.02-3.27z"/><path fill="#181720" d="M1092 1137c-1 2-1 2-3.5 3.12-4.1 1.25-8.14 1.1-12.37 1l-2.38-.02-5.75-.1v-1l3.18-.4 4.13-.54 2.1-.26 2.02-.26 1.85-.24c7.87-1.36 7.87-1.36 10.72-1.3"/><path fill="#302f39" d="M893 1104c3.3.66 5.84 1.9 8.75 3.56L906 1110l-1 4-3-1v-3c-2.5-.69-4.38-1-7-1z"/><path fill="#010102" d="M1434 1062c2.73-.22 4.46-.3 6.88 1.06 1.79 3.1.83 5.5.12 8.94h-2l-.81-2.87c-1.18-3.11-1.46-3.64-4.19-5.13z"/><path fill="#000001" d="m1050 1033 12 1 1 3-5.87.06-3.31.04c-2.82-.1-2.82-.1-4.82-1.1z"/><path fill="#07090d" d="M242 1015h14l-1 3h-13z"/><path fill="#000001" d="M196 1006c4.61-.18 7.76-.05 12 2v2h-12z"/><path fill="#666466" d="M990 984c-2.62 2.62-4.49 2.58-8.12 3.13l-3.33.5-2.55.37-1 4-3-1 1-5c5.8-1.5 11-2.22 17-2"/><path fill="#040407" d="m993.31 975.31 1.69.69-1 2h9v1c-9.41 2.3-9.41 2.3-14 2v-2l-3-1c4.43-3.08 4.43-3.08 7.31-2.69"/><path fill="#bfbebe" d="M1210 955c-2.17 1.6-4 2.4-6.62 3l-2.09.5-2.16.5a85 85 0 0 0-13.13 4c-2.81.13-2.81.13-5 0a21 21 0 0 1 6.58-3.22l2.08-.64 6.36-1.92 2.04-.62q2.67-.84 5.3-1.78c2.76-.86 3.98-.84 6.64.18"/><path fill="#969697" d="m691.25 945.94 5.75.06c-.78 1.48-.78 1.48-2 3-2.63.22-5 .28-7.62.19l-2.15-.04L680 949c1-2 1-2 2.67-2.62 2.9-.47 5.65-.47 8.58-.44"/><path fill="#919193" d="M825 932c-3.14 1.4-5.55 2.26-9 2v-2h-12v-1c16.33-2.12 16.33-2.12 21 1"/><path fill="#95a3ab" d="M1521 894h1q.05 3.44.06 6.88l.03 1.97c.02 4.92.02 4.92-1.09 7.15q-.22 2.18-.32 4.35l-.12 2.58-.12 2.7-.44 9.37h-1c-.41-23.58-.41-23.58 2-35"/><path fill="#515154" d="m987 897-2.75.77-3.69 1.04-1.93.55a151 151 0 0 0-12.76 4.22c-2.26.5-3.7.13-5.87-.58l5-1v-2l3.18-.62 4.13-.82 2.1-.4 2.02-.4 1.85-.37c5.77-1.3 5.77-1.3 8.72-.39"/><path fill="#424046" d="M190 895c4.2-.35 5.78.38 9 3q2.47 1.08 5 2v2c-2.81.19-2.81.19-6 0-1.56-1.44-1.56-1.44-3-3-2.69-.69-2.69-.69-5-1z"/><path fill="#2c2b31" d="M1167 818h6l-1.94.88C1169 820 1169 820 1168 822h6v2c-3.43 1.25-6.32 2.28-10 2a29 29 0 0 1 3-8"/><path fill="#2a2a2c" d="m472 811 10 2v4l-16-1v-1h10v-2h-4z"/><path fill="#7e7e80" d="M1254 803v3l-10 3v-2h-5c1-2 1-2 2.64-2.63 4.24-1.02 8-1.59 12.36-1.37"/><path fill="#28272c" d="M941 801h8c-3.3 2.93-6.58 3.86-10.81 4.69l-1.8.39c-4.2.87-8.1 1.04-12.39.92v-1l6.25-1.5 1.78-.43c3.04-.72 5.84-1.26 8.97-1.07z"/><path fill="#35343a" d="m1260 792-2.8 1.06-3.64 1.38-1.85.7c-4.6 1.75-4.6 1.75-5.71 2.86q-3 .06-6 0c3.06-3.55 5.46-4.75 10-5.81l3.13-.77c2.73-.4 4.3-.33 6.87.58"/><path d="m1462 785 4 1v9l-4 1z"/><path fill="#222225" d="M61 754h1v5l2 1h-2l-1 2v-2h-3l-.75 1.81c-1.41 2.48-2.9 3.62-5.25 5.19l3-9 5-1z"/><path fill="#6841a9" d="m687.25 755.66 2.76.05 2.99.03 3.12.07 3.16.04q3.86.06 7.72.15v1l-9.6 1.29c-4.85.63-9.52.8-14.4.71 1.11-2.4 1.57-2.93 4.25-3.34"/><path fill="#a47bd7" d="M497 751h18v1c-6.07 2.16-11.6 2.2-18 2z"/><path fill="#3c2868" d="M792 747c-2.58 2.58-5.17 2.92-8.62 3.63l-1.9.4c-4.57.9-8.82 1.25-13.48.97 7.56-4.07 15.49-5.27 24-5"/><path fill="#4f4e53" d="m1530 743 4 1-2 2-.81 2.06c-1.19 1.94-1.19 1.94-3.44 2.5l-2.75.44c-2.07.9-4 1.93-6 3 1.14-3.42 2.21-4.75 5-7 2.75-.25 2.75-.25 5 0z"/><path fill="#010103" d="M1427 738v3l-6 1v3h-6c1.08-3.13 1.98-3.99 4.88-5.75 2.73-1.1 4.25-1.58 7.12-1.25"/><path fill="#010102" d="M1534 735h5c-.4 2.27-.89 3.83-2.19 5.75-2.6 1.8-4.73 1.46-7.81 1.25l1-4h4z"/><path fill="#9a9a9a" d="m198 732 1 2 3 1-7 8-2-4c1.25-2.75 1.25-2.75 3-5h2z"/><path fill="#16161c" d="m1432.56 710.94 2.44.06c-.2 1.9-.2 1.9-1 4-2.18 1.05-2.18 1.05-4.87 1.75l-2.68.73c-2.45.52-2.45.52-5.45.52 1-3 1-3 2.6-3.91l1.9-.72c6.35-2.42 6.35-2.42 7.06-2.43"/><path fill="#6f6d6d" d="m1490 694 2 1a73 73 0 0 1-10 7l-3.06 2.13c-2.48 1.58-4.08 2.43-6.94 2.87l1-5 3.31-.31c3.3-.47 4.47-1.18 6.69-3.69 1.96-1.1 3.97-2.03 6-3z"/><path fill="#959495" d="m248 690 .81 1.94C250 694 250 694 253 695a337 337 0 0 1-4.5 3.13c-1.5.87-1.5.87-3.5.87l-1 3-1-4 5-1v-3h-4v-3z"/><path fill="#b0b0b4" d="m1613 680 2 1-5.54 6.18c-4.82 5.37-4.82 5.37-6.46 6.82h-2l-1 3-1-4 2.81-1.69c3.04-2 4.4-4.14 6.19-7.31l3-1c1.19-1.56 1.19-1.56 2-3"/><path fill="#000003" d="M364 643h2c.18 9.41.18 9.41-1 14l-3-1c-.2-4.92.21-8.42 2-13"/><path fill="#7240bd" d="M550 620h9v1l-5 1-.68 6.84C553 631 553 631 552 633h-2z"/><path d="M241 626h6v3l-6 1v3h-6v-3l5-1z"/><path fill="#2a194c" d="M1245 586h6v3c-2 2-2 2-4.62 2.13L1244 591l-1 3h-6c1.18-2.35 2.05-3.02 4.44-4.25 2.56-.75 2.56-.75 5.56.25v-3z"/><path fill="#aca3bf" d="M444 582h15l-2 3c-2.47.22-4.67.28-7.12.19l-2-.04L443 585z"/><path fill="#000005" d="M512 578h29v1h-22v2h-6z"/><path fill="#baa2d8" d="M392 559h1l1 9 3-2v6h-2l-1 3-2-4-2 2 .44-2.5c.64-3.82 1.1-7.66 1.56-11.5"/><path fill="#271645" d="M1289 567c-4.37 4.69-9.3 7.2-15 10l-2.37 1.19-1.63.81c0-2 0-2 1.13-3.5 2.44-1.96 4.89-2.59 7.87-3.5q2.03-.95 4-2v-3c2.5-1.25 3.41-.78 6 0"/><path fill="#2c194f" d="M1301 558c-3.6 4.23-8.15 6.47-13 9-.2-2.3-.31-3.57 1.07-5.48 4.1-3.48 6.58-4.06 11.93-3.52"/><path fill="#3a393f" d="M636 535h27v3h-8v-1l-19-1z"/><path fill="#b1b1b2" d="m1703 455 1.19 1.31c1.81 1.69 1.81 1.69 4.25 3.06 2.92 1.86 4.6 3.8 6.56 6.63v2l2 1c-4.18-.49-6.45-3-9.25-5.87l-1.4-1.36c-3.35-3.39-3.35-3.39-3.35-6.77"/><path fill="#2c1e4b" d="m1204 421 2 1-1.24 1.43-1.63 1.88-1.62 1.87A47 47 0 0 0 1198 432l-6-1c3.68-3.9 7.56-7 12-10"/><path fill="#000001" d="M1592 414h12v4c-4.61.18-7.76.06-12-2z"/><path fill="#35235f" d="M1244 405v3a394 394 0 0 1-5 3 51 51 0 0 0-4 5l-3-1 5-6-2-1c3.12-2.08 5.2-3.42 9-3"/><path fill="#1e2023" d="m1506 386 4 1 1 4 5 1v1l-6.84.1c-2.16-.1-2.16-.1-4.16-1.1v-2h-7v-1l8-1z"/><path fill="#939496" d="M444 297h1v14l-4 1q.17-2.62.38-5.25l.2-2.95A17 17 0 0 1 444 297"/><path fill="#050309" d="M940 203c0 3.93-1.4 5.16-4 8h-2l-1 3a30 30 0 0 1-8 4c1.6-4.47 5.07-6.86 8.63-9.75l1.85-1.54q2.25-1.86 4.52-3.71"/><path fill="#858588" d="m609 186 1.69 1.06c2.85 1.16 4.4.75 7.31-.06l2 4h-2l1.56 2.38L621 196l-1 2c-4.56-3.61-8.02-6.99-11-12"/><path fill="#929394" d="m653 186 5.81-.06 3.27-.04c2.92.1 2.92.1 5.92 1.1v2h-15z"/><path fill="#555458" d="m888.2 172.36 1.8.64a79 79 0 0 1-23 11c1.6-3.13 3.44-3.75 6.69-4.88a33 33 0 0 0 10.74-6.26c1.57-.86 1.57-.86 3.77-.5"/><path fill="#1a191e" d="M629 170c2.88-.19 2.88-.19 6 0a94 94 0 0 1 2 3c3.13.69 3.13.69 6 1v3l5 1c-2.79 1.03-3.87 1.05-6.75.06L639 177v-2l-1.69-.37A46 46 0 0 1 630 172z"/><path fill="#565658" d="M585 129v2h-20v-2q3.88-.3 7.75-.56l2.21-.17 2.15-.15 1.97-.14c2.1.02 3.9.44 5.92 1.02"/><path fill="#2f2f35" d="M993 108c-2.56 3.13-5.1 4.05-9 5-2.68.15-5.32.12-8 0 4.1-5.31 10.87-5.25 17-5"/><path fill="#f1f2f2" d="M519 74h10c-1.56 2.5-1.56 2.5-4 5-2.48.45-4.45.25-7 0z"/><path fill="#bfbfc0" d="m710 68-1 2h-13v-3c5.44-1.11 8.75-.82 14 1"/><path fill="#bbbbbc" d="m678 64-1 2h-13v-3c5.44-1.11 8.75-.82 14 1"/><path fill="#8b8b8e" d="M1003 22h7v5l-7 1c-1-3-1-3 0-6"/><path fill="#8d8d8f" d="M1289 1830h12v3l-11 1z"/><path fill="#99999b" d="M467 1830h12l-1 4-11-1z"/><path fill="#8d8d8d" d="M455 1826h12l-1 4-11-1z"/><path fill="#aaa" d="M443 1822h12l-1 4-11-1z"/><path fill="#1d1c23" d="M457 1757c6.72 2 12.8 4.73 19 8v1c-6.3.5-6.3.5-9.62-1.87L464 1762l-2.69-1.06c-2.53-1.03-2.97-1.68-4.31-3.94"/><path fill="#010207" d="M789 1735h20v1l-8 1v1q-2.44.05-4.87.06l-2.75.04c-2.38-.1-2.38-.1-4.38-1.1z"/><path fill="#908f96" d="M1291 1720c.25 2.81.25 2.81 0 6-2 1.88-2 1.88-4 3v-2h-5l-1 3 1-5h4l1-3c2.53-2 2.53-2 4-2"/><path fill="#d0d2d1" d="M350 1703c4.92 4.18 4.92 4.18 5.31 8.31L355 1714l-5-1z"/><path fill="#969599" d="m1422 1701 4 1-1 11h-3z"/><path fill="#242427" d="M1418 1670h1l.15 1.71.22 2.23.22 2.21.41 1.85 2 1v3l-1.87.69c-2.74 1.69-3.18 3.3-4.13 6.31-1.36-3.39-.64-5.66.45-9.05.92-3.26 1.22-6.59 1.55-9.95"/><path fill="#000003" d="M1325 1654c1.47 4 .69 7.65.06 11.75l-.3 2.11-.76 5.14h-2c-.4-6.98.37-12.52 3-19"/><path fill="#07080e" d="M1207 1601c-5.76 2.96-10.39 3.44-16.81 3.31l-2.6.01c-6.32-.05-6.32-.05-8.59-2.32l1.56.03 7.07.1 2.44.05c5.44.04 12.08-3.05 16.93-1.18"/><path fill="#010107" d="M1289 1478q2.94.17 5.88.38l3.3.2c2.82.42 2.82.42 4.82 2.42-1 1-1 1-3.65 1.13h-6.56c-2.79-.13-2.79-.13-4.79-1.13z"/><path fill="#6f6e73" d="M1500 1375h5l1 2-3 1-.19 2.38-.81 2.62c-2.44 1.13-2.44 1.13-5 2-1.31 1.63-1.31 1.63-2 3 .75-4.75.75-4.75 3-7q1.08-2.97 2-6"/><path fill="#db4b0e" d="M1352 1368c2.38.25 2.38.25 5 1 1.19 2 1.19 2 2 4l2 1v3h2l-1 3c-4.06-2.09-5.92-3.94-8-8l-1.19-2.31z"/><path fill="#1f2028" d="m909 1352 2 1q-1.83 1.74-3.69 3.44l-2.07 1.93c-2.46 1.8-4.25 2.26-7.24 2.63l-1 3-6 1v2l-2-1c1.31-1.5 1.31-1.5 3-3h3v-2l1.8-.7c5.11-2.11 8.58-4.1 12.2-8.3"/><path fill="#939397" d="m1518 1353 4 1-1 11h-3z"/><path fill="#c93f09" d="m1400 1353 2 1a39 39 0 0 1 1 5h2c2.77 3 3.86 6.13 5 10l-4-1-1-5h-2c-3.43-6.29-3.43-6.29-3-10"/><path fill="#e44605" d="M1341 1300h1q.05 2.72.06 5.44l.04 3.06c-.1 2.5-.1 2.5-1.1 3.5q-.23 2.67-.32 5.35l-.12 3.27-.12 3.44-.44 11.94h-1v-27h2z"/><path fill="#cfc8bf" d="M1300 1297h2c-.5 2.17-1 4-2 6l.43 1.67c.78 3.2.68 6.32.63 9.58l-.01 1.97-.05 4.78h-1l-1-10h-1q-.08-2.65-.12-5.31l-.08-3c.2-2.64.36-3.8 2.2-5.69"/><path fill="#14141b" d="M864 1287c5.09.58 9.57 1.68 14.38 3.44l1.9.66c4.6 1.65 4.6 1.65 5.72 3.9q-4.47-.88-8.94-1.81l-2.57-.5-2.45-.52-2.27-.46-1.77-.71-1-3z"/><path fill="#858388" d="m1519 1266 2 1c.41 2.5.41 2.5.63 5.56l.22 3.07.15 2.37-4 1-.06-5.94-.04-3.34c.1-2.72.1-2.72 1.1-3.72"/><path fill="#9f846e" d="M996 1239h13v4l-12-1z"/><path fill="#ced6de" d="M1521 1226h12v3l-11 1z"/><path fill="#926c4e" d="m1261 1210 10 1v3h-21v-1l13-1z"/><path fill="#565560" d="M436 1197h1c1.13 5.35 1.1 10.55 1 16l-2-2-1 9h-1c-.32-7.9.64-15.24 2-23"/><path fill="#525357" d="M1444 1195a39 39 0 0 1 1 5h5l2 5-1.62-.87c-2.38-1.13-2.38-1.13-5.57-2L1442 1201c-.87-2.12-.87-2.12-1-4h-6v-1q1.68-.55 3.38-1.06l1.9-.6c1.72-.34 1.72-.34 3.72.66"/><path fill="#4a4855" d="M506 1189q2.22.17 4.44.38l2.5.2 2.06.42 1 2h-2l-1 2h-8z"/><path fill="#07070d" d="M715 1151h2c.27 2.26.27 2.26 0 5-1.86 2.05-1.86 2.05-4.31 3.88l-2.43 1.86L708 1163l-3-1 2.19-2.12a77 77 0 0 0 6.7-7.74z"/><path fill="#b5bfc8" d="m1614 1133 4 1-1 11h-3z"/><path fill="#c0c8cf" d="m1615 1078 2 1c.41 2.5.41 2.5.63 5.56l.22 3.07.15 2.37-4 1-.06-5.94-.04-3.34c.1-2.72.1-2.72 1.1-3.72"/><path fill="#3a383c" d="m1441 1064 3 1 .18 1.57c.47 3.85 1 7.64 1.82 11.43l-5 1z"/><path fill="#99999b" d="M195 1022h12l-1 4-11-1z"/><path fill="#a2a2a4" d="M155 1010h12l-1 4-11-1z"/><path fill="#a6a6a8" d="M143 1006h12l-1 4-11-1z"/><path fill="#c9c9c9" d="M993 999h6v3l6 1v3c-4.67.7-7.03-1.08-10.64-3.71L993 1001z"/><path fill="#000001" d="m145 990 11 1v3h-12z"/><path fill="#050509" d="M277 969h14l2 4c-1 1-1 1-3.5 1.1l-3.06-.04-3.07-.02L281 974v-1l7-1-11-2z"/><path fill="#8b8b8d" d="m146 927 5.37.68C153 928 153 928 154 929l3.31.38c3.3.42 5.29 1.22 7.69 3.62v2a248 248 0 0 1-19-7zm15 0h2l1 3h-3z"/><path fill="#000002" d="m956 909-1 3c-2.76 1.38-5.05 1.1-8.12 1.06l-3.33-.02L941 913v-2l6.38-1 1.82-.29c4.57-.71 4.57-.71 6.8-.71M106 910h5l1 4h5v4l-6-1v-3l-5-1z"/><path fill="#a6a6a7" d="M82 870c3.76 1.25 4.15 2.63 6 6l1 1q.06 2.5 0 5l-4-1v-5l-3-1z"/><path fill="#98a3ac" d="M1494 863h2v2l3 1-1 2 4-1c1 2 1 2 .06 5.13L1501 875h-3l-1-9-3-1z"/><path fill="#16161a" d="M1108 865c-3 1.5-5.66 1.06-9 1v3c-3 .87-5.64 1.1-8.75 1.06l-2.42-.02-1.83-.04c5.66-4.15 15.22-7.35 22-5"/><path fill="#cad0d5" d="m1531 854 2 1c.41 2.5.41 2.5.63 5.56l.22 3.07.15 2.37-4 1-.06-5.94-.04-3.34c.1-2.72.1-2.72 1.1-3.72"/><path fill="#6a696b" d="M1161 845h-3v2l-2.55.59-3.33.78-3.3.78c-2.82.85-2.82.85-4.82 2.85-2.12.13-2.12.13-4 0v-2q4-1.52 8-3l2.4-.9 2.35-.85 2.17-.8c2.37-.51 3.8-.21 6.08.55"/><path fill="#878687" d="M1122 846c-2.32 2.68-4.32 3.66-7.69 4.69l-2.45.76-1.86.55-3-3c5.26-2.63 9.15-3.41 15-3"/><path fill="#838182" d="M1134 844c-4.79 3.67-7.07 4.11-13 4l-1-3c5.03-2.14 8.67-2 14-1"/><path fill="#878789" d="m1210.06 814.94 1.94.06v2c-5.55 2.82-9.74 4.53-16 4 .71-1.43.71-1.43 2-3 2.13-.7 2.13-.7 4.56-1.12 2.74-.5 4.95-1.87 7.5-1.94"/><path fill="#313134" d="M439 813q1.94-.08 3.88-.12l2.17-.08c1.95.2 1.95.2 3.95 2.2 1.73.63 1.73.63 3.63 1.13l3.37.87v1c-11.52-.78-11.52-.78-17-3z"/><path fill="#0a0a0e" d="m1290 799 2 1-2.94 1.38C1286 803 1286 803 1285 805l3 1h-5l-1 2v-2h-6c4.18-3.5 8.88-5.3 14-7"/><path fill="#000004" d="m1371.63 765.88 2.37.12v3l-1.71.62-2.23.82-2.21.8c-1.85.76-1.85.76-2.85 1.76q-2.5.06-5 0l1-3q2.97-1.07 6-2c2-2 2-2 4.63-2.12"/><path fill="#000002" d="M479 762c5.77-.2 10.64-.26 16 2v1h-16z"/><path fill="#442872" d="m706.88 758.88 2.37.02 5.75.1v1c-6.36.74-12.6 1.14-19 1v2h-8c6.46-3.48 11.64-4.28 18.88-4.12"/><path fill="#3a3a3f" d="m1405.13 728.43 1.87.57-7.85 4.34c-4.54 2.47-8.88 4.66-14.15 4.66 2.35-2.7 4.53-3.78 7.88-5a41 41 0 0 0 8-3.81c2.12-1.19 2.12-1.19 4.25-.76"/><path fill="#040407" d="M107 714h7v3l3 1h-3l-1 2v-2h-5l-1 3v-3l-4-1 3-1z"/><path fill="#3d2765" d="M935 715v1l-2.96.83q-4.16 1.2-8.3 2.5l-2.2.67-4.49 1.4-2.19.67-1.96.62c-2.23.36-3.76-.01-5.9-.69l13.11-4.37a305 305 0 0 0 5.14-1.79c3.48-1.07 6.14-.98 9.75-.84"/><path fill="#c69ff8" d="M379 709c2.44.81 2.44.81 5 2l1 3c2.06.69 2.06.69 4 1v4c-7.38-2.3-7.38-2.3-9-4-.69-3.12-.69-3.12-1-6"/><path fill="#979696" d="m237 694 5 1c0 3 0 3-1.62 5.25C238 702 238 702 234.8 701.88L232 701l-1-2 5-1z"/><path fill="#412870" d="M1024 688c-6.7 3.69-13.46 5.06-21 6 .6-1.93.6-1.93 2-4 2.25-.61 4.14-.96 6.44-1.19 11.08-1.3 11.08-1.3 12.56-.81"/><path fill="#000103" d="M1598 686c2.19.31 2.19.31 4 1v3h-5l-1 4h-5v-3l1.81-.81c2.19-1.19 2.19-1.19 3.69-2.88z"/><path fill="#000001" d="M193 654h6v3l-6 1v3h-6v-3l5-1zM368 628h2v12l-4 1 .44-5.94.24-3.34C367 629 367 629 368 628"/><path fill="#4a2982" d="m910.19 546.38 1.81.62v1l6 1v1h-8l-1 3-3-1 1-2h-6l1.81-.87C905 548 905 548 906.5 546.8c1.5-.81 1.5-.81 3.69-.43"/><path fill="#101117" d="M1653 539c1 3 1 3 0 5.5-7.8 14.3-7.8 14.3-12 16.5 1.73-4.97 4.43-9.32 7.13-13.81l1.41-2.4z"/><path fill="#949497" d="m1730 537 4 1-1 11h-3z"/><path fill="#bdbebf" d="M347 533c.24 6.42.24 6.42-.81 8.94C345 543 345 543 342 543v-8c3.88-2 3.88-2 5-2"/><path fill="#8b8b8e" d="M442 486c2 2 2 2 2.16 3.77l-.1 2.04c-.07 2.46-.05 4.23.56 6.63.6 4.02-.6 7.67-1.62 11.56h-1z"/><path fill="#f7f7f7" d="M354 486c.35 5.19.35 8.71-3 13h-1v-12c3-1 3-1 4-1"/><path fill="#6f6f72" d="m1618 406 11 1v3h-12z"/><path fill="#909192" d="m1606 402 11 1v3h-12z"/><path fill="#9b9b9d" d="m1594 398 11 1v3h-12z"/><path fill="#969799" d="M428 386h1q.12 3.38.19 6.75l.07 1.92c.07 4.1-.4 6.35-3.26 9.33-.37-12.67-.37-12.67 2-18"/><path fill="#959295" d="m1566 390 11 1v3h-12z"/><path fill="#131417" d="M381 332h1v19l-4 1-1 4v-6l2-1c.41-1.67.41-1.67.63-3.82l.26-2.31.24-2.43.26-2.45z"/><path fill="#0c0a11" d="M894 261c1 2 1 2 .25 4.44-1.2 2.47-2.3 3.69-4.25 5.56l-.94 2c-1.28 2.42-2.8 3.5-5.06 5-.25-1.75-.25-1.75 0-4q1.97-2.04 4-4 1.63-2.65 3.07-5.41C892 263 892 263 894 261"/><path fill="#141418" d="M1302 239h1l1 7h3l.31 3.38c.27 1.96.27 1.96.69 3.62l1.5 1c1.5 1 1.5 1 1.88 2.6q.24 1.87.4 3.74c.22 1.66.22 1.66 1.22 3.66l-3-1-1-6-3-1v-9h-4z"/><path fill="#333238" d="M642 222c3.24.53 5.3 1.2 8 3q2.49.58 5 1v3h-6l-1-3-7-1z"/><path fill="#8c8b8d" d="m392 214 2 1v12l-4-1 .44-5.44.24-3.06c.32-2.5.32-2.5 1.32-3.5"/><path fill="#000001" d="M410 215c.08 4.2 0 7.9-1 12h-3v-11c2-1 2-1 4-1"/><path fill="#898a8c" d="m396 202 2 1v12l-4-1 .44-5.44.24-3.06c.32-2.5.32-2.5 1.32-3.5"/><path fill="#8f9091" d="m400 190 2 1v12l-4-1 .44-5.44.24-3.06c.32-2.5.32-2.5 1.32-3.5"/><path fill="#a2a3a4" d="m404 178 2 1v12l-4-1 .44-5.44.24-3.06c.32-2.5.32-2.5 1.32-3.5"/><path fill="#868587" d="M637 181q2.85-.08 5.69-.12l3.2-.08c3.13.2 5.29.87 8.11 2.2v1q-2.5.06-5 0l-1-1a86 86 0 0 0-4.56-.56L639 182v3l1.88.38C643 186 643 186 645 188c-3 1-3 1-6 1a13 13 0 0 1-2-8"/><path fill="#2c2930" d="M903 170v3l-2.37.31c-2.63.69-2.63.69-3.57 2.25L896 177c-3.12.19-3.12.19-6 0 1.31-2 1.31-2 3-4h3v-2c2.46-1.23 4.28-1.07 7-1"/><path fill="#313035" d="m928 154 2 1c-.37 2.44-.37 2.44-1 5l-2 1v-3l-2.62.88-1.65.54q-2.5.85-4.98 1.77C915 162 915 162 913 161c9.87-5.05 9.87-5.05 15-7"/><path fill="#1e1d22" d="m1254 129 .81 1.81c1.19 2.19 1.19 2.19 2.66 3.58 3.54 3.73 4.76 9.65 5.53 14.61l-.69-1.44c-1.83-2.18-3.56-2.2-6.31-2.56l2-1v-7h-4z"/><path fill="#302f36" d="M988 122h6l-1 4-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-4-1h3v-3l-3-1h4z"/><path fill="#2a292e" d="M955 113c2.06.44 2.06.44 4 1-.56 1.44-.56 1.44-2 3q-2.66.4-5.34.68c-1.66.32-1.66.32-3.16 1.88-1.88 1.8-2.64 1.64-5.19 1.63L940 121c4.28-3.1 9.13-5.05 14-7z"/><path fill="#d0d0d0" d="m455 109 6 2c-.75 2.44-.75 2.44-2 5-2.12.81-2.12.81-4 1l-2-6z"/><path fill="#b9b9bc" d="M507 78h7c-.87 4.88-.87 4.88-2 6q-2.5.06-5 0c-1-3-1-3 0-6"/><path fill="#212023" d="m678 74 4 1-1 4 11 1v1h-15v-3h-6v-1l7-1z"/><path fill="#464b4c" d="m750 74 11 1v3h-13z"/><path fill="#a4a5a7" d="M519 71v3h-12v-3c4.38-.77 7.64-1.19 12 0"/><path fill="#929497" d="M883 67v3h-12v-3c4.38-.77 7.64-1.19 12 0"/><path fill="#a2a4a6" d="M991 23v3h-12v-3c4.38-.77 7.64-1.19 12 0"/><path fill="#8c8d8e" d="M1003 19v3h-12v-3c4.38-.77 7.64-1.19 12 0"/><path fill="#807f82" d="M1015 15v3h-12v-3c4.38-.77 7.64-1.19 12 0"/><path fill="#89898b" d="M1027 11v3h-12v-3c4.38-.77 7.64-1.19 12 0"/><path fill="#b2b2b4" d="M1141 6h8l1 5h-8z"/><path fill="#9a9a9d" d="M1039 7v3h-12V7c4.38-.77 7.64-1.19 12 0"/><path fill="#f9f9f9" d="M1124 2h12l1 4c-5.19.35-8.71.35-13-3z"/><path fill="#020203" d="M413 1787h4l1 2 2.56 1.5 2.44 1.5v2c-3.66.33-5.4.46-8.37-1.81C413 1790 413 1790 413 1787"/><path fill="#3e3d46" d="M440 1748a55 55 0 0 1 17 8c-2.66 1.02-3.76 1.09-6.47.07l-2.6-1.38-2.59-1.37c-4.07-2.3-4.07-2.3-5.34-3.32z"/><path fill="#4b4954" d="M913 1662h10l1 4h-9z"/><path fill="#000105" d="M1253 1589c.36 4.24-.46 6.58-3 10h-2l-1 2c-.63-7.17-.63-7.17 1.88-10.5 2.12-1.5 2.12-1.5 4.12-1.5"/><path fill="#0d0f14" d="M349 1456c2 2 2 2 2.23 3.7l-.03 2-.02 2.16-.06 2.27-.02 2.28-.1 5.59h-1l-1-7-3-1v-4h2z"/><path fill="#0e0f13" d="M1471 1399h3v4h-3l1 3 1.81-1c2.19-1 2.19-1 5.19-1l-7 7-2-1c-.48-1.74-.48-1.74-.75-3.87l-.3-2.12c.06-2.36.41-3.32 2.05-5.01"/><path fill="#53321c" d="M1252 1374q3.04.14 6.06.44l5.94.56 1 3q-2.94.12-5.87.19l-3.31.1c-2.82-.29-2.82-.29-4.2-1.8l-.62-1.49z"/><path fill="#282830" d="m887 1297 7.46 3a183 183 0 0 0 13.54 5v2a53 53 0 0 1-20-7z"/><path fill="#cbc2b5" d="m1339 1273 3 1c-.37 2.44-.37 2.44-1 5l-2 1c-.63 1.63-.63 1.63-1.12 3.56l-.51 1.94-.37 1.5c-1.48-2.35-2.03-3.63-1.81-6.44.9-2.84 2.18-5.08 3.81-7.56"/><path fill="#a7b4bf" d="M1529 1203h4v4l2 1-1 2h6v-3l4 1h-2l-1 3c-7.11.62-7.11.62-10.5-1.75-1.6-2.4-1.9-3.44-1.5-6.25"/><path fill="#927257" d="M1167 1207v1l-2.15.15-2.79.22-2.77.22-2.29.41-1 2-3-1a67 67 0 0 0-3.8-.32l-2.15-.12-2.24-.12-7.81-.44v-1a302 302 0 0 1 30-1"/><path fill="#302e39" d="m909.85 1206.9 2.21.04 3.94.06v3h-8l-2 4h-2v-2h-4v-2l1.5-.4 1.94-.54 1.93-.52c2.02-.67 2.3-1.52 4.48-1.64"/><path fill="#22232c" d="M986 1189q-4.69 1.3-9.37 2.56l-2.7.75-2.58.7-2.38.64c-1.97.35-1.97.35-3.97-.65l3-1v-2q3.38-.55 6.75-1.06l1.92-.32c3.44-.52 6-.6 9.33.38"/><path fill="#000106" d="M708 1155c3 1 3 1 4 3l-3.31 2.5-1.87 1.4C705 1163 705 1163 702 1163v-4l1.88-.31c2.44-.8 2.96-1.47 4.12-3.69"/><path fill="#a9b3bd" d="m1610 1145 4 1-1 11h-3z"/><path fill="#21212a" d="M961 1130q6.51.9 13 2v2l14 2v1c-10.22-.27-18.35-1.04-27-7M790 1060c1 2 1 2 .58 3.88-4.9 14.28-4.9 14.28-8.58 16.12-1.12 1.56-1.12 1.56-2 3l1-5 3-1c.66-1.82.66-1.82 1.06-4.12l.48-2.45.46-2.43c.56-2.4 1.22-4.66 2-7z"/><path fill="#fefefe" d="m1447 1061 3 1v11l-4 1z"/><path fill="#d2d7db" d="m1443 1008 1 4h2c2.56 3.84 2.2 7.58 2 12l-2-4h-2c-1.32-3.95-1.09-7.89-1-12"/><path fill="#e4e3e2" d="m1199 1003 4 1c0 3 0 3-1.12 4.69-2.64 1.85-4.74 1.52-7.88 1.31v-4h4z"/><path fill="#cac9c8" d="M1036 1000v1l-1.5.4c-.32.1-.32.1-1.94.54l-1.93.52c-1.63.54-1.63.54-2.63 1.54-5 1.44-9.8 2.27-15 2 2.42-2.91 4.31-3.52 8-4.12 11.45-1.95 11.45-1.95 15-1.88"/><path fill="#cacfd5" d="m1539 986 2 1c.41 2.5.41 2.5.63 5.56l.22 3.07.15 2.37-4 1-.06-5.94-.04-3.34c.1-2.72.1-2.72 1.1-3.72"/><path fill="#07080c" d="M599 978h9v2h14v1h-22z"/><path fill="#000001" d="M689 962h13v3h-14z"/><path fill="#000003" d="m1091.69 949.94 2.45.02 1.86.04-2 4h-9l-1-3c2.88-.96 4.7-1.1 7.69-1.06"/><path fill="#c1c1c2" d="m1431 939 1 2c-.94 2.63-.94 2.63-2 5l3 1-3.31.19c-1.94.26-1.94.26-3.69.81-1.81 2.5-1.81 2.5-3 5l-2 1c1.32-6.44 5.56-10.35 10-15"/><path fill="#7a7a7d" d="M812 923v1l-7.5 2-2.1.58A48 48 0 0 1 788 928v-1l7.5-2 2.1-.58c4.94-1.28 9.3-1.6 14.4-1.42"/><path fill="#616065" d="M804 923v1l-7.5 2-2.1.58A48 48 0 0 1 780 928v-1l7.5-2 2.1-.58c4.94-1.28 9.3-1.6 14.4-1.42"/><path fill="#35343b" d="M741 927h17l-1 3h-11v-2h-5z"/><path fill="#141319" d="M1291 904c-1.26 2.51-2.5 2.87-5 4l.04 1.5c.06 4.62-.28 8.94-1.04 13.5h-1q-.37-3.9-.69-7.81l-.22-2.24c-.42-5.45-.42-5.45 1.22-7.95 2.3-1.36 4.07-1.21 6.69-1"/><path fill="#838285" d="M190 901q2.44.63 4.88 1.31l2.74.74c2.64 1.05 2.9 1.69 4.38 3.95 1.91.44 1.91.44 4.06.56 2.16.13 2.16.13 3.94.44l1 2c-5.83.42-9.36-.71-14.44-3.5l-1.93-1.01q-2.32-1.23-4.63-2.49z"/><path fill="#9c9b9d" d="M167 890h5l1 2a40 40 0 0 0 4 2v3h-5l-1-2c-2.06-1.12-2.06-1.12-4-2z"/><path fill="#302e34" d="m1044 859 2 1Zm-4 1c2.78-.15 5.3.34 8 1a19 19 0 0 1-6.06 3c-2.94 1-2.94 1-4.94 3-2.16.41-2.16.41-4.62.63l-2.48.22-1.9.15 1-2 3.31-.31c1.93-.2 1.93-.2 3.69-.69l1.94-2.62z"/><path fill="#3b3a3f" d="M528 832q3.66-.12 7.31-.19l2.1-.07c5.15-.08 5.15-.08 7.57 1.77L546 835c-6.08.13-11.97-.2-18-1z"/><path fill="#c6c7c7" d="M7 826c.5 6.38.5 6.38-1.94 9.44C3 837 3 837 2 837v-9c4-2 4-2 5-2"/><path fill="#878688" d="M1298 788v2h5c-3.64 3.32-5.94 4.53-11 5l1-2h2v-2h-5c2.56-1.83 4.77-3 8-3"/><path fill="#111014" d="M999 764c-6.64 3.52-12.51 4.72-20 5 5.93-4.14 12.8-7.03 20-5"/><path fill="#7f7e7f" d="M1353 763h6v3h-6zm0 3v3l-7 1 1-3c2.22-1.11 3.56-1.08 6-1"/><path fill="#07080d" d="M62 754h4v6l2 1h-3v4h-3z"/><path fill="#555659" d="m1384.06 738.94 2.94.06c-1.94 3.89-7.2 5.2-11 7l-2.08 1.03c-3.47 1.63-5.29 2.12-8.92.97l4.81-2 2.71-1.12c2.48-.88 2.48-.88 5.48-.88l.94-1.94c1.57-3.05 1.57-3.05 5.12-3.12"/><path fill="#1a1b1f" d="M1428 736q-3.33 1.98-6.69 3.94l-1.9 1.13c-3.45 2-6.37 3.44-10.41 3.93a36 36 0 0 1 9.5-7.31l2.84-1.62c2.84-1.14 3.87-1.2 6.66-.07"/><path fill="#000005" d="M856.26 737.7q1.94.04 3.87.11l2 .04 4.87.15-1 2c-3.29 1.1-5.74 1.1-9.19 1.06l-3.29-.02L851 741c1.14-3.05 1.96-3 5.26-3.3"/><path fill="#59398d" d="M835 738q-3.52 1.01-7.06 2l-2 .58A40 40 0 0 1 813 742c4.07-3.22 7.31-4.14 12.44-4.62l2.87-.3c2.5-.07 4.33.17 6.69.92"/><path fill="#010105" d="M1478 710v3l-6 1-1 3h-4c.19-1.81.19-1.81 1-4 3.37-2.62 5.76-3.36 10-3"/><path fill="#7e7f81" d="m230 691 2 1c-3.2 3.6-5.8 5.6-10.3 7.3-1.7.7-1.7.7-4.51 2.45L215 703l-2-1c8.42-6.45 8.42-6.45 13-9l2.19-1.25z"/><path fill="#050509" d="m1500 692 2 1-2 3 2 1-4 1v3l-2-1 1-2c-3 1-3.95 1.75-6 4l-3-1a68 68 0 0 1 11-8z"/><path fill="#000002" d="M161 674h6v3l-6 1v3h-6v-3l5-1z"/><path fill="#131319" d="M321 653h1c-.45 5.29-1.1 10.04-3 15h-2v6h-1c-.35-5.03-.01-8.77 2.2-13.32 1.19-2.49 1.97-5.06 2.8-7.68"/><path fill="#020307" d="m1650 634 4 1h-3l.63 2.25c.42 3.14.21 4.2-1.63 6.75-2.12 1.25-2.12 1.25-4 2-1.1-3.29-.8-4.71 0-8h4z"/><path fill="#a59db9" d="M541 594h20c-2.9 1.94-3.56 2.26-6.8 2.41l-2.15.12-2.24.1-2.27.11q-2.78.14-5.54.26z"/><path fill="#000001" d="m440 567 5.88-.06 3.3-.04c2.82.1 2.82.1 4.82 1.1v2h-14z"/><path fill="#0d0e11" d="M1703 558h3l1 6 3 1v-5h1v6l-3 1-1 6h-1v-8h-3z"/><path fill="#ac99c4" d="M858 557v3c-3.16.94-5.97 1.1-9.25 1.06l-2.7-.02L844 561c1-2 1-2 3.63-3.12 3.7-.97 6.57-1.03 10.37-.88"/><path fill="#04030b" d="M819 554h22v1l-5.69 1.5-3.2.84c-2.82.6-5.24.79-8.11.66l3-1v-2h-8z"/><path fill="#5b4f6c" d="M889 547c-1.63 1.63-3.7 1.6-5.94 2-4.85.9-4.85.9-7.06 2q-2.06.1-4.12.06l-2.2-.02L868 551c4.96-4.96 14.48-5.96 21-4"/><path fill="#1f1135" d="m1007.06 508.94 1.94.06-1 3-14 1v2l-7 1 1-3c4.67-1.75 9.22-2.34 14.18-2.75 2.31-.32 2.47-1.24 4.88-1.31"/><path fill="#3e4345" d="M355 497h1c.23 5.09.05 9.23-2 14l-2 2v-3h-2c.31-2.37.31-2.37 1-5q.74-.46 1.5-.94c1.5-1.06 1.5-1.06 2.19-4.19z"/><path fill="#000001" d="M1689 454h6l1 3c2.06.69 2.06.69 4 1v4l-5-1v-3l-6-1z"/><path fill="#0b0b0f" d="M1683 447c2.94.75 2.94.75 6 2 .88 2.13.88 2.13 1 4l4 1h-6l-1 3h-5v-3l6-1v-3l-5-1z"/><path fill="#575758" d="M364 436h1c.55 13.6.55 13.6-3 20l-1 2h-2a38 38 0 0 1 2.5-8.75c1.7-4.35 2.19-8.61 2.5-13.25"/><path fill="#121215" d="M369 414h1v21l-4 1v-2h2l-.04-2.6c-.04-5.89-.04-11.59 1.04-17.4"/><path fill="#000001" d="M1617 422h11v4c-7.43.14-7.43.14-11-1z"/><path fill="#412b71" d="m1270 389 2 1-1.15.96-5.16 4.35-1.8 1.52-1.75 1.47-1.6 1.35c-1.54 1.35-1.54 1.35-3.1 3.04L1256 404c-2.19-.31-2.19-.31-4-1a21 21 0 0 1 5.28-4.34 72 72 0 0 0 8.03-5.85l2.68-2.17z"/><path fill="#4d4c53" d="m1378 366 12 1v3h-12z"/><path fill="#090a10" d="M383 353c3.1 3.1 2.85 6.74 3 11-.18 2.4-.55 4.63-1 7h-1l-1-6h-1q-.05-2.71-.06-5.44l-.04-3.06c.1-2.5.1-2.5 1.1-3.5"/><path fill="#131219" d="M852 345h1c1 8.73-3.05 15.96-8 23 .58-5.55 2.02-10.26 4.06-15.44l.86-2.21q1.02-2.68 2.08-5.35"/><path fill="#0b0b0e" d="M434 309h1c.55 11.52.55 11.52-3 17h-1l-1-8 3-1z"/><path fill="#707073" d="M474 302c1.02 3.23.95 5.65.44 8.98l-.42 2.84-.46 2.93L472 327h-1c-.57-17.34-.57-17.34 3-25"/><path fill="#000001" d="M433 303h1a672 672 0 0 1 .1 7.42c-.1 2.41-.22 4.34-1.1 6.58l-3 1q.17-3.2.38-6.37l.09-1.83c.3-4.57.3-4.57 2.53-6.8"/><path fill="#3e3d42" d="m484 280 2 1v12c-2-2-2-2-3-5l-2 4c-.3-5.08-.1-7.74 3-12"/><path fill="#818183" d="M831 190h3l-1 3zm10 1 3 1c-4.27 2.44-6.95 3.36-12 3l-.56 1.94C830 199 830 199 828.3 199.49c-1.76.24-3.54.38-5.31.51l1-2 2.44-.37L829 197l.88-2 1.12-2c2.13-.57 2.13-.57 4.56-.69 4.37-.24 4.37-.24 5.44-1.31"/><path fill="#8f8f91" d="m753.75 181.94 2.98.02 2.27.04v2c-2.94 1.47-5.9 1.1-9.12 1.06l-2-.01L743 185c1-2 1-2 2.7-2.62 2.72-.45 5.3-.48 8.05-.44"/><path fill="#6a696d" d="M788 173c-8.34 3.31-15 4.57-24 4v-1l7.13-1.42q2.81-.57 5.62-1.2l2.63-.57 2.4-.54c2.36-.29 3.96 0 6.22.73"/><path fill="#868586" d="M913 155c-.12 1.81-.12 1.81-1 4-3.75 2.38-6.56 3.4-11 3v-2c1.6-1.29 1.6-1.29 3.75-2.62l2.1-1.36c2.28-1.08 3.67-1.23 6.15-1.02"/><path fill="#131118" d="m1002 96-6 1v2l-6.69 2.5-1.9.72c-3.6 1.32-6.5 2.08-10.41 1.78l1.69-.81C981 102 981 102 983.25 100.5c2.97-1.62 5.62-2.3 8.9-3.02C994 97 994 97 996.24 95.8c2.27-1.05 3.44-.63 5.75.19"/><path fill="#000001" d="M509 86h11v3l-12 1z"/><path fill="#a9a9aa" d="m1150 2 11 1v3h-12z"/><path fill="#f7f8f8" d="M1079 2h12v2c-4.58 1.79-8.08 2.2-13 2z"/><path fill="#f9f9f9" d="m478 1826 5.38.44 3.02.24c2.6.32 2.6.32 4.6 1.32v2h-12z"/><path fill="#000001" d="M1314 1810v4h-12l1-3c7.43-1.14 7.43-1.14 11-1"/><path fill="#5a5b5d" d="M371 1747h6v6l4 1 2 7-3-1-.94-1.94c-1.3-2.51-2.48-3.03-5.06-4.06l1-6z"/><path fill="#8e210a" d="M1421 1380h6l-1 3h-2l-1 3a26 26 0 0 1-6 5c-2.37-.19-2.37-.19-4-1l5-2v-2l4-1z"/><path fill="#593921" d="M1185 1359h14l-1 4c-4.65-.44-8.72-1-13-3z"/><path fill="#e44605" d="M1343 1345c2.57 2.57 2.54 4.48 3 8l3 1v7c-3-1-3-1-4.69-4.06-1.59-4-1.6-7.7-1.31-11.94"/><path fill="#bbb7b2" d="m1294 1324 .94 1.94c1.06 2.06 1.06 2.06 2.06 3.06q.36 3.78.56 7.56l.13 2.16.31 5.28c-4.44-6.42-4.24-12.39-4-20"/><path fill="#000001" d="M1287 1325c2.72 3.1 3.4 5.02 3.31 9.13v2.63c-.31 2.24-.31 2.24-2.31 4.24-1-1-1-1-1.1-4.38l.05-6.34z"/><path fill="#dd4b0c" d="M1410 1271c1.39 2.78.61 4.12-.25 7.06l-.77 2.73-.98 2.21-3 1v4h-2l-1 2c.43-4.47 1.51-6.97 4.09-10.48 1.4-2.34 1.54-4.85 1.91-7.52z"/><path fill="#ded6d0" d="M1317 1256v5l-3 1c-.69 2.06-.69 2.06-1 4h-3c.32-3.57.64-5.56 2.94-8.37 2.06-1.63 2.06-1.63 4.06-1.63"/><path fill="#e5491a" d="M1430 1249c3.92 1.3 6.33 2.83 9 6v2l-7 2v-3h2v-3l-4-1z"/><path fill="#000103" d="M1058 1215h2l1 3c-.87 1.46-.87 1.46-2 3-.63 1.82-.63 1.82-1.12 3.69l-.88 3.31-1-2h-2c.62-4.2 2.02-7.3 4-11"/><path fill="#a0adb7" d="m1547 1200 1 2 3 1h-3l-1 4h-6v-5h6z"/><path fill="#484653" d="M516 1192c3.8.67 7.35 1.73 11 3v3h-8l-.37-1.87C518 1194 518 1194 516 1192"/><path fill="#262630" d="M999 1184c-7.33 3.49-13.9 4.72-22 5 3.39-2.48 6.98-3.2 11-4.19l2.2-.57c.34-.1.34-.1 2.11-.53l1.93-.49c1.76-.22 1.76-.22 4.76.78"/><path fill="#373740" d="M437 1140h1l1 16-3-2-1 3-2-1 1-10h1l1 5z"/><path fill="#37373f" d="M397 1142c1.29 2.57 1.81 4.08 2 7a76 76 0 0 1-3 6.84 24 24 0 0 0-2 7.16h-1c.6-14.78.6-14.78 4-21"/><path fill="#edeef1" d="m1467 1065 4 1c-.37 1.94-.37 1.94-1 4l-2 1-1 3-4-1v-3l2-1c1.13-2.06 1.13-2.06 2-4"/><path fill="#6c6976" d="M1313 1063h1v16h-2c-1.13-4.5-2.37-10.48-.05-14.74z"/><path fill="#adb1b2" d="M327 1050h12v3c-4.9.9-8.42 1.43-13-1z"/><path fill="#1c1c1f" d="M223 1018h16l1 4h8v1h-9l-1-3-1.5.04c-4.62.06-8.94-.28-13.5-1.04z"/><path fill="#b8b7b6" d="M1099 983h-2v2c-4.08 1.95-7.32 2.22-11.81 2.13l-1.81-.03-4.38-.1v-1q4.15-1.05 8.31-2.06l2.38-.6 2.3-.56 2.1-.53c1.91-.25 1.91-.25 4.91.75"/><path fill="#454648" d="M1228 978c.69 1.81.69 1.81 1 4-1.25 1.44-1.25 1.44-3 3l-1.5 2.56c-1.39 2.26-2.2 3.24-4.5 4.44.47-5.06 3-9.01 6-13z"/><path fill="#f7f7f7" d="M78 970h5l1 4-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-4-1v-3h5z"/><path fill="#9c9c9c" d="m639.13 958.94 3.32.02 2.55.04v1l-5 1 8 1v1h-12v-2l-2 1q-3 .06-6 0c3.95-2.7 6.28-3.11 11.13-3.06"/><path fill="#d7d7d5" d="m1230 933 1 3c-3.3 5.12-7.8 8.95-13 12 1.55-3.64 3.9-6.32 6.56-9.19l1.25-1.36c2.81-3.07 2.81-3.07 4.19-4.45"/><path fill="#0b0c10" d="M857 930v2h-6v2l-18 1c2.73-1.82 3.8-2.33 6.87-2.85l2.24-.4 2.33-.37 2.31-.41c3.47-.6 6.72-1.04 10.25-.97"/><path fill="#423f46" d="M280 927h13l1 3-5.87.06-3.31.04C282 930 282 930 280 929z"/><path fill="#000001" d="m889 924 2 1c-1.52 2.68-1.87 2.96-5 4q-2.85.1-5.69.06l-3-.02L875 929l1-2c2.69-.6 2.69-.6 6-1.06 5.9-.85 5.9-.85 7-1.94"/><path fill="#302e36" d="m886.7 898.6 2.99.15 3 .1 2.31.15v3l-14 1c3-4 3-4 5.7-4.4"/><path fill="#898a8b" d="M965 890v2l9 1-1 2h-12l-1-3c2.75-2 2.75-2 5-2"/><path fill="#a8b1b9" d="M1494 874h2l-2 9-4 1-1 4h-1v-6l-2-1 7-1-.06-2.44c.06-2.56.06-2.56 1.06-3.56"/><path fill="#000001" d="M70 835c1.3 3.87.95 7.1.69 11.13l-.11 2.24-.13 2.14-.12 1.96L70 854l-2 1q-.09-4.44-.12-8.87l-.06-2.56q0-1.19-.02-2.44l-.03-2.25C68 837 68 837 70 835"/><path fill="#d6d6d9" d="M1474 825h3c.9 4.9 1.43 8.42-1 13l-2-1z"/><path fill="#2d2d2f" d="M467 811h5v2h4v2q-3.15.08-6.31.13l-1.8.05c-3.21.03-5.46.05-7.89-2.18h7z"/><path fill="#9d9d9d" d="M209 710c1 3 1 3 .38 5.13-1.86 2.53-3.37 2.34-6.38 2.87-1.31 1.56-1.31 1.56-2 3l-3-1v-2l4-1v-3l6-1z"/><path fill="#8b8c8f" d="m120 699 1 4a368 368 0 0 1-4.69 4l-2.63 2.25c-2.92 1.9-4.28 2.14-7.68 1.75 1.75-3.87 1.75-3.87 4-5l2.38-.37c2.92-.7 4.43-1.6 6.62-3.63z"/><path fill="#a36ede" d="M368 698h2l1 4h5l1-4v5l-3 1 2 7c-3.29-3.11-5.95-5.9-8-10z"/><path fill="#4d4b4f" d="M307 674h1v7h2q.08 2.13.13 4.25l.07 2.4c-.22 2.56-.91 4.14-2.2 6.35-.9-2.7-1.12-4.24-1.1-7.01l.01-2.3.03-2.38.01-2.41z"/><path fill="#06060b" d="m311.63 645.88 2.37.12-1.75.81C310 648 310 648 308.19 649.56c-3.06 2-5.56 2.04-9.19 2.44l-3 1v-2l-2-1h4v-3h5l-2 2c4.17-.54 6.89-2.95 10.63-3.12"/><path fill="#bab8bd" d="m1694 595 3 1-1.31.69c-3 2.33-4.39 5.5-5.69 9-1.04 2.41-1.7 3.16-4 4.31-.36-4.23.4-6.63 3-10h3l.44-1.94c.56-2.06.56-2.06 1.56-3.06"/><path fill="#010103" d="m1683 598 4 2c-1.13 3.38-2.32 4.71-5 7h-3v-5h3z"/><path fill="#7053a2" d="M720 597c-7.19 4.93-15.62 4.23-24 4v-1l7.5-1.5 2.1-.43c4.9-.95 9.43-1.2 14.4-1.07"/><path fill="#ae9cc9" d="M657.04 598.7q1.8.04 3.58.11l1.86.04 4.52.15c-.59 1.49-.59 1.49-2 3-2.3.22-4.33.28-6.63.19l-1.85-.04L652 602c1.15-2.92 1.85-2.99 5.04-3.3"/><path fill="#a69bbe" d="M593 594h14l1 2c-7.02 1-13.92 1.1-21 1v-1l6-1z"/><path fill="#a89dc0" d="m739 583-5 1-1 4c-7.21.47-7.21.47-10.5-1.5L721 585l1.8-.15c3.57-.34 6.66-.63 10.01-1.98 2.54-1.01 3.67-.8 6.19.13"/><path fill="#9989c1" d="M809 569h14c-3.83 2.56-5.5 2.17-10 2v3h-5z"/><path fill="#010102" d="m1715.13 547.88 2.87.12-1.44 1.13c-2.08 2.5-2.2 4.69-2.56 7.87l-4-1c-.12-2.87-.12-2.87 0-6 2-2 2-2 5.13-2.12"/><path fill="#45267f" d="M937 538h10l-2 4h-8z"/><path fill="#f2eff2" d="m1726 470 4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.06-.62 2.06-1 4l-4-1v-5l4 1z"/><path fill="#000001" d="M1605 418h11v4l-11-1z"/><path fill="#2e2d34" d="M837 393h1l.11 2.85.2 3.77.07 1.88c.26 4.55 1.44 7.5 3.62 11.5v2l3 1 1 3c-3.8-1.5-5.66-3.7-8-7a18 18 0 0 1-1.1-6.8l.01-2.15.03-2.24.01-2.27z"/><path fill="#07040e" d="m1230 399 2 1-1 1 2.31-.5c2.69-.5 2.69-.5 5.69-.5-4.03 3.14-7.9 6.05-13 7l-1-4z"/><path fill="#c9caca" d="m1530 380-1 2h-12v-3c5.1-1.25 8.15-.65 13 1"/><path fill="#505256" d="m1482 374 10 1v3h-12z"/><path fill="#d8d7d8" d="M1446 367v3h-12v-3c4.36-1.19 7.62-.77 12 0"/><path fill="#010104" d="M434 288h1v10h2l-2 11h-1l-1-5-2 1c.88-4.75.88-4.75 2-7q.33-2.55.56-5.12l.26-2.76z"/><path fill="#000001" d="M437 284h1v14h-3q-.08-2.94-.12-5.87l-.08-3.31c.2-2.82.2-2.82 2.2-4.82"/><path fill="#28272d" d="M882 274h2l-1 5-3 1c-.5 1.38-1 2.76-1.4 4.17-1.13 3.45-2.92 6.62-4.6 9.83-1.21-2.42-.92-3.03-.19-5.56l.52-1.88C875 285 875 285 877 284q1.39-2.5 2.63-5.06l1.35-2.79z"/><path fill="#434147" d="m508 208 1 2c2.06.63 2.06.63 4 1l-3 1v6h-1v-6l-2 1-1 3-3 1c1.61-6.7 1.61-6.7 3.57-8.23q.7-.37 1.43-.77m-1 10 2 1-3 2z"/><path fill="#000001" d="M414 192h4l-1 11h-3z"/><path fill="#17161c" d="m954 184 2 1a56 56 0 0 1-16 14l-1-2a77 77 0 0 1 11.23-9.67C952 186 952 186 954 184"/><path fill="#7c7b7e" d="M557 160q5.6-.09 11.19-.12l3.2-.06 3.1-.02 2.83-.03c2.9.25 5.05 1.02 7.68 2.23v1a433 433 0 0 1-5.96.1C577 163 577 163 574 162a73 73 0 0 0-4.01-.32l-2.3-.12-2.38-.12L557 161z"/><path fill="#868688" d="m930 143 1 3h6c-2.4 1.77-4.74 2.7-7.56 3.63l-2.38.78C925 151 925 151 923 151l1-4 6-1z"/><path fill="#4e4f51" d="m434 141 4 2c-.31 2.38-.31 2.38-1 5-1.44 1.06-1.44 1.06-3 2q-1.02 2-2 4l-2 1c-.62 2.56-.62 2.56-1 5a47 47 0 0 1-3-7l2.31-.19c2.69-.81 2.69-.81 4.5-3 1.34-3.15 1.43-5.42 1.19-8.81"/><path fill="#5c5c5e" d="M570 127c-2.89 2.13-5.45 2.53-9 3v2h-4v-3h-6v-1c6.34-1.15 12.57-1.1 19-1"/><path fill="#000001" d="M872 82h11v3l-11 1z"/><path fill="#e2e3e2" d="M624 59h13v3h-13z"/><path fill="#0b0b0f" d="M1202 59h4l1 6h3v4c-3.26-.56-5.04-1.29-7-4-.69-3.19-.69-3.19-1-6"/><path fill="#a9a8a9" d="m426 1806 6 1v3l-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l-4-1v-3h5z"/><path fill="#0c0d13" d="m1298 1719 1 2c-.78 1.84-.78 1.84-1.94 3.94-1.51 2.75-2.06 3.84-2.06 7.06l-2 1v-2l-5 4-2-1 2.12-2.27 2.76-2.98 1.39-1.49c2.4-2.6 4.35-4.94 5.73-8.26"/><path fill="#7c7b83" d="M1281 1711h1c.23 6.08.02 12.2-2 18-1.57 1.29-1.57 1.29-3 2-.2-4.32.35-7.05 2-11 .75-2.98 1.38-5.99 2-9"/><path fill="#8f8d95" d="M1298 1711c.36 4.23-.4 6.63-3 10h-3c-.81-1.62-.81-1.62-1-4 3.66-6 3.66-6 7-6"/><path fill="#5d5d64" d="M398 1696c2.76 1.38 2.84 2.82 3.88 5.69 1.2 3.2 2.4 5.94 4.43 8.68 1.69 2.63 1.69 2.63 1.44 5L407 1717q-1.47-2.17-2.92-4.37-1.31-1.96-2.7-3.88c-2.03-4.06-2.65-8.3-3.38-12.75"/><path fill="#47454e" d="m1235 1625 2 1c-4.15 4.88-8.72 8.34-15 10-2.27-.4-2.27-.4-4-1l1.94-.92A82 82 0 0 0 1235 1625"/><path fill="#31323b" d="m582 1564 2 1c-8.08 7.5-8.08 7.5-13 9q-2.01.97-4 2c3.74-5.24 8.88-9.73 15-12"/><path fill="#e7470a" d="M1340 1336c1.5 1.31 1.5 1.31 3 3v3h2v5h-1l-1 5c-2.52-2.84-2.44-6.08-2.62-9.69l-.12-1.84q-.14-2.24-.26-4.47"/><path fill="#0c0d13" d="m944 1322 7 1 .63 2.25c1.4 2.81 1.95 3.32 4.5 4.88L959 1332v3c-2.96-1.37-5.64-2.73-8-5-.25-2.69-.25-2.69 0-5l-7-2z"/><path fill="#700a02" d="M1462 1286c.38 8.7.38 8.7-2 12l-2 1v-12c2-1 2-1 4-1"/><path fill="#530800" d="M1461 1280h2c2.15 4.44 2.42 8.47 2.63 13.31l.11 2.25q.14 2.72.26 5.44c-1.9-3.38-2.77-6.72-3.62-10.5l-.8-3.4a28 28 0 0 1-.58-7.1"/><path fill="#000001" d="M1507 1280h3v13l-3-1z"/><path fill="#d1c5b9" d="M1347 1267h3c-1.05 3.9-2.02 7.46-4 11h-3v-5l3-1z"/><path fill="#5b3c25" d="M1065 1266h3v14h-2c-.94-3.16-1.1-5.97-1.06-9.25l.02-2.7z"/><path fill="#680a02" d="M1436 1263c4.59-.4 7.09.82 11 3l-1 5c-2.37-.19-2.37-.19-5-1-1.12-2.44-1.12-2.44-2-5-1.62-1.31-1.62-1.31-3-2"/><path fill="#e0460a" d="m1376 1254 3 1c-1.12 1.5-1.12 1.5-3 3-3.19.19-3.19.19-6 0l-1 3-3 1-1 3h-3a19 19 0 0 1 6-8c2.31-.86 4.6-1.41 7-2z"/><path fill="#947052" d="M1209 1210q3.44-.05 6.88-.06l1.97-.03c4.92-.02 4.92-.02 7.15 1.09q2.3.33 4.63.56l2.47.26 1.9.18v1c-8.59.2-16.62.2-25-2z"/><path fill="#96a2ac" d="M1579 1094c4.43 4.43 4.13 13.23 4.13 19.25q-.05 1.88-.13 3.75l-2 1-1-8.62-.29-2.45c-.5-4.35-.82-8.56-.71-12.93"/><path fill="#d5dadf" d="M1486 1079h4l1 3 2.5.88 2.5 1.12c.81 2.63.81 2.63 1 5l-3-1v-2h-5l-.37-2.37c-.63-2.63-.63-2.63-2.63-4.63"/><path fill="#111119" d="M788 1059c1.57 3.14.01 5.83-1 9l-.66 2.4-.72 2.41q-.3 1.12-.64 2.27c-1.24 2.43-2.49 2.95-4.98 3.92a58 58 0 0 1 5.05-13.13A66 66 0 0 0 788 1059"/><path fill="#cdd3d8" d="M1461 1050c2 1.38 2 1.38 4 3v2l1.81.69c2.8 1.68 3.77 3.4 5.19 6.31v3c-2.5-1.19-2.5-1.19-5-3-.31-2.69-.31-2.69 0-5h-3v-3h-3z"/><path fill="#54535d" d="M1264 1042h1v14h-3q-.08-2.37-.12-4.75l-.08-2.67c.2-2.7.83-4.28 2.2-6.58"/><path fill="#a5afb6" d="m1566 1034 .31 1.94.69 2.06 3 1v4l4 1-4 1-1-4h-5l-.31-1.94-.69-2.06-3-1c2.5-1.25 3.41-.78 6 0z"/><path fill="#8c8c8e" d="M299 1036c11.82-.69 11.82-.69 15.75 1.88L317 1040v2c-1.87.19-1.87.19-4 0l-.72-1.47c-1.28-1.53-1.28-1.53-4.32-2.04l-3.58-.18-1.86-.1q-2.25-.12-4.52-.21z"/><path fill="#95a0aa" d="M1555 1033h5l1 3 2 1-1 2-2-1 1 3h-4c-1.26-2.9-2-4.8-2-8"/><path fill="#07090e" d="M260 1019h15c-4.2 3.15-4.85 3.36-9.75 3.19l-2.98-.08-2.27-.11z"/><path d="M157 994c7.43-.14 7.43-.14 11 1v3h-10z"/><path fill="#a19f9f" d="m1067 966-1 2c-2.1.6-4.12 1.08-6.25 1.5-3.26.67-6.35 1.33-9.42 2.66-2.67.96-3.68.63-6.33-.16q4.05-1.52 8.13-3l2.3-.87c4.4-1.59 7.9-2.49 12.57-2.13"/><path fill="#99989a" d="M262 955h3v3h6l-4 2 7 1v1c-6.28.23-11.88-.6-18-2v-1l6-1z"/><path fill="#9d9c9e" d="M597 954h15l1 3-17-1z"/><path fill="#8d8e8e" d="M698.26 947.7q1.94.04 3.87.11l2 .04 4.87.15-1 2c-4.8 1.55-10 1.09-15 1 1.14-3.05 1.96-3 5.26-3.3"/><path fill="#cdcdcd" d="M39 941c3 0 3 0 4.38 1.25L45 944c2.72 2.3 4.6 3.87 8 5l-1 4v-2a217 217 0 0 0-6.07-2.96l-2.05-.98-2.08-.96A9.3 9.3 0 0 1 38 942z"/><path fill="#dad8d7" d="M1236 922c2.48 2.48 2.26 3.26 2.27 6.68v2.81l-.02 2.95.02 2.93v2.81l-.01 2.58A8.5 8.5 0 0 1 1236 948l.02-1.86.04-8.39.03-2.93.02-5.44c-.11-2.38-.11-2.38-.68-4.63L1235 923z"/><path fill="#5d5b60" d="M226 915c7.3-.68 11.71 1.47 18 5l4 2c-2 1-2 1-5.3.07q-2.01-.68-4.01-1.38l-2.02-.69q-5.4-1.84-10.67-4z"/><path fill="#79787b" d="m918 913-1.68.34q-3.8.76-7.57 1.53l-2.64.53-2.56.53-2.35.48c-2.2.59-2.2.59-4.34 1.68-1.86.91-1.86.91-4.11.53L891 918c4.47-3.21 8.75-3.94 14.06-4.75l2.43-.42c3.87-.62 6.7-.98 10.51.17"/><path fill="#878688" d="M921 900c-2.08 2.08-2.83 2.38-5.56 3.06-3.34.84-3.34.84-4.44 1.94-2.2.24-4.36.42-6.56.56l-1.87.13-4.57.31c2.7-2.03 4.91-2.27 8.25-2.65 1.75-.35 1.75-.35 4.19-1.91 3.54-1.99 6.59-1.7 10.56-1.44"/><path fill="#9ea8b2" d="m1497 871 1 4h2l-2 7-4 1c-.36-5.05.56-7.73 3-12"/><path fill="#8d8e90" d="m78 868 3 1q1.05 2.99 2 6l2 1c1.4 3.61 2.33 7.2 3 11-3.43-3.03-5.02-5.82-6.69-10.06l-1.26-3.16Q78.97 870.9 78 868"/><path fill="#6c6c70" d="M1071 859c-6.24 3.58-10.75 5.67-18 5 3.2-2.75 6.05-3.77 10.13-4.75l3.19-.8c2.68-.45 2.68-.45 4.68.55"/><path fill="#b6bfc5" d="M1487 856h3v12l-4-2z"/><path fill="#7a7979" d="M1158 836h2v2l4 1q-1.42.45-2.87.94C1158 841 1158 841 1156 842q-2.06.1-4.12.06l-2.2-.02-1.68-.04c1.3-2.6 2.3-2.64 5-3.56 3.91-1.35 3.91-1.35 5-2.44"/><path fill="#a7a7a8" d="m115.2 777.8 1.8.2c-1.2 3.63-2.67 4.5-5.62 6.72C110 786 110 786 109 789h-3c.6-3.27 1.67-5.22 3.81-7.75l1.52-1.86C113 778 113 778 115.2 777.8"/><path fill="#7f7d7e" d="m1330 771 1 4-2.44.94C1326 777 1326 777 1325 778q-3 .06-6 0c2.97-4.18 5.61-7 11-7"/><path fill="#101114" d="m69 746 1 3 3 1h-3l-1 4h-7l2.94-3.44 1.65-1.93A50 50 0 0 1 69 746"/><path fill="#37373b" d="M1368 747a60 60 0 0 1-17 8q2.46-2.54 5-5l1.81-1.81c3.3-1.8 6.5-1.43 10.19-1.19"/><path fill="#bf99ef" d="M454 747h14v3q-2.66.12-5.31.19l-3 .1C457 750 457 750 454 747"/><path fill="#7a797a" d="M1400 741c-2 2-2 2-5 3l-1 3 2 1-3 2v-3h-7l1-2h4v-2c3.13-1.86 5.37-2.2 9-2"/><path fill="#6f42a7" d="M415 736q3.13.64 6.25 1.31l1.78.36c3.67.8 6.13 1.86 8.97 4.33q1.5 1.01 3 2c-6.8-.46-12.95-2.93-19-6z"/><path fill="#010204" d="M154 734h2c.41 2.89.11 3.84-1.62 6.25-2.98 2.2-4.76 2.08-8.38 1.75l1-3 3-1 2.06-2.06z"/><path fill="#101014" d="M1555 715h6v3h5l-1.44 1.31C1563 721 1563 721 1562 724l-6 1v-2h5l-1-4-5-1z"/><path fill="#c5a6e4" d="M391 701h1l1 8 2-5h1l-1 11-1-2-3-1q-.3-2.2-.56-4.37l-.32-2.47C390 703 390 703 391 701"/><path fill="#c7a3ea" d="m385 699 .87 1.8a57 57 0 0 0 4.23 6.74c.9 1.46.9 1.46 1.9 4.46l2 1v2c-3.2-.23-4.03-1.04-6.19-3.5-2.42-3.89-2.61-8.02-2.81-12.5"/><path fill="#8d8989" d="M1493 691c-1.16 3.48-1.86 4.22-5 6-3.25.19-3.25.19-6 0 1.04-2.82 1.67-3.82 4.38-5.25 2.38-.68 4.16-.93 6.62-.75"/><path fill="#e8e4e9" d="m1626 675-3.25 3.44-1.83 1.93c-2.05 1.74-3.35 2.07-5.92 2.63l-1 2-2-1c1.38-2 1.38-2 3-4h2l1-3c4.36-3.21 4.36-3.21 8-2"/><path fill="#8a898b" d="m261 681 8 1c-1.54 4.3-4.24 5.71-8 8l-1-2 4-1v-3h-4z"/><path fill="#2b174e" d="m1083 664 2 1c-5.04 3.98-9.7 5.05-16 6 2.11-4.22 2.11-4.22 4-6 2.17-.4 4-.32 6.2-.11 1.8.11 1.8.11 3.8-.89"/><path fill="#626367" d="M224 631c1.13 3.75 1.13 3.75 0 6l-2.44.44c-2.56.56-2.56.56-3.56 1.93l-1 1.63c-3.45 2.52-5.73 3.37-10 3l1.69-.81L211 642l2.69-1.31C216 639 216 639 216.8 636.3L217 634h6z"/><path fill="#bbb2cb" d="M445 578q2.6-.08 5.19-.12l2.92-.08c3.02.2 5.16.93 7.89 2.2v1q-3.15.04-6.31.06l-1.8.03c-2.94.01-5.08-.15-7.89-1.09z"/><path fill="#b798db" d="M395 575h2l1 11-4 2-.06-5.94-.04-3.34c.1-2.72.1-2.72 1.1-3.72"/><path fill="#c3c2c3" d="m328 558 2 1v11h-4l.44-5.44.24-3.06c.32-2.5.32-2.5 1.32-3.5"/><path fill="#2d2d32" d="m638.63 539.94 2.14.01 5.23.05v1l-2.55.18-3.33.26-3.3.24C634 542 634 542 632 543q-2.06.11-4.13.1l-2.44-.01-2.55-.03L614 543v-1l3.21-.18 6.28-.38c5.26-.33 9.8-1.57 15.13-1.5"/><path fill="#0a0413" d="M947 522h13v1a103 103 0 0 1-20 5v2h-2l1-3c1.85-.73 1.85-.73 4.06-1.19l2.23-.48L947 525z"/><path d="M1722 508h4l.1 6.05c-.1 1.95-.1 1.95-1.1 3.95h-3z"/><path fill="#301c59" d="M1302 481c5 6.36 5 6.36 5 11l3 1a7 7 0 0 1 2 3 60 60 0 0 1-1 4l-3.37-5.25-.97-1.49c-4.98-7.79-4.98-7.79-4.66-12.26"/><path fill="#100f16" d="M840 388h1l.08 3.11c.32 8.73 1.26 15.86 4.92 23.89h-2c-5.43-8.29-4.4-17.52-4-27"/><path fill="#3a2466" d="M1258 390c3.02 3.38 3.02 3.38 2.94 6.13-1.21 2.41-2.47 2.91-4.94 3.87v-4h-2c1.15-2.47 2.05-4.05 4-6"/><path fill="#f4f4f4" d="M1519 386h10l1 4h-9z"/><path fill="#140d27" d="m1266 379 2 1q-4.44 4.57-9.12 8.88c-.34.3-.34.3-2.01 1.86L1255 392l-3-1a113 113 0 0 1 14-12"/><path fill="#525258" d="M1382 374c14.78-.55 14.78-.55 21 3v1c-5.18.24-9.07-.22-14-2-2.33-.43-4.65-.72-7-1z"/><path fill="#2f1c5c" d="M1306 354h1q2.16 7.46 4 15h-5c-1.2-5.2-.76-9.74 0-15"/><path fill="#f9f9f9" d="m1362 342 4 1c-.37 1.94-.37 1.94-1 4l-2 1c-.62 2.06-.62 2.06-1 4h-3l-1-6 4 1z"/><path fill="#959597" d="M439 315h2q.12 3.19.19 6.38l.07 1.82c.03 1.76.03 1.76-.26 4.8-1.51 1.41-1.51 1.41-3 2l-.06-5.25-.04-2.95c.09-2.46.4-4.46 1.1-6.8"/><path fill="#100f16" d="M872 296c1.8 4.48.03 8.54-1.73 12.75-1.93 4.08-1.93 4.08-4.27 5.25 2.9-12.67 2.9-12.67 6-18"/><path fill="#05030a" d="m920 226 1 3-2 2q-1.06 2.48-2 5c-1.47-2.93-.78-4.88 0-8 1.56-1.37 1.56-1.37 3-2m-7 5 3 1-4 4zm1 5 2 1-3 4z"/><path fill="#000001" d="M419 180h3l-1 11h-3l-.1-7.71c.1-2.29.1-2.29 1.1-3.29M752 171h13v3h-13z"/><path fill="#08070e" d="m998 149 2 1-1 3 2 1-5 2v-2l-7 2c2.43-3.91 4.83-5.31 9-7m-3 7 1 2-3-1z"/><path fill="#131319" d="M902 132c2.06.44 2.06.44 4 1h-3v2q-2.87 1.04-5.75 2.06l-3.23 1.16c-2.94.76-4.24.85-7.02-.22 3.9-2.76 7.5-3.7 12.1-4.57C901 133 901 133 902 132"/><path fill="#101015" d="m1235 98 4 1 .59 5.27c.41 1.73.41 1.73 2.41 3.73h-4v6l-3-1v-5l3-1v-7h-3z"/><path fill="#a9abab" d="M846 82h4c-.12 1.88-.12 1.88-1 4-4.62 2.25-8.94 2.23-14 2l-1-2 11-1z"/><path fill="#000001" d="M992 34h11v3l-11 1z"/><path fill="#1c1c1f" d="m1267.29 1822.9 2.77.04 4.94.06c-1.78 1.26-2.97 2-5.13 2.5l-1.87.5-1 2h2v2h-3v-3l-6-1 1.94-.94c4.09-2.1 4.09-2.1 5.35-2.16"/><path fill="#000001" d="M1355 1786c-.36 2.66-.71 4.58-2.25 6.81-2.33 1.58-4 1.41-6.75 1.19l1-4h4v-3c2-1 2-1 4-1"/><path fill="#363739" d="M401 1780c3.17.58 4.78 1.42 7.02 3.72l1.6 1.63 1.63 1.71 1.68 1.72q2.04 2.1 4.07 4.22l-1 2-1.57-1.64c-3.64-3.74-6.93-6.71-11.43-9.36-1.31-2.19-1.31-2.19-2-4"/><path fill="#1c1b1e" d="M1361 1782h5l-1.44.69c-1.56 1.31-1.56 1.31-1.93 3.81l-.63 2.5c-2.37 1.38-2.37 1.38-5 2l-3-1 1-5 6 1z"/><path fill="#3e3e46" d="m1241 1754 4 1h-3v3l3 1c-9.23 4.6-9.23 4.6-15 4 2-2 2-2 4.63-2.12l2.37.12.88-1.81c.94-1.84 1.94-3.5 3.12-5.19"/><path fill="#403f48" d="M1256 1754c-4.48 3.3-8.45 4.26-14 4v-3c5.24-1.82 8.56-2.11 14-1"/><path fill="#010102" d="M1388 1749h2c.33 3.66.46 5.4-1.81 8.38-2.19 1.62-2.19 1.62-5.19 1.62v-4h3l.44-2.44c.56-2.56.56-2.56 1.56-3.56"/><path fill="#7c7c85" d="M456 1746c2 1.38 2 1.38 4 3v2h-5l1 3-4.87-1.37-2.75-.78C446 1751 446 1751 444 1749l10 1 1-2v2l2-1z"/><path fill="#86848c" d="M424 1641h1c.21 7.6.13 14.63-2 22h-1c-.24-7.62.16-14.59 2-22"/><path fill="#292933" d="m669 1508 2 1c-4.46 4.23-7.99 7.5-14 9q-2.03.95-4 2v-2c4.59-3.66 9.72-6.5 15-9z"/><path fill="#14171c" d="M346 1466h2c1 5.96 1.07 10.29-1 16h-1z"/><path fill="#181c21" d="M348 1402h1c.21 8.27.09 15.92-2 24h-1l-.06-6.44-.03-1.85q0-2.35.09-4.71l1-1q.34-2.52.56-5.06l.26-2.79z"/><path fill="#242122" d="m1339 1392 5.19 1.38 2.92.77c2.71.8 5.28 1.75 7.89 2.85v1h-10l-1 2-.25-2.37c-.75-2.63-.75-2.63-2.81-3.94l-1.94-.69z"/><path fill="#3f3f42" d="m1494 1386 1 2-2 1-1 3-3 1 1 6-2.37-.06c-2.63.06-2.63.06-4.63 1.06-.2-1.81-.2-1.81 0-4 1.4-1.56 1.4-1.56 3.25-3 2.76-2.2 5.3-4.45 7.75-7"/><path fill="#292931" d="m941 1324 5 1c.3 1.8.3 1.8 0 4-2.12 1.74-2.12 1.74-4.87 3.31l-2.75 1.62L936 1335l-2-1 8-6z"/><path fill="#c59072" d="M1417 1241c2 1.56 2 1.56 3 3-6-1-6-1-8.75-1.56a66 66 0 0 0-20.25.56c6.73-4.8 18.19-4.12 26-2"/><path fill="#c7bdaf" d="M1373 1237h12v1l-1.8.59c-4.11 1.35-8.2 2.73-12.2 4.41l-1-2 1-2h2z"/><path fill="#23232c" d="m860.32 1236.3 1.68.7c-4.8 3.85-9.23 6.83-15 9 1.24-3.72 2.65-4.5 5.81-6.75l2.65-1.92c2.54-1.33 2.54-1.33 4.86-1.03"/><path fill="#99785c" d="M1069 1227h1v5h2l1-2q-.84 4.44-2 8.81a82 82 0 0 0-2 12.19h-1z"/><path fill="#9f9690" d="M1386 1228c7.11.39 13.35 1.4 20 4v1c-7.34.56-13.16-1.45-20-4z"/><path fill="#9b7d63" d="m1073 1219 1 3 2 1-4 9h-2c-.35-5.23-.28-8.63 3-13"/><path fill="#8e7a6d" d="M1046 1220c-7.38 4.12-16.65 5.5-25 5 3.77-2.25 7.68-2.86 11.94-3.62l2.13-.41c3.7-.68 7.16-1.13 10.93-.97"/><path fill="#a0acb7" d="m1569 1184 1 2c2.06.63 2.06.63 4 1l-2 2c-.62 2.63-.62 2.63-1 5h-8l1-3h5z"/><path fill="#7b8b95" d="M1591 1069h4l1 3 2 1v5h-4v-5h-3z"/><path fill="#020206" d="m1087 1039 6 1-1 4h6v1h-8v-3h-14v-1l11-1z"/><path fill="#f4f5f7" d="M1550 1018a74 74 0 0 1 12 12h-4v-3l-4-1v-2l-3-1z"/><path fill="#090a0f" d="m217 1006.81 2.69-.04c2.31.23 2.31.23 4.31 2.23-1 1-1 1-3.94 1.1q-1.81 0-3.62-.04l-1.87-.01-4.57-.05v-2c2.48-1.24 4.24-1.17 7-1.19"/><path fill="#323235" d="M160 999c5.27-.2 5.27-.2 7 0l2 2c2.06.5 2.06.5 4.44.88 4.3.85 4.3.85 6.62 3.25l.94 1.87-1.6-.93c-2.88-1.29-5.54-1.72-8.65-2.2l-3.27-.5-2.48-.37v-2l-5-1z"/><path fill="#020206" d="m292 971 14 2v1h-8v2h6v1h-9v-3l-3-1z"/><path fill="#999a9b" d="m606.92 962.6 5.08.4v1l-5 1 16 1v1h-22c4-4 4-4 5.92-4.4"/><path fill="#b4b3b4" d="m1171 935-1 2h5v1h-7l-1 3-2-1 1-2-10 1a27 27 0 0 1 8.06-3.69l2.66-.82c2.28-.49 2.28-.49 4.28.51"/><path fill="#000002" d="M1448 904h2v12h-4l.44-5.44.24-3.06c.32-2.5.32-2.5 1.32-3.5"/><path fill="#9d9d9e" d="M179 898h4v2l6 2v3h-5v-3l-6-1z"/><path fill="#313134" d="M489 825c7.04-.21 13.24-.08 20 2v1c-6.98.23-13.25-.13-20-2z"/><path fill="#c8c9ce" d="M1469 817h1c.33 4.47-.4 7.2-2.27 11.12-1.24 3.19-1.47 6.5-1.73 9.88h-1c-.28-6.48.09-12.76 2-19z"/><path fill="#7b7c7d" d="m1213 819-1 3c-1.96.95-1.96.95-4.37 1.69l-2.4.76c-2.23.55-2.23.55-5.23.55l-2-2q2.9-1.3 5.81-2.56l1.66-.75c4.19-1.8 4.19-1.8 7.53-.69"/><path fill="#161519" d="m1254 814-2 6-1-2-9 1c1-3 1-3 3.19-4.19 3.06-.88 5.63-.96 8.81-.81"/><path fill="#343438" d="m1474 797 4 2-1 10h-3z"/><path fill="#808183" d="m1262.56 798.94 2.44.06v3l-1.9.62-2.47.82-2.47.8c-2.16.76-2.16.76-4.16 1.76v-3l-4-1 1.93-.4 2.5-.54 2.5-.52c2.7-.7 2.68-1.53 5.63-1.6"/><path fill="#a3a2a5" d="m98 796 4 1c-.31 1.94-.31 1.94-1 4l-3 1q-1.02 1.5-2 3h-2c.53-3.82 1.5-6.06 4-9"/><path fill="#48474c" d="m175.19 769.75 1.81.25c-.55 4.64-2.2 7.36-5 11h-2v-2l2-1 1-3-3-1c2.46-3.94 2.46-3.94 5.19-4.25"/><path fill="#020103" d="M57 765v4l-3 1-1 3h-3l-1 2-1-3c1.1-1.82 1.1-1.82 2.75-3.69l1.6-1.88C54 765 54 765 57 765"/><path fill="#a1a0a3" d="m156 742 1 2h5v2h-4l-1 3h-6c.4-2.89.79-3.82 3.06-5.75z"/><path fill="#3e3f42" d="M311 703c2.63 3.13 3.88 6.06 5.13 9.94l1 3.09A77 77 0 0 1 319 724c-2.69-3.22-4.23-6.61-5.69-10.5l-.68-1.72c-1.15-3.02-1.92-5.54-1.63-8.78"/><path fill="#18181d" d="M1149 717c-5.6 2.32-11.13 4.47-17 6v-3c11.74-5.63 11.74-5.63 17-3"/><path fill="#4f4f52" d="m244 704 2 1-1.46 1.1c-4.21 3.36-6.4 5.6-7.54 10.9l-2-1 1-2-6 2c4.27-4.54 9.04-8.25 14-12"/><path fill="#432c6b" d="m969.63 702.9 5.37.1c-5.26 5.26-13.93 5.57-21 6l1-2c2.69-.82 2.69-.82 6-1.56 2.86-.65 5.97-2.38 8.63-2.54"/><path fill="#404045" d="M116 706v3c-4 3.9-4 3.9-7.31 4.25L107 713l-1 4-5 1 1.44-1.19c1.91-2.22 2.17-3.94 2.56-6.81l2.69.25c4.2-.32 5.3-1.42 8.31-4.25"/><path fill="#9d9d9e" d="m228 698 2 1-4 4 1 2h-5v-3l2.44-.87C227 700 227 700 228 698m-6 8-6 4-2-1 1-3c3.13-1.04 3.99-.93 7 0"/><path fill="#452976" d="m1029 680 2 1c3.53.15 6.62.04 10-1-2.33 3.02-4.71 3.65-8.28 4.47-1.72.53-1.72.53-3.3 1.61-1.42.92-1.42.92-3.6.54L1024 686l1.94-1.87C1028 682 1028 682 1029 680"/><path fill="#0e0e13" d="m1272.19 656.38 1.81.62-2.08 1.24-2.73 1.63-2.71 1.62c-2.48 1.51-2.48 1.51-4.05 2.77-1.43.74-1.43.74-3.62.37L1257 664l4.81-2.87 2.71-1.62c2.48-1.51 2.48-1.51 4.05-2.77 1.43-.74 1.43-.74 3.62-.37"/><path fill="#c092ea" d="M380 641h1l.06 7.38.03 2.11c.02 5.28.02 5.28-1.09 7.51h-2v11h-1q-.08-3.15-.12-6.31l-.06-1.8c-.03-3.41.26-5.15 2.18-7.89.41-2.92.41-2.92.63-6.19l.22-3.29z"/><path fill="#1b1131" d="M1181 628q-3.43 1.8-6.87 3.56l-1.98 1.04-1.9.97-1.74.9-1.51.53-2-1c4.48-5.16 8.94-8.13 16-6"/><path fill="#131218" d="m1367 597 3 1a305 305 0 0 1-4.19 4l-2.35 2.25c-2.8 1.99-4.12 2.13-7.46 1.75 2.7-3.8 4.6-5.5 9-7z"/><path fill="#8048ca" d="M417 599c4.49 1.12 4.49 1.55 7 5a24 24 0 0 0 4 2h-7l-1-3-2 9h-1z"/><path fill="#000002" d="m376 593 2 1c.2 4.92-.21 8.42-2 13h-2l.44-6.44.12-1.85c.33-4.6.33-4.6 1.44-5.71"/><path fill="#949394" d="m294 587 1 3h-12v-3c8-1.38 8-1.38 11 0"/><path fill="#09080d" d="m1414 548 1 3c-3.88 6.27-9.18 11.52-15 16 1.45-3.14 3.06-5.05 5.56-7.43 2.43-2.65 4.27-5.67 6.2-8.68C1413 549 1413 549 1414 548"/><path fill="#202026" d="m727.75 531.94 7.25.06v1c-7.7 1.23-15.18 2.37-23 2 5.36-2.87 9.78-3.14 15.75-3.06"/><path fill="#a4a6a6" d="M350 511c2.32 2.98 2.41 5.3 2.33 9-.54 3.28-1.92 4.74-4.33 7h-2c-.19-1.87-.19-1.87 0-4l1.47-.81C349 521 349 521 349.5 518.62l.18-2.75.2-2.75z"/><path fill="#3f3e45" d="M452 504h1l-1 17 2-4c.13 2.88.13 2.88 0 6l-2 2c-2-2-2-2-2.27-4.57.03-5.87.26-10.9 2.27-16.43"/><path fill="#2a194a" d="M1140 459c-3.62 3.47-7.22 3.66-12 4v2l-7 1c2.75-2 2.75-2 5-2l1-3c1.93-.98 1.93-.98 4.31-1.75l2.37-.8c2.49-.48 3.94-.26 6.32.55"/><path fill="#352263" d="M1272 420c3.62 1.2 3.95 2.07 5.75 5.31l1.36 2.37c1 2.62.86 3.73-.11 6.32l-1.25-2.75c-1.15-2.34-2-3.5-3.94-5.37-1.81-1.88-1.81-1.88-2.06-4.13z"/><path fill="#0c0d11" d="M1392 424h23v1h-21l2 7c-2-1.19-2-1.19-4-3z"/><path fill="#3b2863" d="m1230 416 2 1c-1.02 2.75-1.61 3.8-4.28 5.16l-2.66.9c-3.44 1.18-5.67 2.15-8.06 4.94l-2-1c4.73-4.12 9.73-7.6 15-11"/><path fill="#2b1b4a" d="M1330 402c2.68 2.38 4.11 4.85 5.69 8.06l1.32 2.66c.99 2.28.99 2.28.99 4.28h-3l-.33-1.71-.48-2.23-.46-2.21-.73-1.85-3-1z"/><path fill="#818381" d="m390 262 2 1c.57 10.9.57 10.9-3 15l-3 1c-.25-2.25-.25-2.25 0-5l2-1.81c2.3-2.53 2.27-3.15 2.25-6.44q-.09-1.87-.25-3.75"/><path fill="#040209" d="m930 214 2 1c-2.83 4.67-5.68 7.66-10 11l-2-1c2.92-4.42 5.67-7.9 10-11"/><path fill="#89898a" d="M885 167c2.19.31 2.19.31 4 1v2h-5v3c-2.7 1.35-5 1.06-8 1l1-3 2.31-.31c2.69-.69 2.69-.69 4.25-2.31z"/><path fill="#69686a" d="M626 132h4c-.62 3.7-.62 3.7-2.56 5.25L626 138v-3h-19v-1h19z"/><path fill="#6b6a6c" d="M543 130h8v3l8 1v1h-15v-2l2-1z"/><path fill="#0f0f15" d="m1037 127 1 2-4.14 2.06q-1.98 1-3.93 2.06l-2.12 1.13-1.98 1.08c-1.83.67-1.83.67-4.83-.33 5.2-3.75 9.69-6.66 16-8"/><path fill="#6a696c" d="m630.72 125.9 3.34.04 3.35.02 2.59.04c-3.69 3.95-3.69 3.95-6.5 4.44-2.9-.51-4.32-1.5-6.5-3.44 1-1 1-1 3.72-1.1"/><path fill="#2f2f33" d="m472 105 2 1a490 490 0 0 1-5.18 5.37C467 113 467 113 464 114c-1.69 2.06-1.69 2.06-3 4l-2-1 2-2c.63-2.12.63-2.12 1-4h4l1-3c2.44-1.75 2.44-1.75 5-3"/><path fill="#39393c" d="M1225 80h3v5h6l-1 7h2l-1 2-4-1 1-6-1.87-.12L1227 86c-1.25-3.06-1.25-3.06-2-6"/><path fill="#030107" d="m1069 75-1 3q-3.66-.17-7.31-.37l-2.1-.1-2.02-.12-1.85-.1c-1.98-.36-3.13-1.1-4.72-2.31q4.19-.3 8.38-.56l2.4-.17 2.3-.15 2.13-.14C1067 74 1067 74 1069 75"/><path fill="#37383d" d="m1206 53 2 1v2l6 1 1 8-4-2v-4l-4-1z"/><path fill="#000001" d="M981 38h10v3l-11 1z"/><path fill="#08080d" d="m1015 26 .63 1.44c2.08 2.36 4.34 2.23 7.37 2.56v2l2 1h-9l-1-3h-7v-1h7z"/><path fill="#a8a9a7" d="M454 1817h7l1 5h-7z"/><path fill="#1f1f22" d="M1302 1815h6l-1 1.25c-1.19 2.08-1.16 3.4-1 5.75h-4v-3h-7v-1h6z"/><path fill="#3f3f48" d="M605 1766h12l-1 3h-12z"/><path fill="#121216" d="m1379 1755 4 1v3h2l-1 7-1.87.31c-2.45.8-2.97 1.47-4.13 3.69l1-5h3v-7h-3z"/><path fill="#8a8a90" d="M428 1735h5l1 3h4v4c-2.7-.15-4.47-.48-6.45-2.4a88 88 0 0 1-3.55-4.6"/><path fill="#2a2c31" d="M408 1717c2 1.13 2 1.13 4 3 .25 3.19.25 3.19 0 6l4 2-1 2c-3.54-1.86-5.77-3.1-7-7-.12-3.25-.12-3.25 0-6"/><path fill="#eeeef0" d="m1416.06 1705.94 1.94.06v7l-5 1c-.1-5.37-.1-5.37 0-7 1-1 1-1 3.06-1.06"/><path fill="#09090f" d="M1150 1435a49 49 0 0 1 11.19 3.44l3.17 1.3c2.64 1.26 2.64 1.26 4.64 3.26-10.56-1.93-10.56-1.93-15-4v-2z"/><path fill="#0a0a0f" d="M1077 1404a51 51 0 0 1 17 6v1l-4.37-.44-2.47-.24c-2.16-.32-2.16-.32-4.16-1.32v-2l-2.44-.37-2.56-.63z"/><path fill="#632512" d="M1413 1391v2c-6.5 2-12.18 3.39-19 3 2.73-2.17 5.59-2.8 8.94-3.62l3.15-.8c2.42-.48 4.46-.71 6.91-.58"/><path fill="#f5f6f7" d="m1512.06 1357.94 1.94.06v7l-5 1c-.1-5.37-.1-5.37 0-7 1-1 1-1 3.06-1.06"/><path fill="#cda689" d="M1339 1293c1.43 3.8.5 6.1-.87 9.81-1.59 4.47-2.7 8.48-3.13 13.19h-1c-.2-8.9.6-15.13 5-23"/><path fill="#eb4706" d="M1345 1300h1c.23 5.74.03 10.59-2 16l-2 1q-.08-2.2-.12-4.37l-.08-2.47c.2-2.16.2-2.16 2.2-4.16q.6-2.99 1-6"/><path fill="#c6c4c8" d="m1509 1266 5 1v7l-5-1z"/><path fill="#583926" d="m952 1242 2 1-1 2h12v1c-14.78 1.33-14.78 1.33-21 0a28 28 0 0 1 8-4m13 1 2 1-2 1z"/><path fill="#000003" d="m957 1231 4 1h-2l-1 2h-2zm-1 3-1 4h-8l1-3c5.75-1 5.75-1 8-1"/><path fill="#90745c" d="m1079.15 1212.77 2.64.03 2.85.02 2.98.06 3.01.02 7.37.1-1 3-3-1c-2.62-.27-5.24-.28-7.88-.31l-2.14-.09c-3.66-.04-6.06.08-8.99 2.42L1073 1219c2.74-5.87 2.74-5.87 6.15-6.23"/><path fill="#44434f" d="m638 1197-3 1v2l2 1h-12c2-3 2-3 3.69-3.69 3.13-.42 6.15-.37 9.31-.31"/><path fill="#13131c" d="m733 1140 2 1a91 91 0 0 1-4 4h-2l-.81 1.81c-2.18 4.01-5.15 6.12-9.19 8.19l-2-1a44 44 0 0 1 10-8 91 91 0 0 0 5-5z"/><path fill="#1f1f27" d="M437 1119h1c1.9 4.78 2.25 8.76 2.13 13.88l-.03 2.08-.1 5.04h-1v-8h-2z"/><path fill="#212029" d="M1215 1087c2.13.38 2.13.38 4 1q-1.17.67-2.37 1.38C1214 1091 1214 1091 1212 1093l-2.37.88c-2.63 1.12-2.63 1.12-4.7 3.3L1203 1099c-2.12-.39-2.12-.39-4-1 4.51-3.54 8.83-6.54 14-9z"/><path fill="#5e5d64" d="M1446 1084v5h-2l-3 9h-1l-.06-3.81-.04-2.15c.1-2.04.1-2.04 1.1-5.04 3.56-3 3.56-3 5-3"/><path fill="#121119" d="M432 1077c4.62 6.7 4.23 13.06 4 21h-1c-1.8-7.05-3.43-13.66-3-21"/><path fill="#1d1c23" d="M406 1080c1.17 3.5.75 4.51-.37 7.94-.98 3-1.9 5.98-2.63 9.06h-2a163 163 0 0 1 2.4-14.18l.6-1.82z"/><path fill="#e6eaed" d="m1605 1078 5 1v7l-5-1z"/><path fill="#e7e9eb" d="m1454 1072 1 2h10l-1 3c-5.42 1.23-5.42 1.23-8.37.06L1454 1076z"/><path fill="#f4f5f5" d="M1422 1035h3l1 3 3 1 1 6-4-1v-3l-4-1z"/><path fill="#e1dfde" d="m1070.44 1026.75 3-.08c2.56.33 2.56.33 3.9 1.85l.66 1.48q-2.94.08-5.87.13l-3.31.07c-2.82-.2-2.82-.2-4.82-2.2 1.74-1.74 4.07-1.21 6.44-1.25"/><path fill="#42404a" d="M1274 1015c1.49 3.97.84 7.94 0 12l-1 1a86 86 0 0 0-.56 4.56l-.26 2.5-.18 1.94h-2v-10l3-1-.06-3.81-.04-2.15c.1-2.04.1-2.04 1.1-5.04"/><path fill="#3d3e41" d="M234 1020h4l1 2c2.38.31 4.67.51 7.06.63l2.01.11q2.46.14 4.93.26v1l-8.67.56c-3.21.21-6.24.51-9.33 1.44z"/><path fill="#b3b3b4" d="M1010 1011c4.24.64 8.25 1.9 12 4l1 2q2.97 1.1 6 2c-2.17.95-3.56 1.14-5.82.4q-2.62-1.13-5.18-2.4v-2l-2.31-.31c-2.86-.73-3.85-1.46-5.69-3.69"/><path fill="#e9ecf0" d="m1529 986 5 1v7l-5-1z"/><path fill="#c2c4c4" d="M122 989h7l1 5h-7z"/><path fill="#07080e" d="M308 978h10l-2 3c-1.79.34-1.79.34-3.91.3l-2.3-.04-2.42-.07-2.42-.04q-2.98-.06-5.95-.15v-1h9z"/><path fill="#dcdddc" d="M94 977h7l1 5h-7z"/><path fill="#000001" d="m97 970 10 1v3H97z"/><path fill="#8b8b8d" d="m791.63 931.94 3.03.02 2.34.04-1 3h-12v-2c2.64-1.32 4.68-1.1 7.63-1.06"/><path fill="#919294" d="m861 919 11 1c-4.5 3.38-4.5 3.38-8.25 3.19l-2.14-.08L860 923z"/><path fill="#87888a" d="m832.44 919.94 2.06.02 1.5.04v1l-1.82.26-8.12 1.18-2.86.4-2.73.4-2.52.37C816 924 816 924 815 925q-2.02.1-4.06.06l-2.23-.02L807 925v-1l8.4-1.85c5.64-1.22 11.25-2.31 17.04-2.21"/><path fill="#727276" d="m834.19 916.94 2.17.02 1.64.04c-3.75 3.68-7.77 3.74-12.75 4.19l-2.4.25q-2.92.3-5.85.56c2.16-1.5 3.95-2.4 6.5-3.06 7.72-2.05 7.72-2.05 10.69-2"/><path fill="#e1e2e3" d="m2 894 5 1v7l-5-1z"/><path fill="#dce1e5" d="M1496 867v5q-2.49.57-5 1c-1-1-1-1-1.06-3.56l.06-2.44c3-1 3-1 6 0"/><path fill="#69696d" d="M1090 853c-2.53 2.53-5 2.93-8.37 3.69l-1.76.43c-3.27.75-5.69 1.05-8.87-.12 6.55-2.7 11.88-4.54 19-4"/><path fill="#79797b" d="M1188 828c-1.13 2.22-1.84 2.93-4.16 3.95l-2.46.74-2.48.76-1.9.55v-2l-3-1c9.41-3.44 9.41-3.44 14-3"/><path fill="#787678" d="m1366 753 4 1-7 4 1 2h-3v2h-6v-2l6-2v-2l5-1z"/><path fill="#7e7c7e" d="m1383 741 2 1-2 3 2 1h-2v2l-8 1c4.96-6.16 4.96-6.16 8-8"/><path fill="#7848b8" d="M432 742h10l1 3 3 1h-10l-1-3z"/><path fill="#838187" d="M1546 737c-1.75 3.88-1.75 3.88-4 5q-2.06.32-4.12.56l-2.2.26-1.68.18c1.02-2.78 1.66-3.82 4.31-5.25 2.7-.76 4.9-.91 7.69-.75"/><path fill="#151419" d="M1102 733c-6.2 3.82-11.77 4.73-19 5l1-2a90 90 0 0 1 5.03-1.49q1.95-.5 3.85-1.12c3.5-1.12 5.63-1.46 9.12-.39"/><path fill="#807e81" d="M1411.81 734.38 1414 736v2l-2.12-.22-2.76-.28-2.74-.28C1404 737 1404 737 1402 737v-2l-2-1c4.4-1.3 7.6-1.68 11.81.38"/><path fill="#010005" d="M919 722h12l-1 3h-12z"/><path fill="#593690" d="M908 715h10v3l-11 1z"/><path fill="#9c9a9b" d="m225 710 2 1c-.4 2.32-.9 3.86-2.25 5.81-1.75 1.19-1.75 1.19-4.44.88L218 717l1-3h4z"/><path fill="#19191e" d="M1169 709c-12.49 5.37-12.49 5.37-19 7l-1-2 6.25-2.44 1.78-.7c8.12-3.14 8.12-3.14 11.97-1.86"/><path fill="#131317" d="M1567 707h5v3h5l1-3 2 1c-.56 1.88-.56 1.88-2 4-2.32.97-4.53 1.45-7 2v-4h-4z"/><path fill="#caa5f0" d="m383 698 2 1c.6 1.96.6 1.96 1.06 4.38A60 60 0 0 0 388 711c-3.5-1.17-4.24-1.82-6-5-.3-2.44-.19-4.52 0-7z"/><path fill="#040407" d="m203 700 2 1v2l2 1c-1.69 1.56-1.69 1.56-4 3-2.75-.31-2.75-.31-5-1l1-4h4z"/><path fill="#575659" d="M1508 679c-2.36 2.63-4.8 4.15-7.94 5.75l-2.59 1.36c-2.7.97-3.83.9-6.47-.11 12.85-9.08 12.85-9.08 17-7"/><path fill="#08080c" d="M1531 671v3l-3 1-1 2 3 1h-4l-1 3-1-2-2.37 1.56L1519 682l-2-1a35 35 0 0 1 9-7l2.81-1.69z"/><path fill="#1f1e24" d="m1530 661 2 1c-4.4 4.25-9.16 5.5-15 7v-2l-3-1 4-4 3 1h-2v3l3.81-1.37 2.15-.78A24 24 0 0 0 1530 661"/><path fill="#848286" d="M309 654c-1.11 3.34-2.47 4.64-5 7l-3-1 1-2h-5c3.68-2.7 7.39-4.34 12-4"/><path fill="#404046" d="M319 644h4c-.5 6.52-.5 6.52-3.06 8.94L318 654l1-6-3-1h2z"/><path fill="#0a0b10" d="M1659 623h3l1 3 2 1-1 3-5-3v3h-4l1-4h2z"/><path fill="#ca97f9" d="M380 622h1v14h-3q-.08-2.37-.12-4.75l-.08-2.67c.2-2.7.83-4.28 2.2-6.58"/><path fill="#7844c0" d="M435 606c8.2-.63 8.2-.63 11.56 2l1.44 2c-3.43 1.19-5.38.8-8.75-.44l-2.42-.87L435 608z"/><path fill="#5f3a9d" d="m677.69 605.81 1.84.04 4.47.15c-2.73 2.73-4.77 2.95-8.58 3.1l-2.67-.04-2.7-.02L668 609c2.64-3.63 5.55-3.36 9.69-3.19"/><path fill="#aa8fce" d="M420 594h8l1 3c1.85.73 1.85.73 4.06 1.19l2.23.48 1.71.33v1l-3.87.06-2.18.04C429 600 429 600 427 599v-2h-2v-2h-5z"/><path fill="#a89dbf" d="m661 590-4 2v3h-10c4.2-5.4 7.34-5.65 14-5"/><path fill="#000001" d="M380 576h2v12l-3 1-.06-5.94-.04-3.34c.1-2.72.1-2.72 1.1-3.72"/><path fill="#664999" d="m908.06 544.94 2.94.06c-4.26 3.89-10.28 6-16 6v2l-4-1c2.32-1.76 2.91-2 6-2v-2l1.5-.4 1.94-.54 1.93-.52c4.8-1.58 4.8-1.58 5.7-1.6"/><path fill="#31195e" d="M1004 515h2l.31 2.94c.54 3.82 1.72 7.27 3 10.9A44 44 0 0 1 1011 537c-2.74-2.74-3.43-5.68-4.62-9.31l-.7-2.02c-1.22-3.67-1.97-6.75-1.68-10.67"/><path fill="#130b26" d="M1077 484c-4.54 2.11-9.1 4.12-13.81 5.81l-2.15.77c-2.34.48-3.8.16-6.04-.58 2.42-2.42 4.02-2.68 7.31-3.5 2.9-.73 5.56-1.44 8.3-2.66 2.65-.93 3.8-.8 6.39.16"/><path fill="#ecebed" d="m1725 482 5 1v8c-3.87-1.87-3.87-1.87-5-3q-.06-3 0-6"/><path fill="#333338" d="M411 464h1a76 76 0 0 1-1.5 17l-1.5 8h-1q.17-4.27.38-8.56l.09-2.43c.24-4.97.97-9.27 2.53-14.01"/><path fill="#030304" d="M1668 442h7l1 2c3.06.63 3.06.63 6 1v1h-6l-1 2v-2l-7-1z"/><path fill="#1e1c22" d="M844 415c5 2 5 2 7.25 3.03 3.24 1.14 6.14 1.39 9.56 1.6l1.81.11q2.19.15 4.38.26v1q-4.34.2-8.69.31l-2.47.13c-5.98.12-5.98.12-8.54-1.54-1.38-1.36-1.38-1.36-3.3-3.9z"/><path fill="#332061" d="m1266 410 2 4-3.44-.62c-4.8-.63-8.94.32-13.56 1.62l1-3c4.4-1.96 9.25-2.24 14-2"/><path fill="#b6b5b8" d="M1598 406h7l1 5h-7z"/><path fill="#313035" d="M914 406c-1.25 3.75-2.72 4.87-6 7-2.25-.06-2.25-.06-4-1l-1-3 3.81-1.5 2.15-.84C911 406 911 406 914 406"/><path d="M1565 406h11l-1 4-9-1z"/><path fill="#2d2d30" d="m366 402 5 2-2 17h-1l-.44-6.69-.12-1.9A67 67 0 0 0 366 402"/><path fill="#222228" d="M838 391h1v15h2l3 9h-2c-5.37-7.29-4.38-15.4-4-24"/><path fill="#18181c" d="m1363 365 5 1 1 7h-2v4l-4 1v-4h3v-6l-3-1z"/><path fill="#27282b" d="m1447 370 10 1v3h-11z"/><path fill="#2e2f32" d="m379 345 1 4-3 1v15h-1l-.44-6.69-.12-1.9A67 67 0 0 0 374 346z"/><path fill="#c7c8c8" d="m368 326 2 1v11h-3c-.94-4.78-1.3-7.52 1-12"/><path fill="#19181e" d="M883 278c0 6.78-5.03 12.8-9 18 0-3.68 1.3-6.3 2.81-9.56l.8-1.79c2.43-5.3 2.43-5.3 5.39-6.65"/><path fill="#000001" d="M441 267h1v12l-4-1q.17-2.2.38-4.37l.2-2.47C439 269 439 269 441 267"/><path fill="#48464c" d="M489 248h1c.46 7.7.32 13.45-4 20h-1c.66-6.86 2.24-13.34 4-20"/><path fill="#e6e6e6" d="m1293 206 5 1v7l-5-1z"/><path fill="#abacac" d="M411 190v7l-5 1v-7c3-1 3-1 5-1"/><path fill="#010106" d="m492 151 2 1c-.57 3.87-2.34 6.18-5 9h-3c.43-5 2.42-6.79 6-10"/><path fill="#e4e4e5" d="m1269 150 5 1v7l-5-1z"/><path fill="#adadaf" d="M435 142c.58 4.96-1.14 7.16-4 11l-4-1h3l-.19-3.31c0-2.62 0-3.43 1.56-5.63C433 142 433 142 435 142"/><path fill="#000002" d="M477 102h6v3l-5 1v3l-5 1z"/><path fill="#757577" d="m503 82 9 2v1h-5l-1 3h-7l-1-2 1.94-.31L502 85z"/><path fill="#343438" d="m881 74 1 3h6v1l-2.94.94C882 80 882 80 881 81q-2.77.1-5.56.06l-3.07-.02L870 81l1-2h7l.38-1.94L879 75z"/><path fill="#dee0e0" d="M535 70h8l-1 4c-2.87.63-2.87.63-6 1l-2-2z"/><path fill="#000001" d="M1004 30h11l-1 3-9 1z"/><path fill="#cdcecf" d="M991 26h7l-1 5h-7z"/><path fill="#000001" d="M1016 26h11l-1 3-9 1z"/><path d="M468 1815h11l1 3h-12z"/><path fill="#87868e" d="M428 1707c2.4 2.4 2.34 3.33 2.63 6.63l.22 2.47.15 1.9-4 1c-.98-5.27-.98-5.27-1-7l2-2z"/><path d="M1408 1700h2v12h-3q-.05-2.71-.06-5.44l-.04-3.06c.1-2.5.1-2.5 1.1-3.5"/><path fill="#010108" d="M767 1696h2v12h-3q-.04-2.43-.06-4.87l-.04-2.75c.1-2.38.1-2.38 1.1-4.38"/><path fill="#010105" d="M1317 1685c1.45 2.34 2.04 3.58 1.76 6.36l-.63 2.39-.62 2.42-.51 1.83h-3q.17-2.12.38-4.25l.2-2.4c.44-2.44 1.2-4.2 2.42-6.35"/><path fill="#787681" d="M1296 1490q2.94-.08 5.88-.12l3.3-.08c2.82.2 2.82.2 4.82 2.2-1 1-1 1-2.93 1.13h-4.75a28 28 0 0 1-6.32-1.13z"/><path fill="#c4c3ba" d="m1354 1382 3 1 1 6c-4.22.2-7.17-.2-11-2v-1h7z"/><path fill="#52331d" d="M1262 1358c3.43 4.9 4.23 8.04 4 14h-3a83 83 0 0 1-1-14"/><path fill="#c3bdb1" d="M1332 1352h3q.33 1.93.63 3.88l.35 2.17.02 1.95-2 2v-2h-3c-1.2-3.62-.54-4.64 1-8"/><path fill="#340805" d="M1465 1330h1c.73 7.64-1.83 13.2-5 20h-1c-.32-4.43.36-7.27 2.2-11.19 1.26-2.85 2-5.8 2.8-8.81"/><path fill="#e14907" d="M1402 1282h1c-.6 10.56-.6 10.56-4 15-1-3.02-1.3-4.86-1-8 1.44-2.12 1.44-2.12 3-4z"/><path fill="#270505" d="M1446 1259c3.78 1.51 3.78 1.51 4.88 3.94 1.63 3 4.1 3.58 7.12 5.06a22 22 0 0 1 3 4l-1 2a471 471 0 0 1-5.31-4.12l-1.52-1.15c-3.6-2.87-5.37-5.48-7.17-9.73"/><path fill="#e13e0a" d="M1424 1250h6l1 2 3 1v3c-4.17-.47-6.93-1.05-10-4z"/><path fill="#50311b" d="M1007 1250q2.16-.05 4.31-.06l2.43-.04c2.26.1 2.26.1 5.26 1.1v2h-13z"/><path fill="#462f21" d="m947 1245-4 2a66005.21 66005.21 0 0 0-7.24 3.75l-2.59 1.36-2.17.89-2-1c5.06-4.68 10.85-9.32 18-7"/><path fill="#c7c0b5" d="M1337 1242c2.13 1.07 3.37 2.3 5 4l-2.44.94C1337 1248 1337 1248 1336 1249q-3 .06-6 0v-3l2.38-.31c2.62-.69 2.62-.69 3.93-2.25z"/><path fill="#1f1816" d="m1302 1216 2 1-1.28.99-1.66 1.32-1.65 1.3C1298 1222 1298 1222 1297 1225c-1.56 1.69-1.56 1.69-3 3l2 1-3 1c-1.69-.94-1.69-.94-3-2l5.54-6.27c1.99-2.23 3.84-4.25 6.46-5.73"/><path fill="#c6d0d7" d="M1553 1205v3c-1.19 1.19-1.19 1.19-3 2l-2.56-.06-2.44.06c-1.31 1.5-1.31 1.5-2 3 .25-2.87.25-2.87 1-6 2.86-2.24 5.46-2.2 9-2"/><path fill="#ced5d8" d="M1535 1190c.4 4.59-.82 7.09-3 11h-5l4-2-.69-2.81c-.31-3.19-.31-3.19 1-5.07 1.69-1.12 1.69-1.12 3.69-1.12"/><path fill="#dbe0e4" d="m1528 1179 2 1v10h-4l.44-4.94.24-2.77c.32-2.29.32-2.29 1.32-3.29"/><path fill="#26262f" d="m1219 1088-1 15h-1l-.11-1.86-.2-2.45-.18-2.43c-.6-2.62-1.47-3.57-3.51-5.26 2.63-3 2.63-3 6-3"/><path fill="#e4e4e2" d="M1214 986h4c.23 2.84.36 4.46-1.25 6.88-1.75 1.12-1.75 1.12-3.94.8L1211 993v-2l2-1c.63-2.06.63-2.06 1-4"/><path fill="#212023" d="m973.5 983.9 3.06.04 3.07.02 2.37.04c-2.85 2.85-5.05 2.56-9 3l-1 6-2-1c-.1-5.37-.1-5.37 0-7 1-1 1-1 3.5-1.1"/><path fill="#8c8d8e" d="m689 954 11 1v1l-8.25 1.56-2.36.46-2.28.42-2.1.4c-2.22.18-3.89-.19-6.01-.84l1.93-.37 2.5-.5 2.5-.5L688 956z"/><path fill="#5b5c5f" d="M23 921c5.3 4.38 10.2 9.08 15 14-3 0-3 0-4.65-1.43l-1.73-1.88a26 26 0 0 0-6.56-5.38L23 925c-.13-2.08-.13-2.08 0-4"/><path fill="#09080c" d="M1213 910v1h-5l-2 5 1.06-1c2.98-1.54 5.62-1.13 8.94-1l-4 2-2 1.19c-2.43.98-3.57.68-6-.19.38-2.94.38-2.94 1-6 2.7-1.35 5-1.07 8-1"/><path fill="#878788" d="M980 886v2h8c-2.34 2.16-3.66 2.96-6.87 3.31A33 33 0 0 1 972 889c2.85-1.95 4.43-3 8-3"/><path fill="#27262a" d="m786.31 828.94 3.24.02 2.45.04c-5.48 4.13-13.46 3.14-20 3 4.24-3.34 9.13-3.12 14.31-3.06"/><path fill="#828182" d="m1344 770-1 4h-6l-1 3-1-2c-2.06-.62-2.06-.62-4-1l5.38-2 3.02-1.12c2.6-.88 2.6-.88 4.6-.88"/><path fill="#1d1d23" d="m1354.13 746.81 2.87.19-1 4-7 2c0-3 0-3 .63-4.69 1.37-1.31 1.37-1.31 4.5-1.5"/><path fill="#07080b" d="M341 741c2 1 2 1 2.87 3.3 1.3 3.09 2.83 4.7 5.25 7.01l2.2 2.12L353 755c-3 0-3 0-4.44-1.1l-1.48-1.52-1.6-1.65-1.67-1.73-1.7-1.73L338 743z"/><path fill="#201f24" d="m1234 695 3 1c-4.6 3.99-9.75 6.94-15 10l-1-2 5.05-3.62q1.84-1.3 3.65-2.64c1.55-1.12 1.55-1.12 4.3-2.74"/><path fill="#000003" d="m1524 678 1 3-4 1v3l-6 1c.25-1.86.25-1.86 1-4 1.85-1.27 1.85-1.27 4.06-2.25l2.23-1.02z"/><path fill="#504e52" d="m1529 663 2 1c-5.42 5.82-11.35 8.23-19 10 3-3.35 6.15-5.31 10.19-7.19 5.72-2.72 5.72-2.72 6.81-3.81"/><path fill="#010102" d="M1631 655h4c-.54 3.79-2.12 5.55-5 8h-2l-1 2c-1-3-1-3 0-6l3-1z"/><path fill="#222227" d="M1573 632h4c-.62 4.68-.62 4.68-2.56 6.31l-1.44.69v-3l-2.31 1c-2.69 1-2.69 1-5.69 1 1.25-1.5 1.25-1.5 3-3 2.19-.19 2.19-.19 4 0z"/><path fill="#0f0f15" d="M1583 623c2.13.38 2.13.38 4 1l-1.24 1.4-1.63 1.85-1.62 1.83A28 28 0 0 0 1579 635c-1.1-3.28-1.03-3.62.31-6.56l.8-1.82c.89-1.62.89-1.62 2.89-3.62"/><path fill="#aa9ac0" d="M544 608c2.38-.44 2.38-.44 4-1l-1 3c-6.73.15-13.31-.3-20-1v-1c5.91-.68 11.12-1.15 17 0"/><path fill="#c8a3f2" d="M388 590h1v13h-3q-.08-2.4-.12-4.81l-.08-2.71c.2-2.53.53-3.61 2.2-5.48"/><path fill="#101017" d="m1468 583 1 3a98 98 0 0 1-3 5q-.78 1.77-1.5 3.56a27 27 0 0 1-4.5 7.44 50 50 0 0 1 2.88-10.06l.99-2.54a31 31 0 0 1 4.13-6.4"/><path fill="#afa1c8" d="m796.19 569.94 2.73.02 2.08.04v3h-12l-1-2c3.01-1 5.04-1.1 8.19-1.06"/><path fill="#05050b" d="m791 560 2 1-1 1 6 1c-2.62 2.14-4.86 2.37-8.19 2.63l-2.73.22-2.08.15c2.63-4.87 2.63-4.87 6-6"/><path fill="#afb0b0" d="m332 548 2 1v10l-4-1 .44-4.44.24-2.5C331 549 331 549 332 548"/><path fill="#291749" d="m1325 540 2 1c-4.4 4.14-8.31 6.91-14 9v-3l-4-1 1.88-.37C1313 545 1313 545 1315 543v3c4.09-1.4 6.77-3.23 10-6"/><path fill="#3e4043" d="M352 524c.69 1.69.69 1.69 1 4l-1.37 2.19c-2.22 3.83-2.35 7.45-2.63 11.81h-1l-.18-1.6c-.84-7.03-.84-7.03-1.82-10.4l1.88-.75c2.47-1.46 3.07-2.63 4.12-5.25"/><path fill="#000001" d="M432 530q2.43-.12 4.88-.19l2.74-.1c2.38.29 2.38.29 3.7 1.8L444 533c-4.65 1.41-7.52.5-12-1z"/><path fill="#07080d" d="M358 516h3v15c-3.15-4.2-3.36-4.85-3.19-9.75l.08-2.98z"/><path fill="#4a3678" d="M1330 520c.13 2.88.13 2.88 0 6l-2 2h-3v-7c2-1 2-1 5-1"/><path fill="#02010b" d="m1350 520 4 1-12 11-2-1z"/><path fill="#090414" d="M988 514c-16.15 5.57-16.15 5.57-24 7l1-3 1.84-.33c5.5-1.04 10.73-2.39 16.03-4.18C985 513 985 513 988 514"/><path fill="#2c2c32" d="M818 514v1l-1.93.37-2.5.5-2.5.5-2.07.63-1 2c-3.22 1.18-6.35 1.1-9.75 1.06l-2.98-.02L793 520v-1h10v-2c5.22-2.28 9.3-3.36 15-3"/><path fill="#8a8a8d" d="M408 505h1v9l4 1-1-8c2.41 3.62 2.52 5.74 3 10l3 1a70 70 0 0 1-11-3z"/><path fill="#2e2e30" d="M1734 497h3l1 9-4 2z"/><path fill="#38383f" d="M456 482h1v12h-2l-1 4q-.08-2.87-.12-5.75l-.08-3.23A13 13 0 0 1 456 482"/><path fill="#101116" d="m1419 469 1 2h2l2.5 4.75 1.4 2.67a11.6 11.6 0 0 1 1.1 6.58h-2l-3-6.31-.87-1.8c-1.32-2.83-2.13-4.73-2.13-7.89"/><path fill="#2e1b55" d="M1128 463h6c-1.4 2.79-3.16 2.9-6 4l-2 2c-3.12.13-3.12.13-6 0l1-3 7-1z"/><path fill="#8e8e8e" d="M1649 415v3h-11l1-3c3.41-1.52 6.44-.73 10 0"/><path fill="#dcdcdc" d="M1622 414h7l1 5h-7z"/><path fill="#aaabac" d="m1630 410 9 1v3h-10z"/><path fill="#343336" d="m1566 398 2 2-1 3h11v2h-13l-1-3h-5v-1h7z"/><path fill="#020006" d="M1240 395c-.19 1.81-.19 1.81-1 4-2.31 1.81-2.31 1.81-5 3-2.31-.31-2.31-.31-4-1l4-2v-3c2.22-1.11 3.56-1.08 6-1"/><path fill="#2d1f4e" d="m1258 388 2 1c-1.81 2-1.81 2-4 4h-3l-.21 1.9c-.79 2.1-.79 2.1-2.86 3.15l-2.5.7-2.5.73-1.93.52c2.24-3.42 4.45-5.02 8-7 5.57-3.57 5.57-3.57 7-5"/><path fill="#bdc0bf" d="M370 375h1q.12 2.85.19 5.69l.1 3.2c-.33 3.5-1.17 5.34-3.29 8.11h-2c-.19-2.31-.19-2.31 0-5l1.47-1.37c1.98-2.1 2.01-3.47 2.22-6.32l.2-2.45z"/><path fill="#040308" d="m943 375 2 1v3c-1.86 1.64-1.86 1.64-4.31 3.25l-2.43 1.64C936 385 936 385 933 384z"/><path fill="#4d4f50" d="M380 328h1q.08 3.09.13 6.19l.07 3.48c-.2 3.28-.67 5.44-2.2 8.33-2.12.81-2.12.81-4 1l.88-1.62c2.7-5.72 3.6-11.15 4.12-17.38"/><path fill="#545557" d="M384 304h1q.08 3.09.13 6.19l.07 3.48c-.2 3.28-.67 5.44-2.2 8.33-2.12.81-2.12.81-4 1l.88-1.62c2.7-5.72 3.6-11.15 4.12-17.38"/><path fill="#a4a4a5" d="m1339 290 2 1c.86 3.53 1.51 6.6 0 10l-3 1q-.05-2.71-.06-5.44l-.04-3.06c.1-2.5.1-2.5 1.1-3.5"/><path fill="#767578" d="M491 234h1c.27 4.7-.5 7.9-2.37 12.19l-1.34 3.1A34 34 0 0 1 485 255c.9-7.44 3.34-14.02 6-21"/><path fill="#fdfdfe" d="m1305 234 5 1v7l-5-1z"/><path fill="#7d7d7e" d="M498 217h2l-.81 2q-1.39 3.52-2.69 7.06A61 61 0 0 1 493 234c-1.37-3.7-.51-5.47 1-9q.57-3 1-6h3z"/><path fill="#36363c" d="M1279 184q.62.67 1.25 1.38A19 19 0 0 0 1286 189l-1 2q-.06 3 0 6h5l-2 1-1 2-4-2v-8l-4-2z"/><path fill="#525355" d="M415 186h2l-1 4h-3l.13 2.81c-.13 3.19-.13 3.19-.82 5.06C411 199 411 199 408.37 199.2L406 199l4-2v-8l5-1z"/><path fill="#858585" d="m497 162 5 1c-1.01 2.54-1.65 3.78-4 5.25-2 .75-2 .75-4 .75v-4h3z"/><path fill="#000003" d="M818 155h10v2c-4.17 1.3-7.59 2.27-12 2z"/><path fill="#67666a" d="M946 144a39 39 0 0 1-12 6l-1.62 1.19c-1.38.81-1.38.81-3.57.44L927 151c3.25-2.72 6.7-4.27 10.63-5.75l1.77-.7c4.35-1.67 4.35-1.67 6.6-.55"/><path fill="#78787b" d="m874.69 147.94 5.31.06c-3.39 3.13-5.71 3.34-10.25 3.19l-3.27-.08L864 151c3.3-3.3 6.25-3.11 10.69-3.06"/><path fill="#8b8b8e" d="m948 136-3 6h-6v-4q1.68-.8 3.38-1.56c.3-.15.3-.15 1.9-.88C946 135 946 135 948 136"/><path fill="#020206" d="M628 115h8v4h-22v-1h19v-2h-5z"/><path fill="#090a0e" d="m1226 82 .81 1.94C1228 86 1228 86 1231 87l-1 4v-3h-3l-1 6-3-1v-5l3-1z"/><path fill="#1f2022" d="M867 78h3v3h7v1h-6v2c-4.41 2.2-8.19 2.21-13 2v-1l9-2z"/><path fill="#08070c" d="m895 74 1 3h6l-1 4-5-1v-2h-8v-1h6z"/><path fill="#989899" d="m941 43 1 3h-11v-3c3.85-1.28 6.03-.67 10 0"/><path fill="#08080c" d="m1263 1820 1 2h20v1l-1.68.08q-3.78.16-7.57.36l-2.64.12-2.56.12-2.35.11c-2.16.2-4.12.63-6.2 1.21l-1-3z"/><path fill="#ececec" d="m1315.06 1816.94 2.94.06-2 5h-7c2.41-4.99 2.41-4.99 6.06-5.06"/><path fill="#ebebeb" d="M442 1813c2.88-.12 2.88-.12 6 0 2 2 2 2 2 5h-7z"/><path fill="#000002" d="m366 1722 3 1 1 9h-4z"/><path fill="#010208" d="M793 1670h23v1l-16 1v1h-8z"/><path fill="#8a8891" d="M1314 1666v12h-3v-11c2-1 2-1 3-1"/><path fill="#5d5c63" d="M473 1634c4.65.44 8.72 1 13 3v1c-7 1.51-7 1.51-10.69-.37L473 1636z"/><path fill="#54535c" d="M1254 1603h2c.36 3.85.28 5.58-1.87 8.88L1252 1614h-2c.62-4.2 2.02-7.3 4-11"/><path fill="#787681" d="M1315 1493h3v9h-1l-1 8h-1z"/><path fill="#262730" d="m767 1444 2 1c-4.74 5.56-8.87 8.3-16 10 4.18-4.34 8.6-8.23 14-11"/><path fill="#010109" d="M1409 1394h9l-2 4h-8z"/><path fill="#e9490c" d="M1410 1382h9v4c-6.75.13-6.75.13-9-1z"/><path fill="#000105" d="M1241 1382h12v3q-2.43.05-4.87.06l-2.75.04c-2.38-.1-2.38-.1-4.38-1.1z"/><path fill="#202129" d="m887 1367 2 1c-3.69 3.13-7.5 6.13-12 8-2.44-.25-2.44-.25-4-1l3-1 1-2c2.21-1.32 2.21-1.32 4.94-2.69l2.71-1.38z"/><path fill="#d8d3cb" d="M1297 1287h1v8h3l-3 9-1-3h-2z"/><path fill="#523522" d="m1018 1295 2 1v2h2v3l7 2c-2.73 1.07-3.8 1.08-6.58 0l-2.67-1.44-2.7-1.43-2.05-1.13 3-1z"/><path fill="#120d0e" d="M1264 1269h1c.53 2.67.96 5.28 1 8-1.31 1.81-1.31 1.81-3 3q-1.05 1.47-2 3c-.38-5.6.1-9.16 3-14"/><path fill="#0c0103" d="M1450 1258a36 36 0 0 1 6 5c.38 2.81.38 2.81 0 5-4.33-2.54-6.6-3.95-8-9z"/><path fill="#c77350" d="m1424 1241 4.59 2.83c1.41 1.17 1.41 1.17 2.41 4.17-3.7.34-5.99.35-8.87-2l-2.13-2h4z"/><path fill="#2d241f" d="M1418 1235h4v3l2.23-.28c3.8.38 4.7 1.88 7.08 4.78l2.12 2.53 1.57 1.97c-3.74 0-4.28-.87-6.94-3.37-2.57-2.41-4.83-4.2-8.06-5.63v-2z"/><path fill="#5d544e" d="M1053 1216c-.59 5.37-.59 5.37-1 7l-2 1 1-6-3 1q-2.1.1-4.19.06l-2.17-.02-1.64-.04c3.49-3.49 8.27-3.14 13-3"/><path fill="#000005" d="M1120 1199h16v1l-11 1 4 2h-10z"/><path fill="#252429" d="M1434 1170h1v26c-2.47-4.95-2.47-4.95-3-8l1-1q.23-1.9.32-3.82l.12-2.31.12-2.43z"/><path fill="#4f4e5b" d="M482 1178c3.49.65 6.03 2.1 9 4l-1 4h-4v-4l-4-1z"/><path fill="#c2c0c2" d="M1426 1046c1.82-.2 1.82-.2 4 0 1.5 1.36 1.5 1.36 2.88 3.19a41 41 0 0 0 6.5 6.68l2.62 2.13v1h-5v-4l-4-2v-2l-4-1v-3z"/><path fill="#07090e" d="M281 1023h15v1c-5.1 2.09-9.53 2.22-15 2z"/><path fill="#d8d8d9" d="M194 1013c2.88-.12 2.88-.12 6 0 2 2 2 2 2 5h-7z"/><path fill="#dfdfe1" d="M154 1001c2.88-.12 2.88-.12 6 0 2 2 2 2 2 5h-7z"/><path fill="#e4e4e5" d="M142 997c2.88-.12 2.88-.12 6 0 2 2 2 2 2 5h-7z"/><path fill="#000001" d="m125 982 9 1 1 3h-10z"/><path fill="#c7c6c7" d="m1050 974-1 3h-13l1-2c4.27-1.3 8.59-1.1 13-1"/><path fill="#383741" d="M1288 960h1c.24 4.14-.63 7.11-2 11q-1.05 4-2 8h-1c-.36-6.51.7-11.89 3-18z"/><path fill="#929293" d="m298.94 958.9 5.49.05 4.57.05-2 1v2q-2.16.08-4.31.13l-2.43.07c-2.4-.21-3.43-.7-5.26-2.2 1-1 1-1 3.94-1.1"/><path fill="#17181c" d="M1240 947h2l1 11-5 1z"/><path fill="#919192" d="M692 951h7l-2 2 12 1v1h-18z"/><path fill="#959595" d="M740 946c-1.61 1.95-2.43 2.93-5 3.25l-2.23-.18-2.43-.16-2.53-.22-2.56-.19a470 470 0 0 1-6.25-.5v-1a166 166 0 0 1 21-1"/><path fill="#979798" d="M746.26 943.7q1.94.04 3.87.11l2 .04 4.87.15v1c-5.36 2.26-10.23 2.2-16 2 1.14-3.05 1.96-3 5.26-3.3"/><path fill="#929294" d="m761 942-1 4-9 1 1-2-5-1v-1c9.41-1.15 9.41-1.15 14-1"/><path fill="#fbfcfc" d="M1529 935h1v10l-4 1c-.37-6.38-.37-6.38 1.44-9.44z"/><path d="m805.19 941.94 2.73.02 2.08.04v2c-3.15 1.05-5.39 1.1-8.69 1.06l-3-.02L796 945c1.87-3.75 5.47-3.11 9.19-3.06"/><path fill="#a5a4a5" d="M185 939h11v4c-7.43-1-7.43-1-11-2z"/><path fill="#000002" d="m1134 938-1 4-10 1c3.58-4.22 5.2-5.68 11-5M932.69 913.94l2.45.02 1.86.04v3h-13c1.8-3.6 5.09-3.11 8.69-3.06"/><path fill="#4f4f54" d="M1454 908c2.06.44 2.06.44 4 1v5l-6 2c.88-6.87.88-6.87 2-8"/><path fill="#8a8a8a" d="M1007 878q2 .47 4 1 2.63.32 5.25.56l2.7.26 2.05.18v1h-10v2l-9-2 4-2z"/><path fill="#474649" d="m997.85 875.9 2.21.04 2.23.02 1.71.04a37 37 0 0 1-16 6c-2.25-.45-2.25-.45-4-1l2.37-.84 3.07-1.1 3.06-1.09c5.32-2.07 5.32-2.07 5.35-2.07"/><path fill="#000002" d="m1098 869-1 4-11 1c3.73-4.28 6.44-5 12-5"/><path fill="#edeff1" d="m1521 854 5 1v7c-3.87-.87-3.87-.87-5-2q-.06-3 0-6"/><path fill="#e3e5e6" d="M1493 822h8l-1 4h-5v2h-2z"/><path fill="#797a81" d="M1469 807h1c.33 4.7-.3 7.15-3 11l-2 3c-.35-5.23-.3-8.65 3-13z"/><path fill="#1a191e" d="M837 800h15c-2.76 2.76-5.43 2.45-9.12 2.63l-2 .11q-2.44.14-4.88.26z"/><path fill="#1c1b1f" d="M950 799c-7.33 3.01-14.16 3.65-22 4 3.49-2 7.1-2.86 11-3.69l1.87-.43c3.34-.72 5.89-1.09 9.13.12"/><path fill="#343338" d="M1304 775c-7.22 3.3-14.14 4.97-22 6 .8-1.47.8-1.47 2-3q2.46-.42 4.93-.68c3.14-.49 6-1.65 8.96-2.8 2.41-.6 3.78-.3 6.11.48"/><path fill="#c6c6c7" d="m41 770 1 4-3 1-.31 2.19c-.75 3.05-1.97 5.2-3.69 7.81l-4-1 3-1 .38-2.44L35 778l2-1q1.02-2.5 2-5z"/><path fill="#5e5d60" d="m1403 744 2 1c-4.88 3.83-10.03 7.13-16 9l-2-1c1.36-2.73 3.23-3.04 5.95-4.13 2.88-1.22 5.6-2.7 8.35-4.2z"/><path fill="#7e7d7e" d="M1406 743c-3.62 3.47-7.22 3.66-12 4l1-4c4.02-.84 7.02-1.05 11 0"/><path fill="#161519" d="M1074 741c-.55 1.95-.55 1.95-2 4-2.5.47-4.61.7-7.12.75l-2 .09q-2.44.1-4.88.16c2.3-2.3 3.79-2.72 6.88-3.62l2.61-.8c2.29-.53 4.18-.7 6.51-.58"/><path fill="#212025" d="m1138 740 4 1a43 43 0 0 1-11 5l-2.62 1.19c-2.7.92-3.73.6-6.38-.19 5.36-2.61 10.03-4.5 16-5z"/><path fill="#06060c" d="M145 740v2l-4 1v3h-6c1.9-5.72 4.09-6.4 10-6"/><path fill="#1d1c21" d="m1145 737-1 3h-6v2l-4.81 1.5-2.71.84c-2.48.66-2.48.66-5.48.66 3.16-2.68 6.06-3.79 10-5q3.14-1.19 6.27-2.41c1.73-.59 1.73-.59 3.73-.59"/><path fill="#a2a2a3" d="M170 734v2h2l1-2-1 4h-5v4h-5l4-1-1-3-2-1c3.63-3 3.63-3 7-3"/><path fill="#010206" d="M334 735h4v3h3v4c-4.75-.75-4.75-.75-7-3-.12-2.12-.12-2.12 0-4"/><path fill="#17171b" d="m1445.17 725.4 1.83.6q-.81.37-1.64.77l-2.17 1.04-2.15 1.02c-2.04 1.17-2.04 1.17-3.54 2.73-1.84 1.76-3.22 2.1-5.69 2.63l-2.17.48-1.64.33c1.25-2.5 2.19-2.72 4.69-3.87a44 44 0 0 0 7.56-4.44c2.75-1.69 2.75-1.69 4.92-1.3"/><path fill="#5c3893" d="M892 719h12v3h-11z"/><path fill="#c098f3" d="M380 703c2.95 2.87 5.63 5.63 8 9h-3l-1 2v-3l-7-1v-2h3z"/><path fill="#010102" d="M122 706v3l-5 1v3h-4c.98-5.68 3.27-7.47 9-7"/><path fill="#ad6cf1" d="M370 679h1v14l3-1v10l-2-4-1.11-1.51c-1.12-1.88-1.12-3.1-1.09-5.28l.02-2.16.06-2.24.02-2.27z"/><path fill="#010104" d="M1073 674h5l-1 3-8 1 3-1zm-8 4c2.19.31 2.19.31 4 1-2 2-2 2-4.62 2.13L1062 681c1.25-1.56 1.25-1.56 3-3"/><path fill="#16161c" d="M1561 631h3l1 3-5 1v3h-6c1.25-3.76 2.63-4.15 6-6z"/><path fill="#a98ccf" d="M479 611q2.85-.08 5.69-.12l3.2-.08c3.13.2 5.29.87 8.11 2.2v1a49 49 0 0 1-17-2z"/><path fill="#b2adc3" d="M539 589h19v1l-10 1v1h-8z"/><path fill="#a69abf" d="M743 582v2h7v1l-2.2.18c-4.97.44-9.89.9-14.8 1.82 1-3 1-3 3.31-4.19 2.44-.73 4.18-1 6.69-.81"/><path fill="#b0a5c1" d="m431.72 577.9 3.34.04 3.35.02 2.59.04v2c-2.35 1.18-3.88 1.17-6.5 1.19l-2.4.04C430 581 430 581 428 579c1-1 1-1 3.72-1.1"/><path fill="#6d54a0" d="M835 570c-6.05 2.67-11.39 4.51-18 5v-2q3.38-1.05 6.75-2.06l1.92-.6c3.43-1.02 5.86-1.73 9.33-.34"/><path fill="#c9b8e0" d="m393 559 4 1c.81 1.69.81 1.69 1 4-1.48 1.95-3.14 3.37-5 5z"/><path fill="#000001" d="M342 561h4l-1 10h-3z"/><path fill="#988ba6" d="M392 557c4.06.65 7.54 1.95 11.31 3.56L409 563v1q-2.4-.14-4.81-.31l-2.7-.18c-2.84-.58-3.63-1.38-5.49-3.51-2.19-.69-2.19-.69-4-1z"/><path d="M815 555h12v2c-3.01 1-5.04 1.1-8.19 1.06l-2.73-.02L814 558z"/><path fill="#737478" d="M422 520q3 .46 6 1l2.26.37c4.74.89 7.67 1.62 10.74 5.63-7-.98-12.88-2.39-19-6z"/><path fill="#100a1f" d="M1029 501c-1 2-1 2-3.1 2.82l-2.65.74-2.6.76q-3.8.97-7.65 1.68c4.03-4.98 9.81-6.45 16-6"/><path fill="#120b24" d="m1166 440 2 1c-2.55 3.11-5.26 3.78-9 5l-1 2c-2.29.85-2.29.85-5.06 1.63l-2.79.78-2.15.59 1-3a95 95 0 0 1 5.16-1.47c2.59-.74 4.6-2.06 6.84-3.53 2.69-1.75 2.69-1.75 5-3"/><path fill="#e4e3e4" d="M1662 430h7l1 5c-2.87.13-2.87.13-6 0l-2-2z"/><path fill="#000001" d="M1638 430h10v4l-9-1z"/><path fill="#2e1b52" d="M1336 417h2l3.03 7.32c.93 2.56 1.51 5 1.97 7.68l-2 1-3-8h-1z"/><path fill="#121116" d="m846 415 5 1v2h21c-3.55 2.37-6.14 2.35-10.25 2.31h-2.04c-4.88-.1-8.5-.7-12.71-3.31z"/><path fill="#0a090e" d="M1580 414h11v3h-11z"/><path fill="#0a0a0d" d="M377 356h1v19l3-1-1 3-6 1 .97-1.73a16 16 0 0 0 1.44-6.6l.12-2.42.1-2.5.11-2.55q.15-3.1.26-6.2"/><path fill="#423f45" d="M477 305h1c.41 7.88-.56 14.5-3 22h-1c.49-7.48 1.33-14.69 3-22"/><path fill="#838385" d="M474 298c1.11 3.3.89 5.04-.37 8.25-1.56 4.3-2.22 8.2-2.63 12.75h-1a61 61 0 0 1 2-19h2z"/><path fill="#3c3b41" d="M1316 272h5c0 2.84-.4 5.24-1 8l6 1-2 9h-1v-8h-4v-8h-3z"/><path fill="#e4e5e6" d="M415 178c.13 2.88.13 2.88 0 6l-2 2h-3v-7c3-1 3-1 5-1"/><path fill="#7f8082" d="M767 182h13l-1 3q-2.16.12-4.31.19l-2.43.1C770 185 770 185 767 182"/><path fill="#8f9091" d="m770.13 177.94 2.75.02 2.12.04v3h-12v-2c2.52-1.26 4.31-1.1 7.13-1.06"/><path fill="#303034" d="M621 147h1l1 13h2l.88 2.75A48 48 0 0 0 629 170c-2.5-1.75-2.5-1.75-5-4-.31-2.25-.31-2.25 0-4l-3-1z"/><path fill="#737376" d="m905.25 138.88 2.14.05 1.61.07c-2.03 2.03-2.72 2.37-5.37 3.06l-1.9.5-1.73.44-1.94.63c-3.33.6-6.68.44-10.06.37v-1l1.83-.37 2.42-.5 2.4-.5c2.35-.63 2.35-.63 4.38-1.7 2.26-1.07 3.74-1.14 6.22-1.06"/><path fill="#4f4e52" d="M630 129h8l-13 13v-3q1.93-1.84 3.89-3.66C630 134 630 134 630.3 131.19z"/><path fill="#202023" d="M552 126c-6.82 2.45-13.84 3.9-21 5l2-1 1-3 2.3-.18 3.01-.26 3-.24c7.77-.92 7.77-.92 9.69-.32"/><path fill="#656366" d="m614 125 3 1v1l13 1 1 6-1-2-3-1 1-1-2.77-.18-3.6-.26-1.83-.12c-4.57-.33-4.57-.33-6.8-1.44z"/><path fill="#69696d" d="m986 119 2 1c-10.37 7.67-10.37 7.67-16 9 4.15-4.39 8.16-8.05 14-10"/><path fill="#9f9fa3" d="m1250 117 4 1-.25 2.31.25 2.69 2 1.5 2 1.5q.06 2.5 0 5c1.5 1.31 1.5 1.31 3 2-2.37-.19-2.37-.19-5-1-1.19-2.44-1.19-2.44-2-5l-2-2c-.63-1.95-.63-1.95-1.12-4.12l-.51-2.2z"/><path fill="#18171d" d="m1071 84 2 1c.35 5.78.46 11.1-3 16a34 34 0 0 1-6 4 48 48 0 0 1 4.63-7.69c2.84-4.43 2.9-8.18 2.37-13.31"/><path fill="#9c9b9d" d="M1233 86c3.88 1.75 3.88 1.75 5 4q-.41 2.52-1 5l4 2-5-1-1-4h-2z"/><path fill="#0e0d13" d="M1070 81c2 0 2 0 4.13 1.63 1.87 2.37 1.87 2.37 2.3 5.3-.47 3.37-1.49 5.32-3.43 8.07-1.44-2.89-.97-5.53-.84-8.71-.18-2.55-.89-4.1-2.16-6.29"/><path fill="#1a1a1c" d="M809 79v3h-11l1-3c3.41-1.52 6.44-.73 10 0"/><path fill="#d3d5d6" d="M883 70h7c-.87 3.88-.87 3.88-2 5q-3 .06-6 0z"/><path fill="#bab8bb" d="M1217 62c1.88.63 1.88.63 4 2 1.14 2.75 2 5.01 2 8l2 1c-2.26-.16-3.6-.57-5.16-2.26-2.05-2.9-3.4-5.06-2.84-8.74"/><path fill="#e2e3e3" d="M911 58h7c-.87 3.88-.87 3.88-2 5q-3 .06-6 0z"/><path fill="#e8eaea" d="M959 38h7c-.87 3.88-.87 3.88-2 5q-3 .06-6 0z"/><path fill="#e4e6e6" d="M979 30h7c-.87 3.88-.87 3.88-2 5q-3 .06-6 0z"/><path fill="#3f3f42" d="M1264 1834h10v3l-8 1z"/><path fill="#858588" d="M1276 1824h13v1l-2.12.33-2.76.48-2.74.46c-2.72.83-2.97 1.41-4.38 3.73h-3c.5-2.17 1-4 2-6"/><path d="M1289 1815h11v3h-11z"/><path fill="#000001" d="m445 1806 9 1 1 3h-9z"/><path fill="#4d4b54" d="m479 1760 5 1 1 3q-2.85.08-5.69.13l-3.2.07c-3.13-.2-5.29-.87-8.11-2.2 3.13-1 5.1-.96 8.25-.06l2.14.59 1.61.47z"/><path fill="#aaaaad" d="m1414 1727 3 1c1.23 5.42 1.23 5.42.06 8.38L1416 1738l-2-1z"/><path fill="#8a8991" d="m1284 1710 2 1c.45 2.65.45 2.65.69 5.94l.26 3.27c.05 2.79.05 2.79-.95 4.79h-2l1-10h-2l-1 3c.88-6.87.88-6.87 2-8"/><path fill="#464749" d="M346 1674c3 1.03 3.98 1.96 5.74 4.66 1.6 3.57 1.6 6.86 1.45 10.71l-.04 1.95-.15 4.68h-1l-.04-2.27c-.36-7.67-2.02-13.15-5.96-19.73"/><path fill="#53515b" d="m1236 1626 2 4c-7.46 9.06-7.46 9.06-12 11l-2-1a21 21 0 0 1 5.5-4.44c2.56-1.6 3.86-3.06 5.5-5.56l-2-1z"/><path fill="#191922" d="m547 1590 3 1-1 1 4 2c-6.07 3.1-6.07 3.1-9.94 2.69L541 1596c1.52-2.68 1.87-2.96 5-4z"/><path fill="#0c0c11" d="M1064 1398c9.6 1.74 9.6 1.74 13 5l-1 3-4-2v-2l-2.87-.31c-3.13-.69-3.13-.69-4.33-2.23z"/><path fill="#ea4f0a" d="M1398 1387c2.38-.3 2.38-.3 5.13-.19l2.75.08 2.12.11v3h-12z"/><path fill="#181921" d="m934 1337 2 1-3 1-.75 1.75c-1.86 3.35-4.77 5.64-8.25 7.25-2.23-.36-2.23-.36-4-1 1.9-2.34 3.7-3.37 6.44-4.44 3.24-1.29 5.26-2.9 7.56-5.56"/><path fill="#cd7e59" d="m1383.32 1244.33 1.68.67h-3v2a34 34 0 0 1-12 6c1.25-3.75 2.49-4.24 5.81-6.25l2.65-1.64c2.54-1.11 2.54-1.11 4.86-.78"/><path fill="#9d7c61" d="M1078 1214h17l-2 3c-2.46.4-2.46.4-5.37.38l-2.9.02c-2.73-.4-2.73-.4-6.73-3.4"/><path fill="#a2b1bd" d="m1525 1210 7 1v3l-10 1 1-3h2z"/><path fill="#1e1e28" d="M941 1200c2.06.44 2.06.44 4 1h-3v2l-5.19 1.44-2.92.8q-3.91 1.02-7.89 1.76c.62-1.4.62-1.4 2-3 2.79-.91 2.79-.91 6.06-1.62 5.86-1.3 5.86-1.3 6.94-2.38"/><path fill="#dfe3e9" d="m1582 1180 2 1c-2.44 5.53-6.97 7.93-12 11 1.2-2.6 2.35-4.59 4.44-6.56l1.32-1.27 1.24-1.17 1.81-1.81z"/><path fill="#33323c" d="m764 1113 1 4-2.12.75c-3.3 1.43-5.94 3.2-8.88 5.25 1.91-4.12 3.46-7.28 7.69-9.25z"/><path fill="#e8eaee" d="m1471 1068 3 2-1 5c-3.09 0-3.68-.24-6-2 1.04-3.13 1.32-3.48 4-5"/><path fill="#c3c9cf" d="M1567 1035c4.7 3.41 8.27 7.57 12 12-3 0-3 0-4.5-1.44l-1.5-1.56-3-1v-4h-5l2-1z"/><path fill="#e1e1e0" d="M1009 1008h5v2l7 1 1 3c-5.55-.44-8.6-1.56-13-5z"/><path fill="#ebebeb" d="M1413 982h1v28h-1v-24h-2z"/><path fill="#d7d7d9" d="m977 989 9 1v4q-4.53-.87-9-2z"/><path fill="#000001" d="m566.81 978.44 2.96.3 2.23.26v2q-3.15.08-6.31.13l-1.8.05c-3.21.03-5.46.05-7.89-2.18 3.8-.94 6.94-.98 10.81-.56"/><path fill="#1d1d20" d="M1027 971c2.06.44 2.06.44 4 1-5.88 2.87-11.57 3.96-18 5l-5 1c2.73-3.73 6.34-3.92 10.63-4.62 7.27-1.28 7.27-1.28 8.37-2.38"/><path fill="#6d7072" d="M63 956h3v4h7l1 4-3 1-1-2c-2.56-.62-2.56-.62-5-1z"/><path fill="#9d9e9e" d="M666 956h14c-2.66 2.66-5.05 2.44-8.62 2.63l-1.86.11q-2.25.14-4.52.26z"/><path fill="#939293" d="m277.63 954.94 6.37.06v1h-5v2h-10v-2c2.82-1.4 5.55-1.1 8.63-1.06"/><path fill="#b6b5b6" d="M1209 950v3l-9 2v2l-10 1 1-2q2.56-.81 5.17-1.5c2.36-.64 4.57-1.57 6.83-2.5q3-1.02 6-2"/><path fill="#020103" d="M166 941c6.75-.12 6.75-.12 9 1v3h-8z"/><path fill="#969899" d="M763 940h13v2l-14 1z"/><path fill="#403e43" d="M302 931q2.4-.04 4.81-.06l2.7-.04c2.49.1 2.49.1 5.49 1.1v2q-2.4.04-4.81.06l-2.7.04c-2.49-.1-2.49-.1-5.49-1.1z"/><path fill="#929295" d="M793 929v2a37 37 0 0 1-14 2c1.33-1.8 2.43-2.85 4.62-3.42 3.13-.46 6.22-.7 9.38-.58"/><path fill="#0d0d10" d="m117 914 1.32.77c3.84 2.22 7.7 4.29 11.68 6.23v1h-5l-1 2v-4h-2v-2l-5 1z"/><path fill="#000002" d="M1047 882h9l-1 3-10 1z"/><path fill="#b9c0c7" d="M1524 874c4.88 1.88 4.88 1.88 6 3q.06 2.5 0 5c-2.27-.38-3.64-.62-5.25-2.31-.9-2.01-.9-3.51-.75-5.69"/><path fill="#76777a" d="M1089 862h13c-10.2 5.1-10.2 5.1-13.44 4.69L1087 866z"/><path fill="#000003" d="M1175 846v3l-11 1c3.47-4.44 5.7-4.4 11-4"/><path fill="#b6bcc0" d="m1499 828 1 3c2.06.69 2.06.69 4 1l-1 2-3-1-1 3v-3h-5v-4c2-1 2-1 5-1"/><path fill="#000001" d="M12 831h1v12h-3q-.08-2.44-.12-4.87l-.08-2.75C10 833 10 833 12 831"/><path fill="#4b4b4e" d="M15 816v3h-2l.19 3.31c.01 3.46.01 3.46-1.63 5.32L10 829c-.69 2.69-.69 2.69-1 5H8v-8l3-1c.13-5.75.13-5.75-1-8z"/><path fill="#4c4b50" d="M179 763c0 3 0 3-1 6l2-1 1-3 3 1-6 7-1-3-3-1z"/><path fill="#9f9da3" d="M1506 765c-1.75 3.88-1.75 3.88-4 5l-2.5.44c-2.5.56-2.5.56-4.5 2.56v-5c7.3-3.44 7.3-3.44 11-3"/><path fill="#050508" d="M70 746h5v3h2l-1 4h-3v-2l-3-1z"/><path fill="#6340a0" d="M755 748h13v2l-13 1z"/><path fill="#0a0b0f" d="m1544 728 1 2h5l-1.94.81C1546 732 1546 732 1545 735h-5l-1 2v-2h-4v-4h3v3h6z"/><path fill="#5c3892" d="M824 734c6.75-.11 13.3.07 20 1-2.67 2.26-4.76 2.21-8.19 2.13l-2.73-.06L831 737v-2h-7z"/><path fill="#78777a" d="M1414 732v3l-7.42-.68c-2.27-.28-4.38-.7-6.58-1.32q1.87-.8 3.75-1.56l2.1-.88c3.21-.84 5.26 0 8.15 1.44"/><path fill="#727175" d="M1430 722a33 33 0 0 1-15 7c1-3 1-3 3.14-4.42l2.67-1.33 2.65-1.36c2.74-.96 3.87-.91 6.54.11"/><path fill="#999899" d="M208 719c2 2 2 2 2 4-1.24 1.22-1.24 1.22-2.87 2.5A58 58 0 0 0 202 730v-2l-3-1 4-2v-2l3-1c1.19-1.56 1.19-1.56 2-3"/><path fill="#1f1f24" d="m1445 704 1 2c2.06.63 2.06.63 4 1l-1.94.31-2.06.69-1 3-4-1 2-3h-6v-1l5.37-.68C1444 705 1444 705 1445 704"/><path fill="#7c3cc5" d="M408 661h1c.24 5.78-.13 10.51-2 16h-1q-.08-2.6-.12-5.19l-.08-2.92c.2-3.02.93-5.16 2.2-7.89"/><path fill="#9a9c9c" d="m196 647 1 4c-6.14 4.57-6.14 4.57-11 4 1.75-3.87 1.75-3.87 4-5q2.5-.06 5 0z"/><path fill="#1d1c23" d="M1539 647h4v3l3.31-1c2.45-.74 4.07-1 6.69-1-2.71 3.5-5.86 3.98-10 5v-2l-6 1v-2h2z"/><path fill="#7442be" d="M554 621c2.06.44 2.06.44 4 1l-3 1-.37 2.67-.5 3.52-.24 1.75c-.53 3.6-1.4 6.74-2.89 10.06h-1v-8h2l.18-2.37.26-3.07.24-3.06c.32-2.5.32-2.5 1.32-3.5"/><path fill="#6f4daa" d="M648 609v1q-3.87.8-7.75 1.56l-2.21.46-2.15.42-1.97.4c-2.18.18-3.83-.2-5.92-.84 6.55-3.66 12.68-3.3 20-3"/><path fill="#160e2b" d="m1242 595 2 1c-12.72 8.17-12.72 8.17-16.37 7.69L1226 603c3.1-2.72 6.27-3.96 10.1-5.3 1.9-.7 1.9-.7 4.28-1.95z"/><path fill="#8b71b3" d="M712 597c-4.24 3.34-9.13 3.12-14.31 3.06l-3.24-.02L692 600v-1c6.75-1.41 13.1-2.3 20-2"/><path fill="#bab8bc" d="M295 590h4l-1 5h-8v4l-2-1 1-4h5z"/><path fill="#020007" d="M1261 590v2c-1.28 1.04-1.28 1.04-2.94 2.06-3 1.89-3 1.89-4.06 2.94q-2.5.06-5 0c3.12-4.56 6.11-7 12-7"/><path fill="#b8b0cb" d="M708 583c2.58.27 5.16.41 7.75.56l2.11.13 5.14.31v1h-23c1.9-3.82 4.52-2.9 8-2"/><path fill="#e2e1e2" d="M333 570h2v5l-3 1-1 2h-5l2.94-3.44 1.65-1.93A50 50 0 0 1 333 570"/><path fill="#010007" d="m1318 551 2 3a26 26 0 0 1-10 7l-2-1c2.96-3.9 5.57-6.72 10-9"/><path fill="#d7d8d8" d="M343 547v8l-3 1-2-1c-.25-2.31-.25-2.31 0-5 3.2-3 3.2-3 5-3"/><path fill="#9077bb" d="M903 545c-.65 1.4-.65 1.4-2 3-2.6.95-2.6.95-5.62 1.69l-3.04.76-2.34.55c2.43-4.75 2.43-4.75 4.81-5.81 2.74-.24 5.44-.25 8.19-.19"/><path fill="#301c57" d="m1359 498 3 1c-1.75 6.75-1.75 6.75-4 9v-4l-4 2a40 40 0 0 1 5-8"/><path fill="#a5a4a7" d="m1679 439 .63 1.5 1.37 1.5h2.94c3.06 0 3.06 0 4.93 1.38C1690 445 1690 445 1690 447c-4.17.49-6.53-.48-9.84-2.84-1.16-1.16-1.16-1.16-1.54-3.41z"/><path fill="#8a8b8d" d="M425 425h1q.16 3.6.25 7.19l.1 2.04c.07 3.6-.07 5.88-2.37 8.76L422 445c-.12-6.75-.12-6.75 1-9h2z"/><path fill="#323135" d="m1658 430 3 1a42 42 0 0 1 2 6l-5-1v-3h-8l1-2 2.94.06C1657 431 1657 431 1658 430"/><path fill="#d4d4d6" d="M1633 418h5l2 5h-8z"/><path fill="#39393d" d="M1327 296c2.16 1.27 3.47 2.07 5 4 .73 3 .93 5.93 1 9h5c-1.25 1.06-1.25 1.06-3 2-2.19-.37-2.19-.37-4-1l-.31-3.31c-.45-3.27-1.24-4.45-3.69-6.69-.19-2.19-.19-2.19 0-4"/><path d="m1310 265 4 1v9l-3-1z"/><path fill="#000001" d="M446 248c1.13 3.38.82 4.77.06 8.19l-.59 2.73L445 261h-2q-.12-2.7-.19-5.37l-.1-3.03c.29-2.6.29-2.6 1.8-3.95z"/><path fill="#89888a" d="M855 183v3h-5l-1-2c3-1 3-1 6-1m-7 3-1 4h-5l1-3c3-1 3-1 5-1"/><path fill="#2f2d33" d="M871 183h7l-1 3q-2.48 1.05-5 2l-1 2h-5l4-1v-3l-2-1h3z"/><path fill="#3b383e" d="M599 179c4.88 4.63 4.88 4.63 6 8h-2l-1 2-1-3-3-1c-1.19-2.06-1.19-2.06-2-4h3z"/><path fill="#09090e" d="M653 124h2c-.87 4.88-.87 4.88-2 6q-2.36.55-4.75 1l-2.6.5-2.65.5-5 1c2.6-3.33 5.11-3.62 9.19-4.19l3.29-.48L653 128z"/><path fill="#a2a5a5" d="M763 82c4.37-.37 6.32.76 10 3 3.31.69 3.31.69 6 1v1c-5.4.25-9.93-.03-15-2z"/><path fill="#929295" d="M1225 74c3.88 1.75 3.88 1.75 5 4q-.41 2.52-1 5l4 2h-5v-5h-3z"/><path fill="#000001" d="M962 46h9v3l-10 1zM1324 1806v4h-9v-3c3.07-.91 5.8-1.09 9-1"/><path fill="#5b5a63" d="M451 1752a64 64 0 0 1 19 9c-6.88-.3-12.28-3.27-18-7z"/><path fill="#181820" d="M782 1736c4.58.76 4.58.76 6.78 2 2.85 1.29 5.36 1.42 8.47 1.63l3.27.22 2.48.15v1q-3.31.12-6.62.19l-1.88.07c-4.64.08-7.54-.8-11.5-3.26z"/><path fill="#3a3943" d="M1272 1735a29 29 0 0 1-3 8h-4c-.31-1.75-.31-1.75 0-4 4.22-4 4.22-4 7-4"/><path fill="#64646d" d="M430 1705c3.02 1.5 3.14 3.68 4.19 6.81l1.1 3.21c.69 2.9.75 4.25-.29 6.98l-1-5h-2l-1-3.75-.56-2.1c-.43-2.12-.52-4-.44-6.15"/><path fill="#05050a" d="M1321 1663h1v10h2l1-4v5l-1.87.69c-2.74 1.69-3.18 3.3-4.13 6.31-1.38-3.73-.44-5.4 1-9 .46-2.99.75-5.99 1-9"/><path fill="#0c0c14" d="M1229 1628c-5.62 4-5.62 4-9 4v2l-8 1c1-2 1-2 3.94-3l3.06-1 2-2c5.58-2.2 5.58-2.2 8-1"/><path fill="#000001" d="M339 1562h3v12h-3z"/><path fill="#c8c7ca" d="M1430 1446h7v3l-3 1-1 2h-3z"/><path fill="#000006" d="m1401.19 1397.81 2.73.08 2.08.11c-.65 1.49-.65 1.49-2 3-2.6.3-2.6.3-5.62.19l-3.04-.08-2.34-.11c2.51-3.18 4.23-3.34 8.19-3.19"/><path fill="#cdbcac" d="M1354 1378c4.14 3.39 7.82 6.68 11 11l-4 1 1-3h-4a63 63 0 0 1-4-9"/><path fill="#1e140d" d="M1232 1376c7.12-.54 12.45 1.3 19 4-4.14 1.7-7.66.26-11.75-.94l-2.11-.59q-2.58-.72-5.14-1.47z"/><path fill="#452d1a" d="M1166 1356c6-.43 10.51.55 16 3v1c-6 .43-10.51-.55-16-3z"/><path fill="#0a0b11" d="M970 1341c4.78 1.64 4.78 1.64 5.84 3.4 1.68 2.32 3.73 3.08 6.28 4.29l2.76 1.32 2.12.99c-2 1-2 1-3.99.4-5.53-2.44-10.2-4.77-13.01-10.4"/><path fill="#2f1e15" d="M1256 1328h1c1.9 6.8 3.4 12.92 3 20-4.25-5.66-4.25-13.2-4-20"/><path fill="#e94b05" d="M1392 1327c2 2 2 2 2.25 4.56-.31 4.28-1.22 8.29-2.25 12.44h-1a1892 1892 0 0 1-.1-12.4l-.01-2.02c.11-1.58.11-1.58 1.11-2.58"/><path fill="#1f1613" d="M1260 1280h1c.41 5.85-.37 9.74-3 15h-1c-.41-5.85.37-9.74 3-15"/><path fill="#000001" d="M1503 1267h3v11l-3-1z"/><path fill="#c76339" d="m1389.19 1243.94 2.17.02 1.64.04c-2.65 1.99-5.47 3.05-8.56 4.19l-2.94 1.1c-2.5.71-2.5.71-4.5-.29l5-2v-2c2.75-.92 4.36-1.1 7.19-1.06"/><path fill="#b49882" d="m987.19 1233.94 2.17.02 1.64.04c-.68 1.46-.68 1.46-2 3-2.38.51-2.38.51-5.12.69l-2.76.2-2.12.11 1-3c2.75-.92 4.36-1.1 7.19-1.06"/><path fill="#bfc8d0" d="m1525 1202 3 1v5h-3l-1 3h-1c-.69-2.81-.69-2.81-1-6 1.44-1.87 1.44-1.87 3-3"/><path fill="#eaedef" d="M1539 1179c.13 2.88.13 2.88 0 6l-2 2h-3c-.19-2.87-.19-2.87 0-6 1.75-1.75 2.52-2 5-2"/><path fill="#8c99a4" d="M1583 1168h3v4h-3v5h-4c.38-2.44.38-2.44 1-5l2-1z"/><path fill="#a4a2a8" d="M1441 1145h1c.41 5.85-.37 9.74-3 15h-1c-.41-5.85.37-9.74 3-15"/><path fill="#25252d" d="M399 1111h1q-.14 3.88-.31 7.75l-.07 2.21c-.21 4.23-.51 6.87-3.62 10.04-.48-6.93 1.32-13.34 3-20"/><path fill="#24242d" d="M1186 1109c-.31 1.94-.31 1.94-1 4l-3 1-1-2-1.19 1c-2.36 1.3-4.16 1.14-6.81 1l1.25-.81c1.75-1.19 1.75-1.19 3.56-2.82 2.85-1.78 4.9-1.6 8.19-1.37"/><path fill="#e2e2e4" d="m1445 1058 1 2 3 1h-2l.07 2.08.06 2.73.07 2.71c-.21 2.6-.7 3.44-2.2 5.48l-.56-7.44-.17-2.14q-.18-2.7-.27-5.42z"/><path fill="#f1f1f2" d="M256 1030h8l2 4h-8z"/><path fill="#ebebe8" d="m1156.19 1026.94 2.73.02 2.08.04v2c-3 .87-5.64 1.1-8.75 1.06l-2.42-.02-1.83-.04v-2c3.01-1 5.04-1.1 8.19-1.06"/><path fill="#d8d7d6" d="M1076 1027c4.75.75 4.75.75 7 3h-2v2l3 1-3-.44q-6-.84-12-1.56v-1l8-1z"/><path fill="#000001" d="M1192 1022v3l-5 1v3h-6c1.52-2.68 1.87-2.96 5-4l1.5-1.56c1.5-1.44 1.5-1.44 4.5-1.44"/><path fill="#43414b" d="M1281 990c1.63 4.06.31 7.27-.94 11.25l-.59 1.97-1.47 4.78h-1c-.51-6.9 1.13-11.75 4-18"/><path fill="#343638" d="m128 987 7 1 1 3h6v1l-3.31.5-1.87.28C135 993 135 993 132 993l-.87-1.87C130 989 130 989 128 987"/><path fill="#010106" d="M633 971v1l6 1v1l-8.3.78c-2.7.22-2.7.22-5.7.22-1.19-1.5-1.19-1.5-2-3 3.4-.78 6.5-1.1 10-1"/><path fill="#000001" d="m674 966-1 3h-13l1-2c4.39-.86 8.52-1.1 13-1"/><path fill="#545558" d="M265 963c5.13-.12 9.94.16 15 1v2l3 1-3.81.06-2.15.04C275 967 275 967 272 966v-2h-7z"/><path fill="#bebdbe" d="M1131 949v4h-10v-3l3.88-.5 2.17-.28c1.95-.22 1.95-.22 3.95-.22"/><path fill="#929192" d="m717.19 947.38 1.81.62-3 1-1 2 6 1v1h-9v-1l-9-1v-1l1.86-.15 4.88-.44c3.08-.56 4.35-2.57 7.45-2.03"/><path fill="#838386" d="M191 944c6.9-.51 11.75 1.13 18 4-3.71 1.34-6.36.66-10.12-.31l-3.2-.8C193 946 193 946 191 944"/><path fill="#141416" d="M37 931h4v3l4 1h-4l2 5c-2.84-1.23-4.68-2.13-6-5-.12-2.19-.12-2.19 0-4"/><path fill="#616164" d="M136 922c4.45 1.1 6.83 1.7 10 5a82 82 0 0 0 7 3c-2 1-2 1-4.2.4-5.37-2.1-9.1-3.96-12.8-8.4"/><path fill="#434147" d="M249 919h11l-1 4-10-2z"/><path fill="#2d2d31" d="M915 916v1l-1.64.4-2.17.54-2.15.52q-2.54.69-5.04 1.54-2.1.1-4.19.06l-2.17-.02L896 920c5.56-4.07 12.33-4.32 19-4"/><path fill="#666569" d="M931 896c-5.54 3.96-11.42 4.3-18 4 5.54-3.96 11.42-4.3 18-4"/><path fill="#8d9ca6" d="M1523 893h2c1.36 2.73.88 4.61.56 7.63l-.3 3.03-.26 2.34h-2z"/><path fill="#8e8d8e" d="M980 885h11l-1 3h-10z"/><path fill="#000001" d="M1063.04 877.6q1.06.08 2.15.15l2.17.1q.8.08 1.64.15v3l-11 1c3-4 3-4 5.04-4.4"/><path fill="#494a4d" d="m1466 868 4 2-1 8h-3z"/><path fill="#99999a" d="M148 869c3 1 3 1 3.95 2.63l.74 1.93.76 1.94.55 1.5h-4v-3l-3-1z"/><path fill="#8e8f90" d="M1051 871h10v3h-11z"/><path fill="#353339" d="M1067 852h2v2l9-1c-3.12 3.12-7.1 3.9-11.44 4.13L1065 857c.88-3.87.88-3.87 2-5"/><path fill="#1a1a1f" d="M1206 833c-5.32 3.04-9.87 4.67-16 5 2.26-2.57 4.2-3.68 7.44-4.75l2.3-.8c2.47-.5 3.9-.24 6.26.55"/><path fill="#2d2e32" d="M79 818h1c.18 3.95.1 6.6-2 10h-2l-1 3v-8l3-1c.69-2.06.69-2.06 1-4"/><path fill="#9f9fa2" d="m93 801 1 4 4-3-1 5h-4l-1 4h-2c.5-3.72 1.12-6.73 3-10"/><path fill="#212127" d="m1213.73 802.8 5.27.2v3l-5.18 1.46q-2.42.73-4.82 1.54c1.16-5.8 1.16-5.8 4.73-6.2"/><path fill="#7c7d80" d="m25 796 2 1c-1.25 2.5-1.25 2.5-3 5-2.19.31-2.19.31-4 0v6l-5 1 1-2h2v-6l2.38-.25C23 800 23 800 24.3 797.94z"/><path fill="#000001" d="m32 793 1 4-3 1-1 5h-3c-.37-2.19-.37-2.19 0-5a36 36 0 0 1 6-5"/><path fill="#3d3d40" d="M369 787c3.71.71 6.68 2.26 10 4v2l4 2c-3.18-.5-6.2-1.3-9.25-2.31l-2.08-.68C370 791 370 791 369.27 788.9q-.14-.93-.27-1.89"/><path fill="#242229" d="M1272 778h5l-1.81.81c-2.19 1.19-2.19 1.19-3.57 2.69-2.26 2.09-4.71 2.6-7.62 3.5l-1.62 1.19c-1.38.81-1.38.81-3.57.43q-.9-.3-1.81-.62l3-1 .9-1.52c1.1-1.48 1.1-1.48 3.26-2q1.22-.07 2.46-.17l2.48-.2 1.9-.11z"/><path fill="#252428" d="M1037 778q-3.37 1.3-6.75 2.56l-1.92.75c-5.84 2.16-5.84 2.16-9.33.69h2v-2q2.87-.8 5.75-1.56l3.23-.88c2.86-.53 4.34-.54 7.02.44"/><path fill="#7e7e81" d="m165 775 2 1-2 2-.87 2.5c-1.35 3-2.27 3.16-5.13 4.5-1.25 2.13-1.25 2.13-2 4 0-5.47 4.63-9.9 8-14"/><path fill="#0e0a1f" d="M834 742c-5.54 3.96-11.42 4.3-18 4 5.54-3.96 11.42-4.3 18-4"/><path fill="#572f85" d="M400 730c5.95 1.4 10.76 3.89 16 7-2.34.8-3.61 1.12-6 .36-3.9-2.05-7.08-3.95-10-7.36"/><path fill="#616164" d="M1442 714v2q-2.87 1.3-5.75 2.56l-1.63.75c-2.54 1.1-4.24 1.75-7.01 1.34L1426 720c4.5-3.05 10.44-6 16-6"/><path fill="#848488" d="m211 703 2 1-2.69 1.69c-2.25 1.48-4.05 3-5.87 5-2.6 2.46-3.93 3.01-7.44 3.31l1-2h2l1-3 3-1 2-2c2-2 2-2 5-3"/><path fill="#010104" d="m996.19 701.81 2.73.08 2.08.11c-.65 1.49-.65 1.49-2 3-2.6.3-2.6.3-5.62.19l-3.04-.08L988 705c2.51-3.18 4.23-3.34 8.19-3.19"/><path fill="#010206" d="m1089 668 2 2c-2.12 2.13-2.12 2.13-5 4-3.31-.75-3.31-.75-6-2 2.77-2.77 5.23-3.02 9-4"/><path fill="#2e2e34" d="M321 651c0 3.03-.51 5.17-1.37 8.06l-.78 2.66C318 664 318 664 316 666c-.56-6.08.15-9.22 4-14z"/><path fill="#c497e9" d="M382 637a14 14 0 0 1 3 3c-.34 1.75-.34 1.75-1 4q-.55 2.87-1.06 5.75l-.54 2.98-.4 2.27h-1c-.1-6.11-.07-11.97 1-18"/><path fill="#412774" d="M1044 642a22 22 0 0 1 5.19 6.94l1.04 2.02c.82 2.17.92 3.75.77 6.04h-2l-2.5-5.81-.72-1.66c-1.15-2.7-1.78-4.55-1.78-7.53"/><path fill="#29173a" d="M373 625h1c.35 6.33-.7 11.1-3 17-.98-3.18-1.02-5.76-.69-9.06l.24-2.6c.44-2.3.93-3.57 2.45-5.34"/><path fill="#131118" d="M1488 617h2c.3 7.85.3 7.85-2 11l-3 1 1.46-6.74c.54-2.26.54-2.26 1.54-5.26"/><path fill="#0b0a0f" d="m1337 615 4 1-4.81 4-1.37 1.16c-3.48 2.84-3.48 2.84-6.82 2.84 1.12-3.26 1.93-3.96 5.06-5.62L1336 617z"/><path fill="#06060c" d="M1621 591c0 3 0 3-1.1 4.44l-3.17 3.09-1.73 1.66-1.73 1.7L1609 606l-2-1c3.87-4.96 8.2-8.94 13-13z"/><path fill="#66676a" d="M1698 586c1.11 3.34 1.1 4.7 0 8-1.87 1.06-1.87 1.06-4 2-1.75 2.13-1.75 2.13-3 4 .75-4.75.75-4.75 3-7l1-3.06c1-2.94 1-2.94 3-3.94"/><path fill="#9d96ae" d="m787 571-1 2-18 1a31 31 0 0 1 9.5-3.19l2.84-.54c2.57-.26 4.23-.08 6.66.73"/><path fill="#6a6577" d="m784.19 567.94 2.17.02 1.64.04v1l-1.72.37c-3.67.8-7.27 1.6-10.84 2.75-3.8 1.15-7.5 1.06-11.44.88v-1l2.74-.62 5.38-1.23c9.83-2.25 9.83-2.25 12.07-2.21"/><path fill="#85729f" d="M391 559h1c.35 6.33-.7 11.1-3 17h-1c-.35-6.33.7-11.1 3-17"/><path fill="#06060d" d="M708 567q5.16-.12 10.31-.19l2.96-.07 5.46-.08c2.72.4 3.57 1.24 5.27 3.34h-6v-2h-18z"/><path fill="#000001" d="M412 559h10l1 3h-11z"/><path fill="#07060d" d="M794 555h12v3h-10v-2z"/><path fill="#18171d" d="M779 525c-5.86 4.17-12.06 4.28-19 4 6.4-3.3 11.88-4.33 19-4"/><path fill="#000004" d="M950 519h10v2c-3.82 1.42-6.91 2.24-11 2z"/><path fill="#190f2e" d="m1108 470-4 1v2c-4.27 2.44-6.95 3.36-12 3 1.53-3.06 4.4-3.55 7.44-4.69l1.81-.72c4.5-1.72 4.5-1.72 6.75-.59"/><path fill="#929194" d="m1691 447 1.19 1.5c1.81 1.5 1.81 1.5 4.31 1.5s2.5 0 4.31 1.38C1702 453 1702 453 1702 455h-6v-3l-5-1z"/><path fill="#fbfbfb" d="M362 434c-.51 4.36-1.17 7.97-3 12h-1v-11c3-1 3-1 4-1"/><path fill="#2a1856" d="M1267 413c1.94 2.06 1.94 2.06 3 4l-1 2c-1.5-.87-1.5-.87-3-2v-2h-13l1-2a24.4 24.4 0 0 1 13 0"/><path fill="#311e58" d="M1237 409c-1.1 3.3-1.95 4.05-4.56 6.19l-1.94 1.6-1.5 1.21c-1-2-1-2-.87-3.94 1.65-3.9 4.83-5.06 8.87-5.06"/><path fill="#302e34" d="m923 402 2 1-2 1zm-7 1 1 2h5l-1.81.81C918 407 918 407 916.5 408.7L915 410c-2.19-.31-2.19-.31-4-1l3-3-2-1z"/><path fill="#e8e7e8" d="M1586 402h7l1 5h-6z"/><path fill="#c0bfc0" d="m1361 354 4 1 .31 3.31c.36 2.6.56 3.53 2.25 5.63L1369 365c-3.05-.33-4.03-1.03-6.25-3.25-1.88-2.96-2.14-4.33-1.75-7.75"/><path fill="#09090f" d="M880 284h1c.13 2.38.13 2.38 0 5l-2 2-.87 2.94c-1.22 3.3-2.17 4.28-5.13 6.06.4-4.4 2.18-6.9 4.84-10.25A14 14 0 0 0 880 284"/><path fill="#68686c" d="M447 268h1a433 433 0 0 1 .1 5.96C448 276 448 276 447 279h-2l-1 9c-.94-3.76-1.05-4.8-.19-8.31l.58-2.36q1.23-4.69 2.61-9.33"/><path fill="#575859" d="M396 244h1q.08 3.15.13 6.31l.05 1.8c.03 3.21.05 5.46-2.18 7.89l-5-1 1.92-1.11c2.79-2.53 2.85-4.39 3.33-8.08l.45-3.3z"/><path d="m1298 237 4 1v9l-3-1z"/><path fill="#8e8e8f" d="M469 208v9l-2 1-2-1c-.37-5.52-.37-5.52 1.44-7.87C468 208 468 208 469 208"/><path fill="#2a292e" d="M464 205c1 2 1 2 .33 4.46l-1.08 2.91-1.05 2.9A27 27 0 0 1 458 222c.18-6.94 1.88-11.42 6-17"/><path fill="#78787b" d="M477 181h1v8l2 1h-2l-1 4v-3h-2q-.08-1.94-.12-3.87l-.08-2.18c.2-1.95.2-1.95 2.2-3.95"/><path d="m1274 181 4 1v9l-3-1z"/><path fill="#909092" d="M784 174c2.38-.3 2.38-.3 5.13-.19l2.75.08 2.12.11-1 3h-11z"/><path fill="#2a292f" d="m912 162 2 1c-.87 4.88-.87 4.88-2 6-2.34.14-4.66.04-7 0z"/><path fill="#8d8d8e" d="m552 156 3 3c-2.55 2.35-3.71 2.98-7.25 3.19L545 162v-3l2.94-.94C551 157 551 157 552 156"/><path fill="#47474b" d="M860 151c-5.33 3.64-9.66 4.28-16 4 4.44-4.44 10.1-4.27 16-4"/><path fill="#7e7d7f" d="m506 148 2 1-1 2.31c-1 2.69-1 2.69-1 5.69l-3-1 1-2h-4l-1 3-2-1c2.87-2.95 5.63-5.63 9-8"/><path fill="#010103" d="M1258 145c1.88.13 1.88.13 4 1a31 31 0 0 1 2 6l-4 1c-2.2-2.62-2.18-4.65-2-8"/><path fill="#0d0d12" d="M1250 122h1l2 7h-3l.19 2.88c-.19 3.12-.19 3.12-1.69 4.5l-1.5.62c-.12-6.75-.12-6.75 1-9h2z"/><path fill="#929698" d="m480 95 1 4c-8.2 6.03-8.2 6.03-11 7-2.25-.37-2.25-.37-4-1 3.34-2.75 5.87-3.75 10.13-4.25 1.87-.75 1.87-.75 3.12-3.31z"/><path fill="#09090f" d="M509 90h11l-1 3h-10z"/><path fill="#090a0f" d="M521 86h10v2l2 1h-12z"/><path fill="#000001" d="M884 78h11l-1 3h-10z"/><path fill="#f4f4f5" d="M855 78h8l-1 4h-8z"/><path fill="#e5e4e5" d="M799 75v3h-10v-3c3.85-1.28 6.03-.67 10 0"/><path fill="#000001" d="M1149 18h9l-1 4-8-1z"/><path fill="#090b0f" d="M469 1811h10v3h-10z"/><path fill="#242427" d="m1352 1791 2 1a14.7 14.7 0 0 1-7 6l-1 2c-3.06.63-3.06.63-6 1v-2h5v-5h6z"/><path fill="#000005" d="M509 1774c6.63-.12 6.63-.12 10 1v2h-14l4-1z"/><path fill="#121219" d="m1271 1747 2 1c-4.12 3.88-7.45 6.65-13 8l3.7-3.7q1.49-1.5 2.92-3.05c1.38-1.25 1.38-1.25 4.38-2.25"/><path fill="#0e0e13" d="M1281 1739v3h-2l1 5-3 2v-2l-4-1c5.29-5.43 5.29-5.43 8-7"/><path fill="#010106" d="M1298 1721h3v5h-3v4l-4-1z"/><path fill="#7f8088" d="m417 1717 1 5-4-3 2 7c-1.87-.62-1.87-.62-4-2-1.25-3.12-1.25-3.12-2-6 2.5-.69 4.38-1 7-1"/><path fill="#4a4a52" d="M454 1619c4.68.62 4.68.62 6.31 2.56l.69 1.44h-2l1 1.31c1 1.69 1 1.69 1 4.69h-2l-1.94-3.31-1.09-1.87C455 1622 455 1622 454 1619"/><path fill="#17181f" d="M443 1581h1l.37 2.52.5 3.3.5 3.26c.57 2.64 1.1 3.82 2.63 5.92-.37 2.19-.37 2.19-1 4-3.73-4.54-4.13-7.95-4.06-13.75l.02-2.98z"/><path fill="#000102" d="M1483 1391h3l-1 5h-2l-1 3-3 1v-5l3-1z"/><path fill="#0b0c11" d="M1023 1375c1.75-.25 1.75-.25 4 0a90 90 0 0 1 4 4l5 3 1 2c-2.87-.31-2.87-.31-6-1-1.12-1.5-1.12-1.5-2-3-2.12-.69-2.12-.69-4-1v-3z"/><path fill="#06080f" d="M1491 1374h3v6h-3l-1 3-3-1 1-3 2-1c.63-2.06.63-2.06 1-4"/><path fill="#16100d" d="M1196 1367c6.94.65 13.4 2.87 20 5v1c-8.19.68-13.33-1.45-20-6"/><path fill="#110c09" d="M1265 1357h2c3.97 3.97 4 8.6 4 14-3.4-4.53-4.93-8.45-6-14"/><path fill="#cac6ba" d="M1331 1299h3v10l-3 1z"/><path fill="#513521" d="M1070 1296c2.61 1.3 2.68 2.36 3.63 5.06l.78 2.23.59 1.71h1l1-6h1q.08 1.94.13 3.88l.07 2.17c-.2 1.95-.2 1.95-2.2 3.95-4.6-3.52-5.12-7.58-6-13"/><path fill="#20120e" d="M999 1296c5.22-.35 8.67-.31 13 3l2 2a39 39 0 0 1-15-4z"/><path fill="#0f090a" d="m929 1262 1.56.88c2.06.95 3.82 1.5 6 2.06 2.9.77 4.56 1.63 6.44 4.06h-5v-2h-9z"/><path fill="#050101" d="M1438 1247h4v3l4 1v3c-2.31.31-2.31.31-5 0-1.82-2.38-3-3.96-3-7"/><path fill="#826a57" d="M1068 1228h1v8h-2l-1 6h-1v-10l3-1z"/><path d="m1030.19 1214.94 2.17.02 1.64.04v3h-12c1.73-3.46 4.71-3.12 8.19-3.06"/><path fill="#24232c" d="m1076 1169-4 1v2h-16v-1l6.63-1 1.87-.29c3.87-.57 7.58-.8 11.5-.71"/><path fill="#292933" d="m975 1136 13 1-1 3 2 2h-9l4-3-9-2z"/><path fill="#d8d9d8" d="M299 1046h10v3h-10z"/><path fill="#09090e" d="m1053.5 1037.9 3.06.04 5.44.06v2c-2.23 1.11-3.53 1.16-6 1.19l-2.12.04c-1.88-.23-1.88-.23-3.88-2.23 1-1 1-1 3.5-1.1"/><path fill="#8b8c8f" d="M279 1032c6.05-.55 9.28.09 14 4l1 2c-2.25.25-2.25.25-5 0l-1.87-2c-2.38-2.23-2.8-2.27-5.88-2.25l-3.25.25z"/><path fill="#949fa9" d="M1546 1026c5.97.97 5.97.97 8 3 .19 2.13.19 2.13 0 4l-3-1v-2h-5z"/><path fill="#000001" d="M1027 1026h8v4l-9-1z"/><path fill="#c1c7cd" d="m1540 1010 1 4 4 1v3l3 1-1 3c-3.83-1.45-5.28-3.3-7-7-.19-2.81-.19-2.81 0-5"/><path fill="#030306" d="m1203 1011 1 2h2l-1 5-6-1c1.15-2.47 2.05-4.05 4-6"/><path fill="#b8b7b8" d="M1007 986h12l-1 3q-1.9.12-3.81.19l-2.15.1C1010 989 1010 989 1007 986"/><path fill="#6d6f71" d="M92 972h2l1 2c1.56.56 1.56.56 3.44 1 2.75.64 3.47.9 5.56 3-.37 2.13-.37 2.13-1 4l-2-4h-8z"/><path fill="#e7e6e5" d="M1231 962v8l-4 1c-.26-4.73-.26-4.73 0-7 1.36-1.36 2.05-2 4-2"/><path fill="#c1bfbe" d="M1105 957v3c-2.29 1.14-3.6 1.1-6.12 1.06l-2.2-.02-1.68-.04v-2c3.5-1.6 6.14-2.22 10-2"/><path fill="#abacad" d="M1136 950h10l-1 3h-10z"/><path fill="#292831" d="m1287 940 1 2-1 2 2 1c-.74 4.1-1.83 8.01-3 12h-1c-.22-6.1-.01-11.22 2-17"/><path fill="#929393" d="M704 943h15c-2.13 2.13-3.04 2.48-5.87 3.19l-2.06.54c-2.3.3-3.88-.04-6.07-.73h3v-2z"/><path fill="#a4a4a5" d="M198 943h11l1 3q-2.15.12-4.31.19l-2.43.1C201 946 201 946 198 943"/><path fill="#000002" d="M909 918h9l-1 3-9 1z"/><path fill="#b2b0b2" d="m1232 912-2.94 1.13c-1.66.8-1.66.8-3.06 1.87-.87 3.19-.87 3.19-1 6h-3v-3l-2-1 3-1 1-3c5.4-2.3 5.4-2.3 8-1"/><path fill="#000001" d="m1110 866-1 3-10 1c3.47-4.44 5.7-4.4 11-4"/><path fill="#8a898a" d="M1111 850c-1.21 2.42-1.93 2.6-4.37 3.63l-1.84.78c-1.79.59-1.79.59-4.79.59v-2l-2-1c4.58-1.79 8.08-2.2 13-2"/><path fill="#26242a" d="M1164 823v3l-11 1c3.04-4.56 5.85-4.11 11-4"/><path fill="#232326" d="M404 790c3.66.63 6.88 1.64 10.31 3.06l2.68 1.1 2.01.84c-1.94.56-1.94.56-4 1l-1-1q-1.96-.2-3.94-.31c-2.18-.24-2.18-.24-4.06-.69-1.5-2.06-1.5-2.06-2-4"/><path fill="#807f80" d="m1311 779 2 1c-1.82 3.64-6.3 4.68-10 6-3.06.25-3.06.25-5 0v-2l1.83-.59 2.42-.78 2.4-.78c2.35-.85 2.35-.85 4.52-2.01z"/><path fill="#242427" d="M367 770c6.92 2.15 6.92 2.15 9 4v2l5 2c-2 1-2 1-4.81.38-4.2-1.81-7.1-4.2-9.19-8.38"/><path fill="#000002" d="m762 758-1 3h-12l1-2c4-1.18 7.87-1.08 12-1"/><path fill="#949596" d="M1518 757c-1.75 3.88-1.75 3.88-4 5l-2.5.44c-2.5.56-2.5.56-4.5 2.56.13-2.31.13-2.31 1-5 3.32-2.14 6.01-3.46 10-3"/><path fill="#090a10" d="M70 751h3l-1 5-3 1-1 3h-2v-6l3-1z"/><path fill="#0a090e" d="M1052 744c2.06.44 2.06.44 4 1-6.35 2.85-12.03 4.66-19 5 3-2.56 5.3-3.5 9.19-4.12 4.72-.8 4.72-.8 5.81-1.88"/><path fill="#0c0c11" d="m1093.63 731.88 2.37.12-1.87.88C1092 734 1092 734 1090 736c-4.3 1.4-8.5 2.34-13 3 1.76-2.27 3.1-3.81 5.98-4.42a90 90 0 0 1 4.22-.3c2.95-.45 3.2-2.25 6.42-2.4"/><path fill="#010006" d="M905 726h10l-1 3h-10z"/><path fill="#010007" d="m379 722 6 1v2l4 1v3h-4v-3l-5-1z"/><path fill="#55348b" d="m874 723 2 1-1 2h12c-3 2-4 2.29-7.44 2.56-2.96.25-5.7.65-8.56 1.44z"/><path fill="#49484c" d="m250 705 3 1a73 73 0 0 1-10 7q-3 1.98-6 4c.19-1.81.19-1.81 1-4 3.12-2.25 6.4-3.67 10-5v-2z"/><path fill="#c9a9e8" d="m396 706 2 1v11l-3-1a433 433 0 0 1-.1-5.96c.1-2.04.1-2.04 1.1-5.04"/><path fill="#858483" d="m1474 698 2 1-1 3h-2v4h-5c-.31-1.81-.31-1.81 0-4 2.44-1.69 2.44-1.69 5-3z"/><path fill="#47484b" d="m165 666 6 2-4 1-1 4-2.94.38-3.06.62-1 2-5-1 1.94-.94C158 673 158 673 159 672q3-.06 6 0z"/><path fill="#a7a3a5" d="m176 659 1 4c-6.14 4.57-6.14 4.57-11 4 1.75-3.87 1.75-3.87 4-5q2.5-.06 5 0z"/><path fill="#000103" d="M275 659h6c-1.25 3.75-2.57 4.24-6 6h-2v-2l-3-1h4z"/><path fill="#737376" d="M1550 653v3h-3l-1 3-5 1c.19-1.81.19-1.81 1-4 2.9-2.14 4.36-3 8-3"/><path fill="#e8e8e9" d="m1649 653 1 2-1 3h5l-1 4c-1.94-.37-1.94-.37-4-1l-1-2c-1.56-1.12-1.56-1.12-3-2z"/><path fill="#a1a2a5" d="m216 635 1 4c-6.14 4.57-6.14 4.57-11 4 1.75-3.87 1.75-3.87 4-5q2.5-.06 5 0z"/><path fill="#b48ae1" d="M461 633c1.08 3.23.99 5.14.69 8.5l-.24 2.9c-.44 2.54-.98 3.58-2.45 5.6q-.08-3.7-.12-7.37l-.06-2.12c-.01-1.84.08-3.68.18-5.51z"/><path fill="#b5a1cc" d="m440 596 1.65.62A59 59 0 0 0 458 600l1 3-7.25-1.37-2.07-.39c-3.52-.68-6.55-1.39-9.68-3.24z"/><path fill="#b7a4cd" d="M416.19 586.94 418 588v2h5c-2.43 1.54-3.67 2.03-6.58 1.76l-2.67-.63-2.7-.62L409 590l2-1v-2c3-1 3-1 5.19-.06"/><path fill="#15141a" d="m1384 584 2 1-4.75 4.31-1.34 1.23A92 92 0 0 1 1372 597l-2-1a571 571 0 0 1 14-12"/><path fill="#2d184f" d="m1261 579 3 1v2c-7.11 4.6-7.11 4.6-12 4l2-4 3 1z"/><path fill="#b2a4c4" d="M414 574h12l-1 3c-7.43.14-7.43.14-11-1z"/><path fill="#a098ad" d="M426 570c5.36.85 10.68 1.92 16 3v1c-5.13.1-9.96.03-15-1z"/><path fill="#06070d" d="M676 571h23v3l-1.72-.48a38 38 0 0 0-6.83-.84l-2.56-.12-2.64-.12-2.7-.13L676 572z"/><path fill="#000003" d="M938 523h10l-1 3-9 1z"/><path fill="#000002" d="m406 521 9 1 1 3h-9z"/><path fill="#2b1851" d="M1338 476c2 1 2 1 2.85 3.51l1.56 6.31c.58 3.14.7 6 .59 9.18a43 43 0 0 1-3.62-9.5l-.8-2.84c-.5-2.34-.7-4.28-.58-6.66"/><path fill="#2d1a51" d="m1149.06 452.94 2.94.06c-.68 1.95-.68 1.95-2 4-2.38.6-2.38.6-5.12.75l-2.76.17-2.12.08 2.44-1.94c3.8-3.07 3.8-3.07 6.62-3.12"/><path fill="#47464a" d="m1682 445 9 1v5l5 1v1h-6l-1-4-2.37-.31C1684 448 1684 448 1682 445"/><path fill="#2e1c54" d="m1191 431 1 2h6v1a70 70 0 0 1-11 3v-4z"/><path fill="#2d1d52" d="M1229 413c-.3 3.59-.77 4.78-3.44 7.31C1223 422 1223 422 1221 422v-3h4v-2l-3-1c4.75-3 4.75-3 7-3"/><path fill="#636265" d="m1597 406 .56 1.94c1.44 2.06 1.44 2.06 3.13 2.55 1.76.24 3.54.38 5.31.51v2h-9l-2-4z"/><path fill="#0b0b0f" d="m1558.75 405.81 2.42.08 1.83.11-1 3h-12c4.5-3.37 4.5-3.37 8.75-3.19"/><path fill="#4a4c4e" d="M1509 387c4 2 4 2 5.78 3.03 2.7 1.18 5.03 1.39 7.97 1.6l2.98.22 2.27.15v1c-13.57.4-13.57.4-18-2-.87-2.12-.87-2.12-1-4"/><path fill="#151519" d="m1323 290 4 1 .15 1.86.22 2.45.22 2.43A10 10 0 0 0 1330 303h-6l2-1-1-8h-2z"/><path fill="#2e3234" d="M388 298c.22 3.86-.4 6.5-2 10l-4-8c3.75-2 3.75-2 6-2"/><path fill="#353339" d="M890 260c-2.31 6.12-2.31 6.12-4 8h-3c-.31-2.31-.31-2.31 0-5 2.38-1.82 3.96-3 7-3"/><path fill="#333239" d="M898 245c-.53 3.82-1.5 6.06-4 9l-1-2h-3l1.44-1.31C893 249 893 249 894 246c2-1 2-1 4-1"/><path fill="#f8f8f8" d="M394 242c.35 4.67.35 4.67 0 7-2 1.94-2 1.94-4 3v-9c3-1 3-1 4-1"/><path fill="#727376" d="m824.06 199.94 2.94.06c-3.03 2.5-5.93 3.92-9.62 5.25l-2.92 1.08C812 207 812 207 810 206c2.72-3.52 5.86-3.93 10-5 1-1 1-1 4.06-1.06"/><path fill="#858587" d="M812 198h5l-3 1v2l5 1v1l-5.18 1.46q-2.42.72-4.82 1.54c0-3 0-3 2-5zm-3 3 2 1Z"/><path fill="#6b6b6e" d="m879.63 174.88 2.37.12c-2.44 2.8-5 4.12-8.37 5.63l-2.84 1.28A37 37 0 0 1 863 184a32 32 0 0 1 10.45-6.29c2.73-1.25 2.93-2.68 6.17-2.84"/><path fill="#0b0c10" d="M425 163c5.54-.37 5.54-.37 7.88 1.5L434 166l-2 5h-2v-7h-4l-1 4z"/><path fill="#0f1014" d="M441 138h1v5l1.81-2c2.19-2 2.19-2 5.19-2-1 3-1 3-2.12 4.19-2.96 1.28-5.68.93-8.88.81l-1 3c.38-2.44.38-2.44 1-5l2-1z"/><path fill="#807e81" d="M604 138h19l-1 4v-3l-2.25 1.13c-5.11 1.77-10.62-.05-15.75-1.13z"/><path fill="#616163" d="M850 82c2 2 2 2 2 5-6.07 2.16-11.6 2.2-18 2v-3l1 1c5 .51 9.44.11 14-2z"/><path fill="#c3c2c5" d="M729 78h4l1 2c3.16 1.05 5.87 1.31 9.19 1.56l3.29.26L749 82v1q-3.6.12-7.19.19l-2.04.07c-4.4.07-7.2-.4-10.77-3.26z"/><path fill="#b4b3b5" d="m1162 6 7 1v3h-9z"/><path fill="#aeafb0" d="M1316 1822h9v3l-7 1z"/><path fill="#a9a6ab" d="M1358 1793c-.5 1.81-.5 1.81-2 4a54 54 0 0 1-8 3c1.96-4.73 4.65-7.57 10-7"/><path fill="#000001" d="M1400 1726h2v9h-4l.44-3.94.24-2.21c.32-1.85.32-1.85 1.32-2.85"/><path fill="#d4d4d6" d="M1426 1692h3c1.16 4.05.84 6.22-1 10l-2-1z"/><path fill="#222225" d="M353 1691h1l.4 1.5.54 1.94.52 1.93c.54 1.63.54 1.63 1.54 2.63q.06 3 0 6h-1v-5l-6 1c.79-3.41 1.87-6.69 3-10"/><path fill="#f7f7f8" d="M1420 1693h2v8l-4 1c-.2-5.27-.2-5.27 0-7z"/><path fill="#dbdadc" d="M1430 1669h3c1.47 3.81.43 6.29-1 10l-2-1z"/><path fill="#171820" d="m580 1569 3 1-2 1v3c-2.31 2.25-2.31 2.25-5 4l-3-1c1.49-3.79 3.62-5.77 7-8"/><path fill="#959699" d="m346 1563 2 1v9l-2 1c-2.22-6.4-2.22-6.4-1.12-9.44z"/><path fill="#74717c" d="M1311 1556h1v12h2l-1 7h-2z"/><path fill="#b8b7b9" d="M1444 1438h9v3l-7 1z"/><path d="M1425 1431h10v3h-10z"/><path fill="#9b9d9f" d="M1458 1425c-1.75 3.88-1.75 3.88-4 5l-2.5.44c-2.5.56-2.5.56-4.5 2.56.16-2.26.57-3.6 2.26-5.16 2.9-2.05 5.06-3.4 8.74-2.84"/><path fill="#38383b" d="m1458 1419 2 1-1 6-2.25-.06c-2.45.05-4.41.37-6.75 1.06l2-4h5z"/><path fill="#0a0b11" d="M1104 1417c5.25.54 9.22 1.76 14 4v2l2 1-6-1v-2h-6v-2h-4z"/><path fill="#1c1c22" d="M1510 1347h1v7l3 1-6 2-1 8h-1q-.05-2.15-.06-4.31l-.04-2.43c.1-2.26.1-2.26 1.1-5.26h3z"/><path fill="#50341f" d="m1113 1330-1 5-6-1v-3c2.46-1.23 4.28-1.07 7-1"/><path fill="#16100b" d="M1045 1285c3.32 4.72 4.27 9.26 4 15-2-1-2-1-3.12-3.56-1.18-3.79-1.06-7.51-.88-11.44"/><path fill="#cf794d" d="M1345 1283c1.47 3.81.55 6.32-1 10l-2 1c-1.12 1.56-1.12 1.56-2 3 .5-5.06 1.21-10.21 5-14"/><path fill="#821905" d="M1411 1283c1.08 2.8.96 4.2.31 7.19-1.31 2.81-1.31 2.81-3.43 3.68l-1.88.13c.55-4.64 2.33-7.3 5-11"/><path fill="#0d0f12" d="M341 1268h1v10l-4-1c.75-6.75.75-6.75 3-9"/><path fill="#5d3c26" d="m959 1268 1 3h6l1 3q-2.16-.14-4.31-.31l-2.43-.18c-2.62-.6-3.57-1.47-5.26-3.51h4z"/><path fill="#e94b10" d="m1423 1254 5.27.68c1.73.32 1.73.32 3.73 1.32l-1 3v-2h-4l-1 3-4-1 3-2-2-1z"/><path fill="#020209" d="m856 1242 3 1c-1.19 2.44-1.19 2.44-3 5-2.69.81-2.69.81-5 1 .38-2.76.84-3.84 2.88-5.81z"/><path fill="#aa8e7c" d="m1000 1231-1.25.81c-1.75 1.19-1.75 1.19-3.69 2.75A13.4 13.4 0 0 1 989 1237l1-2-4-2 1.68-.4c.36-.1.36-.1 2.2-.54l2.17-.52c5.08-1.4 5.08-1.4 7.95-.54"/><path fill="#050406" d="m1291.06 1228.94 1.94.06v4h-3l-2 4v-3h-2c2.41-4.98 2.41-4.98 5.06-5.06"/><path fill="#3a3839" d="m1363 1225-10 2v2l-8 2c4.24-5.6 11.38-7.65 18-6"/><path fill="#404049" d="M1304 1168h16v3c-5.63.24-10.55-.61-16-2z"/><path fill="#aeb7c0" d="m1599 1148 4 1-.94 1.28a16 16 0 0 0-2.06 5.22 17 17 0 0 1-3 6.5l-1-5 3-1z"/><path fill="#393843" d="m735 1144 1 4-6 5-2-1c1.38-2.5 1.38-2.5 3-5h2v-2z"/><path fill="#d4dae0" d="M1618 1124h3c1.16 4.05.84 6.22-1 10l-2-1z"/><path fill="#d8dde1" d="M1502 1093c4.39.51 7.34 1.52 11 4l-1 4-1-3-2.81.19c-3.19-.19-3.19-.19-5.07-1.57-1.12-1.62-1.12-1.62-1.12-3.62"/><path fill="#08090d" d="M1140 1042h8v1l9 1v1l-7.37.06-2.12.03c-5.28.02-5.28.02-7.51-1.09z"/><path fill="#c6c5c8" d="M279 1042h9v3c-4.05 1.16-6.22.84-10-1z"/><path fill="#c8c8c9" d="M239 1034h9v3c-4.05 1.16-6.22.84-10-1z"/><path fill="#46454f" d="M1269 1028h1l1 13-2 1-1-8h-3c1.15-2.47 2.05-4.05 4-6"/><path fill="#5b5b5d" d="M1030 1022c12.67 2.9 12.67 2.9 18 6-3.14 1.03-4.82 1-7.87-.25l-2.1-.85q-4.08-1.8-8.03-3.9z"/><path fill="#f3f3f3" d="M238 1026h7l2 4h-8z"/><path fill="#1e1e21" d="M1007 1013c8.35 2.31 8.35 2.31 11.06 5.19l.94 1.81-4.37-.44-2.47-.24c-2.16-.32-2.16-.32-4.16-1.32-.62-2.56-.62-2.56-1-5"/><path fill="#818083" d="M1002 1008h4l1 2q2.74 1.4 5.56 2.63l5.44 2.37v1c-5 .4-7.38-.46-11.37-3.44l-2.65-1.93-1.98-1.63z"/><path fill="#c2c1c2" d="m1005 1003 8 2v1l-5 1 1 4-3-1v-2l-5-1 4-1z"/><path fill="#aaa8aa" d="M988 987c-2.45 1.96-4.08 2.23-7.19 2.13l-2.17-.06L977 989v3h-2l1-5c8.45-1.33 8.45-1.33 12 0"/><path fill="#000001" d="M136 986c5.75-.12 5.75-.12 8 1v3h-8z"/><path fill="#717174" d="M1422 949c0 6.01-3.14 10.6-7 15 .27-6.55 2.81-10.07 7-15"/><path fill="#000001" d="m190 950 4.38-.06 2.46-.04c2.16.1 2.16.1 4.16 1.1v2h-10z"/><path fill="#9a9a9a" d="M317 942c5.75-.12 5.75-.12 8 1v2h-17v-1l9-1z"/><path fill="#8e8e91" d="M855 917v3h-14c2.25-2.25 2.84-2.32 5.88-2.62l2.05-.23q3.03-.21 6.07-.15"/><path fill="#040407" d="m1194 915 3 1v2l4 1c-2.65 1.46-3.9 2-7 2v-2h-6v-1l2.44-.37 2.56-.63z"/><path fill="#454349" d="m242.63 915.44 2.47.3 1.9.26-1 3q-2.22-.17-4.44-.37l-2.5-.22L237 918l-1-2c2.52-1.26 3.85-.92 6.63-.56"/><path fill="#000001" d="m1038.63 885.94 2.47.02 1.9.04-1 3h-10v-2c2.4-1.2 3.95-1.1 6.63-1.06"/><path fill="#6c6c71" d="M1460 874h2v5l-1.94.56c-2.06 1.44-2.06 1.44-2.55 3.13a88 88 0 0 0-.51 5.31h-1c-.35-5.23-.3-8.65 3-13z"/><path fill="#8d9aa4" d="M1515 856h3v10h-3z"/><path fill="#bababf" d="M1464 851h2c-.46 2.13-.94 3.9-2 5.81-1.16 2.55-1.4 4.66-1.62 7.44l-.23 2.7-.15 2.05h-1q-.12-3.09-.19-6.19l-.1-3.48c.32-3.62 1.07-5.48 3.29-8.33"/><path fill="#07070b" d="m1168 847-1.5.81-1.5 1.19v3h-3v-1l-6-1c3.83-2.82 7.32-4.73 12-3"/><path fill="#000004" d="M1202 834h8v3l-10 1z"/><path fill="#06060b" d="m1214 833 3.25 1.63L1220 836v1c-4.52.25-4.52.25-6.81-.94-3.26-1.58-6.62-1.73-10.19-2.06 4.16-2.87 6.5-3.65 11-1"/><path fill="#0f0f12" d="m475 808 8.79 1.46c.53.1.53.1 3.21.54l6 1v1c-12.67.55-12.67.55-18-3z"/><path fill="#514f54" d="m1494 767 4 1h-3v6l-2.25-.06c-2.45.05-4.41.37-6.75 1.06.69-1.94.69-1.94 2-4 2.63-.75 2.63-.75 5-1z"/><path fill="#3b3b3d" d="m1518 751 2 1-1 6-2.25-.06c-2.45.05-4.41.37-6.75 1.06l2-4h5z"/><path fill="#a4a3a5" d="m150 750-1 3-3 1-1 1.56-1 1.44h-3c.37-2.71.81-3.82 2.81-5.75 2.34-1.34 3.55-1.53 6.19-1.25"/><path fill="#343439" d="M1380 741c-1.45 2.58-2.7 3.58-5.44 4.69l-1.8.76c-1.76.55-1.76.55-4.76.55.5-1.81.5-1.81 2-4 6.83-3.59 6.83-3.59 10-2"/><path fill="#818182" d="m1412 730 10 1v3c-4.22.2-7.17-.2-11-2z"/><path fill="#828082" d="m1420 726 9 1v3l-10-1z"/><path fill="#a6a5a7" d="M189 722v2h-2v2c-1.37 1.63-1.37 1.63-3 3h-2v-3l-3-1c6.29-3.43 6.29-3.43 10-3"/><path fill="#2e2e32" d="M319 720a31 31 0 0 1 8 11v3c-3.98-2.41-6.28-5.74-8-10-.1-2.22-.1-2.22 0-4"/><path fill="#05020c" d="m935 718 2 1-5 6-2-1 1-2h-9v-1l2.08-.4c.45-.1.45-.1 2.73-.54l2.71-.52C932 719 932 719 935 718"/><path fill="#020204" d="M185 711h6v3c-4.22 3-4.22 3-7 3v-2l-2-1h3z"/><path fill="#201f24" d="M1217 709a50593.1 50593.1 0 0 1-7.42 4.79c-2.58 1.21-2.58 1.21-4.9.9L1203 714l2.38-.25c2.62-.75 2.62-.75 3.74-2.75l.88-2c3.13-1.04 3.99-.93 7 0"/><path fill="#a0a0a2" d="M215 703v2l3 1h-3l-1 4-4-1h3v-3l-2.37 1.56L208 709l-2-1c2.54-2.8 5.08-5 9-5"/><path fill="#52338a" d="M980 695h11v2c-2.75.92-4.36 1.1-7.19 1.06l-2.17-.02L980 698z"/><path fill="#939395" d="m259 681 2 1-3.31 4-1.87 2.25C254 690 254 690 251 690v-4l1.5-.84 1.94-1.1 1.93-1.09C258 682 258 682 259 681"/><path fill="#262629" d="m160 673 2 1-2 4h-6v3l-3-1h2v-6l6 2z"/><path fill="#858386" d="m288 664-2.94 1.31C282 667 282 667 281 670h-9c2.92-1.95 4.63-2.45 8-3v-2c5.75-2.12 5.75-2.12 8-1"/><path fill="#2d164f" d="M1063 669v2h6c-3.2 2.83-5.78 3.52-10 4v-5c2-1 2-1 4-1"/><path fill="#08080e" d="m355 661 3 1v10l-2 1c-1.32-3.95-1.09-7.89-1-12"/><path fill="#16151b" d="m1470 654 2 1c-3.73 4.43-7.3 8.59-12 12 1.52-3.48 3.58-5.96 6.13-8.75l2.19-2.42z"/><path fill="#030208" d="M1179 633c-1.12 2-1.12 2-3 4-3.19.25-3.19.25-6 0 .81-1.94.81-1.94 2-4 3.13-1.04 3.99-.93 7 0"/><path fill="#341c62" d="M1036 611c2.88 2.46 3.97 4.9 5.25 8.44l1.08 2.87c.68 2.75.64 4.07-.33 6.69l-3-6.75-.87-1.92c-2.55-5.84-2.55-5.84-2.13-9.33"/><path fill="#844ecc" d="m415.06 595.94 1.94.06v2l-3 1-1 16h-2l.44-8.94.12-2.57.12-2.45.11-2.27c.3-2.59.66-2.75 3.27-2.83"/><path fill="#130926" d="M1224 604c2.13.38 2.13.38 4 1-5.04 3.98-9.7 5.05-16 6 1.08-2.06 1.64-2.87 3.88-3.66l2.12-.4c3.8-.74 3.8-.74 6-2.94"/><path fill="#c3c1c6" d="m1702 583 3 1-1.5.69-1.5 1.31v2.44c0 2.56 0 2.56-1.37 4.37C1699 594 1699 594 1697 594c.6-4.24 1.9-7.9 5-11"/><path fill="#aea6c3" d="M647 590c-4.27 2.14-6.77 3.12-11.62 3.06l-3.04-.02L630 593c5.2-3.65 10.88-3.12 17-3"/><path fill="#c29fea" d="m392 584 1 3-2 2c-.41 2.16-.41 2.16-.62 4.63l-.23 2.47-.15 1.9h-1l-.68-5.18C388 591 388 591 387 588c.75-1.62.75-1.62 2-3z"/><path fill="#101016" d="m1624 583 2 1q-2.07 2.7-4.19 5.38l-1.17 1.53c-2.57 3.22-4.57 4.89-8.64 6.09z"/><path fill="#02000a" d="M378 582h1l1 6h2l-1 6-3 1-2-3 1-1q.34-2.27.56-4.56z"/><path fill="#907db8" d="M781 579h10a27 27 0 0 1-7.94 3.69l-2.59.82c-2.58.51-4.01.34-6.47-.51l2.94-.87C780 581 780 581 781 579"/><path fill="#46494a" d="M343 504h3v9l-3-1c-1.12-5.75-1.12-5.75 0-8"/><path fill="#323137" d="M845 502h10l1 4c-1 1-1 1-2.85 1.1l-2.21-.04-2.23-.02L847 507v-1h6v-2h-8z"/><path fill="#08080c" d="M1703 469c2.39.58 4.67 1.22 7 2v6h-3v-4h-3z"/><path fill="#000003" d="M408 460h1q.05 2.16.06 4.31l.04 2.43C409 469 409 469 408 472h-2q-.08-2.16-.12-4.31l-.08-2.43c.21-2.4.63-3.46 2.2-5.26"/><path fill="#d3d3d3" d="m348 462 2 1v9h-3c-1.16-4.05-.84-6.22 1-10"/><path fill="#2d1a53" d="M1182 438v3l-5.27 1.37c-1.73.63-1.73.63-3.73 2.63l-3-1 2.81-2.44 1.58-1.37c2.53-1.87 4.5-2.19 7.61-2.19"/><path fill="#000001" d="M1629 426h8v4c-5.75.13-5.75.13-8-1z"/><path fill="#242427" d="m1594 406 2 1 1 5 2 2h-7v-4l-3-1h5z"/><path fill="#f8f8f8" d="M1540 390h8l1 4h-7z"/><path fill="#130b25" d="M1302 348h6q1.05 2.37 2.06 4.75l1.16 2.67c.83 2.73.76 3.95-.22 6.58l-.95-2.55-1.3-3.32-.62-1.69c-1.08-2.72-1.8-4.23-4.3-5.82z"/><path fill="#a2a0a3" d="m1347 310 2 1c.59 2.31.74 4.62 1 7l-4 2-.06-4.44-.04-2.5c.1-2.06.1-2.06 1.1-3.06"/><path fill="#cacbca" d="m372 302 2 1v9h-3c-1.16-4.05-.84-6.22 1-10"/><path fill="#7a7d7d" d="M387 281h1q.12 3.13.19 6.25l.07 1.78c.07 3.98-.52 6.07-3.26 8.97l-3-1 1.94-2.37c2.84-4.18 2.87-8.7 3.06-13.63"/><path fill="#f5f6f5" d="M386 280v8l-4 2q-.12-1.94-.19-3.87l-.1-2.18c.29-1.95.29-1.95 1.74-3.2C385 280 385 280 386 280"/><path fill="#0a0a10" d="m399 265 2 1v14c-3.03-4.04-3.39-4.55-3.25-9.19l.05-2.67c.2-2.14.2-2.14 1.2-3.14"/><path fill="#929295" d="M452 262h1v11l-3 1q-.08-2.15-.12-4.31l-.08-2.43c.21-2.4.63-3.46 2.2-5.26"/><path fill="#928f95" d="m1319 246 2 1c.59 2.31.74 4.62 1 7l-4 2-.1-6.93c.1-2.07.1-2.07 1.1-3.07"/><path fill="#939294" d="m1307 218 2 1c.59 2.31.74 4.62 1 7l-4 2-.1-6.93c.1-2.07.1-2.07 1.1-3.07"/><path fill="#444448" d="m839.19 196.38 1.81.62c-5.22 4.1-10.6 5.5-17 7 1.33-2.67 2.65-2.87 5.38-4 4.36-1.82 4.36-1.82 6.27-3.15 1.35-.85 1.35-.85 3.54-.47"/><path fill="#848587" d="m510 190 1 2 3 1-1.81 3-1.02 1.69L510 199h-3v-5l2-1z"/><path fill="#3b3940" d="M540 174c0 2 0 2-1.64 3.7q-1.08.9-2.17 1.86l-2.15 1.88C532 183 532 183 529 184c1.84-4.79 5.67-10 11-10"/><path fill="#525156" d="M903 166v2c-4.08 3.19-8 3.64-13 4 3.53-4.23 7.43-6 13-6"/><path fill="#8b8a8e" d="m1283 162 2 1c.59 2.31.74 4.62 1 7l-4 2-.1-6.93c.1-2.07.1-2.07 1.1-3.07"/><path fill="#828183" d="M838 161v1l-2.59.4-3.35.54-3.34.52C826 164 826 164 825 165q-2.02.1-4.06.06l-2.23-.02L817 165c5.7-4.4 14.1-4.26 21-4"/><path fill="#8c8c8c" d="m514 147-1 3h-2v3l-6 1c1.46-4.64 1.46-4.64 3.94-6.31C511 147 511 147 514 147"/><path fill="#c2c1c2" d="m1271 134 2 1c.59 2.31.74 4.62 1 7l-4 2-.1-6.93c.1-2.07.1-2.07 1.1-3.07"/><path fill="#2d2b32" d="m1014 135 3 1-4.19 3.44-2.35 1.93c-2.57 1.7-4.43 2.25-7.46 2.63 2.95-4.2 5.33-5.92 10-8z"/><path fill="#bcbcbd" d="m1267 126 2 1c.59 2.31.74 4.62 1 7l-4 2-.1-6.93c.1-2.07.1-2.07 1.1-3.07"/><path fill="#4e5556" d="m455.19 118.31 1.81.69c-.19 1.81-.19 1.81-1 4-2.56 1.75-2.56 1.75-5 3-.69-2.31-.69-2.31-1-5 1.58-2.5 2.21-3.11 5.19-2.69"/><path fill="#000002" d="M551.04 118.6q1.06.08 2.15.15l2.17.1q.8.08 1.64.15v3l-11 1c3-4 3-4 5.04-4.4"/><path fill="#090b11" d="M473 110h4v4h-3l-1 3h-3v-4h3z"/><path fill="#dedfde" d="M770 82c6.43.6 12.68 1.64 19 3v1c-6.14.23-12.1-.14-18-2z"/><path fill="#b3b4b5" d="M660 70h4v2l13 2v1q-3.4.12-6.81.19l-1.95.07c-2.9.05-4.78.07-7.19-1.65C660 72 660 72 660 70"/><path fill="#8c8c8e" d="m903 59 1 3h-9v-3c3.29-1.1 4.71-.8 8 0"/><path fill="#090a0e" d="M1041 22h9v2l3 1h-12z"/><path fill="#9c9d9f" d="M1325 1818h8v3l-7 1z"/><path fill="#babdbb" d="M435 1818h8l-1 4-7-1z"/><path fill="#000001" d="M436 1802c5.75-.12 5.75-.12 8 1v3h-7z"/><path fill="#08090f" d="m394 1763 4 1v2l3 1v3h-4v-3h-3z"/><path fill="#35333e" d="m466 1744 1 2h2v5h5v3c-3.69-.5-5.6-1.1-8-4h2v-2h-2l-1-3z"/><path fill="#aaaaab" d="m1402 1738 3 1-1.5.63-1.5 1.37v2.94c0 3.06 0 3.06-1.37 4.93-1.63 1.13-1.63 1.13-3.63 1.13l1-5h2l-.12-2.37c.12-2.63.12-2.63 2.12-4.63"/><path fill="#c1c1c4" d="m1410 1737 4 1-1 7h-3z"/><path fill="#c3c2c3" d="M355 1733h3v8h-3c-.88-5.37-.88-5.37-1-7z"/><path fill="#000003" d="m407 1723 3 1 1 3a90 90 0 0 0 4 4l-3 2c-4.9-5.25-4.9-5.25-5.31-8.37z"/><path fill="#8c8c94" d="M414 1719c3.16 1.05 3.51 1.32 5.19 3.94l1.04 1.59c.77 1.47.77 1.47.77 3.47-1.81-.12-1.81-.12-4-1-3-5.04-3-5.04-3-8"/><path fill="#919492" d="M347 1713h3v8h-3c-.88-5.37-.88-5.37-1-7z"/><path fill="#0f0f12" d="M1417 1676c0 2.3-.13 4.32-.44 6.57l-.27 2.03-.29 2.09-1 7.31h-1v-17z"/><path fill="#45444d" d="M446 1604h2l1 4h2l2 5c-3-1-3.95-1.75-6-4v2h-2l-1-4h2z"/><path fill="#b6b5b7" d="M1437 1442h8v3l-7 1z"/><path fill="#eaeaeb" d="m1435.56 1436.94 2.44.06-1 5h-6c1.12-4.98 1.12-4.98 4.56-5.06"/><path fill="#636566" d="m1446 1427 4 1-2 2a89 89 0 0 0-2 4l-7-1 1-2h5z"/><path fill="#000003" d="M1360 1401v3l2 1h-6l-1-2c-1.63-.41-1.63-.41-3.56-.62l-3.44-.38v-1c8.45-1.33 8.45-1.33 12 0"/><path fill="#ca4b10" d="m1365 1382 1.64.91c3.53 1.63 7.23 2.34 11 3.21l2.36.88 1 3a30 30 0 0 1-14-5c-1.23-1.63-1.23-1.63-2-3"/><path fill="#020203" d="M1310 1375c4.75.75 4.75.75 7 3 .13 2.13.13 2.13 0 4-5.31-2.56-5.31-2.56-7-4z"/><path fill="#8b8b8f" d="m1514 1365 4 1-1 7h-3z"/><path fill="#000001" d="M1504 1354h2v10h-3l-.06-4.44-.04-2.5c.1-2.06.1-2.06 1.1-3.06"/><path fill="#dd480a" d="M1397 1344c1.99 2.54 2.54 4.86 3.13 8l.5 2.63c.37 2.37.37 2.37.37 5.37-2-1-2-1-3.12-4.06-1.07-4-1.07-7.83-.88-11.94"/><path fill="#780c03" d="M1454 1341h1v5h2l-2 7-1-3c-2.51 1.26-2.87 2.5-4 5-1-2-1-2-.75-4.06l.75-1.94 3-1z"/><path fill="#f7f7f8" d="M1521 1328h1v9l-4 1q.17-1.94.38-3.87l.2-2.18c.42-1.95.42-1.95 2.42-3.95"/><path fill="#e44003" d="M1344 1296h1q.12 2.43.19 4.88l.1 2.74c-.29 2.38-.29 2.38-1.8 3.7l-1.49.68q-.08-2.15-.12-4.31l-.08-2.43c.21-2.4.63-3.46 2.2-5.26"/><path fill="#751103" d="m1407 1293 3 1-1 2h-2v7h-3c.86-7.57.86-7.57 3-10"/><path fill="#cfc8c0" d="m1317 1258 1 4-3 1 .13 2.88c-.13 3.12-.13 3.12-2.13 5.12v3h-3l1-5 3-1v-6l2-1z"/><path fill="#88878a" d="m1515 1258 2 1c.59 2.31.74 4.62 1 7l-4 1q-.05-1.96-.06-3.94l-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#5e3e29" d="m1036 1253 4 1-1 1q-.34 2.77-.56 5.56l-.26 3.07-.18 2.37h-2z"/><path fill="#ded2c5" d="M1397 1234c6.63-.12 6.63-.12 10 1v2h-11z"/><path fill="#9c816b" d="M998 1234c3.19-.31 3.19-.31 6 0l-4 3 9 1v1h-14z"/><path fill="#080708" d="m1053 1222 1 4h2l-1 5h-3l-2 1a43 43 0 0 1 3-10"/><path fill="#ced7df" d="M1533 1222h8v3l-7 1z"/><path fill="#edf1f1" d="M1510 1218v8h-4v-7c3-1 3-1 4-1"/><path d="M1041 1211c2.16-.3 2.16-.3 4.63-.19l2.47.08 1.9.11-1 3h-10z"/><path fill="#595762" d="M396 1189h1v6h2v11h-2c-1-5.7-1.1-11.21-1-17"/><path fill="#b1bac2" d="m1535 1191 2 1a39 39 0 0 1 1 5h3l-1 4h-2l-1-3h-4z"/><path fill="#4e4b59" d="M494 1186c6.75-.12 6.75-.12 9 1l-1 3h-7z"/><path fill="#92a0ac" d="M1581 1149c2 2 2 2 2.31 4.75-.37 3.9-1.3 5.92-3.31 9.25l-2 1 .94-5.81.52-3.27c.54-2.92.54-2.92 1.54-5.92"/><path fill="#23232c" d="M1218 1152v7h-13v-1h9v-3c2.56-3 2.56-3 4-3"/><path fill="#1f1f28" d="M893 1101c6.92 2.15 6.92 2.15 9 4v2l5 1c-1.67.68-1.67.68-4 1-2.58-1.14-2.58-1.14-5.25-2.81l-2.7-1.65L893 1103z"/><path fill="#a6afb6" d="M1510 1092c5.97.97 5.97.97 8 3 .19 2.13.19 2.13 0 4h-3v-3l-5-1z"/><path fill="#1b1a23" d="M779 1083v3l-2 2-.87 2.94c-1.22 3.3-2.17 4.28-5.13 6.06 1.59-5.56 3.85-9.85 8-14"/><path fill="#d1d7dd" d="m1611 1070 2 1c.59 2.31.74 4.62 1 7l-4 1q-.05-1.96-.06-3.94l-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#86939d" d="M1575 1050c4.07.63 6.73 2.6 10 5l1 2q-.9-.45-1.81-.94C1582 1055 1582 1055 1579 1054l1 4h-2l-.37-1.94-.63-2.06-2-1z"/><path fill="#323438" d="M343 1040c2.31-.31 2.31-.31 5 0 1.88 2.25 1.88 2.25 3 5-.31 2.31-.31 2.31-1 4l-2-1v-2h-2v-5z"/><path fill="#3d3e42" d="M266 1027h8l1 6h-2l-1-2q-2.97-1.1-6-2z"/><path fill="#d8d8d6" d="m1059 1024 9 2-3 2 1 2c-6.43-.86-6.43-.86-9-3z"/><path fill="#d3d7dc" d="M1548 1022c3.34.58 4.7 1.6 7 4l3 1v3l4 1h-5l-1-3c-2.37-1.06-2.37-1.06-5-2-1.81-1-1.81-1-3-2z"/><path fill="#000001" d="m1016 1021 8 1v4l-7-1z"/><path fill="#edeeed" d="M222 1022c2.75-.25 2.75-.25 6 0 2.38 2 2.38 2 4 4h-9z"/><path fill="#e2e3e3" d="M1419 1014c2 2 2 2 2.2 3.95l-.08 2.17-.05 2.2-.07 1.68-3 1-.1-7.71c.1-2.29.1-2.29 1.1-3.29"/><path fill="#9d9d9f" d="M135 1002h8l-1 4-7-1z"/><path fill="#afaeae" d="M115 994h8l-1 4-7-1z"/><path fill="#e0dfde" d="m1210 990 3 1h-2l3 3c-1.75 3.88-1.75 3.88-4 5l-3-1 1-3h2z"/><path fill="#bab8ba" d="M87 982h8l-1 4-7-1z"/><path fill="#000001" d="m116 978 8 1v3h-8z"/><path fill="#bcbbbb" d="M1181 964c-5.49 2.45-10 3.43-16 3 2.39-1.8 4.53-2.53 7.44-3.19l2.3-.54c2.39-.29 4 0 6.26.73"/><path fill="#a3a4a4" d="M627 963v2c-3.15 1.05-5.39 1.1-8.69 1.06l-3-.02L613 966v-2l4.75-.5 2.67-.28c2.21-.19 4.36-.26 6.58-.22"/><path fill="#06070c" d="M1028 963q1.94-.08 3.88-.12l2.17-.08c1.95.2 1.95.2 3.95 2.2-1 1-1 1-3.29 1.1l-2.77-.04-2.79-.02-2.15-.04z"/><path fill="#c0bfbe" d="M1107 954h9v3h-10z"/><path fill="#141317" d="M1110 946h12v1l-4.25 1.5-2.4.84c-2.25.63-4.03.79-6.35.66z"/><path fill="#38373e" d="M633 942h13v2h8v1h-12v-2h-9z"/><path fill="#b7b5b6" d="M1227 921h6l-3 1-.25 2.31c-.9 3.21-1.97 3.98-4.75 5.69.49-3.12 1-6 2-9"/><path fill="#b3b1b1" d="M1226 918h7v3h-6l-1 4-2-1z"/><path fill="#000001" d="m1205 914-1 4h-7l-1-3c3.07-.91 5.8-1.09 9-1"/><path fill="#444248" d="M208 902h6l1 4c-3.51 1.21-3.51 1.21-5.75.25L208 905z"/><path fill="#111116" d="M1321 890v10h-1l-1-7-4 4c-.69-1.81-.69-1.81-1-4 2.18-3.2 2.92-3 7-3"/><path fill="#8b8c8d" d="M1040 869c2.16-.3 2.16-.3 4.63-.19l2.47.08 1.9.11-1 3h-10z"/><path fill="#88898b" d="M1097 854c-2.4 2.9-4.31 3.5-8 4v-2l-8 1c2.82-1.88 3.7-2.3 6.88-2.62l2.11-.23q3.5-.26 7.01-.15"/><path fill="#cfd5db" d="m1527 846 2 1c.59 2.31.74 4.62 1 7l-4 1q-.05-1.96-.06-3.94l-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#29272e" d="M1097 842h7l-1 3-8 1z"/><path fill="#5b5a5d" d="m1173 841-3 1-1 2c-2.5.85-2.5.85-5.56 1.63l-3.07.78q-1.16.3-2.37.59v-2h3v-2q1.87-.8 3.75-1.56l2.1-.88c2.45-.64 3.79-.36 6.15.44"/><path fill="#000003" d="M74 822c1.3 2.6.81 3.76.19 6.56l-.52 2.44c-.67 2-.67 2-2.67 3v-11z"/><path fill="#9a9a9c" d="m4 810 2 1v8l-4-1c.88-6.87.88-6.87 2-8"/><path fill="#2e2e33" d="M1228 803h-2v2q-2.37.8-4.75 1.56l-2.67.88c-2.66.58-4.05.45-6.58-.44l1.57-.59 2.12-.78 2.2-.83q2.5-.96 4.92-2.05c2.19-.75 2.19-.75 5.19.25"/><path fill="#46444a" d="m157 802-1 3h-2v8h-1l-1-7-1 2h-2v-4c2.82-1.61 4.75-2.23 8-2"/><path fill="#dedde0" d="M1478 790h4l-1 8h-3z"/><path fill="#474549" d="m1484.38 775.25 1.62.75-2 2a89 89 0 0 0-2 4v-2l-7 2c1.2-3.6 2.36-4.33 5.43-6.28 1.57-.72 1.57-.72 3.94-.47"/><path fill="#535255" d="M49 766h3c-2.46 5.1-2.46 5.1-4 7l-3 1-1 2-2-1 3.31-4.5 1.87-2.53z"/><path fill="#3f3f41" d="M335 761c3.1 1.75 5.5 3.5 8 6l3 1c.69 2.06.69 2.06 1 4-2.31-.17-3.66-.65-5.27-2.33a121 121 0 0 1-2.48-3.2c-1.25-1.47-1.25-1.47-2.9-2.54L335 763z"/><path fill="#0d0d11" d="m1385 759-2 6-1-3-9 2c1.74-3.07 3.25-4.31 6.56-5.31 3.13-.84 3.13-.84 5.44.31"/><path fill="#000004" d="M829 747c-2 2-2 2-5.04 2.2q-1.8-.02-3.59-.08l-1.85-.02-4.52-.1 1-2c2.4-.45 2.4-.45 5.38-.69l2.96-.26C826 746 826 746 829 747"/><path fill="#8a4dd2" d="M408 730c2.72.13 5.35.32 8 1 1.5 2.06 1.5 2.06 2 4a39 39 0 0 1-8-1c-1.5-2.06-1.5-2.06-2-4"/><path fill="#25133e" d="M392 727c4.68 1.34 8.04 3.17 12 6a9.3 9.3 0 0 1-6.56.38c-2.96-1.67-3.94-3.38-5.44-6.38"/><path fill="#202126" d="m1411 720 2 1-2 1zm-2 2 7 1c-3.75 3.06-7.08 5-12 5v-2l1.94-.31 2.06-.69z"/><path fill="#323236" d="m1430 716 2 1c-2.26 2.57-4.2 3.68-7.44 4.75l-2.3.8c-2.47.5-3.9.24-6.26-.55 2.9-2.02 5.2-3.17 8.69-3.56C1428 718 1428 718 1430 716"/><path fill="#000005" d="M937 718c2.16-.3 2.16-.3 4.63-.19l2.47.08 1.9.11-1 3h-10z"/><path fill="#646365" d="m1473 698-4.19 3.44-2.35 1.93c-2.57 1.7-4.43 2.25-7.46 2.63 2.73-4.1 8.75-10.63 14-8"/><path fill="#090a10" d="M138 694h4c-1.07 2.92-1.78 4.78-4 7-2.12.13-2.12.13-4 0v-3h4z"/><path fill="#b284e1" d="M455 686c1.87 3.16 2.35 5.34 2 9a100 100 0 0 1-2 6h-1q-.05-3.46-.06-6.94l-.03-2q0-2.52.09-5.06z"/><path fill="#424047" d="M285 678v3h-4l-1 4h-4c.13-1.81.13-1.81 1-4 5.04-3 5.04-3 8-3"/><path fill="#9d98a0" d="m1627 670 3 1c-2.38 2.75-4.62 3.76-8 5-3.86 1.71-3.86 1.71-5 4l-2-1c2.27-3.97 5.25-5.33 9.26-7.17C1626 671 1626 671 1627 670"/><path fill="#c2a0e9" d="M398 672v6h-1l-1 15h-1v-20c2-1 2-1 3-1"/><path fill="#100f14" d="M1251 668v2l-4.69 2.44-2.63 1.37A29 29 0 0 1 1236 676c1.48-2.95 4.14-3.65 7-5l3.25-1.69C1249 668 1249 668 1251 668"/><path fill="#020204" d="M1543 666v3l-5 1-1 3v-3l-5 1c1.21-2.42 1.93-2.6 4.38-3.62l1.83-.8c1.79-.58 1.79-.58 4.79-.58"/><path fill="#bb94e5" d="M401 656h1a423 423 0 0 1-2 19h-1q-.08-3.62-.12-7.25l-.06-2.07c-.03-3.75.06-6.44 2.18-9.68"/><path fill="#0a0a0f" d="m179 663 1 3h6v3h-7v-3h-5v-1h5z"/><path fill="#807e7f" d="M1533 665c-1.15 3.44-1.96 4.17-5 6-2.25.19-2.25.19-4 0v-3c3.02-2.26 5.2-3.42 9-3"/><path fill="#6d6d70" d="M313 652c-1.54 4.3-4.24 5.71-8 8 1-3 1.75-3.95 4-6l-6 1c1-2 1-2 2.75-2.62 2.45-.41 4.77-.45 7.25-.38"/><path fill="#2e1955" d="M1105 650h7l1 4h-7z"/><path fill="#525357" d="M1565 640h2c-.4 2.89-.79 3.82-3.06 5.75L1562 647v-2l-5 1 1-3c3-1.12 3-1.12 6-2z"/><path fill="#c390fb" d="M375 639h2v10h-3l-.1-6.05c.1-1.95.1-1.95 1.1-3.95"/><path fill="#07070d" d="M1589 621h2a30 30 0 0 1-7 10h-2v2h-2c1.6-3.8 4.03-7.14 7-10h2z"/><path fill="#abadae" d="M227 623v3h-8v-3c3.29-1.1 4.71-.8 8 0"/><path fill="#080413" d="m1240 597 2 1c-1 3-1 3-2.64 3.88l-2.05.68q-1.06.38-2.17.74l-2.14.7-1.86.63q-2.07.7-4.14 1.37c2.29-2.52 4.62-4.03 7.63-5.62 4.29-2.3 4.29-2.3 5.37-3.38"/><path fill="#2d1b4f" d="m1233 594-1 5-6 2c.78-4.52 2.18-7 7-7"/><path fill="#414245" d="m315 582 9 2v1h-6l-1 4-6-1v-1h5z"/><path fill="#54368f" d="M812 579h-3v2l4 1q-2.16.33-4.31.63l-2.43.35c-2.5.02-3.38-.39-5.26-1.98 7.3-3.63 7.3-3.63 11-2"/><path fill="#8b8a8e" d="m1726 549 4 1-1 7h-3z"/><path fill="#000003" d="M390 550h8v4l-8-1z"/><path fill="#7c7e80" d="M347 534h1c.29 3.38.46 6.63 0 10-2.5 2.13-2.5 2.13-5 3l-1-4 3-1c.73-1.85.73-1.85 1.19-4.06l.48-2.23z"/><path fill="#020205" d="M428 529h11v1h-7v2l7 1v1c-6.62.13-6.62.13-10-1z"/><path fill="#919293" d="M427 519h9l-1 4-7-1z"/><path fill="#2b184d" d="M1020 509c-3.01 3.86-3.01 3.86-5.82 4.27a76 76 0 0 1-5.18-.27c3.47-4.44 5.7-4.4 11-4"/><path fill="#07070b" d="m1716 486 2 1v9h-3l-.06-4.44-.04-2.5c.1-2.06.1-2.06 1.1-3.06"/><path fill="#302f35" d="M894 490q2.19-.12 4.38-.19l2.46-.1c2.16.29 2.16.29 3.45 1.8L905 493l-1 2h-8v-1l2.44-.37L901 493l1-2-8 1z"/><path fill="#b5b3b6" d="m1731 474 2 1c.59 2.31.74 4.62 1 7l-4 1-.1-6.15c.1-1.85.1-1.85 1.1-2.85"/><path fill="#c2c2c3" d="m1670 426 7 1v3h-8z"/><path fill="#161619" d="M1624 421c2.38-.19 2.38-.19 5 0l.88 1.44c1.12 1.56 1.12 1.56 2.72 1.88l1.9.18c3.34.33 3.34.33 4.94 2.06l.56 1.44h-3v-2h-8l-1 3v-7z"/><path fill="#8c8a8c" d="m1650 418 7 1v3h-8z"/><path fill="#342357" d="m1240 402 1 2c-2.45 4.04-5.78 6.09-10 8h-2c1.27-3.82 2.57-4.05 6-6 1.71-1.28 3.35-2.63 5-4"/><path fill="#2a1b4c" d="m1322 387 1 3h2c1.32 1.6 1.32 1.6 2.69 3.75l1.38 2.1c1.1 2.54.87 3.61-.07 6.15-3-4.62-3-4.62-3-8h-2v-2h-2z"/><path fill="#0b0c0f" d="m1478 386 2.54.96c3.6 1.2 7.07 1.48 10.84 1.67l1.94.11q2.34.15 4.68.26v1l-19 1z"/><path fill="#fefefe" d="M1471 378h9l1 4a168 168 0 0 1-3.87-.31l-2.18-.18C1473 381 1473 381 1471 378"/><path fill="#5f5e65" d="M1396 374h7v2l8 1v1c-5.85.41-9.74-.37-15-3z"/><path fill="#494a4c" d="M363 367h3v9l-4-2z"/><path fill="#2b2e31" d="M383 321c1.63 4.07.3 7-1 11l-2-4-1.19-2.12L378 324l1-2c2.06-.62 2.06-.62 4-1"/><path fill="#1f1e25" d="m868 304 1 2c-.74 2.1-1.53 4.1-2.44 6.13l-.73 1.7q-.9 2.1-1.83 4.17c-1.2-3.99-.61-6.13 1-10 1.63-2.37 1.63-2.37 3-4"/><path fill="#aca9ac" d="m1335 282 2 1c.59 2.31.74 4.62 1 7l-4 1-.1-6.15c.1-1.85.1-1.85 1.1-2.85"/><path fill="#08080e" d="M394 285h3l1 17-2 1z"/><path fill="#b6b5ba" d="m1323 254 2 1c.59 2.31.74 4.62 1 7l-4 1-.1-6.15c.1-1.85.1-1.85 1.1-2.85"/><path fill="#010103" d="M448 238h1q.08 2.43.13 4.88l.07 2.74C449 248 449 248 447 250c-2.12.13-2.12.13-4 0l2-4h2z"/><path fill="#b6b5b6" d="m1311 226 2 1c.59 2.31.74 4.62 1 7l-4 1q-.05-1.96-.06-3.94l-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#000001" d="m1286 209 4 1v8h-3z"/><path fill="#b5b4b6" d="m1299 198 2 1c.59 2.31.74 4.62 1 7l-4 1q-.05-1.96-.06-3.94l-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#27262c" d="m937 199 2 1-11 11c0-3 0-3 2-5l.31-2 .69-2c2.5-1.19 2.5-1.19 5-2z"/><path fill="#a8a6aa" d="m1287 170 2 1c.59 2.31.74 4.62 1 7l-4 1q-.05-1.96-.06-3.94l-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#9fa1a1" d="m408 170 2 1v8l-4-1c.88-6.87.88-6.87 2-8"/><path d="M1270 172h4v8h-3z"/><path fill="#313036" d="m908 164 2 1-4 3 1 2-4 2v-2l-4-1 4-1v-2c2.38-1.06 2.38-1.06 5-2"/><path fill="#c8c7c8" d="m1275 142 2 1c.59 2.31.74 4.62 1 7l-4 1q-.05-1.96-.06-3.94l-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#615f62" d="m518 139 2 1c-3.47 3.02-6.86 5.93-11 8l-2-1 3.81-3.44 2.15-1.93C515 140 515 140 518 139"/><path fill="#030205" d="m505 138 4 1h-3v3l2 1c-1.81 1.56-1.81 1.56-4 3l-3-1-2 1 1-3 4-2z"/><path fill="#2e2c32" d="m910.19 131.94 2.17.02 1.64.04c-3.12 3.12-5.92 3.96-10.31 4.13L901 136l2-1v-2c2.75-.92 4.36-1.1 7.19-1.06"/><path fill="#2b2a30" d="M986 126v3l-2.87.88C980 131 980 131 978 133c-2.12-.37-2.12-.37-4-1q1.87-1.5 3.75-3 1.05-.83 2.1-1.69c2.33-1.42 3.49-1.6 6.15-1.31"/><path fill="#a8a6a9" d="m1251 98 2 1c.59 2.31.74 4.62 1 7l-4 1-.1-6.15c.1-1.85.1-1.85 1.1-2.85"/><path fill="#030306" d="m745 90 1.73.97a16 16 0 0 0 6.6 1.44l2.42.12 2.5.1 2.55.11q3.1.15 6.2.26v1h-22z"/><path fill="#a3a4a4" d="M507 75v3h-8v-3c3.29-1.1 4.71-.8 8 0"/><path fill="#b1b0b2" d="M911 55v3h-8v-3c3.29-1.1 4.71-.8 8 0"/><path fill="#000001" d="M952 50h8v3l-8 1z"/><path fill="#989a9b" d="M931 47v3h-8v-3c3.29-1.1 4.71-.8 8 0"/><path fill="#acadad" d="M959 35v3h-8v-3c3.29-1.1 4.71-.8 8 0"/><path fill="#9b9c9e" d="M979 27v3h-8v-3c3.29-1.1 4.71-.8 8 0"/><path fill="#2b2c2f" d="m1138 6 3 1h-2c.62 3.7.62 3.7 2.56 5.25l1.44.75c-2.87.13-2.87.13-6 0l-2-2a90 90 0 0 0-6-1V9h9z"/><path fill="#f8f9fa" d="M1055 6h8l-1 3c-2.7 1.35-5 1.07-8 1z"/><path fill="#e0e0e2" d="M466 1822h7l1 4h-7z"/><path d="M458 1811h8l1 3h-10z"/><path fill="#07090f" d="M402 1771c3 0 3 0 5.19 1.81C409 1775 409 1775 409 1778h-4v-3h-3z"/><path fill="#000004" d="M479 1770c7.43-.14 7.43-.14 11 1v2h-10z"/><path fill="#b0afb2" d="m1394 1751 3 1-1.5.69-1.5 1.31v2.44c0 2.56 0 2.56-1.37 4.37-1.63 1.19-1.63 1.19-3.63 1.19.55-4.64 2.33-7.3 5-11"/><path fill="#7f8087" d="M423 1722c2 2 2 2 2 5h4l-1 4c-2.44-1.19-2.44-1.19-5-3-.87-2.62-.87-2.62-1-5z"/><path fill="#101016" d="m1305 1709 .38 1.94.62 2.06 2 1h-2l-1 3v-2l-6 4c.75-2.94.75-2.94 2-6 2.13-.87 2.13-.87 4-1z"/><path fill="#94929a" d="m401 1694 4 2v8l-3-1z"/><path fill="#fefefe" d="M1425 1667h1v10l-4 1 1.46-6.84c.54-2.16.54-2.16 1.54-4.16"/><path fill="#4c4b54" d="M447 1609c3.73 1.24 4.8 2.8 7 6v3c-2.86-1.28-5.07-2.5-7-5-.19-2.19-.19-2.19 0-4"/><path fill="#2f2f3b" d="m750 1454 2 1c-3.01 4.7-3.01 4.7-5.82 5.55-1.72.23-3.45.35-5.18.45v-2q2.18-1.3 4.38-2.56l2.46-1.44z"/><path fill="#e84b06" d="m1349 1355 2 4 2-2-.19 2.19c.23 3.34 1.32 5.08 3.19 7.81h-3l-1-3-3-1z"/><path fill="#21150e" d="M1123 1343c5.59.42 9.94 1.58 15 4v1c-5.29-.45-10.04-1.1-15-3z"/><path fill="#e64a04" d="M1392 1316h1l1 13-3-1q-.05-2.71-.06-5.44l-.04-3.06c.1-2.5.1-2.5 1.1-3.5"/><path fill="#352216" d="M1045 1293a18 18 0 0 1 3.19 6.94l.54 2.02c.3 2.29-.04 3.86-.73 6.04-2.63-5.26-3.41-9.15-3-15"/><path fill="#c8c1b6" d="M1331 1291a11 11 0 0 1 3 3c-.31 2.69-.31 2.69-1 5h-2v9h-1l-.06-5.69-.04-3.2c.1-2.83.45-5.35 1.1-8.11"/><path fill="#fefefe" d="m1518 1291 4 1v9c-3.73-3.73-3.53-4.98-4-10"/><path fill="#4f331e" d="m1155.07 1277.9 5 .06 1.93.04 1 3q-1.9.08-3.81.13l-2.15.07c-2.25-.22-3.3-.8-5.04-2.2 1-1 1-1 3.07-1.1"/><path fill="#2b2c32" d="m1494 1244 2 1v4h6l-2 7h-1v-5l-5-1z"/><path fill="#2d2d36" d="m843 1246 3 1-3 1q-1.03 1.99-2 4-2.42 3.07-5 6l-1-2 2.88-3.87 1.61-2.18C841 1248 841 1248 843 1246"/><path fill="#8b8990" d="M1494 1238a20 20 0 0 1 4 4c-.25 2.75-.25 2.75-1 5l4 2h-5c-1.8-3.83-2.2-6.78-2-11"/><path fill="#4f5054" d="m1487 1235 7 2v7l-3-1v-4l-4-2z"/><path fill="#05080e" d="M1318 1239v3h-3v4h-4c-.25-1.81-.25-1.81 0-4 2.5-2.42 3.46-3 7-3"/><path fill="#0e1015" d="M350 1235h1v10l-2 1-1-3-2-1v-4l3 1z"/><path fill="#61606b" d="M436 1230c1.86 3.13 2.2 5.37 2 9h-1l-1 11h-1l-.09-9.23c-.02-3.73.19-7.12 1.09-10.77"/><path fill="#926c4f" d="M1288 1222c.69 1.81.69 1.81 1 4-1.25 1.75-1.25 1.75-3 3h-3c.25-2.37.25-2.37 1-5 2.06-1.31 2.06-1.31 4-2"/><path fill="#997252" d="M1254 1210c6.52-.25 6.52-.25 8.94 1l1.06 1c-1 1-1 1-3.5 1.1l-3.06-.04-3.07-.02-2.37-.04 2-1z"/><path fill="#0b0b10" d="m1454 1206 3 3h-2l-1 3c-4.75 1.13-4.75 1.13-7 0v-2l7-1z"/><path fill="#2e2d37" d="M663 1185c2.06.44 2.06.44 4 1-12.62 6.6-12.62 6.6-18 5l2.8-1.06 5.5-2.07c4.59-1.76 4.59-1.76 5.7-2.87"/><path fill="#8c8b95" d="M1318 1163v4l-4.37-.37-2.47-.22c-2.16-.41-2.16-.41-4.16-2.41 7.43-1.14 7.43-1.14 11-1"/><path fill="#26252e" d="m1138 1129-4 2 3 1v2h-6v-3l-4 1v-2a42 42 0 0 1 11-1"/><path fill="#edf1f4" d="M1611 1126h3v7l-4 1c-.1-5.37-.1-5.37 0-7z"/><path fill="#1b1c23" d="M909 1109q2.22.63 4.44 1.31l2.5.74 2.06.95 1 3 5 1c-3.01.93-3.87 1.04-7 0v-2l-2.87-.81c-3.16-1.2-3.6-1.46-5.13-4.19"/><path fill="#aeb6bc" d="M1535 1107a30 30 0 0 1 2 4l-1 2h-2l-2 4-2-4 3-1c.63-2 .63-2 1-4z"/><path fill="#a6aeb6" d="M1521 1100h4v3h3v4h-4v-4h-3z"/><path fill="#a8a7ae" d="M1445 1093c.63 1.88.63 1.88 1 4l-2 2c-.63 2.82-.63 2.82-1.12 6.13l-.51 3.32-.37 2.55h-1c-.26-5.53.01-10.77 2-16z"/><path fill="#ebeff1" d="m1610 1090 4 1v7l-4-1z"/><path fill="#778791" d="M1579 1054c2.7.56 4.96 1.06 7 3 .19 2.13.19 2.13 0 4-3 0-3 0-5.19-1.81-1.81-2.19-1.81-2.19-1.81-5.19"/><path fill="#434447" d="M319 1046h8l-1 4-7-1z"/><path fill="#16151c" d="M1266 1023c0 3.24-.68 5.13-1.87 8.13l-1.06 2.69C1262 1036 1262 1036 1260 1037c.7-4.5 2-8.9 4-13z"/><path fill="#000001" d="m1169 1034-1 3h-10v-2c3.72-1.19 7.13-1.07 11-1"/><path fill="#1d1d22" d="m274 1028 2.19.44 2.81.56 5 1v1h-5l-1 3-4-1z"/><path fill="#cdcdcd" d="M1048 1019h10l-1 3h-8z"/><path fill="#cfcece" d="M1036 1015h9v3h-9z"/><path fill="#f7f7f7" d="M166 1006h7l1 4h-7z"/><path fill="#cecdcd" d="M1048 1000v1l-2.3.4-3.01.54-3 .52c-2.69.54-2.69.54-5.69 1.54-2.69-.44-2.69-.44-5-1l1-2c5.81-1.38 12.06-1.13 18-1"/><path fill="#c9c8c8" d="m1016 982-1 3h-10v-2c3.72-1.19 7.13-1.07 11-1"/><path fill="#bcc3ca" d="M1534 979h3l1 7-4 1z"/><path fill="#919293" d="M784 939c-2.57 2.57-4.48 2.54-8 3v-2h-6v-1c4.92-1.1 9.04-.82 14 0"/><path fill="#969697" d="M879 916h10l-2 3c-2.16.3-2.16.3-4.62.19l-2.48-.08-1.9-.11z"/><path fill="#000002" d="m963.04 905.7 2.15.11 2.17.08 1.64.11-1 3h-10c3-3 3-3 5.04-3.3"/><path fill="#706f72" d="m900 904-6 1v2c-2.75.92-4.36 1.1-7.19 1.06l-2.17-.02L883 908c2.5-1.87 4.97-2.77 7.94-3.69l2.59-.82c2.58-.51 4.01-.34 6.47.51"/><path fill="#565559" d="M896 904q-3.12 1.05-6.25 2.06l-1.78.6c-3.34 1.08-5.65 1.65-8.97.34h3v-2q2.37-.55 4.75-1.06l2.67-.6a13 13 0 0 1 6.58.66"/><path fill="#000001" d="M989 898h9v3h-10z"/><path fill="#979698" d="m177 895 1 2 2.81-.25c3.19.25 3.19.25 5.06 2.25l1.13 2-4-1v-2c-1.94.38-1.94.38-4 1l-1 2 4 1h-5v-4l-3-1h3z"/><path fill="#413f45" d="M177 887c3.72 1.57 7.42 3.14 11 5v2c-4.64-.55-7.36-2.2-11-5z"/><path fill="#6d6d71" d="m1053 865-6 1v2c-2.75.92-4.36 1.1-7.19 1.06l-2.17-.02-1.64-.04c2.5-1.87 4.97-2.77 7.94-3.69l2.59-.82c2.58-.51 4.01-.34 6.47.51"/><path fill="#454548" d="m1144 852-4 2-1.75 1.13c-3.38 1.3-6.67 1.03-10.25.87 5.33-3.64 9.66-4.28 16-4"/><path fill="#949496" d="M84 847h1c.12 5.78-.17 11.28-1 17-2-2-2-2-2.27-3.7.04-4.8.75-8.75 2.27-13.3"/><path fill="#939faa" d="M1510 849h4v6l-4 1c-1.33-2.67-.67-4.17 0-7"/><path fill="#26242b" d="M1115 839v3l-9 1v-3c3-1.5 5.66-1.06 9-1"/><path fill="#000003" d="m1221 830-1 3h-9l1-3c5.63-1.12 5.63-1.12 9 0M1244 818h7v3l-9 2zM1254 814h8v2l-10 2z"/><path fill="#808082" d="M1229 807v2l3 1c-1.25 1.06-1.25 1.06-3 2-2.97-.38-5.38-1.6-8-3 2.9-1.26 4.8-2 8-2"/><path fill="#151419" d="M866 796v1l-13 1v2h-8c6.67-3.58 13.53-4.27 21-4"/><path fill="#969598" d="M103 788c0 3 0 3-1 6l2 1c-2.52 2-4.86 2.49-8 3 1.65-3.9 4.24-6.84 7-10"/><path fill="#212026" d="M1265 787c-4.26 3.1-6.92 3.3-12 3 1.03-1.86 1.7-2.85 3.62-3.8l1.82-.51 1.8-.55c2.23-.18 3.06.47 4.76 1.86"/><path fill="#888788" d="m1321.63 769.9 5.37.1c-2.83 3.25-4.69 4.52-9 5 1.12-4.95 1.12-4.95 3.63-5.1"/><path fill="#ae89dd" d="M524 759h15v1l-7 1v1h-8z"/><path fill="#6a6a6d" d="M1352 757a464 464 0 0 1-5.75 3.06l-1.63.9c-2.53 1.3-4.14 2.1-7.01 1.72L1336 762q2.87-1.55 5.75-3.06l1.63-.9c3.23-1.67 5.18-2.5 8.62-1.04"/><path fill="#a4a1a5" d="M1530 749c-1.75 3.88-1.75 3.88-4 5l-2.5.44c-2.5.56-2.5.56-4.5 2.56.16-2.26.57-3.6 2.26-5.16 2.9-2.05 5.06-3.4 8.74-2.84"/><path fill="#000105" d="m846 742-1 3h-10v-2c3.72-1.19 7.13-1.07 11-1"/><path fill="#000005" d="M883 734v2c-2.52 1.26-4.31 1.1-7.12 1.06l-2.76-.02L871 737c1-2 1-2 2.63-2.62 3.16-.5 6.17-.44 9.37-.38"/><path fill="#643c9c" d="M831 735v3h-11c2.08-4.15 6.98-3.08 11-3"/><path fill="#000003" d="M1438 730h5v3l-8 3v-2l-2-1 5-1z"/><path fill="#321b56" d="M887 728c-2.25 2.25-3.33 2.51-6.37 3.19l-2.34.54c-2.4.28-4 0-6.29-.73 5.26-2.63 9.15-3.41 15-3"/><path fill="#60399a" d="m861.16 726.7 2.46.11 2.48.08 1.9.11 1 2c-3.95 1.32-7.89 1.09-12 1 1.12-2.34 1.52-2.94 4.16-3.3"/><path fill="#4f4f53" d="m228 716 2 1-1 5c-6.62 2-6.62 2-10 2z"/><path fill="#838182" d="m1436 718 9 1v3h-7z"/><path fill="#5f5f61" d="M1455 718c-1.53 2.83-3 3.67-6 4.75l-2.12.8c-1.88.45-1.88.45-3.88-.55 8.1-6.95 8.1-6.95 12-5"/><path fill="#58368e" d="M925 711h10v2c-2.29 1.14-3.6 1.1-6.12 1.06l-2.2-.02L925 714z"/><path fill="#101014" d="m128 697 2 1c-.69 1.5-.69 1.5-2 3-2.62.19-2.62.19-5 0v4l3 1h-3v4l-3-1h2v-3l-3-1h3v-5h5z"/><path fill="#444447" d="m256 698 1 3h-3v4h-6q1.15-1.48 2.31-2.94l1.3-1.65C253 699 253 699 256 698"/><path fill="#5f5f62" d="M265 688c-1.43 3.45-3.1 5.1-6.12 7.25l-2.2 1.58L255 698l-2-1 2.19-1.69a54 54 0 0 0 5.5-5.06C263 688 263 688 265 688"/><path fill="#868485" d="M1493 687h6c-1.25 3.74-2.63 4.19-6 6v-2h-2z"/><path fill="#19181e" d="m1459 667 2 1-11 8-2-1 4.38-3.44 2.46-1.93C1457 668 1457 668 1459 667"/><path fill="#605c63" d="M1642 659c-2.35 2.72-4.56 3.97-8 5l-3-1c1.16-2.32 2.04-3.02 4.38-4.25 2.75-.79 3.98-.73 6.62.25"/><path fill="#2c1850" d="M1111 654q-3.74 1.93-7.56 3.69l-1.76.82c-1.68.49-1.68.49-4.68-.51l1-3c4.4-.7 8.53-1.1 13-1"/><path fill="#7e3fc4" d="M409 653h1v14h-1l-1-5h-1c-.2-5.27-.2-5.27 0-7z"/><path fill="#09080d" d="m1321 626 3 1c-10.37 6.52-10.37 6.52-16 8v-2l1.57-.77a91 91 0 0 0 9.24-5.04z"/><path fill="#7845c3" d="M453 615h1v12l-3-2c-.36-1.87-.36-1.87-.31-4v-2.12C451 617 451 617 453 615"/><path fill="#6947a5" d="M664 607v1c-5.49 1.87-10.22 2.24-16 2v-2a92 92 0 0 1 16-1"/><path fill="#05050b" d="m1478 577 3 1-9 9c.43-5 2.42-6.79 6-10"/><path fill="#0a0a10" d="M347 562h2v9l-3 1-.1-6.93c.1-2.07.1-2.07 1.1-3.07"/><path fill="#07070c" d="M413 555h10l-2 3c-1.95.3-1.95.3-4.12.19l-2.2-.08L413 558z"/><path fill="#000001" d="M831 551h9v2l-11 2z"/><path fill="#575559" d="m1726 524 4 1-3 1c-.69 2.06-.69 2.06-1 4h-2l1 8h-2c-.32-5.5-.53-9.44 3-14"/><path fill="#010004" d="M977 511h9l-1 3h-9z"/><path fill="#eaeaec" d="M1739 506h1v15l-2-1-.06-6.44-.03-1.85q0-2.36.09-4.71z"/><path fill="#39393c" d="m1710 465 1.75 1.38A37 37 0 0 0 1718 470l-1 6h-2l-1-5-3-1z"/><path fill="#949595" d="M362 445c.69 1.69.69 1.69 1 4-1.31 2.5-1.31 2.5-3 5q-1.02 2-2 4c-.63-8.2-.63-8.2 2-11.56z"/><path fill="#0e0d13" d="m1513 435 13 1v3l-9-1v-2z"/><path fill="#4a494c" d="M1650 426h3l-1 5h5v1c-5.75.13-5.75.13-8-1v-2h-7v-1l5.37-.68C1649 427 1649 427 1650 426"/><path fill="#0f091e" d="M1337 411c3 1 3 1 4.13 2.67 1.8 3.76 3.35 7.15 3.87 11.33-3.08-2.77-4.78-5.03-6-9v-3h-2z"/><path fill="#382366" d="M1300 403c4.64 2.88 6.44 6.65 7.69 11.88l.31 2.12c-3.92-3.36-6.08-7.28-8-12z"/><path fill="#040308" d="M905 399h3c-.34 2.58-.6 3.69-2.69 5.31-3.03.9-5.26.38-8.31-.31v-1l2.88-.87C903 401 903 401 905 399"/><path fill="#8c8c8e" d="M455 394h2c.96 2.88 1.1 4.7 1.06 7.69l-.02 2.45L458 406c-1.49-.68-1.49-.68-3-2-.3-2.38-.3-2.38-.19-5.12l.08-2.76z"/><path fill="#312e35" d="m940 386 1 3c2.06.69 2.06.69 4 1-4.75 3-4.75 3-7 3v-3l-3-1z"/><path fill="#aeafb2" d="M1515 386c1.88-.19 1.88-.19 4 0l.84 1.47c1.16 1.53 1.16 1.53 3.32 2.04l2.46.18 2.48.2 1.9.11v1q-2.9.12-5.81.19l-3.27.1c-2.92-.29-2.92-.29-4.8-1.68C1515 388 1515 388 1515 386"/><path fill="#fbfafb" d="M1497 382h8l1 4c-5.52.37-5.52.37-7.87-1.44C1497 383 1497 383 1497 382"/><path fill="#432d78" d="m1283 378 5 1v5l-2 1v-6zm-2 7h5c-1.37 1.5-1.37 1.5-3 3h-2zm-3 4 2 1Z"/><path fill="#321f5d" d="M1288 366h2v4l-4 2v-2l-2.31 1.5C1281 373 1281 373 1278 373a19.3 19.3 0 0 1 9-6z"/><path fill="#2e2d32" d="M852 336h2a47 47 0 0 1-3.06 10.31l-1.1 2.68L849 351c-1.28-2.57-.83-3.61-.19-6.37l.52-2.34A31 31 0 0 1 852 336"/><path fill="#000001" d="M1330 312h4v8l-3-1zM1326 303h4v8h-3z"/><path d="m1322 293 4 1v8l-3-1z"/><path fill="#000001" d="M1318 284h4v8h-3z"/><path fill="#666868" d="M383 255h3v8l-4-1z"/><path d="m1306 256 4 1v7h-3z"/><path fill="#39383d" d="M748 230c-7.47 2.77-14.07 3.37-22 3v-1l2.15-.18 2.79-.26 2.77-.24C736 231 736 231 737 230c4.14-.78 6.93-1.2 11 0"/><path fill="#000001" d="M1294 228h4v8h-3z"/><path fill="#313035" d="m907 227 3 1-1 5-1.81.44q-2.62.69-5.19 1.56c0-2 0-2 1.94-4l2.06-2z"/><path fill="#515054" d="M657 225q2.5.49 5 1l2.3.44 2.45.5 2.36.46 1.89.6 1 2c-10.15-.35-10.15-.35-13.69-3.06z"/><path fill="#26262a" d="M409 210v4l-2 1c-.62 2.56-.62 2.56-1 5h-1v-6l-3-1 1-2c2.09-.67 3.8-1 6-1"/><path fill="#000002" d="M1282 200h4v8h-3z"/><path fill="#8a8b8b" d="M625 200a29 29 0 0 1 8 3v3c-5.75-.75-5.75-.75-8-3z"/><path fill="#858586" d="m842.19 186.31 1.81.69-2 3h5v1l-9 2-1-4c1.6-2.45 2.23-3.11 5.19-2.69"/><path fill="#8d8d8f" d="M623 170c2.38.25 2.38.25 5 1a13 13 0 0 1 2 4h-2l-2 2v-2l-3-1z"/><path fill="#000001" d="M789 163h10v3h-9z"/><path fill="#969598" d="M619 162a11 11 0 0 1 3 3c-.31 2.69-.31 2.69-1 5h-3v-4l-2-1z"/><path fill="#8a8b8c" d="M856 154h9v3h-10z"/><path fill="#28272e" d="m963 138 1 3-2.37.88C959 143 959 143 957 145c-2.12.13-2.12.13-4 0 1-3 1-3 3.07-4.17l2.5-1.02 2.5-1.04z"/><path fill="#28282a" d="m801 86 4 2c-4.63 2.36-8.76 2.24-13.81 2.13l-2.4-.03L783 90v-1l18-1z"/><path fill="#6d6e6f" d="M855 75v3h-8v-3c3.29-1.1 4.71-.8 8 0"/><path fill="#898a8e" d="M543 70h4c-.19 1.88-.19 1.88-1 4-3.66 2.1-6.85 2.2-11 2l-1-2 2.88.06C540 74 540 74 542 73z"/><path fill="#5e6163" d="M642 70h10v2l3 1v1c-5.23.38-8.38-.78-13-3z"/><path fill="#717174" d="m918 58 2 1-1 5-9 2v-3l1.5-.33 1.94-.48 1.93-.46L917 61z"/><path fill="#2b2a2d" d="m1025 18 1 3h6v1a70 70 0 0 1-11 3q.9-3.03 2-6z"/><path fill="#c9c9ca" d="M1120 2c2.49.41 4.6.78 6.81 2 3.27 1.5 6.64 1.67 10.19 2v1h-12V5l-1.94-.37L1121 4z"/><path fill="#949495" d="M1136 0h14v2h-14z"/><path fill="#505154" d="M495 1824c5.56.39 7.48 1.97 11 6-2.69.38-2.69.38-6 0-2-1.77-3.51-3.78-5-6"/><path fill="#e6e8e7" d="M434 1809h6v5h-5z"/><path fill="#a8a7aa" d="M1346 1806h8l-1 3-7 1z"/><path fill="#363639" d="m1334 1803 2 1-1 6-2-1c-3-.19-3-.19-6 0l-2 2-3-1c2.55-2.45 4.28-3.2 7.75-3.19l3.25.19z"/><path fill="#cbcbcd" d="M370 1748h5v6l-5-1z"/><path fill="#5a5862" d="M1235 1630a19 19 0 0 1-5 7l-2-1 1-2-3-1c5.63-3 5.63-3 9-3"/><path fill="#3e3c47" d="m639.19 1523.31 1.81.69c-4.62 4.88-4.62 4.88-8 6 .4-2.36.9-3.86 2.31-5.81 1.69-1.19 1.69-1.19 3.88-.88"/><path fill="#05090f" d="M1478 1391h4v4h-4v3h-3l1-4h2z"/><path fill="#5b391f" d="M1176 1355h8v4l-8-1z"/><path fill="#0d0d13" d="M981 1350c5.9 1.55 9.71 2.71 13 8l-2 1v-2l-4-1v-2l-1.81-.44q-2.62-.69-5.19-1.56z"/><path fill="#c6c0b6" d="M1330 1339c2.38 2.38 2.4 3.99 2.78 7.25.25 1.97.69 3.84 1.22 5.75l-4-1z"/><path fill="#d1d1d3" d="M1526 1329h3c1.03 2.79 1.05 3.87.06 6.75L1528 1338l-2-1z"/><path fill="#9c9b9f" d="M1526 1320h3v9h-3z"/><path fill="#000104" d="M1068 1307c3.03 1.51 3.42 3.9 4.54 6.99.46 2.01.46 2.01-.35 4.51l-1.19 1.5-1-6h-2q-.57-3-1-6z"/><path fill="#12151a" d="M348 1288h1v10h-3q-.08-1.94-.12-3.87l-.08-2.18c.2-1.95.2-1.95 2.2-3.95"/><path fill="#51331d" d="m1176 1282 9 1-1 3-7-1z"/><path fill="#dc490e" d="M1358 1267c1 2 1 2-.06 5.31-1.82 4.07-4.39 6.09-7.94 8.69 1.54-3.2 3.29-6.22 5.13-9.25q.8-1.34 1.63-2.7z"/><path fill="#937257" d="m1123 1262 11 1-2 3c-2.16.3-2.16.3-4.62.19l-2.48-.08-1.9-.11 4-2z"/><path fill="#881b0e" d="M1441 1255c2.78 1.52 3.84 3.07 5 6v3h-2v-2l-9-1v-1h7z"/><path fill="#d6cfc4" d="m1334 1241-1 4c-6.62 2-6.62 2-10 2l2.81-2.44 1.58-1.37a10 10 0 0 1 6.61-2.19"/><path fill="#cfc3b9" d="M1393 1233h13l1 2h-10l-1 2-4-1z"/><path fill="#000002" d="m1356 1222-1 2-2-1-1 3h-6v-3c3.4-.78 6.5-1.1 10-1"/><path fill="#494754" d="m537 1198 7 1v3h-10z"/><path fill="#cfd4d9" d="m1603 1161 3 1-1 7h-3c-.1-5.37-.1-5.37 0-7z"/><path fill="#aab3bc" d="M1614 1117c.38 1.69.38 1.69 0 4a45 45 0 0 1-6 5v-6h2v-2c2-1 2-1 4-1"/><path fill="#96a3ac" d="M1575 1079h2l1 8h1v7c-3.48-3.64-4.43-8.12-5-13z"/><path fill="#b1bac2" d="M1495 1078c2.52 1.97 3 2.97 3.69 6.19l.31 2.81 4 1h-5l-.25-1.87c-.75-2.13-.75-2.13-2.81-3.38l-1.94-.75v-3z"/><path fill="#a2aeb6" d="m1594 1062 4 4h-2l2 7c-3-1-3-1-4.19-2.75-.93-2.57-1.03-4.53-.81-7.25z"/><path fill="#bdc4cc" d="M1469 1058h5c1 2 1 2 .94 4.38.06 2.79.65 4.25 2.06 6.62-4.35-2.85-6.37-6.1-8-11"/><path fill="#08090e" d="m1162.07 1037.9 5 .06 1.93.04v3q-1.94.08-3.87.13l-2.18.07c-1.95-.2-1.95-.2-3.95-2.2 1-1 1-1 3.07-1.1"/><path fill="#dfdfdf" d="M1047 1023q1.94-.04 3.88-.06l2.17-.04c1.95.1 1.95.1 3.95 1.1v2h-9z"/><path fill="#a7a7a9" d="M167 1004q1.96-.04 3.94-.06l2.21-.04c1.85.1 1.85.1 2.85 1.1q.06 2.5 0 5h-2l-1-3-7-1z"/><path fill="#222226" d="m142 992 2 1q2.8.34 5.63.56l3.03.26 2.34.18.31 1.94.69 2.06 3 1-5-1v-2l-11-1z"/><path fill="#95a3ab" d="M1523 988h3v9h-3z"/><path fill="#08080d" d="M126 979h9v3h-9z"/><path fill="#4f4e58" d="M1316 974h1q.06 3 0 6l-1 1q-.34 2.77-.56 5.56l-.26 3.07-.18 2.37-2-1q.45-3.98.94-7.94l.26-2.28.26-2.18.24-2.02c.3-1.58.3-1.58 1.3-2.58"/><path fill="#17181b" d="M92 971h4l1 2h11v5l-2-2c-2.6-.41-2.6-.41-5.62-.62l-3.04-.23L95 975l-1-3z"/><path fill="#212028" d="m1286 932 4 1-2 2q-.6 2.99-1 6l-1 6h-1q-.05-2.9-.06-5.81l-.04-3.27c.1-2.92.1-2.92 1.1-5.92"/><path fill="#79797d" d="m789.63 938.94 3.03.02 2.34.04v1l-5.25 1.56q-1.45.44-2.95.88c-2.76.55-4.2.5-6.8-.44h2v-2c2.64-1.32 4.68-1.1 7.63-1.06"/><path fill="#a2a1a3" d="M161 930c3.95-.18 6.6-.1 10 2v2c-2.75.25-2.75.25-6 0-2.37-2-2.37-2-4-4"/><path fill="#646368" d="M248 923c5.37-.33 9.3.3 14 3l2 2c-5.52-.73-10.7-2.33-16-4z"/><path fill="#9c9b9d" d="M210 913h6l1 4c-3.51 1.21-3.51 1.21-5.75.25L210 916z"/><path fill="#adb6bf" d="M1478 906h3c0 3 0 3-1.81 5.19C1477 913 1477 913 1474 913v-4h3z"/><path fill="#ebeef0" d="m1469 902 1 4h-3l-1 5h-3c-.37-1.69-.37-1.69 0-4a45 45 0 0 1 6-5"/><path fill="#a7a6a8" d="M97 891h3l.81 2.44L102 896l3 1v2c-3.18-.34-4.02-1.02-6.25-3.44C97 893 97 893 97 891"/><path fill="#747376" d="M976 895h12v1l-5.25 1.56q-1.45.44-2.95.88c-2.76.55-4.2.5-6.8-.44h3z"/><path fill="#9a9b9a" d="m156 879 6 2v5h-4v-4h-2z"/><path fill="#909192" d="M1065 867h9v3h-9z"/><path fill="#000001" d="m1122.19 861.81 2.17.08 1.64.11v2c-2.4 1.2-3.95 1.1-6.62 1.06l-2.48-.02-1.9-.04c2.3-2.77 3.6-3.36 7.19-3.19"/><path fill="#25232a" d="M1119 835h8v3l-9 1z"/><path fill="#090910" d="M14 831h3l1 8-4 1z"/><path fill="#111014" d="m749.19 811.94 2.17.02 1.64.04v2a78 78 0 0 1-15 1c3.8-1.97 6.88-3.13 11.19-3.06"/><path fill="#212026" d="M1201 807h7l-1 3-8 1z"/><path fill="#404045" d="m1235 801-5 2v2c-6.29 2.43-6.29 2.43-10 1l6-1v-2q1.68-.8 3.38-1.56.93-.44 1.9-.88c1.72-.56 1.72-.56 3.72.44"/><path fill="#9b9c9d" d="M109 786h1c.38 2.6.4 4.37-1 6.63-2.3 2.83-3.46 4.19-7 5.37l2-4 1-2.19c1-1.81 1-1.81 3-2.81z"/><path fill="#8b8b8c" d="M1307 777v3c-3.13 1.86-5.37 2.2-9 2l1-3c1.85-.73 1.85-.73 4.06-1.19l2.23-.48z"/><path fill="#a4a3a5" d="M139 758c-.38 2.75-.8 3.82-2.87 5.75C134 765 134 765 131 765c1.86-4.15 3.17-7 8-7"/><path fill="#010206" d="m343 743 4 1v2l3 1-1 3c-2.47-1.15-4.05-2.05-6-4z"/><path fill="#a47fd6" d="M542 739h1v19h-4v-3h2z"/><path fill="#0b0c0d" d="M76.19 741.75 79 742l-2 4h-7c1.82-3.24 2.36-3.95 6.19-4.25"/><path fill="#232227" d="M1173 730c-4.6 3.37-9.45 4.08-15 5 1.39-2.78 2.97-3.03 5.81-4.19l2.65-1.1c2.69-.75 3.96-.66 6.54.29"/><path fill="#543184" d="m856.38 730.7 2.75.11 2.75.08 2.12.11c-3.26 2.24-6.24 4.29-10.31 3.63L852 734c1.14-2.5 1.59-2.95 4.38-3.3"/><path fill="#010005" d="M890 730h9l-1 3h-9z"/><path fill="#1e1e22" d="m1168 727 1 3c-4.52 2.04-9.16 3.01-14 4 1.18-2.47 2.05-3.02 4.6-4.17l3.03-1.02 3.03-1.04z"/><path fill="#5d3894" d="m872 727-1 3c-3.3 1.35-6.74 1.1-10.25 1.06L855 731c2-2 2-2 3.84-2.12l4.19.25c3.52-.23 5.15-2.4 8.97-2.13"/><path fill="#7a7b7e" d="M1562 725c-1.75 3.88-1.75 3.88-4 5q-3 .55-6 1c.98-2.45 1.65-3.77 3.88-5.25 2.2-.78 3.8-.92 6.12-.75"/><path fill="#000001" d="m96 725 1 4-4 1v3h-4c.19-1.81.19-1.81 1-4 2.5-1.69 2.5-1.69 5-3z"/><path fill="#838182" d="M1429 723h10l-1 3c-6.75.13-6.75.13-9-1z"/><path fill="#8a888f" d="m95 720 2 1c-.75 1.5-.75 1.5-2 3-2.12.19-2.12.19-4 0l-1 3h-4c.63-1.87.63-1.87 2-4 3.06-1.19 3.06-1.19 6-2z"/><path fill="#a9a7aa" d="M1578 718h8l-1 3-7 1z"/><path fill="#1f1f25" d="m1431.56 714.94 2.44.06-1.87.88C1430 717 1430 717 1428 719c-1.74.29-3.49.57-5.25.75-1.75.25-1.75.25-4.75 1.25.5-1.94.5-1.94 2-4 2.09-.5 4.08-.69 6.22-.82 2.55-.26 2.5-1.17 5.34-1.24"/><path fill="#1d1d21" d="M1205 713c-2.23 2.24-4.02 3-6.94 4.19l-2.65 1.1c-2.41.71-2.41.71-5.41-.29 9.23-5.74 9.23-5.74 15-5"/><path fill="#47454b" d="M277 685c2.19.31 2.19.31 4 1l-1 3-2.19.38c-2.77.61-5.2 1.5-7.81 2.62l1-3 3-1 1.44-1.62z"/><path fill="#18171d" d="m1486.13 682.88 1.87.12v3l-2.44.94C1483 688 1483 688 1482 689q-2.5.06-5 0l2.38-1.87c5.13-4.16 5.13-4.16 6.74-4.25"/><path fill="#9a989a" d="M268 673c1.88.25 1.88.25 4 1a17 17 0 0 1 2 4l-8-1z"/><path fill="#15151b" d="m1505 672 1 2 2 1-3.31 1.5-1.87.84c-1.82.66-1.82.66-4.82.66v-3z"/><path fill="#170e2e" d="M1197 620c-2.4 1.92-3.96 2.21-7 2v2l-9 3c2.17-3.25 3.46-4.03 6.88-5.75l2.61-1.36c2.73-.97 3.85-.9 6.51.11"/><path fill="#9e9fa0" d="M234 618v4h-8l1-3c2.67-.9 4.26-1.1 7-1"/><path fill="#000102" d="M271 610h7l-1 4h-6z"/><path fill="#939395" d="m278 594 1 3c-1 1-1 1-2.63 1.1L271 598v-3c2.67-.9 4.26-1.1 7-1"/><path fill="#a498bb" d="m663.6 589.8 3.02.07 3.04.06 2.34.07-2 3-11-1c2-2 2-2 4.6-2.2"/><path fill="#573592" d="M773 589v4h-11l1-2c2.29-.63 2.29-.63 5.06-1.12z"/><path fill="#ad94cf" d="M411 590c3.63-.2 5.87.14 9 2v2l-1.9-.04-2.48-.02-2.46-.04c-2.16.1-2.16.1-4.16 1.1z"/><path fill="#553492" d="M811 583c-1.14 1.49-1.14 1.49-3 3-2.7.3-2.7.3-5.69.19l-3-.08L797 586v-1l5.31-1.56 3-.88C808 582 808 582 811 583"/><path fill="#edeeef" d="M1730 530v7l-4 1v-7c2-1 2-1 4-1"/><path fill="#0f091f" d="M1367 498c1.07 3.74 1.07 3.74.44 6.06-2.06 2.77-4.29 3.57-7.44 4.94 1.78-4.04 4.4-7.46 7-11"/><path fill="#99979c" d="M1724 483h1l.81 2.88c1.24 3.25 1.52 3.42 4.19 5.12.19 2.13.19 2.13 0 4-4.79-2.08-4.79-2.08-6-5a66 66 0 0 1 0-7"/><path fill="#505152" d="M359 473h1c.37 8.45.37 8.45-2 12l-1-2h-2z"/><path fill="#321e5d" d="M1291 456c2.97 1.48 3.82 4.04 5 7q1.14 3.96 2 8c-1.46-.84-1.46-.84-3-2-.69-2.62-.69-2.62-1-5h-2z"/><path fill="#2c1952" d="M1164 450c-1.12 1.5-1.12 1.5-3 3-3.19.19-3.19.19-6 0l-1-3c3.66-.75 6.42-1.24 10 0"/><path fill="#473474" d="M1242 411c0 3.2-.13 4.45-2 7-3.12.75-3.12.75-6 1a394 394 0 0 1 3.61-4.5 19 19 0 0 1 4.39-3.5"/><path fill="#d3d3d3" d="m356 406 2 1v8h-3c-1.03-2.79-1.05-3.87-.06-6.75z"/><path fill="#646467" d="m1550 386 6 1v3h-8z"/><path fill="#dbdcdc" d="M1540 383h9v3h-9z"/><path fill="#8b8c8d" d="M433 370h1q.16 3.84.25 7.69l.1 2.18c.07 3.71.01 6.18-2.37 9.15L430 391c.88-5.75.88-5.75 2-8a172 172 0 0 0 .7-8.48z"/><path fill="#352262" d="m1302 352 4 1-1 5-5-2z"/><path fill="#8f8e91" d="M460 232h1v10h-3q-.08-1.94-.12-3.87l-.08-2.18c.2-1.95.2-1.95 2.2-3.95"/><path fill="#7f8083" d="M747 186h10v2l2 1c-8.45.37-8.45.37-12-2z"/><path fill="#919193" d="M639 182h9v3h-9z"/><path fill="#8a8a8c" d="M767 181v1a102 102 0 0 1-10 3l2-1v-2h-8v-1c5.6-.83 10.42-1.07 16 0"/><path fill="#908e91" d="m485 171 1 3 2 1-1.87 3-1.06 1.69L484 181h-2c-.37-4.27.6-6.49 3-10"/><path fill="#878788" d="M892 164h5c-.62 1.88-.62 1.88-2 4-3.12 1.25-3.12 1.25-6 2v-2l-2-1 1.94-.37L891 166z"/><path fill="#828284" d="m537 163 1 3q1.32-.46 2.69-.94A62 62 0 0 1 549 163a25.4 25.4 0 0 1-11 6l-2 1q-.06-3 0-6z"/><path fill="#919092" d="M583.7 157.8q2.64.03 5.3.2l-2 4-7-1c.8-2.12 1.4-2.93 3.7-3.2"/><path fill="#7c7b7c" d="M537 135v2c-3.53 2.5-6.35 1.92-10.4 1.29L525 138c3.95-2.82 7.2-3.1 12-3"/><path fill="#1e1d23" d="m647 123 5 1v4c-2.7 1.35-5 1.06-8 1l1.5-.75L647 127c.19-2.12.19-2.12 0-4"/><path fill="#908d91" d="M1254 106h3l1 7-4 1z"/><path fill="#292a2c" d="M819 86h11l1 3q-2.72-.17-5.44-.37l-3.06-.22L820 88z"/><path fill="#08080d" d="m628.25 77.81 2.7.08L633 78c-3.15 2.8-5.07 3.35-9.25 3.19l-2.7-.08L619 81c3.15-2.8 5.07-3.35 9.25-3.19"/><path fill="#fdfdfd" d="M559 66h10c-4.44 3.33-5.79 3.5-11 4z"/><path fill="#313235" d="M1167 21c8.68 4.18 8.68 4.18 11 8l-4.37-1.37-2.47-.78C1169 26 1169 26 1167 24z"/><path fill="#212124" d="M1051 10h3v3h9v1l-5.27.59C1056 15 1056 15 1054 17c-2.62.13-2.62.13-5 0l1.44-.81C1052 15 1052 15 1053 12h-2z"/><path fill="#f4f5f6" d="M1039 10h7v3c-2.7 1.35-5 1.07-8 1z"/><path fill="#c7c7c9" d="M415 1806h7v4l-7-1z"/><path fill="#000001" d="M1332 1802v4h-7v-3c2.46-1.23 4.28-1.07 7-1"/><path fill="#b9b7ba" d="M1362 1794h7v3l-7 1z"/><path fill="#c0bec1" d="M1390 1766h4l-1 7h-3z"/><path fill="#2b2b34" d="M1263 1753c-2.73 3.75-6.7 4.13-11 5 1-3 1-3 3.81-4.75 3.15-1.24 4.16-1.53 7.19-.25"/><path fill="#cccccd" d="M1402 1750h4l-1 7h-3z"/><path fill="#000002" d="m430 1747 5 1v2h4l1 4c-2.37-.31-2.37-.31-5-1-1.12-1.5-1.12-1.5-2-3l-3-1z"/><path fill="#afafb2" d="M362 1746h4v7h-3z"/><path fill="#393742" d="M449 1728c1.5 1.31 1.5 1.31 3 3v3l4 1-1 2-6-2v-2h-2z"/><path fill="#090a10" d="M364 1700h1v11h-3c-.29-7.43-.29-7.43 2-11"/><path fill="#000003" d="M387 1680h2l1 10-2 1c-1.2-2.4-1.1-3.95-1.06-6.62l.02-2.48z"/><path fill="#151519" d="M350 1664c2.87 2.98 3.33 5.23 3.26 9.33l-.07 1.92-.04 1.97q-.07 2.4-.15 4.78h-1l-1-5.62-.56-3.17a57 57 0 0 1-.44-9.21"/><path fill="#474650" d="M1239 1630h2c-.9 3.7-1.44 5.57-4.5 7.94l-2.5 1.06-2-1q1.67-2 3.38-4l1.9-2.25z"/><path d="M339 1602h3v10l-3-2c-.3-1.95-.3-1.95-.19-4.12l.08-2.2z"/><path fill="#c0bfc2" d="M1458 1430h7v3l-7 1z"/><path fill="#000001" d="M1438 1426h6l-1 4h-6z"/><path fill="#4f2f18" d="m1262 1369 1 3h2l-1 3h-9l4-1 1-3h2z"/><path fill="#d0cbc8" d="M1310 1362c3.75 1.25 4.24 2.57 6 6v2q.47 2 1 4c-3.94-3.45-3.94-3.45-4.25-6.25l.25-1.75-3-1z"/><path fill="#d7d0c9" d="m1301 1344 4 2v7l-3-1z"/><path fill="#000001" d="m1291 1342 3 1v8h-3z"/><path fill="#c7bfb8" d="M1302 1333h3v8l-3 1z"/><path fill="#4e331e" d="m1119.73 1333.8 5.27.2c-2.83 2.83-3.97 3.9-8.06 4.31-2.94-.31-2.94-.31-4.32-1.81l-.62-1.5 1.87.62 2.13.38c2-2 2-2 3.73-2.2"/><path fill="#e04907" d="M1397 1297h1v8h-4v-6h3z"/><path fill="#392216" d="M995 1291c4.75.43 6.92 1.33 10 5h-6l-.87-1.94C997 1292 997 1292 995 1291"/><path fill="#080910" d="M1503 1280h3c.24 4.82-.55 8.46-2 13h-1z"/><path fill="#000001" d="M1291 1279h3c.13 5.75.13 5.75-1 8h-3z"/><path fill="#49484f" d="M1508 1267h1l.25 2.88c.75 3.12.75 3.12 2.81 4.5l1.94.62-2 2c-2.62-.37-2.62-.37-5-1z"/><path fill="#6a0b05" d="m1450 1268 5 5-1 3v-2h-2l-1 2-3-7z"/><path fill="#440704" d="M1453 1268c3.28 3.1 6 5.89 8 10l-1 2-.69-1.75c-1.31-2.25-1.31-2.25-3.93-4.25-2.38-2-2.38-2-2.7-4.25z"/><path fill="#7d0f03" d="M1442 1261v1l-3.37.31c-3.47.5-3.47.5-4.76 2.07l-.87 1.62c-2.5 1.75-2.5 1.75-5 3l-2-1c5.06-5.27 8.51-8.25 16-7"/><path fill="#020104" d="m1277 1245 3 1-4 8h-2c-.26-3.15-.25-4.62 1.5-7.31z"/><path fill="#cec2b3" d="m1377 1239 3 1h-2v2l2 1c-2.62 2.2-4.65 2.18-8 2 1.13-3.4 1.97-4.22 5-6"/><path fill="#c4bbaf" d="M1345 1238h8l-1 3c-3.29 1.1-4.71.8-8 0z"/><path fill="#a19a8f" d="M1406 1233c4.76.6 8.95 2.5 13 5l1 2h-3v-2l-2.12-.31c-3.34-.8-5.86-2.07-8.88-3.69z"/><path fill="#aa8f7a" d="M979 1236v2c-2.4 1.2-3.95 1.1-6.62 1.06l-2.48-.02-1.9-.04c2.08-4.15 6.98-3.08 11-3"/><path fill="#cac9cc" d="m1499 1230 2 1c.59 2.31.74 4.62 1 7h-4c-.1-5.37-.1-5.37 0-7z"/><path fill="#08080e" d="M1464 1222h6l1 7-3-1c-.69-2.06-.69-2.06-1-4h-3z"/><path fill="#12121a" d="M899 1221c-4.43 2.94-7.5 4.54-13 4 4.47-3.85 7.19-5.87 13-4"/><path fill="#dde3e8" d="M1554 1210h7v3l-7 1z"/><path fill="#dbe1e5" d="m1520 1194 2 1v7h-4c.88-6.87.88-6.87 2-8"/><path fill="#000004" d="M612 1191h11l1 3h-8v-2z"/><path fill="#d9dee2" d="M1590 1178h4l-1 7h-3z"/><path fill="#515059" d="m395 1172 2 1v8l-2-1-2 3 .44-4.94.24-2.77c.32-2.29.32-2.29 1.32-3.29"/><path fill="#b8c2c6" d="M1534 1132v8h-3l-1-6c2.88-2 2.88-2 4-2"/><path fill="#22212a" d="m1127 1130-1 3c-1.7.77-1.7.77-3.81 1.25l-2.08.52c-2.3.25-3.92-.08-6.11-.77h3v-2l6.05-1.56c1.95-.44 1.95-.44 3.95-.44"/><path fill="#cfd5d7" d="M1522 1118h4v7h-3z"/><path fill="#dbdfe3" d="M1507 1106h7v4l-7-1z"/><path fill="#dadee3" d="M1491 1094h7v4l-7-1z"/><path fill="#e7ebee" d="M1471 1082h7v4l-7-1z"/><path fill="#2c2b35" d="M844 1065c3 1.36 5.29 2.95 7.75 5.13l1.86 1.63L855 1073l-3 1c-1.5-1-1.5-1-3-2.44-2.68-2.56-2.68-2.56-6-2.56z"/><path fill="#f2f5f6" d="m1463 1059 6 5-3 2c-1.94-.75-1.94-.75-4-2-.75-2.12-.75-2.12-1-4z"/><path fill="#d6dade" d="m1599 1054 2 1c.59 2.31.74 4.62 1 7h-4c-.1-5.37-.1-5.37 0-7z"/><path fill="#d5d5d6" d="M257 1038h8l-1 4-7-1z"/><path fill="#535355" d="M1050 1029c4.86.6 9.32 1.57 14 3v1c-5.6.38-9.16-.1-14-3z"/><path fill="#dadee2" d="m1570 1030 7 1v3h-7z"/><path fill="#9da8b1" d="M1536 1017c3.14 1.05 3.65 1.5 5.56 4 2.24 2.9 2.24 2.9 4.44 4l-1 4-1.62-2.19c-2.43-3.05-5.32-5.41-8.38-7.81z"/><path fill="#010102" d="M1225 990v5h-3l-1 3h-2l-2 1c1.08-3.52 3.6-9 8-9"/><path fill="#d8d8d7" d="M1210 990c.25 2.31.25 2.31 0 5-2 1.81-2 1.81-4 3v-3l-3-1c3.63-4 3.63-4 7-4"/><path fill="#adaeaf" d="M67 970h7v4l-7-1z"/><path fill="#000001" d="m89 966 6 1 1 3h-7z"/><path fill="#c5c4c3" d="M1069 966c1.95-.3 1.95-.3 4.13-.19l2.19.08 1.68.11-1 3h-9z"/><path fill="#d5d6d6" d="M47 958h7v4l-7-1z"/><path fill="#36363a" d="M215 953c4.81-.21 8.59-.2 13 2v1q-2.4.12-4.81.19l-2.7.1c-2.9-.34-3.72-1.04-5.49-3.29"/><path fill="#bebdbe" d="M1134 946c1.95-.3 1.95-.3 4.13-.19l2.19.08 1.68.11-1 3h-9z"/><path fill="#000005" d="M1245 936h1v17h-1l-.18-1.83-.26-2.42-.24-2.4c-.32-2.35-.32-2.35-.91-4.5-.56-2.52.29-3.7 1.59-5.85"/><path fill="#000001" d="M1137 935h9l-1 3h-8z"/><path fill="#aaa9aa" d="M1195 930h9l-2 4-7-2z"/><path fill="#9e9e9f" d="m254 929 9 2-1 2h-7z"/><path fill="#bbbaba" d="m1202 925-1 4h-8v-2c5.63-2 5.63-2 9-2"/><path fill="#bfbec2" d="M6 914h4v7H7z"/><path fill="#262529" d="M940 910c-2.52 1.96-4.83 2.57-7.94 3.19l-2.59.54c-2.48.27-4.11.04-6.47-.73 5.9-2.3 10.67-3.35 17-3"/><path fill="#a4a4a4" d="M106 900c3.48.65 6.1 1.98 9 4v2c-2.81-.19-2.81-.19-6-1-1.87-2.56-1.87-2.56-3-5"/><path fill="#010105" d="m1003 894 2 1-1 1 4 2a102 102 0 0 1-10 3v-3c2.5-2.19 2.5-2.19 5-4"/><path fill="#0d0c12" d="m1318.63 888.88 2.37.12-1.81.81c-2.19 1.19-2.19 1.19-3.57 2.69-2.32 2.15-4.57 2.75-7.62 3.5-2.37-.37-2.37-.37-4-1 2.69-1.97 5.17-2.67 8.38-3.44 2.82-.98 3-2.53 6.24-2.68"/><path fill="#e1e5e9" d="m1472 886 2 1v7h-4c.88-6.87.88-6.87 2-8"/><path fill="#dce2e5" d="m1480 874 2 1v7h-4c.88-6.87.88-6.87 2-8"/><path fill="#525256" d="M1069 874c-5.26 2.63-9.15 3.41-15 3a18 18 0 0 1 6.94-3.19l2.02-.54c2.29-.3 3.86.04 6.04.73"/><path fill="#8e8e8e" d="M1027 873c1.95-.3 1.95-.3 4.13-.19l2.19.08 1.68.11-1 3h-9z"/><path fill="#28272e" d="m1003.82 870.7 5.18.3v3h-10c3-3 3-3 4.82-3.3"/><path fill="#838486" d="m1081.63 865.94 3.03.02 2.34.04c-8.58 4.33-8.58 4.33-13 4v-3c2.64-1.32 4.68-1.1 7.63-1.06"/><path fill="#6e7273" d="M3 849h1c.41 5.85-.37 9.74-3 15H0c-.42-5.5 1.09-9.87 3-15"/><path fill="#838485" d="m1098.13 855.94 2.19.02 1.68.04v2c-2.29 1.14-3.6 1.1-6.12 1.06l-2.2-.02-1.68-.04v-2c2.29-1.14 3.6-1.1 6.13-1.06"/><path fill="#dde0e5" d="m1519 834 2 1c.59 2.31.74 4.62 1 7h-4c-.1-5.37-.1-5.37 0-7z"/><path fill="#828182" d="M1168 838v4h-9c1.86-3.72 5.08-4.28 9-4"/><path fill="#808081" d="M1171 835h7v3l-9 1z"/><path fill="#caced1" d="m1500 824 2 2c-.37 2.63-.37 2.63-1 5l2 1-4-1v-3l-5 1 1-3h5z"/><path fill="#000001" d="M14 820h3v8l-3 1z"/><path fill="#403e42" d="M398 802c9.17 1.33 9.17 1.33 12.63 4.13L412 808c-9.17-1.33-9.17-1.33-12.62-4.12z"/><path fill="#d8d7da" d="M1482 786h7v3l-7 1z"/><path fill="#c1c2c4" d="m24 778 2 1v7h-4c.88-6.87.88-6.87 2-8"/><path fill="#d4d2d5" d="M1494 778h7v3l-7 1z"/><path fill="#c8c7ca" d="M1506 770h7v3l-7 1z"/><path fill="#020109" d="M516 766c8.2-.63 8.2-.63 11.56 2l1.44 2c-6.22.28-6.22.28-9-2q-2-1.02-4-2"/><path fill="#c2c3c4" d="M1518 762h7v3l-7 1z"/><path fill="#07070f" d="m465.82 761.7 5.18.3v3h-10c3-3 3-3 4.82-3.3"/><path fill="#cfced1" d="M1530 754h7v3l-7 1z"/><path fill="#ae89dd" d="M507 755h5l1 3h-17v-1l11-1z"/><path fill="#b690e5" d="M468 748q.9.45 1.81.94A29 29 0 0 0 475 751v2l-16-2v-1h9z"/><path fill="#000103" d="m440 750 3 3h-11v-2c2.8-1.4 4.9-1.25 8-1"/><path fill="#cccace" d="M1546 742h7v3l-7 1z"/><path fill="#bababc" d="M1562 730h7v3l-7 1z"/><path fill="#afafb1" d="M78 726v4h-7v-3c2.67-.9 4.26-1.1 7-1"/><path fill="#454549" d="M236 714c-1.13 3.38-2.32 4.71-5 7h-3c.38-2.75.8-3.82 2.88-5.75C233 714 233 714 236 714"/><path fill="#ddd" d="M103 712v3c-4 3.9-4 3.9-7.31 4.25L94 719c.74-2.4 1.47-3.7 3.7-4.93A94 94 0 0 1 103 712"/><path fill="#503282" d="M928 710c8.45-.18 8.45-.18 12 1v2c-4.62 2.13-4.62 2.13-8 1l3-1v-2h-7z"/><path fill="#c9c9ca" d="M106 702v4h-7v-3c2.67-.9 4.26-1.1 7-1"/><path fill="#55338a" d="M969 699c1.95-.3 1.95-.3 4.13-.19l2.19.08 1.68.11-1 3h-9z"/><path fill="#000004" d="M1004 698c1.95-.3 1.95-.3 4.13-.19l2.19.08 1.68.11-1 3h-9z"/><path fill="#b577fa" d="m367 695 5 1 2 7-4-1v-4h-2z"/><path fill="#070212" d="M1024 691v2l-9 2v2h-7c2.65-2 5.46-3 8.56-4.12l2.94-1.08c2.5-.8 2.5-.8 4.5-.8"/><path fill="#b5b5b7" d="M122 690v4h-7v-3c2.67-.9 4.26-1.1 7-1"/><path fill="#5d5d61" d="m250 679 2 1c-3.52 4.44-7.75 5.52-13 7 1.85-3.05 3.86-4.28 7-6l2.19-1.25z"/><path fill="#100a23" d="M1059 678c-4.43 2.94-7.5 4.54-13 4 1-2 1-2 3.21-2.89l2.73-.8 2.71-.82c2.35-.49 2.35-.49 4.35.51"/><path fill="#c7c8c9" d="M142 674v4h-7v-3c2.67-.9 4.26-1.1 7-1"/><path fill="#32195b" d="M1071 667v3c-2.7 1.35-5 1.07-8 1l1-3c2.46-1.23 4.28-1.07 7-1"/><path fill="#070708" d="m176.13 665.88 2.87.12v3l-12 1 4-2c2-2 2-2 5.13-2.12"/><path fill="#babcbc" d="M154 666v4h-7v-3c2.67-.9 4.26-1.1 7-1"/><path fill="#b2b0b3" d="M1642 662h7v3l-7 1z"/><path fill="#08090d" d="M1549 659v2h4v4l-5-1v-2l-4-1c2.75-2 2.75-2 5-2"/><path fill="#c7c8c7" d="M166 658v4h-7v-3c2.67-.9 4.26-1.1 7-1"/><path fill="#06060b" d="m1640 650 3 1c-.62 1.94-.62 1.94-2 4-3.63 1.03-6.42 1.24-10 0l-1 3v-3l-2-1h2l1-2v2h9z"/><path fill="#b9b9ba" d="M186 646v4h-7v-3c2.67-.9 4.26-1.1 7-1"/><path fill="#000002" d="M220 638h7v3l-7 1z"/><path fill="#c3c3c4" d="M206 634v4h-7v-3c2.67-.9 4.26-1.1 7-1"/><path fill="#e2e5e5" d="M219 630h5l-1 5h-5z"/><path fill="#c191ed" d="M381 612h1v14l-2-2-2 2c.71-4.75 1.78-9.35 3-14"/><path fill="#1c1134" d="m1212 612-1.57.77c-3.2 1.58-6.29 3.14-9.3 5.04L1199 619l-2-1a23 23 0 0 1 6.94-5.25l2.02-1.08c2.42-.8 3.67-.5 6.04.33"/><path fill="#000102" d="M264 614h7v3l-7 1z"/><path fill="#e6e9e8" d="M247 614h5l-1 5h-5z"/><path fill="#cdcbcd" d="M263 606h5l-1 5h-5z"/><path fill="#c19cee" d="M385 598h1v5h3l-1 4-5 1z"/><path fill="#dbdbdd" d="M1702 594h4l-1 7h-3z"/><path fill="#18191c" d="m1690 589 1 2 4-1c-.54 3.8-2.2 5.47-5 8v-5l-2-1h2z"/><path fill="#51308e" d="M804 582v2c-2.4 1.2-3.95 1.1-6.62 1.06l-2.48-.02-1.9-.04c2.08-4.15 6.98-3.08 11-3"/><path fill="#a8a9aa" d="M326 570v4h-7v-3c2.67-.9 4.26-1.1 7-1"/><path fill="#f7f7f7" d="M338 558v6l-4 2v-7c3-1 3-1 4-1"/><path fill="#0a0b11" d="M1651 547c.64 1.77.64 1.77 1 4-.9 1.89-.9 1.89-2.25 3.69l-1.33 1.82C1647 558 1647 558 1644 559a81 81 0 0 1 4.63-8.25z"/><path fill="#43237c" d="M924 542h8v3h-9z"/><path fill="#331e5d" d="m1322 526 .81 1.81c1.19 2.19 1.19 2.19 2.75 3.63 1.9 2.05 2.07 3.84 2.44 6.56l-4 1 .13-2.87C1324 533 1324 533 1322 531c-.12-2.62-.12-2.62 0-5"/><path fill="#503382" d="M967 526v3l-10 1-1-2c3.82-1.42 6.91-2.24 11-2"/><path fill="#0a0a10" d="M807 521c-3.21 2.85-5.5 3.43-9.75 3.69l-2.98.2-2.27.11c1-2 1-2 2.9-2.63 4.08-.86 7.9-1.64 12.1-1.37"/><path fill="#2e1853" d="M1030 506c-1.19 2-1.19 2-3 4-2.69.25-2.69.25-5 0l-1-3c3-1.5 5.66-1.06 9-1"/><path fill="#08090e" d="M1662 496c2.15 2.15 2.91 3.66 3.11 6.72l-.01 1.85-.01 2.03-.03 2.09-.06 7.31h-1l-.56-8.3c-.33-4.59-.33-4.59-1.44-5.7q-.06-3 0-6"/><path fill="#2c2c31" d="m882.81 498.88 2.96.05 2.23.07v1l-16 3v-2c3.77-1.75 6.67-2.22 10.81-2.12"/><path fill="#08090e" d="M1413 464c6.94 6.76 6.94 6.76 7.13 10.69L1420 477c-7-8.2-7-8.2-7-13"/><path fill="#c4c3c5" d="m1723 462 2 1c.59 2.31.74 4.62 1 7h-4c-.1-5.37-.1-5.37 0-7z"/><path fill="#08090f" d="M1405 450c2.1 2.03 3.99 4.1 5.75 6.44l1.36 1.74c.89 1.82.89 1.82.53 4.04L1412 464q-1.48-2.37-2.94-4.75l-1.65-2.67A20 20 0 0 1 1405 450"/><path fill="#cccbcc" d="m1702 446 7 1v3h-7z"/><path fill="#c9c9cb" d="m1690 438 7 1v3h-7z"/><path fill="#919294" d="M421 422h1l1 6h1c.19 2.88.19 2.88 0 6l-3 2z"/><path fill="#484a4c" d="M367 421h1q.08 1.94.13 3.88l.07 2.17C368 429 368 429 366 431h-3a57 57 0 0 1 4-10"/><path fill="#170d2c" d="M1342 423c5.9 5.59 5.9 5.59 7 9q-.44 2.01-1 4l-3-5.37-1.69-3.03C1342 425 1342 425 1342 423"/><path fill="#737777" d="M366 420c.75 1.63.75 1.63 1 4a35 35 0 0 1-4 6h-1c-.35-4.67-.35-4.67 0-7 2-1.94 2-1.94 4-3"/><path fill="#070410" d="M1202 421c-1.27 2.53-2.43 2.93-4.94 4.19l-2.15 1.1-1.91.71-2-1c.75-1.94.75-1.94 2-4 2.94-1.22 5.85-1.09 9-1"/><path fill="#313036" d="M893 414c3 1 3 1 4 3l-6 3c.88-4.87.88-4.87 2-6m-3 6 1 2h-7v-1z"/><path fill="#462e78" d="m1268 392 2 1-9 9-3-1z"/><path fill="#f8f8f8" d="M1558 394h7l1 4h-6z"/><path fill="#442d79" d="m1276 390 2 1-10 9c0-3 0-3 1.75-4.82l2.25-1.87 2.25-1.88z"/><path fill="#000004" d="M1278 363h3c-.75 4.75-.75 4.75-3 7h-3l1-4h2z"/><path fill="#cecdcf" d="m1375 358 2 1c.59 2.31.74 4.62 1 7h-4c-.1-5.37-.1-5.37 0-7z"/><path fill="#848989" d="M374 358h1c.37 5.55.37 5.55-1.37 7.69L372 367q-1.05 1.47-2 3c-.37-6.53-.37-6.53 1.44-8.81L373 360z"/><path fill="#d4d3d4" d="m1367 346 2 1c.59 2.31.74 4.62 1 7h-4c-.1-5.37-.1-5.37 0-7z"/><path fill="#b8b6ba" d="m1359 334 2 1c.59 2.31.74 4.62 1 7h-4c-.1-5.37-.1-5.37 0-7z"/><path fill="#e2e2e5" d="m1345 326 5 1v5l-5-1z"/><path fill="#28272d" d="M860 316h3c-.66 4.39-2.19 7.98-4 12-1-3.13-.96-5.1-.06-8.25l.59-2.14z"/><path fill="#313036" d="M863 309h1c-.59 5.37-.59 5.37-1 7l-2 1v-2l-3 1v-5h2v2h2z"/><path fill="#07080d" d="M406 229h3v14h-1l-1-7h-1z"/><path fill="#f3f3f3" d="M398 226v6l-4 2v-7c3-1 3-1 4-1"/><path fill="#d8d8d9" d="M402 214v6l-4 2v-7c3-1 3-1 4-1"/><path fill="#88888b" d="M790 207h9l-1 3h-8z"/><path fill="#5b5c5f" d="M835 198a30 30 0 0 1-11 6c-2.44-.31-2.44-.31-4-1 2.65-1.46 3.9-2 7-2v-2c5.75-2.12 5.75-2.12 8-1"/><path fill="#2e2b32" d="m889 176 2 2c-1.12 1.5-1.12 1.5-3 3-3.19.19-3.19.19-6 0 1.51-2.34 2.46-3.77 5.06-4.87z"/><path fill="#7a7b7c" d="M894 169c-3.73 3.04-7.47 4.49-12 6v-2h2v-3q1.93-.55 3.88-1.06l2.17-.6C892 168 892 168 894 169"/><path fill="#929192" d="M814 166h8v3h-9z"/><path fill="#6b6a6e" d="M487 167c0 3.56-.84 4.65-2.81 7.56l-1.58 2.38A13.6 13.6 0 0 1 478 181c1.7-6.02 1.7-6.02 5-8q1.57-2.45 3-5z"/><path fill="#858586" d="m892.85 162.9 6.15.1-1 3-1-2h-5l-1 3-8 1v-2l1.5-.4 1.94-.54 1.93-.52c2.02-.67 2.3-1.52 4.48-1.64"/><path fill="#2f2e34" d="m982 152 1 2 3 1-7 4-1-5z"/><path fill="#d2d3d3" d="m428 134 2 1v7h-4c.88-6.87.88-6.87 2-8"/><path fill="#0a0b11" d="M450 134h3c.25 2.25.25 2.25 0 5-2 2.31-2 2.31-4 4v-4l-3-1h4z"/><path fill="#28272c" d="m641.5 128.56 2.5.44-3 2-1.94 1.29-2.06 1.34-2.06 1.35C633 136 633 136 630 136c7.54-7.5 7.54-7.5 11.5-7.44"/><path fill="#f0f0f0" d="m1257 126 5 1v5l-5-1z"/><path fill="#5b5a5e" d="M628 125h16l-1 3-5 1 1-2-11-1z"/><path fill="#cac9ca" d="m1239 78 2 1c.59 2.31.74 4.62 1 7h-4c-.1-5.37-.1-5.37 0-7z"/><path fill="#403f43" d="M681 75q.9.45 1.81.94A29 29 0 0 0 688 78v2h-7c-.56-1.94-.56-1.94-1-4z"/><path fill="#cdccce" d="M695 74q2.38.68 4.75 1.38l2.67.77c2.34.77 4.41 1.69 6.58 2.85-9.74.57-9.74.57-14-3z"/><path fill="#c7c6c8" d="m1231 66 2 1c.59 2.31.74 4.62 1 7h-4c-.1-5.37-.1-5.37 0-7z"/><path fill="#0b090f" d="M913 70h9l-1 3h-8z"/><path fill="#e4e4e4" d="m617 66 15 3v1q-2.9.04-5.81.06l-3.27.04C620 70 620 70 617 69z"/><path d="M932 58h9l-1 3h-8z"/><path fill="#bababd" d="m1219 50 2 1c.59 2.31.74 4.62 1 7h-4c-.1-5.37-.1-5.37 0-7z"/><path fill="#bfbec0" d="m1186 22 7 1v3h-7z"/><path fill="#d8d8d9" d="M1015 18h7l-2 4h-6z"/><path fill="#565659" d="M1301 1826h7v3l-6 1z"/><path fill="#090b0f" d="M1299 1811v3h-9v-2c3-1.5 5.66-1.06 9-1"/><path fill="#05080c" d="M1360 1776h3c-.62 2.44-.62 2.44-2 5-3.12.81-3.12.81-6 1l1-3h3z"/><path fill="#a3a9a9" d="M384 1766h3c1.75 1.75 1.75 1.75 3 4l-1 4-6-7z"/><path fill="#c2c5c3" d="M359 1716v6l-5-1v-4c2-1 2-1 5-1"/><path fill="#636266" d="m1418 1713 4 1-1 6h-3z"/><path fill="#5f6162" d="M343 1697h3v7h-3q-.57-3-1-6z"/><path fill="#45444c" d="M762 1690c.3 5.18.3 5.18 0 7l-3 3v-9c2-1 2-1 3-1"/><path fill="#bbbcbf" d="M1421 1688h1c.19 2.38.19 2.38 0 5l-1.44.88c-1.56 1.12-1.56 1.12-1.98 2.8q-.35 2.64-.58 5.32h-1c-.35-5.23-.3-8.65 3-13z"/><path fill="#020208" d="m789 1675-8 5v-2l-2-1c5.5-3.12 5.5-3.12 10-2"/><path fill="#010106" d="m475 1626 7 1 1 3h-7z"/><path fill="#000006" d="M504 1606h16l-1 3-16-1z"/><path fill="#202029" d="m640 1528 2 1c-2.36 3.74-3.89 5.25-8.25 6.31q-1.87.38-3.75.69c2.24-3.54 4.1-4.6 8-6z"/><path fill="#363641" d="M696 1487c-.37 1.94-.37 1.94-1 4l-2 1-1 3c-2.06.69-2.06.69-4 1 4.2-9 4.2-9 8-9"/><path fill="#b7b6b8" d="M1430 1452h3v8l-3 1z"/><path fill="#323337" d="m1426 1436 2 1-1 5h-8l1-3c2.5-1.12 2.5-1.12 5-2z"/><path fill="#740d05" d="m1426 1372 4 3h-3l-1 3-4-1c1.75-3.87 1.75-3.87 4-5"/><path fill="#202028" d="m929.31 1340.31 1.69.69a30 30 0 0 1-9 5q-2.01.97-4 2c6.12-8.39 6.12-8.39 11.31-7.69"/><path fill="#bab5af" d="m1301 1340 1 4Zm-3 4h3l2 11c-3-2-3-2-3.73-4.16l-.46-2.46-.48-2.48z"/><path fill="#d0c8c0" d="M1303 1287h2v13h-1l-1-5h-1c-.1-5.37-.1-5.37 0-7z"/><path fill="#000004" d="M1059 1282h2c1.14 2.29 1.1 3.6 1.06 6.13l-.02 2.19-.04 1.68h-2c-1.2-3.6-1.07-6.23-1-10"/><path fill="#000006" d="M1264 1277h1q-.17 2.72-.37 5.44l-.22 3.06-.41 2.5-2 1q-.08-2.15-.12-4.31l-.08-2.43c.21-2.4.63-3.46 2.2-5.26"/><path fill="#030309" d="m871 1235 1 3h-2l-2 4-2-1c-.62-2.06-.62-2.06-1-4z"/><path fill="#d6cbc0" d="M1351 1233v4h-8v-2c2.9-1.26 4.8-2 8-2"/><path fill="#a8b4bd" d="M1542 1191h2c.75 1.69.75 1.69 1 4-1.94 2.75-1.94 2.75-4 5v-3l-3-1h3z"/><path fill="#000004" d="M503 1179c5.75-.12 5.75-.12 8 1l-1 3h-5v-2h-2z"/><path fill="#b5bec5" d="M1544 1171h1c.29 5.01.28 7.07-3 11-.69 2.75-.69 2.75-1 5h-1q-.12-1.94-.19-3.87l-.1-2.18.29-1.95c1.47-1.06 1.47-1.06 3-2 .69-3.12.69-3.12 1-6"/><path fill="#edf2f4" d="M1541 1162h1c.63 7.21.63 7.21-2 10.5l-2 1.5c-.42-4.66 1.02-7.9 3-12"/><path fill="#1e1e27" d="m1083.13 1168.44 2.75.3 2.12.26v1a83 83 0 0 1-16 1c4.24-2.12 6.32-3.12 11.13-2.56"/><path fill="#83919b" d="M1600 1134h2v10l-3-1z"/><path fill="#0c0c10" d="m1434 1058 4 4h-4l-1 4-3-1 2-4h2z"/><path fill="#9ba0a2" d="M335 1041c3.56.61 6.68 1.58 10 3l-1 2c-2.81.31-2.81.31-6 0-1.87-2.5-1.87-2.5-3-5"/><path fill="#67686a" d="M232 1030h7l-1 4-6-1z"/><path fill="#707272" d="M216 1026h7l-1 4-6-1z"/><path fill="#e1e1e1" d="M1035 1019h8l1 3h-8z"/><path fill="#c5c4c3" d="M1179 966c-4.14 2.41-9.45 4.5-14.25 3.55L1163 969h2v-2l5.88-1.06 3.3-.6c2.82-.34 2.82-.34 4.82.66"/><path fill="#060607" d="M1413 956h1a80 80 0 0 1-3 14h-1c-.56-9.58-.56-9.58 3-14"/><path fill="#9c9b9d" d="M224 946h7l1 3 5 1v1l-9-1v-2z"/><path d="m1115.82 942.7 5.18.3-1 3h-9c3-3 3-3 4.82-3.3"/><path fill="#000002" d="M157 938h6v2h2v2l-8-1z"/><path fill="#323138" d="M695 935h7l1 3h-9z"/><path fill="#f8f7f7" d="M1445 929c-2.56 5.31-2.56 5.31-4 7h-3c.78-4.52 2.18-7 7-7"/><path fill="#000002" d="m138 929 7 1v4l-7-2z"/><path fill="#06070b" d="m1444 915 5 1-5 5-1-2-4 1v-2l1.94-.37 2.06-.63z"/><path fill="#272729" d="M109 908c1.75-.25 1.75-.25 4 0a20 20 0 0 1 4 4v2h-5l-1-4h-2z"/><path fill="#050509" d="m961 907-2 1 3 2h-14c8.98-4.49 8.98-4.49 13-3"/><path fill="#a8b1b9" d="m1477 902 1 2 3 1c-1.81 2-1.81 2-4 4h-3c.75-4.75.75-4.75 3-7"/><path fill="#434148" d="m900.69 899.81 2.45.08 1.86.11c-1.9 1.9-3.33 2.94-6.05 3.2q-2.97-.01-5.95-.2c2.42-2.98 3.9-3.35 7.69-3.19"/><path fill="#c2cad0" d="M1514 842c2 1.69 2 1.69 4 4q.06 2.5 0 5c1.5 1.31 1.5 1.31 3 2l-5-1c-1.25-3.43-2.28-6.32-2-10"/><path fill="#7e7e7e" d="m1161.31 839.31 1.69.69-3 1 4 2-8 2v-2l-2-1c4.43-3.08 4.43-3.08 7.31-2.69"/><path fill="#939fa8" d="m1502 835 4 2c-.75 4.75-.75 4.75-3 7-1.5-3-1.06-5.66-1-9"/><path fill="#29282d" d="m1117 836 2 1-1 2 7-1c-3.27 1.88-6.28 2.5-10 3v-2h-6v-1l5.37-.68C1116 837 1116 837 1117 836"/><path fill="#3e3e41" d="M440 817a40 40 0 0 1 15 2l1 2a520 520 0 0 1-8.1-.59A28 28 0 0 1 440 818z"/><path fill="#27272b" d="M86 805c1 3 1 3 .07 5.23l-1.38 2.4-1.37 2.4C82 817 82 817 80 818c.48-5.15 3.19-8.82 6-13"/><path fill="#87898b" d="m1270 792-7 3 3 2-7 1c1.63-3.45 2.52-4.78 6.06-6.37 2.94-.63 2.94-.63 4.94.37"/><path fill="#08080d" d="m98 785 4 1-3.44 4.5-1.93 2.53L95 795h-1v-5h3z"/><path fill="#9d9d9e" d="M161 776c.14 2.51.22 4.47-.75 6.81C159 784 159 784 156.88 784.2L155 784c0-3 0-3 1.06-4.39 3.83-3.61 3.83-3.61 4.94-3.61"/><path fill="#010204" d="M661 770h7c-.62 1.49-.62 1.49-2 3-2.82.3-2.82.3-6.12.19l-3.33-.08L654 773v-1h7z"/><path fill="#1e1f22" d="M351 756c3.45 1.43 5.1 3.1 7.25 6.13l1.58 2.19L361 766c-2.31-.12-2.31-.12-5-1-1.69-2.87-1.69-2.87-3-6a59 59 0 0 0-2-3"/><path fill="#35343a" d="M1333 762c-6.23 4.08-6.23 4.08-9.44 3.75L1322 765c3.34-3.85 6.16-4.55 11-3"/><path fill="#7c7b7c" d="m1356 755 5 2c-2.4 2.9-4.31 3.5-8 4l1-3-4-1h6z"/><path fill="#b4b4b6" d="M47 751h3v7h-4z"/><path fill="#643ea3" d="M658 755c5.27-.2 5.27-.2 7 0l2 2c-1 1-1 1-2.85 1.1l-2.21-.04-2.23-.02L658 758z"/><path fill="#020305" d="M79 738h5a40 40 0 0 1-5 8l-2-1z"/><path fill="#503380" d="m959 706-4 2-2.94 1.63A13.7 13.7 0 0 1 944 711l1.81-.81c2.19-1.19 2.19-1.19 3.44-2.82 2.88-2.26 6.22-1.6 9.75-1.37"/><path fill="#07090e" d="M129 702h4v4h-3l-1 3-2-1 1-2-2-1h3z"/><path fill="#8d9092" d="M164 667h1v5c-7.43 2.43-7.43 2.43-11 1 2.98-2.54 5.2-3.28 9-4z"/><path fill="#39353b" d="m1643 650 3 1-1.44.69c-2.18 1.83-2.2 3.56-2.56 6.31l-7 2 2-4h4l.44-2.44c.56-2.56.56-2.56 1.56-3.56"/><path fill="#acaaae" d="M1658 646h4l-1 7h-3z"/><path fill="#000002" d="M200 650h7v3l-7 1z"/><path fill="#0b0b0f" d="M1566 646v6l-2 1v-3l-2.37 1.56L1559 653l-2-1c5.6-6 5.6-6 9-6"/><path fill="#2d1850" d="M1148 639c-3.83 2.7-6.33 3.33-11 3v-3c7.43-1.29 7.43-1.29 11 0"/><path fill="#7a7a7d" d="M231 627h7v1h-5l-1 4h-6v-2l1.94-.37L230 629z"/><path fill="#9c9aa0" d="m1671 623 1 2-2 1 .25 2.31c-.25 2.69-.25 2.69-2.25 4.5l-2 1.19c.35-7.37.35-7.37 3.06-10z"/><path fill="#000001" d="M248 622h7v3l-7 1z"/><path fill="#bcbabe" d="M1686 614h4l-1 7h-3z"/><path fill="#090a0e" d="M1683 592h3v6l4 1c-2.2.76-3.5 1.16-5.75.44-1.99-2.29-1.44-4.54-1.25-7.44"/><path fill="#21143c" d="m1246.69 590.81 2.31.19c-3.9 3.4-8.1 6.24-13 8l-2-1 1.43-.77c5.29-2.92 5.29-2.92 7.2-4.98 1.37-1.25 1.37-1.25 4.06-1.44"/><path fill="#aaabaf" d="M303 586h4l1 5h-6z"/><path fill="#b4b6b8" d="M311 582h4l1 5h-6z"/><path fill="#131317" d="m1702 573 2 1-1 7-3 1-2 4v-9l1 2h3z"/><path fill="#06060d" d="m537.13 574.94 2.19.02 1.68.04 1 3q-2.2.08-4.37.13l-2.47.07C533 578 533 578 531 576c2.29-1.14 3.6-1.1 6.13-1.06"/><path fill="#06060c" d="M769 559h15v3l-2.44-.44c-4.16-.7-8.36-1.12-12.56-1.56z"/><path d="M349 551v9h-3v-8c2-1 2-1 3-1"/><path fill="#8a70b1" d="m910.16 540.7 2.46.11 2.48.08 1.9.11-1 2c-3.6 1.2-6.23 1.07-10 1 1.12-2.34 1.52-2.94 4.16-3.3"/><path fill="#252327" d="M1722 531h1v7l3 1-7 3 1-5h2z"/><path fill="#72559e" d="M934 533h8l-1 3h-8z"/><path fill="#dfe0e1" d="m340 511 2 1v8h-3q-.05-1.96-.06-3.94l-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#2f2e31" d="m1723 492 1.88 2.38A40 40 0 0 0 1729 499v3l-5-2z"/><path fill="#020106" d="M1372 497c.75 1.75.75 1.75 1 4-1.75 2.25-1.75 2.25-4 4h-3l1-3h2l.38-1.94.62-2.06z"/><path fill="#28282e" d="M904 495v1q-2.87.8-5.75 1.56l-3.23.88c-2.86.53-4.34.54-7.02-.44 4.84-4 9.97-3.29 16-3"/><path d="M1714 477h4v7h-3z"/><path fill="#010105" d="m1158 438 3 1-4 3c0-3 0-3 1-4m-4 4 1 4h-5v-3z"/><path fill="#301d59" d="m1202 432 2 1-2 4 4 1c-2.7 1.35-5 1.06-8 1 1.07-2.92 1.78-4.78 4-7"/><path fill="#bebcbe" d="M1653 426h4l1 5h-6z"/><path fill="#311d58" d="M1329 407c3.88 1.75 3.88 1.75 5 4q.06 3 0 6c-1.94-1.06-1.94-1.06-4-3-.66-2.37-.81-4.53-1-7"/><path fill="#090a0f" d="m1320 293 2 1v8l-2 1c-1.52-3.03-1.12-5.62-1-9z"/><path fill="#8f9092" d="m445 287 2 4 2-4c.37 5.5.37 5.5-1.37 7.88C446 296 446 296 444 296z"/><path fill="#39383e" d="M1304 244h5v8l5 1-3 2-4-2v-7h-3z"/><path fill="#030208" d="m916 231 1 3-1 2h-2l-2 5-3-1c.96-2.88 1.42-3.65 3.5-5.62C915 232 915 232 916 231"/><path fill="#46454a" d="M643 219a70 70 0 0 1 12 5c-3.81 1.47-6.29.43-10-1v-2h-2z"/><path fill="#09090e" d="m1271 182 3 1v8l-3-1z"/><path fill="#2c2c31" d="M482 169c1 3 1 3-.14 5.45l-1.67 2.68-1.65 2.69C477 182 477 182 475 183c-.19-1.81-.19-1.81 0-4a100 100 0 0 1 3-3c1.43-2.28 2.72-4.63 4-7"/><path fill="#3b393f" d="M896 171v2c-7.11 4.6-7.11 4.6-12 4 3.58-3.07 7.04-6 12-6"/><path fill="#07060b" d="m643 171 4 2v3l5 2c-2.79 1.03-3.87 1.05-6.75.06L643 177z"/><path fill="#4d4b50" d="M593 172c3 1.36 5.29 2.95 7.75 5.13l1.86 1.63L604 180l-1 4-4.44-4.87c-.2-.24-.2-.24-1.27-1.4-3.18-3.5-3.18-3.5-4.29-5.73"/><path fill="#88888b" d="M620 162c2.67 2.49 4.56 4.63 6 8h-3l1 4h-2v-3l-2-1 .56-2.31C621 165 621 165 620 162"/><path fill="#000001" d="m626 158 3 1 1 7-4-1z"/><path fill="#838283" d="m914 151 1 3-1.43.59-1.88.78-1.87.78c-1.82.85-1.82.85-3.38 1.94-2.12 1.34-4 1.04-6.44.91 3.75-2 3.75-2 6-2l1-3 7-1z"/><path fill="#8f8e8e" d="M500 154h4c-1.04 3.02-2.24 4.36-5 5.81l-3 1.19-2-1q1.19-.93 2.44-1.87C499 156 499 156 500 154"/><path fill="#000002" d="M1254 137h4v7h-3z"/><path fill="#080a0e" d="M478 106h4c-.4 2.89-.79 3.82-3.06 5.75L477 113v-3l-3-1h4z"/><path fill="#07070c" d="M759 94h6v3h-13v-1h7z"/><path fill="#0b0b0f" d="M858 90h9v2c-3 1.5-5.66 1.06-9 1z"/><path fill="#535457" d="m866 78 2 2c-.25 2.06-.25 2.06-1 4-2.64 1.32-4.68 1.1-7.62 1.06l-3.04-.02L854 85l1-2 1.68.04 2.2.02 2.17.04C863 83 863 83 865 82c.63-2.06.63-2.06 1-4"/><path fill="#626467" d="M871 71v3h-7v-3c2.67-1.33 4.17-.67 7 0"/><path fill="#0a0a10" d="M963 50h8l-1 3c-1.81.81-1.81.81-4 1-2.25-.94-2.25-.94-4-2z"/><path fill="#0a0a0e" d="M1139 18h7l1 3h-9z"/><path fill="#bebfbf" d="M1170 10c4.75.75 4.75.75 7 3-1 1-1 1-2.63 1.1L1169 14z"/><path fill="#cecfd0" d="M1039 3h8v3h-8z"/><path fill="#949596" d="M489 1834h8v3h-8z"/><path fill="#323234" d="M493 1823c5.23-.35 8.63-.28 13 3l-1 3-2.62-1.94c-3.05-2.05-5.7-2.71-9.38-3.06z"/><path fill="#08090e" d="M447 1803h7l1 3h-8z"/><path fill="#828586" d="m414 1793 7 2v6l-2-4-6 1z"/><path fill="#060609" d="M1366 1774h6c-.81 2.44-.81 2.44-2 5l-3 1z"/><path fill="#53535b" d="M1271 1738h3l-1 4 3 1c-1 1-1 1-3.56 1.06l-2.44-.06q-.06-2.5 0-5z"/><path fill="#1b1b22" d="M773 1730c3.67.3 4.78.76 7.31 3.56L782 1736c-3.67-.3-4.78-.76-7.31-3.56z"/><path fill="#6a6971" d="M436 1722a83 83 0 0 1 8 9l-1 2c-7-7-7-7-7.12-9.12z"/><path fill="#f2f2f3" d="M1412 1721h2v6l-4 2c-.12-2.87-.12-2.87 0-6z"/><path fill="#1e1d25" d="M942 1714c.83 2.38 1.12 3.63.33 6.07l-1.08 2.06-1.05 2.07a10.5 10.5 0 0 1-4.2 3.8c1.54-5.02 3.03-9.61 6-14"/><path fill="#4c4c55" d="M433 1709c2.7 2.3 3.51 4.04 4.19 7.5l.48 2.34c.33 2.16.33 2.16.33 5.16-2.17-2.17-2.68-3.5-3.62-6.37l-.8-2.34c-.56-2.2-.7-4.02-.58-6.29"/><path fill="#313239" d="M1311 1693c.98 2.6 1.12 3.69.07 6.32l-1.38 2.37-1.37 2.38C1307 1706 1307 1706 1305 1707c1.49-4.96 3.6-9.41 6-14"/><path fill="#8e8f90" d="M339 1682h3v8h-3z"/><path fill="#c0c1c1" d="M346 1685c3 1 3 1 4.19 2.56 1.16 3.49.97 6.8.81 10.44h-1l-.37-1.71-.5-2.23-.5-2.21C348 1690 348 1690 346 1689z"/><path fill="#8a8791" d="M1307 1684h3v8h-3z"/><path fill="#17181e" d="M936 1681c3.44 3.17 5.56 5.43 7 10-3.75-1.25-4.82-2.76-7-6-.25-2.31-.25-2.31 0-4"/><path fill="#4e4c55" d="M1252 1604c.63 1.88.63 1.88 1 4l-2 2c-.62 2.13-.62 2.13-1 4l3-1v2l-4 2c-1-2.69-1.23-4.42-.18-7.11q1.5-2.99 3.18-5.89"/><path fill="#25252e" d="m601 1553 2 1c-1.13 2.22-1.84 2.93-4.16 3.95l-2.47.74-2.47.76-1.9.55c2.43-3.91 4.83-5.31 9-7M626 1536l2 1q-1.6 1.8-3.25 3.56l-1.83 2c-1.92 1.44-1.92 1.44-4.15 1.15L617 1543c2.37-2.62 4.95-4.24 8-6z"/><path fill="#181921" d="m712 1483 3 1-10 7-2-1a367 367 0 0 1 4.31-3.56q1.2-1 2.43-2z"/><path fill="#d7d5cd" d="M1353 1391q2.19-.12 4.38-.19l2.46-.1c2.16.29 2.16.29 3.45 1.8l.71 1.49-3.81.06-2.15.04c-2.04-.1-2.04-.1-5.04-1.1z"/><path fill="#e14608" d="M1397 1383h12l1 3h-6v-2h-7z"/><path fill="#3b2617" d="M1240 1376c5.16-.24 9.19.08 14 2v1c-5.16.24-9.19-.08-14-2z"/><path fill="#d8ad91" d="M1344 1365c6.7 5.1 6.7 5.1 7.94 8.88l.06 2.12-2.31-2.37a21 21 0 0 0-4.5-3.7l-1.19-.93c-.07-2.06-.07-2.06 0-4"/><path fill="#716f6c" d="M1300 1353c3.44 3.17 5.56 5.43 7 10-3.36-1.39-5.15-2.86-7-6-.19-2.25-.19-2.25 0-4"/><path fill="#482d1c" d="m1258 1345 1 3h2v8h-2a18 18 0 0 1-1.06-7.19l.02-2.17z"/><path fill="#bcbcbe" d="m1522 1346 4 1-2 7-2-1z"/><path fill="#0e0e16" d="M926 1312c4.65.55 7.32 2.24 11 5l1 2a35 35 0 0 1-12-6z"/><path fill="#111018" d="M902 1301c4.76.47 8.08 1.03 12 4v2a70 70 0 0 1-12-5z"/><path fill="#e2e3e3" d="M1526 1291h3v8h-3z"/><path fill="#1e1d25" d="M876 1292c3.8.67 7.35 1.73 11 3v2c-4.96.58-7.2-1.08-11-4z"/><path fill="#010105" d="M921 1262h8v3h-8z"/><path fill="#cbc4ba" d="m1321 1255 2 4c-.81 1.5-.81 1.5-2 3h-3c-.19-2.37-.19-2.37 0-5 1.5-1.31 1.5-1.31 3-2"/><path fill="#787781" d="M392 1248c2 2.5 2.35 4.53 2.63 7.69l.22 2.45.15 1.86-2-3-2 2c-.14-7.43-.14-7.43 1-11"/><path fill="#07080e" d="m851 1244 2 1-3 7-4-1c.4-2.89.79-3.82 3.06-5.75z"/><path fill="#6a4f3e" d="m964 1236 4 1-3 1-1 3 4 1-11 1c3-2 3-2 5-3 1.13-2.06 1.13-2.06 2-4"/><path fill="#000002" d="M1286 1234h2a29 29 0 0 1-3 8h-3c.6-3.34 1.74-5.46 4-8"/><path fill="#06070b" d="m1397.07 1218.9 5 .06 1.93.04-1 3c-5.27.2-5.27.2-7 0l-2-2c1-1 1-1 3.07-1.1"/><path fill="#26262f" d="M904 1215c-2.88 2.24-5.73 4.36-9 6-2.31-.31-2.31-.31-4-1l5-1 1-3c2.46-1.23 4.28-1.07 7-1"/><path fill="#bebec1" d="m1466 1202 6 1 1 3h-7z"/><path fill="#d7dce2" d="M1570 1198h7l-1 3-6 1z"/><path fill="#2c2c37" d="M954 1190h7l-1 4h-6z"/><path fill="#adabaf" d="M1446 1158h3v8h-3z"/><path fill="#312f39" d="M716 1158v3c-2.12 1.64-2.12 1.64-4.87 3.25l-2.75 1.64L706 1167l-2-1 4.88-3.44 2.74-1.93C714 1159 714 1159 716 1158"/><path fill="#403f49" d="M436 1156c2 2 2 2 2.13 4.63L438 1163l-2-1-2 4v-9z"/><path fill="#727177" d="M1446 1142h3v8h-3z"/><path fill="#d9e0e2" d="M1531 1140h3v8h-3z"/><path fill="#55545a" d="M1446 1134h3v8h-3z"/><path fill="#c2c1c4" d="M1450 1104h3v8h-3z"/><path fill="#34333c" d="m770 1101 1 2-1 2 3 1-4 5-1-3-2-1z"/><path fill="#9ba8b1" d="M1576 1093c2.68 2.68 2.39 4.4 2.63 8.13l.22 3.32.15 2.55c-2.33-3.42-3.12-5.45-3.06-9.62l.02-2.48z"/><path fill="#15161e" d="M881 1093c4.6.5 4.6.5 6.56 2.94L889 1098a24 24 0 0 0 4 2l-2 1c-4.11-2-6.9-4.72-10-8"/><path fill="#6a6971" d="M1450 1088h3v8h-3z"/><path fill="#8d99a4" d="M1604 1088h5c0 3.7-.93 6.47-2 10h-1v-7h-2z"/><path fill="#b6bfc6" d="M1466 1051c2.29.38 3.65.65 5.31 2.31.9 2.23.46 3.46-.31 5.69l-2-1-1-3-2-1z"/><path fill="#727376" d="m1424 1044 2 1v2h3q1.6 2.95 3 6l-1 2-3.5-3.81-1.97-2.15C1424 1047 1424 1047 1424 1044"/><path fill="#000001" d="m1406 1024 4 1v6h-3z"/><path fill="#919da7" d="M1536 1013h3v3l3 1v3c-3 0-3 0-4.69-1.25-1.57-2.1-1.58-3.19-1.31-5.75"/><path fill="#d7d6d5" d="M1181 1016v2h-8v-3c3.62-1.2 4.64-.54 8 1"/><path fill="#000002" d="M1002 1014h6l1 4-7-1z"/><path fill="#c3c9d0" d="M1542 998h3c.93 3.01 1.04 3.87 0 7l-3 1z"/><path fill="#8b8b8d" d="M983 996q2.5.98 5 2l2.19.88c2.23 1.38 2.85 2.71 3.81 5.12-3.77-1.81-7.38-3.9-11-6z"/><path fill="#07090d" d="m157 991 10 1-1 2h-8z"/><path fill="#07080d" d="M147 987h9l-1 3c-5.75.13-5.75.13-8-1z"/><path fill="#bbb" d="m1036 978 3 1c-.59 1.49-.59 1.49-2 3-2.3.22-4.33.28-6.62.19l-1.86-.04-4.52-.15v-1l1.86-.15 4.88-.44c2.26-.41 2.26-.41 3.87-1.51z"/><path fill="#bcbdbc" d="M106 981h6l-1 5h-4z"/><path fill="#000001" d="m108 974 7 1v3h-7z"/><path fill="#050507" d="M1009 972c1.85.34 1.85.34 4.06.94l2.23.59 1.71.47v1c-4.32 1.1-8.62 2.18-13 3l3-1c.63-2 .63-2 1-4z"/><path fill="#000002" d="m82 962 6 1v3h-7z"/><path fill="#06070d" d="M204 958h9l-1 3-9-1z"/><path fill="#393a3e" d="m728.19 952.94 2.17.02 1.64.04c-3.22 2.2-5.2 3.13-9.12 3.06l-2.2-.02L719 956c2.88-2.88 5.29-3.13 9.19-3.06"/><path fill="#151518" d="M761 949v1l-8 1v2h-8l4-2 1.75-1.12c3.38-1.32 6.67-1.04 10.25-.88"/><path fill="#07070e" d="M1247 945c2 2 2 2 2.2 4.38l-.08 2.75-.05 2.75-.07 2.12c-1.93-1.62-2.91-2.47-3.33-5l.08-2.12.05-2.14c.2-1.74.2-1.74 1.2-2.74"/><path fill="#f2f2f2" d="m1433 945 1 3a337 337 0 0 1-4.5 3.13c-1.5.87-1.5.87-3.5.87l1-5h6z"/><path fill="#a09ea1" d="M1145 943c-4.37 3.28-7.77 3.35-13 3 4.03-2.83 8.16-4.86 13-3"/><path fill="#454449" d="M794 941c-3.26 3.26-6.9 3.05-11.31 3.06L779 944v-1a39 39 0 0 1 15-2"/><path fill="#e3e6ea" d="m1534 938 4 1-2 7-2-1z"/><path fill="#9b9c9c" d="m294 938 9 1v2h-10z"/><path fill="#020206" d="m1148 931 2 1-1 2 3 1c-2.37 1.56-2.37 1.56-5 3l-2-1 1-2h-7v-1h9z"/><path fill="#b2bac2" d="M1534 922h3v8h-3z"/><path fill="#a2a1a3" d="M145 923h7l1 4-7-1z"/><path fill="#646468" d="m816.19 919.94 2.17.02 1.64.04c-3.32 2.37-6.13 3.13-10.19 3.06l-2.17-.02L806 923c3.32-2.37 6.13-3.13 10.19-3.06"/><path fill="#000001" d="M1185 919h9v2l-9 1z"/><path fill="#828184" d="M124 915c4.22.62 7.35 1.84 11 4l1 3c-4.29-1.56-8.1-3.68-12-6z"/><path fill="#a4a3a4" d="M1224 916c-4.98 2.38-9.48 4.43-15 5 3.8-3.56 9.77-7.61 15-5"/><path fill="#9d9d9f" d="M200 910h7v4c-4.75-.87-4.75-.87-7-2z"/><path fill="#929397" d="M8 902c2.06.44 2.06.44 4 1v7h2l-1 4c-1.56-1.62-1.56-1.62-3-4 .31-3.25.31-3.25 1-6l-4-1z"/><path fill="#d2d8dc" d="m1473 902 2 1-8 9c-1.25-2.5-.78-3.41 0-6h4z"/><path fill="#1d1e21" d="M973 902h-3l-1 3-9 1c4.06-3.31 7.74-5.95 13-4"/><path fill="#d5d8db" d="M1466 894h4l-1 5h-2v3h-2z"/><path fill="#010106" d="m1034.25 888.44 2.7.3 2.05.26v1l-3.25.38A19 19 0 0 0 1028 893l1-3-4-1c3.27-1.03 5.86-.96 9.25-.56"/><path fill="#86858a" d="M1466 878h3v8h-3z"/><path fill="#030307" d="m1047 884-1 4-4 1 1-3h-7v-1l4.38-1.06 2.46-.6c2.16-.34 2.16-.34 4.16.66"/><path fill="#8f8f8f" d="m1021 877-1 3h-10l1-2a38 38 0 0 1 10-1"/><path fill="#626265" d="m1019.19 872.94 2.17.02 1.64.04c-3.32 2.37-6.13 3.13-10.19 3.06l-2.17-.02-1.64-.04c3.32-2.37 6.13-3.13 10.19-3.06"/><path fill="#818185" d="M1079 858a40 40 0 0 1-14 4c4.12-2.87 8.97-6.21 14-4"/><path fill="#9a9a9f" d="M1470 850h3v8h-3z"/><path fill="#424145" d="M1158 848q-2.37 1.05-4.75 2.06l-2.67 1.16c-2.73.83-3.95.76-6.58-.22 2.13-2.13 3.04-2.48 5.88-3.19l2.05-.54c2.3-.3 3.88.04 6.07.73"/><path fill="#6c6d74" d="M1474 809h3v8h-3z"/><path fill="#141318" d="M1265 810c-1 2-1 2-3 4-2.7.36-5.3.23-8 0a15 15 0 0 1 5.5-3.69l1.84-.82c1.66-.49 1.66-.49 3.66.51"/><path fill="#444549" d="m20 802 2 1q-.39 1.73-.81 3.44l-.46 1.93L20 810l-3 1v5h-1l-2-7 5-1z"/><path fill="#212026" d="M1223 799h7l-1 3-7 1z"/><path fill="#100f14" d="M1324 786c-.65 1.95-.65 1.95-2 4-2.6.6-2.6.6-5.62.75l-3.04.17-2.34.08a28 28 0 0 1 7.06-3.62l2.1-.8c1.84-.58 1.84-.58 3.84-.58"/><path fill="#8b8a8b" d="m1315 783-1 3-8 1 1-3c2.93-.98 4.96-1.08 8-1"/><path fill="#3f3e41" d="M347 772c3.87.57 6.15 2.39 9 5v2c-4.34-.48-6.2-2.9-9-6z"/><path fill="#010005" d="M725 762h14l-2 3c-3.12.19-3.12.19-6 0v-1l-6-1z"/><path fill="#a782d6" d="m515 756 6 1-2 2 3 2-12-1v-1h5z"/><path fill="#a37cce" d="M462 751c4.81-.21 8.59-.2 13 2v1c-4.81.21-8.59.2-13-2z"/><path fill="#503282" d="M754 751c2.47-.22 4.67-.28 7.13-.19l2 .04 4.87.15v1l-16 2z"/><path fill="#0e0718" d="M440 750c5.75-.12 5.75-.12 8 1v2l3 1c-4.65.32-7.22-.22-11-3z"/><path fill="#49474e" d="M194 748h3c-1.5 3.11-3.2 6.05-5 9l-2-1 1.44-3.44.8-1.93C193 749 193 749 194 748"/><path fill="#767679" d="M1400 736q-2.37 1.3-4.75 2.56l-2.67 1.44c-2.79 1.08-3.85 1.07-6.58 0 3.45-3.17 9.48-6.26 14-4"/><path fill="#7641c7" d="M445 733h1v9h-3c-.2-5.27-.2-5.27 0-7z"/><path fill="#4c4c50" d="m91 724 4 1-8 6-2-1 1-4h4z"/><path fill="#252528" d="m187 715 2 1a367 367 0 0 1-4.31 3.56l-2.43 2L180 723l-3-1a25 25 0 0 1 10-7"/><path fill="#000105" d="M951 714h8l-1 3h-8z"/><path fill="#58348e" d="M941 707h9l-2 3c-1.73.3-1.73.3-3.62.19L941 710z"/><path fill="#000003" d="M979 706c1.73-.3 1.73-.3 3.63-.19l3.37.19v3h-9z"/><path fill="#7843c3" d="M448 697v6l-3 1-2 2c.61-6.63.61-6.63 2.5-8.44 1.5-.56 1.5-.56 2.5-.56"/><path fill="#0b0b0c" d="M1623 663h4c-.51 3.14-1 5.48-3 8l-2-1z"/><path fill="#08080c" d="M363 627c2.4 2.4 2.34 3.33 2.63 6.63l.22 2.47.15 1.9-2 1c-1.26-2.52-1.1-4.31-1.06-7.12l.02-2.76z"/><path fill="#000001" d="M256 618h7l-1 3-6 1z"/><path fill="#b190da" d="M461 609c1.94.31 1.94.31 4 1l1 3h6l-5 4q-.93-1.19-1.87-2.44C463 612 463 612 461 611z"/><path fill="#777779" d="m275 602 2 1-1 5h-5l-1-2 1.94-.31L274 605z"/><path fill="#000001" d="M286 602h8v3h-8zM311 590h8v3h-8z"/><path fill="#a899c5" d="M739 585h8c-1 3-1 3-2.37 4.25C743 590 743 590 740 589v-2z"/><path fill="#000001" d="M319 586h8v3h-8z"/><path fill="#b2aabd" d="M488 585h19v1h-16v2l-3-1z"/><path fill="#a899c4" d="m768.63 579.94 2.47.02 1.9.04-1 2c-2.07.41-2.07.41-4.56.63l-2.5.22-1.94.15-1-2c2.4-1.2 3.95-1.1 6.63-1.06"/><path fill="#50308a" d="m834 574-1 3h-8v-2c3-1.5 5.66-1.06 9-1"/><path fill="#8c79b9" d="M827 570c-3.62 2.17-6.88 2.48-11 3l-3 1v-3q2.37-.55 4.75-1.06l2.67-.6a13 13 0 0 1 6.58.66"/><path fill="#514d62" d="M803 565c-3.26 3.26-6.9 3.05-11.31 3.06L788 568v-1a39 39 0 0 1 15-2"/><path fill="#c9bada" d="M404 566c6.63-.12 6.63-.12 10 1v2c-6.62.13-6.62.13-10-1z"/><path fill="#231b2f" d="M921 535c-3.7 2.66-7.87 4.33-12.44 3.63L907 538c4.84-2.9 8.4-3.38 14-3"/><path fill="#4d4d4f" d="m1727 507 2 1-.04 2.3-.02 3.01-.04 3c.1 2.69.1 2.69 1.1 5.69l-4 1z"/><path fill="#4b2d82" d="M989 518c1.73-.3 1.73-.3 3.63-.19l3.37.19v3h-9z"/><path fill="#02010a" d="m964 515 2 1-2 5-7 1 3-1c1.06-1.94 1.06-1.94 2-4z"/><path fill="#88878b" d="m417 507 1 4 4 1h-2v2l-6-1a20 20 0 0 1 3-6"/><path fill="#0e0719" d="M1011 507c-2.72 2.72-5.22 3.9-9.1 4.13q-2.95 0-5.9-.13v-1c5.13-1.92 9.5-3.42 15-3"/><path fill="#1d1233" d="M1022 506c-4.37 3.28-7.77 3.35-13 3 3.43-3.43 8.36-4.85 13-3"/><path fill="#231643" d="M1084 482c-.68 1.46-.68 1.46-2 3-2.38.51-2.38.51-5.12.69l-2.76.2-2.12.11c2.05-2.05 3.33-2.68 6-3.69l2.13-.82c1.87-.49 1.87-.49 3.87.51"/><path fill="#170f2c" d="m1089 476 2 1c-2.51 3.06-4.9 3.75-8.69 4.69l-3 .76-2.31.55c1.21-2.42 1.86-2.53 4.31-3.5a57 57 0 0 0 6-2.75z"/><path fill="#130b25" d="M1122 464c-4.83 2.74-8.4 4.56-14 4a41 41 0 0 1 6.44-3.69l1.74-.82c2.25-.6 3.63-.2 5.82.51"/><path fill="#231547" d="M1356 455c2.37 1.15 2.95 1.89 3.95 4.38l.74 2.75.76 2.75.55 2.12c-3.31-3.09-5.87-5.58-6.12-10.25z"/><path fill="#000001" d="M1649 434h8v3h-8z"/><path fill="#301f5a" d="M1226 426c2.25.25 2.25.25 4 1l-2.31 1.31c-2.69 1.69-2.69 1.69-4.25 3.44L1222 433c-2.19-.31-2.19-.31-4-1l1.75-.75c2.25-1.25 2.25-1.25 4.25-3.44z"/><path fill="#323037" d="M868 422h4v2l5 1v1h-11l2-1z"/><path fill="#8e9090" d="M355 415h3v8h-3z"/><path fill="#09090f" d="M1596 418c1.73-.3 1.73-.3 3.63-.19l3.37.19v3h-9z"/><path fill="#33215f" d="m1251 416-4 1v2c-2.35 1.43-3.48 2.09-6.25 1.63L1239 420l3-1 1-3c2.96-1.22 5-.97 8 0"/><path fill="#020107" d="M1213 411c-.19 1.81-.19 1.81-1 4-4.18 3-4.18 3-7 3 1.86-4.15 3.17-7 8-7"/><path fill="#221738" d="m1233 404 2 1c-2.7 3.17-6.23 5.31-10 7h-3c1.54-2.82 3.17-3.8 6.06-5.12 3.87-1.8 3.87-1.8 4.94-2.88"/><path fill="#979898" d="M1582 402h4l2 5h5v1c-3.85.36-5.58.28-8.87-1.87L1582 404z"/><path fill="#cdcccd" d="m1587 394 7 2-1 2h-7z"/><path fill="#a1a4a3" d="M359 384h3v8h-3z"/><path fill="#000002" d="m1260 379 2 1c-1.19 2.44-1.19 2.44-3 5-2.69.81-2.69.81-5 1v-3l1.88-.31c2.44-.8 2.96-1.47 4.12-3.69"/><path fill="#e4e4e5" d="M1473 371h8v3h-8z"/><path fill="#6e6e73" d="M1457 371h8v3h-8z"/><path fill="#838287" d="M1418 367h8v3h-8z"/><path fill="#66656a" d="M1410 367h8v3h-8z"/><path fill="#545359" d="M1402 367h8v3h-8z"/><path fill="#cacccb" d="m364 350 2 1v7l-4-1z"/><path fill="#f5f6f5" d="M374 350v6l-4 2v-7c3-1 3-1 4-1"/><path fill="#59585d" d="M479 284h1c.21 4.81.2 8.59-2 13h-1c-.21-4.81-.2-8.59 2-13"/><path fill="#8c8c8f" d="M477 268h1v9h-2l-2 1c.88-7.74.88-7.74 3-10"/><path fill="#37373b" d="M447 260h1a433 433 0 0 1 .1 5.96C448 268 448 268 447 271h-2l-1 2c.66-4.49 1.7-8.67 3-13"/><path fill="#d1d2d1" d="m380 262 2 1v7l-4-1z"/><path fill="#f7f6f7" d="m1318 262 4 1v6l-4-1z"/><path fill="#2c2d2e" d="M395 258h1c.22 3.86-.4 6.5-2 10h-1v-6l-3-1v-2l5 1z"/><path fill="#5e5e62" d="m497 226 1 2c-.74 2.1-1.53 4.1-2.44 6.13l-.73 1.7q-.9 2.1-1.83 4.17h-1c-.74-5.92 1.63-9.25 5-14"/><path fill="#4b4a4e" d="M497 228c1.16 2.87 1 4.02 0 7.02q-.7 1.62-1.44 3.23l-.73 1.68q-.9 2.04-1.83 4.07c-1.16-2.87-1-4.02 0-7.02q.7-1.62 1.44-3.23l.73-1.68q.9-2.04 1.83-4.07"/><path fill="#100f15" d="M919 222c0 3.78-1.05 5.8-3 9-2.19.88-2.19.88-4 1 1.65-3.9 4.24-6.84 7-10"/><path fill="#636266" d="M645 219c5.32.53 9.34 2.45 14 5-2.73 1.07-3.8 1.08-6.58 0l-2.67-1.44-2.7-1.43L645 220z"/><path fill="#000002" d="M453 223h1v9h-3c-.2-5.27-.2-5.27 0-7z"/><path fill="#808082" d="M651 220c4.94.48 9.35 1.25 14 3v2l-5.31-.81-3-.46c-2.82-.76-3.89-1.48-5.69-3.73"/><path fill="#88898b" d="m830 195-1 3-8 1 1-3c2.7-1.35 5-1.06 8-1"/><path fill="#3e3d43" d="m523 190 1 3-2 1-1.44 2.56L519 199h-3a35 35 0 0 1 7-9"/><path fill="#f6f6f7" d="m1282 178 4 1v6l-4-1z"/><path fill="#818283" d="M784 178h11l-1 3c-6.62-.75-6.62-.75-10-3"/><path d="M422 172h3v8h-3z"/><path fill="#8c8b8e" d="M613 149h1v13l-2 1c-1.6-4.95-.28-9.13 1-14"/><path fill="#4b4b4f" d="M931 153q-2.44 1.3-4.87 2.56l-2.75 1.44-2.38 1-2-1c1.2-2.4 1.91-2.63 4.31-3.69l1.8-.82c2.3-.6 3.67-.22 5.89.51"/><path fill="#313035" d="m987 147 2 1-1 5-5 1c0-3 0-3 2-5.19z"/><path fill="#46454b" d="m942 145 2 1c-2.56 2.98-5.38 4.52-9 6-2.37-.31-2.37-.31-4-1 3.13-2.65 6.1-3.79 10-5z"/><path fill="#8d8d90" d="M927 134c1.73-.3 1.73-.3 3.63-.19l3.37.19v3h-9z"/><path fill="#010003" d="M1250 129h4v7l-3-1z"/><path fill="#717275" d="M945 128c-1.72 1.72-4 1.39-6.3 1.68-1.7.32-1.7.32-3.39 1.44-1.31.88-1.31.88-3.5.5q-.9-.3-1.81-.62c5.03-2.69 9.26-4.91 15-3"/><path fill="#a8a9aa" d="M448 111h2v7h-4c.88-5.87.88-5.87 2-7"/><path fill="#48474c" d="M990 110h8v5l-2 1v-4h-7z"/><path fill="#a9aaac" d="M462 102v4h-7l1-3c2.22-1.11 3.56-1.08 6-1"/><path fill="#333239" d="M1012 96c-1.51 1.51-3.18 1.56-5.25 2l-2.45.52q-3.64.76-7.3 1.48c2.36-3.25 5.26-3.82 8.96-4.77 2.27-.26 3.88.1 6.04.77"/><path d="M500 90h8v3h-8z"/><path fill="#7a7f7e" d="M761 75h8v3h-8z"/><path fill="#a1a0a3" d="M728 71h8v3h-8z"/><path fill="#68676c" d="M720 71h8v3h-8z"/><path fill="#818082" d="M688 67h8v3h-8z"/><path fill="#c6c7c9" d="M526 67v3h-7v-3c3.01-.93 3.87-1.04 7 0"/><path fill="#7f8083" d="M656 63h8v3h-8z"/><path fill="#56585a" d="M648 63h8v3h-8z"/><path fill="#c3c5c6" d="M542 63v3h-7v-3c3.01-.93 3.87-1.04 7 0"/><path fill="#bcbbbd" d="M616 59h8v3h-8z"/><path fill="#d8d9d8" d="M566 59v3h-7v-3c3.01-.93 3.87-1.04 7 0"/><path d="M943 54h8v3h-8z"/><path fill="#0b0a10" d="M972 46h7v3c-4.75 1.13-4.75 1.13-7 0z"/><path fill="#c0bfc1" d="m1286.19 1824.94 2.17.02 1.64.04v1l-3.31.31c-3.24.45-4.52 1.2-6.69 3.69h-3c.81-1.94.81-1.94 2-4 2.75-.92 4.36-1.1 7.19-1.06"/><path fill="#040405" d="M391 1767h6l1 5c-3 0-3 0-5.19-1.94C391 1768 391 1768 391 1767"/><path fill="#45444e" d="M495 1764h10l-1 3-9-1z"/><path fill="#06080d" d="M1376 1759h2v5l-5 2v-3h2z"/><path fill="#7b777c" d="m1389 1756 2 1c-.48 4.31-1.75 6.17-5 9q.17-1.94.38-3.87l.2-2.18c.42-1.95.42-1.95 2.42-3.95"/><path fill="#72727b" d="m466 1754 4 1 .38 1.94.62 2.06 2 1-9-1 4-2-2-1z"/><path fill="#8d8d96" d="m446 1746 8 1v3c-5.75.13-5.75.13-8-1z"/><path fill="#000001" d="m362 1713 3 1v7h-3z"/><path fill="#84838b" d="M1300 1700h2l1 7-1-4h-3v6l-4-1h3l-.12-2.87c.12-3.13.12-3.13 2.12-5.13"/><path fill="#6c6b75" d="M1201 1642h10v2c-3.6 1.2-6.23 1.07-10 1z"/><path fill="#010106" d="m1218.81 1629.94 1.19 1.06c-2.57 2.57-4.48 2.54-8 3v-4c4.55-1.23 4.55-1.23 6.81-.06"/><path fill="#04050a" d="m457 1606 1 3 3 1-2 2 2 6c-3-1.75-3.88-2.58-4.81-6-.19-3-.19-3 .81-6"/><path fill="#4c4b53" d="m439 1592 3 1v7h-3z"/><path fill="#31313c" d="m541.38 1591.19 1.62.81-1.87.25c-2.13.75-2.13.75-3.38 2.81L537 1597l-4-1c5.25-5.06 5.25-5.06 8.38-4.81"/><path fill="#1e1e27" d="M597 1560c-5.42 4.06-5.42 4.06-8.37 3.75L587 1563c3.13-3.6 5.4-4.53 10-3"/><path fill="#73727c" d="m1308 1490 9 2-2 1c-.62 2.06-.62 2.06-1 4v-3h-6z"/><path fill="#1b1c24" d="m721 1476 2 1c-2.23 3.35-3.26 4.82-7.25 5.81l-2.75.19c1.63-3.12 4-4.3 7-6z"/><path fill="#030208" d="M1283 1475h3v3h2v2h-8c1.31-2.5 1.31-2.5 3-5"/><path fill="#000001" d="M1446 1422h5v4h-6z"/><path fill="#ab5327" d="M1399 1393h-2v2l-9 1c3.73-3.73 5.95-4.68 11-3"/><path fill="#e24705" d="M1392 1386h7c-1.19 2-1.19 2-3 4-2.69.25-2.69.25-5 0z"/><path fill="#760f01" d="M1417 1368h2v2h2v4h-4l-2-4h2z"/><path fill="#d36632" d="m1349 1366 8 8-1 2a198 198 0 0 1-3.5-2.75l-1.97-1.55c-1.53-1.7-1.53-1.7-1.75-3.9z"/><path fill="#000001" d="M1500 1365h2v8h-3c-.1-5.37-.1-5.37 0-7z"/><path fill="#181820" d="m897 1365-9 6-2-1c1.38-1.5 1.38-1.5 3-3h2v-2c2-1 2-1 6 0"/><path fill="#4f4f54" d="M1513 1339h3l1 4-3 1v2h-2z"/><path fill="#750a01" d="M1458 1323h1l.38 2.44.62 2.56 2 1-1 3h-1l-1 5h-1z"/><path fill="#050408" d="M979 1288c2.88-.12 2.88-.12 6 0l2 2h-5v2l3 1h-5z"/><path fill="#cdc7b9" d="M1341 1278c1.2 3.62.54 4.64-1 8h-2c-.19-2.87-.19-2.87 0-6 1.5-1.37 1.5-1.37 3-2"/><path fill="#573a25" d="m1148 1275 14 2v1h-13z"/><path fill="#0c0e13" d="M350 1271c.98 3.05.98 4.95 0 8l-4-1v-4l3-1z"/><path fill="#1b1b24" d="m849 1244 2 1a54 54 0 0 1-11 9c1.9-4.44 4.87-7.52 9-10"/><path fill="#040507" d="M1430 1242h7l1 4c-5.66-.62-5.66-.62-7.37-2.56z"/><path fill="#e5eaeb" d="M1500 1218h2v8h-3c-.1-5.37-.1-5.37 0-7z"/><path fill="#0e0f16" d="M918 1213q-2.37 1.3-4.75 2.56l-2.67 1.44c-2.79 1.08-3.85 1.07-6.58 0l7-2v-2c3.13-1.04 3.99-.93 7 0"/><path fill="#05060b" d="m1159.25 1195.38 2.7.33 2.05.29c-2.01 1.8-2.94 2-5.7 1.98l-2.99-.36-3-.33-2.31-.29c3.31-2.03 5.43-2.13 9.25-1.62"/><path fill="#474451" d="M488 1180c6.65 1.48 6.65 1.48 8.44 4.13l.56 1.87-1.81-.5c-2.19-.5-2.19-.5-5.19-.5l1-3-3-1z"/><path fill="#bdc5cc" d="m1585 1173 4 2c-2.66 3.74-4.58 5.65-9 7l2-4h3z"/><path fill="#909fa9" d="m1584 1162 3 1v4l2 1-3 3v-4l-3-1z"/><path fill="#83929d" d="M1591 1156h3v4l-4 1zm-2 5h1v5h-2z"/><path fill="#878591" d="m1278 1159 10 1v2h-10z"/><path fill="#9aa5ae" d="m1598 1154 1 2-2 1 1 6h-6l-1 3v-4l3-1q1.05-2.99 2-6z"/><path fill="#82909a" d="M1596 1146h2v8h-3c-.1-5.37-.1-5.37 0-7z"/><path fill="#808d99" d="M1604 1122c1.86 3.13 2.2 5.37 2 9h-3q-.05-1.96-.06-3.94l-.04-2.21c.1-1.85.1-1.85 1.1-2.85"/><path fill="#3d3c40" d="M1438 1126h3a43 43 0 0 1-3 10h-1l-.06-3.87-.04-2.18c.1-1.95.1-1.95 1.1-3.95"/><path fill="#2c2b35" d="M927 1121c4.76-.26 4.76-.26 7 0 1.44 1.5 1.44 1.5 2 3-2.35 1.43-3.48 2.09-6.25 1.63L928 1125l2-2z"/><path fill="#818f9a" d="M1603 1092h3v8l-3-1z"/><path fill="#36353f" d="M775 1097c1 3 1 3-.25 5.69L773 1105h-3c.35-3.24.56-4.62 3.06-6.81z"/><path fill="#ebecee" d="M1463 1078h7v4l-6-1z"/><path fill="#262730" d="m855 1073 7 6-2 1c-3.1-1.55-5.37-2.9-7-6z"/><path fill="#b8c2c9" d="M1474 1063c4.05 2.92 6.39 5.74 9 10-2.49-.39-3.72-.71-5.48-2.56l-1.27-1.88-1.3-1.87c-.95-1.69-.95-1.69-.95-3.69"/><path fill="#d4d4d2" d="M1175 1025c-2.58 2.04-4.98 2.54-8.19 3.13l-2.73.5-2.08.37v-3l5.38-1.06 3.02-.6c2.6-.34 2.6-.34 4.6.66"/><path fill="#d5d4d4" d="M1413 1021q.4.67.81 1.38c1.19 1.62 1.19 1.62 2.82 2.37 1.37 1.25 1.37 1.25 1.56 2.93 0 1.77-.1 3.55-.19 5.32-2.94-1.47-3.91-4.02-5-7-.19-2.87-.19-2.87 0-5"/><path fill="#121215" d="m1026 1022 1.56.88c3.09 1.42 6.16 2.24 9.44 3.12-2.4 1.2-3.95 1.1-6.62 1.06l-2.48-.02-1.9-.04-1-2z"/><path fill="#08080d" d="M1400 1012c2 2 2 2 2.31 4.38-.36 3.07-1.4 4.25-3.31 6.62l-.1-7.71c.1-2.29.1-2.29 1.1-3.29"/><path fill="#dfdfe0" d="M1024 1015h8v3h-7z"/><path fill="#111018" d="M1272 1010c.58 1.88.58 1.88 1 4-1.31 1.94-1.31 1.94-3 4a88 88 0 0 0-2 5c-1.02-2.65-1.13-3.7 0-6.36l1.44-2.39 1.43-2.42z"/><path fill="#b5b8b8" d="M179 1009c7.43-.29 7.43-.29 11 2v3h-3l-1-2c-1.63-.63-1.63-.63-3.56-1.12l-1.94-.51-1.5-.37z"/><path fill="#939296" d="M1408 1004h1l2 16c-3-3-3-3-3.3-6.14q.04-1.77.11-3.55l.04-1.84z"/><path fill="#313234" d="M972 991c7.1 2.21 7.1 2.21 9 4v3h-3l-1-4-5-1z"/><path fill="#010102" d="M970 994h7v4l-6-1z"/><path fill="#cdcbcc" d="M999 986v2l-10 1v-2c3.6-1.2 6.23-1.07 10-1"/><path fill="#cac8c8" d="m1062 970-1 3h-9c1-2 1-2 2.75-2.62 2.45-.41 4.77-.45 7.25-.38"/><path fill="#b0afaf" d="m1098.85 961.9 2.21.04 2.23.02 1.71.04-1 3c-2.88.13-2.88.13-6 0l-2-2c1-1 1-1 2.85-1.1"/><path fill="#c1c1c0" d="M1234 960c.37 5.42.37 5.42-1.5 8.38L1231 970l-1-9c3-1 3-1 4-1"/><path fill="#959495" d="M649 956v2h-11l1-2c6.2-1.26 6.2-1.26 10 0"/><path fill="#eaeaeb" d="m1426 947 1 2-1 3 3-1v3h-3l-1 3v-3h-2c1.88-5.87 1.88-5.87 3-7"/><path fill="#17171b" d="M190 947c4.61.55 8.64 1.4 13 3-3.92 1.41-7.01.89-11 0-1.44-1.56-1.44-1.56-2-3"/><path fill="#acadae" d="m1149.85 945.9 2.21.04 2.23.02 1.71.04-1 3c-2.88.13-2.88.13-6 0l-2-2c1-1 1-1 2.85-1.1"/><path fill="#dadee2" d="M1528 932h2c-.76 3.69-.76 3.69-2 5.31-1.61 2.72-1.65 5.57-2 8.69h-1c-.38-5.6.1-9.16 3-14"/><path fill="#27262e" d="M1314 926c3 4 3 4 2.86 6.58l-.67 2.67-.65 2.7-.54 2.05h-1z"/><path fill="#999a9b" d="m261 930 5 1-1 3h-13v-1l10-1z"/><path fill="#bac1c8" d="M1530 924v4l-4 1-1 7h-1c.84-12 .84-12 6-12"/><path fill="#000001" d="M1174 923h8l-1 3h-7z"/><path fill="#000004" d="M1245 908h1q.33 3.44.63 6.88l.19 1.97c.14 1.71.17 3.43.18 5.15l-2 2-2-5h1z"/><path fill="#302f35" d="M870 903h7v3h-8z"/><path fill="#6e7272" d="M4 891h4v10H7l-.25-2.87C6 895 6 895 3.94 893.63L2 893z"/><path fill="#000003" d="M71 866h3v7l-3 1z"/><path fill="#f7f7f9" d="m1526 866 4 1v6l-4-1z"/><path fill="#909192" d="M1078 864h9l-1 2c-2.07.41-2.07.41-4.56.63l-2.5.22-1.94.15z"/><path fill="#000001" d="M1131 858c1.73-.3 1.73-.3 3.63-.19l3.37.19-1 3h-8z"/><path fill="#858485" d="M1132 850v3l-4 1v-2h-7v-1c3.72-.7 7.21-1.11 11-1"/><path fill="#000003" d="m1186 842-1 3-8 1v-3c3.07-.91 5.8-1.09 9-1"/><path fill="#292a2d" d="m1460 840 5 1-1.94 1.63c-2.45 2.81-2.73 4.7-3.06 8.37h-1a433 433 0 0 1-.1-5.96c.1-2.04.1-2.04 1.1-5.04"/><path fill="#6f6d70" d="m1214 826-4 2-3 1.69c-3 1.31-3 1.31-5.37 1L1200 830c4.9-3.13 8.21-4.16 14-4"/><path fill="#a3a3a4" d="M83 821h2v8h-3c-.1-5.37-.1-5.37 0-7z"/><path fill="#868687" d="M1217 819h7v2l-9 2z"/><path fill="#d6d9db" d="M1494 815h7l1 3h-8z"/><path fill="#7a7a7b" d="m1231.25 810.75 1.75.25v3l-8 1c3.45-3.94 3.45-3.94 6.25-4.25"/><path fill="#8b8b8d" d="M1259 803c1.73-.3 1.73-.3 3.63-.19l3.37.19-1 3h-8z"/><path fill="#25232a" d="m1221 800 2 1-4 4v-2l-10 2 1-3 2.15-.18 2.79-.26 2.77-.24C1220 801 1220 801 1221 800"/><path fill="#636265" d="M1294 796q-2.68 1.3-5.37 2.56l-3.03 1.44-2.6 1-2-1c8.04-5.54 8.04-5.54 13-4"/><path fill="#818385" d="m1261 795-3 5-1-2h-5c1-2 1-2 3.94-3.19 3.06-.81 3.06-.81 5.06.19"/><path fill="#28262c" d="M1013 785c-2.67 2.67-5.33 3.35-9 4-2.44-.37-2.44-.37-4-1h3v-2c3.6-1.2 6.23-1.07 10-1"/><path fill="#010104" d="m110 770 3 1v2l-2 1-1 3h-3v-4h2z"/><path fill="#010008" d="M494 763h4v2h7v1h-16v-1l5-1z"/><path fill="#000001" d="M1518 746h5v4h-6z"/><path fill="#7f45cc" d="m441 736 1 4-6 2v-2l-2-2z"/><path fill="#ad83df" d="m380 715 12 4v2c-7.02-.19-7.02-.19-9.42-2.18-1.08-1.38-1.08-1.38-2.58-3.82"/><path fill="#010005" d="M1038 686h8l-1 3h-7z"/><path fill="#4d2e84" d="M1019 683h8l-1 3h-7z"/><path fill="#1b1a20" d="m1450 677-4.87 3.06-2.75 1.73L1440 683l-2-1 2.81-2.44 1.58-1.37c2.88-2.14 4.2-2.32 7.61-1.19"/><path fill="#1b1b20" d="M1241 678q-2.68 1.3-5.37 2.56l-3.03 1.44-2.6 1-2-1c4.63-4 7.07-5.62 13-4"/><path fill="#000001" d="m177 669-4 1v3h-6v-3c3.4-.78 6.5-1.1 10-1"/><path fill="#121218" d="M1525 660v5l-6 1v-3h2v-2c3-1 3-1 4-1"/><path fill="#1c1b21" d="m1404 655 2 1-1.5 1.25c-1.5 1.75-1.5 1.75-1.69 3.94l.19 1.81-1.81-.5c-2.19-.5-2.19-.5-5.19-.5z"/><path fill="#1d1b21" d="m1301 642 2 1q-2.12 1.48-4.25 2.94l-2.4 1.65A17 17 0 0 1 1290 650c2.46-4.31 6.41-6.41 11-8"/><path fill="#757879" d="M252 615c1.13 3.75 1.13 3.75 0 6-1.63.63-1.63.63-3.56 1.13l-1.94.5-1.5.37v-5h6z"/><path fill="#2c1559" d="M1039 614c1.46.71 1.46.71 3 2 .51 2.16.51 2.16.69 4.63l.2 2.47.11 1.9c-3.25-3.53-3.56-6.38-4-11"/><path fill="#0a0b10" d="M320 590h7l-2 4-5-1z"/><path fill="#06060c" d="M375 575c2.66 2.66 2.96 5.36 3 9-.94 2.44-.94 2.44-2 4-1-3.01-1.1-5.04-1.06-8.19l.02-2.73z"/><path fill="#998db4" d="m811 566-2 1v5l-3-1v-2h-2l1-3c2.5-1.25 3.41-.78 6 0"/><path fill="#06070c" d="m469.63 566.94 3.03.02 2.34.04v1h-7v2h-6v-2c2.64-1.32 4.68-1.1 7.63-1.06"/><path fill="#040208" d="M454 568h2l1 4 5 1c-2.17.95-3.56 1.14-5.82.4q-2.62-1.13-5.18-2.4l3-1z"/><path fill="#06060c" d="M741 563h14l1 3h-5v-1l-10-1z"/><path fill="#2d1851" d="M1288 562v4l-9 1c6.13-5 6.13-5 9-5"/><path fill="#30195c" d="M1018 556c1.46.71 1.46.71 3 2 .51 2.16.51 2.16.69 4.63l.2 2.47.11 1.9h-2c-2.29-7.43-2.29-7.43-2-11"/><path fill="#000001" d="M1708 557h2v8h-3c-.1-5.37-.1-5.37 0-7z"/><path d="M401 555h9v3c-2.81.19-2.81.19-6 0z"/><path fill="#0e0d13" d="m1419 543 1 3c-2.2 3.9-4.19 6.55-8 9a92 92 0 0 1 7-12"/><path fill="#44227d" d="M912 546h8v2c-2.7 1.35-5 1.07-8 1z"/><path fill="#100f15" d="m1423 537 3 1-4 7h-2c.51-3.14 1-5.48 3-8"/><path fill="#342057" d="m1332 524 1 3 4-2v2l3 1-1.94.88C1336 530 1336 530 1335 532v-4h-3z"/><path d="M928 527h7v3h-8z"/><path fill="#341f5c" d="M1318 515c3.94 4.18 3.94 4.18 4.25 8.31L1322 526c-1.46-.71-1.46-.71-3-2-.51-2.16-.51-2.16-.69-4.62l-.2-2.48z"/><path fill="#492d78" d="M987 519c-.71 1.49-.71 1.49-2 3-2.16.3-2.16.3-4.62.19l-2.48-.08-1.9-.11c2.03-2.03 2.85-2.44 5.5-3.19l1.84-.54C985 518 985 518 987 519"/><path fill="#2c1955" d="m1090 482-2 4-6-1c2.47-3.12 4.1-3.32 8-3"/><path fill="#2c2b32" d="M961 476c-1.76 2.18-2.7 2.94-5.48 3.51l-2.7.18-2.74.2-2.08.11a30 30 0 0 1 7.06-3.69l2.1-.82C959 475 959 475 961 476"/><path fill="#2f1b58" d="m1115.69 469.75 2.31.25-2 4h-6c2.34-3.94 2.34-3.94 5.69-4.25"/><path fill="#0c0618" d="M1108 467v2l-4.81 2-2.71 1.13c-2.48.87-2.48.87-5.48.87 1.33-2.66 2.68-2.95 5.38-4.12l2.4-1.08c2.22-.8 2.22-.8 5.22-.8"/><path fill="#0f0f15" d="M1413 462c2.23.37 3.63.63 5.25 2.25 1 2.31.88 4.26.75 6.75-2.4-2.88-4.47-5.56-6-9"/><path fill="#2a1851" d="m1156.19 451.94 2.17.59 1.64.47v1c-6.62 2-6.62 2-10 2l2-3-3-1c3.22-1.07 4.05-.94 7.19-.06"/><path fill="#09090e" d="M1629 430h7v3h-8z"/><path fill="#010005" d="M1199 419v2l-6 1c2.63-3 2.63-3 6-3m-7 3 1 3-4 1 1-3z"/><path fill="#2f1f4f" d="m1216.73 416.8 5.27.2-1 2h-3v2l-3 1v-2l-2-1c2-2 2-2 3.73-2.2"/><path fill="#372360" d="m1253 393 2 1-1 2h2v4c-2.94-.37-2.94-.37-6-1l-1-2 3-1z"/><path fill="#585b5b" d="M371 388h1c.38 5.6-.1 9.16-3 14h-2c-.19-1.81-.19-1.81 0-4l1.44-1.31c2.41-2.61 2.23-5.2 2.56-8.69"/><path fill="#030108" d="m1270 372 4 2-2.81 2.44-1.58 1.37C1268 379 1268 379 1265 380v-3h4z"/><path fill="#8c8c8f" d="M459 356h1l.44 3.94.24 2.21C461 364 461 364 462 365q.1 2.02.06 4.06l-.02 2.23L462 373h-1l-1-5h-1z"/><path fill="#090a0e" d="M1350 343h1c.96 3.67 1.28 6-.44 9.38L1349 355l-2-1 1-5h2z"/><path fill="#807f84" d="M1348 334h2l.38 1.94.62 2.06 2 1v2c-1.94.56-1.94.56-4 1-1-1-1-1-1.1-2.63z"/><path fill="#c7c9c9" d="M378 328h1c.37 6.35.37 6.35-1.37 9.44C376 339 376 339 374 339c.78-3.9 2.39-7.39 4-11"/><path fill="#d9dbda" d="m376 280 2 1v7l-4-1z"/><path fill="#323137" d="m882 264 .31 1.94.69 2.06 3 1-2 5h-2a18 18 0 0 1 0-10"/><path fill="#313036" d="m898 239 4 5-4 4v-3l-3 1q.45-.9.94-1.81C897 242 897 242 898 239"/><path fill="#8b8b8d" d="m759 219-1 3-9 1 1-3a34 34 0 0 1 9-1"/><path fill="#35343a" d="m633 217 6 1 1 3-6 1z"/><path fill="#0a0b10" d="M1283 210h3v8l-3-1z"/><path fill="#313035" d="m929 204 1 3c-1.19 2.19-1.19 2.19-3 4-2.19.25-2.19.25-4 0v-3c2.44-1.62 2.44-1.62 5-3z"/><path fill="#0a0b10" d="M1280 200h2v8l-2 1c-1.37-2.73-1.13-4.98-1-8z"/><path fill="#414046" d="M524 189h4l-1 4h-3zm-2 4 2 1-3 3z"/><path fill="#79787e" d="m1285 190 .81 1.88A8.7 8.7 0 0 0 1290 196l-5 2c-1-1-1-1-1.12-4 .12-3 .12-3 1.12-4"/><path fill="#808082" d="m857 184 2 1c-3.24 3.24-7.62 3.99-12 5l2-5 5.37.1C856 185 856 185 857 184"/><path fill="#010005" d="M859 143h6l-1 3-7 1z"/><path fill="#848384" d="M523 139h6c-.68 1.46-.68 1.46-2 3-2.38.51-2.38.51-5.12.69l-2.76.2-2.12.11 1-2h5z"/><path fill="#b2b4b4" d="M442 130h1c.58 4.96-1.14 7.16-4 11l-4-1 1.28-1.17 1.66-1.58 1.65-1.55c1.57-1.9 2-3.3 2.41-5.7"/><path fill="#57565b" d="M1251 123c3.41 3.14 5.68 5.43 7 10v3c-1.5-1.25-1.5-1.25-3-3-.19-2.19-.19-2.19 0-4l-4-2z"/><path fill="#000002" d="M534 123h7l-1 3-7 1z"/><path fill="#8e8d92" d="M1240 98h2l.38 1.94.62 2.06 2 1v2c-1.94.56-1.94.56-4 1-1-1-1-1-1.1-2.63z"/><path fill="#35343b" d="m994 100 2 1q-1.61 1.5-3.25 3l-1.83 1.69c-2.2 1.5-3.31 1.59-5.92 1.31v-2l2.44-.87C990 103 990 103 991 101z"/><path fill="#000001" d="M484 98h7v3l-7 1z"/><path fill="#0b0c11" d="M1222 75h1v5l2 1-2 1c-.62 2.56-.62 2.56-1 5l-3-2 1-4h2z"/><path fill="#0a090f" d="M924 66h7l-2 4-5-1z"/><path fill="#323135" d="m921 58 7 2v1h-6v3l-6 1 1.38-.69c2.19-1.77 2.72-3.68 3.62-6.31"/><path fill="#0a0a0f" d="M993 38h8l-1 3h-7z"/><path fill="#09090d" d="M1159 26h7v3h-8z"/><path fill="#08070b" d="M1027 22c1.43 2.35 2.09 3.48 1.63 6.25L1028 30h-2l1-4h-7v-1l2.94-.94C1026 23 1026 23 1027 22"/><path fill="#7d7d7e" d="M1022 18h1q.06 2.5 0 5c-1 1-1 1-2.63 1.1L1015 24l-1-2 1.5-.37 1.94-.5 1.93-.5C1021 20 1021 20 1022 18"/><path fill="#a6a5a7" d="M1178 14c2.25.25 2.25.25 4 1v3h-8c1.75-2.06 1.75-2.06 4-4"/><path fill="#d8d9d9" d="M1282 1834h7v3h-7z"/><path fill="#707173" d="M487 1824c7.7-.69 7.7-.69 11 1.88 2 2.12 2 2.12 2 4.12h-3l-1-3c-2.07-.73-2.07-.73-4.56-1.19l-2.5-.48-1.94-.33z"/><path fill="#000001" d="M1339 1798v4h-6v-3c2.22-1.11 3.56-1.08 6-1M430 1798l5 1v3h-6z"/><path fill="#08090e" d="M1374 1765v5l-6 2-1-2 4.59-3.9c1.41-1.1 1.41-1.1 2.41-1.1"/><path fill="#201f26" d="m427 1741 4.38 1.81 2.46 1.02c2.2 1.19 3.02 2 4.16 4.17q-2.48-.6-4.94-1.25l-2.77-.7c-2.29-1.05-2.29-1.05-3.1-3.16z"/><path fill="#76767d" d="m1276 1734 1 2-1 2h3v2c-2.2 1.9-2.96 2-6 2q.45-1.73.94-3.44l.52-1.93c.54-1.63.54-1.63 1.54-2.63"/><path fill="#3b3a43" d="M945 1708h1v12l-4 2z"/><path fill="#000003" d="M391 1693c1.5.56 1.5.56 3 2 .28 2.39.13 4.58 0 7h-2c-1.5-3-1.06-5.66-1-9"/><path fill="#8a8891" d="M1303 1694h3v7h-3z"/><path fill="#616069" d="M1311 1689c.88 2.47 1.13 3.64.22 6.14l-1.16 2.11-1.15 2.14-.91 1.61c-1.12-3.75-.73-5.45 1-9 1.13-1.81 1.13-1.81 2-3"/><path fill="#5f5f64" d="M1419 1682h3v4l-3 1c-1.19 1.56-1.19 1.56-2 3-.12-2.87-.12-2.87 0-6z"/><path fill="#47464f" d="M939 1679h4l1 7h-2v-2h-2z"/><path fill="#84828d" d="M1283 1652h2v9l-2 1a565 565 0 0 1-.88-6.93l-.12-2.07z"/><path fill="#85848c" d="M424 1625h1q.3 3.18.56 6.38l.17 1.82c.38 4.57.38 4.57-.73 6.8h-2z"/><path fill="#2e2f39" d="M617 1544c-2.38 2.75-4.62 3.76-8 5q-2.01.97-4 2c5.6-7.35 5.6-7.35 12-7"/><path fill="#807a7f" d="m1485 1398 4 1-6 7-1-3c1.44-2.69 1.44-2.69 3-5"/><path fill="#c7c0b3" d="M1338 1362c3.67 3.08 4.57 5.25 5 10l-2 1c-3.44-7.3-3.44-7.3-3-11"/><path fill="#58381f" d="m1163 1351 9 1v3c-3.12-.49-6-1-9-2z"/><path fill="#eeeeef" d="M1516 1347h2v6l-4 1c-.12-2.37-.12-2.37 0-5z"/><path fill="#492f1c" d="M1148 1348q2.73.63 5.44 1.31l3.06.74 2.5.95 1 3-5-1v-2l-2.94-.37-3.06-.63z"/><path fill="#710" d="M1403 1347h4v3h-2l1 7-1-4h-2z"/><path fill="#d2b7a3" d="M1334 1340c1.9 2.46 2.51 4.67 3.13 7.69l.5 2.45.37 1.86-3-1a18 18 0 0 1-1.06-7.19l.02-2.17z"/><path fill="#363534" d="M1294 1341c6.1 9.07 6.1 9.07 5.75 12.44l-.75 1.56v-3l-3-1c-1.43-3.39-2.28-6.32-2-10"/><path fill="#1a1a21" d="m947 1324 3 1c.13 2.38.13 2.38 0 5l-2 2c-2.12-.37-2.12-.37-4-1l4-2z"/><path fill="#573a26" d="M1082 1319h2l.81 1.94c1.19 2.06 1.19 2.06 4.19 3.06v2l-6-1z"/><path fill="#0c0b0e" d="m1055 1313 2 1v6h-6l-1-2h5z"/><path fill="#e0dfe1" d="M1517 1292c5.23 7.24 5.23 7.24 4.69 11.38l-.69 1.62c-2.94-4.43-4.54-7.5-4-13"/><path fill="#231710" d="M985 1288c4.67.56 7.94 1.78 12 4-2.61.97-3.84 1.05-6.56.25A16 16 0 0 1 985 1288"/><path fill="#2e2d36" d="M849 1285h6l1 2c-.69 1.5-.69 1.5-2 3-2.62.19-2.62.19-5 0l3-4z"/><path fill="#967155" d="M1256 1268h2v7l-3 1c-.1-5.37-.1-5.37 0-7z"/><path fill="#3a2a1d" d="M1061 1255c3 3 3 3 3.07 6.14a131 131 0 0 1-.38 3.55l-.19 1.84q-.23 2.24-.5 4.47h-1z"/><path fill="#2a1f1b" d="M1270 1253c.75 1.56.75 1.56 1 4a52 52 0 0 1-4 7h-2a41 41 0 0 1 5-11"/><path fill="#54341e" d="M1016 1250h5l1 3c-1 1-1 1-3.72 1.1l-3.34-.04-3.35-.02-2.59-.04v-1h10v-2z"/><path fill="#d0c0ae" d="m1385 1237 9 1c-2.2 2.2-3.18 2.46-6.12 3.13l-2.2.5-1.68.37v-2h4v-2z"/><path fill="#b7b8b9" d="M1485 1229q1.73 1.43 3.44 2.88l1.93 1.61c1.63 1.51 1.63 1.51 2.63 3.51-2.31-.19-2.31-.19-5-1-3-4.03-3-4.03-3-7"/><path d="M1415 1230h5l1 4h-5z"/><path fill="#bdc8cf" d="M1513 1214h7l-3 5 3 1c-2.37-.19-2.37-.19-5-1-1.31-2.56-1.31-2.56-2-5"/><path fill="#595863" d="M436 1207h1l1 12h-3c-.18-8.45-.18-8.45 1-12"/><path fill="#0f0c10" d="M1299 1209h10v6l-2 1-1-5-7-1z"/><path fill="#c4c4c5" d="M1478 1207v3h-7l1-3c2.5-1.25 3.41-.78 6 0"/><path fill="#d0d5db" d="M1575 1194h7l-1 4-5-1z"/><path fill="#fbfcfd" d="m1578 1187 1 3h-2v4l-5-1z"/><path fill="#9fabb5" d="M1567 1184v3l-2 1-1 3-5-1v-2l1.5-.62 1.94-.82 1.93-.8c1.63-.76 1.63-.76 2.63-1.76"/><path fill="#a9a9aa" d="m1446 1186 4 1-1 3h-7v-2h4z"/><path fill="#44434d" d="M436 1161c2 2 2 2 2.13 4.63L438 1168l-2-1-1 7h-1c-.21-4.81-.2-8.59 2-13"/><path fill="#a8b3b7" d="M1538 1134q3-.06 6 0c1 1 1 1 1.1 2.85l-.04 2.21-.02 2.23-.04 1.71h-1l-1-6h-5z"/><path fill="#d5dbde" d="M1518 1106c2.9 1.1 5.5 2.16 8 4l-1 4-3.5-3.44-1.97-1.93C1518 1107 1518 1107 1518 1106"/><path fill="#0f1017" d="m899 1103 5 1v2l7 2c-2.79 1.03-3.87 1.05-6.75.06L902 1107v-2l-3-1z"/><path fill="#bec4ca" d="m1508 1094 6 2 1 5 3 1-1 3c-1.94-1.19-1.94-1.19-4-3l-.5-2.56-.5-2.44c-2.06-1.31-2.06-1.31-4-2z"/><path fill="#18181f" d="M408 1071h2c.31 2.19.31 2.19 0 5-1.5 1.95-3.13 3.37-5 5a43 43 0 0 1 3-10"/><path fill="#98a4ad" d="M1565 1052c2.58 2.93 2.58 6.26 3 10l-3-1c-.84-3.38-1.1-5.67 0-9"/><path fill="#0c0c10" d="m1430 1054 4 4-3 1-1 2h-3l1-4h2z"/><path fill="#97a2ac" d="M1576 1046a16 16 0 0 1 2 3q-.86-.45-1.75-.94c-2.1-.99-4-1.58-6.25-2.06l-1 3-1-3-3-1c3.8-1.52 7.4-.74 11 1"/><path fill="#35363a" d="m315 1036 9 1-1 4a40 40 0 0 1-8-5"/><path fill="#2f2e37" d="M1264 1030c.93 2.55 1.09 3.74.22 6.36l-1.16 2.39-1.15 2.42-.91 1.83h-1c-.54-5.5 1.06-8.57 4-13"/><path fill="#e0e0de" d="m1147 1027 4 1h-3v2h9v1h-13z"/><path fill="#818283" d="M1181 1024c-7.24 5.23-7.24 5.23-11.37 4.69l-1.63-.69c4.43-2.94 7.5-4.54 13-4"/><path fill="#fcfcfc" d="M206 1018c5.66.62 5.66.62 7.38 2.56l.62 1.44h-7z"/><path fill="#d7d6d6" d="m1202 996 1 2 3 1h-3v3h-5v-3l4-1z"/><path fill="#676470" d="M1316 999h2v11l-3-2z"/><path fill="#121216" d="M1233 970h1v6l3 1-8 2 .88-1.69A97 97 0 0 0 1233 970"/><path fill="#c5c4c3" d="M1089 962v3h-8v-2c2.7-1.35 5-1.07 8-1"/><path fill="#3d3c3f" d="M1090 957c-7.3 3.44-7.3 3.44-11 3v-2c7.43-2.43 7.43-2.43 11-1"/><path fill="#adacae" d="M1124 954h8l-2 4-6-2z"/><path fill="#000001" d="M205 954h8v3c-5.75.13-5.75.13-8-1z"/><path fill="#c8c7c7" d="M1217 946c0 3 0 3-1.02 4.35l-1.36 1.27-1.33 1.3C1212 954 1212 954 1210 955l2-4 .94-2.19C1214 947 1214 947 1217 946"/><path fill="#bfbebf" d="m1166 938-1 3h-8c2.61-3.48 4.83-3.28 9-3"/><path fill="#000001" d="M821.7 937.8q2.64.03 5.3.2v2c-3 1.5-5.66 1.06-9 1 .8-2.12 1.4-2.93 3.7-3.2"/><path fill="#acacad" d="M1170 938h8l-1 3h-6z"/><path fill="#aaaaac" d="M1182 934h9l-1 3c-5.37-.59-5.37-.59-7-1z"/><path fill="#9c9d9e" d="M273 934h7v3h-8z"/><path fill="#8f9091" d="M784 933c-.81 1.94-.81 1.94-2 4l-3 1v-2l-3-1c2.82-1.61 4.75-2.23 8-2"/><path fill="#939294" d="M176 931h8l-1 3h-6z"/><path fill="#bebcbd" d="m1189 930-1 3h-8c2.61-3.48 4.83-3.28 9-3"/><path fill="#9f9fa1" d="M241 926h7l1 3h-7z"/><path fill="#08080e" d="M1161 923h8v3h-7z"/><path fill="#000001" d="M902 922v3h-9c1.86-3.71 5.3-3.1 9-3"/><path fill="#afb7bf" d="M1530 921c0 3 0 3-1.87 5l-2.13 2c-1.25 1.75-1.25 1.75-2 3 1.13-10 1.13-10 6-10"/><path fill="#6a6a6e" d="M890 920q-2.12.8-4.25 1.56l-2.4.88c-2.54.6-3.9.4-6.35-.44 3.87-3.22 8.35-3.34 13-2"/><path fill="#a7b0b9" d="M1529 914h1v7h-3l-1 3c-.69-1.81-.69-1.81-1-4 1.38-1.56 1.38-1.56 3-3z"/><path fill="#909da6" d="M1534 908h3l1 6h-4z"/><path fill="#000001" d="M14 901h3l1 6h-4z"/><path fill="#919192" d="m162 881 6 5-3 1-2-1-1 3c-1.1-3.29-.8-4.71 0-8"/><path fill="#323136" d="m953 884 11 1v1c-4.1 1-7.8 1.08-12 1z"/><path fill="#2a2c30" d="m78 874 2 1v3l3 1a42 42 0 0 1 2 6h-2l-2.5-4.37-1.4-2.47C78 876 78 876 78 874"/><path fill="#848383" d="M1148 843h8v2l-9 1z"/><path fill="#000002" d="m1198 838-1 3h-8c2.61-3.48 4.83-3.28 9-3"/><path fill="#24222a" d="M1132 831h8l-1 3h-7z"/><path fill="#333335" d="M428 802h8v3c-5.75.13-5.75.13-8-1z"/><path fill="#8c8d8e" d="M1269 799h7l-1 3h-7z"/><path fill="#000003" d="M1315 790h6l-1 3-7 1z"/><path fill="#7e7f80" d="m1284.19 789.75 1.81.25v4h-7c2.46-3.94 2.46-3.94 5.19-4.25"/><path fill="#313235" d="m1388 756-3 2-1.31 1.13c-2.54 1.31-4.89 1-7.69.87 1-2 1-2 3.19-3.12 3.08-.96 5.6-1.03 8.81-.88"/><path fill="#6b6b6d" d="m1383 754 2 1c-2.36 2.1-3.55 2.97-6.75 3.19L1376 758v-2l-3-1 5.18.1c1.82-.1 1.82-.1 4.82-1.1"/><path fill="#4b4a50" d="m197 749 1 3 2 1-4 5-2-1 2-4-2-1z"/><path fill="#623a9d" d="m846 731 2 2-11 1v-2c3.24-1.08 5.6-1.22 9-1"/><path fill="#b68bed" d="m390 716 4 1v5h-5l-1-2 4 1v-2h-2z"/><path fill="#6a4599" d="m384 720 5 1 1 3a24 24 0 0 0 4 2c-3 0-3 0-6-1v-2l-4-2z"/><path fill="#a8a6aa" d="M1598 702h6l1 2c-2.4 1.92-3.96 2.21-7 2z"/><path fill="#1f1d22" d="m1222 702 1 2h-2l-1 3-5 1c1.1-3 1.68-3.85 4.63-5.25z"/><path fill="#553489" d="M954 703c2.88-.12 2.88-.12 6 0l2 2c-1 1-1 1-2.85 1.1l-2.21-.04-2.23-.02L953 706z"/><path fill="#b9b9ba" d="M1603 698h7v3c-3.53 1.22-3.53 1.22-5.75.19L1603 700z"/><path fill="#100f14" d="m1214 684 2 1-4 1v2a101 101 0 0 1-6 3l-2-1 3.31-2.44 1.87-1.37C1211 685 1211 685 1214 684"/><path fill="#0b0c10" d="m148 683 1 2h4v4h-3v-3h-7l1.94-.94C147 684 147 684 148 683"/><path fill="#000002" d="M243 678v4h-6v-3c2.22-1.11 3.56-1.08 6-1"/><path fill="#0a0418" d="M1073 674c-4.37 3.28-7.77 3.35-13 3 4.03-2.83 8.16-4.86 13-3"/><path fill="#8241c9" d="M409 667c3 4 3 4 2.86 6.8l-.67 2.95-.65 2.98L410 682h-1z"/><path fill="#07070a" d="M259 667h2v3h3v2l-6 2z"/><path fill="#7f7b84" d="m1635 662 3 1-7 6-1-4c2.31-1.56 2.31-1.56 5-3"/><path fill="#0e0d13" d="m1326 624 2 1c-3.78 4.35-6.37 5.79-12 7 3.06-3.3 6.23-5.6 10-8"/><path fill="#010107" d="M1221 609h6c-1.19 2-1.19 2-3 4-2.69.25-2.69.25-5 0z"/><path fill="#0e0c12" d="m1351 607 2 1c-5.54 5.85-5.54 5.85-9 7l-3-1z"/><path fill="#623ca1" d="m691 606 3 1c-2.45 2.3-3.6 2.99-7.02 3.07l-3.23-.38-3.27-.37L678 609v-1l2.34-.18 3.03-.26 3.03-.24C689 607 689 607 691 606"/><path fill="#ac99c8" d="M477 607v1h-11l-1-2c4.65-1.41 7.52-.5 12 1"/><path fill="#7b44c5" d="M422 602a527 527 0 0 1 7.52 1.37c2.29.58 3.65 1.18 5.48 2.63l-1 2-1.06-.94c-2.03-1.1-3.24-1.27-5.5-1.5-3.44-.56-3.44-.56-4.65-2.1z"/><path fill="#656468" d="M289 600c-4.3 3.94-4.3 3.94-7.81 4.25L279 604l-1-2c3.83-1.8 6.78-2.2 11-2"/><path fill="#ac9cc6" d="M647 599h7c-.56 1.44-.56 1.44-2 3-2.34.58-4.58.78-7 1z"/><path fill="#0a0a0f" d="M328 586h6v3c-3.75 1.13-3.75 1.13-6 0z"/><path fill="#000002" d="M1696 580h2v6h-4zm-5 6 3 1Z"/><path fill="#9a8abe" d="m779.06 578.94 1.94.06-1 3c-3.65 1.62-6.2 2.46-10 1l2.94-1.44c5.08-2.6 5.08-2.6 6.12-2.62"/><path fill="#54328e" d="m836 574 3 1c-1.7 2.04-2.64 2.92-5.26 3.51l-2.43.18-2.45.2-1.86.11-1-2 3.31-.37c2.8-.39 4.37-1 6.69-2.63"/><path fill="#000001" d="M1703 566h3v6h-4z"/><path fill="#06060b" d="m379 558 2 1-.06 2.75c.06 3.25.06 3.25.69 5.5L382 569l-2 2c-1-3.01-1.1-5.04-1.06-8.19l.02-2.73z"/><path fill="#44267e" d="M900 550h7l-1 3h-7z"/><path fill="#604297" d="m922.06 540.94 2.94.06c-3.86 3.86-3.86 3.86-7.11 4.27A72 72 0 0 1 912 545v-1l2.94-.94c6.06-2.1 6.06-2.1 7.12-2.12"/><path fill="#46297e" d="M949 534h6l-2 4-5-1z"/><path fill="#c8c9cb" d="M1734 530h3v7h-3z"/><path fill="#39383e" d="M684 531h15v2l-5 1v-1l-10-1z"/><path fill="#020106" d="m926 527 2 1-1 2 2 1-5 2v-2h-6v-1h7z"/><path fill="#4f3084" d="M974 522h8v2c-2.7 1.35-5 1.07-8 1z"/><path fill="#07050c" d="M940 519h7l-1 3h-7z"/><path fill="#231442" d="m1350 443 .81 1.94C1352 447 1352 447 1355 448l1 7c-3-3.75-3-3.75-3-6h-2z"/><path fill="#0a0b11" d="M1399 438h2q1.05 1.87 2.06 3.75l1.16 2.1c.91 2.51.66 3.68-.22 6.15-2.35-2.35-2.73-3.96-3.62-7.12l-.8-2.76z"/><path fill="#0b0716" d="m1212 414 2 1-4.5 2.93C1208 419 1208 419 1206 421c-2.12-.37-2.12-.37-4-1l3.31-2.44 1.87-1.37C1209 415 1209 415 1212 414"/><path fill="#8e8e90" d="M1609 410h2l1 3 6 1-1 2-7-1z"/><path fill="#c4c5c5" d="M366 408h1c-.35 8.35-.35 8.35-3.06 11.06L362 420c.75-4.75.75-4.75 3-7 .63-2.62.63-2.62 1-5"/><path fill="#0b0a10" d="m912 399 2 1c-1.31 1.5-1.31 1.5-3 3h-3v2l-6 1c2.6-3.03 5.45-4.3 9-6z"/><path fill="#191031" d="M1324 386c3.51 3.87 4.8 6.91 6 12-3.15-2.85-5.83-5.96-6.19-10.31z"/><path fill="#21143e" d="M1319 379c4.92 4.18 4.92 4.18 5.31 8.31L1324 390l-.81-1.25c-1.19-1.75-1.19-1.75-2.82-3.69-1.52-2.28-1.66-3.37-1.37-6.06"/><path fill="#48327e" d="M1283 379h3v5h-5c.88-3.87.88-3.87 2-5"/><path fill="#0f0e15" d="M847 362h1l-1 10h-2l-1 3c.55-4.61 1.4-8.64 3-13"/><path fill="#000006" d="M1314 357h2l2 8-4-1z"/><path fill="#000001" d="M1342 336h4v6h-3z"/><path fill="#07070d" d="M886 275c1.23 2.46 1.07 4.28 1 7l-6 2c1.5-3.11 3.2-6.05 5-9"/><path fill="#727274" d="M657 223c4.49.66 8.67 1.7 13 3v1c-7.71.69-7.71.69-11-2z"/><path fill="#6c6c6f" d="M636 213c2.76.52 5.33 1.1 8 2 .69 2.06.69 2.06 1 4-3.94-1.31-6.23-2.92-9-6"/><path fill="#5f5e61" d="M859 184c2.06.44 2.06.44 4 1-2.71 3.5-5.86 3.98-10 5v-2a99 99 0 0 1 5-3z"/><path fill="#010105" d="M802 159h6l-2 1v2l2 1h-7z"/><path fill="#88888b" d="M893.7 144.8q2.64.03 5.3.2v2c-3 1.5-5.66 1.06-9 1 .8-2.12 1.4-2.93 3.7-3.2"/><path fill="#848284" d="m516 143 2 3-3 3v-3l-2.37 1.56L510 149l-2-1c2.35-2.72 4.63-3.81 8-5"/><path fill="#8d8d8f" d="m922 137-1 4h-5v-3c2.22-1.11 3.56-1.08 6-1"/><path fill="#0b0b10" d="M1243 122h3v7h-3z"/><path fill="#000001" d="M1230 93h4v6h-3z"/><path fill="#09090e" d="M873 86h6v2l4 1h-10z"/><path fill="#5e6063" d="M547 70h7l-1.87.81C550 72 550 72 549.18 73.56c-1.54 1.87-2.46 1.8-4.82 2.13L541 76a42 42 0 0 1 5-3z"/><path fill="#49484b" d="m947 46 11 2v1h-8l-1 3-2-1z"/><path fill="#2c2d30" d="m969 38 1 3 2 1c-.62 1.5-.62 1.5-2 3-3.12.19-3.12.19-6 0l1.38-.69c2.19-1.77 2.72-3.68 3.62-6.31"/><path fill="#9c9e9f" d="m489 1825 7 1 1 4h-4v-3z"/><path fill="#38393d" d="m421 1796 2 2q2.97 1.08 6 2l-1 2h-7z"/><path fill="#000105" d="m1275.06 1746.94 1.94.06c0 2 0 2-1.25 3.63-2.1 1.65-3.15 1.64-5.75 1.37 2.41-4.98 2.41-4.98 5.06-5.06"/><path fill="#000001" d="M378 1746h3l1 6h-4z"/><path fill="#706f77" d="M448 1738q1.73 1.43 3.44 2.88l1.93 1.61C455 1744 455 1744 456 1746l-6-1z"/><path fill="#95979a" d="m364 1734 4 1v7h2l-1 4c-3.12-3.37-3.1-5.52-3-10z"/><path fill="#2c2b30" d="M1414 1694h1c.63 7.21.63 7.21-2 10.5l-2 1.5c-.12-2.37-.12-2.37 0-5l2-2c.63-2.62.63-2.62 1-5"/><path fill="#353438" d="m1426 1678 4 1-1 5h-3z"/><path fill="#919098" d="M396 1669h1v12l-3-2c-.43-3.95-.08-6.61 2-10"/><path fill="#2f2e38" d="m644 1526-7 6-2-1c1.07-2.9 1.7-3.84 4.5-5.31 2.5-.69 2.5-.69 4.5.31"/><path fill="#44424d" d="m1267 1482 3 1c.19 2.88.19 2.88 0 6l-3 2z"/><path fill="#383640" d="m1267 1481 14 1v1q-2.65.37-5.31.69l-3 .38c-2.69-.07-2.69-.07-5.69-3.07"/><path fill="#2a2a34" d="m743 1460 2 1a27 27 0 0 1-8 6h-3a91 91 0 0 1 4-4h2v-2z"/><path fill="#2d2d38" d="M777 1437v2h-2l-.75 1.94c-1.25 2.06-1.25 2.06-3.37 2.81l-1.88.25c4.47-7 4.47-7 8-7"/><path fill="#24232d" d="m804 1420 2 1c-1.63 3.12-3.92 4.42-7 6h-3z"/><path fill="#121213" d="m1298 1352 .81 1.25a135 135 0 0 0 4.19 5.75l-1 2-4-2-1 2-1-3h2z"/><path fill="#000001" d="M1294 1352h4v6h-3z"/><path fill="#ea4d08" d="M1395 1346c2.4 2.4 2.34 3.33 2.63 6.63l.22 2.47.15 1.9-1-2h-2z"/><path fill="#160e0c" d="m1257 1328 2 1c.92 2.75 1.1 4.36 1.06 7.19l-.02 2.17-.04 1.64c-2-2.5-2.35-4.53-2.62-7.69l-.23-2.45z"/><path fill="#26262f" d="m922 1313 5 1v2l1.94.38 2.06.62 1 2a21 21 0 0 1-10-4z"/><path fill="#5a3b25" d="M1074 1296c2.85 2.85 2.56 5.05 3 9h-3c-1.12-5.62-1.12-5.62 0-9"/><path fill="#765842" d="M1258 1275h1a27 27 0 0 1-2 12h-2c-.42-4.66 1.02-7.9 3-12"/><path fill="#000001" d="M1296 1271h2v6h-4c.88-4.87.88-4.87 2-6"/><path fill="#d6ae98" d="m1357 1260 2 1-2 4h-2l-.87 1.94C1353 1269 1353 1269 1351 1270c1.66-3.6 3.78-6.74 6-10"/><path fill="#6b6b70" d="m1505 1258 .81 1.88a8.7 8.7 0 0 0 4.19 4.12c-1 1-1 1-3.56 1.06l-2.44-.06q-.06-3 0-6z"/><path fill="#730b03" d="M1435 1262h9v2h2l1-2v3q-2.5.06-5 0l-1-1c-1.63-.1-1.63-.1-3.56-.06l-3.44.06z"/><path d="M1499 1259h3v7h-3z"/><path fill="#937155" d="M1260 1259h2c.13 5.75.13 5.75-1 8h-2c-.1-5.37-.1-5.37 0-7z"/><path fill="#6c6872" d="m1501 1250 .81 1.88a8.7 8.7 0 0 0 4.19 4.12c-1 1-1 1-3.56 1.06l-2.44-.06q-.06-3 0-6z"/><path fill="#b6512e" d="M1430 1245c4.03 2.07 5.93 3.97 8 8l-1.62-.87a66 66 0 0 0-8.38-3.13z"/><path fill="#000001" d="M1337 1227h7v3h-7z"/><path fill="#896547" d="M1287 1217h4c-1 3-1 3-2.37 4.19-1.63.81-1.63.81-4.63.81v-3h3z"/><path fill="#11121a" d="m983.19 1189.94 2.17.02 1.64.04c-2.5 2-4.53 2.35-7.69 2.63l-2.45.22-1.86.15c1.73-3.46 4.71-3.12 8.19-3.06"/><path fill="#85838e" d="M1302 1163h12v1l-2.25.44c-2.75.56-2.75.56-4.87 1.18-1.88.38-1.88.38-4.88-.62z"/><path fill="#414049" d="M397 1154h1v8l-2-2-2 3c.75-6.75.75-6.75 3-9"/><path fill="#8a97a1" d="m1598 1142 4 3h-2l-1 9h-1v-8l-2-1h2z"/><path fill="#c5cccf" d="M1531 1119h6l-2 7h-1v-5h-3z"/><path fill="#bcc3c8" d="M1519 1104c3.28.26 4.6.55 6.81 3.06l1.19 1.94c-3.82-.53-6.06-1.5-9-4z"/><path fill="#dae0e3" d="m1619 1090 2 1c.63 3.06.63 3.06 1 6h-4q-.06-3 0-6z"/><path fill="#d3dadf" d="M1478 1086h7l-3 4-4-1z"/><path fill="#dadfe2" d="M1462 1054h2v3h3v5c-2.5-1.75-2.5-1.75-5-4-.31-2.25-.31-2.25 0-4"/><path fill="#faf9fa" d="M1582 1046q1.73 1.43 3.44 2.88l1.93 1.61c1.63 1.51 1.63 1.51 2.63 3.51-2.31-.19-2.31-.19-5-1-3-4.03-3-4.03-3-7"/><path fill="#c9cdce" d="M328 1041c6.48-.37 6.48-.37 8.94 1.38C338 1044 338 1044 338 1046h-3v-2l-7-2z"/><path fill="#5b5d5f" d="M247 1024c4.2.62 7.3 2.02 11 4-2 2-2 2-4.12 2.13L252 1030l-.81-1.87A8.7 8.7 0 0 0 247 1024"/><path fill="#767679" d="M174 1014h6l-2 4h-4z"/><path fill="#d8d7d6" d="M1188 1007h5l1 3-6 1z"/><path fill="#5b5b5f" d="M162 1001h3v2h9v1l-5.27.68c-1.73.32-1.73.32-3.73 1.32l-2-1c-.62-2.06-.62-2.06-1-4"/><path fill="#d9d8d7" d="m1212 983 3 1-1 6-3-1q-.06-2.5 0-5z"/><path fill="#f5f3f5" d="M86 974h6v4h-5z"/><path fill="#06070c" d="M993 971h9l1 2-7 1v-2z"/><path fill="#010105" d="m228 958 5 2v4l3 1h-4v-3h-5l2-1z"/><path fill="#000004" d="m720 958-3 1v2l-8 1c3.36-3.68 6.12-4.13 11-4"/><path fill="#aeaeae" d="M1110 958h7l1 3-9-1z"/><path fill="#bbbaba" d="m1211 950 2 1c-2.37 2.74-4.55 3.93-8 5-2.81.13-2.81.13-5 0 3.13-2.65 6.1-3.79 10-5z"/><path fill="#101013" d="m203 951 12 2v1h-11z"/><path fill="#949495" d="m263 952-1 2h-8l-1-2c3.7-.95 6.3-.95 10 0"/><path fill="#646568" d="M43 940c3.78 1.51 6.05 2.45 7.88 6.19L52 949c-3.9-1.59-6.22-3.9-9-7z"/><path fill="#bfbec0" d="M1147 942c1.73-.3 1.73-.3 3.63-.19l3.37.19v2l-9 1z"/><path fill="#949394" d="m197 936-1 2h-7l-1-2c3.1-1.55 5.62-.56 9 0"/><path fill="#06060c" d="m879.63 929.9 5.37.1 1 3c-5.27.2-5.27.2-7 0l-2-2c1-1 1-1 2.63-1.1"/><path fill="#8c8e90" d="m841 924 1.67.46c2.53.59 5 .86 7.58 1.1l2.7.26 2.05.18v1h-14z"/><path fill="#807f82" d="M220 915q2.48.17 4.94.38l2.77.2 2.29.42 1 2q-2.48-.17-4.94-.37l-2.77-.22L221 917z"/><path fill="#000001" d="M976 902c3.13-.19 3.13-.19 6 0v3h-8z"/><path fill="#010005" d="m1235 899 5 1v2l-6 2z"/><path fill="#aaa9ac" d="M90 883c3.42 1.14 4.15 2 6 5v3l-3-1v-3l-3-1z"/><path fill="#2c2b30" d="M972 879h7v2c-2.7 1.35-5 1.07-8 1z"/><path fill="#040509" d="M1086 870c2.06.44 2.06.44 4 1-4.37 3.28-7.77 3.35-13 3v-1l1.71-.4c.37-.1.37-.1 2.23-.54l2.21-.52C1085 871 1085 871 1086 870"/><path fill="#e4e7eb" d="m1535 866 2 1c.63 3.06.63 3.06 1 6h-4q-.06-3 0-6z"/><path fill="#4e4c51" d="M153 869c2.86-.1 5.25.21 8 1l-2 2v3h-2c-1.07-1.31-1.07-1.31-2.12-3l-1.08-1.69z"/><path fill="#a0abb5" d="M1501 856h1v11l-4 1 .94-2.06c1.22-3.37 1.64-6.39 2.06-9.94"/><path fill="#8c8d8f" d="m1089 856-2 4a29 29 0 0 1-8-3c3.37-.61 6.57-1.13 10-1"/><path fill="#000002" d="M1235 822c3.13-.19 3.13-.19 6 0v3h-8z"/><path fill="#16161b" d="M17 811h1v9h-4l-1 4c-.19-2.31-.19-2.31 0-5q1.47-1.53 3-3c.69-2.69.69-2.69 1-5"/><path d="M18 812h3v7h-3z"/><path fill="#9b9b9b" d="M146 800v5c-1.81 1.06-1.81 1.06-4 2l-3-1 1.44-.81C142 804 142 804 143 801c2-1 2-1 3-1"/><path fill="#848487" d="M1240 802h6v3l-8 1z"/><path fill="#000003" d="M95 786h2v4h-3zm-3 4 2 1-1 4h-2z"/><path fill="#05070c" d="m72 754 1 3-5 4 1-5h3zm-7 7 3 1h-2l-1 2z"/><path fill="#432b71" d="M782 749c-4.84 2.9-8.4 3.38-14 3 9.08-4.9 9.08-4.9 14-3"/><path fill="#777577" d="m1385 747 .38 2.44.62 2.56 2 1-3 1v-3l-1.94-.37-2.06-.63-1-2c3-1 3-1 5-1"/><path fill="#ccc" d="M51 746h3v7l-4-3z"/><path fill="#864ccf" d="M429 739h7v3h-6z"/><path fill="#08090f" d="m97 726 1 3 3 1h-3v3h-4l1-4h2z"/><path fill="#47464b" d="m226 722-1 3-2 1-1 3-4-1 2.31-3 1.3-1.69C223 722 223 722 226 722"/><path fill="#010103" d="M1551 722h4l-1 5-4-1z"/><path fill="#a9a8aa" d="M191 717h5v3l-6 1z"/><path fill="#000102" d="M1567 715v3h-6v-3c2.5-1.25 3.41-.78 6 0"/><path fill="#0b0c11" d="m112 711 1 2c2.06.63 2.06.63 4 1v3h-3v-3h-7l1.94-.94C111 712 111 712 112 711"/><path fill="#af84e5" d="m375 706 5 1-3 1 2 1c.63 3.06.63 3.06 1 6-1.94-1.12-1.94-1.12-4-3-.75-3.19-.75-3.19-1-6"/><path fill="#79797e" d="m131 692 2 1c-2.12 2.33-4.02 3.91-7 5-2.25-.37-2.25-.37-4-1 2.65-2.17 4.65-3.3 8-4z"/><path fill="#000005" d="m1023 694-1 3h-7v-2c2.7-1.35 5-1.07 8-1"/><path fill="#09080d" d="m1214 685-4.31 2.5-2.43 1.4C1205 690 1205 690 1202 690c3.1-4.9 6.4-6.66 12-5"/><path fill="#a7a5aa" d="m1613 680 2 1-6 7-3-1c1.52-2.68 1.87-2.96 5-4 1.19-1.56 1.19-1.56 2-3"/><path fill="#18181c" d="M246 679c2.13.38 2.13.38 4 1-4.16 2.86-8.14 3.98-13 5v-3l5.27-.59C244 681 244 681 246 679"/><path fill="#757679" d="M152 675h1v5c-3.91 2.18-6.41 3.4-11 3 2.1-1.12 3.76-1.97 6.13-2.31 1.87-.69 1.87-.69 3.12-3.25z"/><path fill="#37363a" d="m1508 674 4 1-7 5-2-1c3.52-4 3.52-4 5-5"/><path fill="#878788" d="M275 674h7l-5 5-2-1z"/><path fill="#959395" d="M272 670h2v3l2 1v4h-2c-1.69-1.75-1.69-1.75-3-4 .31-2.25.31-2.25 1-4"/><path fill="#7d3fc3" d="M410 641c1.57 2.5 2.03 3.7 1.75 6.69L411 650l-2 1-.1-6.93c.1-2.07.1-2.07 1.1-3.07"/><path fill="#241643" d="m1185 620 4 1a27 27 0 0 1-8 6l-2-1q1.19-.93 2.44-1.87C1184 622 1184 622 1185 620"/><path fill="#be98eb" d="M385 608h1v14l-4 2 .96-1.6c1.28-2.95 1.46-5.46 1.67-8.65l.22-3.27z"/><path fill="#535356" d="m268 607 2 1-1 5h-6v-2l1.94-.31L267 610z"/><path fill="#14131a" d="M1462 592c1 3 1 3 .3 5l-1.11 2.13-1.08 2.13C1459 603 1459 603 1457 604c.57-4.68 2.56-8.06 5-12"/><path fill="#0a0b10" d="M312 594h6v3c-3.75 1.13-3.75 1.13-6 0z"/><path fill="#ab99c7" d="m702.63 594.44 3.03.3 2.34.26v1l-2.01.18c-8.77.84-8.77.84-12.99 1.82l2-1v-2c2.73-1.36 4.61-.88 7.63-.56"/><path fill="#000001" d="M303 594h7v3h-7z"/><path fill="#414246" d="m307 586 3 2-1 5h-5l-1-2h5z"/><path fill="#b2abc5" d="M630 589h10c-2.3 2.3-3 2.32-6.12 2.63l-2.2.22-1.68.15z"/><path fill="#2c1558" d="m1028 582 2 1c.63 2.29.63 2.29 1.13 5.06l.87 4.94c-1.46-.71-1.46-.71-3-2-.51-2.16-.51-2.16-.69-4.62l-.2-2.48z"/><path fill="#797b7d" d="m323 578 2 1-1 5h-5l-1-2h4z"/><path fill="#0c0d11" d="m333 578 1 3 3 1v3h-3v-3h-7v-1h6z"/><path fill="#50308b" d="M818 578v2l-9 1v-2c3.2-1.07 5.66-1.07 9-1"/><path fill="#06060b" d="m450.63 562.94 3.03.02 2.34.04v1l-3.19.44q-4.43.63-8.81 1.56l-1-2c2.64-1.32 4.68-1.1 7.63-1.06"/><path fill="#06070c" d="M428 559h9c-2.36 2.1-3.55 2.97-6.75 3.19L428 562z"/><path fill="#424247" d="M347 544c.63 1.88.63 1.88 1 4l-2 2a90 90 0 0 0-1 6h-1q-.12-2.16-.19-4.31l-.1-2.43c.35-2.74 1.18-3.55 3.29-5.26"/><path d="m876.13 542.94 2.19.02 1.68.04v2l-10 1v-2c2.29-1.14 3.6-1.1 6.13-1.06"/><path fill="#010104" d="m895 535 4 1-2 1-1 3-10-1v-1l1.71-.15 2.23-.23 2.21-.2L894 537z"/><path fill="#383a3c" d="M339 528h3v6l-4-1z"/><path fill="#4d2f82" d="m973 523 1 3h-2l-1 3-4-1v-2z"/><path fill="#482b80" d="M997 517c2 2 2 2 2 5h-12v-1h9z"/><path fill="#2e1a55" d="M1313 506c3.21 3.21 4.93 5.7 5.19 10.31L1318 519c-2.81-3.9-5.57-8.05-5-13"/><path fill="#010004" d="M992 507h7l1 2-9 1z"/><path fill="#333137" d="M924 483h5v3l-7 1z"/><path fill="#87878a" d="M418 471c2 1 2 1 3 3q.1 2.06.06 4.13L421 482c-1.5-1-1.5-1-3-3a48 48 0 0 1 0-8"/><path fill="#838484" d="M347 472h3v7h-3z"/><path fill="#09080f" d="M1640 471q2.51.43 5 1v3l2 1c-5.66-.62-5.66-.62-7.37-2.56L1639 472z"/><path fill="#2b1753" d="M1146.68 456.8q2.66.03 5.32.2v1l-2.88.87C1146 460 1146 460 1144 462c-2.63.12-2.63.12-5 0l1.87-.81c2.13-1.19 2.13-1.19 2.94-2.82 1.19-1.37 1.19-1.37 2.87-1.56"/><path fill="#0b0b10" d="M1601 456q1.9-.12 3.81-.19l2.15-.1c2.58.37 3.4 1.33 5.04 3.29q-2.48-.17-4.94-.37l-2.77-.22-2.29-.41z"/><path fill="#09080d" d="M1667 446h7v3h-7z"/><path fill="#e4e4e5" d="m352 434 2 1v6h-4z"/><path fill="#29184b" d="M1344 433c3.48 3.02 4.8 5.57 6 10-2.44-1.06-2.44-1.06-5-3-.76-2.4-.86-4.46-1-7"/><path fill="#333438" d="M1618 414c2 2 2 2 2.13 4.13L1620 420q-1.73-.43-3.44-.87l-1.93-.5C1613 418 1613 418 1612 416z"/><path fill="#848386" d="m1553 394 .68 1.95c1.32 2.05 1.32 2.05 3.7 2.66l2.74.14 2.76.17 2.12.08v1c-4.81.21-8.59.2-13-2z"/><path fill="#67696a" d="M359 392h3v7h-3z"/><path fill="#090616" d="M1327 388h2q.8 1.62 1.56 3.25l.88 1.83c.68 2.32.32 3.65-.44 5.92l-2-3.81-1.12-2.15C1327 391 1327 391 1327 388"/><path fill="#4c4b4f" d="m1355 350 7 3-2 1v5l-1-4-4-1z"/><path fill="#000001" d="M1351 349h3v6h-3a88 88 0 0 1-1-5z"/><path fill="#7a7e7f" d="M378 337c.81 1.69.81 1.69 1 4-1.48 1.95-3.14 3.37-5 5-.25-2.25-.25-2.25 0-5 2-2.31 2-2.31 4-4"/><path fill="#878889" d="M382 313c.81 1.69.81 1.69 1 4-1.48 1.95-3.14 3.37-5 5-.25-2.25-.25-2.25 0-5 2-2.31 2-2.31 4-4"/><path fill="#8b8b8c" d="M371 312h3v7h-3z"/><path fill="#989b9b" d="M375 288h3v7h-3z"/><path fill="#2b2c2e" d="M392 274c.86 2.4 1.23 3.52.13 5.88L391 282q-.58 3-1 6h-1c-.32-5.5-.53-9.44 3-14"/><path d="M1315 276h3v7h-3z"/><path fill="#89898b" d="M453 267h1v12l-4 2 .94-1.94c1.38-3.98 1.71-7.88 2.06-12.06"/><path fill="#bfc1bf" d="M390 264h1c.23 3.37.34 5.41-1.37 8.38L388 274h-2c.57-2.87.86-3.86 3-6 .63-2.12.63-2.12 1-4"/><path d="M1303 248h3v7h-3z"/><path fill="#16151c" d="M905 240c.75 1.75.75 1.75 1 4-1.81 2-1.81 2-4 4-1.25 2.25-1.25 2.25-2 4l-1-2c.96-2.13.96-2.13 2.38-4.56l1.4-2.44c1.22-2 1.22-2 2.22-3"/><path d="M1291 220h3v7h-3z"/><path fill="#78787b" d="M774 218c-2.46 2.46-4.27 2.77-7.62 3.63l-3.04.78-2.34.59c2.47-4.93 8.06-5.35 13-5"/><path fill="#808083" d="m777 213 7 1c-2.62 2.15-4.64 3.4-8 4z"/><path d="M1279 192h3v7h-3z"/><path fill="#020105" d="M664 178h7l-4 1v2h6v1h-8z"/><path fill="#75757b" d="m1273 162 .81 1.88A8.7 8.7 0 0 0 1278 168c-1 1-1 1-3.56 1.06L1272 169q-.06-3 0-6z"/><path d="M1267 164h3v7h-3zM426 164h3v7h-3z"/><path fill="#929293" d="M836 162v2c-3 1.5-5.66 1.06-9 1v-2c3.07-.91 5.8-1.09 9-1"/><path fill="#000001" d="M430 156h3v7h-3z"/><path fill="#0a0b11" d="M1251 138h3v7l-2 1c-1.35-2.7-1.07-5-1-8"/><path fill="#020106" d="M870 139h6l-1 3-7 1z"/><path fill="#000004" d="m651 130-1 3h-7v-2c2.7-1.35 5-1.06 8-1"/><path fill="#59585c" d="m585 128 4 1v2h5v1h-10z"/><path fill="#020106" d="m913 123 5 1-1 3h-6z"/><path fill="#000001" d="M1246 122h4v6h-3z"/><path fill="#b6b6b9" d="m1258 113 4 2c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#36353b" d="M1050 114a91 91 0 0 1-4 4h-2v-3l-2-1c3.29-1.1 4.71-.8 8 0"/><path fill="#3e3d43" d="M1001 108c-.75 4.75-.75 4.75-3 7v-5l-4-1c2.46-1.23 4.28-1.07 7-1"/><path fill="#0a0a0f" d="M500 94h7v3h-7z"/><path d="M492 94h7v3h-7z"/><path fill="#909192" d="m487 90 2 1-1 5h-6v-2l1.94-.31L486 93z"/><path fill="#818083" d="m495 86 2 1-1 5h-5l-1-2h4z"/><path fill="#eae8eb" d="m733 78 4 1 8 2v1c-8.45.37-8.45.37-12-2z"/><path d="M896 74h7v3h-7z"/><path fill="#68686b" d="M682 74c2.38-.19 2.38-.19 5 0a13 13 0 0 1 2 3 25 25 0 0 0 3 2c-6.52-.5-6.52-.5-8.94-3.06z"/><path d="M924 62h7v3h-7z"/><path fill="#a6a8aa" d="M575 59h7v3h-7z"/><path fill="#0a090f" d="M944 58h6l-1 4-5-1z"/><path fill="#0a0b0f" d="M953 54h6l1 3c-4.75 1.13-4.75 1.13-7 0z"/><path fill="#f4f4f4" d="M931 50h5v4h-6z"/><path fill="#0b0a10" d="M981 42h7l1 3h-7z"/><path d="M972 42h7v3h-7z"/><path fill="#848689" d="M1327 1808h6l1 2h-5l-1 4c-1-1-1-1-1.06-3.56z"/><path fill="#3b3a43" d="m477 1753 7 1 1 3-1 2-4-1h2v-3l-5-1z"/><path fill="#8a8b93" d="m455 1751 7 1 1 3-7-1z"/><path fill="#6a686e" d="M435 1744a26 26 0 0 1 9 3v2c-6.7-1.61-6.7-1.61-8.23-3.57q-.37-.7-.77-1.43"/><path fill="#44424c" d="M925 1741v4h-7c1.74-3.08 3.34-4 7-4"/><path fill="#201f26" d="M928 1735v2c-5.42 3.08-5.42 3.08-8.37 2.69L918 1739c3.25-2.67 5.76-4 10-4"/><path fill="#f6f7f7" d="m358 1728 4 1v5l-4-1z"/><path fill="#17171e" d="m398 1704 4 5 1.75 2.19C405 1713 405 1713 405 1715c-3-1-3-1-4.62-3.25A23 23 0 0 1 398 1704"/><path fill="#939199" d="m405 1704 4 2v6l-3-1z"/><path fill="#000004" d="M1313 1699c1.04 3.13.93 3.99 0 7h-3c-.19-2.37-.19-2.37 0-5 1.5-1.31 1.5-1.31 3-2"/><path fill="#86838d" d="M403 1684c2 2 2 2 2.13 4.63L405 1691l-3 1c-.1-5.37-.1-5.37 0-7z"/><path fill="#353439" d="m1417 1680 5 1-1.87 1.19c-2.46 2.09-3.15 3.78-4.13 6.81-1.03-2.79-1.05-3.87-.06-6.75z"/><path fill="#000005" d="m1296 1479 7 1-1 2h-7z"/><path fill="#919195" d="m1431.63 1435.9 5.37.1-1.87.75c-2.48 1.46-3.08 2.63-4.13 5.25h-2q-.06-2.5 0-5c1-1 1-1 2.63-1.1"/><path fill="#d04001" d="M1380 1387c1.85.12 1.85.12 4.06.44l2.23.3 1.71.26-1 2c-2.87.13-2.87.13-6 0l-2-2z"/><path fill="#d5d1ca" d="M1340 1387h7l1 3c-2.87.19-2.87.19-6 0-1.37-1.5-1.37-1.5-2-3"/><path fill="#6e6e72" d="M1504 1367h5l1 2h-4v5l-2-1z"/><path fill="#000106" d="m1195 1370 8 1-1 2h-7z"/><path fill="#261a11" d="M1185 1363q2.19.14 4.38.31l2.46.18c2.16.51 2.16.51 3.45 2.05l.71 1.46a70 70 0 0 1-11-3z"/><path fill="#583923" d="M1079 1309c2.2 2.62 2.18 4.65 2 8h-3c-.1-5.37-.1-5.37 0-7z"/><path fill="#0b0d12" d="M350 1297v7l-4-2v-4c3-1 3-1 4-1"/><path fill="#563822" d="M1011 1295h7v3h-6z"/><path fill="#c9420c" d="m1407 1283 1 2c-.78 1.95-.78 1.95-1.94 4.13l-1.15 2.19-.91 1.68h-1v-5h2v-4z"/><path fill="#060408" d="M1269 1258h1c.84 4.02 1.05 7.02 0 11-1.56-1.19-1.56-1.19-3-3 .29-2.87 1.09-5.26 2-8"/><path fill="#624026" d="M943 1263h6l1 3h-7z"/><path fill="#643f24" d="M936 1259h7l-1 4-6-2z"/><path fill="#e54c11" d="M1415 1250h7v3l-8-1z"/><path fill="#e14b0e" d="M1405 1250h10l-1 3c-3.73-.5-5.81-.87-9-3"/><path fill="#322721" d="M1275 1244c.69 1.69.69 1.69 1 4-1.31 2.75-1.31 2.75-3 5h-2c1.75-6.75 1.75-6.75 4-9"/><path fill="#52331d" d="M984 1247h9v3l-9-1z"/><path fill="#d1bfae" d="M1370 1243c2.47.82 3.95 1.53 6 3h-3l-1 3h-2c-1-3-1-3 0-6"/><path fill="#010208" d="m864 1238 3 1-2 2-2-2zm-5 3h3l-2 4-3-1c.81-1.5.81-1.5 2-3"/><path fill="#000006" d="M1301 1222v3l-3 1c-1.19 1.56-1.19 1.56-2 3l-2-1 1-3h2v-2c2-1 2-1 4-1"/><path d="M1065 1219h1l-1 7h-3c.75-4.75.75-4.75 3-7"/><path fill="#cad3dc" d="M1535 1212h6l1 2h-5l-1 4c-1-1-1-1-1.06-3.56z"/><path fill="#22212a" d="M925 1208c-3.7 1.98-6.8 3.38-11 4 3.6-3.85 5.75-5.63 11-4"/><path fill="#000001" d="M1434 1198h7v3h-6z"/><path fill="#42404c" d="M644 1194h6l1 3h-8z"/><path fill="#403e4b" d="M668 1186h6v3h-7z"/><path fill="#26252e" d="M497 1182c4.67.56 7.94 1.78 12 4-2.47.88-3.64 1.13-6.14.22l-2.11-1.16-2.14-1.15-1.61-.91z"/><path fill="#15181d" d="M348 1177h1c.37 8.45.37 8.45-2 12h-1a27 27 0 0 1 2-12"/><path fill="#bcc1c7" d="m1606 1156 4 2-1 4h-3z"/><path fill="#3b3a45" d="M717 1158h4l-1 3-4-1zm-2 3 1 3-4 1z"/><path fill="#d9e0e2" d="M1538 1139c1.94.69 1.94.69 4 2 .75 2.63.75 2.63 1 5-2.5-1.25-2.5-1.25-5-3-.31-2.19-.31-2.19 0-4"/><path fill="#0e0f15" d="M1181 1108q-1.68 1.05-3.37 2.06l-1.9 1.16-1.73.78-2-1c3.16-3.3 4.5-4.35 9-3"/><path fill="#7e8d99" d="M1599 1081h3v7l-3-1z"/><path fill="#232024" d="m1450 1073 3 1a11 11 0 0 1 1 4l-1 2h-3z"/><path fill="#f0f3f4" d="M1456 1052h2v3l4 2-2 3v-2l-4-1z"/><path fill="#c2c3c4" d="M305 1038h6l2 4c-3.69-.5-5.6-1.1-8-4"/><path fill="#302f33" d="M1409 1021c2 1 2 1 2.85 3.29l.78 2.77.78 2.79.59 2.15-3-1c-1.43-3.39-2.28-6.32-2-10"/><path fill="#ebeae7" d="M1165 1023h7v2l-8 1z"/><path fill="#a4a4a5" d="M1029 1020c4.58.72 8.7 2.3 13 4-2.55.93-3.74 1.09-6.36.22l-2.39-1.16-2.42-1.15-1.83-.91z"/><path fill="#0e0e11" d="M1196 1015h3v2l3 1h-3l-1 3v-3l-5 1c1.31-2 1.31-2 3-4"/><path fill="#403f44" d="M998 1008c5.54.5 5.54.5 7.88 3.06l1.12 1.94c-4.31-.48-6.17-1.75-9-5"/><path fill="#b8b8b9" d="M988 991h9c-2.36 2.1-3.55 2.97-6.75 3.19L988 994z"/><path fill="#c3c2c1" d="m1024 981-1.87.88C1020 983 1020 983 1018 985l-3-1 1-3c2.86-1.43 4.93-.6 8 0"/><path fill="#313135" d="m100 975 5 1 1 3 2 1-3 2-1.37-2.37C102 977 102 977 100 975"/><path fill="#f1f1f2" d="M1415 971h2v7l-3 1z"/><path fill="#f8f9fb" d="m1526 970 4 1v5l-4-2z"/><path fill="#06070c" d="M693 966q2 .45 4 1 2.37.32 4.75.56l2.42.26 1.83.18v1q-2.94.08-5.87.13l-3.31.07C694 969 694 969 692 967z"/><path d="m68 954 6 1v3h-5z"/><path fill="#959596" d="m244 947-1 3h-8l-1-2 6.05-.78C242 947 242 947 244 947"/><path fill="#8b8c8d" d="m733 943-1 3h-8c1-2 1-2 2.81-2.62 2.12-.37 4.05-.46 6.19-.38"/><path fill="#dcdbda" d="m1222 944 2 1-7 8-1-4z"/><path fill="#a8abab" d="M34 938q2.19.35 4.38.75l2.46.42c2.16.83 2.16.83 3.45 2.9L45 944l-3-1v-2l-5 1z"/><path fill="#949595" d="m213 939-1 3c-5.27.2-5.27.2-7 0l-2-2 3.31-.5 1.87-.28C210 939 210 939 213 939"/><path fill="#727375" d="M165 935c4.67.56 7.94 1.77 12 4-2.47.88-3.64 1.13-6.14.22l-2.11-1.16-2.14-1.15L165 936z"/><path fill="#a9a9a9" d="M1206 926h8l-1 3-3 1z"/><path fill="#c0bfc0" d="M1216 918h6v3h-7z"/><path fill="#a4a4a5" d="M130 915h5l1 4-5-1z"/><path fill="#010103" d="M112 914h5v4l-6-1z"/><path fill="#c5c9ca" d="M1462 906h1v5h3v3h-3l-1 3z"/><path fill="#7a7a7c" d="M929 907h10v1c-7.43 2.29-7.43 2.29-11 2z"/><path fill="#0a090d" d="M1003 895c-1 1.5-1 1.5-3 3a48 48 0 0 1-8 0c3.23-2.28 7.2-4.9 11-3"/><path fill="#555358" d="M173 888c2.7.15 4.47.48 6.45 2.4A88 88 0 0 1 183 895a63 63 0 0 1-10-6z"/><path fill="#47454b" d="m170 879 2 2 2-1v2h2l-1 3-4-2-1 2-2-3h2z"/><path fill="#eef2f3" d="M1490 874v5l-4 1v-5c3-1 3-1 4-1"/><path fill="#000002" d="m1148.13 853.81 2.87.19-1 3h-7c1.24-2.97 1.8-2.99 5.13-3.19"/><path fill="#2a292f" d="M1081 847h8v2l-9 1z"/><path fill="#39393c" d="M1125 840c-6.23 3.1-6.23 3.1-9.44 2.69L1114 842c3.28-3.28 6.7-3.43 11-2"/><path fill="#66676a" d="m1183.44 821.25 1.56.75c-6.23 4.08-6.23 4.08-9.44 3.75L1174 825c6.23-4.08 6.23-4.08 9.44-3.75"/><path fill="#9f9fa0" d="M0 818h2v10H0z"/><path fill="#87888a" d="m14 810 2 1v5l-5 1-1-2 3-1c.69-2.06.69-2.06 1-4"/><path fill="#858586" d="m1238 811 5 1v2h-7zm-4 3h2l-1 2z"/><path fill="#858587" d="M1219 810h7l-1 3h-7z"/><path fill="#53535a" d="M1469 802h1c.37 4.56.37 4.56-1.37 6.63L1467 810q-1.05 1.47-2 3l1-7 3-1z"/><path fill="#09090f" d="M30 798h3v6l-3 1z"/><path fill="#26242b" d="M1234 796q3 .43 6 1c-2.62 2.15-4.64 3.4-8 4 .88-3.87.88-3.87 2-5"/><path fill="#fff" d="m1472 791 2 1v5l-4 1z"/><path fill="#010004" d="M1324 786h7l-1 3h-6z"/><path fill="#8b8a8c" d="M1298 787h7l-1 3h-6z"/><path fill="#000002" d="m34 787 4 1-1 5h-3z"/><path fill="#9b9b9c" d="m118 778 2 1-2 3zm0 4-1 2-2-1-1 3-4-1c2.47-3.12 4.1-3.32 8-3"/><path fill="#1c1b21" d="m1298.13 770.81 2.87.19-1 3h-7c1.24-2.97 1.8-2.99 5.13-3.19"/><path fill="#010102" d="m1486 770 1 4h-6v-3c2-1 2-1 5-1"/><path fill="#1c1b22" d="M1304 767h5v3l-7 1z"/><path fill="#878788" d="M1330 766h6v2l-8 2z"/><path fill="#06070c" d="M123 755a49 49 0 0 1 7 4l-4 3-1-3-2-1z"/><path fill="#000001" d="M1511 755v3h-6v-3c2.5-1.25 3.41-.78 6 0"/><path fill="#000002" d="M1393 754h5v3l-7 1z"/><path fill="#06070b" d="M433 754h7v2l-8 1z"/><path fill="#06060a" d="m1399 750 2 1-1 2 2 1h-3l-1 3v-3h-5z"/><path fill="#241843" d="M791 749v1a39 39 0 0 1-15 2v-1a39 39 0 0 1 15-2"/><path fill="#7c7b7c" d="m1414 733 7 1c-4.75 4-4.75 4-7 4v-2l-2-1h2z"/><path fill="#121215" d="M325 728c1.81.13 1.81.13 4 1a25 25 0 0 1 3 6l-1 2-3-3.37-1.69-1.9C325 730 325 730 325 728"/><path fill="#1b1a21" d="m1418 718-4 4 2 1h-5v-5c4.75-1.12 4.75-1.12 7 0"/><path fill="#7e7c7c" d="m1451 718-5 4-1-3h-5c3.87-2.63 6.63-1.84 11-1"/><path fill="#000105" d="m973 710-1 3h-8c1-2 1-2 2.81-2.62 2.12-.37 4.05-.46 6.19-.38"/><path fill="#757474" d="M1473 702h5c-.75 1.94-.75 1.94-2 4-2.12.75-2.12.75-4 1z"/><path fill="#3b3b3f" d="M1474 696h-3l-1 3c-1.63.73-1.63.73-3.56 1.19l-1.94.48-1.5.33c6.38-6.6 6.38-6.6 11-5"/><path fill="#06080c" d="M1587 691h3v3h6l-1.94.38-2.06.62-1 2v-2h-4z"/><path fill="#19191e" d="M1419 693c-2.5 2-4.53 2.35-7.69 2.63l-2.45.22-1.86.15c3.68-3.68 7.12-3.32 12-3"/><path fill="#434348" d="m269 687 2 3c-.75 1.5-.75 1.5-2 3-2.12.19-2.12.19-4 0 1.15-2.47 2.05-4.05 4-6"/><path fill="#636164" d="m1517 671 2 1c-1.31 1.5-1.31 1.5-3 3h-3l-1 3-4-1z"/><path fill="#482d7b" d="M1050 670h4l-1 6-4 1c.57-2.87.86-3.86 3-6z"/><path fill="#06080d" d="m1622 660 1 2 3 1h-3v3h-4l1-4h2z"/><path fill="#b988ea" d="M374 654h3v15h-1l-.44-5.81-.24-3.27C375 657 375 657 374 654"/><path fill="#8c8c8f" d="M183 655h2l-1 5h-6v-2l1.94-.37L182 657z"/><path fill="#15171a" d="m201.31 649.19 1.69.81h-3v7l-1-3h-5c4.43-5.05 4.43-5.05 7.31-4.81"/><path fill="#2f1953" d="m1120.13 646.81 2.87.19-1 3h-7c1.24-2.97 1.8-2.99 5.13-3.19"/><path fill="#2e2e33" d="M318 647c-5.42 3.08-5.42 3.08-8.37 2.69L308 649c3.2-3.2 5.78-3.36 10-2"/><path fill="#efeeef" d="M199 642h5l-1 4h-5z"/><path fill="#3c236d" d="M1043 635c2 2.55 3.86 4.95 5 8-.45 2.13-.45 2.13-1 4q-1.01-1.87-2-3.75l-1.12-2.1a12 12 0 0 1-.88-6.15"/><path fill="#07080e" d="M235 634h6l1 3h-7z"/><path fill="#2c1b52" d="M1161 630v2l2 1c-1 1-1 1-3.37 1.25-2.63-.25-2.63-.25-4.44-1.75L1154 631c2.5-.69 4.38-1 7-1"/><path fill="#05070c" d="m1654 628 1 2 3 1h-3v3h-4l1-4h2z"/><path fill="#7c777f" d="m1681 610 4 1c-2.35 3.92-4.64 4.84-9 6l1-3h4z"/><path fill="#7341be" d="M451 609c2.06.44 2.06.44 4 1l-1 4h-3c-.56-1.94-.56-1.94-1-4z"/><path fill="#291849" d="M1204 610h6a26 26 0 0 1-7 5l-3-1 1-2h3z"/><path fill="#0a0b10" d="M287 606h7l-1 3h-6z"/><path fill="#19191d" d="m301 590 7 3v1h-5v3l-3-1z"/><path fill="#a2a3a3" d="M279 590c1.94.31 1.94.31 4 1l1 3h-6z"/><path fill="#a89db8" d="m489 590 1.69.44c2.42.59 4.86 1.08 7.31 1.56l-2 2c-2.4-.03-4.62-.56-7-1z"/><path fill="#040406" d="M1698 587c-1.81 2-1.81 2-4 4h-3v-4c3.13-1.04 3.99-.93 7 0"/><path fill="#55338f" d="M786 586v3h-8c2.47-3.12 4.1-3.32 8-3"/><path fill="#09090e" d="M1692 579h2v7h-3q-.06-3 0-6z"/><path fill="#1f113a" d="m1266 580 2 1-3 1v2c-2.37 1.69-2.37 1.69-5 3l-2-1c2.46-2.63 4.58-4.75 8-6"/><path fill="#a498bc" d="M766 577h14v1h-12v2h-3z"/><path fill="#9886c0" d="M801 575c-1.7 2.04-2.64 2.92-5.26 3.51l-2.43.18-2.45.2-1.86.11 1.91-.93c2.09-1.07 2.09-1.07 3.53-2.2 2.2-1.23 4.08-1 6.56-.87"/><path fill="#06060c" d="m640 575 9 2v1h-10z"/><path fill="#6a696d" d="M1712 559h5l1 2h-4v5l-2-1z"/><path fill="#08090f" d="M350 551h3v8l-3-1z"/><path fill="#472584" d="M896 554v3h-8v-2a24 24 0 0 1 8-1"/><path fill="#6e6d71" d="M1716 551h5l1 2h-4v5l-2-1z"/><path fill="#f1f1f3" d="M1723 544h3v5l-4 1q-.06-2.5 0-5z"/><path fill="#08090e" d="M354 537h3v10c-3.16-4.22-3.34-5.06-3-10"/><path fill="#2c2b31" d="m789.25 519.81 2.14.08 1.61.11c-2.5 2.5-3.67 2.36-7.12 2.63l-2.76.22-2.12.15c2.84-2.44 4.5-3.38 8.25-3.19"/><path fill="#919595" d="M354 497h1c.31 2.81.31 2.81 0 6-2.5 1.88-2.5 1.88-5 3-.19-1.87-.19-1.87 0-4 1.44-1.06 1.44-1.06 3-2z"/><path fill="#88878a" d="m414 496 2 1c.59 2.31.74 4.62 1 7l-3 1z"/><path fill="#2e1a55" d="m1108 472 2 1c-2.62 3.88-2.62 3.88-6 5l-1-4c2.38-1.06 2.38-1.06 5-2"/><path fill="#0a0a10" d="m1612 459 3.88.44 2.17.24c1.95.32 1.95.32 3.95 1.32v2h-4v-2h-6z"/><path fill="#130b2a" d="M1356 451c3.61 3.17 3.61 3.17 5 5-.19 2.31-.19 2.31-1 4-3.94-4.43-3.94-4.43-4.25-7.31z"/><path fill="#0d071a" d="M1172 435c2.25.25 2.25.25 4 1-1.75 2.06-1.75 2.06-4 4-2.25-.25-2.25-.25-4-1 1.75-2.06 1.75-2.06 4-4"/><path fill="#1f1338" d="M1337 413h2l3 10-3-1c-.73-2.07-.73-2.07-1.19-4.56l-.48-2.5z"/><path fill="#020109" d="M1334 398h2c1.43 2.35 2.09 3.48 1.63 6.25L1337 406c-1.5-1.19-1.5-1.19-3-3-.19-2.69-.19-2.69 0-5"/><path fill="#edeaec" d="M1572 398h5l1 4h-5z"/><path fill="#341f62" d="M1293 388c2.28 2.2 3.9 3.98 5 7-.37 2.25-.37 2.25-1 4-2.31-2.31-2.5-3.48-3.12-6.62l-.51-2.48z"/><path fill="#c8c9c9" d="M382 304h1q-.17 1.9-.37 3.81l-.22 2.15A9.4 9.4 0 0 1 380 315h-2c.78-3.9 2.39-7.39 4-11"/><path fill="#939494" d="M394 250h1c.13 2.88.13 2.88 0 6l-2 2-3-1z"/><path fill="#090a0f" d="M1299 248h3v6l-3 1z"/><path fill="#0a0a0f" d="M1295 238h3v6l-3 1z"/><path fill="#848386" d="M462 233h2c1.2 3.6 1.07 6.23 1 10-1.5-1.06-1.5-1.06-3-3-.27-2.4-.14-4.58 0-7"/><path fill="#4a4a4e" d="M502 217c1 2 1 2 .25 4.94-1.15 2.8-1.95 4.18-4.25 6.06.62-4.2 2.02-7.3 4-11"/><path fill="#807f82" d="m1297 218 .81 1.88A8.7 8.7 0 0 0 1302 224q-2.49.57-5 1c-1-1-1-1-1.12-3.5.12-2.5.12-2.5 1.12-3.5"/><path fill="#131217" d="M1290 211h1l.88 2.94C1293 217 1293 217 1295 218v6h-1v-5h-4z"/><path fill="#08080d" d="M423 181h2v10c-3.47-4.63-3.47-4.63-3-9z"/><path fill="#0a0a0f" d="M426 172h3v6l-3 1z"/><path fill="#06060b" d="m632 158 .88 1.88C634 162 634 162 636 164l-5 1c-.62-2.37-.62-2.37-1-5z"/><path fill="#090a0f" d="M1255 146h3v7l-3-1z"/><path fill="#030308" d="m857 143 2 1c0 3 0 3-1.12 4.31-2.96 1.09-5.06-.18-7.88-1.31v-1l2.94-.94C856 144 856 144 857 143"/><path fill="#1f1e23" d="M943 120c-4.02 1.81-7.61 3.33-12 4 1.2-2.63 2.15-3.07 4.88-4.25 2.99-.72 4.31-.83 7.12.25"/><path fill="#2e2f34" d="M984 110v3h-8c2.47-3.12 4.1-3.32 8-3"/><path fill="#09090e" d="M492 98h7l-1 3h-6z"/><path fill="#403f46" d="m1007.13 97.81 2.87.19-1 3h-7c1.24-2.97 1.8-2.99 5.13-3.19"/><path fill="#38373b" d="m497 86 2 1q2.06.1 4.13.06l2.19-.02L507 87v2h-8l-1 3-2-1z"/><path fill="#000002" d="M1223 81h3v6l-4-2z"/><path fill="#4d4c51" d="M714 79c5.66.62 5.66.62 7.38 2.56L722 83l-7-1z"/><path fill="#858487" d="M687 74c1.88-.19 1.88-.19 4 0a18 18 0 0 1 2 3 25 25 0 0 0 3 2c-4.76-.35-4.76-.35-7-1-1.44-2.06-1.44-2.06-2-4"/><path fill="#e8e8e9" d="m839 74 1 4h-6v-3z"/><path fill="#3b3c41" d="M1220 73h6l-1 7h-2v-5l-3-1z"/><path fill="#7f8083" d="m927 54 2 1-1 5h-5l-1-2 1.94-.31L926 57z"/><path fill="#a0a0a4" d="M1205 51c1.75-.37 1.75-.37 4 0 1.63 1.88 2.74 3.83 4 6-2.31-.19-2.31-.19-5-1-1.81-2.56-1.81-2.56-3-5"/><path fill="#8d8d90" d="M1187 31h2c2.04 2.9 3 4.4 3 8-2.5-1.19-2.5-1.19-5-3-.31-2.69-.31-2.69 0-5"/><path fill="#a5a3a6" d="m1175 23 1.69.88a33 33 0 0 0 4.93 2.06c2.44 1.09 3.21 1.72 4.38 4.06h-4v-3l-2.37-.31c-2.63-.69-2.63-.69-3.94-2.25z"/><path fill="#525055" d="m1035 15 2 1c-.31 1.94-.31 1.94-1 4-3.6 1.2-6.23 1.07-10 1l1-2c2.31-.59 4.62-.74 7-1z"/><path fill="#f8f7f8" d="M1164 14h5l1 4h-5z"/><path fill="#f3f3f4" d="M1027 14h5l-1 4h-5z"/><path fill="#555558" d="M506 1834h5l-1 4h-4z"/><path fill="#ebeaec" d="m1298 1822 4 1-1 3h-6z"/><path fill="#8e8e8f" d="M443 1812c6.75-.12 6.75-.12 9 1l-1 5-1.19-1.94c-2.2-2.5-3.57-2.7-6.81-3.06z"/><path fill="#8d8b8f" d="M1340 1810h6l-1 4c-1.94-.31-1.94-.31-4-1z"/><path fill="#111214" d="m1314 1808 1 2h7l-8 4z"/><path fill="#08090f" d="M1306 1807h5l1 3h-7z"/><path fill="#090b11" d="M457 1807h6v3h-6z"/><path fill="#fcfcfc" d="m426 1806 6 1v3h-5z"/><path fill="#b0b3b2" d="M410 1802h6l-2 4-4-1z"/><path fill="#d8d8d9" d="M1368 1790h6l-1 4c-1.94-.31-1.94-.31-4-1z"/><path fill="#737476" d="M402 1786c2.88-.19 2.88-.19 6 0 1.38 1.5 1.38 1.5 2 3l-5 1z"/><path fill="#dcdbdd" d="m1386 1772 4 2-1 4h-3z"/><path fill="#000005" d="M1201 1774h8l-1 3c-2.37.19-2.37.19-5 0z"/><path fill="#cecfcf" d="M374 1760v6l-4-1c.38-1.94.38-1.94 1-4 2-1 2-1 3-1"/><path fill="#b0afb1" d="m1398 1756 4 2-1 4h-3z"/><path fill="#000002" d="M449 1758h6l1 3h-7z"/><path fill="#acacae" d="m1406 1744 4 2-1 4h-3z"/><path fill="#9e9fa1" d="M362 1740v6l-4-1c.38-1.94.38-1.94 1-4 2-1 2-1 3-1"/><path fill="#99999f" d="m1278 1732 4 3c-1.31 1.5-1.31 1.5-3 3h-3z"/><path fill="#d0d2d2" d="M351 1727h3v6h-3z"/><path fill="#85848c" d="m409 1710 3 3h-2v5c-2-1-2-1-3.12-4.06L406 1711z"/><path fill="#95949c" d="M398 1684h2l1 9-3-1z"/><path fill="#000003" d="M352 1612h2v14h-1v-11h-2z"/><path fill="#010207" d="M1246 1609c-1.15 2.47-2.05 4.05-4 6l-1-2-2 1c1-3 1-3 2.38-4.19 1.62-.81 1.62-.81 4.62-.81"/><path fill="#464450" d="m564 1574-2 4h-3l-1-4c3-1 3-1 6 0"/><path fill="#7d7985" d="M1278 1486h6v3h-6z"/><path fill="#4f4e59" d="M1262 1483h3v8l-3-2c-.19-3.12-.19-3.12 0-6"/><path fill="#413f4b" d="m1270 1484 4 1-3 6-2-1z"/><path fill="#a1a1a4" d="M1452 1434h6l-1 4c-1.94-.31-1.94-.31-4-1z"/><path fill="#9e9ba0" d="M1464 1426h6l-1 4c-1.94-.31-1.94-.31-4-1z"/><path fill="#181821" d="m801 1424 2 1c-3.32 4.8-3.32 4.8-6.75 5.81l-2.25.19z"/><path fill="#07070c" d="M1348.94 1401.81c3.06.19 3.06.19 5.06 2.19-5.27.98-5.27.98-7 1l-2-2c1-1 1-1 3.94-1.19"/><path fill="#c7c7cb" d="m1502 1388 4 2-1 4h-3z"/><path fill="#c4bdb4" d="M1328 1375h6l-1 4-5-2z"/><path fill="#7e1304" d="m1421 1372 2 1v5c-3.7-.62-3.7-.62-5.25-2.56l-.75-1.44h4z"/><path fill="#54331c" d="M1220 1366h9l-1 4-3-1v-1l-5-1z"/><path fill="#080a11" d="M1499 1353a28 28 0 0 1 3 10h-3z"/><path fill="#000104" d="M1270 1360h2l1 7h-3z"/><path fill="#7a0d03" d="m1447 1355 2 1-1 4 2 1-3 2-2-6z"/><path fill="#54341d" d="M1146 1342c-.56 1.5-.56 1.5-2 3-2.39.28-4.58.13-7 0 2.61-3.48 4.83-3.28 9-3"/><path fill="#cec9bb" d="M1335 1289h3c-.59 5.37-.59 5.37-1 7l-2 1z"/><path fill="#000004" d="M1268 1265h1c.37 5.54.37 5.54-1.5 7.88l-1.5 1.12c-.24-5.51-.24-5.51.94-7.87z"/><path fill="#090a0e" d="m1490 1243 4 2-2 1c-1.12 2.06-1.12 2.06-2 4h-2v-4h2z"/><path fill="#d1aa92" d="m1381 1245-9 5c1-4 1-4 2.81-5.31 2.19-.69 2.19-.69 6.19.31"/><path fill="#c89e88" d="M1424 1241v3c-2.94-.37-2.94-.37-6-1l-1-2c3.13-1.04 3.99-.93 7 0"/><path fill="#06070e" d="M1482 1238c2 1.25 2 1.25 4 3 .25 2.19.25 2.19 0 4h-3v-4l-2-1z"/><path fill="#949196" d="m1502 1238 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#030204" d="m946 1235 2 1-1 2 3 1-6 2c.88-4.87.88-4.87 2-6"/><path fill="#000002" d="M1482 1234h4v5l-4-1z"/><path fill="#bdc7d1" d="m1529.06 1214.94 2.94.06v1l-5 1-1 5-3-2c2.41-4.99 2.41-4.99 6.06-5.06"/><path fill="#060505" d="m1065 1217 4 1-3 8c-1.11-2.22-1.08-3.56-1-6l-2 1z"/><path fill="#c7d1d9" d="M1548 1214h6l-1 4c-1.94-.31-1.94-.31-4-1z"/><path fill="#8d8e94" d="M1467 1211c2.95 2.87 5.63 5.63 8 9-6.43-2.43-6.43-2.43-8-4-.19-2.62-.19-2.62 0-5"/><path fill="#c7cfd6" d="M1560 1206h6l-1 4c-1.94-.31-1.94-.31-4-1z"/><path fill="#c4c4c4" d="m1458 1194 4 1v3h-6z"/><path fill="#cbd2d6" d="M1523 1190h3v6l-4-2z"/><path fill="#dee3e8" d="m1586 1184 4 2-1 4h-3z"/><path fill="#94a2ad" d="M1565 1187h4v4h-5z"/><path fill="#a2adb6" d="M1543 1182h2v6l-3 1q-.06-3 0-6z"/><path fill="#c3c9cf" d="m1598 1168 4 2-1 4h-3z"/><path fill="#9fa9b5" d="m1605 1135 5 2v1l-5 1-1 5h-1c-.29-6.43-.29-6.43 2-9"/><path fill="#1f1e27" d="m1140 1126-1 3-11 1c2.17-2.17 3.1-2.45 6-3.12l2.13-.51c1.87-.37 1.87-.37 3.87-.37"/><path fill="#565658" d="m1446 1121 4 1v4h-4z"/><path fill="#1e1d26" d="M934 1121c3.8.67 7.35 1.73 11 3-3.81 1.47-6.32.55-10-1z"/><path fill="#020208" d="M752 1114h2l-1 5h-2v2l-3-1q.93-1.5 1.88-3l1.05-1.69z"/><path fill="#d6dbe0" d="M1502 1102h6l-2 4-4-1z"/><path fill="#010107" d="M772 1086c.69 1.81.69 1.81 1 4-1.31 1.75-1.31 1.75-3 3h-2c.56-3.27 1.5-4.83 4-7"/><path fill="#c8ced4" d="m1606 1066 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#e6e9ec" d="m1456 1048 3 1v3h2l1 5c-1.87-.56-1.87-.56-4-2-.97-2.32-1.45-4.53-2-7"/><path fill="#dbdce0" d="m1594 1050 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#4b4a54" d="m1266 1033 3 1q.08 1.94.13 3.88l.07 2.17c-.2 1.95-.2 1.95-2.2 3.95v-7l-2-1z"/><path fill="#dcdfe3" d="m1578 1034 4 1v3h-6z"/><path fill="#07090e" d="m1039 1034 7 1-1 2h-6z"/><path fill="#29292e" d="m296 1033 8 1v1h-5l-1 3-3-1z"/><path fill="#fcfcfd" d="M278 1034c4.68.62 4.68.62 6.31 2.56l.69 1.44h-6z"/><path fill="#07080e" d="M1028 1030h6v3h-6z"/><path fill="#09090e" d="M1404 1025h2v6l-3 1q-.06-3 0-6z"/><path fill="#f4f4f5" d="m1414 1018 3 1v6l-3-1z"/><path fill="#e9eae9" d="m1414 1013 4 1v11h-1l-1-10h-2z"/><path fill="#000002" d="M995 1010h5l1 4-6-1z"/><path fill="#dee1e5" d="m1546 1005 4 1v4h-4z"/><path fill="#bdbcbc" d="M1020 982c2.13.38 2.13.38 4 1-1.06 1.5-1.06 1.5-3 3-2.4.27-4.58.14-7 0l1.88-.87C1018 984 1018 984 1020 982"/><path fill="#b6b6b6" d="m1024 982 10 2v2l-10-1z"/><path fill="#9e9da0" d="M74 974h6l-2 4-4-1z"/><path fill="#08080d" d="m1239 970 2 1v6h-3q-.06-3 0-6z"/><path fill="#cbcac9" d="M1166 971c-2.52 2-4.86 2.49-8 3v-3c3.29-1.1 4.71-.8 8 0"/><path fill="#9a9a9a" d="M343 967h8v3c-2.87.19-2.87.19-6 0z"/><path fill="#06060c" d="M1010 967h9l1 2c-2.75.69-2.75.69-6 1-2.37-1.44-2.37-1.44-4-3"/><path fill="#919091" d="m336 963-1 3h-9c3.32-3.06 5.55-3.3 10-3"/><path fill="#d8d9d7" d="M1223 962h2l1 8h-3z"/><path fill="#adb0b0" d="M54 962h6l-2 4-4-1z"/><path fill="#000003" d="M689 962h13v3h-3v-2h-10z"/><path d="M1239 960h2v7h-3z"/><path fill="#f9f9f9" d="m58 958 6 1v3h-5z"/><path fill="#d9dbda" d="M42 954h6l-2 4-4-1z"/><path fill="#a1adb4" d="m1530 945 4 1v4h-4z"/><path fill="#030306" d="m1096 947 3 1 1 3-5 2 1-3-2-1z"/><path fill="#a09fa0" d="M210 946h14v1h-11v2l-3-1z"/><path fill="#161718" d="m49 945 7 1 1 3 2 2c-1.87.19-1.87.19-4 0-1-1.37-1-1.37-2-3-2.12-1.19-2.12-1.19-4-2z"/><path fill="#acacad" d="M1161 942h6l-1 3h-6z"/><path fill="#020204" d="m1427 932 3 1c-.37 2.44-.37 2.44-1 5l-2 1c-1.12-4.75-1.12-4.75 0-7"/><path fill="#9d9d9c" d="m26 927 4 2v5c-2-1.25-2-1.25-4-3-.25-2.19-.25-2.19 0-4"/><path fill="#010105" d="M1172 923h2v3l3 1h-5l-1 2v-2l-4-1 1.94-.37 2.06-.63z"/><path fill="#acafaf" d="M14 920v6l-4-1c.38-1.94.38-1.94 1-4 2-1 2-1 3-1"/><path fill="#fbfcfc" d="M1450 921h4v4h-5z"/><path fill="#d0cfce" d="m1235 919 3 1-1 4-1-1-1 7h-1a433 433 0 0 1-.1-5.96c.1-2.04.1-2.04 1.1-5.04"/><path fill="#ebedef" d="M1458 914c2.13-.19 2.13-.19 4 0l-1 4h-2l-1 2v-2l-2-1c.75-1.5.75-1.5 2-3"/><path fill="#99999d" d="M6 908v6l-4-1c.38-1.94.38-1.94 1-4 2-1 2-1 3-1"/><path fill="#2a292e" d="M101 902c5.66 1.48 5.66 1.48 7.38 4.13L109 908l-7-2z"/><path fill="#454349" d="m920.16 894.7 2.46.11 2.48.08 1.9.11c-3.75 2.04-6.8 2.52-11 3 1.12-2.34 1.52-2.94 4.16-3.3"/><path fill="#d0d7dc" d="M1475 882h3v6l-4-2z"/><path fill="#2a292e" d="M987 875h6l-1 3h-6z"/><path fill="#555358" d="M145 855c1.92.71 1.92.71 4 2 .83 2.16.83 2.16 1.25 4.63l.45 2.47.3 1.9c-2.54-1.27-2.9-2.42-4.12-4.94l-1.08-2.15C145 857 145 857 145 855"/><path fill="#878687" d="m1142 847-1 3h-7c2.47-3.12 4.1-3.32 8-3"/><path fill="#858384" d="m1128 845-1 2c-3.06.63-3.06.63-6 1l-1-3c3.29-1.1 4.71-.8 8 0"/><path fill="#c2c9d0" d="m1522 842 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#828182" d="m1152.06 834.94 1.94.06v2c-4.38 3-4.38 3-7 3 2.41-4.98 2.41-4.98 5.06-5.06"/><path fill="#d6dce0" d="m1514 830 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#858486" d="M1203 827v2c-3 1.5-5.66 1.06-9 1 1-2 1-2 2.81-2.62 2.12-.37 4.05-.46 6.19-.38"/><path fill="#f1f2f5" d="M1469 823h1c.42 4.66-1.02 7.9-3 12h-1c.78-8.45.78-8.45 3-12"/><path fill="#26242c" d="M1144 827h6v3h-7z"/><path fill="#87878a" d="m1194 821-1 3-7 1c2-3.54 3.92-4 8-4"/><path fill="#8e8d90" d="M86 812c1 2 1 2 .31 5.06C85 820 85 820 82.87 820.88L81 821c1.5-3.11 3.2-6.05 5-9"/><path fill="#9d9d9e" d="M141 808h1v7l-3 1c.88-6.87.88-6.87 2-8"/><path fill="#000001" d="M1276 806h6v3h-7zM25 804v7h-3v-6c2-1 2-1 3-1"/><path fill="#edeced" d="M18 802v5l-4 1v-5c3-1 3-1 4-1"/><path fill="#c7c7c7" d="M15 790h3v6l-4-2z"/><path fill="#c0bcc1" d="M1488 782h6l-1 4c-1.94-.31-1.94-.31-4-1z"/><path fill="#010102" d="M1471 778h4v4h-5z"/><path fill="#818182" d="M1317 778c-1.37 2-1.37 2-3 4h-2l-1-2-2-1c4.63-2.12 4.63-2.12 8-1"/><path fill="#aaa8ad" d="M1500 774h6l-1 4c-1.94-.31-1.94-.31-4-1z"/><path fill="#cfcfcf" d="M27 774h3v6l-4-2z"/><path fill="#979799" d="m132 767 3 1v2l-7 3c1.15-2.47 2.05-4.05 4-6"/><path fill="#9b9c9c" d="M1512 766h6l-1 4c-1.94-.31-1.94-.31-4-1z"/><path fill="#08090e" d="m61 762 1 3 2 1c-1.31 1.5-1.31 1.5-3 3h-3v-4h3z"/><path fill="#a4a3a6" d="M1524 758h6l-1 4c-1.94-.31-1.94-.31-4-1z"/><path fill="#b08ce1" d="M488 751h3l-1 4h-10v-1h8z"/><path fill="#010105" d="M127 754h5l-1 4h-4z"/><path fill="#ccc9ce" d="M1536 750h6l-1 4c-1.94-.31-1.94-.31-4-1z"/><path fill="#613e9b" d="M750.75 750.94 752 752c-2.82 1.61-4.75 2.23-8 2l1-3c2.4-1.04 3.42-1.31 5.75-.06"/><path fill="#050508" d="m1408 745 2 1-2 2 2 2h-3l-1 3v-3l-4-1 2.44-1.44C1407 746 1407 746 1408 745"/><path fill="#0a0511" d="m431 747 9 2v1l-9 1z"/><path fill="#959497" d="m150 747 1 2 2 1-3.31.5-1.87.28C146 751 146 751 143 751c2.17-2.5 3.73-3.44 7-4"/><path fill="#000002" d="m412 742 7 1v2h-7z"/><path fill="#c9c7c9" d="M1552 738h6l-1 4c-1.94-.31-1.94-.31-4-1z"/><path fill="#000004" d="M390 730h5l1 4-5-1z"/><path fill="#b0afb2" d="M1568 726h6l-1 4c-1.94-.31-1.94-.31-4-1z"/><path fill="#8347cd" d="m411 726 1 3a18 18 0 0 0 3 2l-5-1-1-3h-6v-1c5.75-1.12 5.75-1.12 8 0"/><path fill="#737173" d="M1438 722h6l-5 5-3-1 2-1z"/><path fill="#222126" d="M1203 716q-1.9 1.3-3.81 2.56l-2.15 1.44c-2.04 1-2.04 1-5.04 0q1.9-1.3 3.81-2.56l2.15-1.44c2.04-1 2.04-1 5.04 0"/><path fill="#cbcccb" d="M95 706c1.94.31 1.94.31 4 1l1 3h-6z"/><path fill="#c3a2e2" d="M391 701h1l.68 6.05C393 709 393 709 394 711l-4-2c-.12-5.75-.12-5.75 1-8"/><path fill="#979698" d="m226 702 7 2v2c-4.55.37-4.55.37-6.81-1.5L225 703z"/><path fill="#070213" d="M1007 698c-4.66 2.73-7.6 3.32-13 3 3.84-2.99 8.3-4.8 13-3"/><path fill="#c4c3c4" d="M111 694c1.94.31 1.94.31 4 1l1 3h-6z"/><path fill="#080a0f" d="M155 682h6v3c-3 1-3 1-6 0z"/><path fill="#827e80" d="M1510 680c-1 3-1 3-3.06 4.19l-1.94.81v-2l-3-1c2.82-1.61 4.75-2.23 8-2"/><path fill="#c5c4c5" d="M131 678c1.94.31 1.94.31 4 1l1 3h-6z"/><path fill="#18181d" d="M1245 675c-2.3 2.3-3 2.32-6.12 2.63l-2.2.22-1.68.15c3.18-3.48 5.43-4.42 10-3"/><path fill="#97999a" d="M143 670c1.94.31 1.94.31 4 1l1 3h-6z"/><path fill="#929595" d="M155 662c1.94.31 1.94.31 4 1l1 3h-6z"/><path fill="#b0aeae" d="M167 654c1.94.31 1.94.31 4 1l1 3h-6z"/><path fill="#0d0c11" d="M1283 652h-2l-1 3-6 1c2.18-3.99 4.62-5.46 9-4"/><path fill="#626365" d="m1555 651-2 4h-3v-2l-2-1c3.63-2.12 3.63-2.12 7-1"/><path fill="#06070c" d="m1634 648 1 2 3 1h-3v3h-4c0-3 0-3 1.5-4.69z"/><path fill="#abacab" d="M187 642c1.94.31 1.94.31 4 1l1 3h-6z"/><path fill="#04070c" d="M1644 639h2c-.37 2.44-.37 2.44-1 5l-2 1c-2.12-.94-2.12-.94-4-2l3-1q1.01-1.5 2-3"/><path fill="#010105" d="M1151 642h7c-.62 1.5-.62 1.5-2 3-3.12.19-3.12.19-6 0z"/><path fill="#08090f" d="M228 638h6v3c-3.75 1.13-3.75 1.13-6 0z"/><path fill="#151319" d="M1314 632c2.25.25 2.25.25 4 1-1.19 1.5-1.19 1.5-3 3-2.69.19-2.69.19-5 0 1.75-2.06 1.75-2.06 4-4"/><path fill="#acadae" d="M207 630c1.94.31 1.94.31 4 1l1 3h-6z"/><path fill="#7141ba" d="M550 620h9v1l-2.44.38-2.56.62-1 2h-2l-1 2z"/><path fill="#a5a5a6" d="M235 614c1.94.31 1.94.31 4 1l1 3h-6z"/><path fill="#919596" d="M260 611a37 37 0 0 1 1 4c-1 1-1 1-4.06 1.06L254 616v-2l2.44-.94C259 612 259 612 260 611"/><path fill="#643ea4" d="m660 610 1 2c-6.52.25-6.52.25-8.94-1l-1.06-1c5.63-1.12 5.63-1.12 9 0"/><path fill="#d0ced1" d="m1698 600 4 2-1 4h-3z"/><path fill="#5d3b98" d="M742 598c-.56 1.5-.56 1.5-2 3-2.39.28-4.58.13-7 0 1.86-3.71 5.3-3.1 9-3"/><path fill="#000001" d="M1687 593h3v5h-4z"/><path fill="#c0c0c3" d="m1706 588 4 2-1 4h-3z"/><path fill="#030109" d="m1264 587 1 3-4 2v-2l-4 1c1.47-2.93 3.74-4 7-4m-5 5 2 1Z"/><path fill="#aaa9ac" d="M1706 575h3a49 49 0 0 1-4 7c-.62-2.37-.62-2.37-1-5z"/><path fill="#7b7a7d" d="M1709 567q2.51.43 5 1l-4 1v5l-2-1q-.06-2.5 0-5z"/><path fill="#010006" d="M1304 562c0 3 0 3-1.25 4.75-1.75 1.25-1.75 1.25-3.94.94L1297 567c2.36-2.53 3.66-3.89 7-5"/><path fill="#482884" d="M885 558v2c-3 1.5-5.66 1.06-9 1v-2c3.06-.54 5.89-1 9-1"/><path fill="#46474a" d="M335 543h3v7l-3-1z"/><path fill="#06070b" d="M867 539h9l1 3h-5v-2h-5z"/><path fill="#f7f7f7" d="M343 534h3q-.9 3.03-2 6l-2 1q-.06-3 0-6z"/><path fill="#38225b" d="m1341 518 2 1-3 8h-3c.65-3.49 2.1-6.03 4-9"/><path fill="#969598" d="M415 515c5.75-.12 5.75-.12 8 1v2l-8-1z"/><path fill="#646769" d="M355 497h1v8h-2v2l-4-1 1.94-1.12c2.7-2.46 2.74-4.33 3.06-7.88"/><path fill="#2d1b53" d="m1049 497 2 1-3 4-5-1c2.05-2.25 3-3 6-4"/><path fill="#111116" d="M1427 486c3 2 3 2 3.51 3.73l.49 5.27h-2z"/><path fill="#e1e0e3" d="m1735 482 2 1v6h-3q-.06-3 0-6z"/><path fill="#010003" d="m1071.63 478.81 2.37.19v3h-7c1.22-2.66 1.6-2.97 4.63-3.19"/><path fill="#959396" d="m1726 470 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#080413" d="M1126 460c-3.2 2.83-5.78 3.52-10 4 5.35-5.6 5.35-5.6 10-4"/><path fill="#d6d6d5" d="m1718 458 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#6b6d6d" d="M362 449h1c.24 5.48.24 5.48-.87 7.88L361 458h-2a26 26 0 0 1 3-9"/><path fill="#d8d8d8" d="m1710 450 4 1v3h-6z"/><path fill="#9c9b9e" d="m1698 442 4 1v3h-6z"/><path fill="#08070d" d="M1561 443q2.15-.12 4.31-.19l2.43-.1c2.74.35 3.55 1.18 5.26 3.29-4.03-.55-8.02-1.15-12-2z"/><path fill="#afafb0" d="m1686 434 4 1v3h-6z"/><path fill="#090414" d="M1345 425h2c2.47 2.47 2.98 3.7 3.19 7.25L1350 435c-2.64-3.13-3.84-6.09-5-10"/><path fill="#dcdcdd" d="M1663 423h6v3h-6z"/><path fill="#422f6c" d="m1222 423 6 1v1h-5l-1 3-3-1z"/><path fill="#cecdcd" d="M1623 407h6v3h-6z"/><path fill="#29272d" d="m912.44 404.25 1.56.75c-6.23 4.08-6.23 4.08-9.44 3.75L903 408c6.23-4.08 6.23-4.08 9.44-3.75"/><path fill="#747777" d="m370 393 1 2c-.87 1.5-.87 1.5-2 3h-2l-1 4v-7z"/><path fill="#030107" d="M1254 384v2l3 1-5 3v-3l-3-1c2.75-2 2.75-2 5-2"/><path fill="#7b7d83" d="M1486 382h5l1 4-5-1z"/><path fill="#d9d8db" d="M1373 370c1.94.81 1.94.81 4 2l1 3h-5z"/><path fill="#c1c0c1" d="m1370 354 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#020007" d="m1306 342 1 4h-7c1.16-2.22 1.68-2.9 4.13-3.69z"/><path fill="#a4a2a4" d="m1362 342 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#49484d" d="m1335 310 2 1v5l5 1c-1.25 1.06-1.25 1.06-3 2-2.19-.37-2.19-.37-4-1z"/><path fill="#99989c" d="M1337 310h1v5l4 1q-2.49.57-5 1c-1-1-1-1-1.12-3.5.12-2.5.12-2.5 1.12-3.5"/><path fill="#3f4343" d="M388 290h1v8l-7 1 2.44-2.25c2.15-2.13 3.22-3.65 3.56-6.75"/><path fill="#09090f" d="M1315 284h3c.37 4.55.37 4.55-1.5 6.81L1315 292z"/><path fill="#868589" d="m1321 274 .81 1.88A8.7 8.7 0 0 0 1326 280c-3.75 1.13-3.75 1.13-6 0q-.06-2.5 0-5z"/><path fill="#121316" d="M445 263c1.07 3.27.9 5.43.06 8.75l-.59 2.42L444 276h-1v-11h2z"/><path fill="#28272e" d="m898 248 1 4-3 1c-1.19 2.56-1.19 2.56-2 5l-1-2c1-1.95 1-1.95 2.44-4.12l1.43-2.2z"/><path fill="#7f7b81" d="M1309 246h1v5l4 1q-2.49.57-5 1c-1-1-1-1-1.12-3.5.12-2.5.12-2.5 1.12-3.5"/><path fill="#4a4a4d" d="m1299 226 2 1-.12 2.31c.12 2.69.12 2.69 1.18 4.25.94 1.44.94 1.44.57 3.63L1302 239c-3.28-4.37-3.35-7.77-3-13"/><path fill="#2c2c31" d="M775 222h6v3h-7z"/><path fill="#908f91" d="M464 219h1l-1 9h-2l.44-3.94.24-2.21C463 220 463 220 464 219"/><path fill="#898a8b" d="M655 219h8l-1 3-7-1z"/><path fill="#49494e" d="M634 213c3.11 1.5 6.05 3.2 9 5-1.69.81-1.69.81-4 1-2-1.47-3.36-3.09-5-5z"/><path fill="#88888a" d="m786 211-1 3h-7c2.47-3.12 4.1-3.32 8-3"/><path fill="#565559" d="M511 201c0 3.62-.92 5.08-3 8l-3 1c1.31-3.94 2.92-6.23 6-9"/><path fill="#2e2d32" d="m939 194 4 1-4 2v2l-4 2v-3h3z"/><path fill="#08080e" d="M418 193h3v9l-3-2c-.4-2.39-.14-4.56 0-7"/><path fill="#7d7e81" d="M613 190c3.26.35 4.02 1.02 6.25 3.56L621 196l-1 2-3.5-2.87-1.97-1.62L613 192z"/><path fill="#000002" d="M467 185h3v5l-4 1z"/><path fill="#86888a" d="M415 179h1c.13 6.75.13 6.75-1 9l-5-1 1.94-1.19c2.5-2.2 2.7-3.57 3.06-6.81"/><path fill="#828385" d="m821.13 169.88 2.87.12-1 3-7-1c2-2 2-2 5.13-2.12"/><path fill="#848788" d="m422 162 2 1v5l-5 1-1-2h4z"/><path fill="#030208" d="M987 158h3l-1 3-3-1zm-4 3h2v3l-4 1z"/><path fill="#000004" d="M848 147h7c-1.19 1.5-1.19 1.5-3 3-2.69.19-2.69.19-5 0z"/><path fill="#afafb0" d="M423 142h3v6l-4-2z"/><path fill="#010006" d="M893 131h6v3h-7z"/><path fill="#c8c9c9" d="M431 130h3v6l-4-2z"/><path fill="#7b7b7e" d="m973 125 2 1c-2.12 2.33-4.03 3.89-7 5l-3-1c2.69-2.25 4.65-3.88 8-5"/><path fill="#08090b" d="m463 114 .44 1.94C464 118 464 118 465 119v2l-7-1z"/><path fill="#37353c" d="M1059 103c2.19.31 2.19.31 4 1l-6 5-1-3c1.25-1.56 1.25-1.56 3-3"/><path fill="#07070b" d="M782 94h12l-1 3-11-2z"/><path fill="#b9bdbc" d="M471 90c1.94.31 1.94.31 4 1l1 3h-6z"/><path fill="#a9a7a8" d="m1242 86 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#1a1a1d" d="m710 78 4 1 1 5h-5z"/><path fill="#fbfbfc" d="M507 78h5v3l-6 1z"/><path fill="#a2a2a4" d="m1234 74 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#060607" d="M1211 65h4l2 4h-6z"/><path fill="#4f5152" d="m554 62 5 1v3h-5z"/><path fill="#b5b2b4" d="m1226 62 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#0a0a0f" d="m1206 62 1 3h3v4c-1.94-.31-1.94-.31-4-1-1-3-1-3 0-6"/><path fill="#0b0a10" d="M933 62h6l1 3h-7z"/><path fill="#c9c8c9" d="m1214 46 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#19191c" d="m1001 26 1 3h6v1h-5v3h-3q-.06-3 0-6z"/><path fill="#c8c9c8" d="m1194 26 4 1v3h-6z"/><path fill="#e6e5e7" d="M1003 22h5v3l-6 1z"/><path fill="#2a2c2e" d="M1074 6c2.44.38 2.44.38 5 1l1 2h-2v2l-4-1z"/><path fill="#313134" d="M1294 1821h-2l-1 5-1-3h-6v-1c6.63-2.12 6.63-2.12 10-1"/><path fill="#f0f0f0" d="m454 1818 5 1 1 3h-5z"/><path fill="#a8a6aa" d="M1353 1802h5l-1 4-4-1z"/><path d="M423 1795h6v3h-6z"/><path fill="#424149" d="M1242 1755h6v3h-6z"/><path fill="#fbfbfc" d="M1408 1732h2v5l-4 1c.88-4.87.88-4.87 2-6"/><path fill="#4b4851" d="M444 1731c3.91 1.27 5.8 2.51 8 6l-1 2-3.5-2.87-1.97-1.62L444 1733z"/><path d="M370 1733h3v6h-3z"/><path fill="#96949c" d="M1288 1723h2c-.37 2.44-.37 2.44-1 5l-2 1v-2l-4-1h4z"/><path fill="#0f1116" d="M405 1715c1.5.63 1.5.63 3 2 .31 3 .31 3 0 6l-2 2c-1.2-3.6-1.07-6.23-1-10"/><path fill="#7e7c86" d="M1298 1701v7l-3-1v-5c2-1 2-1 3-1"/><path fill="#06050b" d="m933 1679 3 2-1 7-1-3h-3l1-2 2 1z"/><path fill="#13141a" d="M928 1676c4.55-.37 4.55-.37 6.81 1.5l1.19 1.5-1 2-7-3z"/><path fill="#313235" d="M347 1672h3c1.43 2.35 2.09 3.48 1.63 6.25L351 1680l-1.37-2.37C348 1675 348 1675 346 1673z"/><path fill="#797881" d="M1191 1642c2.88-.12 2.88-.12 6 0l2 2-7 1z"/><path fill="#4a4952" d="M474 1639c3.41.79 6.69 1.87 10 3v1l-9-1z"/><path fill="#4e4c56" d="m1241 1626 1 3c-3.61 3.9-3.61 3.9-5 5h-3z"/><path fill="#4c4957" d="m490 1590 5 1v3l-5-1z"/><path fill="#03040a" d="m478 1585 4 2-1 4-4-2z"/><path fill="#efeeef" d="m336 1564 2 1v6l-2 1c-1.12-5.75-1.12-5.75 0-8"/><path fill="#2f2e39" d="m796 1423 2 1-3.37 3-1.9 1.69C791 1430 791 1430 789 1430c1.07-3.21 1.36-3.52 4.06-5.19z"/><path fill="#da6f34" d="m1382 1391 10 1-3 3h-2v-2l-5-1z"/><path fill="#e4e3e5" d="M1506 1384h4l-2 6-2-1z"/><path fill="#010203" d="m1487 1386 4 1c-.31 1.94-.31 1.94-1 4l-3 1z"/><path fill="#442b18" d="M1267 1369c2.78 1.7 3.85 2.48 4.75 5.69l.25 2.31c-2.5-1.75-2.5-1.75-5-4-.31-2.25-.31-2.25 0-4"/><path fill="#070a11" d="M1495 1365c1.5.69 1.5.69 3 2 .19 2.63.19 2.63 0 5h-3z"/><path fill="#c4beb8" d="M1314 1359h2l2 5-4 1z"/><path fill="#b1b1b5" d="m1512.06 1357.94 1.94.06v2l-1.37.88c-2.04 1.4-2.66 2.88-3.63 5.12-.1-5.37-.1-5.37 0-7 1-1 1-1 3.06-1.06"/><path fill="#16161e" d="M906 1360c-2.97 1.9-5.51 3.35-9 4 1-3 1-3 3.38-4.25 2.62-.75 2.62-.75 5.62.25"/><path fill="#010005" d="M1132 1350c5.37.59 5.37.59 7 1l1 2c-5.75.13-5.75.13-8-1z"/><path fill="#c5bdb6" d="M1306 1344h2l1 7h-3z"/><path fill="#807c78" d="m1296 1341 2 3 1.19 1.44c.81 1.56.81 1.56.43 3.75q-.3.9-.62 1.81c-2.35-2.55-2.98-3.71-3.19-7.25z"/><path fill="#4d3423" d="m1078 1317 4 1 1 7c-2.77-1.26-3.06-2.17-4.19-5.12z"/><path fill="#8d8b92" d="M393 1317h1l1 9-3-1c-.1-5.37-.1-5.37 0-7z"/><path fill="#e1470a" d="M1341 1300h1q.12 2.69.19 5.38l.1 3.02c-.29 2.6-.29 2.6-1.8 3.95l-1.49.65v-4h2z"/><path fill="#5a3722" d="m1043 1306 4 2v3l-4-1z"/><path fill="#9c2706" d="M1402 1299c1.63 4.07.3 7-1 11h-1q-.08-1.9-.12-3.81l-.08-2.15c.22-2.25.8-3.3 2.2-5.04"/><path fill="#573622" d="m1250 1293 4 1-3 5-3-2h2z"/><path fill="#472d1c" d="M1005 1295c4.22.48 6.8 1.17 10 4-2.75.25-2.75.25-6 0-2.37-2-2.37-2-4-4"/><path fill="#472e1d" d="M989 1288c4.23.6 8 1.57 12 3-3 1.5-5.86.85-9 0-1.94-1.56-1.94-1.56-3-3"/><path fill="#d0c1ad" d="M1338 1286h2q-.17 2.44-.37 4.88l-.22 2.74c-.41 2.38-.41 2.38-2.41 4.38q-.05-2.71-.06-5.44l-.04-3.06c.1-2.5.1-2.5 1.1-3.5"/><path fill="#400601" d="M1461 1280h2c2.1 4.43 2.1 4.43 1.63 7.31l-.63 1.69c-2.08-3.12-3.42-5.2-3-9"/><path fill="#0e0e15" d="M848 1280c6.52.62 6.52.62 8.94 2.56L858 1284c-3.72-.5-6.73-1.12-10-3z"/><path fill="#eeedef" d="m1514 1278 4 1v5l-4-2z"/><path fill="#e1dcd6" d="M1302 1276h3v6h-3z"/><path fill="#090b11" d="M1499 1268h3v6l-2 1z"/><path fill="#4b3220" d="M941 1265c7.9.9 7.9.9 10 3v2l-4.44-1.37-2.5-.78C942 1267 942 1267 941 1265"/><path fill="#52321e" d="M1070 1262h6v3h-5z"/><path fill="#020204" d="M1303 1257h4l-1 5h-3z"/><path d="M1495 1252h3v6h-3z"/><path fill="#010104" d="M922 1247h6v3h-6z"/><path fill="#302f38" d="m858 1232 1 2h3v2l-6 2c.88-4.87.88-4.87 2-6"/><path fill="#d5c9c1" d="m1389 1230 4 1v2h-11v-1h7z"/><path fill="#957358" d="M1073 1226h1q.15 2.4.25 4.81l.14 2.71c-.39 2.48-.39 2.48-2.4 4.3L1070 1239l.94-2c1.3-3.65 1.7-7.16 2.06-11"/><path fill="#000001" d="M1061 1228h1v8h-3c.88-6.87.88-6.87 2-8"/><path fill="#8d694d" d="m1282 1227 1 2 2 1-4 4v-4l-2-1z"/><path fill="#d1d8dc" d="M1503 1214h3v5l-4-1z"/><path fill="#dfe4e8" d="M1517 1210h2l-1 5h-4c1.88-3.87 1.88-3.87 3-5"/><path fill="#15151d" d="M929 1208q-2.18 1.05-4.37 2.06l-2.47 1.16-2.16.78-2-1c3.82-3.03 6.19-4.5 11-3"/><path fill="#000001" d="M1442 1202h6v3h-6z"/><path fill="#313235" d="m1443 1194 1 7h-2l-1-4h-6v-1l3.44-1 1.93-.56c1.63-.44 1.63-.44 2.63-.44"/><path fill="#4b4956" d="m520 1194 7 1v2l-8-1z"/><path fill="#14141b" d="M667 1184c-3.83 2.7-6.33 3.33-11 3 6.55-4.59 6.55-4.59 11-3"/><path fill="#3e3c47" d="M686 1178h5v3h-6z"/><path fill="#a1a9ae" d="M1532 1174h2v7l-3-1-1-3h2z"/><path fill="#e7ebf0" d="M1610 1151h3v6h-3z"/><path fill="#e8eced" d="m1530 1120 4 1v5l-4-1z"/><path fill="#a9b0b7" d="m1529 1107 4 1v4h-4z"/><path fill="#15151d" d="m1187.31 1104.25 1.69.75q-1.68 1.05-3.37 2.06l-1.9 1.16-1.73.78-2-1c4.43-4.06 4.43-4.06 7.31-3.75"/><path fill="#2a2933" d="M885 1099c5.75.88 5.75.88 8 2v2l2 1c-4.4-.5-6.71-2.09-10-5"/><path fill="#c9d0d6" d="M1486 1079h4l1 3a24 24 0 0 0 4 2l-6 2-.37-2.37c-.63-2.63-.63-2.63-2.63-4.63"/><path fill="#4d4950" d="M1445 1078h1c.25 2.81.25 2.81 0 6-2 1.88-2 1.88-4 3a26 26 0 0 1 3-9"/><path fill="#7b8994" d="M1587 1063h4l-1 5-3-1z"/><path fill="#eef0f3" d="m1572 1038 4 1 1 6c-2.5-1.81-2.5-1.81-5-4z"/><path fill="#7e7e83" d="M265 1038h6v3h-6z"/><path fill="#4e4e57" d="m1266 1035 2 2c-.37 2.63-.37 2.63-1 5l-2-2-2 1c1.88-4.87 1.88-4.87 3-6"/><path fill="#08090e" d="M1173 1034h5l1 3h-7z"/><path fill="#5c5d60" d="M287 1032c5.75.88 5.75.88 8 2l-1 4a78 78 0 0 1-7-6"/><path fill="#b0b1b2" d="M222 1021c7.02-.77 7.02-.77 9.42 1.11 1.08 1.39 1.08 1.39 2.58 3.89l-2.06-.87c-3.28-1.26-6.55-2.2-9.94-3.13z"/><path fill="#e7e8e8" d="M179 1018h6v3h-6z"/><path d="M1010 1018h5l1 3h-7z"/><path fill="#07080d" d="M212 1007q2.43-.08 4.88-.12l2.74-.08c2.38.2 2.38.2 4.38 2.2l-5 1v-1l-7-1z"/><path fill="#24232c" d="M1279 990c1.47 3.81.43 6.29-1 10h-1v-8h2z"/><path fill="#fcfcfc" d="m122 990 5 1 1 3h-5z"/><path fill="#000002" d="M1233 977c1 3 1 3 0 6h-3v-5z"/><path fill="#e2e3e3" d="M1419 972c2 2 2 2 2.2 4.16l-.08 2.47-.05 2.47-.07 1.9h-1l-1-5h-1q-.06-2.5 0-5z"/><path fill="#f2f3f3" d="M1418 961h3v6h-3z"/><path d="M75 959h6v3h-6z"/><path fill="#32323a" d="M1289 954c.26 3.15.25 4.62-1.5 7.31L1286 963v-8c2-1 2-1 3-1"/><path fill="#040407" d="M1083 952h2v2h6v1h-7v2h-2l-1-3h2z"/><path fill="#010102" d="M61 951h6v3h-6z"/><path fill="#07070c" d="M1110 939h10l-1 3-9-2z"/><path fill="#bfbebf" d="M1171 934h6v2l-7 1z"/><path fill="#b3bbc2" d="M1460 924h1c.4 4.59-.82 7.09-3 11-.95-2.17-1.14-3.56-.4-5.82q1.13-2.62 2.4-5.18"/><path fill="#a2a1a3" d="M154 927h7v3c-2.37.19-2.37.19-5 0-1.31-1.5-1.31-1.5-2-3"/><path fill="#423f46" d="M265 923h6v3h-5z"/><path fill="#9e9ea0" d="M230 922h6l1 3-7-1z"/><path fill="#000002" d="M118 918h4v2h2v2l-6-1z"/><path fill="#818183" d="M901 915c-3.2 2.83-5.78 3.52-10 4 2.07-4.14 5.74-4.24 10-4"/><path fill="#504f51" d="M117 912c3.38 1.19 5.65 2.28 8 5-5.68-.92-5.68-.92-8-3z"/><path fill="#3e3d40" d="m1226 912-3.87 2-2.18 1.13c-1.95.87-1.95.87-3.95.87v-2c5.9-3.35 5.9-3.35 10-2"/><path fill="#858587" d="m961 897 1 2h6c-2.74 2.28-5.54 2.54-9 3z"/><path fill="#b2bbc2" d="m1493 880-1 3h-3v5h-1v-6l-2-1c4.75-1 4.75-1 7-1"/><path fill="#a7b0b8" d="M1524 878q.65.67 1.31 1.38A27 27 0 0 0 1530 883l-4 2c-1.92-2.4-2.21-3.96-2-7"/><path fill="#6b6b6f" d="M83 879h2a14 14 0 0 1 3 4c-.31 2.25-.31 2.25-1 4a337 337 0 0 1-3.12-4.5C83 881 83 881 83 879"/><path fill="#04050a" d="M78 876c3 3.75 3 3.75 3 6h-2l-1 4v-5l-3-1h3z"/><path fill="#898989" d="m1030.13 875.94 2.75.02 2.12.04v1h-8v2h-4v-2c2.52-1.26 4.31-1.1 7.13-1.06"/><path fill="#010207" d="M71 859c1.92 2.4 2.21 3.96 2 7h-2l-1 4c-.14-7.43-.14-7.43 1-11"/><path fill="#97a3af" d="M1504 855h1l1 8h-3l-1 3v-6h2z"/><path fill="#000002" d="M1157 850h5v3h-6z"/><path fill="#aeb7bf" d="M1513 842a24 24 0 0 1 2 4l-1 3-3-1c.88-4.87.88-4.87 2-6"/><path fill="#818184" d="M1164 830h7c-1.19 1.5-1.19 1.5-3 3-2.69.19-2.69.19-5 0z"/><path fill="#a4abaf" d="M1488 823h2v7h-3z"/><path fill="#fefefe" d="m5 827 1 4-4 2v-5z"/><path fill="#79797c" d="M1194 824h7c-1.19 1.5-1.19 1.5-3 3-2.69.19-2.69.19-5 0z"/><path fill="#090a10" d="M18 821h3v8l-1-3h-2z"/><path fill="#d4d9db" d="m1502 818 4 1v3h-5z"/><path fill="#88888a" d="m1200.63 817.81 2.37.19v3h-7c1.22-2.66 1.6-2.97 4.63-3.19"/><path fill="#848587" d="M1230 806h6l-1 3h-6z"/><path fill="#97979a" d="M98 801h3v5h-3z"/><path fill="#000104" d="M83 802h3v5l-4-1z"/><path fill="#e5e6e5" d="M25 790c.75 1.69.75 1.69 1 4-1.94 2.75-1.94 2.75-4 5-.37-5.54-.37-5.54 1.5-7.87z"/><path fill="#8c8b8d" d="M1289 791h6v2l-7 1z"/><path fill="#151417" d="m398 785 4.44 1.38 2.5.77C407 788 407 788 408 790a43 43 0 0 1-10-3z"/><path fill="#8b8b8c" d="M1291 782h6l-1 3h-6z"/><path fill="#36363b" d="M356 776c3.34.6 5.46 1.74 8 4v2c-1.87-.25-1.87-.25-4-1-1.12-2-1.12-2-2-4z"/><path d="M1492 766v4h-5v-3c2-1 2-1 5-1"/><path fill="#9c9da2" d="M51 762c.25 1.81.25 1.81 0 4-2 1.75-2 1.75-4 3-.69-1.81-.69-1.81-1-4 1.62-2.15 2.26-3 5-3"/><path fill="#555558" d="M1337 762q-2.18 1.05-4.37 2.06l-2.47 1.16-2.16.78-2-1q1.61-1.05 3.25-2.06l1.83-1.16c2.38-.96 3.54-.62 5.92.22"/><path fill="#000001" d="M1493 763h6v3h-6z"/><path fill="#323135" d="m1374 760 2 1c-3.29 2.91-5.6 4.5-10 5 2.46-2.63 4.58-4.75 8-6"/><path fill="#000001" d="M1516 750v4h-5v-3c2-1 2-1 5-1"/><path fill="#26252a" d="m1130 748-9 4v-3c3.8-1.8 4.7-2.07 9-1"/><path fill="#7a797b" d="m1383 741 2 1-2 3 2 1-5 1-1-3z"/><path fill="#1a191f" d="M1361 743h7v2l-7 1z"/><path fill="#191920" d="M1370 739h6v3h-6z"/><path fill="#160829" d="M403 734c3.27.56 4.83 1.5 7 4h-6z"/><path fill="#fdfdfe" d="m1554 733-1 5h-5v-2c3.46-3 3.46-3 6-3"/><path fill="#232227" d="M1187 724c-2.85 2.85-5.05 2.56-9 3l1-3c5.75-1.12 5.75-1.12 8 0"/><path fill="#444346" d="M314 721h4l1 4-4 1z"/><path fill="#cac7ca" d="M1585 714h5l-1 4-4-1z"/><path fill="#868383" d="M1445 715h7v3c-2.37.19-2.37.19-5 0-1.31-1.5-1.31-1.5-2-3"/><path fill="#c39cf8" d="M382 715h7v4q-3.03-.9-6-2z"/><path fill="#9b9a9c" d="m228 698 2 1-4 4 1 2h-4c.31-1.94.31-1.94 1-4l3-1z"/><path fill="#fcfcfb" d="M115 698h5l-2 4-4-1z"/><path fill="#000004" d="M1028 690h6v2l-7 1z"/><path fill="#030008" d="M1038 687v2h-2l-1 4-3-1h2v-2l-3-1c4.75-2 4.75-2 7-2"/><path fill="#141317" d="m1222 684-3.87 2-2.18 1.13c-1.95.87-1.95.87-3.95.87v-2c5.9-3.35 5.9-3.35 10-2"/><path fill="#fefeff" d="m1615 682 3 1-7 7-2-1z"/><path fill="#271948" d="M1050 680c-6.23 4.08-6.23 4.08-9.44 3.75L1039 683h3l1-3c3.13-1.04 3.99-.93 7 0"/><path fill="#4c2d81" d="m1034.63 678.81 2.37.19v3h-7c1.22-2.66 1.6-2.97 4.63-3.19"/><path fill="#010102" d="M1619 667h4c-.31 1.94-.31 1.94-1 4l-3 1-2-2h2z"/><path d="M180 662h6v3h-6z"/><path fill="#000001" d="M213 642h6v3h-6z"/><path fill="#06060c" d="M319 643c-1.37 1.5-1.37 1.5-3 3h-2v-3l-3-1c3.4-1.22 4.93-.86 8 1"/><path fill="#b683e9" d="M376 632h1l1 9-1-2h-2l-1 2c.49-3.12 1-6 2-9"/><path d="M228 634h6v3h-6z"/><path fill="#47454a" d="m1666 626 1 3-7 6c1.31-3.94 2.92-6.23 6-9"/><path fill="#06020c" d="M372 625c0 2.78-.21 5.42-.5 8.19l-.28 2.73L371 638h-1l-1-12z"/><path fill="#e4e6e5" d="M227 626h6l-2 1-1 3-4-1z"/><path fill="#000002" d="M1663 622h3v5l-4-1z"/><path fill="#edf2f0" d="M255 610h5l-2 4-4-1z"/><path fill="#000001" d="M279 606h6v3h-6z"/><path fill="#9974c7" d="M433 600c4.23.6 8 1.57 12 3-3.82 1.47-6.3.53-10-1z"/><path fill="#07060b" d="M371 592h1v9h2l-1 4c-2-2-2-2-2.2-4.6l.07-3.02.06-3.04z"/><path d="M296 598h6v3h-6z"/><path fill="#a496bf" d="M758 581c2.06.44 2.06.44 4 1-2 2-2 2-4.38 2.2l-2.75-.08-2.75-.05L750 584v-1l5.37-.68C757 582 757 582 758 581"/><path fill="#37383a" d="M325 578c5.37.59 5.37.59 7 1l1 2h-6l-1 3-2-1z"/><path fill="#15131a" d="m1393 575 3 1-7 7-2-1z"/><path d="M1699 573h3v6h-3z"/><path fill="#0b0a10" d="m342 573 4 1-1 4h-3z"/><path fill="#c7b8d4" d="M418 570h6l1 3-7-1z"/><path fill="#503487" d="M856 567c-4.1 1.98-7.34 3.42-12 3 3.83-2.82 7.32-4.73 12-3"/><path fill="#06090d" d="M1699 566h3v6h-3z"/><path fill="#0c0c12" d="M1643 559c.75 1.75.75 1.75 1 4-1.94 2.25-1.94 2.25-4 4l-1-2z"/><path fill="#2b1a4c" d="M1295 562c-2.36 2.53-3.66 3.89-7 5v-4c2.46-1.23 4.28-1.07 7-1"/><path fill="#05050c" d="M813 551q2.69-.08 5.38-.12l3.02-.08c2.6.2 2.6.2 4.6 2.2l-5 1v-1l-8-1z"/><path fill="#07080d" d="M1708 549c2 2 2 2 2.13 4.63L1710 556h-3q-.06-3 0-6z"/><path fill="#07080c" d="M851 543h6v3h-6z"/><path fill="#0a0610" d="m911 533 12 1v1l-7.52.78C913 536 913 536 910 536z"/><path fill="#6e7071" d="M350 524c.63 1.88.63 1.88 1 4l-2 2h-3v-3c2-1.69 2-1.69 4-3"/><path fill="#000003" d="M1355 518h2c0 3 0 3-2 5.19l-2 1.81-3-1a294 294 0 0 1 3.71-4.69z"/><path fill="#1c0f31" d="m1009 509-1 3h-9v-1c3.43-1.25 6.32-2.28 10-2"/><path fill="#341d5d" d="m1356 504 2 1c-3.61 3.9-3.61 3.9-5 5h-3c1-3 1-3 3.44-4.75z"/><path fill="#2d1954" d="M1040 502a91 91 0 0 1-4 4h-2v-4c3-1 3-1 6 0"/><path fill="#555559" d="M451 497h1c.2 4.22-.2 7.17-2 11h-1v-8h2z"/><path fill="#020005" d="M1048 487h6l-1 3h-6z"/><path fill="#07080d" d="M399 476h1v8h2l-1 4-2-1z"/><path fill="#0f091f" d="M1366 471c3.1 1.55 3.72 3.88 5 7-.42 2.2-.42 2.2-1 4l-2-4.37-1.12-2.47C1366 473 1366 473 1366 471"/><path fill="#010104" d="M1086 471h6v3h-6z"/><path fill="#8e8e90" d="m447 466 3 2c.19 2.13.19 2.13 0 4h-2l-1 2z"/><path fill="#000001" d="M1707 466h3v5l-4-1zM1701 462h5v4h-4z"/><path fill="#351e60" d="M1319 437c3.67 3.08 4.57 5.25 5 10l-1-2h-2c-1.61-2.82-2.23-4.75-2-8"/><path fill="#0e0f15" d="M1395 429h2v3h2l1 6c-3.25-2.83-4.52-4.69-5-9"/><path fill="#ececed" d="m1680 430 6 2-1 2h-5z"/><path fill="#09090f" d="M1610 422h5v3h-8l3-1z"/><path fill="#222127" d="M844 415c2.44.81 2.44.81 5 2l1 3 3 1c-4.3-.48-6.22-1.7-9-5z"/><path fill="#2d2c33" d="M839 410c1.5 1.38 1.5 1.38 3 3v2l3 1 1 3a13 13 0 0 1-7-6z"/><path fill="#29262d" d="m901.31 409.25 1.69.75c-1.12 1.5-1.12 1.5-3 3-3.19.19-3.19.19-6 0 4.43-4.06 4.43-4.06 7.31-3.75"/><path fill="#f5f5f6" d="M1600 406h5l1 4-5-1z"/><path fill="#332159" d="m1263 384 2 1-2 5-4-1z"/><path fill="#46484d" d="M1485 382c1.5 1.38 1.5 1.38 3 3v2l3 1-7-1z"/><path fill="#99999e" d="M1460 378h4l1 2a31 31 0 0 0 3 2h-6z"/><path fill="#2b1a4e" d="m1314 373 5 5-1 2h-4z"/><path fill="#37353b" d="m957 373 3 4a101 101 0 0 1-6 3l-2-1z"/><path fill="#010103" d="m1367 373 4 1-1 4h-3z"/><path fill="#331b62" d="m1288 370 2 1v8c-2.06-1.75-2.06-1.75-4-4 .19-2.22.42-3.42 2-5"/><path fill="#000001" d="M1362 368h4v5h-3zM1359 362h3v5l-4-1z"/><path fill="#3d3b41" d="M473 345h1v9l-3-2c.88-5.87.88-5.87 2-7"/><path d="M1347 343h3v6h-3z"/><path fill="#0a0a0f" d="m1327 313 3 1v6h-2z"/><path fill="#fdfdfd" d="m1330 290 4 1v5l-4-2z"/><path fill="#040406" d="M438 282h2l-1 13h-1q-.3-2.97-.56-5.94l-.32-3.34L437 283z"/><path fill="#0a090f" d="M1311 276h3c.19 2.38.19 2.38 0 5-1.5 1.31-1.5 1.31-3 2z"/><path fill="#09090e" d="M1307 266h3a28 28 0 0 1-3 9zM1303 257h3c.19 2.38.19 2.38 0 5-1.5 1.31-1.5 1.31-3 2z"/><path fill="#08080d" d="M410 217h3v9c-3-3-3-3-3.19-6.19z"/><path fill="#000001" d="M456 214h1v7h-3c-.12-2.37-.12-2.37 0-5z"/><path fill="#eef0ef" d="m405 202 1 4-4 2v-5z"/><path fill="#919092" d="M471 199h2l-1 7h-2q-.06-3 0-6z"/><path fill="#0b0b0f" d="M1275 192h3v6l-2 1z"/><path fill="#f0f2f1" d="m409 190 1 4-4 2v-5z"/><path fill="#2d2c32" d="m947 186 3 1-6 7-1-4h3z"/><path fill="#8f8f91" d="M478 183h3v6h-3z"/><path fill="#828284" d="M800 174h7l-1 3h-5z"/><path fill="#07070d" d="M479 169h3c-1.07 2.92-1.78 4.78-4 7-.62-2.37-.62-2.37-1-5z"/><path fill="#888989" d="M885 167c2.19.31 2.19.31 4 1v2h-5l-1 3-1-3c1.25-1.56 1.25-1.56 3-3"/><path fill="#0a090e" d="m800 162 8 1c-3.19 2.13-5.27 2.5-9 3z"/><path fill="#959295" d="m426 154 2 2c-.37 2.63-.37 2.63-1 5l-5-1 4-1z"/><path fill="#8a8a8c" d="M931 143h6v3h-6z"/><path fill="#88878b" d="m938 139 1 3c2.06.69 2.06.69 4 1l-6 2v-2l-4-1h5z"/><path fill="#58585d" d="m969 129 2 1-7 6-2-1c1.99-2.98 3.58-4.79 7-6"/><path fill="#000003" d="M524 130v3l-6 1v-3c3-1 3-1 6-1"/><path fill="#010006" d="m909 127-1 3-6 1 1-3c2.22-1.11 3.56-1.08 6-1"/><path fill="#0a0b10" d="M1240 115h2v6h-3q-.06-2.5 0-5z"/><path fill="#000001" d="M1243 115h3v6h-3z"/><path d="M1239 108h3v6h-3z"/><path fill="#0b0b10" d="M1231 101h3v6h-2v-2h-2z"/><path fill="#c7cacb" d="M473 98q2.51.43 5 1c-4.62 4-4.62 4-8 4 1.88-3.87 1.88-3.87 3-5"/><path fill="#acaeb1" d="m525 74 4 1c-3.01 3.86-3.01 3.86-5.82 4.27A76 76 0 0 1 518 79v-1l6-1z"/><path fill="#27282b" d="M639 72h2v2l6 1v1h-9z"/><path fill="#363539" d="M714 71h6v3h-6z"/><path fill="#444347" d="M682 67h6v3h-6z"/><path fill="#333537" d="M642 63h6v3h-6z"/><path fill="#ecebed" d="M942 46h4l-1 4h-5z"/><path fill="#c4c5c4" d="M1191 35c4.75 1.88 4.75 1.88 7 3l-1 4c-4.87-4.75-4.87-4.75-6-7"/><path fill="#000001" d="m1175 29 4 1v3h-5z"/><path fill="#6a6a6d" d="M1139 7h2l1 3 7 1v1h-9z"/><path fill="#fafafa" d="M1144 6h5l1 4-5-1z"/><path fill="#a2a1a4" d="M1295 1821h7l-1.75.81a41 41 0 0 0-6.25 4.19z"/><path fill="#e7e9ea" d="M1329 1810h5l-1 4h-4z"/><path fill="#64686c" d="M395 1778h3l1 4h2l-1 3c-3.9-3.71-3.9-3.71-5-5z"/><path fill="#070809" d="m399 1775 5 1 1 5c-1.87-.19-1.87-.19-4-1-1.25-2.56-1.25-2.56-2-5"/><path fill="#676e72" d="M390 1770a24 24 0 0 1 4 4c-.25 2.25-.25 2.25-1 4-1.5-1.19-1.5-1.19-3-3-.19-2.69-.19-2.69 0-5"/><path fill="#000001" d="M389 1763h4l1 5-5-2z"/><path fill="#000002" d="M1379 1759h3v5h-4z"/><path fill="#8f8e96" d="M439 1743h6v3h-5z"/><path fill="#85838b" d="M438 1739h2l1 4-6 1-1-2h4z"/><path d="M374 1740h3v5h-4z"/><path fill="#090a11" d="M371 1725h2v6h-3q-.06-2.5 0-5z"/><path fill="#86868e" d="m1286 1715 4 3-1 4h-3z"/><path fill="#919098" d="M410 1713h3l1 5h-4z"/><path fill="#abaaaf" d="M1414 1706c2.06.44 2.06.44 4 1l-1.37 1.25a19 19 0 0 0-3.63 5.75c-.1-5.37-.1-5.37 0-7z"/><path fill="#fefefe" d="m351 1708 3 2-1 4-3-1z"/><path fill="#47454d" d="M778 1667h6v3h-5z"/><path fill="#000003" d="m469 1622 5 1 1 3-5-1z"/><path fill="#1b1c26" d="m671 1508 2 1-4 5-4-1z"/><path fill="#000001" d="M341 1455h1v7h-3c.88-5.87.88-5.87 2-7"/><path fill="#000102" d="M339 1430h3v7c-1.5-1.25-1.5-1.25-3-3-.19-2.19-.19-2.19 0-4"/><path fill="#727175" d="m1469 1412 5 1-2 4c-1.5-1.37-1.5-1.37-3-3z"/><path fill="#010102" d="M1467 1407h4l-1 4h-2l-1 2z"/><path fill="#06050a" d="M1401 1402h7l-1 3-6-1z"/><path fill="#01040a" d="M1396 1399c-2 2-2 2-5 3-2.19-.94-2.19-.94-4-2 3.43-1.62 5.38-2.34 9-1"/><path fill="#230908" d="m1424 1386 2 1c-2.25 2.06-2.25 2.06-5 4-2.31-.25-2.31-.25-4-1q1.68-1.05 3.38-2.06l1.9-1.16z"/><path fill="#000001" d="M1491 1381h3v5h-3c-.56-1.94-.56-1.94-1-4z"/><path fill="#c2bbb2" d="M1334 1379h6l-1 3h-5z"/><path fill="#2b2b33" d="M875 1373v2h-2l-1 3c-2.06.69-2.06.69-4 1 1.53-3.53 2.88-6 7-6"/><path fill="#d5d5d8" d="M1506 1369h4v4l-4 1z"/><path fill="#da4d0e" d="m1346 1360 3 1v2h2l1 5c-4.87-4.62-4.87-4.62-6-8"/><path fill="#181920" d="m911 1352 2 1c-3.5 4.88-3.5 4.88-8 6z"/><path fill="#000105" d="M1267 1350c1.43 2.35 2.09 3.48 1.63 6.25l-.63 1.75-2-1q-.06-3 0-6z"/><path fill="#000207" d="m1122 1346 7 2v1h-8z"/><path fill="#4f311d" d="M1085 1319h4v2l2 2h-6z"/><path fill="#583622" d="M1029 1303h9l-1 3-8-2z"/><path fill="#000306" d="m1051 1293 2 1v6l-2 1c-1.12-5.75-1.12-5.75 0-8"/><path fill="#e04c09" d="M1400 1289h2c-.62 5.66-.62 5.66-2.56 7.38l-1.44.62c.88-6.87.88-6.87 2-8"/><path fill="#040507" d="m1290 1287 3 1-1 1a70 70 0 0 0-.56 4.06l-.26 2.23-.18 1.71h-1l-.59-5.37-.41-1.63-2-1c1.31-1.06 1.31-1.06 3-2"/><path fill="#52311b" d="M1199 1286h5v3h-5z"/><path fill="#d94a0e" d="M1349 1282c.75 1.69.75 1.69 1 4-1.94 2.75-1.94 2.75-4 5-.37-5.54-.37-5.54 1.5-7.87z"/><path fill="#53331d" d="M1112 1270h5v3h-5z"/><path fill="#e64a0e" d="m1370 1258 2 1-1.87 3-1.06 1.69-1.07 1.31h-2v-4l3-1z"/><path fill="#000003" d="M1271 1256h2c.19 2.38.19 2.38 0 5-1.5 1.31-1.5 1.31-3 2q-.06-3 0-6z"/><path fill="#07090f" d="M1491 1252h3v6l-3-1z"/><path fill="#67432b" d="M928 1254h5v3h-6z"/><path fill="#d1cabf" d="m1326 1248 2 1c-2.46 4.8-2.46 4.8-5.19 5.81l-1.81.19 1-3h2z"/><path fill="#d83c06" d="M1424 1250h6v3h-5z"/><path fill="#ca8366" d="M1408 1241q2.15-.12 4.31-.19l2.43-.1c2.74.35 3.55 1.18 5.26 3.29l-9.37-1.56-2.63-.44z"/><path fill="#000001" d="M1486 1240h4v5h-3z"/><path fill="#010103" d="m1321 1238 3 1v3h-5v-3z"/><path fill="#000003" d="M938 1239h6v2l-7 1z"/><path fill="#e5eaec" d="M1507 1230h5l-1 4-5-2z"/><path fill="#000001" d="M1465 1218h5v4c-1.94-.31-1.94-.31-4-1z"/><path fill="#eef2f7" d="M1537 1214h5l-1 4h-4z"/><path fill="#a0afb9" d="M1535 1207h5v3h-6z"/><path fill="#0a0b10" d="M1441 1206h6v3h-5z"/><path fill="#b0bbc3" d="m1532 1199 1 3-3 1-1 5h-1l-2-7h6z"/><path fill="#f6f6f5" d="M1444 1194h5l1 4-5-1z"/><path fill="#bfc0c3" d="M1437 1191c1.94.31 1.94.31 4 1l1 3h-5z"/><path fill="#4f4d58" d="M482 1178c3.27.56 4.83 1.5 7 4h-6z"/><path fill="#82808b" d="M1288 1160h3l-1 3h-11v-1h9z"/><path fill="#fbfcfd" d="M1603 1152h3v4l-4 1z"/><path fill="#8d97a2" d="M1603 1144c1.5 1.38 1.5 1.38 3 3v2l-6-1v-2h3z"/><path fill="#2c2c35" d="M1215 1089h3l-1 5-4-2c.81-1.5.81-1.5 2-3"/><path fill="#fafbfc" d="m1602 1070 4 1v5l-4-2z"/><path fill="#27262e" d="m828 1053-1 2c-1.85.41-1.85.41-4.06.63l-2.23.22-1.71.15c2.61-3.48 4.83-3.28 9-3"/><path fill="#dbdcdd" d="M327 1050h5l-1 4-5-2zM1422 1040h6l-2 1 2 5c-2.62-1.05-3.8-1.65-5.25-4.12z"/><path fill="#e1e2e3" d="m1422 1027 3 1v5h-3z"/><path fill="#707075" d="M267 1029c2.94.81 2.94.81 6 2l1 3c-1.81.31-1.81.31-4 0-1.75-2.5-1.75-2.5-3-5"/><path fill="#f4f5f4" d="m1418 1027 3 1v5h-3z"/><path fill="#aeb7be" d="M1548 1024c2.9 1.1 5.5 2.16 8 4l-1 2-8-4z"/><path fill="#eceae8" d="M1175 1019h5v3h-6z"/><path fill="#e7e6e8" d="M134 994h5v4h-4z"/><path fill="#f4f4f4" d="M114 986h5v4h-4z"/><path fill="#07080d" d="M1234 979h3v5l-3 1z"/><path fill="#000001" d="M1237 969h1l-1 7h-3c.57-2.74 1.24-4.8 3-7"/><path fill="#96a3ac" d="M1520 969h2v3h-2v9h-1q-.05-2.71-.06-5.44l-.04-3.06c.1-2.5.1-2.5 1.1-3.5"/><path fill="#010104" d="M272 969h5l-3 1v2l3 1h-6z"/><path fill="#030306" d="m1025 969 2 1-1 3-5 1c1.75-3.87 1.75-3.87 4-5"/><path fill="#e7e7e7" d="m66 962 5 1 1 3h-5z"/><path fill="#37353a" d="M1414 960c.88 2.09 1.2 3.45.4 5.6q-1.15 2.24-2.4 4.4c-.88-2.09-1.2-3.45-.4-5.6q1.15-2.24 2.4-4.4"/><path fill="#9c9c9d" d="M612 963v1l-2.94.38-3.06.62-1 2-4-1c3.63-3.48 6.21-3.28 11-3"/><path fill="#06070b" d="M1085 947h7v3h-5v-2z"/><path d="m56 946 5 1v3l-5-1z"/><path fill="#8e8e8f" d="m721 944 1.69.44c2.42.59 4.86 1.08 7.31 1.56v1h-9z"/><path fill="#ebedf0" d="M1529 935c.75 1.69.75 1.69 1 4-1.94 2.75-1.94 2.75-4 5 .75-6.75.75-6.75 3-9"/><path fill="#a4a5a6" d="m175 935 7 1v2h-7z"/><path fill="#000005" d="m1161 927 2 1-3 5-1-2h-5v-1h6z"/><path fill="#88888b" d="M848 920h10l-1 3-1.69-.44c-2.42-.59-4.86-1.08-7.31-1.56z"/><path fill="#37353b" d="m806 916 9 2v1h-11z"/><path fill="#07070c" d="M945 914c3.08.66 5.08 1.54 8 3h-9z"/><path fill="#010004" d="m956 909-1 3-7 1 3-1v-2c3-1 3-1 5-1"/><path fill="#b9b9bb" d="M0 901h2v8l-2 1z"/><path d="M18 908h3v5h-4z"/><path fill="#06070c" d="M1218 903h6l1 2-7 1z"/><path fill="#8f9090" d="m913 902 5 2-2 1v2h-4z"/><path fill="#413e45" d="M190 895h5l1 3h-6z"/><path fill="#878689" d="m1005 885 7 2v1l-9 2z"/><path fill="#cdd4d8" d="M1490 874h2v6l-5 1 2-2c.63-2.62.63-2.62 1-5"/><path fill="#94a0a9" d="M1518 859h1l.68 5.27c.32 1.73.32 1.73 1.32 3.73h-2l-1 2-2-4h2z"/><path fill="#8c8c8e" d="M1061 866c-2 2-2 2-3.73 2.2l-5.27-.2 1-2c3.28-.95 4.7-1.1 8 0"/><path fill="#9b9b9c" d="M140 854c2 2 2 2 2.13 4.63L142 861l-3-1q-.06-2.5 0-5z"/><path fill="#78777a" d="M1188 828c-.62 1.5-.62 1.5-2 3-3.12.19-3.12.19-6 0 2.47-3.12 4.1-3.32 8-3"/><path fill="#424147" d="M148 818h1c-.59 5.37-.59 5.37-1 7l-2 1c-.12-2.87-.12-2.87 0-6z"/><path fill="#000004" d="M75 815h3v5l-3 1z"/><path fill="#3b3b3f" d="m436 814 9 2v1h-9z"/><path fill="#212128" d="M1190 811h6v2l-7 1z"/><path fill="#000003" d="m79 807 3 1v5h-3z"/><path fill="#0a0a11" d="M26 805h3v5l-3 1z"/><path fill="#1c1d20" d="M25 798h1v6l-5 1 1-3 2-1z"/><path fill="#888a8b" d="M1252 798h5v3h-6z"/><path fill="#808182" d="m1273.19 794.81 1.81.19v3h-7c2.46-2.95 2.46-2.95 5.19-3.19"/><path fill="#757478" d="m1299.31 791.31 1.69.69c-4.43 3.08-4.43 3.08-7.31 2.69L1292 794c4.43-3.08 4.43-3.08 7.31-2.69"/><path fill="#c4c1c6" d="m1480.13 780.88 1.87.12c-1.75 3.88-1.75 3.88-4 5v-2l-2-1c2-2 2-2 4.13-2.12"/><path fill="#000003" d="m1338.19 781.81 1.81.19v3h-7c2.46-2.95 2.46-2.95 5.19-3.19"/><path fill="#46444a" d="M166 780h3v5l-3 1v-3l-2 1z"/><path fill="#212127" d="M1281 775h2v5l-3 1v-2l-3-1 3-1z"/><path fill="#1e1c22" d="M1286 775h5v3h-6z"/><path fill="#000004" d="M1353 774h5v3h-6z"/><path fill="#8c8b8c" d="m1314.63 773.81 2.37.19-1 3h-6c1.22-2.66 1.6-2.97 4.63-3.19"/><path fill="#000002" d="M1369 766h5v3h-6z"/><path fill="#9f9f9f" d="m181 753 1 3-4 3-1-3zm-6 4 2 1Z"/><path fill="#000001" d="M62 754h4c-.31 1.94-.31 1.94-1 4l-3 1zM1401 750h5v3h-6zM1416 742h5v3h-6z"/><path fill="#b78ded" d="M453 742h3v4h-3l-1 2q-.06-2.5 0-5z"/><path fill="#19181f" d="M1387 731h5v3h-6z"/><path fill="#03010a" d="M904 727v2l2 1c-5.27.98-5.27.98-7 1l-2-2c4.75-2 4.75-2 7-2"/><path fill="#05060b" d="M172 715h5v3h-6z"/><path fill="#898686" d="M1453 711h6v3h-5z"/><path fill="#000001" d="m1574 706 3 1v3h-5v-3z"/><path fill="#8a8887" d="M1460 707h6v3h-5z"/><path fill="#0d0419" d="M364 701c3.24 1.62 4.42 4.84 6 8l-5-1z"/><path fill="#000002" d="m194 706 3 1v3l-5 1v-4z"/><path fill="#523483" d="M960 703h8c-1.25 1.56-1.25 1.56-3 3-2.12-.19-2.12-.19-4-1z"/><path fill="#000001" d="m123 701 5 1v3h-5z"/><path fill="#8756c0" d="M365 693c1.87 1.38 2.87 2.47 3.42 4.76q.34 2.61.58 5.24c-2.59-1.3-2.96-2.33-4-5-.12-2.75-.12-2.75 0-5"/><path fill="#e4e3e4" d="M1610 694h4l-1 4-4-1z"/><path fill="#a5a4a6" d="M236 690h5v3h-6z"/><path fill="#fdfdfd" d="M135 682h5l-2 4-4-1z"/><path fill="#d0cbd2" d="M1626 675c-2.53 3.1-5.22 3.9-9 5 1-3 1-3 3.38-4.75C1623 674 1623 674 1626 675"/><path fill="#04050a" d="m173 670 1 3 3 1c-1.12 1.13-1.12 1.13-3 2-3.19-.87-3.19-.87-6-2v-1h5z"/><path fill="#9d9b9d" d="M274 670h5l1 3h-6z"/><path fill="#1a191f" d="m1399 663 2 1c-5.14 5.57-5.14 5.57-9 6z"/><path fill="#5a565d" d="m1631 666-1 4-6 1c.81-1.94.81-1.94 2-4 3-1 3-1 5-1"/><path fill="#05070d" d="M267 659h5v3h-6z"/><path fill="#6e6e71" d="m1540 658 1 4-2 1v-2l-6 2c1.6-3.2 3.71-3.88 7-5"/><path fill="#2c1951" d="M1090 659h3c-.62 1.94-.62 1.94-2 4-3.12.75-3.12.75-6 1 1-2 1-2 4-3z"/><path fill="#979699" d="M297 658h5l-1 3-5 1z"/><path fill="#0c0b11" d="m1272.25 656.31 1.75.69c-1.75 1.56-1.75 1.56-4 3-2.25-.31-2.25-.31-4-1 3.45-3.08 3.45-3.08 6.25-2.69"/><path fill="#18171c" d="M292 657c-1.19 1.5-1.19 1.5-3 3-2.69.19-2.69.19-5 0v-2c5.75-2.12 5.75-2.12 8-1"/><path fill="#090b10" d="M201 654h6l-1 3h-5z"/><path fill="#f0eff0" d="M179 654h5l-2 4-4-1z"/><path fill="#909093" d="M204 643a37 37 0 0 1 1 4c-1 1-1 1-4.06 1.06L198 648v-2l2.44-.37L203 645z"/><path fill="#18171f" d="M1542 643h5l-1 4-4-1z"/><path fill="#000106" d="M316 639h6l-1 3h-5z"/><path fill="#030207" d="M1187 630c-1.19 1.5-1.19 1.5-3 3-2.69.19-2.69.19-5 0 1.12-2.14 1.7-2.89 4-3.75 2-.25 2-.25 4 .75"/><path fill="#010102" d="M370 620c1.76 2.32 2 2.91 2 6h-3l-1 3-1-3 2-2c.63-2.12.63-2.12 1-4"/><path fill="#080a10" d="M258 622h6c-.69 1.5-.69 1.5-2 3-2.62.19-2.62.19-5 0z"/><path fill="#050607" d="m367 620 3 2a49 49 0 0 1-4 7c-.12-6.75-.12-6.75 1-9"/><path fill="#000001" d="M1671 613h3v5h-3c-.56-1.94-.56-1.94-1-4z"/><path fill="#06070d" d="m286 606 1 3 2 1-4 4-3-1 2-1q1.1-2.97 2-6"/><path fill="#000001" d="M1675 607h3v5h-4z"/><path fill="#090a10" d="M296 602h6l-1 3h-5z"/><path fill="#fdfdfd" d="M284 594h4l-1 4h-4z"/><path fill="#bc98e4" d="M390 589a14 14 0 0 1 3 3c-.19 2.13-.19 2.13-1 4l-2 1z"/><path fill="#000003" d="m512 578 7 1v2h-6z"/><path fill="#020204" d="M381 572c0 3.7-.93 6.47-2 10h-1v-9z"/><path fill="#04020a" d="M382 574h3l-2 10h-1a565 565 0 0 1-.88-6.93L381 575z"/><path fill="#d8d7da" d="M1710 569h4v4l-4 1z"/><path fill="#020105" d="M765.7 562.8q2.64.03 5.3.2c-2.36 2.1-3.55 2.97-6.75 3.19L762 566c.8-2.12 1.4-2.93 3.7-3.2"/><path fill="#d0cfd2" d="M1714 561h4v4l-4 1z"/><path fill="#595b5e" d="M343 556c0 3.2-.74 5.1-2 8h-1l-2-7z"/><path fill="#4c2c85" d="m887 555-2 4h-8l4-2 2.13-1.12C885 555 885 555 887 555"/><path fill="#d6d5d9" d="M1718 553h4v4l-4 1z"/><path fill="#0c090f" d="M849 550h7l-1 2c-1.85.41-1.85.41-4.06.63l-2.23.22-1.71.15 2-1z"/><path fill="#4e3084" d="m908 546 2 1-2 4-7-1 1.81-.87c1.84-.95 3.5-1.95 5.19-3.13"/><path fill="#030107" d="m848 548 2 1-1 3c-1.85.73-1.85.73-4.06 1.19l-2.23.48-1.71.33 1-2h4z"/><path fill="#07070b" d="M899 531h6v3h-5z"/><path fill="#111016" d="M1431 520h1c.37 5.54.37 5.54-1.5 7.88L1429 529c.88-6.75.88-6.75 2-9"/><path fill="#07060d" d="M928 523h7v3h-5v-2z"/><path fill="#e1e1e2" d="m344 486 2 1v5l-4-1z"/><path fill="#010005" d="M1059 483h5v3h-5z"/><path fill="#8d8d8f" d="M420 460h1c.37 5.42.37 5.42-1.5 8.38L418 470v-7h2z"/><path fill="#2e1b54" d="m1142.19 457.81 1.81.19-1 3h-6c2.46-2.95 2.46-2.95 5.19-3.19"/><path fill="#444546" d="M363 453c.98 3.05.98 4.95 0 8h-2v-2l-2-1h2z"/><path fill="#101117" d="m1410 454 2 1v2h2l1 5h-2c-2.04-2.9-3-4.4-3-8"/><path fill="#08080d" d="M1682 454h6l-1 3h-5z"/><path fill="#090a0e" d="M1642 434h5v3h-7l2-1z"/><path fill="#000005" d="m1177 430 2 2c-2 2-2 2-4.62 2.13L1172 434c1.63-1.7 2.87-2.93 5-4"/><path fill="#08090e" d="m1624.63 425.81 2.37.19-1 3h-6c1.22-2.66 1.6-2.97 4.63-3.19"/><path fill="#3e3e42" d="M1631 424h10l1 4q-.77-.46-1.56-.94c-3.17-1.38-6.01-1.66-9.44-2.06z"/><path fill="#2d1c52" d="m1214 422 3 1a91 91 0 0 1-4 4h-2c1.88-3.87 1.88-3.87 3-5"/><path fill="#313132" d="M1579 404h2q2.08 2.45 4 5h-6z"/><path fill="#3d256d" d="m1266 385 3 1a91 91 0 0 1-4 4h-2c.38-1.94.38-1.94 1-4z"/><path fill="#ceced2" d="m1493 382 5 1v2l2 1-6-1z"/><path fill="#2a2a2e" d="M1450 379h3a47 47 0 0 1 4 5c-2.37-.25-2.37-.25-5-1-1.31-2.06-1.31-2.06-2-4"/><path fill="#e2e2e2" d="m1500 374 6 2-1 2h-5z"/><path fill="#9d9ba2" d="M1417 374c3.6-.41 4.78-.15 7.81 2l2.19 2c-6.52-.62-6.52-.62-8.94-2.56z"/><path fill="#2f1b56" d="m1310 370 4 1c-.37 1.94-.37 1.94-1 4l-2 1z"/><path fill="#f1f0f2" d="m1350 334 4 1v5l-4-2z"/><path fill="#04050a" d="M389 323h1v5l-4 2-3-3 5-1z"/><path fill="#090a0e" d="M1331 321h3v5l-3 1z"/><path fill="#f1eff1" d="m1338 310 4 1v4h-4z"/><path fill="#f2f1f2" d="m1326 282 4 1v4h-4z"/><path fill="#f8f7f9" d="m1314 254 4 1v4h-4z"/><path fill="#e1dde2" d="m1310 246 4 1v4h-4z"/><path fill="#e7e7e7" d="m384 242 2 1v5l-4-1z"/><path fill="#cdcdd1" d="m1305 234 .81 1.88c1.24 2.2 2.12 2.8 4.19 4.12v2l-5-1z"/><path fill="#8a8a8c" d="M488 231h2v6h-3q-.06-2.5 0-5z"/><path fill="#d8d8d9" d="m388 226 2 1v5l-4-1z"/><path fill="#f8f7f8" d="m1302 226 4 1v4h-4z"/><path fill="#3c3a40" d="M507 212h2v6h-3q-.06-2.5 0-5z"/><path fill="#3d3a40" d="M511 205h2v5h-4c.88-3.87.88-3.87 2-5"/><path fill="#09090f" d="M414 205h3v7l-1-2h-2z"/><path fill="#f7f7f7" d="m1290 198 4 1v4h-4z"/><path fill="#100d12" d="M656 179c4.76-.26 4.76-.26 7 0 1.44 1.5 1.44 1.5 2 3-6.75-.75-6.75-.75-9-3"/><path fill="#f0eef0" d="m1278 170 4 1v4h-4z"/><path fill="#959495" d="M800 170h7v2l-7 1z"/><path fill="#e8eaea" d="M422 162v5h-4v-4c3-1 3-1 4-1"/><path fill="#8c8c8d" d="M838 160h3c-.69 1.94-.69 1.94-2 4-2.62.75-2.62.75-5 1l2-1v-2h2z"/><path fill="#27262c" d="M922 158h5v3h-6z"/><path fill="#0b0b11" d="M434 157h3v5l-3 1z"/><path fill="#efedee" d="M426 154v5h-4v-4c3-1 3-1 4-1"/><path fill="#939295" d="M966 127v2c-5.27.2-5.27.2-7 0l-2-2c3.1-1.55 5.62-.56 9 0"/><path fill="#000003" d="M526 127h6l-1 3h-5z"/><path fill="#313136" d="m980 106 2 1-1 1 3 2-6 1v-2l-2-1c1.88-1.06 1.88-1.06 4-2"/><path fill="#f6f5f6" d="m1242 98 4 1v5l-4-2z"/><path d="M1235 101h3v6l-3-1z"/><path fill="#0b0b10" d="M1227 94h3v6l-2 1z"/><path fill="#0a0b0f" d="M1216 75h2v6h-3q-.06-2.5 0-5z"/><path fill="#dad9db" d="M664 70c4.68.62 4.68.62 6.31 2.56L671 74c-4.75-.87-4.75-.87-7-2z"/><path fill="#000001" d="M1214 69h4v5h-3z"/><path fill="#e2e2e3" d="m704 66 6 2-1 2h-5z"/><path fill="#e2e3e2" d="m672 62 6 2-1 2h-5z"/><path fill="#0a090e" d="M1179 38h6v2c-1.87 1.06-1.87 1.06-4 2l-2-1z"/><path fill="#09090d" d="M1151 22h6v3h-5z"/><path fill="#e8e8e9" d="m1478 1210 4 1v3l-5-1z"/><path fill="#e7ebf0" d="m1584.06 1189.94 1.94.06-1 4h-3l-1-3c1-1 1-1 3.06-1.06"/><path fill="#abaaad" d="m1182 18 4 1v3l-5-1z"/><path fill="#9b989d" d="M1358 1798h4l-1 4h-3z"/><path fill="#b3b6b7" d="M406 1798h4v4l-4-1z"/><path fill="#8a8c8e" d="M402 1794h4v4l-4-1z"/><path fill="#696c6e" d="M398 1790h4v4l-4-1z"/><path fill="#ededf0" d="M1374 1786h4l-1 4h-3z"/><path fill="#56585a" d="M394 1786h4v4l-4-1z"/><path fill="#505356" d="M390 1782h4v4l-4-1z"/><path fill="#eaeaed" d="M1378 1782h4l-1 4h-3z"/><path fill="#f1f1f4" d="M1382 1778h4l-1 4h-3z"/><path fill="#5c6062" d="M386 1778h4v4l-4-1z"/><path fill="#787e7f" d="M382 1774h4v4l-4-1z"/><path fill="#a4a8a8" d="M378 1770h4v4l-4-1z"/><path fill="#d1d4d4" d="M374 1766h4v4l-4-1z"/><path fill="#adabae" d="M1394 1762h4l-1 4h-3z"/><path fill="#9d9ba0" d="M1470 1422h4l-1 4h-3z"/><path fill="#6e6b71" d="M1474 1418h4l-1 4h-3z"/><path fill="#545157" d="M1478 1414h4l-1 4h-3z"/><path fill="#4f4b51" d="M1482 1410h4l-1 4h-3z"/><path fill="#59555b" d="M1486 1406h4l-1 4h-3z"/><path fill="#737175" d="M1490 1402h4l-1 4h-3z"/><path fill="#9a9a9e" d="M1494 1398h4l-1 4h-3z"/><path fill="#cac9cd" d="M1498 1394h4l-1 4h-3z"/><path fill="#e9edef" d="M1502 1226h4v4l-4-1z"/><path fill="#f9f8f5" d="m1490 1222 4 1v3h-4z"/><path fill="#f2f2f0" d="m1486 1218 4 1v3h-4z"/><path fill="#f2f1f1" d="m1482 1214 4 1v3h-4z"/><path fill="#d8dee2" d="M1507 1210h3v4h-4z"/><path fill="#cfd6dc" d="M1511 1206h3v4h-4z"/><path fill="#cdd4db" d="M1566 1202h4l-1 4h-3z"/><path fill="#c9d1d8" d="M1515 1202h3v4h-4z"/><path fill="#bfbfbf" d="m1462 1198 4 1v3h-4z"/><path fill="#c9cfd5" d="M1594 1174h4l-1 4h-3z"/><path fill="#c1c8cc" d="M1518 1114h4v4l-4-1z"/><path fill="#c0c6cc" d="M1514 1110h4v4l-4-1z"/><path fill="#dadfe3" d="M1498 1098h4v4l-4-1z"/><path fill="#cad0d6" d="m1602 1062 4 1v3h-4z"/><path fill="#ebecee" d="m1590 1046 4 1v3h-4z"/><path fill="#e9eaed" d="m1586 1042 4 1v3h-4z"/><path fill="#ebecef" d="m1582 1038 4 1v3h-4z"/><path fill="#c9cfd4" d="m1566 1026 4 1v3h-4z"/><path fill="#dadde2" d="m1562 1022 4 1v3h-4z"/><path fill="#e5e7eb" d="m1558 1018 4 1v3h-4z"/><path fill="#e7e9ed" d="m1554 1014 4 1v3h-4z"/><path fill="#e2e5e9" d="m1550 1010 4 1v3h-4z"/><path fill="#e3e4e3" d="M38 950h4v4l-4-1z"/><path fill="#c2c3c2" d="M34 946h4v4l-4-1z"/><path fill="#9ea0a1" d="M30 942h4v4l-4-1z"/><path fill="#8a8c8e" d="M26 938h4v4l-4-1z"/><path fill="#878a8c" d="M22 934h4v4l-4-1z"/><path fill="#96989a" d="M18 930h4v4l-4-1z"/><path fill="#b3b6b8" d="M14 926h4v4l-4-1z"/><path fill="#e0e4e8" d="m1510 826 4 1v3h-4z"/><path fill="#dbdfe1" d="m1506 822 4 1v3h-4z"/><path fill="#c0c2c2" d="M19 786h3v4h-4z"/><path fill="#dcdcdc" d="M31 770h3v4h-4z"/><path fill="#c4c3c4" d="M35 766h3v4h-4z"/><path fill="#a8a8aa" d="M39 762h3v4h-4z"/><path fill="#86878a" d="M43 758h3v4h-4z"/><path fill="#cac8cd" d="M1542 746h4l-1 4h-3z"/><path fill="#fbfbfa" d="M55 742h3v4h-4z"/><path fill="#f6f8f6" d="M59 738h3v4h-4z"/><path fill="#bcbbbe" d="M1558 734h4l-1 4h-3z"/><path fill="#f7f8f7" d="M63 734h3v4h-4z"/><path fill="#b2b0b4" d="M1574 722h4l-1 4h-3z"/><path fill="#636369" d="M79 722h3v4h-4z"/><path fill="#818186" d="M83 718h3v4h-4z"/><path fill="#a8a7aa" d="M87 714h3v4h-4z"/><path fill="#c6c3c7" d="M1590 710h4l-1 4h-3z"/><path fill="#d1d2d2" d="M91 710h3v4h-4z"/><path fill="#99959b" d="M1594 706h4l-1 4h-3z"/><path fill="#c3c3c4" d="M107 698h3v4h-4z"/><path fill="#f4f2f6" d="M1614 690h4l-1 4h-3z"/><path fill="#98979b" d="M123 686h3v4h-4z"/><path fill="#e3e0e5" d="M1618 686h4l-1 4h-3z"/><path fill="#c8c7c9" d="M127 682h3v4h-4z"/><path fill="#cbc6cd" d="M1622 682h4l-1 4h-3z"/><path fill="#aca6ae" d="M1626 678h4l-1 4h-3z"/><path fill="#89848c" d="M1630 674h4l-1 4h-3z"/><path fill="#6f6b72" d="M1634 670h4l-1 4h-3z"/><path fill="#5f5b62" d="M1638 666h4l-1 4h-3zM1662 642h4l-1 4h-3z"/><path fill="#767278" d="M1666 638h4l-1 4h-3z"/><path fill="#9b989d" d="M1670 634h4l-1 4h-3z"/><path fill="#c5c3c7" d="M1674 630h4l-1 4h-3z"/><path fill="#e7e5e9" d="M1678 626h4l-1 4h-3z"/><path fill="#a7a2a9" d="M1690 610h4l-1 4h-3z"/><path fill="#d4d1d6" d="M1694 606h4l-1 4h-3z"/><path fill="#f0f0ee" d="m1714 454 4 1v3h-4z"/><path fill="#c9cbcc" d="M435 126h3v4h-4z"/><path fill="#9a9d9f" d="M439 122h3v4h-4z"/><path fill="#707476" d="M443 118h3v4h-4z"/><path fill="#7f8286" d="M463 98h3v4h-4z"/><path fill="#b7babb" d="M467 94h3v4h-4z"/><path fill="#afadaf" d="m1222 58 4 1v3h-4z"/><path fill="#d6d6d7" d="m1210 42 4 1v3h-4z"/><path fill="#c8c8c9" d="m1206 38 4 1v3h-4z"/><path fill="#c8c9c9" d="m1202 34 4 1v3h-4z"/><path fill="#d7d7d7" d="m1198 30 4 1v3h-4z"/><path fill="#eeeff0" d="M1484 824h2v4l-3-1z"/><path fill="#fff" d="M451 107h3l-1 3h-2z"/><path fill="#a3aaad" d="M1491 819h2l-1 3z"/></svg> \ No newline at end of file diff --git a/priv/mob_logo/1024.png b/priv/mob_logo/1024.png new file mode 100644 index 0000000..7cd7776 Binary files /dev/null and b/priv/mob_logo/1024.png differ diff --git a/priv/mob_logo/120.png b/priv/mob_logo/120.png new file mode 100644 index 0000000..88f7167 Binary files /dev/null and b/priv/mob_logo/120.png differ diff --git a/priv/mob_logo/144.png b/priv/mob_logo/144.png new file mode 100644 index 0000000..08592b4 Binary files /dev/null and b/priv/mob_logo/144.png differ diff --git a/priv/mob_logo/152.png b/priv/mob_logo/152.png new file mode 100644 index 0000000..0844c1c Binary files /dev/null and b/priv/mob_logo/152.png differ diff --git a/priv/mob_logo/167.png b/priv/mob_logo/167.png new file mode 100644 index 0000000..3fc1be2 Binary files /dev/null and b/priv/mob_logo/167.png differ diff --git a/priv/mob_logo/180.png b/priv/mob_logo/180.png new file mode 100644 index 0000000..02be7af Binary files /dev/null and b/priv/mob_logo/180.png differ diff --git a/priv/mob_logo/192.png b/priv/mob_logo/192.png new file mode 100644 index 0000000..1e54948 Binary files /dev/null and b/priv/mob_logo/192.png differ diff --git a/priv/mob_logo/20.png b/priv/mob_logo/20.png new file mode 100644 index 0000000..e768875 Binary files /dev/null and b/priv/mob_logo/20.png differ diff --git a/priv/mob_logo/29.png b/priv/mob_logo/29.png new file mode 100644 index 0000000..fe4a0f3 Binary files /dev/null and b/priv/mob_logo/29.png differ diff --git a/priv/mob_logo/40.png b/priv/mob_logo/40.png new file mode 100644 index 0000000..1ad3812 Binary files /dev/null and b/priv/mob_logo/40.png differ diff --git a/priv/mob_logo/48.png b/priv/mob_logo/48.png new file mode 100644 index 0000000..eb28702 Binary files /dev/null and b/priv/mob_logo/48.png differ diff --git a/priv/mob_logo/58.png b/priv/mob_logo/58.png new file mode 100644 index 0000000..64e340c Binary files /dev/null and b/priv/mob_logo/58.png differ diff --git a/priv/mob_logo/60.png b/priv/mob_logo/60.png new file mode 100644 index 0000000..3af61c9 Binary files /dev/null and b/priv/mob_logo/60.png differ diff --git a/priv/mob_logo/72.png b/priv/mob_logo/72.png new file mode 100644 index 0000000..2c32de4 Binary files /dev/null and b/priv/mob_logo/72.png differ diff --git a/priv/mob_logo/76.png b/priv/mob_logo/76.png new file mode 100644 index 0000000..f40d9b0 Binary files /dev/null and b/priv/mob_logo/76.png differ diff --git a/priv/mob_logo/80.png b/priv/mob_logo/80.png new file mode 100644 index 0000000..c74022f Binary files /dev/null and b/priv/mob_logo/80.png differ diff --git a/priv/mob_logo/87.png b/priv/mob_logo/87.png new file mode 100644 index 0000000..a7d40ea Binary files /dev/null and b/priv/mob_logo/87.png differ diff --git a/priv/mob_logo/96.png b/priv/mob_logo/96.png new file mode 100644 index 0000000..443d720 Binary files /dev/null and b/priv/mob_logo/96.png differ diff --git a/priv/security/bundled_versions.exs b/priv/security/bundled_versions.exs new file mode 100644 index 0000000..e67c436 --- /dev/null +++ b/priv/security/bundled_versions.exs @@ -0,0 +1,56 @@ +# Source-of-truth manifest for what versions ship inside the OTP +# tarballs that `MobDev.OtpDownloader` fetches. +# +# `:active_hash` MUST match `@otp_hash` in +# `lib/mob_dev/otp_downloader.ex` — the security-scan layer +# fingerprints `~/.mob/cache/otp-*-{hash}/` against the bundle +# entry for this hash and raises if they disagree. +# +# When updating: +# +# 1. Bump `:active_hash` to the new hash +# 2. Add or replace the corresponding bundle entry +# 3. Run `mix mob.security_scan` — the bundled-runtime layer +# will fingerprint the cached tarball and assert that the +# binary matches the manifest. If it doesn't, fix the +# manifest, the tarball, or both. +# +# Per-platform overrides let a single bundle entry describe +# platforms whose artifact set differs. `%{exqlite_beam: nil}` +# means "this platform deliberately does not ship the exqlite +# beam in the tarball" — the host's `_build/dev/lib/exqlite` +# is bundled at deploy time instead. + +%{ + active_hash: "5c9c69fc", + bundles: %{ + # Same OTP-29 / erts-17.0 / OpenSSL base as 7d46fdd4, Elixir bumped + # rc.5 -> 1.20.1 (stdlib swap; published as otp-5c9c69fc). + "5c9c69fc" => %{ + erts: "17.0", + otp_release: "29", + elixir: "1.20.1", + openssl: "3.4.0", + exqlite_beam: "0.36.0", + openssl_release_date: "2024-10-22", + platforms: [:android, :android_arm32, :ios_sim, :ios_device], + per_platform: %{ + ios_sim: %{exqlite_beam: nil}, + ios_device: %{exqlite_beam: nil} + } + }, + "7d46fdd4" => %{ + erts: "17.0", + otp_release: "29", + elixir: "1.20.0-rc.5", + openssl: "3.4.0", + exqlite_beam: "0.36.0", + openssl_release_date: "2024-10-22", + platforms: [:android, :android_arm32, :ios_sim, :ios_device], + per_platform: %{ + ios_sim: %{exqlite_beam: nil}, + ios_device: %{exqlite_beam: nil} + } + } + } +} diff --git a/scripts/release/README.md b/scripts/release/README.md new file mode 100644 index 0000000..0e9b7ef --- /dev/null +++ b/scripts/release/README.md @@ -0,0 +1,56 @@ +# scripts/release + +Runnable companions to [`build_release.md`](../../build_release.md). Each +script implements one stage of the release build; the markdown carries the +narrative, the scripts carry the imperative. + +## Files + +| Script | Mirrors `build_release.md` step | What it does | +|---|---|---| +| `_lib.sh` | — | Sourced helpers: env defaults, ERTS version detection, Elixir-stdlib bundler. | +| `xcompile_ios_device.sh` | Step 3b.0 | One-time: cross-compile OTP for iOS arm64 (populates `erts/aarch64-apple-ios/` and `/tmp/otp-ios-device`). | +| `tarball_ios_device.sh` | Step 3b | Stage + tar `otp-ios-device-<hash>.tar.gz` (includes EPMD source for static-link). | +| `tarball_ios_sim.sh` | Step 3 | Stage + tar `otp-ios-sim-<hash>.tar.gz`. | +| `tarball_android_arm64.sh` | Step 2 (arm64) | Stage + tar `otp-android-<hash>.tar.gz`. | +| `tarball_android_arm32.sh` | Step 2 (arm32) | Stage + tar `otp-android-arm32-<hash>.tar.gz`. | +| `publish.sh` | Step 4 | Upload (or replace) assets on the GitHub release. | + +## Common environment + +All scripts source `_lib.sh` and respect these env vars: + +| Var | Default | Purpose | +|---|---|---| +| `OTP_SRC` | `~/code/otp` | OTP source checkout (used to read `erts/vsn.mk` and copy headers/libs). | +| `HASH` | auto from `git -C $OTP_SRC rev-parse --short HEAD` | Release tag hash, e.g. `73ba6e0f`. | +| `ERTS_VSN` | auto from `$OTP_SRC/erts/vsn.mk` | e.g. `16.3`. | +| `OUT_DIR` | `/tmp` | Where finished tarballs land. | +| `ELIXIR_LIB` | from `:code.lib_dir(:elixir)` | Host Elixir lib dir for stdlib bundling. | + +Per-script overrides (e.g. `OTP_RELEASE`, `EXQLITE_BUILD`, `ASN1RT_NIF_ARM32`) +are documented in each script's header. + +## Typical full-release flow + +```bash +cd ~/code/mob_dev/scripts/release + +# (one time per OTP hash, ~10 min) +./xcompile_ios_device.sh + +# (assumes Android arm64/arm32 + iOS sim install dirs already exist — +# see build_release.md prerequisites for those cross-compiles) +EXQLITE_BUILD=~/code/toy_lv_app/_build/dev/lib/exqlite ./tarball_android_arm64.sh +EXQLITE_BUILD=~/code/toy_lv_app/_build/dev/lib/exqlite ./tarball_android_arm32.sh +./tarball_ios_sim.sh +./tarball_ios_device.sh + +./publish.sh # uploads whatever is in /tmp matching the hash +``` + +## Schema-bump-only re-upload + +When you only need to refresh one tarball (e.g. iOS device gained EPMD source +without an OTP version change), just run that one script + `publish.sh`. The +publish script auto-detects which tarballs are present and only uploads those. diff --git a/scripts/release/_lib.sh b/scripts/release/_lib.sh new file mode 100755 index 0000000..01444f4 --- /dev/null +++ b/scripts/release/_lib.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# scripts/release/_lib.sh — shared helpers for the release scripts. +# Sourced by sibling scripts; not meant to be run directly. + +set -euo pipefail + +# ── Defaults (override via env or CLI before sourcing) ────────────────────── +: "${OTP_SRC:=$HOME/code/otp}" +: "${OUT_DIR:=/tmp}" +: "${HASH:=}" + +# Auto-detect HASH from the OTP source tree if not set. Force 8 chars so the +# tag (`otp-<hash>`), tarball filename (`otp-...-<hash>.tar.gz`), and the +# `@otp_hash` constant in `otp_downloader.ex` all stay in lockstep. Git's +# default `--short` length grows over time (collision avoidance) so without +# pinning we'd silently produce 10-char tarball names that don't match. +if [ -z "$HASH" ] && [ -d "$OTP_SRC/.git" ]; then + HASH=$(git -C "$OTP_SRC" rev-parse --short=8 HEAD) +fi + +if [ -z "$HASH" ]; then + echo "ERROR: HASH not set and OTP_SRC ($OTP_SRC) is not a git checkout." >&2 + echo " Pass HASH=<hash> as an env var or CLI arg." >&2 + exit 1 +fi + +# Auto-detect ERTS version from $OTP_SRC/erts/vsn.mk (`VSN = 16.3` etc.) +# Fallback to scanning a release dir if a path is provided. +if [ -z "${ERTS_VSN:-}" ]; then + if [ -f "$OTP_SRC/erts/vsn.mk" ]; then + ERTS_VSN=$(awk '/^VSN[ \t]*=/ {print $3; exit}' "$OTP_SRC/erts/vsn.mk") + fi +fi + +if [ -z "${ERTS_VSN:-}" ]; then + echo "ERROR: could not auto-detect ERTS version from $OTP_SRC/erts/vsn.mk" >&2 + echo " Set ERTS_VSN=<vsn> explicitly (e.g. ERTS_VSN=16.3)." >&2 + exit 1 +fi + +# Resolve host Elixir lib dir for the bundled stdlib (elixir, logger, eex). +if [ -z "${ELIXIR_LIB:-}" ]; then + ELIXIR_LIB=$(elixir -e "IO.puts(:code.lib_dir(:elixir))" | xargs dirname) +fi + +log() { printf '[%s] %s\n' "$(basename "$0")" "$*"; } +fail() { printf '[%s] ERROR: %s\n' "$(basename "$0")" "$*" >&2; exit 1; } + +# Copy bundled Elixir stdlib (elixir, logger, eex) into a staged tarball root. +# Same recipe is used by every tarball — bytecode is arch-independent. +bundle_elixir_stdlib() { + local stage="$1" + for app in elixir logger eex; do + mkdir -p "$stage/lib/$app/ebin" + cp "$ELIXIR_LIB/$app/ebin/"* "$stage/lib/$app/ebin/" + done + log "bundled Elixir $(elixir --version | grep Elixir | awk '{print $2}')" +} + +export OTP_SRC OUT_DIR HASH ERTS_VSN ELIXIR_LIB diff --git a/scripts/release/mlx/_lib.sh b/scripts/release/mlx/_lib.sh new file mode 100755 index 0000000..5d71ff6 --- /dev/null +++ b/scripts/release/mlx/_lib.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# scripts/release/mlx/_lib.sh — shared helpers for the MLX release scripts. +# Sourced by sibling scripts; not meant to be run directly. + +set -euo pipefail + +# ── Defaults (override via env or CLI before sourcing) ────────────────────── +# Pin MLX to the version EMLX 0.2.0 expects. Bump together with EMLX. +: "${MLX_VERSION:=0.25.1}" +: "${MLX_SRC:=$HOME/.cache/mob-mlx-build/mlx-${MLX_VERSION}}" +: "${OUT_DIR:=/tmp}" + +# iOS deployment-target floor. Matches the rest of the mob_dev scripts. +: "${IOS_DEPLOYMENT_TARGET:=17.0}" + +log() { printf '[%s] %s\n' "$(basename "$0")" "$*"; } +fail() { printf '[%s] ERROR: %s\n' "$(basename "$0")" "$*" >&2; exit 1; } + +# Fetch and unpack the pinned MLX source archive if not already present. +# Idempotent — leaves an existing checkout alone. +ensure_mlx_src() { + if [ -f "$MLX_SRC/CMakeLists.txt" ]; then + log "MLX source already at $MLX_SRC" + return 0 + fi + + log "downloading MLX $MLX_VERSION..." + mkdir -p "$(dirname "$MLX_SRC")" + local tarball="$(dirname "$MLX_SRC")/mlx-${MLX_VERSION}.tar.gz" + + if [ ! -f "$tarball" ]; then + curl -fSL \ + "https://github.com/ml-explore/mlx/archive/refs/tags/v${MLX_VERSION}.tar.gz" \ + -o "$tarball" + fi + + tar -xzf "$tarball" -C "$(dirname "$MLX_SRC")" + log "MLX source ready at $MLX_SRC" +} + +# Apply the iOS-Metal CMakeLists patches to $MLX_SRC. Idempotent — checks +# for a sentinel comment in CMakeLists.txt before re-applying. Only the +# Metal build script (ios_device_metal.sh) calls this; the CPU build +# (ios_device.sh) doesn't need iOS-Metal support. +apply_ios_metal_patch() { + local sentinel="mob_dev iOS+Metal patch" + if grep -q "$sentinel" "$MLX_SRC/CMakeLists.txt"; then + log "iOS-Metal patches already applied to $MLX_SRC" + return 0 + fi + + log "applying iOS-Metal patches to $MLX_SRC..." + local patch="$SCRIPT_DIR/patches/0001-ios-metal-build.patch" + [ -f "$patch" ] || fail "patch file not found at $patch" + + (cd "$MLX_SRC" && patch -p1 < "$patch") || fail "iOS-Metal patch failed to apply" + log "patches applied" +} + +# Resolve the EMLX source directory. Defaults to the user's test_emlx project +# checkout — overridable via $EMLX_SRC. For a publish-grade build the caller +# should pass a known-good EMLX checkout. +require_emlx_src() { + if [ -z "${EMLX_SRC:-}" ]; then + if [ -d "$HOME/code/test_emlx/deps/emlx/c_src" ]; then + EMLX_SRC="$HOME/code/test_emlx/deps/emlx" + else + fail "EMLX_SRC not set and no fallback at ~/code/test_emlx/deps/emlx — pass EMLX_SRC=/path/to/emlx" + fi + fi + [ -f "$EMLX_SRC/c_src/emlx_nif.cpp" ] || fail "no emlx_nif.cpp at $EMLX_SRC/c_src/" + export EMLX_SRC +} + +# OTP cache (the iOS-{device,sim} OTP tarball Mob already downloads). Used to +# find erl_nif.h when compiling emlx_nif.cpp. +otp_ios_device_dir() { + local pattern="$HOME/.mob/cache/otp-ios-device-*" + local first + first=$(ls -d $pattern 2>/dev/null | head -1) + [ -n "$first" ] || fail "no iOS-device OTP cache at ~/.mob/cache/otp-ios-device-*. Run `mix mob.install` from a Mob project first." + echo "$first" +} + +otp_ios_sim_dir() { + local pattern="$HOME/.mob/cache/otp-ios-sim-*" + local first + first=$(ls -d $pattern 2>/dev/null | head -1) + [ -n "$first" ] || fail "no iOS-sim OTP cache at ~/.mob/cache/otp-ios-sim-*. Run `mix mob.install` from a Mob project first." + echo "$first" +} + +export MLX_VERSION MLX_SRC OUT_DIR IOS_DEPLOYMENT_TARGET diff --git a/scripts/release/mlx/all_ios.sh b/scripts/release/mlx/all_ios.sh new file mode 100755 index 0000000..2429520 --- /dev/null +++ b/scripts/release/mlx/all_ios.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# scripts/release/mlx/all_ios.sh +# One-shot orchestrator: build libmlx.a + libemlx.a + tarball for both +# iOS device and iOS simulator. +# +# Run this from a clean machine + Xcode + a working Mob app's ~/.mob/cache +# (so the OTP iOS-{device,sim} runtimes are present). +# +# ~/code/mob_dev/scripts/release/mlx/all_ios.sh +# +# Produces /tmp/libmlx-<ver>-ios-{device,sim}.tar.gz ready for upload to +# the GitHub release matching `MobDev.MLXDownloader.@release_tag`. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +"$SCRIPT_DIR/ios_device.sh" +"$SCRIPT_DIR/build_emlx_nif_ios_device.sh" +"$SCRIPT_DIR/tarball_mlx_ios_device.sh" + +"$SCRIPT_DIR/ios_sim.sh" +"$SCRIPT_DIR/build_emlx_nif_ios_sim.sh" +"$SCRIPT_DIR/tarball_mlx_ios_sim.sh" + +echo +echo "=== MLX iOS release artifacts ready ===" +ls -lh /tmp/libmlx-*-ios-*.tar.gz diff --git a/scripts/release/mlx/build_emlx_nif_ios_device.sh b/scripts/release/mlx/build_emlx_nif_ios_device.sh new file mode 100755 index 0000000..2578c46 --- /dev/null +++ b/scripts/release/mlx/build_emlx_nif_ios_device.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# scripts/release/mlx/build_emlx_nif_ios_device.sh +# +# Cross-compile EMLX's NIF (deps/emlx/c_src/emlx_nif.cpp) for iOS arm64 +# device and archive into libemlx.a. Mirrors the +# openssl/build_crypto_static_ios_device.sh pattern. +# +# The output libemlx.a holds a single object with a public +# `emlx_nif_nif_init` symbol — matches what MobDev.StaticNifs registers +# when :emlx_nif is in the static_nifs list, so `:erlang.load_nif/2` at +# app boot finds the init function via erts_static_nif_tab. +# +# Inputs (env): +# MLX_PREFIX — output dir from ios_device.sh (default: /tmp/mlx-ios-device-<ver>) +# EMLX_SRC — EMLX checkout with c_src/ and deps (default: ~/code/test_emlx/deps/emlx) +# OTP_IOS_DIR — cached iOS-device OTP runtime (default: first match of ~/.mob/cache/otp-ios-device-*) +# +# Output: +# $MLX_PREFIX/lib/libemlx.a + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" +source ./_lib.sh + +: "${MLX_PREFIX:=/tmp/mlx-ios-device-${MLX_VERSION}}" +: "${IOS_DEPLOYMENT_TARGET:=17.0}" + +[ -f "$MLX_PREFIX/lib/libmlx.a" ] || fail "libmlx.a not found at $MLX_PREFIX/lib/ — run ios_device.sh first" + +require_emlx_src + +if [ -z "${OTP_IOS_DIR:-}" ]; then + OTP_IOS_DIR=$(otp_ios_device_dir) +fi +[ -d "$OTP_IOS_DIR" ] || fail "OTP iOS device dir not found at $OTP_IOS_DIR" + +ERTS_VSN=$(basename "$(ls -d "$OTP_IOS_DIR"/erts-* | head -1)") +log "MLX_PREFIX=$MLX_PREFIX" +log "EMLX_SRC=$EMLX_SRC" +log "OTP_IOS_DIR=$OTP_IOS_DIR ($ERTS_VSN)" + +OUT_OBJ_DIR=$(mktemp -d -t emlx-nif-ios-device-XXXXXX) +trap 'rm -rf "$OUT_OBJ_DIR"' EXIT + +CC="xcrun -sdk iphoneos clang++ -arch arm64 -miphoneos-version-min=${IOS_DEPLOYMENT_TARGET}" +AR="xcrun -sdk iphoneos ar" +RANLIB="xcrun -sdk iphoneos ranlib" + +CFLAGS=( + -std=c++17 -O3 -fPIC + -fno-strict-aliasing + # STATIC_ERLANG_NIF makes ERL_NIF_INIT define a get_init_func() that + # the static driver_tab can call instead of relying on dlopen. + -DSTATIC_ERLANG_NIF + -DSTATIC_ERLANG_NIF_LIBNAME=emlx_nif + -I "$OTP_IOS_DIR/$ERTS_VSN/include" + -I "$MLX_PREFIX/include" + -Wno-macro-redefined # erl_nif.h re-defines STATIC_ERLANG_NIF, benign +) + +# nlohmann/json headers came in via FetchContent at MLX configure time and are +# staged under $MLX_PREFIX/include/_deps/json/. EMLX transitively #includes +# them via "mlx/backend/common/utils.h". +if [ -d "$MLX_PREFIX/include/_deps/json" ]; then + CFLAGS+=( -I "$MLX_PREFIX/include/_deps/json" ) +fi + +log "compiling emlx_nif.cpp for iOS arm64..." +$CC "${CFLAGS[@]}" -c "$EMLX_SRC/c_src/emlx_nif.cpp" -o "$OUT_OBJ_DIR/emlx_nif.o" + +log "archiving libemlx.a..." +rm -f "$MLX_PREFIX/lib/libemlx.a" +$AR rcs "$MLX_PREFIX/lib/libemlx.a" "$OUT_OBJ_DIR/emlx_nif.o" +$RANLIB "$MLX_PREFIX/lib/libemlx.a" + +# Sanity check: confirm iOS-platform tag (2) and presence of the init symbol. +OTOOL_OUT=$(otool -l "$MLX_PREFIX/lib/libemlx.a" 2>/dev/null || true) +if [[ "$OTOOL_OUT" =~ platform[[:space:]]+([0-9]+) ]]; then + PLATFORM="${BASH_REMATCH[1]}" +else + PLATFORM="unknown" +fi +[ "$PLATFORM" = "2" ] || fail "libemlx.a tagged platform=$PLATFORM, expected 2 (iOS device)" + +NM_OUT=$(xcrun -sdk iphoneos nm "$MLX_PREFIX/lib/libemlx.a" 2>/dev/null || true) +[[ "$NM_OUT" == *" T _emlx_nif_nif_init"* ]] || \ + fail "libemlx.a missing emlx_nif_nif_init symbol — STATIC_ERLANG_NIF not applied?" + +log "done — libemlx.a installed at $MLX_PREFIX/lib/libemlx.a" +ls -lh "$MLX_PREFIX/lib/libemlx.a" diff --git a/scripts/release/mlx/build_emlx_nif_ios_sim.sh b/scripts/release/mlx/build_emlx_nif_ios_sim.sh new file mode 100755 index 0000000..4b781b9 --- /dev/null +++ b/scripts/release/mlx/build_emlx_nif_ios_sim.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# scripts/release/mlx/build_emlx_nif_ios_sim.sh +# +# iOS Simulator (arm64) counterpart to build_emlx_nif_ios_device.sh. +# See that file's header for rationale; this differs only in SDK and the +# OS-version-min flag. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" +source ./_lib.sh + +: "${MLX_PREFIX:=/tmp/mlx-ios-sim-${MLX_VERSION}}" +: "${IOS_DEPLOYMENT_TARGET:=17.0}" + +[ -f "$MLX_PREFIX/lib/libmlx.a" ] || fail "libmlx.a not found at $MLX_PREFIX/lib/ — run ios_sim.sh first" + +require_emlx_src + +if [ -z "${OTP_IOS_DIR:-}" ]; then + OTP_IOS_DIR=$(otp_ios_sim_dir) +fi +[ -d "$OTP_IOS_DIR" ] || fail "OTP iOS sim dir not found at $OTP_IOS_DIR" + +ERTS_VSN=$(basename "$(ls -d "$OTP_IOS_DIR"/erts-* | head -1)") +log "MLX_PREFIX=$MLX_PREFIX" +log "EMLX_SRC=$EMLX_SRC" +log "OTP_IOS_DIR=$OTP_IOS_DIR ($ERTS_VSN)" + +OUT_OBJ_DIR=$(mktemp -d -t emlx-nif-ios-sim-XXXXXX) +trap 'rm -rf "$OUT_OBJ_DIR"' EXIT + +CC="xcrun -sdk iphonesimulator clang++ -arch arm64 -mios-simulator-version-min=${IOS_DEPLOYMENT_TARGET}" +AR="xcrun -sdk iphonesimulator ar" +RANLIB="xcrun -sdk iphonesimulator ranlib" + +CFLAGS=( + -std=c++17 -O3 -fPIC + -fno-strict-aliasing + -DSTATIC_ERLANG_NIF + -DSTATIC_ERLANG_NIF_LIBNAME=emlx_nif + -I "$OTP_IOS_DIR/$ERTS_VSN/include" + -I "$MLX_PREFIX/include" + -Wno-macro-redefined +) + +if [ -d "$MLX_PREFIX/include/_deps/json" ]; then + CFLAGS+=( -I "$MLX_PREFIX/include/_deps/json" ) +fi + +log "compiling emlx_nif.cpp for iOS-simulator arm64..." +$CC "${CFLAGS[@]}" -c "$EMLX_SRC/c_src/emlx_nif.cpp" -o "$OUT_OBJ_DIR/emlx_nif.o" + +log "archiving libemlx.a..." +rm -f "$MLX_PREFIX/lib/libemlx.a" +$AR rcs "$MLX_PREFIX/lib/libemlx.a" "$OUT_OBJ_DIR/emlx_nif.o" +$RANLIB "$MLX_PREFIX/lib/libemlx.a" + +# Sanity check: confirm iOS-Simulator platform tag (7) and the init symbol. +OTOOL_OUT=$(otool -l "$MLX_PREFIX/lib/libemlx.a" 2>/dev/null || true) +if [[ "$OTOOL_OUT" =~ platform[[:space:]]+([0-9]+) ]]; then + PLATFORM="${BASH_REMATCH[1]}" +else + PLATFORM="unknown" +fi +[ "$PLATFORM" = "7" ] || fail "libemlx.a tagged platform=$PLATFORM, expected 7 (iOS Simulator)" + +NM_OUT=$(xcrun -sdk iphonesimulator nm "$MLX_PREFIX/lib/libemlx.a" 2>/dev/null || true) +[[ "$NM_OUT" == *" T _emlx_nif_nif_init"* ]] || \ + fail "libemlx.a missing emlx_nif_nif_init symbol — STATIC_ERLANG_NIF not applied?" + +log "done — libemlx.a installed at $MLX_PREFIX/lib/libemlx.a" +ls -lh "$MLX_PREFIX/lib/libemlx.a" diff --git a/scripts/release/mlx/ios_device.sh b/scripts/release/mlx/ios_device.sh new file mode 100755 index 0000000..1036a93 --- /dev/null +++ b/scripts/release/mlx/ios_device.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# scripts/release/mlx/ios_device.sh +# +# Cross-compile MLX (Apple's ML compute library) as a static archive for +# iOS arm64 device. CPU-only — Metal is a follow-up that requires the +# optional Xcode "Metal Toolchain" component plus the CMakeLists.txt +# patches in patches/ (not applied here). +# +# Mirrors scripts/release/openssl/ios_device.sh (the closest analogue — +# third-party native lib cross-compiled to a `.a` for static linking into +# a Mob iOS app). +# +# Inputs (env): +# MLX_VERSION — MLX tag to fetch (default: 0.25.1, matches EMLX 0.2.0) +# MLX_SRC — MLX source checkout (default: ~/.cache/mob-mlx-build/mlx-<ver>) +# IOS_DEPLOYMENT_TARGET — iOS min version (default: 17.0) +# PREFIX — install root (default: /tmp/mlx-ios-device-<ver>) +# +# Output: +# $PREFIX/lib/libmlx.a (static archive, ~25 MB) +# $PREFIX/include/mlx/*.h (public headers for downstream NIF compiles) +# $PREFIX/include/_deps/json/... (mlx pulls these in via FetchContent; +# bundled so the NIF compile can resolve +# them without re-running cmake) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" +source ./_lib.sh + +: "${PREFIX:=/tmp/mlx-ios-device-${MLX_VERSION}}" + +# Sanity: iPhoneOS SDK must be installed. +xcrun --sdk iphoneos --show-sdk-path >/dev/null 2>&1 || \ + fail "iPhoneOS SDK not found — install Xcode + run 'xcode-select --install'" + +ensure_mlx_src + +BUILD_DIR="$MLX_SRC/build-ios-device-${MLX_VERSION}" +mkdir -p "$BUILD_DIR" +cd "$BUILD_DIR" + +if [ -f "$BUILD_DIR/libmlx.a" ] && [ -z "${MLX_FORCE_REBUILD:-}" ]; then + log "libmlx.a already built at $BUILD_DIR — skipping configure+make (set MLX_FORCE_REBUILD=1 to force)" +else + +log "configuring MLX for arm64-apple-ios..." +# MLX_BUILD_METAL=OFF is the conservative choice for v1. With METAL on iOS: +# - upstream CMakeLists has hardcoded macosx SDK assumptions that need the +# patches in patches/0001-ios-metal-platform.patch +# - Xcode 16's "Metal Toolchain" is a separate ~1GB download +# Build CPU-only first; revisit Metal as a v2 once those gates are cleared. +cmake -G "Unix Makefiles" \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_SYSROOT=iphoneos \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="${IOS_DEPLOYMENT_TARGET}" \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DMLX_BUILD_METAL=OFF \ + -DMLX_BUILD_TESTS=OFF \ + -DMLX_BUILD_BENCHMARKS=OFF \ + -DMLX_BUILD_EXAMPLES=OFF \ + -DMLX_BUILD_PYTHON_BINDINGS=OFF \ + "$MLX_SRC" + +log "building libmlx.a (this takes ~3-5 min)..." +make mlx -j"$(sysctl -n hw.ncpu)" + +fi # end of MLX_FORCE_REBUILD guard + +# Sanity check: confirm we got an iOS-platform archive, not macOS. +# Use bash regex against a buffered otool output instead of a pipe — `otool | +# awk '... exit'` triggers SIGPIPE on otool because libmlx.a contains many +# object files (350KB of -l output) and awk closes the pipe after the first +# match. set -o pipefail then aborts the script. +OTOOL_OUT=$(otool -l "$BUILD_DIR/libmlx.a" 2>/dev/null || true) +if [[ "$OTOOL_OUT" =~ platform[[:space:]]+([0-9]+) ]]; then + PLATFORM="${BASH_REMATCH[1]}" +else + PLATFORM="unknown" +fi +[ "$PLATFORM" = "2" ] || fail "libmlx.a tagged platform=$PLATFORM, expected 2 (iOS device)" + +log "installing to $PREFIX..." +rm -rf "$PREFIX" +mkdir -p "$PREFIX/lib" "$PREFIX/include" + +cp "$BUILD_DIR/libmlx.a" "$PREFIX/lib/" + +# MLX public headers — used by downstream NIFs (EMLX) that #include "mlx/mlx.h". +mkdir -p "$PREFIX/include/mlx" +rsync -a --include='*.h' --include='*/' --exclude='*' \ + "$MLX_SRC/mlx/" "$PREFIX/include/mlx/" + +# nlohmann/json is FetchContent'd by MLX at configure time. The EMLX NIF +# transitively #includes it via "mlx/backend/common/utils.h" → MLX internals. +# Stage the unpacked json headers so the NIF compile doesn't need a working +# CMake / network at build time. +if [ -d "$BUILD_DIR/_deps/json-src/include" ]; then + mkdir -p "$PREFIX/include/_deps/json" + rsync -a "$BUILD_DIR/_deps/json-src/include/" "$PREFIX/include/_deps/json/" +fi + +# Write a small VERSION file for the tarball script to pick up and for +# MobDev.MLXDownloader to validate against. +cat > "$PREFIX/VERSION" <<EOF +mlx_version=${MLX_VERSION} +variant=ios-device-cpu +ios_deployment_target=${IOS_DEPLOYMENT_TARGET} +metal_enabled=false +EOF + +log "done — libmlx.a installed at $PREFIX/lib/libmlx.a" +ls -lh "$PREFIX/lib/libmlx.a" +file "$PREFIX/lib/libmlx.a" | head -1 diff --git a/scripts/release/mlx/ios_device_metal.sh b/scripts/release/mlx/ios_device_metal.sh new file mode 100755 index 0000000..2ef4675 --- /dev/null +++ b/scripts/release/mlx/ios_device_metal.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# scripts/release/mlx/ios_device_metal.sh +# +# Cross-compile MLX with Metal GPU support enabled for iOS arm64 device. +# Sibling to ios_device.sh (which builds the CPU-only variant). The CPU +# build remains the conservative default; this script produces the +# Metal-enabled tarball variant that ships GPU compute. +# +# Two things make this distinct from the CPU build: +# +# 1. Source patches — MLX 0.25.1's CMakeLists.txt only enables Metal +# when CMAKE_SYSTEM_NAME=Darwin and hardcodes `xcrun -sdk macosx` +# throughout. The patches in patches/0001-ios-metal-build.patch +# switch SDK selection on CMAKE_SYSTEM_NAME so iOS gets iphoneos + +# `-mios-version-min` instead. Applied idempotently — re-running +# the script after a successful build is a no-op. +# +# 2. Xcode Metal Toolchain — required to compile the .metal kernels +# into a .metallib. Optional ~700MB Xcode component: +# xcodebuild -downloadComponent MetalToolchain +# The script checks for `metal` on PATH and fails early with a +# pointer to that command if missing. +# +# Outputs (per the standard MLX prefix layout): +# +# $PREFIX/lib/libmlx.a — static archive, includes Metal symbols (~27 MB) +# $PREFIX/lib/mlx.metallib — precompiled Metal kernels (~84 MB) +# $PREFIX/include/mlx/*.h — public headers +# $PREFIX/include/_deps/json — nlohmann/json headers (FetchContent'd by MLX) +# $PREFIX/VERSION — variant=ios-device-metal, metal_enabled=true + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" +source ./_lib.sh + +: "${PREFIX:=/tmp/mlx-ios-device-${MLX_VERSION}-metal}" + +# Sanity: iPhoneOS SDK must be installed. +xcrun --sdk iphoneos --show-sdk-path >/dev/null 2>&1 || \ + fail "iPhoneOS SDK not found — install Xcode + run 'xcode-select --install'" + +# Sanity: Metal compiler must be installed. The Metal Toolchain is an +# optional Xcode component (different from the base macOS Metal tools +# that ship with Xcode by default). +if ! xcrun -f metal >/dev/null 2>&1; then + fail "Metal compiler not on PATH — install via: xcodebuild -downloadComponent MetalToolchain" +fi + +# Best-effort check: actually try to run metal --version. If the +# toolchain was downloaded but never registered (the asset is on disk +# but Xcode hasn't picked it up), `xcrun -f` finds the binary path but +# the binary itself fails. Surface that early with a clear error. +if ! xcrun metal --version >/dev/null 2>&1; then + fail "Metal compiler is on PATH but won't run — re-run: xcodebuild -downloadComponent MetalToolchain" +fi + +ensure_mlx_src +apply_ios_metal_patch + +BUILD_DIR="$MLX_SRC/build-ios-device-metal-${MLX_VERSION}" +mkdir -p "$BUILD_DIR" +cd "$BUILD_DIR" + +if [ -f "$BUILD_DIR/libmlx.a" ] && [ -f "$BUILD_DIR/mlx/backend/metal/kernels/mlx.metallib" ] && [ -z "${MLX_FORCE_REBUILD:-}" ]; then + log "libmlx.a + mlx.metallib already built at $BUILD_DIR — skipping configure+make (set MLX_FORCE_REBUILD=1 to force)" +else + +log "configuring MLX for arm64-apple-ios with Metal..." +cmake -G "Unix Makefiles" \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_SYSROOT=iphoneos \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="${IOS_DEPLOYMENT_TARGET}" \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DMLX_BUILD_METAL=ON \ + -DMLX_BUILD_TESTS=OFF \ + -DMLX_BUILD_BENCHMARKS=OFF \ + -DMLX_BUILD_EXAMPLES=OFF \ + -DMLX_BUILD_PYTHON_BINDINGS=OFF \ + "$MLX_SRC" + +log "building libmlx.a + mlx.metallib (this takes ~5 min, mostly Metal kernel compile)..." +make mlx -j"$(sysctl -n hw.ncpu)" + +fi # end of MLX_FORCE_REBUILD guard + +# Sanity check: confirm we got an iOS-platform archive, not macOS. +# Use bash regex against a buffered otool output instead of a pipe — `otool | +# awk '... exit'` triggers SIGPIPE on otool because libmlx.a contains many +# object files and awk closes the pipe after the first match. set -o pipefail +# then aborts the script. +OTOOL_OUT=$(otool -l "$BUILD_DIR/libmlx.a" 2>/dev/null || true) +if [[ "$OTOOL_OUT" =~ platform[[:space:]]+([0-9]+) ]]; then + PLATFORM="${BASH_REMATCH[1]}" +else + PLATFORM="unknown" +fi +[ "$PLATFORM" = "2" ] || fail "libmlx.a tagged platform=$PLATFORM, expected 2 (iOS device)" + +# Sanity: metallib was actually produced. Metal kernel compile failures +# are loud at build time so this is mostly belt-and-braces. +METALLIB_SRC="$BUILD_DIR/mlx/backend/metal/kernels/mlx.metallib" +[ -f "$METALLIB_SRC" ] || fail "mlx.metallib not produced — Metal kernel build failed silently?" + +log "installing to $PREFIX..." +rm -rf "$PREFIX" +mkdir -p "$PREFIX/lib" "$PREFIX/include" + +cp "$BUILD_DIR/libmlx.a" "$PREFIX/lib/" +cp "$METALLIB_SRC" "$PREFIX/lib/" + +# Public headers (same set the CPU build ships). +mkdir -p "$PREFIX/include/mlx" +rsync -a --include='*.h' --include='*/' --exclude='*' \ + "$MLX_SRC/mlx/" "$PREFIX/include/mlx/" + +if [ -d "$BUILD_DIR/_deps/json-src/include" ]; then + mkdir -p "$PREFIX/include/_deps/json" + rsync -a "$BUILD_DIR/_deps/json-src/include/" "$PREFIX/include/_deps/json/" +fi + +cat > "$PREFIX/VERSION" <<EOF +mlx_version=${MLX_VERSION} +variant=ios-device-metal +ios_deployment_target=${IOS_DEPLOYMENT_TARGET} +metal_enabled=true +EOF + +log "done — Metal-enabled MLX installed at $PREFIX" +ls -lh "$PREFIX/lib/libmlx.a" "$PREFIX/lib/mlx.metallib" +file "$PREFIX/lib/libmlx.a" | head -1 diff --git a/scripts/release/mlx/ios_sim.sh b/scripts/release/mlx/ios_sim.sh new file mode 100755 index 0000000..ba8f6e8 --- /dev/null +++ b/scripts/release/mlx/ios_sim.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# scripts/release/mlx/ios_sim.sh +# +# Cross-compile MLX for iOS Simulator arm64 (Apple Silicon Macs). +# Mirrors ios_device.sh — differs only in SDK and the deployment-target +# flag the iOS sim toolchain expects. +# +# CPU-only for v1; see ios_device.sh for the Metal rationale. +# +# Inputs (env): +# MLX_VERSION — MLX tag to fetch (default: 0.25.1) +# MLX_SRC — MLX source checkout (default: shared with ios_device.sh) +# IOS_DEPLOYMENT_TARGET — iOS min version (default: 17.0) +# PREFIX — install root (default: /tmp/mlx-ios-sim-<ver>) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" +source ./_lib.sh + +: "${PREFIX:=/tmp/mlx-ios-sim-${MLX_VERSION}}" + +xcrun --sdk iphonesimulator --show-sdk-path >/dev/null 2>&1 || \ + fail "iPhoneSimulator SDK not found — install Xcode" + +ensure_mlx_src + +BUILD_DIR="$MLX_SRC/build-ios-sim-${MLX_VERSION}" +mkdir -p "$BUILD_DIR" +cd "$BUILD_DIR" + +if [ -f "$BUILD_DIR/libmlx.a" ] && [ -z "${MLX_FORCE_REBUILD:-}" ]; then + log "libmlx.a already built at $BUILD_DIR — skipping configure+make (set MLX_FORCE_REBUILD=1 to force)" +else + +log "configuring MLX for arm64-apple-ios-simulator..." +# Differences from device: +# * sysroot = iphonesimulator (Mach-O platform tag = 7, iOSSimulator) +# * deployment target uses -mios-simulator-version-min when CMake threads it +# through; CMake-iOS handles that internally when sysroot is set +cmake -G "Unix Makefiles" \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_SYSROOT=iphonesimulator \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="${IOS_DEPLOYMENT_TARGET}" \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DMLX_BUILD_METAL=OFF \ + -DMLX_BUILD_TESTS=OFF \ + -DMLX_BUILD_BENCHMARKS=OFF \ + -DMLX_BUILD_EXAMPLES=OFF \ + -DMLX_BUILD_PYTHON_BINDINGS=OFF \ + "$MLX_SRC" + +log "building libmlx.a (this takes ~3-5 min)..." +make mlx -j"$(sysctl -n hw.ncpu)" + +fi # end of MLX_FORCE_REBUILD guard + +# Sanity check: confirm iOS-Simulator platform tag (7), not macOS (1). +# See ios_device.sh for the bash-regex-not-pipeline rationale. +OTOOL_OUT=$(otool -l "$BUILD_DIR/libmlx.a" 2>/dev/null || true) +if [[ "$OTOOL_OUT" =~ platform[[:space:]]+([0-9]+) ]]; then + PLATFORM="${BASH_REMATCH[1]}" +else + PLATFORM="unknown" +fi +[ "$PLATFORM" = "7" ] || fail "libmlx.a tagged platform=$PLATFORM, expected 7 (iOS Simulator)" + +log "installing to $PREFIX..." +rm -rf "$PREFIX" +mkdir -p "$PREFIX/lib" "$PREFIX/include" + +cp "$BUILD_DIR/libmlx.a" "$PREFIX/lib/" + +mkdir -p "$PREFIX/include/mlx" +rsync -a --include='*.h' --include='*/' --exclude='*' \ + "$MLX_SRC/mlx/" "$PREFIX/include/mlx/" + +if [ -d "$BUILD_DIR/_deps/json-src/include" ]; then + mkdir -p "$PREFIX/include/_deps/json" + rsync -a "$BUILD_DIR/_deps/json-src/include/" "$PREFIX/include/_deps/json/" +fi + +cat > "$PREFIX/VERSION" <<EOF +mlx_version=${MLX_VERSION} +variant=ios-sim-cpu +ios_deployment_target=${IOS_DEPLOYMENT_TARGET} +metal_enabled=false +EOF + +log "done — libmlx.a installed at $PREFIX/lib/libmlx.a" +ls -lh "$PREFIX/lib/libmlx.a" +file "$PREFIX/lib/libmlx.a" | head -1 diff --git a/scripts/release/mlx/patches/0001-ios-metal-build.patch b/scripts/release/mlx/patches/0001-ios-metal-build.patch new file mode 100644 index 0000000..ec8cfcc --- /dev/null +++ b/scripts/release/mlx/patches/0001-ios-metal-build.patch @@ -0,0 +1,91 @@ +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -64,11 +64,25 @@ + endif() + endif() + ++elseif(${CMAKE_SYSTEM_NAME} MATCHES "iOS") ++ # mob_dev iOS+Metal patch: treat iOS like Darwin for Metal purposes ++ # (the iphoneos SDK has metal + metallib; metal-cpp ships iOS headers). ++ message(STATUS "Building MLX for iOS — Metal eligible when MLX_BUILD_METAL=ON") + else() + set(MLX_BUILD_METAL OFF) + message(WARNING "MLX is prioritised for Apple silicon systems using macOS.") + endif() + ++# mob_dev iOS+Metal patch: pick the matching xcrun SDK + version-min ++# flag prefix based on the target platform. ++if(${CMAKE_SYSTEM_NAME} MATCHES "iOS") ++ set(MLX_XCRUN_SDK "iphoneos") ++ set(MLX_VERSION_MIN_FLAG_PREFIX "-mios-version-min=") ++else() ++ set(MLX_XCRUN_SDK "macosx") ++ set(MLX_VERSION_MIN_FLAG_PREFIX "-mmacosx-version-min=") ++endif() ++ + # ----------------------------- Lib ----------------------------- + + include(FetchContent) +@@ -96,26 +110,26 @@ + + # Throw an error if xcrun not found + execute_process( +- COMMAND zsh "-c" "/usr/bin/xcrun -sdk macosx --show-sdk-version" +- OUTPUT_VARIABLE MACOS_SDK_VERSION COMMAND_ERROR_IS_FATAL ANY) ++ COMMAND zsh "-c" "/usr/bin/xcrun -sdk ${MLX_XCRUN_SDK} --show-sdk-version" ++ OUTPUT_VARIABLE MLX_SDK_VERSION COMMAND_ERROR_IS_FATAL ANY) + +- if(${MACOS_SDK_VERSION} LESS 14.0) ++ if(${MLX_SDK_VERSION} LESS 14.0) + message( + FATAL_ERROR +- "MLX requires macOS SDK >= 14.0 to be built with MLX_BUILD_METAL=ON") ++ "MLX requires ${MLX_XCRUN_SDK} SDK >= 14.0 to be built with MLX_BUILD_METAL=ON") + endif() +- message(STATUS "Building with macOS SDK version ${MACOS_SDK_VERSION}") ++ message(STATUS "Building with ${MLX_XCRUN_SDK} SDK version ${MLX_SDK_VERSION}") + + set(METAL_CPP_URL + https://developer.apple.com/metal/cpp/files/metal-cpp_macOS15_iOS18.zip) + + if(NOT CMAKE_OSX_DEPLOYMENT_TARGET STREQUAL "") +- set(XCRUN_FLAGS "-mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}") ++ set(XCRUN_FLAGS "${MLX_VERSION_MIN_FLAG_PREFIX}${CMAKE_OSX_DEPLOYMENT_TARGET}") + endif() + execute_process( + COMMAND + zsh "-c" +- "echo \"__METAL_VERSION__\" | xcrun -sdk macosx metal ${XCRUN_FLAGS} -E -x metal -P - | tail -1 | tr -d '\n'" ++ "echo \"__METAL_VERSION__\" | xcrun -sdk ${MLX_XCRUN_SDK} metal ${XCRUN_FLAGS} -E -x metal -P - | tail -1 | tr -d '\n'" + OUTPUT_VARIABLE MLX_METAL_VERSION COMMAND_ERROR_IS_FATAL ANY) + FetchContent_Declare(metal_cpp URL ${METAL_CPP_URL}) + +--- a/mlx/backend/metal/kernels/CMakeLists.txt ++++ b/mlx/backend/metal/kernels/CMakeLists.txt +@@ -15,7 +15,7 @@ + endif() + if(NOT CMAKE_OSX_DEPLOYMENT_TARGET STREQUAL "") + set(METAL_FLAGS ${METAL_FLAGS} +- "-mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}") ++ "${MLX_VERSION_MIN_FLAG_PREFIX}${CMAKE_OSX_DEPLOYMENT_TARGET}") + endif() + if(MLX_METAL_VERSION GREATER_EQUAL 310) + set(VERSION_INCLUDES +@@ -25,7 +25,7 @@ + ${PROJECT_SOURCE_DIR}/mlx/backend/metal/kernels/metal_3_0) + endif() + add_custom_command( +- COMMAND xcrun -sdk macosx metal ${METAL_FLAGS} -c ${SRCFILE} ++ COMMAND xcrun -sdk ${MLX_XCRUN_SDK} metal ${METAL_FLAGS} -c ${SRCFILE} + -I${PROJECT_SOURCE_DIR} -I${VERSION_INCLUDES} -o ${TARGET}.air + DEPENDS ${SRCFILE} ${DEPS} ${BASE_HEADERS} + OUTPUT ${TARGET}.air +@@ -125,7 +125,7 @@ + + add_custom_command( + OUTPUT ${MLX_METAL_PATH}/mlx.metallib +- COMMAND xcrun -sdk macosx metallib ${KERNEL_AIR} -o ++ COMMAND xcrun -sdk ${MLX_XCRUN_SDK} metallib ${KERNEL_AIR} -o + ${MLX_METAL_PATH}/mlx.metallib + DEPENDS ${KERNEL_AIR} + COMMENT "Building mlx.metallib" diff --git a/scripts/release/mlx/publish.sh b/scripts/release/mlx/publish.sh new file mode 100755 index 0000000..74c7ca0 --- /dev/null +++ b/scripts/release/mlx/publish.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# scripts/release/mlx/publish.sh +# Upload the cross-compiled MLX tarballs to a GitHub release so +# MobDev.MLXDownloader can fetch them at `mix mob.deploy --native` time. +# +# Requires: +# * `gh` CLI installed and authenticated against the GenericJam/mob repo +# (`gh auth status`). +# * Tarballs already built by all_ios.sh (or the per-target scripts). +# +# Inputs (env): +# MLX_VERSION — defaults to the value in _lib.sh (0.25.1) +# OUT_DIR — where the tarballs live (default: /tmp) +# GH_REPO — repo to publish into (default: GenericJam/mob) +# DRY_RUN — non-empty to print what would happen without uploading +# +# Tarballs uploaded (must match MobDev.MLXDownloader.@release_tag / +# @base_url): +# - libmlx-<ver>-ios-device.tar.gz +# - libmlx-<ver>-ios-sim.tar.gz +# +# After uploading, bump MobDev.MLXDownloader.@mlx_version (and +# scripts/release/mlx/_lib.sh) in lockstep if this is a new MLX version. +# Existing apps' `mix mob.deploy --native` will re-download into +# ~/.mob/cache/libmlx-<new-ver>-ios-<slice>/ automatically. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" +source ./_lib.sh + +: "${GH_REPO:=GenericJam/mob}" +: "${DRY_RUN:=}" + +TAG="mlx-${MLX_VERSION}" +TITLE="MLX ${MLX_VERSION} for Mob iOS" +NOTES_FILE=$(mktemp) +trap 'rm -f "$NOTES_FILE"' EXIT + +# Sanity: gh CLI present + authenticated. +command -v gh >/dev/null 2>&1 || fail "gh CLI not found — install via 'brew install gh' and run 'gh auth login'." +gh auth status >/dev/null 2>&1 || fail "gh CLI not authenticated — run 'gh auth login' first." + +ASSETS=( + "$OUT_DIR/libmlx-${MLX_VERSION}-ios-device.tar.gz" + "$OUT_DIR/libmlx-${MLX_VERSION}-ios-sim.tar.gz" +) + +# Verify all assets exist before we touch the release. +for asset in "${ASSETS[@]}"; do + [ -f "$asset" ] || fail "missing $asset — run all_ios.sh (or the per-target ios_{device,sim}.sh + tarball_mlx_*.sh) first" +done + +# Build release notes from the VERSION files inside the tarballs. Captures +# variant + MLX upstream version + iOS deployment target in one place. +cat > "$NOTES_FILE" <<EOF +Pre-built MLX + EMLX static archives for Mob iOS. + +Consumed by MobDev.MLXDownloader at \`mix mob.deploy --native\` time when a +project depends on \`:emlx\`. The downloader extracts the tarball to +\`~/.mob/cache/libmlx-${MLX_VERSION}-ios-<slice>/\`; the iOS build script +then links \`libmlx.a\` + \`libemlx.a\` statically into the app binary. + +## Artifacts + +| Slice | File | Platform tag | Variant | +|---|---|---|---| +| iOS device | libmlx-${MLX_VERSION}-ios-device.tar.gz | platform=2 (iOS arm64) | CPU + Accelerate | +| iOS simulator | libmlx-${MLX_VERSION}-ios-sim.tar.gz | platform=7 (iOSSimulator arm64) | CPU + Accelerate | + +## Build provenance + +- MLX upstream: https://github.com/ml-explore/mlx tag v${MLX_VERSION} +- EMLX upstream: ~/code/test_emlx/deps/emlx (Hex 0.2.x) +- Mob iOS deployment target: ${IOS_DEPLOYMENT_TARGET:-17.0} +- Built with: scripts/release/mlx/all_ios.sh + +## Checksums + +\`\`\` +$(for asset in "${ASSETS[@]}"; do + hash=$(shasum -a 256 "$asset" | awk '{print $1}') + name=$(basename "$asset") + echo "$hash $name" +done) +\`\`\` +EOF + +log "tag: $TAG" +log "repo: $GH_REPO" +log "title: $TITLE" +log "assets:" +for asset in "${ASSETS[@]}"; do log " - $asset ($(du -h "$asset" | awk '{print $1}'))"; done + +if [ -n "$DRY_RUN" ]; then + log "DRY_RUN set — would create release + upload assets; exiting." + log "Release notes preview:" + cat "$NOTES_FILE" + exit 0 +fi + +# Create the release if it doesn't exist, otherwise just upload assets. +if gh release view "$TAG" --repo "$GH_REPO" >/dev/null 2>&1; then + log "release $TAG already exists — uploading (clobbering existing assets)" + gh release upload "$TAG" "${ASSETS[@]}" --repo "$GH_REPO" --clobber +else + log "creating release $TAG" + gh release create "$TAG" "${ASSETS[@]}" \ + --repo "$GH_REPO" \ + --title "$TITLE" \ + --notes-file "$NOTES_FILE" +fi + +log "release URL: $(gh release view "$TAG" --repo "$GH_REPO" --json url --jq .url)" +log "MobDev.MLXDownloader will now fetch from this release on next 'mix mob.deploy --native'." diff --git a/scripts/release/mlx/tarball_mlx_ios_device.sh b/scripts/release/mlx/tarball_mlx_ios_device.sh new file mode 100755 index 0000000..20283cc --- /dev/null +++ b/scripts/release/mlx/tarball_mlx_ios_device.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# scripts/release/mlx/tarball_mlx_ios_device.sh +# Pack the cross-compiled MLX artifacts into a release tarball. +# +# Output: $OUT_DIR/libmlx-<ver>-ios-device.tar.gz +# +# This tarball is what MobDev.MLXDownloader.ensure_ios_device/0 fetches at +# `mix mob.deploy --native` time when a project has :emlx in deps. +# +# Inputs (env): +# MLX_PREFIX — install dir from ios_device.sh + build_emlx_nif_ios_device.sh +# (default: /tmp/mlx-ios-device-<ver>) +# OUT_DIR — output dir (default: /tmp) +# MLX_VERSION — pinned MLX version (default: 0.25.1) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" +source ./_lib.sh + +: "${MLX_PREFIX:=/tmp/mlx-ios-device-${MLX_VERSION}}" + +[ -f "$MLX_PREFIX/lib/libmlx.a" ] || fail "no libmlx.a at $MLX_PREFIX/lib/ — run ios_device.sh first" +[ -f "$MLX_PREFIX/lib/libemlx.a" ] || fail "no libemlx.a at $MLX_PREFIX/lib/ — run build_emlx_nif_ios_device.sh first" + +STAGE=$(mktemp -d -t mlx-tarball-XXXXXX) +trap 'rm -rf "$STAGE"' EXIT + +NAME="libmlx-${MLX_VERSION}-ios-device" +STAGE_DIR="$STAGE/$NAME" +mkdir -p "$STAGE_DIR" +rsync -a "$MLX_PREFIX/" "$STAGE_DIR/" + +OUT="$OUT_DIR/${NAME}.tar.gz" +log "writing $OUT..." +tar -czf "$OUT" -C "$STAGE" "$NAME" + +log "done" +ls -lh "$OUT" +shasum -a 256 "$OUT" | awk '{print "sha256: " $1}' diff --git a/scripts/release/mlx/tarball_mlx_ios_sim.sh b/scripts/release/mlx/tarball_mlx_ios_sim.sh new file mode 100755 index 0000000..0cb0176 --- /dev/null +++ b/scripts/release/mlx/tarball_mlx_ios_sim.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# scripts/release/mlx/tarball_mlx_ios_sim.sh +# iOS Simulator counterpart to tarball_mlx_ios_device.sh. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" +source ./_lib.sh + +: "${MLX_PREFIX:=/tmp/mlx-ios-sim-${MLX_VERSION}}" + +[ -f "$MLX_PREFIX/lib/libmlx.a" ] || fail "no libmlx.a at $MLX_PREFIX/lib/ — run ios_sim.sh first" +[ -f "$MLX_PREFIX/lib/libemlx.a" ] || fail "no libemlx.a at $MLX_PREFIX/lib/ — run build_emlx_nif_ios_sim.sh first" + +STAGE=$(mktemp -d -t mlx-tarball-XXXXXX) +trap 'rm -rf "$STAGE"' EXIT + +NAME="libmlx-${MLX_VERSION}-ios-sim" +STAGE_DIR="$STAGE/$NAME" +mkdir -p "$STAGE_DIR" +rsync -a "$MLX_PREFIX/" "$STAGE_DIR/" + +OUT="$OUT_DIR/${NAME}.tar.gz" +log "writing $OUT..." +tar -czf "$OUT" -C "$STAGE" "$NAME" + +log "done" +ls -lh "$OUT" +shasum -a 256 "$OUT" | awk '{print "sha256: " $1}' diff --git a/scripts/release/openssl/README.md b/scripts/release/openssl/README.md new file mode 100644 index 0000000..1a9152c --- /dev/null +++ b/scripts/release/openssl/README.md @@ -0,0 +1,66 @@ +# scripts/release/openssl + +Per-target OpenSSL cross-compile for the four pre-built OTP runtime +tarballs. Each script writes `libcrypto.a` + `libssl.a` to a target +prefix; the OTP cross-compile then links those statically into the +crypto NIF (`crypto.a`) which gets bundled in the tarball at +`erts-VSN/lib/crypto.a` alongside `erts-VSN/lib/libcrypto.a`. + +See [`crypto_plan.md`](../../../../mob/crypto_plan.md) for the design, +[`build_release.md`](../../build_release.md) §3b for where these slot +into the OTP cross-compile flow, and `mob/common_fixes.md` for the +gotchas (BSD `ar`'s empty-archive trap, Android `RTLD_LOCAL`). + +## Files + +| Script | Target | Output prefix | +|---|---|---| +| `android_arm64.sh` | aarch64 Android | `/tmp/openssl-android-arm64` | +| `android_arm32.sh` | armv7a Android | `/tmp/openssl-android-arm32` | +| `ios_sim.sh` | aarch64 iOS Simulator | `/tmp/openssl-ios-sim` | +| `ios_device.sh` | aarch64 iOS Device | `/tmp/openssl-ios-device` | +| `build_crypto_static_android_arm64.sh` | (helper, only used in dev when patching crypto.a outside an OTP rebuild) | `lib/crypto/priv/lib/<arch>/libcrypto_nif.a` | + +## Source + +OpenSSL 3.4.0, cloned shallow at `~/code/openssl`: + +```bash +git clone --depth 1 --branch openssl-3.4.0 https://github.com/openssl/openssl.git ~/code/openssl +``` + +Pinning to 3.4.0 (not 3.5.x) for now because 3.4 is the latest stable +LTS. Bump after running each script clean against a newer tag and +verifying tarball boots end-to-end. + +## Configure flags (all targets) + +``` +no-shared # Build only static libs (no .so/.dylib) +no-tests # Skip the test suite +no-apps # Skip the openssl(1) binary — we don't ship it +no-engine # No dynamic engine API; OpenSSL 3 providers replace it +``` + +## NDK / SDK pins + +- Android: NDK `27.2.12479018`, API 24 minimum (`-D__ANDROID_API__=24`) +- iOS: Xcode's bundled `xcrun` toolchains, deployment target 17.0 + +If the host machine has different NDK / Xcode versions installed, set +`ANDROID_NDK_ROOT` env var to override the default. + +## Verifying a built archive + +```bash +# Architecture sanity: +file /tmp/openssl-android-arm64/lib/libcrypto.a +# → "current ar archive" (the wrapper); extract one .o to confirm: +$NDK/llvm-ar x /tmp/openssl-android-arm64/lib/libcrypto.a libcrypto-lib-evp_pkey.o +file libcrypto-lib-evp_pkey.o +# → ELF 64-bit LSB relocatable, ARM aarch64 + +# x25519 symbols present (peer_net needs them): +$NDK/llvm-nm /tmp/openssl-android-arm64/lib/libcrypto.a | grep -i x25519 +# → ossl_x25519, ossl_x25519_public_from_private, etc. +``` diff --git a/scripts/release/openssl/_build_otp_android_arm64.sh b/scripts/release/openssl/_build_otp_android_arm64.sh new file mode 100755 index 0000000..a1b71eb --- /dev/null +++ b/scripts/release/openssl/_build_otp_android_arm64.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Cross-compile OTP for android-arm64 with --with-ssl pointing at our +# own OpenSSL build. Produces /tmp/otp-android (an OTP install tree) plus +# the per-arch static-lib and config.h artifacts under $OTP_SRC. +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +: "${OPENSSL_PREFIX:=/tmp/openssl-android-arm64}" +: "${OTP_SRC:=$HOME/code/otp}" +: "${OTP_RELEASE:=/tmp/otp-android}" +: "${NDK_ABI_PLAT:=android24}" + +[ -d "$OPENSSL_PREFIX" ] || { echo "missing $OPENSSL_PREFIX" >&2; exit 1; } +[ -d "$ANDROID_NDK_ROOT" ] || { echo "missing $ANDROID_NDK_ROOT" >&2; exit 1; } + +export NDK_ROOT="$ANDROID_NDK_ROOT" +export PATH="$NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64/bin:$PATH" +export NDK_ABI_PLAT +export RELEASE_LIBBEAM=yes + +cd "$OTP_SRC" + +# Clean any stale config from a prior arch (iOS, etc.). +make distclean >/dev/null 2>&1 || true + +./otp_build configure \ + --xcomp-conf=./xcomp/erl-xcomp-arm64-android.conf \ + --with-ssl="$OPENSSL_PREFIX" \ + --disable-dynamic-ssl-lib + +./otp_build boot + +# Stage the install tree at /tmp/otp-android. +rm -rf "$OTP_RELEASE" +./otp_build release -a "$OTP_RELEASE" + +echo +echo "=== OTP Android arm64 with crypto installed at $OTP_RELEASE ===" +ls "$OTP_RELEASE/lib/" | grep -E '^(crypto|public_key|ssl)-' | head -5 || echo "WARN: crypto/ssl/public_key apps NOT in install tree" +find "$OTP_RELEASE/lib/" -name 'crypto.so' 2>/dev/null | head -3 || echo "WARN: crypto.so NIF not found" diff --git a/scripts/release/openssl/_build_otp_android_x86_64.sh b/scripts/release/openssl/_build_otp_android_x86_64.sh new file mode 100755 index 0000000..4cc1436 --- /dev/null +++ b/scripts/release/openssl/_build_otp_android_x86_64.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Cross-compile OTP for android-x86_64 with --with-ssl pointing at our own +# OpenSSL build. Produces /tmp/otp-android-x86_64 (an OTP install tree). +# Mirrors _build_otp_android_arm64.sh — only the xcomp conf, OpenSSL prefix, +# and release dir differ. +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +: "${OPENSSL_PREFIX:=/tmp/openssl-android-x86_64}" +: "${OTP_SRC:=$HOME/code/otp}" +: "${OTP_RELEASE:=/tmp/otp-android-x86_64}" +: "${NDK_ABI_PLAT:=android24}" + +[ -d "$OPENSSL_PREFIX" ] || { echo "missing $OPENSSL_PREFIX" >&2; exit 1; } +[ -d "$ANDROID_NDK_ROOT" ] || { echo "missing $ANDROID_NDK_ROOT" >&2; exit 1; } + +export NDK_ROOT="$ANDROID_NDK_ROOT" +export PATH="$NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64/bin:$PATH" +export NDK_ABI_PLAT +export RELEASE_LIBBEAM=yes + +cd "$OTP_SRC" + +# Install mob's corrected x86_64 xcomp conf into the OTP source. Upstream +# OTP ships an incomplete stub (missing AR/RANLIB, gc-sections, the +# --without-* flags; references the removed ld.gold) — see +# scripts/release/xcomp/erl-xcomp-x86_64-android.conf. Vendored here so a +# fresh OTP checkout builds without manual patching. +VENDORED_CONF="$(dirname "$0")/../xcomp/erl-xcomp-x86_64-android.conf" +[ -f "$VENDORED_CONF" ] && cp "$VENDORED_CONF" "$OTP_SRC/xcomp/erl-xcomp-x86_64-android.conf" + + +# Clean any stale config from a prior arch (arm64, iOS, etc.). +make distclean >/dev/null 2>&1 || true + +./otp_build configure \ + --xcomp-conf=./xcomp/erl-xcomp-x86_64-android.conf \ + --with-ssl="$OPENSSL_PREFIX" \ + --disable-dynamic-ssl-lib + +./otp_build boot + +rm -rf "$OTP_RELEASE" +./otp_build release -a "$OTP_RELEASE" + +echo +echo "=== OTP Android x86_64 with crypto installed at $OTP_RELEASE ===" +ls "$OTP_RELEASE/lib/" | grep -E '^(crypto|public_key|ssl)-' | head -5 || echo "WARN: crypto/ssl/public_key apps NOT in install tree" diff --git a/scripts/release/openssl/_lib.sh b/scripts/release/openssl/_lib.sh new file mode 100755 index 0000000..040a49d --- /dev/null +++ b/scripts/release/openssl/_lib.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# scripts/release/openssl/_lib.sh +# +# Shared values for the cross-compile scripts in this directory. +# Source this from another script: `. "$(dirname "$0")/_lib.sh"`. +# +# Single source of truth on the Bash side; mirrors mob_dev's +# `MobDev.NdkVersion.@recommended` constant. The NDK we build OpenSSL +# (and later libbeam.a) against has to match the one that built the +# bundled OTP tarballs — different libc++ inline namespaces between +# NDK 25 and NDK 27 fail to link with `__cxa_allocate_exception` +# (or similar) at the libpigeon.so step. +# +# When you bump @recommended in lib/mob_dev/ndk_version.ex, bump the +# default below too. CI doesn't (yet) drift-check this against the +# Elixir module, but `mix mob.doctor` will warn the developer if their +# install is out of date. + +# Default NDK version used to cross-compile OpenSSL + OTP tarballs. +# Override with NDK_VERSION=... in the environment if you know what +# you're doing. +: "${NDK_VERSION:=27.2.12479018}" + +# Default NDK install root on macOS. Honors caller-set ANDROID_NDK_ROOT. +: "${ANDROID_NDK_ROOT:=$HOME/Library/Android/sdk/ndk/$NDK_VERSION}" + +export NDK_VERSION ANDROID_NDK_ROOT diff --git a/scripts/release/openssl/android_arm32.sh b/scripts/release/openssl/android_arm32.sh new file mode 100755 index 0000000..17d3519 --- /dev/null +++ b/scripts/release/openssl/android_arm32.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# scripts/release/openssl/android_arm32.sh +# Cross-compile OpenSSL 3.x for Android arm32 (armv7a-linux-androideabi, API 24+). +# Output: $PREFIX/lib/libcrypto.a, $PREFIX/lib/libssl.a, $PREFIX/include/openssl/*.h +# +# Inputs (env): +# OPENSSL_SRC — OpenSSL source checkout (default: ~/code/openssl) +# NDK_VERSION — NDK version (sourced from _lib.sh; matches MobDev.NdkVersion) +# ANDROID_NDK_ROOT — NDK root (default: ~/Library/Android/sdk/ndk/$NDK_VERSION) +# PREFIX — install dir (default: /tmp/openssl-android-arm32) +# ANDROID_API — minimum Android API (default: 24) +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +: "${OPENSSL_SRC:=$HOME/code/openssl}" +: "${PREFIX:=/tmp/openssl-android-arm32}" +: "${ANDROID_API:=24}" + +TOOLCHAIN="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64" +[ -d "$TOOLCHAIN" ] || { echo "ERROR: NDK toolchain not at $TOOLCHAIN" >&2; exit 1; } +[ -d "$OPENSSL_SRC" ] || { echo "ERROR: OPENSSL_SRC not at $OPENSSL_SRC" >&2; exit 1; } + +export ANDROID_NDK_ROOT +export PATH="$TOOLCHAIN/bin:$PATH" + +cd "$OPENSSL_SRC" +make distclean >/dev/null 2>&1 || true + +## See android_arm64.sh for size-flag rationale. arm32 also needs +## no-asm because OpenSSL's hand-written ARM assembly emits non-PIC +## absolute relocations against OPENSSL_armcap_P (ld.lld rejects +## these when libcrypto.a is linked into a .so). +## See android_arm64.sh for the no-X rationale per algorithm. +## arm32 needs no-asm too — non-PIC absolute relocations against +## OPENSSL_armcap_P in the hand-written ARM assembly. +./Configure android-arm \ + -D__ANDROID_API__="$ANDROID_API" \ + -Os -ffunction-sections -fdata-sections \ + -fPIC \ + --prefix="$PREFIX" \ + --openssldir="$PREFIX/ssl" \ + no-shared no-tests no-apps no-engine no-asm \ + no-md2 no-md4 no-mdc2 no-whirlpool no-rmd160 \ + no-rc2 no-rc4 no-idea no-cast no-bf no-blake2 \ + no-seed no-aria no-camellia no-gost \ + no-weak-ssl-ciphers no-ssl3 no-tls1 no-tls1_1 \ + no-srp no-psk no-nextprotoneg + +make -j8 +make install_sw + +echo +echo "OpenSSL Android arm32 installed at: $PREFIX" +echo " $(ls -la "$PREFIX/lib/libcrypto.a")" +echo " arch check: $(file "$PREFIX/lib/libcrypto.a" | head -1)" diff --git a/scripts/release/openssl/android_arm64.sh b/scripts/release/openssl/android_arm64.sh new file mode 100755 index 0000000..d2b86ba --- /dev/null +++ b/scripts/release/openssl/android_arm64.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# scripts/release/openssl/android_arm64.sh +# Cross-compile OpenSSL 3.x for Android arm64 (aarch64-linux-android, API 24+). +# Output: $PREFIX/lib/libcrypto.a, $PREFIX/lib/libssl.a, $PREFIX/include/openssl/*.h +# +# Inputs (env): +# OPENSSL_SRC — OpenSSL source checkout (default: ~/code/openssl) +# NDK_VERSION — NDK version (sourced from _lib.sh; matches MobDev.NdkVersion) +# ANDROID_NDK_ROOT — NDK root (default: ~/Library/Android/sdk/ndk/$NDK_VERSION) +# PREFIX — install dir (default: /tmp/openssl-android-arm64) +# ANDROID_API — minimum Android API (default: 24) +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +: "${OPENSSL_SRC:=$HOME/code/openssl}" +: "${PREFIX:=/tmp/openssl-android-arm64}" +: "${ANDROID_API:=24}" + +TOOLCHAIN="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64" +[ -d "$TOOLCHAIN" ] || { echo "ERROR: NDK toolchain not at $TOOLCHAIN" >&2; exit 1; } +[ -d "$OPENSSL_SRC" ] || { echo "ERROR: OPENSSL_SRC not at $OPENSSL_SRC" >&2; exit 1; } + +export ANDROID_NDK_ROOT +export PATH="$TOOLCHAIN/bin:$PATH" + +cd "$OPENSSL_SRC" + +# Clean any previous arch's config so Configure doesn't get confused. +make distclean >/dev/null 2>&1 || true + +## Size flags: -Os over default -O3, plus per-function/data sections so +## the linker can dead-strip unused crypto code from the final libpigeon.so +## via -Wl,--gc-sections (set on the consuming link, not here). Per +## GRiSP nano (2025-06-11): single biggest C-side shrink technique. +## -fPIC explicit (NDK toolchain default but belt-and-suspenders). +## +## Pass 4 (2026-05-06): disable the legacy / rarely-used algorithms +## that no Mob app should exercise. This is an additive list — every +## entry has a justification. If an app actually needs one, drop it +## from the list and rebuild. +## +## md2/md4/mdc2/whirlpool/ripemd160 — superseded by SHA-2 / BLAKE2. +## No modern code uses them. +## rc2/rc4/idea/cast/bf/blake2/seed/aria/camellia — legacy ciphers. +## AES-GCM and ChaCha20-Poly1305 +## cover real cryptography. +## gost — Russian-only standards. +## weak-ssl-ciphers — RC4, single-DES, NULL, EXPORT. +## ssl3, tls1, tls1_1 — pre-TLS-1.2. Refused by every modern server. +## srp — pre-shared password protocol. Niche. +## psk — pre-shared key TLS variant. Niche. +## nextprotoneg — superseded by ALPN. +./Configure android-arm64 \ + -D__ANDROID_API__="$ANDROID_API" \ + -Os -ffunction-sections -fdata-sections \ + -fPIC \ + --prefix="$PREFIX" \ + --openssldir="$PREFIX/ssl" \ + no-shared no-tests no-apps no-engine \ + no-md2 no-md4 no-mdc2 no-whirlpool no-rmd160 \ + no-rc2 no-rc4 no-idea no-cast no-bf no-blake2 \ + no-seed no-aria no-camellia no-gost \ + no-weak-ssl-ciphers no-ssl3 no-tls1 no-tls1_1 \ + no-srp no-psk no-nextprotoneg + +make -j8 +make install_sw + +echo +echo "OpenSSL Android arm64 installed at: $PREFIX" +echo " $(ls -la "$PREFIX/lib/libcrypto.a")" +echo " $(ls -la "$PREFIX/lib/libssl.a")" +echo " arch check: $(file "$PREFIX/lib/libcrypto.a" | head -1)" diff --git a/scripts/release/openssl/android_x86_64.sh b/scripts/release/openssl/android_x86_64.sh new file mode 100755 index 0000000..c7fbc35 --- /dev/null +++ b/scripts/release/openssl/android_x86_64.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# scripts/release/openssl/android_x86_64.sh +# Cross-compile OpenSSL 3.x for Android x86_64 (x86_64-linux-android, API 24+). +# x86_64 ABI = Android emulators on x86_64 hosts (Intel Macs, most CI runners). +# Output: $PREFIX/lib/libcrypto.a, $PREFIX/lib/libssl.a, $PREFIX/include/openssl/*.h +# +# Mirrors android_arm64.sh — only the Configure target and PREFIX differ. +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +: "${OPENSSL_SRC:=$HOME/code/openssl}" +: "${PREFIX:=/tmp/openssl-android-x86_64}" +: "${ANDROID_API:=24}" + +TOOLCHAIN="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64" +[ -d "$TOOLCHAIN" ] || { echo "ERROR: NDK toolchain not at $TOOLCHAIN" >&2; exit 1; } +[ -d "$OPENSSL_SRC" ] || { echo "ERROR: OPENSSL_SRC not at $OPENSSL_SRC" >&2; exit 1; } + +export ANDROID_NDK_ROOT +export PATH="$TOOLCHAIN/bin:$PATH" + +cd "$OPENSSL_SRC" +make distclean >/dev/null 2>&1 || true + +# Same algorithm-disable list as android_arm64.sh — see that script's comment +# for the per-entry justification. +./Configure android-x86_64 \ + -D__ANDROID_API__="$ANDROID_API" \ + -Os -ffunction-sections -fdata-sections \ + -fPIC \ + --prefix="$PREFIX" \ + --openssldir="$PREFIX/ssl" \ + no-shared no-tests no-apps no-engine \ + no-md2 no-md4 no-mdc2 no-whirlpool no-rmd160 \ + no-rc2 no-rc4 no-idea no-cast no-bf no-blake2 \ + no-seed no-aria no-camellia no-gost \ + no-weak-ssl-ciphers no-ssl3 no-tls1 no-tls1_1 \ + no-srp no-psk no-nextprotoneg + +make -j8 +make install_sw + +echo +echo "OpenSSL Android x86_64 installed at: $PREFIX" +echo " $(ls -la "$PREFIX/lib/libcrypto.a")" +echo " arch check: $(file "$PREFIX/lib/libcrypto.a" | head -1)" diff --git a/scripts/release/openssl/build_crypto_static_android_arm32.sh b/scripts/release/openssl/build_crypto_static_android_arm32.sh new file mode 100755 index 0000000..4ad8a57 --- /dev/null +++ b/scripts/release/openssl/build_crypto_static_android_arm32.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# scripts/release/openssl/build_crypto_static_android_arm32.sh +# +# arm32 version of build_crypto_static_android_arm64.sh. +# See header of that file for the why. +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +: "${OTP_SRC:=$HOME/code/otp}" +: "${OPENSSL_PREFIX:=/tmp/openssl-android-arm32}" +: "${ANDROID_API:=24}" + +TOOLCHAIN="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64" +CC="$TOOLCHAIN/bin/armv7a-linux-androideabi${ANDROID_API}-clang" +AR="$TOOLCHAIN/bin/llvm-ar" +RANLIB="$TOOLCHAIN/bin/llvm-ranlib" + +[ -x "$CC" ] || { echo "ERROR: $CC not found" >&2; exit 1; } +[ -d "$OPENSSL_PREFIX" ] || { echo "ERROR: $OPENSSL_PREFIX missing" >&2; exit 1; } + +CRYPTO_SRC="$OTP_SRC/lib/crypto/c_src" +ARCH=arm-unknown-linux-androideabi +OBJ_DIR="$OTP_SRC/lib/crypto/priv/obj/${ARCH}_static_nif" +LIB_DIR="$OTP_SRC/lib/crypto/priv/lib/$ARCH" +mkdir -p "$OBJ_DIR" "$LIB_DIR" + +CFLAGS=( + -march=armv7-a -mfloat-abi=softfp -mthumb + -fstrict-flex-arrays=3 -fno-strict-aliasing -fno-delete-null-pointer-checks + -fno-strict-overflow -fexceptions + -fstack-protector-strong -fstack-clash-protection + -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 + -fno-common -g -Os -ffunction-sections -fdata-sections -D_GNU_SOURCE -fPIC + -DHAVE_OPENSSL_CRYPTO_MEMCMP + -DSTATIC_ERLANG_NIF + -DDISABLE_EVP_DH=0 -DDISABLE_EVP_HMAC=0 + -I"$OPENSSL_PREFIX/include" + -I"$OTP_SRC/erts/emulator/beam" + -I"$OTP_SRC/erts/include" + -I"$OTP_SRC/erts/include/$ARCH" + -I"$OTP_SRC/erts/include/internal" + -I"$OTP_SRC/erts/include/internal/$ARCH" + -I"$OTP_SRC/erts/emulator/sys/unix" + -I"$OTP_SRC/erts/emulator/sys/common" + -Wno-deprecated-declarations +) + +SOURCES=( + aead.c aes.c algorithms.c api_ng.c atoms.c bn.c cipher.c cmac.c + common.c crypto.c crypto_callback.c dh.c digest.c dss.c ec.c ecdh.c + eddsa.c engine.c evp.c fips.c hash.c hash_equals.c hmac.c info.c + mac.c math.c pbkdf2_hmac.c pkey.c rand.c rsa.c srp.c +) + +echo "=== Compiling crypto NIF sources for arm32 with -DSTATIC_ERLANG_NIF -fPIC ===" +OBJECTS=() +for src in "${SOURCES[@]}"; do + obj="$OBJ_DIR/${src%.c}.o" + OBJECTS+=("$obj") + "$CC" "${CFLAGS[@]}" -c -o "$obj" "$CRYPTO_SRC/$src" +done + +echo "=== Archiving crypto.a ===" +rm -f "$LIB_DIR/crypto.a" +"$AR" rcs "$LIB_DIR/crypto.a" "${OBJECTS[@]}" +"$RANLIB" "$LIB_DIR/crypto.a" + +echo +echo "Done: $LIB_DIR/crypto.a" +ls -la "$LIB_DIR/crypto.a" +"$TOOLCHAIN/bin/llvm-nm" "$LIB_DIR/crypto.a" | grep -E ' T crypto_nif_init$' | head -3 diff --git a/scripts/release/openssl/build_crypto_static_android_arm64.sh b/scripts/release/openssl/build_crypto_static_android_arm64.sh new file mode 100755 index 0000000..d13d99e --- /dev/null +++ b/scripts/release/openssl/build_crypto_static_android_arm64.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# scripts/release/openssl/build_crypto_static_android_arm64.sh +# +# Recompile OTP's crypto NIF C sources with -DSTATIC_ERLANG_NIF for +# Android arm64 and archive them as crypto.a. This .a (plus the +# real libcrypto.a from OpenSSL) is what gets static-linked into the +# app's libpigeon.so. The crypto module's `erlang:load_nif("crypto", ...)` +# then resolves the static `crypto_nif_init` symbol instead of dlopen'ing +# crypto.so — required because Android loads native libs RTLD_LOCAL by +# default, hiding the parent's enif_* symbols from dlopen'd children. +# +# Inputs (env): +# OTP_SRC — OTP source checkout (default: ~/code/otp) +# OPENSSL_PREFIX — pre-built OpenSSL install (default: /tmp/openssl-android-arm64) +# NDK_VERSION — NDK version (sourced from _lib.sh; matches MobDev.NdkVersion) +# ANDROID_NDK_ROOT — NDK root (default: ~/Library/Android/sdk/ndk/$NDK_VERSION) +# ANDROID_API — minimum Android API (default: 24) +# +# Output: +# $OTP_SRC/lib/crypto/priv/lib/aarch64-unknown-linux-android/crypto.a +# +# The release tarball script (tarball_android_arm64.sh) picks this up and +# places it next to libbeam.a etc. so the user's CMakeLists can +# target_link_libraries it via ${OTP_DIR}/${ERTS_VSN}/lib/crypto.a. +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +: "${OTP_SRC:=$HOME/code/otp}" +: "${OPENSSL_PREFIX:=/tmp/openssl-android-arm64}" +: "${ANDROID_API:=24}" + +TOOLCHAIN="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64" +CC="$TOOLCHAIN/bin/aarch64-linux-android${ANDROID_API}-clang" +AR="$TOOLCHAIN/bin/llvm-ar" +RANLIB="$TOOLCHAIN/bin/llvm-ranlib" + +[ -x "$CC" ] || { echo "ERROR: $CC not found" >&2; exit 1; } +[ -d "$OPENSSL_PREFIX" ] || { echo "ERROR: $OPENSSL_PREFIX missing" >&2; exit 1; } + +CRYPTO_SRC="$OTP_SRC/lib/crypto/c_src" +ARCH=aarch64-unknown-linux-android +OBJ_DIR="$OTP_SRC/lib/crypto/priv/obj/${ARCH}_static_nif" +LIB_DIR="$OTP_SRC/lib/crypto/priv/lib/$ARCH" +mkdir -p "$OBJ_DIR" "$LIB_DIR" + +# Match the regular crypto build's CFLAGS, but add -DSTATIC_ERLANG_NIF so +# ERL_NIF_INIT(crypto,...) emits `crypto_nif_init` (the static symbol the +# BEAM dlsym(RTLD_DEFAULT)s when load_nif is called for module crypto). +CFLAGS=( + -fstrict-flex-arrays=3 -fno-strict-aliasing -fno-delete-null-pointer-checks + -fno-strict-overflow -fexceptions -mbranch-protection=standard + -fstack-protector-strong -fstack-clash-protection + -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 + -fno-common -g -Os -ffunction-sections -fdata-sections -D_GNU_SOURCE -fPIC + -DHAVE_OPENSSL_CRYPTO_MEMCMP + -DSTATIC_ERLANG_NIF + -DDISABLE_EVP_DH=0 -DDISABLE_EVP_HMAC=0 + -I"$OPENSSL_PREFIX/include" + -I"$OTP_SRC/erts/emulator/beam" + -I"$OTP_SRC/erts/include" + -I"$OTP_SRC/erts/include/$ARCH" + -I"$OTP_SRC/erts/include/internal" + -I"$OTP_SRC/erts/include/internal/$ARCH" + -I"$OTP_SRC/erts/emulator/sys/unix" + -I"$OTP_SRC/erts/emulator/sys/common" + -Wno-deprecated-declarations +) + +# All crypto NIF sources EXCEPT otp_test_engine.c (test fixture, not +# wanted in production). +SOURCES=( + aead.c aes.c algorithms.c api_ng.c atoms.c bn.c cipher.c cmac.c + common.c crypto.c crypto_callback.c dh.c digest.c dss.c ec.c ecdh.c + eddsa.c engine.c evp.c fips.c hash.c hash_equals.c hmac.c info.c + mac.c math.c pbkdf2_hmac.c pkey.c rand.c rsa.c srp.c +) + +echo "=== Compiling crypto NIF sources with -DSTATIC_ERLANG_NIF ===" +OBJECTS=() +for src in "${SOURCES[@]}"; do + obj="$OBJ_DIR/${src%.c}.o" + OBJECTS+=("$obj") + "$CC" "${CFLAGS[@]}" -c -o "$obj" "$CRYPTO_SRC/$src" +done + +echo "=== Archiving crypto.a ===" +rm -f "$LIB_DIR/crypto.a" +"$AR" rcs "$LIB_DIR/crypto.a" "${OBJECTS[@]}" +"$RANLIB" "$LIB_DIR/crypto.a" + +echo +echo "Done: $LIB_DIR/crypto.a" +ls -la "$LIB_DIR/crypto.a" +echo +echo "Verify static init symbol:" +"$TOOLCHAIN/bin/llvm-nm" "$LIB_DIR/crypto.a" | grep -E ' T crypto_nif_init$' | head -3 diff --git a/scripts/release/openssl/build_crypto_static_android_x86_64.sh b/scripts/release/openssl/build_crypto_static_android_x86_64.sh new file mode 100755 index 0000000..731bee5 --- /dev/null +++ b/scripts/release/openssl/build_crypto_static_android_x86_64.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# scripts/release/openssl/build_crypto_static_android_x86_64.sh +# +# Recompile OTP's crypto NIF C sources with -DSTATIC_ERLANG_NIF for +# Android arm64 and archive them as crypto.a. This .a (plus the +# real libcrypto.a from OpenSSL) is what gets static-linked into the +# app's libpigeon.so. The crypto module's `erlang:load_nif("crypto", ...)` +# then resolves the static `crypto_nif_init` symbol instead of dlopen'ing +# crypto.so — required because Android loads native libs RTLD_LOCAL by +# default, hiding the parent's enif_* symbols from dlopen'd children. +# +# Inputs (env): +# OTP_SRC — OTP source checkout (default: ~/code/otp) +# OPENSSL_PREFIX — pre-built OpenSSL install (default: /tmp/openssl-android-x86_64) +# NDK_VERSION — NDK version (sourced from _lib.sh; matches MobDev.NdkVersion) +# ANDROID_NDK_ROOT — NDK root (default: ~/Library/Android/sdk/ndk/$NDK_VERSION) +# ANDROID_API — minimum Android API (default: 24) +# +# Output: +# $OTP_SRC/lib/crypto/priv/lib/x86_64-pc-linux-android/crypto.a +# +# The release tarball script (tarball_android_x86_64.sh) picks this up and +# places it next to libbeam.a etc. so the user's CMakeLists can +# target_link_libraries it via ${OTP_DIR}/${ERTS_VSN}/lib/crypto.a. +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +: "${OTP_SRC:=$HOME/code/otp}" +: "${OPENSSL_PREFIX:=/tmp/openssl-android-x86_64}" +: "${ANDROID_API:=24}" + +TOOLCHAIN="$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64" +CC="$TOOLCHAIN/bin/x86_64-linux-android${ANDROID_API}-clang" +AR="$TOOLCHAIN/bin/llvm-ar" +RANLIB="$TOOLCHAIN/bin/llvm-ranlib" + +[ -x "$CC" ] || { echo "ERROR: $CC not found" >&2; exit 1; } +[ -d "$OPENSSL_PREFIX" ] || { echo "ERROR: $OPENSSL_PREFIX missing" >&2; exit 1; } + +CRYPTO_SRC="$OTP_SRC/lib/crypto/c_src" +ARCH=x86_64-pc-linux-android +OBJ_DIR="$OTP_SRC/lib/crypto/priv/obj/${ARCH}_static_nif" +LIB_DIR="$OTP_SRC/lib/crypto/priv/lib/$ARCH" +mkdir -p "$OBJ_DIR" "$LIB_DIR" + +# Match the regular crypto build's CFLAGS, but add -DSTATIC_ERLANG_NIF so +# ERL_NIF_INIT(crypto,...) emits `crypto_nif_init` (the static symbol the +# BEAM dlsym(RTLD_DEFAULT)s when load_nif is called for module crypto). +CFLAGS=( + -fstrict-flex-arrays=3 -fno-strict-aliasing -fno-delete-null-pointer-checks + -fno-strict-overflow -fexceptions + -fstack-protector-strong -fstack-clash-protection + -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 + -fno-common -g -Os -ffunction-sections -fdata-sections -D_GNU_SOURCE -fPIC + -DHAVE_OPENSSL_CRYPTO_MEMCMP + -DSTATIC_ERLANG_NIF + -DDISABLE_EVP_DH=0 -DDISABLE_EVP_HMAC=0 + -I"$OPENSSL_PREFIX/include" + -I"$OTP_SRC/erts/emulator/beam" + -I"$OTP_SRC/erts/include" + -I"$OTP_SRC/erts/include/$ARCH" + -I"$OTP_SRC/erts/include/internal" + -I"$OTP_SRC/erts/include/internal/$ARCH" + -I"$OTP_SRC/erts/emulator/sys/unix" + -I"$OTP_SRC/erts/emulator/sys/common" + -Wno-deprecated-declarations +) + +# All crypto NIF sources EXCEPT otp_test_engine.c (test fixture, not +# wanted in production). +SOURCES=( + aead.c aes.c algorithms.c api_ng.c atoms.c bn.c cipher.c cmac.c + common.c crypto.c crypto_callback.c dh.c digest.c dss.c ec.c ecdh.c + eddsa.c engine.c evp.c fips.c hash.c hash_equals.c hmac.c info.c + mac.c math.c pbkdf2_hmac.c pkey.c rand.c rsa.c srp.c +) + +echo "=== Compiling crypto NIF sources with -DSTATIC_ERLANG_NIF ===" +OBJECTS=() +for src in "${SOURCES[@]}"; do + obj="$OBJ_DIR/${src%.c}.o" + OBJECTS+=("$obj") + "$CC" "${CFLAGS[@]}" -c -o "$obj" "$CRYPTO_SRC/$src" +done + +echo "=== Archiving crypto.a ===" +rm -f "$LIB_DIR/crypto.a" +"$AR" rcs "$LIB_DIR/crypto.a" "${OBJECTS[@]}" +"$RANLIB" "$LIB_DIR/crypto.a" + +echo +echo "Done: $LIB_DIR/crypto.a" +ls -la "$LIB_DIR/crypto.a" +echo +echo "Verify static init symbol:" +"$TOOLCHAIN/bin/llvm-nm" "$LIB_DIR/crypto.a" | grep -E ' T crypto_nif_init$' | head -3 diff --git a/scripts/release/openssl/build_crypto_static_ios_device.sh b/scripts/release/openssl/build_crypto_static_ios_device.sh new file mode 100755 index 0000000..4abe5a1 --- /dev/null +++ b/scripts/release/openssl/build_crypto_static_ios_device.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# scripts/release/openssl/build_crypto_static_ios_device.sh +# +# iOS arm64 device counterpart to build_crypto_static_ios_sim.sh. +# See that file's header for rationale (iOS forbids unsigned dlopen so +# crypto NIF must be present in the final signed binary). +# +# Inputs (env): +# OTP_SRC — OTP source checkout (default: ~/code/otp) +# OPENSSL_PREFIX — pre-built OpenSSL install (default: /tmp/openssl-ios-device) +# +# Output: +# $OTP_SRC/lib/crypto/priv/lib/aarch64-apple-ios/crypto.a +set -euo pipefail + +: "${OTP_SRC:=$HOME/code/otp}" +: "${OPENSSL_PREFIX:=/tmp/openssl-ios-device}" + +[ -d "$OTP_SRC" ] || { echo "ERROR: OTP_SRC not at $OTP_SRC" >&2; exit 1; } +[ -d "$OPENSSL_PREFIX" ] || { echo "ERROR: $OPENSSL_PREFIX missing — run scripts/release/openssl/ios_device.sh first" >&2; exit 1; } + +# Targets the device SDK (iphoneos, not iphonesimulator). -miphoneos-version-min +# matches the floor in the per-app build scripts. +CC="xcrun -sdk iphoneos clang -arch arm64 -miphoneos-version-min=17.0" +AR="xcrun -sdk iphoneos ar" +RANLIB="xcrun -sdk iphoneos ranlib" + +CRYPTO_SRC="$OTP_SRC/lib/crypto/c_src" +ARCH=aarch64-apple-ios +OBJ_DIR="$OTP_SRC/lib/crypto/priv/obj/${ARCH}_static_nif" +LIB_DIR="$OTP_SRC/lib/crypto/priv/lib/$ARCH" +mkdir -p "$OBJ_DIR" "$LIB_DIR" + +CFLAGS=( + -fno-strict-aliasing -fno-delete-null-pointer-checks + -fno-strict-overflow -fexceptions + -fstack-protector-strong + -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 + -fno-common -g -Os -ffunction-sections -fdata-sections -fPIC + -DHAVE_OPENSSL_CRYPTO_MEMCMP + -DSTATIC_ERLANG_NIF + -DDISABLE_EVP_DH=0 -DDISABLE_EVP_HMAC=0 + -I"$OPENSSL_PREFIX/include" + -I"$OTP_SRC/erts/emulator/beam" + -I"$OTP_SRC/erts/include" + -I"$OTP_SRC/erts/include/$ARCH" + -I"$OTP_SRC/erts/include/internal" + -I"$OTP_SRC/erts/include/internal/$ARCH" + -I"$OTP_SRC/erts/emulator/sys/unix" + -I"$OTP_SRC/erts/emulator/sys/common" + -Wno-deprecated-declarations +) + +SOURCES=( + aead.c aes.c algorithms.c api_ng.c atoms.c bn.c cipher.c cmac.c + common.c crypto.c crypto_callback.c dh.c digest.c dss.c ec.c ecdh.c + eddsa.c engine.c evp.c fips.c hash.c hash_equals.c hmac.c info.c + mac.c math.c pbkdf2_hmac.c pkey.c rand.c rsa.c srp.c +) + +echo "=== Compiling crypto NIF sources for iOS device arm64 with -DSTATIC_ERLANG_NIF ===" +OBJECTS=() +for src in "${SOURCES[@]}"; do + obj="$OBJ_DIR/${src%.c}.o" + OBJECTS+=("$obj") + $CC "${CFLAGS[@]}" -c -o "$obj" "$CRYPTO_SRC/$src" +done + +echo "=== Archiving crypto.a ===" +rm -f "$LIB_DIR/crypto.a" +$AR rcs "$LIB_DIR/crypto.a" "${OBJECTS[@]}" +$RANLIB "$LIB_DIR/crypto.a" + +echo +echo "Done: $LIB_DIR/crypto.a" +ls -la "$LIB_DIR/crypto.a" +echo +echo "Verify static init symbol:" +xcrun -sdk iphoneos nm "$LIB_DIR/crypto.a" 2>/dev/null | grep -E ' T _crypto_nif_init$' | head -3 diff --git a/scripts/release/openssl/build_crypto_static_ios_sim.sh b/scripts/release/openssl/build_crypto_static_ios_sim.sh new file mode 100755 index 0000000..b9e1859 --- /dev/null +++ b/scripts/release/openssl/build_crypto_static_ios_sim.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# scripts/release/openssl/build_crypto_static_ios_sim.sh +# +# iOS arm64 simulator counterpart to build_crypto_static_android_*.sh. +# Recompile OTP's crypto NIF C sources with -DSTATIC_ERLANG_NIF and archive +# them as crypto.a. The user's app's build.sh links this .a + libcrypto.a +# (real OpenSSL) into the app's main native binary so the static +# `crypto_nif_init` symbol resolves without dlopen. +# +# iOS reasoning differs from Android: +# - Android needs static linking because RTLD_LOCAL hides parent symbols +# from dlopen'd children, breaking dynamic crypto.so on-device. +# - iOS needs static linking because the platform forbids loading +# unsigned dylib/dlopen — every NIF must be present in the final +# signed binary. +# Both ship the same artifact: erts-<vsn>/lib/crypto.a in the OTP tarball. +# +# Inputs (env): +# OTP_SRC — OTP source checkout (default: ~/code/otp) +# OPENSSL_PREFIX — pre-built OpenSSL install (default: /tmp/openssl-ios-sim) +# +# Output: +# $OTP_SRC/lib/crypto/priv/lib/aarch64-apple-iossimulator/crypto.a +# +# tarball_ios_sim.sh picks this up and places it at +# erts-<vsn>/lib/crypto.a in the published tarball. +set -euo pipefail + +: "${OTP_SRC:=$HOME/code/otp}" +: "${OPENSSL_PREFIX:=/tmp/openssl-ios-sim}" + +[ -d "$OTP_SRC" ] || { echo "ERROR: OTP_SRC not at $OTP_SRC" >&2; exit 1; } +[ -d "$OPENSSL_PREFIX" ] || { echo "ERROR: $OPENSSL_PREFIX missing — run scripts/release/openssl/ios_sim.sh first" >&2; exit 1; } + +CC="xcrun -sdk iphonesimulator clang -arch arm64 -mios-simulator-version-min=17.0" +AR="xcrun -sdk iphonesimulator ar" +RANLIB="xcrun -sdk iphonesimulator ranlib" + +CRYPTO_SRC="$OTP_SRC/lib/crypto/c_src" +ARCH=aarch64-apple-iossimulator +OBJ_DIR="$OTP_SRC/lib/crypto/priv/obj/${ARCH}_static_nif" +LIB_DIR="$OTP_SRC/lib/crypto/priv/lib/$ARCH" +mkdir -p "$OBJ_DIR" "$LIB_DIR" + +# Mirror the size + safety flags from the Android script. -fPIC is required +# even for static archives that get linked into a position-independent +# executable (every iOS app binary is PIE). +CFLAGS=( + -fno-strict-aliasing -fno-delete-null-pointer-checks + -fno-strict-overflow -fexceptions + -fstack-protector-strong + -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 + -fno-common -g -Os -ffunction-sections -fdata-sections -fPIC + -DHAVE_OPENSSL_CRYPTO_MEMCMP + -DSTATIC_ERLANG_NIF + -DDISABLE_EVP_DH=0 -DDISABLE_EVP_HMAC=0 + -I"$OPENSSL_PREFIX/include" + -I"$OTP_SRC/erts/emulator/beam" + -I"$OTP_SRC/erts/include" + -I"$OTP_SRC/erts/include/$ARCH" + -I"$OTP_SRC/erts/include/internal" + -I"$OTP_SRC/erts/include/internal/$ARCH" + -I"$OTP_SRC/erts/emulator/sys/unix" + -I"$OTP_SRC/erts/emulator/sys/common" + -Wno-deprecated-declarations +) + +SOURCES=( + aead.c aes.c algorithms.c api_ng.c atoms.c bn.c cipher.c cmac.c + common.c crypto.c crypto_callback.c dh.c digest.c dss.c ec.c ecdh.c + eddsa.c engine.c evp.c fips.c hash.c hash_equals.c hmac.c info.c + mac.c math.c pbkdf2_hmac.c pkey.c rand.c rsa.c srp.c +) + +echo "=== Compiling crypto NIF sources for iOS sim arm64 with -DSTATIC_ERLANG_NIF ===" +OBJECTS=() +for src in "${SOURCES[@]}"; do + obj="$OBJ_DIR/${src%.c}.o" + OBJECTS+=("$obj") + $CC "${CFLAGS[@]}" -c -o "$obj" "$CRYPTO_SRC/$src" +done + +echo "=== Archiving crypto.a ===" +rm -f "$LIB_DIR/crypto.a" +$AR rcs "$LIB_DIR/crypto.a" "${OBJECTS[@]}" +$RANLIB "$LIB_DIR/crypto.a" + +echo +echo "Done: $LIB_DIR/crypto.a" +ls -la "$LIB_DIR/crypto.a" +echo +echo "Verify static init symbol:" +xcrun -sdk iphonesimulator nm "$LIB_DIR/crypto.a" 2>/dev/null | grep -E ' T _crypto_nif_init$' | head -3 diff --git a/scripts/release/openssl/ios_device.sh b/scripts/release/openssl/ios_device.sh new file mode 100755 index 0000000..c2df713 --- /dev/null +++ b/scripts/release/openssl/ios_device.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# scripts/release/openssl/ios_device.sh +# Cross-compile OpenSSL 3.x for iOS device (arm64). +# Output: $PREFIX/lib/libcrypto.a, $PREFIX/lib/libssl.a, $PREFIX/include/openssl/*.h +# +# Inputs (env): +# OPENSSL_SRC — OpenSSL source checkout (default: ~/code/openssl) +# PREFIX — install dir (default: /tmp/openssl-ios-device) +set -euo pipefail + +: "${OPENSSL_SRC:=$HOME/code/openssl}" +: "${PREFIX:=/tmp/openssl-ios-device}" + +[ -d "$OPENSSL_SRC" ] || { echo "ERROR: OPENSSL_SRC not at $OPENSSL_SRC" >&2; exit 1; } +xcrun --sdk iphoneos --show-sdk-path >/dev/null 2>&1 || \ + { echo "ERROR: iphoneos SDK not found — install Xcode + xcode-select --install" >&2; exit 1; } + +export CC="xcrun -sdk iphoneos clang -arch arm64 -miphoneos-version-min=17.0" +export CXX="xcrun -sdk iphoneos clang++ -arch arm64 -miphoneos-version-min=17.0" +export AR="xcrun -sdk iphoneos ar" +export RANLIB="xcrun -sdk iphoneos ranlib" + +cd "$OPENSSL_SRC" +make distclean >/dev/null 2>&1 || true + +## See android_arm64.sh for size-flag rationale. +## See android_arm64.sh for the no-X rationale per algorithm. +./Configure ios64-xcrun \ + -Os -ffunction-sections -fdata-sections \ + --prefix="$PREFIX" \ + --openssldir="$PREFIX/ssl" \ + no-shared no-tests no-apps no-engine \ + no-md2 no-md4 no-mdc2 no-whirlpool no-rmd160 \ + no-rc2 no-rc4 no-idea no-cast no-bf no-blake2 \ + no-seed no-aria no-camellia no-gost \ + no-weak-ssl-ciphers no-ssl3 no-tls1 no-tls1_1 \ + no-srp no-psk no-nextprotoneg + +make -j8 +make install_sw + +echo +echo "OpenSSL iOS device (arm64) installed at: $PREFIX" +echo " $(ls -la "$PREFIX/lib/libcrypto.a")" +echo " arch check: $(file "$PREFIX/lib/libcrypto.a" | head -1)" diff --git a/scripts/release/openssl/ios_sim.sh b/scripts/release/openssl/ios_sim.sh new file mode 100755 index 0000000..81ce62b --- /dev/null +++ b/scripts/release/openssl/ios_sim.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# scripts/release/openssl/ios_sim.sh +# Cross-compile OpenSSL 3.x for iOS simulator (arm64). +# Output: $PREFIX/lib/libcrypto.a, $PREFIX/lib/libssl.a, $PREFIX/include/openssl/*.h +# +# Inputs (env): +# OPENSSL_SRC — OpenSSL source checkout (default: ~/code/openssl) +# PREFIX — install dir (default: /tmp/openssl-ios-sim) +set -euo pipefail + +: "${OPENSSL_SRC:=$HOME/code/openssl}" +: "${PREFIX:=/tmp/openssl-ios-sim}" + +[ -d "$OPENSSL_SRC" ] || { echo "ERROR: OPENSSL_SRC not at $OPENSSL_SRC" >&2; exit 1; } +xcrun --sdk iphonesimulator --show-sdk-path >/dev/null 2>&1 || \ + { echo "ERROR: iphonesimulator SDK not found" >&2; exit 1; } + +# Make sure iossimulator-xcrun targets uses the right SDK; OpenSSL Configure +# auto-detects via xcrun internally. Force arm64 sim target. +export CC="xcrun -sdk iphonesimulator clang -arch arm64 -mios-simulator-version-min=17.0" +export CXX="xcrun -sdk iphonesimulator clang++ -arch arm64 -mios-simulator-version-min=17.0" +export AR="xcrun -sdk iphonesimulator ar" +export RANLIB="xcrun -sdk iphonesimulator ranlib" + +cd "$OPENSSL_SRC" +make distclean >/dev/null 2>&1 || true + +## See android_arm64.sh for size-flag rationale. +## See android_arm64.sh for the no-X rationale per algorithm. +./Configure iossimulator-xcrun \ + -Os -ffunction-sections -fdata-sections \ + --prefix="$PREFIX" \ + --openssldir="$PREFIX/ssl" \ + no-shared no-tests no-apps no-engine \ + no-md2 no-md4 no-mdc2 no-whirlpool no-rmd160 \ + no-rc2 no-rc4 no-idea no-cast no-bf no-blake2 \ + no-seed no-aria no-camellia no-gost \ + no-weak-ssl-ciphers no-ssl3 no-tls1 no-tls1_1 \ + no-srp no-psk no-nextprotoneg + +make -j8 +make install_sw + +echo +echo "OpenSSL iOS simulator (arm64) installed at: $PREFIX" +echo " $(ls -la "$PREFIX/lib/libcrypto.a")" +echo " arch check: $(file "$PREFIX/lib/libcrypto.a" | head -1)" diff --git a/scripts/release/patches/0001-ios-device-skip-forker-fork.patch b/scripts/release/patches/0001-ios-device-skip-forker-fork.patch new file mode 100644 index 0000000..68638ba --- /dev/null +++ b/scripts/release/patches/0001-ios-device-skip-forker-fork.patch @@ -0,0 +1,33 @@ +diff --git a/erts/emulator/sys/unix/sys_drivers.c b/erts/emulator/sys/unix/sys_drivers.c +index c06d6b6603..42a23d1bb8 100644 +--- a/erts/emulator/sys/unix/sys_drivers.c ++++ b/erts/emulator/sys/unix/sys_drivers.c +@@ -40,6 +40,11 @@ + #include <sys/select.h> + #include <arpa/inet.h> + ++/* mob_dev iOS device patch: detect device vs simulator at compile time. */ ++#if defined(__IOS__) ++# include <TargetConditionals.h> ++#endif ++ + #ifdef ISC32 + #include <sys/bsdtypes.h> + #endif +@@ -1587,6 +1592,16 @@ extern struct termios erl_sys_initial_tty_mode; + static ErlDrvData forker_start(ErlDrvPort port_num, char* name, + SysDriverOpts* opts) + { ++#if defined(__IOS__) && TARGET_OS_IPHONE && !TARGET_OS_SIMULATOR ++ /* mob_dev iOS device patch: iOS device sandbox blocks fork(). Skip ++ * the actual fork; the driver still loads, but spawn-external ports ++ * (Port.open({:spawn_executable, ...}), os:cmd/1, inet_gethost_native) ++ * will fail at port-open time. Mob/Phoenix's normal flow doesn't use ++ * those, so the BEAM boots fine. See ++ * mob_dev/scripts/release/patches/ for the patch source. */ ++ forker_port = erts_drvport2id(port_num); ++ return (ErlDrvData)port_num; ++#endif + + int i; + int fds[2]; diff --git a/scripts/release/patches/0002-ios-device-epmd-no-daemon.patch b/scripts/release/patches/0002-ios-device-epmd-no-daemon.patch new file mode 100644 index 0000000..f6dd702 --- /dev/null +++ b/scripts/release/patches/0002-ios-device-epmd-no-daemon.patch @@ -0,0 +1,19 @@ +diff --git a/erts/epmd/src/epmd.c b/erts/epmd/src/epmd.c +index 1733e5b400..dde2fff710 100644 +--- a/erts/epmd/src/epmd.c ++++ b/erts/epmd/src/epmd.c +@@ -236,8 +236,14 @@ int main(int argc, char** argv) + g->max_conn = FD_SETSIZE; + } + ++ /* mob_dev iOS device patch: NO_DAEMON strips run_daemon to avoid ++ * pulling in fork() (denied by iOS sandbox). The -daemon flag is ++ * never passed in our usage, so g->is_daemon is always 0; guard the ++ * call site so the unused branch doesn't keep the symbol live. */ + if (g->is_daemon) { ++#ifndef NO_DAEMON + run_daemon(g); ++#endif + } else { + run(g); + } diff --git a/scripts/release/publish.sh b/scripts/release/publish.sh new file mode 100755 index 0000000..76d6420 --- /dev/null +++ b/scripts/release/publish.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# scripts/release/publish.sh +# Create or replace assets on the GitHub release for this OTP hash. Mirrors +# Step 4 of build_release.md. +# +# Inputs (env or default): +# HASH — release tag hash (auto-detected from $OTP_SRC git) +# OUT_DIR — where the tarballs were written (default: /tmp) +# REPO — GitHub repo (default: GenericJam/mob) +# ASSETS — space-separated list of tarball basenames to upload. Defaults +# to all four if all exist; otherwise only the ones present. +# +# Behaviour: +# - Creates the release `otp-$HASH` if it doesn't exist. +# - For each asset that's already on the release, deletes it first +# (`gh release upload` won't replace by default). +# - Uploads everything in one `gh release upload` call. + +set -euo pipefail + +cd "$(dirname "$0")" +source ./_lib.sh + +: "${REPO:=GenericJam/mob}" +TAG="otp-$HASH" + +# Build default ASSETS list from whatever's in OUT_DIR for this hash. +if [ -z "${ASSETS:-}" ]; then + candidates=( + "otp-android-$HASH.tar.gz" + "otp-android-arm32-$HASH.tar.gz" + "otp-ios-sim-$HASH.tar.gz" + "otp-ios-device-$HASH.tar.gz" + ) + ASSETS="" + for a in "${candidates[@]}"; do + if [ -f "$OUT_DIR/$a" ]; then + ASSETS="$ASSETS $a" + fi + done + ASSETS=$(echo "$ASSETS" | xargs) # trim +fi + +[ -n "$ASSETS" ] || fail "no tarballs found in $OUT_DIR matching $HASH" + +log "REPO=$REPO, TAG=$TAG, OUT_DIR=$OUT_DIR" +log "uploading: $ASSETS" + +# Create the release if missing. +if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + log "creating release $TAG..." + gh release create "$TAG" --repo "$REPO" \ + --title "OTP pre-built runtime $HASH" \ + --notes "Pre-built OTP for Android (aarch64 + arm32), iOS simulator (aarch64-apple-iossimulator), and iOS device (aarch64-apple-ios). OTP source commit: $HASH." +else + log "release $TAG already exists; will replace existing assets..." +fi + +# Delete any of the named assets that are already on the release. +existing=$(gh release view "$TAG" --repo "$REPO" --json assets -q '.assets[].name' || true) +for a in $ASSETS; do + if echo "$existing" | grep -qx "$a"; then + log "deleting existing asset $a..." + gh release delete-asset "$TAG" "$a" --repo "$REPO" --yes + fi +done + +# Upload all the asset paths. +upload_paths="" +for a in $ASSETS; do + upload_paths="$upload_paths $OUT_DIR/$a" +done +log "uploading $upload_paths..." +gh release upload "$TAG" $upload_paths --repo "$REPO" + +log "done. Verifying..." +gh release view "$TAG" --repo "$REPO" --json assets -q '.assets[] | "\(.name) \(.size)"' diff --git a/scripts/release/tarball_android_arm32.sh b/scripts/release/tarball_android_arm32.sh new file mode 100755 index 0000000..9d263ee --- /dev/null +++ b/scripts/release/tarball_android_arm32.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# scripts/release/tarball_android_arm32.sh +# Stage and tar the Android arm32 (armeabi-v7a) OTP runtime + exqlite BEAMs. +# Mirrors Step 2 (arm32) of build_release.md. +# +# Prerequisite: a separately-built arm32 asn1rt_nif.a at +# $ASN1RT_NIF_ARM32 (default /tmp/asn1rt_nif_arm32.a — see build_release.md +# Step 2 arm32 prerequisites for the exact NDK-clang invocation). +# +# Inputs (env or default): +# OTP_SRC — OTP source checkout (default: ~/code/otp) +# OTP_RELEASE — Android arm32 install dir (default: /tmp/otp-android-arm32) +# ASN1RT_NIF_ARM32 — pre-built arm32 asn1rt_nif.a (default: /tmp/asn1rt_nif_arm32.a) +# EXQLITE_BUILD, HASH, OUT_DIR — see _lib.sh / arm64 script +# +# Output: +# $OUT_DIR/otp-android-arm32-$HASH.tar.gz + +set -euo pipefail + +cd "$(dirname "$0")" +source ./_lib.sh + +: "${OTP_RELEASE:=/tmp/otp-android-arm32}" +: "${ASN1RT_NIF_ARM32:=/tmp/asn1rt_nif_arm32.a}" +: "${EXQLITE_BUILD:=}" + +[ -d "$OTP_RELEASE" ] || fail "missing $OTP_RELEASE — cross-compile Android arm32 first" +[ -f "$ASN1RT_NIF_ARM32" ] || fail "missing $ASN1RT_NIF_ARM32 — build it per build_release.md Step 2 arm32 prerequisites" +[ -n "$EXQLITE_BUILD" ] || fail "EXQLITE_BUILD not set" +[ -d "$EXQLITE_BUILD/ebin" ] || fail "EXQLITE_BUILD ($EXQLITE_BUILD) has no ebin/" + +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT + +log "OTP_SRC=$OTP_SRC, OTP_RELEASE=$OTP_RELEASE, ERTS_VSN=$ERTS_VSN, HASH=$HASH" + +cp -r "$OTP_RELEASE/." "$STAGE" + +ERTS_LIB="$STAGE/erts-$ERTS_VSN/lib" +cp "$OTP_SRC/erts/emulator/zstd/obj/arm-unknown-linux-androideabi/opt/libzstd.a" "$ERTS_LIB/" +cp "$OTP_SRC/erts/emulator/pcre/obj/arm-unknown-linux-androideabi/opt/libepcre.a" "$ERTS_LIB/" +cp "$OTP_SRC/erts/emulator/ryu/obj/arm-unknown-linux-androideabi/opt/libryu.a" "$ERTS_LIB/" +cp "$ASN1RT_NIF_ARM32" "$ERTS_LIB/asn1rt_nif.a" + +# Crypto: real OpenSSL static-linked into the app's libpigeon.so etc. +cp "$OTP_SRC/lib/crypto/priv/lib/arm-unknown-linux-androideabi/crypto.a" "$ERTS_LIB/" +: "${OPENSSL_PREFIX_ARM32:=/tmp/openssl-android-arm32}" +cp "$OPENSSL_PREFIX_ARM32/lib/libcrypto.a" "$ERTS_LIB/" + +ERTS_INC="$STAGE/erts-$ERTS_VSN/include" +mkdir -p "$ERTS_INC" +cp "$OTP_SRC/erts/emulator/beam/erl_nif.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/emulator/beam/erl_nif_api_funcs.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/emulator/beam/erl_drv_nif.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/include/arm-unknown-linux-androideabi/erl_int_sizes_config.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/include/erl_fixed_size_int_types.h" "$ERTS_INC/" + +bundle_elixir_stdlib "$STAGE" + +EXQLITE_VSN=$(grep '"exqlite"' "$EXQLITE_BUILD/../../../../mix.lock" \ + | grep -o '"[0-9][^"]*"' | head -1 | tr -d '"') +if [ -z "$EXQLITE_VSN" ]; then + EXQLITE_VSN=$(grep -o '{vsn,"[^"]*"}' "$EXQLITE_BUILD/ebin/exqlite.app" \ + | grep -o '"[^"]*"' | tr -d '"') +fi +[ -n "$EXQLITE_VSN" ] || fail "could not detect exqlite version" +EXQLITE_LIB="$STAGE/lib/exqlite-$EXQLITE_VSN" +mkdir -p "$EXQLITE_LIB/ebin" "$EXQLITE_LIB/priv" +cp "$EXQLITE_BUILD/ebin/"* "$EXQLITE_LIB/ebin/" +log "bundled exqlite $EXQLITE_VSN" + +TARBALL="$OUT_DIR/otp-android-arm32-$HASH.tar.gz" +BASE=$(basename "$STAGE") +log "creating $TARBALL..." +tar czf "$TARBALL" -C "$(dirname "$STAGE")" "$BASE" + +log "verifying contents..." +verify_present() { + tar tzf "$TARBALL" | grep -q "$1" || fail "missing $1" +} +verify_present "erts-$ERTS_VSN" +verify_present "lib/elixir/ebin/elixir.app" +verify_present "lib/exqlite-$EXQLITE_VSN" +verify_present "erts-$ERTS_VSN/lib/crypto.a" +verify_present "erts-$ERTS_VSN/lib/libcrypto.a" + +log "done: $TARBALL ($(du -h "$TARBALL" | cut -f1))" diff --git a/scripts/release/tarball_android_arm64.sh b/scripts/release/tarball_android_arm64.sh new file mode 100755 index 0000000..d181859 --- /dev/null +++ b/scripts/release/tarball_android_arm64.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# scripts/release/tarball_android_arm64.sh +# Stage and tar the Android arm64 OTP runtime + exqlite BEAMs. Mirrors +# Step 2 (arm64) of build_release.md. +# +# Inputs (env or default): +# OTP_SRC — OTP source checkout (default: ~/code/otp) +# OTP_RELEASE — Android arm64 install dir (default: /tmp/otp-android) +# EXQLITE_BUILD — path to a project's _build/dev/lib/exqlite (must exist) +# HASH, OUT_DIR — see _lib.sh +# +# Output: +# $OUT_DIR/otp-android-$HASH.tar.gz + +set -euo pipefail + +cd "$(dirname "$0")" +source ./_lib.sh + +: "${OTP_RELEASE:=/tmp/otp-android}" +: "${EXQLITE_BUILD:=}" + +[ -d "$OTP_RELEASE" ] || fail "missing $OTP_RELEASE — cross-compile Android arm64 first" + +if [ -z "$EXQLITE_BUILD" ]; then + fail "EXQLITE_BUILD not set — point at any project's _build/dev/lib/exqlite (run mix deps.get && mix compile in a project that uses ecto_sqlite3)" +fi + +[ -d "$EXQLITE_BUILD/ebin" ] || fail "EXQLITE_BUILD ($EXQLITE_BUILD) has no ebin/ — did you run mix compile?" + +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT + +log "OTP_SRC=$OTP_SRC, OTP_RELEASE=$OTP_RELEASE, ERTS_VSN=$ERTS_VSN, HASH=$HASH" + +cp -r "$OTP_RELEASE/." "$STAGE" + +# Extra static libs. +ERTS_LIB="$STAGE/erts-$ERTS_VSN/lib" +cp "$OTP_SRC/erts/emulator/zstd/obj/aarch64-unknown-linux-android/opt/libzstd.a" "$ERTS_LIB/" +cp "$OTP_SRC/erts/emulator/pcre/obj/aarch64-unknown-linux-android/opt/libepcre.a" "$ERTS_LIB/" +cp "$OTP_SRC/erts/emulator/ryu/obj/aarch64-unknown-linux-android/opt/libryu.a" "$ERTS_LIB/" +cp "$OTP_SRC/lib/asn1/priv/lib/aarch64-unknown-linux-android/asn1rt_nif.a" "$ERTS_LIB/" + +# Crypto: real OpenSSL static-linked into the app's libpigeon.so. +# Both archives are needed: +# - crypto.a: OTP's crypto NIF C wrapper, built with -DSTATIC_ERLANG_NIF +# (auto-emitted at OTP-build time when ./otp_build configure was +# passed --enable-static-nifs). ERL_NIF_INIT(crypto, ...) emits the +# crypto_nif_init symbol the BEAM resolves at load_nif time via +# dlsym(RTLD_DEFAULT) — no dlopen of crypto.so. +# - libcrypto.a: OpenSSL itself (provides EVP_*, ECDH, AEAD, etc.). +# Android loads native libs RTLD_LOCAL by default, so dlopen'ing a +# separate crypto.so doesn't see the BEAM's enif_* symbols. Static +# linking sidesteps that entirely; the same .a is also App-Store-friendly +# (no separate shared library shipped in the bundle). +cp "$OTP_SRC/lib/crypto/priv/lib/aarch64-unknown-linux-android/crypto.a" "$ERTS_LIB/" +: "${OPENSSL_PREFIX_ARM64:=/tmp/openssl-android-arm64}" +cp "$OPENSSL_PREFIX_ARM64/lib/libcrypto.a" "$ERTS_LIB/" + +# Required headers. +ERTS_INC="$STAGE/erts-$ERTS_VSN/include" +mkdir -p "$ERTS_INC" +cp "$OTP_SRC/erts/emulator/beam/erl_nif.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/emulator/beam/erl_nif_api_funcs.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/emulator/beam/erl_drv_nif.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/include/aarch64-unknown-linux-android/erl_int_sizes_config.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/include/erl_fixed_size_int_types.h" "$ERTS_INC/" + +bundle_elixir_stdlib "$STAGE" + +# exqlite BEAMs (.so NIF lives in the APK; only ebin/ goes here). +# mix.lock is 4 levels up from .../_build/dev/lib/exqlite (project root). +EXQLITE_VSN=$(grep '"exqlite"' "$EXQLITE_BUILD/../../../../mix.lock" \ + | grep -o '"[0-9][^"]*"' | head -1 | tr -d '"') +if [ -z "$EXQLITE_VSN" ]; then + EXQLITE_VSN=$(grep -o '{vsn,"[^"]*"}' "$EXQLITE_BUILD/ebin/exqlite.app" \ + | grep -o '"[^"]*"' | tr -d '"') +fi +[ -n "$EXQLITE_VSN" ] || fail "could not detect exqlite version from $EXQLITE_BUILD" +EXQLITE_LIB="$STAGE/lib/exqlite-$EXQLITE_VSN" +mkdir -p "$EXQLITE_LIB/ebin" "$EXQLITE_LIB/priv" +cp "$EXQLITE_BUILD/ebin/"* "$EXQLITE_LIB/ebin/" +log "bundled exqlite $EXQLITE_VSN" + +TARBALL="$OUT_DIR/otp-android-$HASH.tar.gz" +BASE=$(basename "$STAGE") +log "creating $TARBALL..." +tar czf "$TARBALL" -C "$(dirname "$STAGE")" "$BASE" + +log "verifying contents..." +verify_present() { + tar tzf "$TARBALL" | grep -q "$1" || fail "missing $1" +} +verify_present "erts-$ERTS_VSN" +verify_present "lib/elixir/ebin/elixir.app" +verify_present "lib/exqlite-$EXQLITE_VSN" +verify_present "lib/crypto-.*/priv/lib/crypto.so" +verify_present "lib/public_key-.*/ebin/public_key.beam" +verify_present "lib/ssl-.*/ebin/ssl.beam" +verify_present "erts-$ERTS_VSN/lib/crypto.a" +verify_present "erts-$ERTS_VSN/lib/libcrypto.a" + +log "done: $TARBALL ($(du -h "$TARBALL" | cut -f1))" diff --git a/scripts/release/tarball_android_x86_64.sh b/scripts/release/tarball_android_x86_64.sh new file mode 100755 index 0000000..655bbfb --- /dev/null +++ b/scripts/release/tarball_android_x86_64.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# scripts/release/tarball_android_x86_64.sh +# Stage and tar the Android x86_64 OTP runtime + exqlite BEAMs. Mirrors +# tarball_android_arm64.sh — only the target triple, OpenSSL prefix, OTP +# release dir, and tarball name differ. (x86_64 = Android emulators on +# x86_64 hosts / CI.) +# +# Inputs (env or default): +# OTP_SRC — OTP source checkout (default: ~/code/otp) +# OTP_RELEASE — Android x86_64 install dir (default: /tmp/otp-android-x86_64) +# EXQLITE_BUILD — path to a project's _build/dev/lib/exqlite (must exist) +# HASH, OUT_DIR — see _lib.sh +# +# Output: +# $OUT_DIR/otp-android-x86_64-$HASH.tar.gz + +set -euo pipefail + +cd "$(dirname "$0")" +source ./_lib.sh + +: "${OTP_RELEASE:=/tmp/otp-android-x86_64}" +: "${EXQLITE_BUILD:=}" +TRIPLE="x86_64-pc-linux-android" + +[ -d "$OTP_RELEASE" ] || fail "missing $OTP_RELEASE — cross-compile Android x86_64 first" +[ -n "$EXQLITE_BUILD" ] || fail "EXQLITE_BUILD not set — point at any project's _build/dev/lib/exqlite" +[ -d "$EXQLITE_BUILD/ebin" ] || fail "EXQLITE_BUILD ($EXQLITE_BUILD) has no ebin/ — did you run mix compile?" + +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT + +log "OTP_SRC=$OTP_SRC, OTP_RELEASE=$OTP_RELEASE, ERTS_VSN=$ERTS_VSN, HASH=$HASH, TRIPLE=$TRIPLE" + +cp -r "$OTP_RELEASE/." "$STAGE" + +# Extra static libs (same set as arm64, from the x86_64 target dirs). +ERTS_LIB="$STAGE/erts-$ERTS_VSN/lib" +cp "$OTP_SRC/erts/emulator/zstd/obj/$TRIPLE/opt/libzstd.a" "$ERTS_LIB/" +cp "$OTP_SRC/erts/emulator/pcre/obj/$TRIPLE/opt/libepcre.a" "$ERTS_LIB/" +cp "$OTP_SRC/erts/emulator/ryu/obj/$TRIPLE/opt/libryu.a" "$ERTS_LIB/" +cp "$OTP_SRC/lib/asn1/priv/lib/$TRIPLE/asn1rt_nif.a" "$ERTS_LIB/" + +# Crypto: OTP's crypto NIF + OpenSSL, both static (see arm64 script's comment). +cp "$OTP_SRC/lib/crypto/priv/lib/$TRIPLE/crypto.a" "$ERTS_LIB/" +: "${OPENSSL_PREFIX_X86_64:=/tmp/openssl-android-x86_64}" +cp "$OPENSSL_PREFIX_X86_64/lib/libcrypto.a" "$ERTS_LIB/" + +# Required headers. +ERTS_INC="$STAGE/erts-$ERTS_VSN/include" +mkdir -p "$ERTS_INC" +cp "$OTP_SRC/erts/emulator/beam/erl_nif.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/emulator/beam/erl_nif_api_funcs.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/emulator/beam/erl_drv_nif.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/include/$TRIPLE/erl_int_sizes_config.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/include/erl_fixed_size_int_types.h" "$ERTS_INC/" + +bundle_elixir_stdlib "$STAGE" + +EXQLITE_VSN=$(grep '"exqlite"' "$EXQLITE_BUILD/../../../../mix.lock" \ + | grep -o '"[0-9][^"]*"' | head -1 | tr -d '"') +if [ -z "$EXQLITE_VSN" ]; then + EXQLITE_VSN=$(grep -o '{vsn,"[^"]*"}' "$EXQLITE_BUILD/ebin/exqlite.app" \ + | grep -o '"[^"]*"' | tr -d '"') +fi +[ -n "$EXQLITE_VSN" ] || fail "could not detect exqlite version from $EXQLITE_BUILD" +EXQLITE_LIB="$STAGE/lib/exqlite-$EXQLITE_VSN" +mkdir -p "$EXQLITE_LIB/ebin" "$EXQLITE_LIB/priv" +cp "$EXQLITE_BUILD/ebin/"* "$EXQLITE_LIB/ebin/" +log "bundled exqlite $EXQLITE_VSN" + +TARBALL="$OUT_DIR/otp-android-x86_64-$HASH.tar.gz" +BASE=$(basename "$STAGE") +log "creating $TARBALL..." +tar czf "$TARBALL" -C "$(dirname "$STAGE")" "$BASE" + +log "verifying contents..." +verify_present() { tar tzf "$TARBALL" | grep -q "$1" || fail "missing $1"; } +verify_present "erts-$ERTS_VSN" +verify_present "lib/elixir/ebin/elixir.app" +verify_present "lib/exqlite-$EXQLITE_VSN" +verify_present "lib/crypto-.*/priv/lib/crypto.so" +verify_present "lib/ssl-.*/ebin/ssl.beam" +verify_present "erts-$ERTS_VSN/lib/crypto.a" +verify_present "erts-$ERTS_VSN/lib/libcrypto.a" + +log "done: $TARBALL ($(du -h "$TARBALL" | cut -f1))" diff --git a/scripts/release/tarball_ios_device.sh b/scripts/release/tarball_ios_device.sh new file mode 100755 index 0000000..2ec596a --- /dev/null +++ b/scripts/release/tarball_ios_device.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# scripts/release/tarball_ios_device.sh +# Stage and tar the iOS device OTP runtime + EPMD source. Mirrors Step 3b +# of build_release.md. +# +# Inputs (env or default): +# OTP_SRC — OTP source checkout (default: ~/code/otp) +# OTP_RELEASE — install dir produced by xcompile_ios_device.sh +# (default: /tmp/otp-ios-device) +# HASH — release tag hash (default: auto-detected from $OTP_SRC git) +# OUT_DIR — output directory (default: /tmp) +# +# Output: +# $OUT_DIR/otp-ios-device-$HASH.tar.gz + +set -euo pipefail + +cd "$(dirname "$0")" +source ./_lib.sh + +: "${OTP_RELEASE:=/tmp/otp-ios-device}" + +[ -d "$OTP_RELEASE" ] || fail "missing $OTP_RELEASE — run xcompile_ios_device.sh first" +[ -f "$OTP_SRC/erts/aarch64-apple-ios/config.h" ] \ + || fail "missing $OTP_SRC/erts/aarch64-apple-ios/config.h — run xcompile_ios_device.sh first" + +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT + +log "OTP_SRC=$OTP_SRC" +log "OTP_RELEASE=$OTP_RELEASE" +log "ERTS_VSN=$ERTS_VSN, HASH=$HASH" +log "staging at $STAGE" + +# Copy the OTP runtime install tree. +cp -r "$OTP_RELEASE/." "$STAGE" + +# iOS OTP is cross-compiled --without-ssl (--enable-static-nifs in the iOS +# xcomp configs would break beam.emu's link with --with-ssl). Borrow the +# crypto / public_key / ssl applications from the Android install — BEAM +# bytecode is platform-neutral, and the priv/lib/ NIF artifacts these apps +# normally ship are obsolete here anyway (we replace crypto.so with the +# static crypto.a + libcrypto.a below). +: "${ANDROID_OTP_RELEASE:=/tmp/otp-android}" +[ -d "$ANDROID_OTP_RELEASE" ] || fail "missing $ANDROID_OTP_RELEASE — needed for crypto/public_key/ssl beams (cross-compile Android arm64 first)" + +for app in crypto public_key ssl; do + src=$(ls -d "$ANDROID_OTP_RELEASE/lib/$app-"* 2>/dev/null | head -1) + [ -n "$src" ] || fail "no $app-*/ in $ANDROID_OTP_RELEASE/lib" + cp -r "$src" "$STAGE/lib/" + log "borrowed $(basename "$src") from Android install" +done + +# Add extra static libs (same set as the sim tarball, but iOS-device arch). +ERTS_LIB="$STAGE/erts-$ERTS_VSN/lib" +cp "$OTP_SRC/erts/emulator/zstd/obj/aarch64-apple-ios/opt/libzstd.a" "$ERTS_LIB/" +cp "$OTP_SRC/erts/emulator/pcre/obj/aarch64-apple-ios/opt/libepcre.a" "$ERTS_LIB/" +cp "$OTP_SRC/erts/emulator/ryu/obj/aarch64-apple-ios/opt/libryu.a" "$ERTS_LIB/" +cp "$OTP_SRC/lib/asn1/priv/lib/aarch64-apple-ios/asn1rt_nif.a" "$ERTS_LIB/" + +# Crypto: real OpenSSL static-linked into the app's main native lib. +# App-Store-friendly (no separate dynamic libs ship inside the .ipa). +cp "$OTP_SRC/lib/crypto/priv/lib/aarch64-apple-ios/crypto.a" "$ERTS_LIB/" +: "${OPENSSL_PREFIX_IOS_DEVICE:=/tmp/openssl-ios-device}" +cp "$OPENSSL_PREFIX_IOS_DEVICE/lib/libcrypto.a" "$ERTS_LIB/" + +# Add required headers. +ERTS_INC="$STAGE/erts-$ERTS_VSN/include" +mkdir -p "$ERTS_INC" +cp "$OTP_SRC/erts/emulator/beam/erl_nif.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/emulator/beam/erl_nif_api_funcs.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/emulator/beam/erl_drv_nif.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/include/aarch64-apple-ios/erl_int_sizes_config.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/include/erl_fixed_size_int_types.h" "$ERTS_INC/" + +# Bundle Elixir stdlib (elixir, logger, eex) — bytecode is arch-independent. +bundle_elixir_stdlib "$STAGE" + +# ── EPMD source + iOS-arm64 configure output ──────────────────────────────── +# build_device.sh static-links EPMD into the iOS app. The .c sources and the +# arch-specific config.h must be present alongside the install tree. +# `MobDev.OtpDownloader.valid_otp_dir?/2` validates these are present and +# re-downloads if absent. +log "bundling EPMD source + iOS-arm64 configure output..." +mkdir -p "$STAGE/erts/epmd/src" +cp "$OTP_SRC/erts/epmd/src/epmd.c" "$STAGE/erts/epmd/src/" +cp "$OTP_SRC/erts/epmd/src/epmd_srv.c" "$STAGE/erts/epmd/src/" +cp "$OTP_SRC/erts/epmd/src/epmd_cli.c" "$STAGE/erts/epmd/src/" +# epmd.c → epmd.h, epmd_int.h, both in erts/epmd/src/. Bundle every .h in +# src/ for safety — they're tiny and including all of them costs nothing. +cp "$OTP_SRC/erts/epmd/src/"*.h "$STAGE/erts/epmd/src/" + +mkdir -p "$STAGE/erts/aarch64-apple-ios" +cp -r "$OTP_SRC/erts/aarch64-apple-ios/"* "$STAGE/erts/aarch64-apple-ios/" + +mkdir -p "$STAGE/erts/include" "$STAGE/erts/include/internal" +cp -r "$OTP_SRC/erts/include/"* "$STAGE/erts/include/" +cp -r "$OTP_SRC/erts/include/internal/"* "$STAGE/erts/include/internal/" + +# Tar it up. +TARBALL="$OUT_DIR/otp-ios-device-$HASH.tar.gz" +BASE=$(basename "$STAGE") +log "creating $TARBALL..." +tar czf "$TARBALL" -C "$(dirname "$STAGE")" "$BASE" + +# Verify the schema requirements: erts-*/ install, EPMD source files, +# iOS-arm64 config.h, Elixir stdlib. +log "verifying $TARBALL contents..." +verify_present() { + tar tzf "$TARBALL" | grep -q "$1" \ + || fail "verify failed — tarball missing $1" +} +verify_present "erts-$ERTS_VSN" +verify_present "erts-$ERTS_VSN/lib/crypto.a" +verify_present "erts-$ERTS_VSN/lib/libcrypto.a" +verify_present "erts/epmd/src/epmd.c" +verify_present "erts/epmd/src/epmd_srv.c" +verify_present "erts/epmd/src/epmd_cli.c" +verify_present "erts/epmd/src/epmd.h" +verify_present "erts/epmd/src/epmd_int.h" +verify_present "erts/aarch64-apple-ios/config.h" +verify_present "lib/elixir/ebin/elixir.app" + +log "done: $TARBALL ($(du -h "$TARBALL" | cut -f1))" +log "next: scripts/release/publish.sh" diff --git a/scripts/release/tarball_ios_sim.sh b/scripts/release/tarball_ios_sim.sh new file mode 100755 index 0000000..1e62610 --- /dev/null +++ b/scripts/release/tarball_ios_sim.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# scripts/release/tarball_ios_sim.sh +# Stage and tar the iOS simulator OTP runtime. Mirrors Step 3 of build_release.md. +# +# Inputs (env or default): +# OTP_SRC — OTP source checkout (default: ~/code/otp) +# OTP_RELEASE — install dir for aarch64-apple-iossimulator +# (default: /tmp/otp-ios-sim) +# HASH — release tag hash (auto-detected from $OTP_SRC git) +# OUT_DIR — output directory (default: /tmp) +# +# Output: +# $OUT_DIR/otp-ios-sim-$HASH.tar.gz + +set -euo pipefail + +cd "$(dirname "$0")" +source ./_lib.sh + +: "${OTP_RELEASE:=/tmp/otp-ios-sim}" + +[ -d "$OTP_RELEASE" ] || fail "missing $OTP_RELEASE — cross-compile iOS sim first" + +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT + +log "OTP_SRC=$OTP_SRC, OTP_RELEASE=$OTP_RELEASE, ERTS_VSN=$ERTS_VSN, HASH=$HASH" + +# Copy the OTP runtime. +cp -r "$OTP_RELEASE/." "$STAGE" + +# iOS OTP is cross-compiled --without-ssl (--enable-static-nifs in the iOS +# xcomp configs would break beam.emu's link with --with-ssl). Borrow the +# crypto / public_key / ssl applications from the Android install — BEAM +# bytecode is platform-neutral, and the priv/lib/ NIF artifacts these apps +# normally ship are obsolete here anyway (we replace crypto.so with the +# static crypto.a + libcrypto.a below). +: "${ANDROID_OTP_RELEASE:=/tmp/otp-android}" +[ -d "$ANDROID_OTP_RELEASE" ] || fail "missing $ANDROID_OTP_RELEASE — needed for crypto/public_key/ssl beams (cross-compile Android arm64 first)" + +for app in crypto public_key ssl; do + src=$(ls -d "$ANDROID_OTP_RELEASE/lib/$app-"* 2>/dev/null | head -1) + [ -n "$src" ] || fail "no $app-*/ in $ANDROID_OTP_RELEASE/lib" + cp -r "$src" "$STAGE/lib/" + log "borrowed $(basename "$src") from Android install" +done + +# Add extra static libs. +ERTS_LIB="$STAGE/erts-$ERTS_VSN/lib" +cp "$OTP_SRC/erts/emulator/zstd/obj/aarch64-apple-iossimulator/opt/libzstd.a" "$ERTS_LIB/" +cp "$OTP_SRC/erts/emulator/pcre/obj/aarch64-apple-iossimulator/opt/libepcre.a" "$ERTS_LIB/" +cp "$OTP_SRC/erts/emulator/ryu/obj/aarch64-apple-iossimulator/opt/libryu.a" "$ERTS_LIB/" +cp "$OTP_SRC/lib/asn1/priv/lib/aarch64-apple-iossimulator/asn1rt_nif.a" "$ERTS_LIB/" + +# Crypto: real OpenSSL static-linked into the app's main native lib. +cp "$OTP_SRC/lib/crypto/priv/lib/aarch64-apple-iossimulator/crypto.a" "$ERTS_LIB/" +: "${OPENSSL_PREFIX_IOS_SIM:=/tmp/openssl-ios-sim}" +cp "$OPENSSL_PREFIX_IOS_SIM/lib/libcrypto.a" "$ERTS_LIB/" + +# Add required headers. +ERTS_INC="$STAGE/erts-$ERTS_VSN/include" +mkdir -p "$ERTS_INC" +cp "$OTP_SRC/erts/emulator/beam/erl_nif.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/emulator/beam/erl_nif_api_funcs.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/emulator/beam/erl_drv_nif.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/include/aarch64-apple-iossimulator/erl_int_sizes_config.h" "$ERTS_INC/" +cp "$OTP_SRC/erts/include/erl_fixed_size_int_types.h" "$ERTS_INC/" + +# Bundle Elixir stdlib. +bundle_elixir_stdlib "$STAGE" + +# Tar it up — exclude any stray app build dirs left in OTP_RELEASE. +TARBALL="$OUT_DIR/otp-ios-sim-$HASH.tar.gz" +BASE=$(basename "$STAGE") +log "creating $TARBALL..." +tar czf "$TARBALL" \ + --exclude="$BASE/beamhello" \ + --exclude="$BASE/test_app" \ + --exclude="$BASE/test_app0" \ + -C "$(dirname "$STAGE")" "$BASE" + +log "verifying contents..." +verify_present() { + tar tzf "$TARBALL" | grep -q "$1" || fail "missing $1" +} +verify_present "erts-$ERTS_VSN" +verify_present "lib/elixir/ebin/elixir.app" +verify_present "erts-$ERTS_VSN/lib/crypto.a" +verify_present "erts-$ERTS_VSN/lib/libcrypto.a" + +log "done: $TARBALL ($(du -h "$TARBALL" | cut -f1))" diff --git a/scripts/release/xcomp/erl-xcomp-x86_64-android.conf b/scripts/release/xcomp/erl-xcomp-x86_64-android.conf new file mode 100644 index 0000000..3e4774b --- /dev/null +++ b/scripts/release/xcomp/erl-xcomp-x86_64-android.conf @@ -0,0 +1,289 @@ +## -*-shell-script-*- +## +## %CopyrightBegin% +## +## SPDX-License-Identifier: Apache-2.0 +## +## Copyright Ericsson AB 2021-2025. All Rights Reserved. +## +## Licensed under the Apache License, Version 2.0 (the "License"); +## you may not use this file except in compliance with the License. +## You may obtain a copy of the License at +## +## http://www.apache.org/licenses/LICENSE-2.0 +## +## Unless required by applicable law or agreed to in writing, software +## distributed under the License is distributed on an "AS IS" BASIS, +## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +## See the License for the specific language governing permissions and +## limitations under the License. +## +## %CopyrightEnd% +## +## File: erl-xcomp-x86_64-android.conf +## Author: Paulo Oliveira +## +## ----------------------------------------------------------------------------- +## When cross compiling Erlang/OTP using `otp_build', copy this file and set +## the variables needed below. Then pass the path to the copy of this file as +## an argument to `otp_build' in the configure stage: +## `otp_build configure --xcomp-conf=<FILE>' +## ----------------------------------------------------------------------------- + +## Note that you cannot define arbitrary variables in a cross compilation +## configuration file. Only the ones listed below will be guaranteed to be +## visible throughout the whole execution of all `configure' scripts. Other +## variables needs to be defined as arguments to `configure' or exported in +## the environment. + +## -- Variables for `otp_build' Only ------------------------------------------- + +## Variables in this section are only used, when configuring Erlang/OTP for +## cross compilation using `$ERL_TOP/otp_build configure'. + +## *NOTE*! These variables currently have *no* effect if you configure using +## the `configure' script directly. + +# * `erl_xcomp_build' - The build system used. This value will be passed as +# `--build=$erl_xcomp_build' argument to the `configure' script. It does +# not have to be a full `CPU-VENDOR-OS' triplet, but can be. The full +# `CPU-VENDOR-OS' triplet will be created by +# `$ERL_TOP/make/autoconf/config.sub $erl_xcomp_build'. If set to `guess', +# the build system will be guessed using +# `$ERL_TOP/make/autoconf/config.guess'. +erl_xcomp_build=guess + +# * `erl_xcomp_host' - Cross host/target system to build for. This value will +# be passed as `--host=$erl_xcomp_host' argument to the `configure' script. +# It does not have to be a full `CPU-VENDOR-OS' triplet, but can be. The +# full `CPU-VENDOR-OS' triplet will be created by +# `$ERL_TOP/make/autoconf/config.sub $erl_xcomp_host'. +erl_xcomp_host=x86_64-linux-android + +# * `erl_xcomp_configure_flags' - Extra configure flags to pass to the +# `configure' script. +erl_xcomp_configure_flags="--without-termcap --without-wx \ + --without-debugger --without-observer --without-et --without-cdv \ + --enable-builtin-zlib --enable-deterministic-build" + +## -- Cross Compiler and Other Tools ------------------------------------------- + +## If the cross compilation tools are prefixed by `<HOST>-' you probably do +## not need to set these variables (where `<HOST>' is what has been passed as +## `--host=<HOST>' argument to `configure'). + +## All variables in this section can also be used when native compiling. + +# * `CC' - C compiler. +CC=x86_64-linux-$NDK_ABI_PLAT-clang + +# * `CFLAGS' - C compiler flags. +CFLAGS="-g -Os -ffunction-sections -fdata-sections" + +# * `STATIC_CFLAGS' - Static C compiler flags. +#STATIC_CFLAGS= + +# * `CFLAG_RUNTIME_LIBRARY_PATH' - This flag should set runtime library +# search path for the shared libraries. Note that this actually is a +# linker flag, but it needs to be passed via the compiler. +#CFLAG_RUNTIME_LIBRARY_PATH= + +# * `CPP' - C pre-processor. +#CPP= + +# * `CPPFLAGS' - C pre-processor flags. +#CPPFLAGS= + +# * `CXX' - C++ compiler. +CXX=x86_64-linux-$NDK_ABI_PLAT-clang++ + +# * `CXXFLAGS' - C++ compiler flags. +#CXXFLAGS= + +# * `LD' - Linker. +LD=x86_64-linux-android-ld + +# * `LDFLAGS' - Linker flags. +# Use the static version of libc++ provided by the Android NDK +# when compiling the JIT flavor of the beam.smp executable. +LDFLAGS="-static-libstdc++ -Wl,--gc-sections -Wl,-z,max-page-size=16384" + +# * `LIBS' - Libraries. +#LIBS= + +## -- *D*ynamic *E*rlang *D*river Linking -- + +## *NOTE*! Either set all or none of the `DED_LD*' variables. + +# * `DED_LD' - Linker for Dynamically loaded Erlang Drivers. +#DED_LD= + +# * `DED_LDFLAGS' - Linker flags to use with `DED_LD'. +#DED_LDFLAGS= + +# * `DED_LD_FLAG_RUNTIME_LIBRARY_PATH' - This flag should set runtime library +# search path for shared libraries when linking with `DED_LD'. +#DED_LD_FLAG_RUNTIME_LIBRARY_PATH= + +## -- Large File Support -- + +## *NOTE*! Either set all or none of the `LFS_*' variables. + +# * `LFS_CFLAGS' - Large file support C compiler flags. +#LFS_CFLAGS= + +# * `LFS_LDFLAGS' - Large file support linker flags. +#LFS_LDFLAGS= + +# * `LFS_LIBS' - Large file support libraries. +#LFS_LIBS= + +## -- Other Tools -- + +# * `RANLIB' - `ranlib' archive index tool. +RANLIB="llvm-ranlib" + +# * `AR' - `ar' archiving tool. +AR="llvm-ar" + +# * `GETCONF' - `getconf' system configuration inspection tool. `getconf' is +# currently used for finding out large file support flags to use, and +# on Linux systems for finding out if we have an NPTL thread library or +# not. +#GETCONF= + +## -- Cross System Root Locations ---------------------------------------------- + +# * `erl_xcomp_sysroot' - The absolute path to the system root of the cross +# compilation environment. Currently, the `crypto', `odbc', `ssh' and +# `ssl' applications need the system root. These applications will be +# skipped if the system root has not been set. The system root might be +# needed for other things too. If this is the case and the system root +# has not been set, `configure' will fail and request you to set it. +# +# Starting with Android NDK r19, this path does not matter anymore +# as the NDK toolchain handles the sysroot directory implicitly. +# Set a value anyway to enable all applications as described above. +erl_xcomp_sysroot=/sysroot/path/handled/by/the/Android/NDK + +# * `erl_xcomp_isysroot' - The absolute path to the system root for includes +# of the cross compilation environment. If not set, this value defaults +# to `$erl_xcomp_sysroot', i.e., only set this value if the include system +# root path is not the same as the system root path. +#erl_xcomp_isysroot= + +## -- Optional Feature, and Bug Tests ------------------------------------------ + +## These tests cannot (always) be done automatically when cross compiling. You +## usually do not need to set these variables. Only set these if you really +## know what you are doing. + +## Note that some of these values will override results of tests performed +## by `configure', and some will not be used until `configure' is sure that +## it cannot figure the result out. + +## The `configure' script will issue a warning when a default value is used. +## When a variable has been set, no warning will be issued. + +# * `erl_xcomp_after_morecore_hook' - `yes|no'. Defaults to `no'. If `yes', +# the target system must have a working `__after_morecore_hook' that can be +# used for tracking used `malloc()' implementations core memory usage. +# This is currently only used by unsupported features. +#erl_xcomp_after_morecore_hook= + +# * `erl_xcomp_bigendian' - `yes|no'. No default. If `yes', the target system +# must be big endian. If `no', little endian. This can often be +# automatically detected, but not always. If not automatically detected, +# `configure' will fail unless this variable is set. Since no default +# value is used, `configure' will try to figure this out automatically. +#erl_xcomp_bigendian= + +# * `erl_xcomp_double_middle` - `yes|no`. No default. If `yes`, the +# target system must have doubles in "middle-endian" format. If +# `no`, it has "regular" endianness. This can often be automatically +# detected, but not always. If not automatically detected, +# `configure` will fail unless this variable is set. Since no +# default value is used, `configure` will try to figure this out +# automatically. +#erl_xcomp_double_middle_endian + +# * `erl_xcomp_clock_gettime_cpu_time' - `yes|no'. Defaults to `no'. If `yes', +# the target system must have a working `clock_gettime()' implementation +# that can be used for retrieving process CPU time. +#erl_xcomp_clock_gettime_cpu_time= + +# * `erl_xcomp_getaddrinfo' - `yes|no'. Defaults to `no'. If `yes', the target +# system must have a working `getaddrinfo()' implementation that can +# handle both IPv4 and IPv6. +#erl_xcomp_getaddrinfo= + +# * `erl_xcomp_gethrvtime_procfs_ioctl' - `yes|no'. Defaults to `no'. If `yes', +# the target system must have a working `gethrvtime()' implementation and +# is used with procfs `ioctl()'. +#erl_xcomp_gethrvtime_procfs_ioctl= + +# * `erl_xcomp_dlsym_brk_wrappers' - `yes|no'. Defaults to `no'. If `yes', the +# target system must have a working `dlsym(RTLD_NEXT, <S>)' implementation +# that can be used on `brk' and `sbrk' symbols used by the `malloc()' +# implementation in use, and by this track the `malloc()' implementations +# core memory usage. This is currently only used by unsupported features. +#erl_xcomp_dlsym_brk_wrappers= + +# * `erl_xcomp_kqueue' - `yes|no'. Defaults to `no'. If `yes', the target +# system must have a working `kqueue()' implementation that returns a file +# descriptor which can be used by `poll()' and/or `select()'. If `no' and +# the target system has not got `epoll()' or `/dev/poll', the kernel-poll +# feature will be disabled. +#erl_xcomp_kqueue= + +# * `erl_xcomp_linux_clock_gettime_correction' - `yes|no'. Defaults to `yes' on +# Linux; otherwise, `no'. If `yes', `clock_gettime(CLOCK_MONOTONIC, _)' on +# the target system must work. This variable is recommended to be set to +# `no' on Linux systems with kernel versions less than 2.6. +#erl_xcomp_linux_clock_gettime_correction= + +# * `erl_xcomp_linux_nptl' - `yes|no'. Defaults to `yes' on Linux; otherwise, +# `no'. If `yes', the target system must have NPTL (Native POSIX Thread +# Library). Older Linux systems have LinuxThreads instead of NPTL (Linux +# kernel versions typically less than 2.6). +#erl_xcomp_linux_nptl= + +# * `erl_xcomp_linux_usable_sigaltstack' - `yes|no'. Defaults to `yes' on Linux; +# otherwise, `no'. If `yes', `sigaltstack()' must be usable on the target +# system. `sigaltstack()' on Linux kernel versions less than 2.4 are +# broken. +#erl_xcomp_linux_usable_sigaltstack= + +# * `erl_xcomp_linux_usable_sigusrx' - `yes|no'. Defaults to `yes'. If `yes', +# the `SIGUSR1' and `SIGUSR2' signals must be usable by the ERTS. Old +# LinuxThreads thread libraries (Linux kernel versions typically less than +# 2.2) used these signals and made them unusable by the ERTS. +#erl_xcomp_linux_usable_sigusrx= + +# * `erl_xcomp_poll' - `yes|no'. Defaults to `no' on Darwin/MacOSX; otherwise, +# `yes'. If `yes', the target system must have a working `poll()' +# implementation that also can handle devices. If `no', `select()' will be +# used instead of `poll()'. +#erl_xcomp_poll= + +# * `erl_xcomp_putenv_copy' - `yes|no'. Defaults to `no'. If `yes', the target +# system must have a `putenv()' implementation that stores a copy of the +# key/value pair. +#erl_xcomp_putenv_copy= + +# * `erl_xcomp_reliable_fpe' - `yes|no'. Defaults to `no'. If `yes', the target +# system must have reliable floating point exceptions. +#erl_xcomp_reliable_fpe= + +# * `erl_xcomp_posix_memalign' - `yes|no'. Defaults to `yes' if `posix_memalign' +# system call exists; otherwise `no'. If `yes', the target system must have a +# `posix_memalign' implementation that accepts larger than page size +# alignment. +#erl_xcomp_posix_memalign= + +# * `erl_xcomp_code_model_small` - `yes|no`. Default to `no`. If `yes`, the target +# system must place the beam.smp executable in the lower 2 GB of memory. That is it +# should not use position independent executable. +#erl_xcomp_code_model_small= + +## ----------------------------------------------------------------------------- diff --git a/scripts/release/xcompile_android_arm32.sh b/scripts/release/xcompile_android_arm32.sh new file mode 100755 index 0000000..1a01551 --- /dev/null +++ b/scripts/release/xcompile_android_arm32.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# scripts/release/xcompile_android_arm32.sh +# Cross-compile OTP for Android arm32 (armv7a-linux-androideabi, API 24+). +# +# Inputs (env or default): +# OTP_SRC — OTP source checkout (default: ~/code/otp) +# OPENSSL_PREFIX — pre-built OpenSSL install (default: /tmp/openssl-android-arm32) +# RELEASE_ROOT — install dir to populate (default: /tmp/otp-android-arm32) +# NDK_VERSION — NDK version (sourced from openssl/_lib.sh) +# ANDROID_NDK_ROOT — NDK root (sourced from openssl/_lib.sh) +# NDK_ABI_PLAT — Android API-level prefix (default: android24) +# +# Output: +# $RELEASE_ROOT/{bin,erts-<vsn>,lib,releases,...} +# $OTP_SRC/erts/arm-unknown-linux-androideabi/{config.h,...} +# $OTP_SRC/erts/emulator/{zstd,pcre,ryu}/obj/arm-unknown-linux-androideabi/opt/lib*.a +# +# Mirror of the arm64 sibling under openssl/_build_otp_android_arm64.sh. +# Targets armeabi-v7a for older 32-bit-only devices (e.g. Motorola E 2020). + +set -euo pipefail + +# Resolve our own dir to an absolute path before cd'ing — `dirname "$0"` is +# relative and would break the openssl/_lib.sh source line below. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" +source ./_lib.sh + +# NDK_VERSION + ANDROID_NDK_ROOT come from this shared file (single source of truth). +. "$SCRIPT_DIR/openssl/_lib.sh" + +: "${OPENSSL_PREFIX:=/tmp/openssl-android-arm32}" +: "${RELEASE_ROOT:=/tmp/otp-android-arm32}" +# NDK_ABI_PLAT is interpolated into the xcomp conf's CC line as +# "armv7a-linux-${NDK_ABI_PLAT}-clang". The actual NDK clang for arm32 is +# named "armv7a-linux-androideabi24-clang" — the "eabi" suffix is required. +# arm64 differs (just "aarch64-linux-android24-clang"), which is why the +# arm64 sibling sets NDK_ABI_PLAT=android24. +: "${NDK_ABI_PLAT:=androideabi24}" + +log "OTP_SRC=$OTP_SRC" +log "OPENSSL_PREFIX=$OPENSSL_PREFIX" +log "RELEASE_ROOT=$RELEASE_ROOT" +log "ANDROID_NDK_ROOT=$ANDROID_NDK_ROOT" + +[ -d "$OPENSSL_PREFIX" ] || fail "OPENSSL_PREFIX missing at $OPENSSL_PREFIX — run scripts/release/openssl/android_arm32.sh first" +[ -d "$ANDROID_NDK_ROOT" ] || fail "ANDROID_NDK_ROOT missing at $ANDROID_NDK_ROOT" + +export NDK_ROOT="$ANDROID_NDK_ROOT" +export PATH="$NDK_ROOT/toolchains/llvm/prebuilt/darwin-x86_64/bin:$PATH" +export NDK_ABI_PLAT +export RELEASE_LIBBEAM=yes + +cd "$OTP_SRC" + +# Clean any stale config from a prior arch (iOS, arm64, etc.). +make distclean >/dev/null 2>&1 || true + +log "configuring for arm-unknown-linux-androideabi..." +./otp_build configure \ + --xcomp-conf=./xcomp/erl-xcomp-arm-android.conf \ + --with-ssl="$OPENSSL_PREFIX" \ + --disable-dynamic-ssl-lib + +# Android xcomp configs do NOT set --enable-static-nifs, so beam.emu's link +# is fine without OpenSSL on the link line — crypto.so loads at runtime +# instead. We still build a separate static crypto.a via +# build_crypto_static_android_arm32.sh after this, since Android loads +# native libs RTLD_LOCAL and dlopen-ing crypto.so from a child .so doesn't +# see the parent's enif_* symbols. + +log "building (this takes ~5–10 min)..." +./otp_build boot + +log "installing to $RELEASE_ROOT..." +rm -rf "$RELEASE_ROOT" +./otp_build release -a "$RELEASE_ROOT" + +log "verifying outputs..." +[ -d "$RELEASE_ROOT/erts-$ERTS_VSN" ] \ + || fail "missing $RELEASE_ROOT/erts-$ERTS_VSN" + +ls "$RELEASE_ROOT/lib/" | grep -E '^(crypto|public_key|ssl)-' >/dev/null \ + || fail "crypto/ssl/public_key apps NOT in install tree — --with-ssl wired wrong?" + +log "done. Next: scripts/release/openssl/build_crypto_static_android_arm32.sh, then scripts/release/tarball_android_arm32.sh" diff --git a/scripts/release/xcompile_ios_device.sh b/scripts/release/xcompile_ios_device.sh new file mode 100755 index 0000000..42ea3ed --- /dev/null +++ b/scripts/release/xcompile_ios_device.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# scripts/release/xcompile_ios_device.sh +# Cross-compile OTP for iOS arm64 device. Mirrors Step 3b.0 of build_release.md. +# +# Inputs (env or default): +# OTP_SRC — OTP source checkout (default: ~/code/otp) +# OPENSSL_PREFIX — pre-built OpenSSL install (default: /tmp/openssl-ios-device) +# RELEASE_ROOT — install dir to populate (default: /tmp/otp-ios-device) +# +# Output: +# $RELEASE_ROOT/{bin,erts-<vsn>,lib,releases,...} +# $OTP_SRC/erts/aarch64-apple-ios/config.h (and other configure output) +# $OTP_SRC/erts/emulator/{zstd,pcre,ryu}/obj/aarch64-apple-ios/opt/lib*.a +# $OTP_SRC/lib/asn1/priv/lib/aarch64-apple-ios/asn1rt_nif.a +# +# Source of truth: ~/code/otp/HOWTO/INSTALL-IOS.md (OTP's own iOS recipe). + +set -euo pipefail + +# Resolve our own dir before cd'ing — `dirname "$0"` returns a relative path +# and the subshell trick on line 37 would fail after we're already cd'd here. +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" +source ./_lib.sh + +: "${OPENSSL_PREFIX:=/tmp/openssl-ios-device}" +: "${RELEASE_ROOT:=/tmp/otp-ios-device}" + +log "OTP_SRC=$OTP_SRC" +log "OPENSSL_PREFIX=$OPENSSL_PREFIX" +log "RELEASE_ROOT=$RELEASE_ROOT" + +# Sanity: iPhoneOS SDK must be installed. +if ! xcrun --sdk iphoneos --show-sdk-path >/dev/null 2>&1; then + fail "iPhoneOS SDK not found — install Xcode + run 'xcode-select --install'" +fi + +[ -d "$OPENSSL_PREFIX" ] || fail "OPENSSL_PREFIX missing at $OPENSSL_PREFIX — run scripts/release/openssl/ios_device.sh first (used later by build_crypto_static_ios_device.sh, not the OTP cross-compile itself)" + +PATCHES_DIR="$SCRIPT_DIR/patches" + +cd "$OTP_SRC" + +# Apply iOS-device patches (idempotent — each checks first). +# Without these, the BEAM/EPMD pull in fork() symbols which iOS device +# sandbox blocks; the app dies during boot. See each patch file for context. +apply_patch() { + local patch_file="$1" marker="$2" target="$3" + if [ ! -f "$patch_file" ]; then + log "WARNING: $patch_file not found — assuming OTP source is already patched" + return 0 + fi + if grep -q "$marker" "$target" 2>/dev/null; then + log "$(basename "$patch_file") already applied" + else + log "applying $(basename "$patch_file")..." + patch -p1 < "$patch_file" || fail "patch application failed — inspect $patch_file manually" + fi +} + +apply_patch "$PATCHES_DIR/0001-ios-device-skip-forker-fork.patch" \ + "mob_dev iOS device patch" \ + erts/emulator/sys/unix/sys_drivers.c + +apply_patch "$PATCHES_DIR/0002-ios-device-epmd-no-daemon.patch" \ + "ifndef NO_DAEMON" \ + erts/epmd/src/epmd.c + +# iOS doesn't allow shared libraries; emit static libbeam.a instead of .so. +export RELEASE_LIBBEAM=yes + +# Clean prior arch's config so configure doesn't get confused. +make distclean >/dev/null 2>&1 || true + +# `--without-ssl` is intentional: iOS xcomp configs set --enable-static-nifs, +# which static-links the crypto NIF into beam.emu at OTP build time. With +# --with-ssl, beam.emu's link line needs OpenSSL but OTP's build system +# doesn't propagate the --with-ssl prefix to that link, so the build fails +# with undefined references to RAND_seed / OSSL_PROVIDER_load / etc. +# +# Android's pattern works around this by building static crypto.a in a +# separate step (build_crypto_static_android_*.sh) — we do the same on iOS +# via build_crypto_static_ios_device.sh, run after this cross-compile. The +# tarball script then ships crypto.a + libcrypto.a, and the user's app +# links them at app-build time. +log "configuring for arm64-apple-ios..." +./otp_build configure \ + --xcomp-conf=./xcomp/erl-xcomp-arm64-ios.conf \ + --without-ssl + +# Build everything for the target. +log "building (this takes ~5–10 min)..." +./otp_build boot + +# Assemble install tree. +log "installing to $RELEASE_ROOT..." +rm -rf "$RELEASE_ROOT" +make release RELEASE_ROOT="$RELEASE_ROOT" + +# Verify the artifacts we'll need downstream actually exist. +log "verifying outputs..." +[ -f "$OTP_SRC/erts/aarch64-apple-ios/config.h" ] \ + || fail "missing $OTP_SRC/erts/aarch64-apple-ios/config.h" +[ -d "$RELEASE_ROOT/erts-$ERTS_VSN" ] \ + || fail "missing $RELEASE_ROOT/erts-$ERTS_VSN — 'make release' didn't produce expected layout" +[ -f "$OTP_SRC/erts/emulator/zstd/obj/aarch64-apple-ios/opt/libzstd.a" ] \ + || fail "missing libzstd.a — boot build incomplete" + +log "done. Next: scripts/release/tarball_ios_device.sh" diff --git a/scripts/release/xcompile_ios_sim.sh b/scripts/release/xcompile_ios_sim.sh new file mode 100755 index 0000000..33749d5 --- /dev/null +++ b/scripts/release/xcompile_ios_sim.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# scripts/release/xcompile_ios_sim.sh +# Cross-compile OTP for iOS arm64 simulator. +# +# Inputs (env or default): +# OTP_SRC — OTP source checkout (default: ~/code/otp) +# OPENSSL_PREFIX — pre-built OpenSSL install (default: /tmp/openssl-ios-sim) +# RELEASE_ROOT — install dir to populate (default: /tmp/otp-ios-sim) +# +# Output: +# $RELEASE_ROOT/{bin,erts-<vsn>,lib,releases,...} +# $OTP_SRC/erts/aarch64-apple-iossimulator/config.h (configure output) +# $OTP_SRC/erts/emulator/{zstd,pcre,ryu}/obj/aarch64-apple-iossimulator/opt/lib*.a +# $OTP_SRC/lib/asn1/priv/lib/aarch64-apple-iossimulator/asn1rt_nif.a +# +# The simulator is a separate target from the device because it runs on the +# Mac's network stack (so EPMD daemon mode works), allows fork(), and doesn't +# need the iOS-device-specific patches. We still emit static libbeam.a for +# linking parity with the device build. + +set -euo pipefail + +cd "$(dirname "$0")" +source ./_lib.sh + +: "${OPENSSL_PREFIX:=/tmp/openssl-ios-sim}" +: "${RELEASE_ROOT:=/tmp/otp-ios-sim}" + +log "OTP_SRC=$OTP_SRC" +log "OPENSSL_PREFIX=$OPENSSL_PREFIX" +log "RELEASE_ROOT=$RELEASE_ROOT" + +# Sanity: iPhoneSimulator SDK must be installed. +if ! xcrun --sdk iphonesimulator --show-sdk-path >/dev/null 2>&1; then + fail "iPhoneSimulator SDK not found — install Xcode + run 'xcode-select --install'" +fi + +[ -d "$OPENSSL_PREFIX" ] || fail "OPENSSL_PREFIX missing at $OPENSSL_PREFIX — run scripts/release/openssl/ios_sim.sh first (used later by build_crypto_static_ios_sim.sh, not the OTP cross-compile itself)" + +cd "$OTP_SRC" + +# Match the device build: emit static libbeam.a so the simulator app can link +# the same way (target_link_libraries against an .a, not an .so). +export RELEASE_LIBBEAM=yes + +# Clean any prior arch's config so configure doesn't pick up Android/iOS-device leftovers. +make distclean >/dev/null 2>&1 || true + +# `--without-ssl` is intentional: iOS xcomp configs set --enable-static-nifs, +# which static-links the crypto NIF into beam.emu at OTP build time. With +# --with-ssl, beam.emu's link line needs OpenSSL but OTP's build system +# doesn't propagate the --with-ssl prefix to that link, so the build fails +# with undefined references to RAND_seed / OSSL_PROVIDER_load / etc. +# +# The Android pattern works around this by building static crypto.a in a +# separate step (build_crypto_static_android_*.sh) — we do the same on iOS +# via build_crypto_static_ios_sim.sh, run after this cross-compile. The +# tarball script then ships crypto.a + libcrypto.a, and the user's app +# links them at app-build time. +log "configuring for arm64-apple-iossimulator..." +./otp_build configure \ + --xcomp-conf=./xcomp/erl-xcomp-arm64-iossimulator.conf \ + --without-ssl + +log "building (this takes ~5–10 min)..." +./otp_build boot + +log "installing to $RELEASE_ROOT..." +rm -rf "$RELEASE_ROOT" +make release RELEASE_ROOT="$RELEASE_ROOT" + +log "verifying outputs..." +[ -f "$OTP_SRC/erts/aarch64-apple-iossimulator/config.h" ] \ + || fail "missing $OTP_SRC/erts/aarch64-apple-iossimulator/config.h" +[ -d "$RELEASE_ROOT/erts-$ERTS_VSN" ] \ + || fail "missing $RELEASE_ROOT/erts-$ERTS_VSN — 'make release' didn't produce expected layout" +[ -f "$OTP_SRC/erts/emulator/zstd/obj/aarch64-apple-iossimulator/opt/libzstd.a" ] \ + || fail "missing libzstd.a — boot build incomplete" + +log "done. Next: scripts/release/openssl/build_crypto_static_ios_sim.sh, then scripts/release/tarball_ios_sim.sh" diff --git a/test/acceptance/mob_adopt_acceptance_test.exs b/test/acceptance/mob_adopt_acceptance_test.exs new file mode 100644 index 0000000..bfdc322 --- /dev/null +++ b/test/acceptance/mob_adopt_acceptance_test.exs @@ -0,0 +1,314 @@ +defmodule MobAdoptAcceptanceTest do + @moduledoc """ + End-to-end + Phoenix drift check. + + Generates a real `mix phx.new` project, verifies Phoenix's output + still matches the shape `mob.adopt` patches, adds `:igniter` and a + `path:` dep on this mob_dev checkout to the project's deps, runs + `mix mob.adopt --yes`, asserts the resulting tree, then runs + `mix compile` to catch downstream drift. + + Tagged `@tag :acceptance` and excluded from the default test suite. Run with: + + mix test --only acceptance + + ## Requirements (this is a real shell-out E2E — it needs the toolchain) + + - `phx_new` archive on the system (`mix archive.install hex phx_new`). + Skipped if `mix help phx.new` exits non-zero. + - Network access for `mix deps.get` (fetches Phoenix + this project's + transitive deps), unless everything is already cached. + - `MOB_NEW_DIR` (or `~/code/mob_new`) pointing at a mob_new checkout — + `mob.adopt`'s native trees render from mob_new's + `priv/templates/mob.new/`. Skipped if not found. + - Set `MOB_DIR` / `MOB_DEV_DIR` to use local `path:` deps for `:mob` + and `:mob_dev`; otherwise `:mob` is fetched from Hex (`:mob_dev` is + always the local checkout under test). + + Unlike the mob_new original (where adopt shipped in the globally + installed mob_new archive), adopt now ships in mob_dev — a regular + dep — so the generated project gets mob_dev wired in as a `path:` dep + pointing at the checkout under test, and runs `mix mob.adopt` from + there. + """ + use ExUnit.Case, async: false + + @moduletag :acceptance + @moduletag timeout: 600_000 + + # The mob_dev checkout under test — the project gets a path: dep on + # this so `mix mob.adopt` runs the code we just changed. + @mob_dev_root Path.expand("../..", __DIR__) + + setup_all do + cond do + not phx_new_available?() -> + {:skip, "phx.new not installed — run `mix archive.install hex phx_new`"} + + mob_new_checkout() == nil -> + {:skip, + "mob_new checkout not found — set MOB_NEW_DIR or clone to ~/code/mob_new " <> + "(mob.adopt renders native trees from mob_new's priv/templates/mob.new/)"} + + true -> + :ok + end + end + + setup do + tmp = System.tmp_dir!() |> Path.join("mob_acceptance_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp} + end + + test "LV mode against a phx.new --database sqlite3 project produces a usable mob app", %{ + tmp: tmp + } do + app_dir = Path.join(tmp, "test_mob_app") + + # SQLite-shaped Phoenix project — adopt's LV mob_app.ex assumes the + # host Repo is SQLite. `mix phx.new --database sqlite3` is the only + # blessed shape today. + {output, code} = + System.cmd( + "mix", + [ + "phx.new", + "test_mob_app", + "--no-install", + "--database", + "sqlite3", + "--no-mailer", + "--no-dashboard" + ], + cd: tmp, + stderr_to_stdout: true + ) + + assert code == 0, "mix phx.new failed:\n#{output}" + + # Drift check + assert_phoenix_shape_stable!(app_dir) + + # phx.new doesn't include :igniter or :mob_dev + patch_mix_exs_add_mob_deps!(app_dir) + + {output, code} = System.cmd("mix", ["deps.get"], cd: app_dir, stderr_to_stdout: true) + assert code == 0, "deps.get (initial, with :igniter + :mob_dev) failed:\n#{output}" + + # Run mob.adopt + local_args = if mob_local_checkout?(), do: ["--local"], else: [] + + {output, code} = + System.cmd( + "mix", + ["mob.adopt", "--yes"] ++ local_args, + cd: app_dir, + stderr_to_stdout: true, + env: adopt_env() + ) + + assert code == 0, "mob.adopt failed:\n#{output}" + + # Adopt-output assertions. + mix_exs = File.read!(Path.join(app_dir, "mix.exs")) + assert mix_exs =~ ":mob", "mob.adopt didn't add :mob to mix.exs" + assert mix_exs =~ ":mob_dev", "mob.adopt didn't add :mob_dev to mix.exs" + + for relative <- [ + "lib/test_mob_app/mob_screen.ex", + "lib/test_mob_app/mob_app.ex", + "src/test_mob_app.erl", + "mob.exs", + "android/build.gradle", + "ios/Info.plist" + ] do + assert File.exists?(Path.join(app_dir, relative)), + "mob.adopt didn't emit #{relative}" + end + + # Compile check. + {output, code} = System.cmd("mix", ["deps.get"], cd: app_dir, stderr_to_stdout: true) + assert code == 0, "deps.get failed after adopt:\n#{output}" + + {output, code} = System.cmd("mix", ["compile"], cd: app_dir, stderr_to_stdout: true) + + assert code == 0, + "DRIFT: mix compile failed after mob.adopt — adopt's generated " <> + "code likely references a Phoenix or Mob module that has moved.\n\n" <> + output + + # Runtime smoke check — `mix compile` only catches missing modules at + # the syntactic level (`Ecto.Migrator.run(SomeUndefined.Repo, ...)` + # compiles to a runtime call against an atom). `Application.ensure_all_started` + # actually validates that every declared application is installed and + # loadable. Catches the "adopt forgot to add :ecto_sqlite3" class of bug + # the @moduledoc explicitly warns about. + {output, code} = + System.cmd( + "mix", + ["run", "--no-start", "-e", "{:ok, _} = Application.ensure_all_started(:ecto_sqlite3)"], + cd: app_dir, + stderr_to_stdout: true + ) + + assert code == 0, + "Runtime smoke check failed — `:ecto_sqlite3` is not installed/loadable. " <> + "adopt.deps should have added it for the LV-flavoured mob_app.ex.\n\n" <> + output + end + + test "thin-client mode (--no-live-view) against a --no-ecto phx.new project works", + %{tmp: tmp} do + app_dir = Path.join(tmp, "test_thin_app") + + # No-ecto Phoenix project — thin-client mode has no Repo dependency. + {output, code} = + System.cmd( + "mix", + [ + "phx.new", + "test_thin_app", + "--no-install", + "--no-ecto", + "--no-mailer", + "--no-dashboard" + ], + cd: tmp, + stderr_to_stdout: true + ) + + assert code == 0, "mix phx.new failed:\n#{output}" + + patch_mix_exs_add_mob_deps!(app_dir) + + {output, code} = System.cmd("mix", ["deps.get"], cd: app_dir, stderr_to_stdout: true) + assert code == 0, "deps.get failed:\n#{output}" + + local_args = if mob_local_checkout?(), do: ["--local"], else: [] + + {output, code} = + System.cmd( + "mix", + ["mob.adopt", "--yes", "--no-live-view", "--host-url", "https://example.fly.dev/"] ++ + local_args, + cd: app_dir, + stderr_to_stdout: true, + env: adopt_env() + ) + + assert code == 0, "mob.adopt --no-live-view failed:\n#{output}" + + # Thin mode generates the same set of files as LV mode, minus the + # bridge patches (which no-op without LV). mob_app.ex should be the + # thin variant (`use Mob.App`, no `ensure_all_started(:test_thin_app)`). + mob_app = File.read!(Path.join(app_dir, "lib/test_thin_app/mob_app.ex")) + assert mob_app =~ "use Mob.App" + refute mob_app =~ "{:ok, _} = Application.ensure_all_started(:test_thin_app)" + refute mob_app =~ "Ecto.Migrator.run" + + # config/config.exs should have host_url for the WebView. + config = File.read!(Path.join(app_dir, "config/config.exs")) + assert config =~ ~s(host_url: "https://example.fly.dev/") + + # Compile + boot smoke check. + {output, code} = System.cmd("mix", ["deps.get"], cd: app_dir, stderr_to_stdout: true) + assert code == 0, "deps.get failed after adopt:\n#{output}" + + {output, code} = System.cmd("mix", ["compile"], cd: app_dir, stderr_to_stdout: true) + assert code == 0, "compile failed:\n#{output}" + end + + defp assert_phoenix_shape_stable!(app_dir) do + app_js_path = Path.join(app_dir, "assets/js/app.js") + + assert File.exists?(app_js_path), + "DRIFT: assets/js/app.js not at the expected path. Phoenix may have " <> + "moved the JS entry point — mob.adopt's MobHook patcher targets " <> + "that exact location." + + app_js = File.read!(app_js_path) + + assert app_js =~ "new LiveSocket(", + "DRIFT: assets/js/app.js no longer contains `new LiveSocket(`. " <> + "mob.adopt's MobHook patcher targets that exact substring. " <> + "Either Phoenix changed conventions (check phx.new's release notes) " <> + "or this acceptance test needs updating." + + root_candidates = [ + "lib/test_mob_app_web/components/layouts/root.html.heex", + "lib/test_mob_app_web/templates/layout/root.html.heex" + ] + + root_relative = Enum.find(root_candidates, &File.exists?(Path.join(app_dir, &1))) + + assert root_relative, + "DRIFT: root.html.heex not at any of:\n - " <> + Enum.join(root_candidates, "\n - ") <> + "\nmob.adopt's bridge `<div>` injection targets these paths." + + root = File.read!(Path.join(app_dir, root_relative)) + + assert root =~ ~r/<body[^>]*>/, + "DRIFT: #{root_relative} no longer contains a `<body>` tag. " <> + "mob.adopt injects the bridge `<div>` right after `<body>`." + end + + defp phx_new_available? do + match?({_, 0}, System.cmd("mix", ["help", "phx.new"], stderr_to_stdout: true)) + end + + # `--local` resolves :mob (and :mob_dev) from MOB_DIR / MOB_DEV_DIR. + # Without it, :mob comes from Hex but :mob_dev is still the local + # checkout under test (always a path: dep — see patch_mix_exs_add_mob_deps!). + defp mob_local_checkout? do + with mob_dir when is_binary(mob_dir) <- System.get_env("MOB_DIR"), + mob_dev_dir when is_binary(mob_dev_dir) <- System.get_env("MOB_DEV_DIR"), + true <- File.dir?(mob_dir), + true <- File.dir?(mob_dev_dir) do + true + else + _ -> false + end + end + + defp mob_new_checkout do + [System.get_env("MOB_NEW_DIR"), Path.expand("~/code/mob_new")] + |> Enum.reject(&is_nil/1) + |> Enum.find(fn dir -> File.dir?(Path.join(dir, "priv/templates/mob.new")) end) + end + + # mob.adopt needs MOB_NEW_DIR set so its template resolver finds + # mob_new (the home-dir fallback expands to the running user's home, + # not necessarily where mob_new lives). + defp adopt_env do + [{"MOB_NEW_DIR", mob_new_checkout()}] + end + + # phx.new emits neither :igniter nor :mob_dev. adopt ships in mob_dev, + # so the generated project needs a path: dep on the checkout under test + # to run the code we're testing. + defp patch_mix_exs_add_mob_deps!(app_dir) do + path = Path.join(app_dir, "mix.exs") + content = File.read!(path) + + deps_snippet = + ~s(\\1\n {:igniter, "~> 0.7", only: [:dev, :test]},) <> + ~s(\n {:mob_dev, path: "#{@mob_dev_root}", only: :dev, runtime: false},) + + patched = + Regex.replace( + ~r/(defp deps do\s*\[)/, + content, + deps_snippet, + global: false + ) + + assert patched != content, + "DRIFT: could not patch mix.exs to add :igniter + :mob_dev. The " <> + "`defp deps do [` shape may have changed in phx.new." + + File.write!(path, patched) + end +end diff --git a/test/mix/tasks/mob/adopt/bridge_test.exs b/test/mix/tasks/mob/adopt/bridge_test.exs new file mode 100644 index 0000000..95f9ddb --- /dev/null +++ b/test/mix/tasks/mob/adopt/bridge_test.exs @@ -0,0 +1,120 @@ +defmodule Mix.Tasks.Mob.Adopt.BridgeTest do + use ExUnit.Case, async: true + + import Igniter.Test + + @phx_mix_exs """ + defmodule Test.MixProject do + use Mix.Project + def project, do: [app: :test, version: "0.1.0", elixir: "~> 1.15", deps: deps()] + def application, do: [extra_applications: [:logger]] + defp deps, + do: [ + {:phoenix, "~> 1.7"}, + {:ecto_sql, "~> 3.10"}, + {:ecto_sqlite3, "~> 0.18"} + ] + end + """ + + @stock_app_js """ + import {Socket} from "phoenix" + let liveSocket = new LiveSocket("/live", Socket, {hooks: {}}) + """ + + @stock_root_heex """ + <html> + <body> + Hello + </body> + </html> + """ + + defp blessed_project(extra_files \\ %{}) do + test_project(files: Map.merge(blessed_files(), extra_files)) + end + + defp blessed_files do + %{ + "mix.exs" => @phx_mix_exs, + "assets/js/app.js" => @stock_app_js, + "lib/test_web/components/layouts/root.html.heex" => @stock_root_heex + } + end + + defp project_without(keys) do + test_project(files: Map.drop(blessed_files(), keys)) + end + + describe "mob.adopt.bridge (LV mode, blessed shape)" do + test "injects MobHook into assets/js/app.js" do + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt.bridge") + |> apply_igniter!() + + content = + Rewrite.source!(igniter.rewrite, "assets/js/app.js") |> Rewrite.Source.get(:content) + + assert content =~ "MobHook" + end + + test "injects bridge element into root.html.heex" do + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt.bridge") + |> apply_igniter!() + + content = + Rewrite.source!(igniter.rewrite, "lib/test_web/components/layouts/root.html.heex") + |> Rewrite.Source.get(:content) + + assert content =~ ~s(id="mob-bridge") + end + end + + describe "mob.adopt.bridge (LV mode, refusal)" do + test "refuses when assets/js/app.js missing (no warn-and-proceed)" do + igniter = + project_without(["assets/js/app.js"]) + |> Igniter.compose_task("mob.adopt.bridge") + + assert Enum.any?( + igniter.issues, + &(String.contains?(&1, "requires assets/js/app.js") and + String.contains?(&1, "--no-live-view")) + ) + + # No warning fallback any more. + refute Enum.any?(igniter.warnings, &String.contains?(&1, "MobHook")) + end + + test "refuses when root.html.heex missing" do + igniter = + project_without(["lib/test_web/components/layouts/root.html.heex"]) + |> Igniter.compose_task("mob.adopt.bridge") + + assert Enum.any?(igniter.issues, &String.contains?(&1, "requires a root layout")) + end + end + + describe "mob.adopt.bridge --no-live-view" do + test "skips patches with a notice; files untouched" do + # apply_igniter! resets `notices` to [] during simulate_write, so we + # inspect the un-applied igniter for notices, then check file content. + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt.bridge", ["--no-live-view"]) + + assert Enum.any?(igniter.notices, &String.contains?(&1, "skipped (--no-live-view)")) + + app_js_source = Rewrite.source!(igniter.rewrite, "assets/js/app.js") + refute Rewrite.Source.get(app_js_source, :content) =~ "MobHook" + + heex_source = + Rewrite.source!(igniter.rewrite, "lib/test_web/components/layouts/root.html.heex") + + refute Rewrite.Source.get(heex_source, :content) =~ "mob-bridge" + end + end +end diff --git a/test/mix/tasks/mob/adopt/deps_test.exs b/test/mix/tasks/mob/adopt/deps_test.exs new file mode 100644 index 0000000..280fc33 --- /dev/null +++ b/test/mix/tasks/mob/adopt/deps_test.exs @@ -0,0 +1,76 @@ +defmodule Mix.Tasks.Mob.Adopt.DepsTest do + use ExUnit.Case, async: true + + import Igniter.Test + + @phx_mix_exs """ + defmodule Test.MixProject do + use Mix.Project + def project, do: [app: :test, version: "0.1.0", elixir: "~> 1.15", deps: deps()] + def application, do: [extra_applications: [:logger]] + defp deps, + do: [ + {:phoenix, "~> 1.7"}, + {:ecto_sql, "~> 3.10"}, + {:ecto_sqlite3, "~> 0.18"} + ] + end + """ + + @stock_app_js """ + import {Socket} from "phoenix" + let liveSocket = new LiveSocket("/live", Socket, {hooks: {}}) + """ + + @stock_root_heex """ + <html> + <body> + Hello + </body> + </html> + """ + + defp blessed_project do + test_project( + files: %{ + "mix.exs" => @phx_mix_exs, + "assets/js/app.js" => @stock_app_js, + "lib/test_web/components/layouts/root.html.heex" => @stock_root_heex + } + ) + end + + describe "mob.adopt.deps" do + test "adds :mob and :mob_dev to mix.exs" do + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt.deps") + + source = Rewrite.source!(igniter.rewrite, "mix.exs") + content = Rewrite.Source.get(source, :content) + + assert content =~ ":mob" + assert content =~ ":mob_dev" + assert content =~ "only: :dev" + end + + test "is idempotent on a second run" do + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt.deps") + |> apply_igniter!() + |> Igniter.compose_task("mob.adopt.deps") + + assert_unchanged(igniter) + end + + test "refuses on a non-Phoenix host (standalone guard)" do + igniter = + test_project() + |> Igniter.compose_task("mob.adopt.deps") + + assert Enum.any?(igniter.issues, &String.contains?(&1, "requires a Phoenix project")) + assert_unchanged(igniter) + end + end +end diff --git a/test/mix/tasks/mob/adopt/finalize_test.exs b/test/mix/tasks/mob/adopt/finalize_test.exs new file mode 100644 index 0000000..c5c217d --- /dev/null +++ b/test/mix/tasks/mob/adopt/finalize_test.exs @@ -0,0 +1,45 @@ +defmodule Mix.Tasks.Mob.Adopt.FinalizeTest do + use ExUnit.Case, async: true + + import Igniter.Test + + # apply_igniter! resets `notices` to [] during simulate_write, so we + # inspect the un-applied igniter for the emitted notice. + defp notice(argv) do + igniter = + test_project() + |> Igniter.compose_task("mob.adopt.finalize", argv) + + Enum.join(igniter.notices, "\n") + end + + describe "mob.adopt.finalize notice" do + test "LiveView flavour line (default)" do + text = notice([]) + + assert text =~ "boots the host Phoenix on-device (LiveView bridge)" + refute text =~ "thin-client variant" + end + + test "thin-client flavour line (--no-live-view)" do + text = notice(["--no-live-view"]) + + assert text =~ "thin-client variant (no on-device Phoenix)" + refute text =~ "boots the host Phoenix on-device" + end + + test "default WebView URL line when no --host-url" do + text = notice([]) + + assert text =~ "WebView URL defaults to `http://127.0.0.1:4000/`" + refute text =~ "config :mob, host_url:` set" + end + + test "host_url line when --host-url is given" do + text = notice(["--host-url", "https://my-app.fly.dev/"]) + + assert text =~ "WebView URL set to `https://my-app.fly.dev/`" + refute text =~ "WebView URL defaults to" + end + end +end diff --git a/test/mix/tasks/mob/adopt/mob_app_test.exs b/test/mix/tasks/mob/adopt/mob_app_test.exs new file mode 100644 index 0000000..1a5bf5b --- /dev/null +++ b/test/mix/tasks/mob/adopt/mob_app_test.exs @@ -0,0 +1,138 @@ +defmodule Mix.Tasks.Mob.Adopt.MobAppTest do + use ExUnit.Case, async: true + + import Igniter.Test + + @phx_mix_exs """ + defmodule Test.MixProject do + use Mix.Project + + def project do + [app: :test, version: "0.1.0", elixir: "~> 1.15", deps: deps()] + end + + def application, do: [extra_applications: [:logger]] + + defp deps, + do: [ + {:phoenix, "~> 1.7"}, + {:ecto_sql, "~> 3.10"}, + {:ecto_sqlite3, "~> 0.18"} + ] + end + """ + + @stock_app_js """ + import {Socket} from "phoenix" + let liveSocket = new LiveSocket("/live", Socket, {hooks: {}}) + """ + + @stock_root_heex """ + <html> + <body> + Hello + </body> + </html> + """ + + defp blessed_project do + test_project( + files: %{ + "mix.exs" => @phx_mix_exs, + "assets/js/app.js" => @stock_app_js, + "lib/test_web/components/layouts/root.html.heex" => @stock_root_heex + } + ) + end + + # Thin mode only requires the `:phoenix` dep — no app.js / layout / SQLite shape. + defp phoenix_project do + test_project(files: %{"mix.exs" => @phx_mix_exs}) + end + + describe "mob.adopt.mob_app (default — LiveView flavour)" do + test "generates LV-flavoured mob_app.ex that boots the host Phoenix app" do + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt.mob_app") + + content = + Rewrite.source!(igniter.rewrite, "lib/test/mob_app.ex") + |> Rewrite.Source.get(:content) + + assert content =~ "defmodule Test.MobApp" + assert content =~ "{:ok, _} = Application.ensure_all_started(:test)" + assert content =~ "Mob.NativeLogger.install()" + assert content =~ "Ecto.Migrator.run" + end + + test "endpoint config uses a safe `live_reload` value (regression)" do + # `live_reload: false` crashes `Phoenix.LiveReloader.call/2` because + # it does `config[:patterns]` on the value, and `Access` has no + # clause for booleans. Phoenix's contract is keyword-list-or-unset, + # so we ship `[patterns: []]` (active plug, no patterns to match). + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt.mob_app") + + content = + Rewrite.source!(igniter.rewrite, "lib/test/mob_app.ex") + |> Rewrite.Source.get(:content) + + assert content =~ "live_reload: [patterns: []]" + refute content =~ "live_reload: false" + end + + test "writes src/<app>.erl bootstrap" do + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt.mob_app") + + erl = Rewrite.source!(igniter.rewrite, "src/test.erl") |> Rewrite.Source.get(:content) + assert erl =~ "test" + end + + test "patches mix.exs with erlc_paths and erlc_options" do + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt.mob_app") + + content = Rewrite.source!(igniter.rewrite, "mix.exs") |> Rewrite.Source.get(:content) + assert content =~ ~s(erlc_paths: ["src"]) + assert content =~ "erlc_options: [:debug_info]" + end + + test "refuses on a non-Phoenix host (standalone guard)" do + igniter = + test_project() + |> Igniter.compose_task("mob.adopt.mob_app") + + assert Enum.any?(igniter.issues, &String.contains?(&1, "requires a Phoenix project")) + end + end + + describe "mob.adopt.mob_app --no-live-view (thin-client flavour)" do + test "generates thin mob_app.ex using `use Mob.App` without ensure_all_started" do + igniter = + phoenix_project() + |> Igniter.compose_task("mob.adopt.mob_app", ["--no-live-view"]) + + content = + Rewrite.source!(igniter.rewrite, "lib/test/mob_app.ex") + |> Rewrite.Source.get(:content) + + assert content =~ "defmodule Test.MobApp" + assert content =~ "use Mob.App" + assert content =~ "def navigation" + assert content =~ "def on_start" + assert content =~ "Mob.Screen.start_root(Test.MobScreen)" + assert content =~ "Mob.DNS.configure_pure_beam" + + # Crucially, the thin variant does NOT actually boot the host + # Phoenix app or run Ecto migrations on-device. (The docstring + # mentions both in prose, but the code body does not.) + refute content =~ "{:ok, _} = Application.ensure_all_started" + refute content =~ "Ecto.Migrator.run" + end + end +end diff --git a/test/mix/tasks/mob/adopt/mob_exs_test.exs b/test/mix/tasks/mob/adopt/mob_exs_test.exs new file mode 100644 index 0000000..c543e35 --- /dev/null +++ b/test/mix/tasks/mob/adopt/mob_exs_test.exs @@ -0,0 +1,110 @@ +defmodule Mix.Tasks.Mob.Adopt.MobExsTest do + use ExUnit.Case, async: true + + import Igniter.Test + + @phx_mix_exs """ + defmodule Test.MixProject do + use Mix.Project + def project, do: [app: :test, version: "0.1.0", elixir: "~> 1.15", deps: deps()] + def application, do: [extra_applications: [:logger]] + defp deps, + do: [ + {:phoenix, "~> 1.7"}, + {:ecto_sql, "~> 3.10"}, + {:ecto_sqlite3, "~> 0.18"} + ] + end + """ + + @stock_app_js """ + import {Socket} from "phoenix" + let liveSocket = new LiveSocket("/live", Socket, {hooks: {}}) + """ + + @stock_root_heex """ + <html> + <body> + Hello + </body> + </html> + """ + + defp blessed_project(extra_files \\ %{}) do + files = + Map.merge( + %{ + "mix.exs" => @phx_mix_exs, + "assets/js/app.js" => @stock_app_js, + "lib/test_web/components/layouts/root.html.heex" => @stock_root_heex + }, + extra_files + ) + + test_project(files: files) + end + + describe "mob.adopt.mob_exs" do + test "creates mob.exs" do + blessed_project() + |> Igniter.compose_task("mob.adopt.mob_exs") + |> assert_creates("mob.exs") + end + + test "mob.exs content has the expected structure" do + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt.mob_exs") + + source = Rewrite.source!(igniter.rewrite, "mob.exs") + content = Rewrite.Source.get(source, :content) + + assert content =~ "import Config" + assert content =~ "config :mob_dev" + assert content =~ "mob_dir:" + assert content =~ "elixir_lib:" + end + + # `igniter.assigns[:test_files]` is the Igniter test struct, not a + # Phoenix LiveView socket — `:plug_test` opts these out of the + # `AvoidSocketAssignsInTest` LiveView check. + @tag :plug_test + test "patches .gitignore to ignore mob.exs" do + igniter = + blessed_project(%{".gitignore" => "/_build\n/deps\n"}) + |> Igniter.compose_task("mob.adopt.mob_exs") + |> apply_igniter!() + + # Dotfiles are filtered out by the post-apply `**/*.*` include_glob + # in `Igniter.Test.simulate_write/1`, so they only live in + # `assigns[:test_files]` after apply. Read from there. + content = igniter.assigns[:test_files][".gitignore"] + assert content =~ "mob.exs" + end + + @tag :plug_test + test "is idempotent on .gitignore patches" do + base = + blessed_project(%{".gitignore" => "/_build\n/deps\n"}) + |> Igniter.compose_task("mob.adopt.mob_exs") + |> apply_igniter!() + + first_content = base.assigns[:test_files][".gitignore"] + + after_second = + base + |> Igniter.compose_task("mob.adopt.mob_exs") + |> apply_igniter!() + + assert after_second.assigns[:test_files][".gitignore"] == first_content + end + + test "refuses on a non-Phoenix host (standalone guard)" do + igniter = + test_project() + |> Igniter.compose_task("mob.adopt.mob_exs") + + assert Enum.any?(igniter.issues, &String.contains?(&1, "requires a Phoenix project")) + end + end +end diff --git a/test/mix/tasks/mob/adopt/native/android_test.exs b/test/mix/tasks/mob/adopt/native/android_test.exs new file mode 100644 index 0000000..abb74d6 --- /dev/null +++ b/test/mix/tasks/mob/adopt/native/android_test.exs @@ -0,0 +1,50 @@ +defmodule Mix.Tasks.Mob.Adopt.Native.AndroidTest do + # NOT async: copy_static_binaries writes to the project CWD via File.copy! + # (see the native installer's deliberate divergence from Igniter for + # binary assets), so two test runs would race on android/gradlew. + use ExUnit.Case, async: false + + import Igniter.Test + + setup do + # Each test runs in its own temp cwd so the binary-copy side effects + # don't leak into the repo root or between tests. + cwd = File.cwd!() + + tmp = + System.tmp_dir!() |> Path.join("mob_adopt_native_#{System.unique_integer([:positive])}") + + File.mkdir_p!(tmp) + File.cd!(tmp) + on_exit(fn -> File.cd!(cwd) end) + {:ok, tmp: tmp} + end + + describe "mob.adopt.native.android" do + test "creates AndroidManifest and build.gradle for the app" do + igniter = + test_project() + |> Igniter.compose_task("mob.adopt.native.android") + |> apply_igniter!() + + assert Rewrite.has_source?( + igniter.rewrite, + "android/app/src/main/AndroidManifest.xml" + ) + + assert Rewrite.has_source?(igniter.rewrite, "android/app/build.gradle") + end + + test "MainActivity.kt is templated with the app name", %{tmp: _tmp} do + igniter = + test_project() + |> Igniter.compose_task("mob.adopt.native.android") + |> apply_igniter!() + + path = "android/app/src/main/java/com/example/test/MainActivity.kt" + source = Rewrite.source!(igniter.rewrite, path) + content = Rewrite.Source.get(source, :content) + assert content =~ "com.example.test" + end + end +end diff --git a/test/mix/tasks/mob/adopt/native/ios_test.exs b/test/mix/tasks/mob/adopt/native/ios_test.exs new file mode 100644 index 0000000..7fcb767 --- /dev/null +++ b/test/mix/tasks/mob/adopt/native/ios_test.exs @@ -0,0 +1,61 @@ +defmodule Mix.Tasks.Mob.Adopt.Native.IosTest do + use ExUnit.Case, async: false + + import Igniter.Test + + setup do + cwd = File.cwd!() + tmp = System.tmp_dir!() |> Path.join("mob_adopt_ios_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + File.cd!(tmp) + on_exit(fn -> File.cd!(cwd) end) + {:ok, tmp: tmp} + end + + describe "mob.adopt.native.ios" do + test "creates Info.plist and beam_main.m" do + igniter = + test_project() + |> Igniter.compose_task("mob.adopt.native.ios") + |> apply_igniter!() + + assert Rewrite.has_source?(igniter.rewrite, "ios/Info.plist") + assert Rewrite.has_source?(igniter.rewrite, "ios/beam_main.m") + end + end + + describe "mob.adopt.native.ios --python" do + # `maybe_apply_python/3` shells out to MobDev.Adopt.Generator.apply_python_patches/2, + # which mutates the real cwd (predates the Igniter file API). The setup + # block already cd's into a tmp dir, so write a real mix.exs there for it + # to patch, then assert the on-disk side effects. + test "applies Pythonx wiring (mix.exs dep + python_paths.ex)", %{tmp: tmp} do + File.write!(Path.join(tmp, "mix.exs"), """ + defmodule Test.MixProject do + use Mix.Project + def project, do: [app: :test, version: "0.1.0", deps: deps()] + defp deps do + [{:phoenix, "~> 1.7"}] + end + end + """) + + igniter = + test_project() + |> Igniter.compose_task("mob.adopt.native.ios", ["--python"]) + + assert Enum.any?(igniter.notices, &String.contains?(&1, "Pythonx wiring")) + + assert File.exists?(Path.join(tmp, "lib/test/python_paths.ex")) + assert File.read!(Path.join(tmp, "mix.exs")) =~ ~s({:pythonx, "~> 0.4"}) + end + + test "no Pythonx wiring without --python" do + igniter = + test_project() + |> Igniter.compose_task("mob.adopt.native.ios") + + refute Enum.any?(igniter.notices, &String.contains?(&1, "Pythonx wiring")) + end + end +end diff --git a/test/mix/tasks/mob/adopt/screen_test.exs b/test/mix/tasks/mob/adopt/screen_test.exs new file mode 100644 index 0000000..1069b94 --- /dev/null +++ b/test/mix/tasks/mob/adopt/screen_test.exs @@ -0,0 +1,92 @@ +defmodule Mix.Tasks.Mob.Adopt.ScreenTest do + use ExUnit.Case, async: true + + import Igniter.Test + + @phx_mix_exs """ + defmodule Test.MixProject do + use Mix.Project + def project, do: [app: :test, version: "0.1.0", elixir: "~> 1.15", deps: deps()] + def application, do: [extra_applications: [:logger]] + defp deps, + do: [ + {:phoenix, "~> 1.7"}, + {:ecto_sql, "~> 3.10"}, + {:ecto_sqlite3, "~> 0.18"} + ] + end + """ + + @stock_app_js """ + import {Socket} from "phoenix" + let liveSocket = new LiveSocket("/live", Socket, {hooks: {}}) + """ + + @stock_root_heex """ + <html> + <body> + Hello + </body> + </html> + """ + + defp blessed_project do + test_project( + files: %{ + "mix.exs" => @phx_mix_exs, + "assets/js/app.js" => @stock_app_js, + "lib/test_web/components/layouts/root.html.heex" => @stock_root_heex + } + ) + end + + describe "mob.adopt.screen" do + test "creates lib/<app>/mob_screen.ex reading host URL from app config" do + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt.screen") + + source = Rewrite.source!(igniter.rewrite, "lib/test/mob_screen.ex") + content = Rewrite.Source.get(source, :content) + + assert content =~ "Test.MobScreen" + assert content =~ "Application.get_env(:mob, :host_url" + assert content =~ ~s("http://127.0.0.1:4000/") + refute content =~ "Mob.LiveView.local_url" + end + + test "is idempotent" do + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt.screen") + |> apply_igniter!() + |> Igniter.compose_task("mob.adopt.screen") + + assert_unchanged(igniter) + end + + test "--host-url writes `config :mob, host_url: URL` to config/config.exs" do + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt.screen", ["--host-url", "https://my.fly.dev/"]) + + # The mob_screen.ex itself remains URL-agnostic — it reads the config. + mob_screen = Rewrite.source!(igniter.rewrite, "lib/test/mob_screen.ex") + refute Rewrite.Source.get(mob_screen, :content) =~ "https://my.fly.dev/" + + # config/config.exs gets the new key. + config = Rewrite.source!(igniter.rewrite, "config/config.exs") + content = Rewrite.Source.get(config, :content) + assert content =~ "config :mob" + assert content =~ ~s(host_url: "https://my.fly.dev/") + end + + test "refuses on a non-Phoenix host (standalone guard)" do + igniter = + test_project() + |> Igniter.compose_task("mob.adopt.screen") + + assert Enum.any?(igniter.issues, &String.contains?(&1, "requires a Phoenix project")) + end + end +end diff --git a/test/mix/tasks/mob/adopt_test.exs b/test/mix/tasks/mob/adopt_test.exs new file mode 100644 index 0000000..abe2147 --- /dev/null +++ b/test/mix/tasks/mob/adopt_test.exs @@ -0,0 +1,101 @@ +defmodule Mix.Tasks.Mob.AdoptTest do + use ExUnit.Case, async: true + + import Igniter.Test + + alias Mix.Tasks.Mob.Adopt + + @phx_mix_exs """ + defmodule Test.MixProject do + use Mix.Project + + def project do + [app: :test, version: "0.1.0", elixir: "~> 1.15", deps: deps()] + end + + def application, do: [extra_applications: [:logger]] + + defp deps, + do: [ + {:phoenix, "~> 1.7"}, + {:ecto_sql, "~> 3.10"}, + {:ecto_sqlite3, "~> 0.18"} + ] + end + """ + + @stock_app_js """ + import {Socket} from "phoenix" + let liveSocket = new LiveSocket("/live", Socket, {hooks: {}}) + """ + + @stock_root_heex """ + <html> + <body> + Hello + </body> + </html> + """ + + defp blessed_project do + test_project( + files: %{ + "mix.exs" => @phx_mix_exs, + "assets/js/app.js" => @stock_app_js, + "lib/test_web/components/layouts/root.html.heex" => @stock_root_heex + } + ) + end + + describe "info/2" do + test "composes the expected sub-tasks" do + info = Adopt.info([], nil) + + assert info.composes == [ + "mob.adopt.deps", + "mob.adopt.bridge", + "mob.adopt.screen", + "mob.adopt.mob_app", + "mob.adopt.mob_exs", + "mob.adopt.native", + "mob.adopt.finalize" + ] + end + + test "defaults to both platforms on" do + info = Adopt.info([], nil) + assert info.defaults[:ios] == true + assert info.defaults[:android] == true + end + end + + describe "validate_platforms!/1" do + test "raises when both --no-ios and --no-android are passed" do + assert_raise Mix.Error, ~r/Cannot pass both --no-ios and --no-android/, fn -> + blessed_project() + |> Igniter.compose_task("mob.adopt", ["--no-ios", "--no-android"]) + end + end + end + + describe "igniter/1 guard gate" do + test "composes the sub-installers when the host matches the blessed shape" do + igniter = + blessed_project() + |> Igniter.compose_task("mob.adopt", ["--no-ios"]) + + assert igniter.issues == [] + assert Enum.member?(Rewrite.paths(igniter.rewrite), "lib/test/mob_app.ex") + assert Enum.member?(Rewrite.paths(igniter.rewrite), "lib/test/mob_screen.ex") + end + + test "refuses without composing when the host is not a Phoenix project" do + igniter = + test_project() + |> Igniter.compose_task("mob.adopt", ["--no-ios"]) + + assert Enum.any?(igniter.issues, &String.contains?(&1, "requires a Phoenix project")) + refute Enum.member?(Rewrite.paths(igniter.rewrite), "lib/test/mob_app.ex") + end + end +end diff --git a/test/mix/tasks/mob_add_nif_test.exs b/test/mix/tasks/mob_add_nif_test.exs new file mode 100644 index 0000000..5ba03f2 --- /dev/null +++ b/test/mix/tasks/mob_add_nif_test.exs @@ -0,0 +1,474 @@ +defmodule Mix.Tasks.Mob.AddNifTest do + use ExUnit.Case, async: true + + import Igniter.Test + + describe "name validation" do + test "rejects PascalCase names" do + "AudioEngine" + |> add_nif() + |> assert_has_issue(&(&1 =~ "snake_case")) + end + + test "rejects names that don't start with a letter" do + "_audio" + |> add_nif() + |> assert_has_issue(&(&1 =~ "snake_case")) + end + + test "rejects names with hyphens" do + "audio-engine" + |> add_nif() + |> assert_has_issue(&(&1 =~ "snake_case")) + end + + test "rejects empty names" do + "" + |> add_nif() + |> assert_has_issue(&(&1 =~ "snake_case")) + end + + test "accepts standard snake_case names" do + "audio_engine" + |> add_nif() + |> refute_has_issue() + end + + test "accepts names with digits after the leading letter" do + "sqlite3_nif" + |> add_nif() + |> refute_has_issue() + end + end + + describe "type validation" do + test "rejects unknown --type values" do + "audio_engine" + |> add_nif(["--type", "haskell"]) + |> assert_has_issue(&(&1 =~ "Unknown --type")) + end + + test "accepts --type elixir-only (default)" do + "audio_engine" + |> add_nif() + |> refute_has_issue() + end + + test "accepts --type c" do + "audio_engine" + |> add_nif(["--type", "c"]) + |> refute_has_issue() + end + end + + describe "Elixir stub" do + test "creates lib/<app>/nifs/<name>.ex by default" do + "audio_engine" + |> add_nif() + |> assert_creates("lib/test/nifs/audio_engine.ex") + end + + test "stub module declares @on_load and load_nif/0" do + igniter = add_nif("audio_engine") + file = Rewrite.source!(igniter.rewrite, "lib/test/nifs/audio_engine.ex") + content = Rewrite.Source.get(file, :content) + assert content =~ "@on_load :load_nif" + assert content =~ "def load_nif" + assert content =~ ~s|:erlang.load_nif(~c"audio_engine", 0)| + end + + test "stub fns return :erlang.nif_error so missing native side errors loudly" do + igniter = add_nif("audio_engine") + file = Rewrite.source!(igniter.rewrite, "lib/test/nifs/audio_engine.ex") + content = Rewrite.Source.get(file, :content) + assert content =~ ":erlang.nif_error(:nif_not_loaded)" + end + + test "--module overrides the default module name" do + "audio_engine" + |> add_nif(["--module", "Test.Audio"]) + |> assert_creates("lib/test/audio.ex") + end + end + + describe "mob.exs :static_nifs" do + test "creates :static_nifs key when absent" do + igniter = + test_project(files: %{"mob.exs" => "import Config\nconfig :mob_dev, mob_dir: \"/x\"\n"}) + |> Igniter.compose_task("mob.add_nif", ["audio_engine"]) + + file = Rewrite.source!(igniter.rewrite, "mob.exs") + content = Rewrite.Source.get(file, :content) + assert content =~ "static_nifs:" + assert content =~ "module: :audio_engine" + assert content =~ "archs: [:all]" + end + + test "appends to existing :static_nifs without nuking other entries" do + mob_exs = """ + import Config + + config :mob_dev, + static_nifs: [%{module: :existing_one, archs: [:all]}] + """ + + igniter = + test_project(files: %{"mob.exs" => mob_exs}) + |> Igniter.compose_task("mob.add_nif", ["audio_engine"]) + + file = Rewrite.source!(igniter.rewrite, "mob.exs") + content = Rewrite.Source.get(file, :content) + assert content =~ "module: :existing_one" + assert content =~ "module: :audio_engine" + end + + test "is idempotent — re-adding the same NIF doesn't double up" do + mob_exs = """ + import Config + config :mob_dev, static_nifs: [%{module: :audio_engine, archs: [:all]}] + """ + + igniter = + test_project(files: %{"mob.exs" => mob_exs}) + |> Igniter.compose_task("mob.add_nif", ["audio_engine"]) + + file = Rewrite.source!(igniter.rewrite, "mob.exs") + content = Rewrite.Source.get(file, :content) + # Exactly one occurrence — re-running shouldn't append another row. + assert content |> String.split("module: :audio_engine") |> length() == 2 + end + end + + describe "C skeleton (--type c)" do + test "creates c_src/<name>.c with --type c" do + "audio_engine" + |> add_nif(["--type", "c"]) + |> assert_creates("c_src/audio_engine.c") + end + + test "C file pre-wires ERL_NIF_INIT to the Elixir.<Module> form" do + # First arg to ERL_NIF_INIT must be the BEAM module name — + # `Elixir.<DotPath>` for Elixir modules — so the static-NIF + # table lookup matches what `:erlang.load_nif/2` is called + # with from the Elixir stub. Empirically verified on iPhone: + # using bare `audio_engine` makes BEAM fall through to dlopen + # and fail because the entry.name doesn't match the module. + # See mob.add_nif's c_skeleton/3 docstring for the full diagnosis. + igniter = add_nif("audio_engine", ["--type", "c"]) + file = Rewrite.source!(igniter.rewrite, "c_src/audio_engine.c") + content = Rewrite.Source.get(file, :content) + assert content =~ "ERL_NIF_INIT(Elixir.Test.Nifs.AudioEngine," + end + + test "elixir-only (default) does NOT create c_src/" do + "audio_engine" + |> add_nif() + |> refute_creates("c_src/audio_engine.c") + end + end + + describe "--type zigler" do + test "stub uses `use Zig` macro instead of @on_load + load_nif" do + igniter = add_nif("audio_engine", ["--type", "zigler"]) + file = Rewrite.source!(igniter.rewrite, "lib/test/nifs/audio_engine.ex") + content = Rewrite.Source.get(file, :content) + assert content =~ "use Zig, otp_app: :test" + refute content =~ "@on_load" + refute content =~ ":erlang.load_nif" + end + + test "stub embeds an example pub fn in a ~Z sigil" do + igniter = add_nif("audio_engine", ["--type", "zigler"]) + file = Rewrite.source!(igniter.rewrite, "lib/test/nifs/audio_engine.ex") + content = Rewrite.Source.get(file, :content) + assert content =~ "~Z\"" + assert content =~ "pub fn add_one" + end + + test "moduledoc warns about Mob's static-link incompatibility" do + igniter = add_nif("audio_engine", ["--type", "zigler"]) + file = Rewrite.source!(igniter.rewrite, "lib/test/nifs/audio_engine.ex") + content = Rewrite.Source.get(file, :content) + # Surface the gotcha so users don't ship a dlopen'd .so by accident. + assert content =~ "Static linking" + end + + test "adds :zigler to mix.exs deps" do + igniter = add_nif("audio_engine", ["--type", "zigler"]) + file = Rewrite.source!(igniter.rewrite, "mix.exs") + content = Rewrite.Source.get(file, :content) + assert content =~ ":zigler" + end + + test "does NOT create c_src/<name>.c (zigler manages its own native side)" do + "audio_engine" + |> add_nif(["--type", "zigler"]) + |> refute_creates("c_src/audio_engine.c") + end + + test "still appends to mob.exs :static_nifs" do + igniter = add_nif("audio_engine", ["--type", "zigler"]) + file = Rewrite.source!(igniter.rewrite, "mob.exs") + content = Rewrite.Source.get(file, :content) + assert content =~ "module: :audio_engine" + end + + test "queues mix zig.get so Zigler uses its own pinned Zig" do + # Without this, Zigler 0.15.x falls back to System.find_executable("zig") + # and uses whatever's on PATH (typically the wrong version). + # zig.get downloads Zig 0.15.2 to the user-cache directory, which + # Zigler's executable_path/0 checks before PATH. + "audio_engine" + |> add_nif(["--type", "zigler"]) + |> assert_has_task("zig.get", []) + end + + test "moduledoc warns about the Zig toolchain pin (mix zig.get)" do + igniter = add_nif("audio_engine", ["--type", "zigler"]) + file = Rewrite.source!(igniter.rewrite, "lib/test/nifs/audio_engine.ex") + content = Rewrite.Source.get(file, :content) + # If we ever stop auto-running zig.get, this assertion still + # tells users they need to. + assert content =~ "zig.get" + end + end + + describe "--type rustler" do + test "stub uses `use Rustler` with otp_app + crate" do + igniter = add_nif("audio_engine", ["--type", "rustler"]) + file = Rewrite.source!(igniter.rewrite, "lib/test/nifs/audio_engine.ex") + content = Rewrite.Source.get(file, :content) + assert content =~ "use Rustler, otp_app: :test, crate: \"audio_engine\"" + end + + test "stub keeps :erlang.nif_error fallback so missing native errors loudly" do + igniter = add_nif("audio_engine", ["--type", "rustler"]) + file = Rewrite.source!(igniter.rewrite, "lib/test/nifs/audio_engine.ex") + content = Rewrite.Source.get(file, :content) + assert content =~ ":erlang.nif_error(:nif_not_loaded)" + end + + test "moduledoc warns about Mob's static-link incompatibility" do + igniter = add_nif("audio_engine", ["--type", "rustler"]) + file = Rewrite.source!(igniter.rewrite, "lib/test/nifs/audio_engine.ex") + content = Rewrite.Source.get(file, :content) + assert content =~ "Static linking" + end + + test "adds :rustler to mix.exs deps" do + igniter = add_nif("audio_engine", ["--type", "rustler"]) + file = Rewrite.source!(igniter.rewrite, "mix.exs") + content = Rewrite.Source.get(file, :content) + assert content =~ ":rustler" + end + + test "creates native/<name>/Cargo.toml with the right [package] name" do + igniter = add_nif("audio_engine", ["--type", "rustler"]) + file = Rewrite.source!(igniter.rewrite, "native/audio_engine/Cargo.toml") + content = Rewrite.Source.get(file, :content) + assert content =~ ~s|name = "audio_engine"| + assert content =~ "rustler" + end + + test "Cargo.toml emits both staticlib and cdylib crate types" do + # staticlib is required for Mob's iOS/Android device builds (the + # .a gets linked into the main binary). cdylib keeps the host-dev + # `mix compile` path working. Empirically verified end-to-end on + # iPhone: removing staticlib here would leave the user stuck after + # the host-dev demo works, with no path to actually ship. + igniter = add_nif("audio_engine", ["--type", "rustler"]) + file = Rewrite.source!(igniter.rewrite, "native/audio_engine/Cargo.toml") + content = Rewrite.Source.get(file, :content) + assert content =~ "staticlib" + assert content =~ "cdylib" + end + + test "Cargo.toml pins rustler 0.37+ for the per-crate nif_init symbol" do + # Rustler 0.37+ derives the static-NIF init symbol name from + # CARGO_CRATE_NAME as `<crate>_nif_init`, matching what Mob's + # driver_tab declares. Older versions hardcode `nif_init` and + # need manual symbol-renaming. Don't quietly downgrade this pin. + igniter = add_nif("audio_engine", ["--type", "rustler"]) + file = Rewrite.source!(igniter.rewrite, "native/audio_engine/Cargo.toml") + content = Rewrite.Source.get(file, :content) + assert content =~ ~r/rustler\s*=\s*"0\.(3[7-9]|[4-9]\d)/ + end + + test "Cargo.toml patches rustler to the Android-dlsym-fix fork (mob#7)" do + # Rustler 0.37's nif_filler uses dlopen(NULL) to find enif_* symbols. + # On Bionic that handle doesn't see the app's RTLD_GLOBAL-promoted .so + # — every NIF init panics with `undefined symbol: enif_priv_data`. + # The GenericJam fork patches the Android branch. Without this + # [patch.crates-io] block in the scaffolded Cargo.toml, a user who + # follows the docs hits the panic on first Android deploy and has + # to figure out the workaround themselves. Drop the block (and this + # test) once upstream rustler ships the fix. + igniter = add_nif("audio_engine", ["--type", "rustler"]) + file = Rewrite.source!(igniter.rewrite, "native/audio_engine/Cargo.toml") + content = Rewrite.Source.get(file, :content) + assert content =~ "[patch.crates-io]" + assert content =~ "github.com/GenericJam/rustler" + assert content =~ "genericjam-android-rtld-default" + + # The block must carry a "drop when upstream merges" cue. Without it, + # someone bumps the rustler version, forgets the patch, and either + # (a) breaks Android again, or (b) keeps shipping a workaround forever. + assert content =~ ~r/DROP WHEN|once upstream/i + end + + test "creates native/<name>/src/lib.rs with rustler::init! pointing at the Elixir module" do + igniter = add_nif("audio_engine", ["--type", "rustler"]) + file = Rewrite.source!(igniter.rewrite, "native/audio_engine/src/lib.rs") + content = Rewrite.Source.get(file, :content) + assert content =~ "#[rustler::nif]" + assert content =~ "fn add_one" + assert content =~ ~s|rustler::init!("Elixir.Test.Nifs.AudioEngine"| + end + + test "creates native/<name>/.gitignore with /target" do + igniter = add_nif("audio_engine", ["--type", "rustler"]) + file = Rewrite.source!(igniter.rewrite, "native/audio_engine/.gitignore") + content = Rewrite.Source.get(file, :content) + assert content =~ "/target" + end + + test "does NOT create c_src/<name>.c (Rustler manages its own native side via Cargo)" do + "audio_engine" + |> add_nif(["--type", "rustler"]) + |> refute_creates("c_src/audio_engine.c") + end + + test "creates native/<name>/.cargo/config.toml with -undefined dynamic_lookup for macOS" do + # Rustler's default cdylib references enif_* symbols that aren't + # resolved until BEAM dlopen. macOS ld64 errors on undefined + # symbols without `-undefined dynamic_lookup`, so first + # `mix compile` on a Mac fails with: + # + # Undefined symbols: _enif_raise_exception, _enif_schedule_nif + # + # The config file deferring symbols at link time is what makes the + # scaffold compile out-of-the-box on macOS. Linux linkers defer by + # default and ignore this file (rustflags scope is Apple-only). + igniter = add_nif("audio_engine", ["--type", "rustler"]) + file = Rewrite.source!(igniter.rewrite, "native/audio_engine/.cargo/config.toml") + content = Rewrite.Source.get(file, :content) + + assert content =~ "[target.aarch64-apple-darwin]" + assert content =~ "[target.x86_64-apple-darwin]" + assert content =~ "link-arg=-undefined" + assert content =~ "link-arg=dynamic_lookup" + end + end + + describe "--demo flag" do + test "rejects --demo with --type elixir-only (no NIF to call)" do + igniter = add_nif("audio_engine", ["--type", "elixir-only", "--demo"]) + issues = igniter.issues + + assert Enum.any?(issues, &String.contains?(&1, "--demo requires a native backend")) + end + + test "creates a demo screen module alongside the stub" do + igniter = add_nif("audio_engine", ["--type", "c", "--demo"]) + # The screen lives under the NIF stub's namespace: <Stub>.Screen. + file = Rewrite.source!(igniter.rewrite, "lib/test/nifs/audio_engine/screen.ex") + content = Rewrite.Source.get(file, :content) + + assert content =~ "defmodule Test.Nifs.AudioEngine.Screen" + assert content =~ "use Mob.Screen" + # The screen calls greet/0 on the stub module. + assert content =~ "alias Test.Nifs.AudioEngine" + assert content =~ "Nif.greet()" + # Logs each call so IEx sees it. + assert content =~ "require Logger" + assert content =~ "Logger.info" + end + + test "demo screen NOT generated without --demo" do + "audio_engine" + |> add_nif(["--type", "c"]) + |> refute_creates("lib/test/nifs/audio_engine/screen.ex") + end + + test "C stub uses greet/0 when --demo (instead of hello/1)" do + igniter = add_nif("audio_engine", ["--type", "c", "--demo"]) + file = Rewrite.source!(igniter.rewrite, "lib/test/nifs/audio_engine.ex") + content = Rewrite.Source.get(file, :content) + + assert content =~ "def greet()" + refute content =~ "def hello(_arg)" + end + + test "C source returns \"Hello from C!\" when --demo" do + igniter = add_nif("audio_engine", ["--type", "c", "--demo"]) + file = Rewrite.source!(igniter.rewrite, "c_src/audio_engine.c") + content = Rewrite.Source.get(file, :content) + + assert content =~ ~s|"Hello from C!"| + assert content =~ ~s|{"greet", 0,| + # 0-arity, not the default hello/1 from the non-demo path. + refute content =~ "hello_from_native" + end + + test "Zigler stub returns \"Hello from Zig!\" via ~Z when --demo" do + igniter = add_nif("audio_engine", ["--type", "zigler", "--demo"]) + file = Rewrite.source!(igniter.rewrite, "lib/test/nifs/audio_engine.ex") + content = Rewrite.Source.get(file, :content) + + assert content =~ ~s|"Hello from Zig!"| + assert content =~ "pub fn greet()" + end + + test "Rust crate returns \"Hello from Rust!\" when --demo" do + igniter = add_nif("audio_engine", ["--type", "rustler", "--demo"]) + file = Rewrite.source!(igniter.rewrite, "native/audio_engine/src/lib.rs") + content = Rewrite.Source.get(file, :content) + + assert content =~ ~s|"Hello from Rust!"| + assert content =~ "fn greet()" + assert content =~ "rustler::init!" + assert content =~ "[greet]" + end + + test "Rust stub Elixir-side uses greet/0 (not add_one/1) when --demo" do + igniter = add_nif("audio_engine", ["--type", "rustler", "--demo"]) + file = Rewrite.source!(igniter.rewrite, "lib/test/nifs/audio_engine.ex") + content = Rewrite.Source.get(file, :content) + + assert content =~ "def greet()" + refute content =~ "def add_one(_input)" + end + + test "prints a notice with three wiring options after generation" do + igniter = add_nif("audio_engine", ["--type", "c", "--demo"]) + + notice = Enum.find(igniter.notices, &String.contains?(&1, "Demo screen created")) + assert notice, "no demo-notice in igniter.notices" + + # The three options the user can pick from. + assert notice =~ "Quick test from IEx" + assert notice =~ "Wire into your existing home screen" + assert notice =~ "root screen" + + # Mentions Logger so the user knows the IEx visibility path. + assert notice =~ "Logger.info" + end + end + + describe "regen composition" do + test "queues mob.regen_driver_tab to run after Igniter applies its changes" do + "audio_engine" + |> add_nif() + |> assert_has_task("mob.regen_driver_tab", []) + end + end + + # ── Helpers ───────────────────────────────────────────────────────────── + + defp add_nif(name, extra_args \\ []) do + test_project() + |> Igniter.compose_task("mob.add_nif", [name | extra_args]) + end + + defp refute_has_issue(igniter), do: assert(igniter.issues == []) +end diff --git a/test/mix/tasks/mob_audit_plugins_test.exs b/test/mix/tasks/mob_audit_plugins_test.exs new file mode 100644 index 0000000..66ce6ac --- /dev/null +++ b/test/mix/tasks/mob_audit_plugins_test.exs @@ -0,0 +1,156 @@ +defmodule Mix.Tasks.Mob.AuditPluginsTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.{Audit, Report} + + @demo_root "/Users/kevin/code/mob_plugin_demo/plugins" + + describe "Phase 1 prototypes — well-behaved plugins" do + @describetag :phase1_prototypes + + test "mob_palette_demo (tier 0) produces zero findings" do + dir = Path.join(@demo_root, "mob_palette_demo") + + if File.exists?(dir) do + assert %{findings: []} = Audit.audit_plugin(dir, %{name: :mob_palette_demo}) + else + :ok + end + end + + test "mob_demo_haptic_extras (tier 1 with C NIF) produces zero findings" do + dir = Path.join(@demo_root, "mob_demo_haptic_extras") + + if File.exists?(dir) do + report = Audit.audit_plugin(dir, %{name: :mob_demo_haptic_extras}) + + assert report.findings == [], + "expected zero findings for the well-behaved haptic_extras plugin, got: " <> + inspect(report.findings, pretty: true) + else + :ok + end + end + + test "mob_demo_signature_pad (tier 2 with Kotlin/Swift) produces zero Elixir/C findings" do + dir = Path.join(@demo_root, "mob_demo_signature_pad") + + if File.exists?(dir) do + report = Audit.audit_plugin(dir, %{name: :mob_demo_signature_pad}) + assert report.findings == [] + assert report.kotlin_or_swift_skipped == true + else + :ok + end + end + + test "audit + render_audit on all three prototypes prints a clean roll-up" do + paths = [ + {"mob_palette_demo", :mob_palette_demo}, + {"mob_demo_haptic_extras", :mob_demo_haptic_extras}, + {"mob_demo_signature_pad", :mob_demo_signature_pad} + ] + + reports = + for {sub, name} <- paths, + dir = Path.join(@demo_root, sub), + File.exists?(dir) do + Audit.audit_plugin(dir, %{name: name}) + end + + if reports == [] do + :ok + else + output = Report.render_audit(reports) + IO.puts("\n--- mix mob.audit_plugins (Phase 1 prototypes) ---\n" <> output) + + # Spot-check shape + assert output =~ "Audit summary" + assert output =~ "scanned" + + for {_sub, name} <- paths do + if Enum.any?(reports, &(&1.plugin == name)) do + assert output =~ to_string(name) + end + end + + # Every prototype should be clean. + assert Audit.exit_code(reports) == 0 + end + end + end + + describe "audit_all/1 + activated_plugins/0" do + test "audit_all/1 returns [] when no plugins are activated" do + # No mob.exs in the mob_dev project itself, and no :mob :plugins env set + # — activated_plugins/0 yields [], so audit_all/1 yields []. + original = Application.get_env(:mob, :plugins, []) + Application.put_env(:mob, :plugins, []) + + try do + assert Mix.Tasks.Mob.AuditPlugins.audit_all() == [] + after + Application.put_env(:mob, :plugins, original) + end + end + end + + describe "render_audit/1 — visual style" do + test "renders a finding line with rule, location, snippet, and hint" do + report = %{ + plugin: :evil_plugin, + findings: [ + %{ + severity: :high, + rule: :code_eval, + plugin: :evil_plugin, + file: "lib/evil.ex", + line: 7, + snippet: "Code.eval_string(...)", + hint: "Arbitrary code execution. Remove this call or justify it in the manifest." + } + ], + summary: %{high: 1, medium: 0, low: 0}, + kotlin_or_swift_skipped: false + } + + out = Report.render_audit([report]) + + assert out =~ "evil_plugin" + assert out =~ "HIGH" + assert out =~ "code_eval" + assert out =~ "lib/evil.ex:7" + assert out =~ "Code.eval_string" + assert out =~ "Arbitrary code execution" + assert out =~ "1 high" + end + + test "renders a 'no findings' line for clean plugins" do + report = %{ + plugin: :clean, + findings: [], + summary: %{high: 0, medium: 0, low: 0}, + kotlin_or_swift_skipped: false + } + + out = Report.render_audit([report]) + assert out =~ "clean — no findings" + end + + test "mentions the Kotlin/Swift skip when applicable" do + report = %{ + plugin: :tier2, + findings: [], + summary: %{high: 0, medium: 0, low: 0}, + kotlin_or_swift_skipped: true + } + + out = Report.render_audit([report]) + assert out =~ "Kotlin/Swift sources present but not yet audited" + end + + test "render_audit/1 with no reports prints a friendly empty message" do + assert Report.render_audit([]) =~ "No plugins audited" + end + end +end diff --git a/test/mix/tasks/mob_battery_bench_ios_test.exs b/test/mix/tasks/mob_battery_bench_ios_test.exs new file mode 100644 index 0000000..4112e35 --- /dev/null +++ b/test/mix/tasks/mob_battery_bench_ios_test.exs @@ -0,0 +1,49 @@ +defmodule Mix.Tasks.Mob.BatteryBenchIosTest do + use ExUnit.Case, async: true + + alias Mix.Tasks.Mob.BatteryBenchIos + + describe "node_matches_prefix?/2" do + test "exact match (physical device, e.g. test_nif_ios@10.0.0.120)" do + assert BatteryBenchIos.node_matches_prefix?(:"test_nif_ios@10.0.0.120", "test_nif_ios") + end + + test "exact match (simulator, e.g. test_nif_ios@127.0.0.1)" do + assert BatteryBenchIos.node_matches_prefix?(:"test_nif_ios@127.0.0.1", "test_nif_ios") + end + + test "matches simulator-with-udid suffix (test_nif_ios_<udid>@host)" do + assert BatteryBenchIos.node_matches_prefix?( + :"test_nif_ios_8a4250e9@127.0.0.1", + "test_nif_ios" + ) + end + + test "rejects different app prefix (mob_qa_ios_*@10.0.0.17)" do + refute BatteryBenchIos.node_matches_prefix?( + :"mob_qa_ios_02628f8f@10.0.0.17", + "test_nif_ios" + ) + end + + test "rejects same-prefix-but-not-_ios (test_nif@host)" do + refute BatteryBenchIos.node_matches_prefix?(:"test_nif@10.0.0.120", "test_nif_ios") + end + + test "rejects superset prefix (test_nif_ios_extras shouldn't match test_nif)" do + # The prefix-with-underscore boundary stops `test_nif_ios` from matching + # the prefix `test_nif` — important so a `test_nif` project doesn't + # accidentally pick up a `test_nif_ios_*` simulator node when it has its + # own `test_nif_ios@<ip>` running. + assert BatteryBenchIos.node_matches_prefix?(:"test_nif_ios@10.0.0.1", "test_nif") + # ^ This is the normal case: test_nif's expected prefix would actually be + # "test_nif_ios", not "test_nif". This test documents that bare app names + # would still match _ios variants — which is fine because the bench + # always passes the full "<app>_ios" prefix. + end + + test "nil node returns false" do + refute BatteryBenchIos.node_matches_prefix?(nil, "anything") + end + end +end diff --git a/test/mix/tasks/mob_cache_test.exs b/test/mix/tasks/mob_cache_test.exs new file mode 100644 index 0000000..430cb29 --- /dev/null +++ b/test/mix/tasks/mob_cache_test.exs @@ -0,0 +1,154 @@ +defmodule Mix.Tasks.Mob.CacheTest do + # async: false because tests mutate process-global env vars (MOB_CACHE_DIR + # and MOB_SIM_RUNTIME_DIR) — running them in parallel with PathsTest would + # race. + use ExUnit.Case, async: false + + alias Mix.Tasks.Mob.Cache + + describe "format_size/1" do + test "bytes" do + assert Cache.format_size(0) == "0 B" + assert Cache.format_size(512) == "512 B" + assert Cache.format_size(1023) == "1023 B" + end + + test "kilobytes" do + assert Cache.format_size(1024) == "1.0 KB" + assert Cache.format_size(1536) == "1.5 KB" + end + + test "megabytes" do + assert Cache.format_size(1024 * 1024) == "1.0 MB" + assert Cache.format_size(round(458.5 * 1024 * 1024)) == "458.5 MB" + end + + test "gigabytes" do + assert Cache.format_size(round(2.5 * 1024 * 1024 * 1024)) == "2.50 GB" + end + end + + describe "our_cache/0" do + test "honors MOB_CACHE_DIR" do + System.put_env("MOB_CACHE_DIR", "/tmp/explicitly_set_cache") + + try do + assert %{path: "/tmp/explicitly_set_cache", kind: :ours} = Cache.our_cache() + after + System.delete_env("MOB_CACHE_DIR") + end + end + + test "falls back to ~/.mob/cache when MOB_CACHE_DIR is unset" do + System.delete_env("MOB_CACHE_DIR") + home = System.user_home!() + assert %{path: path, kind: :ours} = Cache.our_cache() + assert path == Path.join([home, ".mob", "cache"]) + end + end + + describe "elixir_make_cache_path/0" do + test "macOS path layout when on Darwin" do + path = Cache.elixir_make_cache_path() + home = System.user_home!() + + case :os.type() do + {:unix, :darwin} -> + assert path == Path.join([home, "Library", "Caches", "elixir_make"]) + + _ -> + assert path == Path.join([home, ".cache", "elixir_make"]) + end + end + end + + describe "sim_runtime_targets/0" do + test "always lists the new default and the legacy /tmp path" do + System.delete_env("MOB_SIM_RUNTIME_DIR") + targets = Cache.sim_runtime_targets() + paths = Enum.map(targets, & &1.path) + + assert MobDev.Paths.default_runtime_dir() in paths + assert MobDev.Paths.legacy_tmp_path() in paths + end + + test "adds MOB_SIM_RUNTIME_DIR override when it differs from defaults" do + System.put_env("MOB_SIM_RUNTIME_DIR", "/somewhere/exotic") + + try do + targets = Cache.sim_runtime_targets() + paths = Enum.map(targets, & &1.path) + assert "/somewhere/exotic" in paths + # All three present, deduplicated, no duplicates. + assert length(paths) == length(Enum.uniq(paths)) + after + System.delete_env("MOB_SIM_RUNTIME_DIR") + end + end + + test "deduplicates when override matches the default" do + default = MobDev.Paths.default_runtime_dir() + System.put_env("MOB_SIM_RUNTIME_DIR", default) + + try do + targets = Cache.sim_runtime_targets() + paths = Enum.map(targets, & &1.path) + # Default + legacy, no duplicate of default. + assert length(paths) == 2 + assert default in paths + assert MobDev.Paths.legacy_tmp_path() in paths + after + System.delete_env("MOB_SIM_RUNTIME_DIR") + end + end + + test "every target has a name, path, kind, and hint" do + System.delete_env("MOB_SIM_RUNTIME_DIR") + + Enum.each(Cache.sim_runtime_targets(), fn t -> + assert is_binary(t.name) and t.name =~ "iOS simulator runtime" + assert is_binary(t.path) and String.starts_with?(t.path, "/") + assert t.kind == :ours + assert is_binary(t.hint) and byte_size(t.hint) > 0 + end) + end + end + + describe "path_status/1" do + setup do + tmp = + Path.join(System.tmp_dir!(), "mob_cache_test_#{System.unique_integer([:positive])}") + + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp} + end + + test "missing path", %{tmp: tmp} do + assert {false, "(not present)"} = Cache.path_status(Path.join(tmp, "nope")) + end + + test "directory size sums regular files", %{tmp: tmp} do + File.mkdir_p!(tmp) + File.write!(Path.join(tmp, "a"), String.duplicate("x", 1024)) + File.write!(Path.join(tmp, "b"), String.duplicate("y", 2048)) + File.mkdir_p!(Path.join(tmp, "sub")) + File.write!(Path.join([tmp, "sub", "c"]), String.duplicate("z", 4096)) + + assert {true, size_str} = Cache.path_status(tmp) + # 7168 bytes = 7.0 KB + assert size_str == "7.0 KB" + end + + test "single file size", %{tmp: tmp} do + File.mkdir_p!(tmp) + file = Path.join(tmp, "f") + File.write!(file, String.duplicate("x", 100)) + assert {true, "100 B"} = Cache.path_status(file) + end + + test "empty directory reports 0 B", %{tmp: tmp} do + File.mkdir_p!(tmp) + assert {true, "0 B"} = Cache.path_status(tmp) + end + end +end diff --git a/test/mix/tasks/mob_connect_test.exs b/test/mix/tasks/mob_connect_test.exs new file mode 100644 index 0000000..65278c8 --- /dev/null +++ b/test/mix/tasks/mob_connect_test.exs @@ -0,0 +1,36 @@ +defmodule Mix.Tasks.Mob.ConnectTest do + use ExUnit.Case, async: true + + alias Mix.Tasks.Mob.Connect + + test "ensure_iex_started/0 starts the IEx application" do + Application.stop(:iex) + + assert :ok = Connect.ensure_iex_started() + assert {:ok, _} = Application.ensure_all_started(:iex) + end + + describe "resolve_platforms/2" do + test "no flags uses the supplied default" do + assert Connect.resolve_platforms([], [:android, :ios]) == {:ok, [:android, :ios]} + assert Connect.resolve_platforms([], [:ios]) == {:ok, [:ios]} + end + + test "--ios-only restricts to iOS, overriding the default" do + assert Connect.resolve_platforms([ios_only: true], [:android, :ios]) == {:ok, [:ios]} + end + + test "--android-only restricts to Android, overriding the default" do + assert Connect.resolve_platforms([android_only: true], [:android, :ios]) == + {:ok, [:android]} + end + + test "combining both flags is an error" do + assert {:error, message} = + Connect.resolve_platforms([ios_only: true, android_only: true], [:android, :ios]) + + assert message =~ "--ios-only" + assert message =~ "--android-only" + end + end +end diff --git a/test/mix/tasks/mob_deploy_beam_flags_test.exs b/test/mix/tasks/mob_deploy_beam_flags_test.exs new file mode 100644 index 0000000..fafbd10 --- /dev/null +++ b/test/mix/tasks/mob_deploy_beam_flags_test.exs @@ -0,0 +1,2647 @@ +defmodule Mix.Tasks.Mob.DeployBeamFlagsTest do + use ExUnit.Case, async: true + + alias Mix.Tasks.Mob.Deploy + + defp native_lock(serials, overrides \\ %{}) do + serials = Enum.sort(serials) + + target_digest = + serials + |> Enum.join(<<0>>) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + + Map.merge( + %{ + bundle_id: MobDev.Config.bundle_id(), + owner: "0123456789abcdef", + serials: serials, + target_digest: target_digest, + phase: :native_ready, + state: :held_success + }, + overrides + ) + end + + defp payload_plan(serials) do + %{ + version: 1, + package: MobDev.Config.bundle_id(), + serials: Enum.sort(serials), + attempt_id: "0123456789abcdef" + } + end + + defp committed_lock(serials), do: native_lock(serials, %{phase: :final_committed}) + + defp committed_result(result, serials), do: {result, committed_lock(serials)} + + defp native_outcome(serials, overrides \\ %{}) do + Map.merge( + %{ + ok?: true, + android_device_disposition: if(serials == [], do: :not_attempted, else: :held), + android_serials: serials, + android_deploy_lock: if(serials == [], do: nil, else: native_lock(serials)), + android_payload_plan: if(serials == [], do: nil, else: payload_plan(serials)) + }, + overrides + ) + end + + defp successful_finalizer(_lock), do: :ok + + defp physical_ios_device(serial) do + %MobDev.Device{ + platform: :ios, + serial: serial, + type: :physical, + status: :discovered, + error: nil + } + end + + defp ios_simulator(serial) do + %MobDev.Device{ + platform: :ios, + serial: serial, + type: :simulator, + status: :booted, + error: nil + } + end + + defp android_device(serial, type \\ :physical) do + %MobDev.Device{ + platform: :android, + serial: serial, + type: type, + status: :discovered, + error: nil + } + end + + defp no_device_match_pattern do + Regex.compile!("No device matched.*mix mob\\.devices", "s") + end + + describe "resolve_target_platforms!/4" do + test "rejects a CoreDevice-shaped identifier with the default platform list" do + hardware_udid = "00008110-001E1C3A34F8401E" + core_device_id = "11111111-2222-3333-4444-555555555555" + target = physical_ios_device(hardware_udid) + + error = + assert_raise Mix.Error, fn -> + Deploy.resolve_target_platforms!( + [:android, :ios], + core_device_id, + fn -> [android_device("emulator-5554", :emulator)] end, + fn -> [target] end + ) + end + + assert error.message == + ~s(No device matched "#{core_device_id}". Run `mix mob.devices` to see available device IDs.) + end + + test "rejects an arbitrary unknown identifier with the default platform list" do + android = android_device("ZY22CRLMWK") + ios = physical_ios_device("00008110-001E1C3A34F8401E") + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:android, :ios], + "not-a-device", + fn -> [android] end, + fn -> [ios] end + ) + end + end + + test "rejects an explicit selection when discovery is empty" do + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!([:android, :ios], "not-a-device", fn -> [] end, fn -> + [] + end) + end + end + + test "does not infer a CoreDevice identifier across multiple physical devices" do + devices = [ + physical_ios_device("00008110-001E1C3A34F8401E"), + physical_ios_device("00008120-001A2B3C4D5E6F78") + ] + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:android, :ios], + "11111111-2222-3333-4444-555555555555", + fn -> [] end, + fn -> devices end + ) + end + end + + test "accepts the exact hardware UDID among multiple physical devices" do + hardware_udid = "00008110-001E1C3A34F8401E" + + devices = [ + physical_ios_device(hardware_udid), + physical_ios_device("00008120-001A2B3C4D5E6F78") + ] + + assert Deploy.resolve_target_platforms!( + [:android, :ios], + hardware_udid, + fn -> [] end, + fn -> devices end + ) == + [:ios] + end + + test "accepts documented Android device identifiers" do + for {id, device} <- [ + {"ZY22CRLMWK", android_device("ZY22CRLMWK")}, + {"emulator-5554", android_device("emulator-5554", :emulator)}, + {"10.0.0.17", android_device("10.0.0.17:5555")}, + {"10.0.0.17:5555", android_device("10.0.0.17:5555")} + ] do + assert Deploy.resolve_target_platforms!( + [:android, :ios], + id, + fn -> [device] end, + fn -> [] end + ) == [:android] + end + end + + test "rejects identifiers that contradict an explicit platform" do + hardware_udid = "00008110-001E1C3A34F8401E" + ios = physical_ios_device(hardware_udid) + android = android_device("ZY22CRLMWK") + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:android], + hardware_udid, + fn -> [android] end, + fn -> [ios] end + ) + end + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:ios], + android.serial, + fn -> [android] end, + fn -> [ios] end + ) + end + end + + test "fails closed when the same identifier appears in both inventories" do + id = "shared-id" + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:android, :ios], + id, + fn -> [android_device(id)] end, + fn -> [physical_ios_device(id)] end + ) + end + end + + test "fails closed on case-insensitive Android serial collisions" do + id = "r5cw3089hvb" + + devices = [ + android_device("R5CW3089HVB"), + android_device("r5cw3089hvb") + ] + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:android, :ios], + id, + fn -> devices end, + fn -> [] end + ) + end + end + + test "fails closed when a bare IP matches multiple WiFi ADB serials" do + id = "10.0.0.17" + + devices = [ + android_device("10.0.0.17:5555"), + android_device("10.0.0.17:4444") + ] + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:android, :ios], + id, + fn -> devices end, + fn -> [] end + ) + end + end + + test "fails closed on iOS simulator display-ID collisions" do + id = "12345678" + + devices = [ + ios_simulator("12345678-ABCD-1234-ABCD-1234567890AB"), + ios_simulator("12345678-EF01-5678-EF01-1234567890AB") + ] + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.resolve_target_platforms!( + [:android, :ios], + id, + fn -> [] end, + fn -> devices end + ) + end + end + end + + describe "run/2 explicit device preflight" do + test "rejects invalid native-ready recovery intent before discovery or orchestration" do + parent = self() + + callbacks = [ + android_lister: fn -> send(parent, :android_discovery_called) end, + ios_lister: fn -> send(parent, :ios_discovery_called) end, + orchestrator: fn _opts, _platforms, _device_id -> + send(parent, :orchestration_called) + end + ] + + invalid_args = [ + ["--resume-native-ready", "--android", "--device", "serial-a"], + ["--resume-native-ready", "--native", "--device", "serial-a"], + ["--resume-native-ready", "--native", "--android"], + [ + "--resume-native-ready", + "--native", + "--android", + "--device", + "serial-a", + "--no-restart" + ] + ] + + for args <- invalid_args do + assert_raise Mix.Error, + "--resume-native-ready requires --native --android --device <exact-id> and restart", + fn -> Deploy.run(args, callbacks) end + end + + refute_received :android_discovery_called + refute_received :ios_discovery_called + refute_received :orchestration_called + end + + test "passes only constrained recovery intent to orchestration after exact discovery" do + parent = self() + id = "serial-a" + + callbacks = [ + android_lister: fn -> [android_device(id)] end, + ios_lister: fn -> [] end, + orchestrator: fn opts, platforms, device_id -> + send(parent, {:recovery_orchestration, opts, platforms, device_id}) + :orchestrated + end + ] + + assert Deploy.run( + ["--resume-native-ready", "--native", "--android", "--device", id], + callbacks + ) == :orchestrated + + assert_received {:recovery_orchestration, opts, [:android], ^id} + assert opts[:resume_native_ready] + assert opts[:native] + refute Keyword.has_key?(opts, :recovery_proof) + end + + test "does not enter orchestration for unmatched, mismatched, or ambiguous IDs" do + parent = self() + android = android_device("emulator-5554", :emulator) + ios = physical_ios_device("00008110-001E1C3A34F8401E") + + cases = [ + {["--android", "--ios"], "11111111-2222-3333-4444-555555555555", [android], [ios]}, + {["--android", "--ios"], "not-a-device", [android], [ios]}, + {["--android"], ios.serial, [android], [ios]}, + {["--android", "--ios"], "r5cw3089hvb", + [android_device("R5CW3089HVB"), android_device("r5cw3089hvb")], []}, + {["--android", "--ios"], "10.0.0.17", + [android_device("10.0.0.17:5555"), android_device("10.0.0.17:4444")], []}, + {["--android", "--ios"], "12345678", [], + [ + ios_simulator("12345678-ABCD-1234-ABCD-1234567890AB"), + ios_simulator("12345678-EF01-5678-EF01-1234567890AB") + ]} + ] + + for {platform_args, id, android_devices, ios_devices} <- cases do + ref = make_ref() + + callbacks = [ + android_lister: fn -> android_devices end, + ios_lister: fn -> ios_devices end, + orchestrator: fn _opts, _platforms, _device_id -> + send(parent, {ref, :orchestration_called}) + end + ] + + assert_raise Mix.Error, no_device_match_pattern(), fn -> + Deploy.run(platform_args ++ ["--native", "--device", id], callbacks) + end + + # The production orchestrator owns flag/config writes, compatibility, + # dependency fetching, compilation, native build/install, and deploy. + refute_received {^ref, :orchestration_called} + end + end + + test "enters the injected orchestrator after an authoritative match" do + parent = self() + id = "emulator-5554" + + callbacks = [ + android_lister: fn -> [android_device(id, :emulator)] end, + ios_lister: fn -> [] end, + orchestrator: fn opts, platforms, device_id -> + send(parent, {:orchestration_called, opts, platforms, device_id}) + :orchestrated + end + ] + + assert Deploy.run(["--android", "--ios", "--native", "--device", id], callbacks) == + :orchestrated + + assert_received {:orchestration_called, opts, [:android], ^id} + assert opts[:native] + end + + test "enters the injected orchestrator for WiFi ADB selectors" do + parent = self() + + cases = [ + {"10.0.0.17", [android_device("10.0.0.17:5555")]}, + {"10.0.0.17:5555", [android_device("10.0.0.17:5555"), android_device("10.0.0.17:4444")]} + ] + + for {id, devices} <- cases do + ref = make_ref() + + callbacks = [ + android_lister: fn -> devices end, + ios_lister: fn -> [] end, + orchestrator: fn opts, platforms, device_id -> + send(parent, {ref, :orchestration_called, opts, platforms, device_id}) + :orchestrated + end + ] + + assert Deploy.run(["--android", "--ios", "--native", "--device", id], callbacks) == + :orchestrated + + assert_received {^ref, :orchestration_called, opts, [:android], ^id} + assert opts[:native] + end + end + end + + describe "with_android_native_host_lock/3" do + test "ordinary native Android deploy excludes a concurrent recovery operation" do + bundle = MobDev.Config.bundle_id() + + assert :ordinary_complete = + Deploy.with_android_native_host_lock(true, [:android], fn -> + assert {:error, :recovery_host_lock_unavailable} = + Task.async(fn -> + MobDev.AndroidDeployRecoveryProof.with_host_lock(bundle, fn -> + :unexpected_recovery + end) + end) + |> Task.await() + + :ordinary_complete + end) + end + + test "non-native and iOS-only operations do not claim the Android host lock" do + operation = fn -> :unlocked end + assert Deploy.with_android_native_host_lock(false, [:android], operation) == :unlocked + assert Deploy.with_android_native_host_lock(true, [:ios], operation) == :unlocked + end + end + + # ── combine_beam_flags/2 ────────────────────────────────────────────────────── + + describe "combine_beam_flags/2" do + test "nil/nil returns nil (read cached value from mob.exs)" do + assert Deploy.combine_beam_flags(nil, nil) == nil + end + + test "schedulers only" do + assert Deploy.combine_beam_flags(2, nil) == "-S 2:2" + end + + test "schedulers 0 means BEAM auto-detect (one per core)" do + assert Deploy.combine_beam_flags(0, nil) == "-S 0:0" + end + + test "schedulers 1 pins to single scheduler" do + assert Deploy.combine_beam_flags(1, nil) == "-S 1:1" + end + + test "flags string only" do + assert Deploy.combine_beam_flags(nil, "-sbwt none") == "-sbwt none" + end + + test "trims whitespace from flags string" do + assert Deploy.combine_beam_flags(nil, " -sbwt none ") == "-sbwt none" + end + + test "schedulers + flags combined" do + assert Deploy.combine_beam_flags(4, "-A 4") == "-S 4:4 -A 4" + end + + test "schedulers + flags trims the flags string" do + assert Deploy.combine_beam_flags(2, " -A 2 ") == "-S 2:2 -A 2" + end + end + + # ── update_beam_flags_in_config/2 ──────────────────────────────────────────── + + describe "update_beam_flags_in_config/2" do + test "appends beam_flags line when key is absent" do + content = """ + import Config + + config :mob_dev, + mob_dir: "/path/to/mob" + """ + + updated = Deploy.update_beam_flags_in_config(content, "-S 2:2") + assert updated =~ ~s(config :mob_dev, beam_flags: "-S 2:2") + assert updated =~ ~r/mob_dir:/ + end + + test "replaces existing beam_flags value" do + content = """ + import Config + + config :mob_dev, + mob_dir: "/path/to/mob", + beam_flags: "-S 1:1" + """ + + updated = Deploy.update_beam_flags_in_config(content, "-S 4:4") + assert updated =~ ~s(beam_flags: "-S 4:4") + refute updated =~ "-S 1:1" + end + + test "replace preserves other keys on surrounding lines" do + content = """ + import Config + + config :mob_dev, + mob_dir: "/path/to/mob", + beam_flags: "-S 1:1", + elixir_lib: "/path/to/elixir" + """ + + updated = Deploy.update_beam_flags_in_config(content, "-S 0:0") + assert updated =~ ~r/mob_dir:/ + assert updated =~ ~r/elixir_lib:/ + assert updated =~ ~s(beam_flags: "-S 0:0") + refute updated =~ "-S 1:1" + end + + test "does not create a duplicate beam_flags key on repeated calls" do + content = """ + import Config + + config :mob_dev, + beam_flags: "-S 1:1" + """ + + updated = Deploy.update_beam_flags_in_config(content, "-S 2:2") + count = updated |> String.split("beam_flags:") |> length() |> Kernel.-(1) + assert count == 1 + end + + test "flags value is properly quoted with inspect/1" do + updated = Deploy.update_beam_flags_in_config("config :mob_dev,\n x: 1\n", "-S 2:2 -A 4") + assert updated =~ ~s(beam_flags: "-S 2:2 -A 4") + end + end + + # ── format_summary/4 — deploy report rendering ──────────────────────────────── + # + # Pin the report shape against regressions. Original bug: devices + # without the app installed were tallied as "Failed on N device(s)" + # in red. The fix introduced a separate "Skipped on N device(s)" + # bucket; these tests assert that the three categories render + # distinctly, that skipped never bleeds into failed (or vice-versa), + # and that the empty-everything case still emits the right hint. + + describe "format_summary/4" do + defp device(name, error \\ nil), + do: %MobDev.Device{name: name, serial: name, platform: :android, error: error} + + defp strip_ansi(line), do: String.replace(line, ~r/\e\[[0-9;]*m/, "") + + test "all three buckets empty → 'No devices found' hint" do + lines = Deploy.format_summary([], [], []) + + joined = lines |> Enum.map(&strip_ansi/1) |> Enum.join("\n") + assert joined =~ "No devices found." + assert joined =~ "mix mob.devices" + refute joined =~ "Deployed" + refute joined =~ "Skipped" + refute joined =~ "Failed" + end + + test "only deployed → green deployed header + restart hint when :restart true" do + lines = Deploy.format_summary([device("iPhone")], [], [], restart: true) + + joined = lines |> Enum.map(&strip_ansi/1) |> Enum.join("\n") + assert joined =~ "Deployed to 1 device(s)" + assert joined =~ "Apps restarted" + assert joined =~ "mix mob.connect" + refute joined =~ "Skipped" + refute joined =~ "Failed" + end + + test "only deployed with :restart false → nl(MyModule) hint" do + lines = Deploy.format_summary([device("iPhone")], [], [], restart: false) + joined = lines |> Enum.map(&strip_ansi/1) |> Enum.join("\n") + + assert joined =~ "BEAMs pushed" + assert joined =~ "nl(MyModule)" + refute joined =~ "Apps restarted" + end + + test "only skipped → yellow informational, NOT counted as failed" do + # Regression: this case used to print "Failed on 1 device(s)" in red. + skip = device("emulator-5554", "com.example not installed on emulator-5554") + lines = Deploy.format_summary([], [], [skip]) + + joined = lines |> Enum.map(&strip_ansi/1) |> Enum.join("\n") + assert joined =~ "Skipped on 1 device(s)" + assert joined =~ "app not installed" + assert joined =~ "build for that platform with --android / --ios" + refute joined =~ "Failed on", "skipped must NOT trigger the Failed header" + end + + test "only failed → red Failed header with x markers per device" do + lines = Deploy.format_summary([], [device("buggy", "push timed out")], []) + + joined = lines |> Enum.map(&strip_ansi/1) |> Enum.join("\n") + assert joined =~ "Failed on 1 device(s)" + assert joined =~ "✗ buggy: push timed out" + refute joined =~ "Skipped" + end + + test "mixed: deployed + skipped + failed all render in distinct blocks" do + ok = device("iPhone") + skip = device("emulator-5554", "not installed") + fail = device("emulator-5556", "adb push failed: broken pipe") + + lines = Deploy.format_summary([ok], [fail], [skip]) + joined = lines |> Enum.map(&strip_ansi/1) |> Enum.join("\n") + + assert joined =~ "Deployed to 1 device(s)" + assert joined =~ "Skipped on 1 device(s)" + assert joined =~ "Failed on 1 device(s)" + assert joined =~ "✗ emulator-5556" + # Skipped row uses the — marker, not ✗ — pin that distinction. + assert joined =~ "— emulator-5554: not installed" + end + + test "5-androids-skipped scenario from the original bug report" do + # The flow that surfaced this: `mix mob.deploy --native` auto- + # detected iPhone, built iOS only, swept BEAM push to all + # connected devices. Five Androids didn't have the app and + # showed up as failures. + iphone = device("iPhone") + androids = for i <- 1..5, do: device("emulator-#{i}", "not installed (ABI mismatch)") + + lines = Deploy.format_summary([iphone], [], androids) + joined = lines |> Enum.map(&strip_ansi/1) |> Enum.join("\n") + + assert joined =~ "Deployed to 1 device(s)" + assert joined =~ "Skipped on 5 device(s)" + refute joined =~ "Failed", "Bug fix: 5 not-installed devices must NOT count as failed" + end + end + + describe "execute_native_deploy!/6" do + test "mixed native work commits and releases Android before building or mutating iOS" do + {:ok, events} = Agent.start_link(fn -> [] end) + serial = "serial-a" + ios_id = "00000000-0000000000000000" + ios_target = physical_ios_device(ios_id) + + record = fn event -> Agent.update(events, &(&1 ++ [event])) end + + builder = fn opts -> + record.({:build, opts}) + + case opts[:platforms] do + [:android] -> native_outcome([serial]) + [:ios] -> native_outcome([]) + end + end + + deployer = fn opts -> + record.({:deploy, opts}) + + case opts[:platforms] do + [:android] -> + committed_result( + {[ + %MobDev.Device{platform: :android, serial: serial, status: :connected} + ], [], []}, + [serial] + ) + + [:ios] -> + {[ios_target], [], []} + end + end + + finalizer = fn lock -> + record.({:release, lock}) + :ok + end + + cleanup = fn plan -> + record.({:cleanup, plan}) + :ok + end + + assert {deployed, [], []} = + Deploy.execute_native_deploy!( + [:android, :ios], + nil, + ios_id, + [ + slim: false, + android_preinstall: fn _context -> :unused end, + android_preinstall_cleanup: fn _plan -> :unused end + ], + [ + restart: true, + force_fs: true, + ios_lister: fn -> [ios_target] end + ], + builder: builder, + deployer: deployer, + finalizer: finalizer, + cleanup: cleanup + ) + + assert Enum.map(deployed, &{&1.platform, &1.serial}) == [ + {:android, serial}, + {:ios, ios_id} + ] + + assert [ + {:build, android_build_opts}, + {:deploy, android_deploy_opts}, + {:release, released_lock}, + {:cleanup, cleaned_plan}, + {:build, ios_build_opts}, + {:deploy, ios_deploy_opts} + ] = Agent.get(events, & &1) + + assert %{ + platforms: [:android], + device: nil, + device_phase: true, + preinstall_arity: {:arity, 1}, + deploy_platforms: [:android], + deploy_device: nil, + has_ios_device?: false + } == %{ + platforms: android_build_opts[:platforms], + device: android_build_opts[:device], + device_phase: android_build_opts[:android_device_phase], + preinstall_arity: Function.info(android_build_opts[:android_preinstall], :arity), + deploy_platforms: android_deploy_opts[:platforms], + deploy_device: android_deploy_opts[:device], + has_ios_device?: Keyword.has_key?(android_deploy_opts, :ios_device) + } + + assert released_lock == committed_lock([serial]) + assert cleaned_plan == payload_plan([serial]) + + assert %{ + build_platforms: [:ios], + build_device: ios_id, + device_phase: false, + has_preinstall?: false, + has_preinstall_cleanup?: false, + deploy_platforms: [:ios], + deploy_ios_device: ios_id, + deploy_device: nil, + has_android_serials?: false, + has_android_lock?: false, + has_android_payload?: false + } == %{ + build_platforms: ios_build_opts[:platforms], + build_device: ios_build_opts[:device], + device_phase: ios_build_opts[:android_device_phase], + has_preinstall?: Keyword.has_key?(ios_build_opts, :android_preinstall), + has_preinstall_cleanup?: + Keyword.has_key?(ios_build_opts, :android_preinstall_cleanup), + deploy_platforms: ios_deploy_opts[:platforms], + deploy_ios_device: ios_deploy_opts[:ios_device], + deploy_device: ios_deploy_opts[:device], + has_android_serials?: + Keyword.has_key?(ios_deploy_opts, :canonical_android_serials), + has_android_lock?: Keyword.has_key?(ios_deploy_opts, :android_deploy_lock), + has_android_payload?: Keyword.has_key?(ios_deploy_opts, :android_payload_plan) + } + end + + test "cleanup errors and exceptions turn Android non-green before every iOS callback" do + events = start_supervised!({Agent, fn -> [] end}) + serial = "serial-a" + + for cleanup_failure <- [:error, :raise] do + Agent.update(events, fn _events -> [] end) + record = fn event -> Agent.update(events, &(&1 ++ [event])) end + + builder = fn opts -> + record.({:build, opts[:platforms]}) + native_outcome([serial]) + end + + deployer = fn opts -> + record.({:deploy, opts[:platforms]}) + + committed_result( + {[ + %MobDev.Device{platform: :android, serial: serial, status: :connected} + ], [], []}, + [serial] + ) + end + + finalizer = fn lock -> + record.({:release, lock.phase}) + :ok + end + + cleanup = fn plan -> + record.({:cleanup, plan.attempt_id}) + + case cleanup_failure do + :error -> {:error, :injected_cleanup_failure} + :raise -> raise "injected cleanup failure" + end + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error} = failure], []} = + Deploy.execute_native_deploy!( + [:android, :ios], + nil, + "ios-device", + [], + [restart: true], + builder: builder, + deployer: deployer, + finalizer: finalizer, + cleanup: cleanup + ) + + assert failure.error == "Native Android payload cleanup failed" + + assert Agent.get(events, & &1) == [ + {:build, [:android]}, + {:deploy, [:android]}, + {:release, :final_committed}, + {:cleanup, "0123456789abcdef"} + ] + end + end + + test "a malformed cleanup result makes an Android-only deploy non-green exactly once" do + parent = self() + serial = "serial-a" + + builder = fn _opts -> native_outcome([serial]) end + + deployer = fn _opts -> + committed_result( + {[ + %MobDev.Device{platform: :android, serial: serial, status: :connected} + ], [], []}, + [serial] + ) + end + + cleanup = fn plan -> + send(parent, {:cleanup, plan.attempt_id}) + :malformed_cleanup_reply + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.execute_native_deploy!( + [:android], + nil, + nil, + [], + [restart: true], + builder: builder, + deployer: deployer, + finalizer: &successful_finalizer/1, + cleanup: cleanup + ) + + assert_received {:cleanup, "0123456789abcdef"} + refute_received {:cleanup, _attempt_id} + end + + test "Android failures and exceptions clean once without masking the primary failure" do + parent = self() + serial = "serial-a" + builder = fn _opts -> native_outcome([serial]) end + + failed_deployer = fn _opts -> + { + {[], + [ + %MobDev.Device{ + platform: :android, + serial: serial, + status: :error, + error: "primary Android failure" + } + ], []}, + native_lock([serial], %{state: :retained_failure}) + } + end + + failed_cleanup = fn plan -> + send(parent, {:failed_cleanup, plan.attempt_id}) + {:error, :secondary_cleanup_failure} + end + + assert {[], [%MobDev.Device{error: "primary Android failure"}], []} = + Deploy.execute_native_deploy!( + [:android, :ios], + nil, + "ios-device", + [], + [restart: true], + builder: builder, + deployer: failed_deployer, + finalizer: fn _lock -> flunk("failed Android must not release") end, + cleanup: failed_cleanup + ) + + assert_received {:failed_cleanup, "0123456789abcdef"} + refute_received {:failed_cleanup, _attempt_id} + + raising_deployer = fn _opts -> raise "primary deploy exception" end + + raising_cleanup = fn plan -> + send(parent, {:raising_cleanup, plan.attempt_id}) + raise "secondary cleanup exception" + end + + assert_raise RuntimeError, "primary deploy exception", fn -> + Deploy.execute_native_deploy!( + [:android, :ios], + nil, + "ios-device", + [], + [restart: true], + builder: builder, + deployer: raising_deployer, + finalizer: fn _lock -> flunk("raising Android must not release") end, + cleanup: raising_cleanup + ) + end + + assert_received {:raising_cleanup, "0123456789abcdef"} + refute_received {:raising_cleanup, _attempt_id} + end + + test "an uncommitted Android result suppresses every iOS callback" do + parent = self() + serial = "serial-a" + + builder = fn opts -> + send(parent, {:build, opts[:platforms]}) + native_outcome([serial]) + end + + deployer = fn opts -> + send(parent, {:deploy, opts[:platforms]}) + + { + {[], + [ + %MobDev.Device{ + platform: :android, + serial: serial, + status: :error, + error: "injected failure" + } + ], []}, + native_lock([serial], %{state: :retained_failure}) + } + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.execute_native_deploy!( + [:android, :ios], + nil, + "ios-device", + [], + [restart: true], + builder: builder, + deployer: deployer, + finalizer: fn _lock -> flunk("uncommitted lease must not release") end, + cleanup: fn _plan -> :ok end + ) + + assert_received {:build, [:android]} + assert_received {:deploy, [:android]} + refute_received {:build, [:ios]} + refute_received {:deploy, [:ios]} + end + + test "an explicitly not-attempted Android phase permits the independent iOS lane" do + {:ok, events} = Agent.start_link(fn -> [] end) + ios_id = "ios-device" + ios_target = physical_ios_device(ios_id) + + builder = fn opts -> + Agent.update(events, &(&1 ++ [{:build, opts[:platforms]}])) + + case opts[:platforms] do + [:android] -> + %{ + ok?: false, + android_device_disposition: :not_attempted, + android_serials: [], + android_deploy_lock: nil, + android_payload_plan: nil + } + + [:ios] -> + native_outcome([]) + end + end + + deployer = fn opts -> + Agent.update(events, &(&1 ++ [{:deploy, opts[:platforms]}])) + + {[ios_target], [], []} + end + + assert {[%MobDev.Device{platform: :ios, serial: ^ios_id}], [], []} = + Deploy.execute_native_deploy!( + [:android, :ios], + nil, + ios_id, + [], + [restart: true, ios_lister: fn -> [ios_target] end], + builder: builder, + deployer: deployer, + finalizer: fn _lock -> flunk("no Android authority exists to release") end, + cleanup: fn _plan -> :ok end + ) + + assert Agent.get(events, & &1) == [ + {:build, [:android]}, + {:build, [:ios]}, + {:deploy, [:ios]} + ] + end + + test "iOS target selection is frozen before the native builder runs" do + parent = self() + full_id = "78354490-EF38-44D7-A437-DD941C20524D" + target = ios_simulator(full_id) + + lister = fn -> + send(parent, :original_ios_lister_called) + [target] + end + + builder = fn opts -> + send(parent, {:builder_called, opts}) + native_outcome([]) + end + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + assert opts[:ios_lister].() == [target] + {[target], [], []} + end + + assert {[^target], [], []} = + Deploy.execute_native_deploy!( + [:ios], + nil, + "78354490", + [], + [restart: true, ios_lister: lister], + builder: builder, + deployer: deployer, + finalizer: fn _lock -> flunk("iOS must not release Android state") end, + cleanup: fn _plan -> flunk("iOS must not clean Android state") end + ) + + assert_received :original_ios_lister_called + refute_received :original_ios_lister_called + + assert_received {:builder_called, builder_opts} + assert builder_opts[:device] == full_id + assert builder_opts[:platforms] == [:ios] + + assert_received {:deployer_called, deployer_opts} + assert deployer_opts[:ios_device] == full_id + assert deployer_opts[:device] == nil + end + + test "ambiguous iOS target selection fails before native build or deploy mutation" do + parent = self() + + first = ios_simulator("78354490-EF38-44D7-A437-DD941C20524D") + second = ios_simulator("78354490-A111-4D7A-B222-DD941C20524D") + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.execute_native_deploy!( + [:ios], + nil, + "78354490", + [], + [restart: true, ios_lister: fn -> [first, second] end], + builder: fn _opts -> send(parent, :builder_called) end, + deployer: fn _opts -> send(parent, :deployer_called) end, + finalizer: fn _lock -> send(parent, :finalizer_called) end, + cleanup: fn _plan -> send(parent, :cleanup_called) end + ) + end) + end + + refute_received :builder_called + refute_received :deployer_called + refute_received :finalizer_called + refute_received :cleanup_called + end + end + + describe "deploy_after_native_build!/4" do + test "aggregate native failure raises before the final Deployer pass" do + parent = self() + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + {[], [], []} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + %{ok?: false, android_serials: []}, + [device: "serial-a"], + deployer + ) + end) + end + + refute_received {:deployer_called, _} + end + + test "missing native result also fails closed before the final Deployer pass" do + parent = self() + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + {[], [], []} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!(true, nil, [device: "serial-a"], deployer) + end) + end + + refute_received {:deployer_called, _} + end + + test "changing discovery snapshot cannot widen the canonical Android allowlist" do + parent = self() + discovered_after_build = ["serial-a", "serial-b", "late-device"] + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + + deployed = + discovered_after_build + |> Enum.filter(&(&1 in opts[:canonical_android_serials])) + |> Enum.map(&%MobDev.Device{serial: &1, platform: :android, status: :connected}) + + committed_result({deployed, [], []}, ["serial-a", "serial-b"]) + end + + assert {deployed, [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome(["serial-a", "serial-b"]), + [platforms: [:android], device: nil, restart: true], + deployer, + &successful_finalizer/1, + fn _plan -> :ok end + ) + + assert Enum.map(deployed, & &1.serial) == ["serial-a", "serial-b"] + + assert_receive {:deployer_called, android_opts} + assert android_opts[:platforms] == [:android] + assert android_opts[:canonical_android_serials] == ["serial-a", "serial-b"] + refute Keyword.has_key?(android_opts, :device) + assert android_opts[:restart] + + refute_received {:deployer_called, _} + end + + test "unsorted implicit ADB discovery stays canonical through held outcome and release" do + parent = self() + + runner = fn "adb", ["devices"] -> + {"List of devices attached\nserial-b\tdevice\nserial-a\tdevice\n", 0} + end + + assert {:ok, ["serial-a", "serial-b"] = serials} = + MobDev.NativeBuild.resolve_android_update_targets(nil, runner) + + native_outcome = + MobDev.NativeBuild.build_outcome([ + {:ok, "Android", + %{ + serials: serials, + deploy_lock: native_lock(serials), + payload_plan: payload_plan(serials) + }} + ]) + + assert native_outcome.android_device_disposition == :held + assert native_outcome.android_serials == serials + + deployer = fn opts -> + send(parent, {:canonical_serials, opts[:canonical_android_serials]}) + + devices = + Enum.map(opts[:canonical_android_serials], fn serial -> + %MobDev.Device{platform: :android, serial: serial, status: :connected} + end) + + committed_result({devices, [], []}, serials) + end + + finalizer = fn lock -> + send(parent, {:released_serials, lock.serials}) + :ok + end + + assert {deployed, [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome, + [platforms: [:android], restart: true], + deployer, + finalizer, + fn _plan -> :ok end + ) + + assert Enum.map(deployed, & &1.serial) == serials + assert_received {:canonical_serials, ^serials} + assert_received {:released_serials, ^serials} + end + + test "canonical WiFi serial replaces the user alias in the final Android pass" do + parent = self() + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + + serials = opts[:canonical_android_serials] + + committed_result( + {[ + %MobDev.Device{ + serial: hd(serials), + platform: :android, + status: :connected + } + ], [], []}, + serials + ) + end + + assert {[%MobDev.Device{serial: "10.0.0.17:5555"}], [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome(["10.0.0.17:5555"]), + [platforms: [:android], device: "10.0.0.17"], + deployer, + &successful_finalizer/1, + fn _plan -> :ok end + ) + + assert_receive {:deployer_called, opts} + assert opts[:platforms] == [:android] + assert opts[:canonical_android_serials] == ["10.0.0.17:5555"] + refute Keyword.has_key?(opts, :device) + + refute_received {:deployer_called, _} + end + + test "canonical native Android skip or missing result becomes a failure" do + skipped = %MobDev.Device{ + serial: "serial-a", + platform: :android, + status: :skipped, + error: "app absent" + } + + for result <- [{[], [], [skipped]}, {[], [], []}] do + deployer = fn _opts -> result end + + assert {[], [%MobDev.Device{serial: "serial-a", status: :error} = failed], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome(["serial-a"]), + [platforms: [:android]], + deployer, + &successful_finalizer/1 + ) + + assert failed.error =~ "Native Android target" + end + end + + test "canonical native Android rejects duplicate, wrong-platform, and extra results" do + canonical = %MobDev.Device{serial: "serial-a", platform: :android, status: :connected} + duplicate = %{canonical | name: "duplicate"} + wrong_platform = %MobDev.Device{serial: "serial-a", platform: :ios, status: :connected} + extra = %MobDev.Device{serial: "serial-b", platform: :android, status: :connected} + + for result <- [ + {[canonical, duplicate], [], []}, + {[wrong_platform], [], []}, + {[canonical, extra], [], []} + ] do + deployer = fn _opts -> result end + + {_deployed, failed, []} = + Deploy.deploy_after_native_build!( + true, + native_outcome(["serial-a"]), + [platforms: [:android]], + deployer, + &successful_finalizer/1 + ) + + assert failed != [] + assert Enum.all?(failed, &(&1.status == :error)) + end + end + + test "malformed callback bucket members become accounted failures without release" do + parent = self() + serial = "serial-a" + + deployer = fn _opts -> {{[:not_a_device], [], []}, committed_lock([serial])} end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android], restart: true], + deployer, + finalizer, + fn _plan -> :ok end + ) + + refute_received :finalizer_called + end + + test "improper Android result buckets become accounted failures without release or iOS" do + parent = self() + serial = "serial-a" + + deployed = %MobDev.Device{ + platform: :android, + serial: serial, + status: :connected, + error: nil + } + + failed = %{deployed | status: :error, error: "reported target error"} + skipped = %{deployed | status: :skipped, error: "reported target skip"} + + improper_results = [ + {:deployed, {[deployed | :malformed_tail], [], []}}, + {:failed, {[], [failed | :malformed_tail], []}}, + {:skipped, {[], [], [skipped | :malformed_tail]}} + ] + + Enum.each(improper_results, fn {bucket, result} -> + Enum.each([:plain, :wrapped], fn shape -> + attempt = make_ref() + + deployer = fn opts -> + case opts[:platforms] do + [:android] -> + send(parent, {attempt, :android_deployed, bucket, shape}) + + if shape == :wrapped, + do: {result, committed_lock([serial])}, + else: result + + [:ios] -> + send(parent, {attempt, :ios_deployed}) + {[], [], []} + end + end + + finalizer = fn lock -> + send(parent, {attempt, :released, lock}) + :ok + end + + cleanup = fn plan -> + send(parent, {attempt, :cleaned, plan}) + :ok + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android, :ios], restart: true, ios_device: "ios-device"], + deployer, + finalizer, + cleanup + ) + + assert_receive {^attempt, :android_deployed, ^bucket, ^shape} + refute_receive {^attempt, :released, _lock} + refute_receive {^attempt, :ios_deployed} + assert_receive {^attempt, :cleaned, cleaned_plan} + assert cleaned_plan == payload_plan([serial]) + refute_receive {^attempt, :cleaned, _plan} + end) + end) + end + + test "an error-status device in the deployed bucket cannot release or start iOS" do + parent = self() + serial = "serial-a" + + deployer = fn opts -> + case opts[:platforms] do + [:android] -> + committed_result( + {[ + %MobDev.Device{ + serial: serial, + platform: :android, + status: :error, + error: "injected failure" + } + ], [], []}, + [serial] + ) + + [:ios] -> + send(parent, :ios_deployed) + {[], [], []} + end + end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android, :ios], restart: true], + deployer, + finalizer, + fn _plan -> :ok end + ) + + refute_received :finalizer_called + refute_received :ios_deployed + end + + test "authoritative Android deployed statuses release the exact held lease" do + parent = self() + serial = "serial-a" + + for status <- [:discovered, :connected, :tunneled] do + attempt = make_ref() + + deployer = fn _opts -> + committed_result( + {[ + %MobDev.Device{ + serial: serial, + platform: :android, + status: status, + error: nil + } + ], [], []}, + [serial] + ) + end + + finalizer = fn lock -> + send(parent, {attempt, :released, lock}) + :ok + end + + cleanup = fn plan -> + send(parent, {attempt, :cleaned, plan}) + :ok + end + + assert {[%MobDev.Device{serial: ^serial, status: ^status, error: nil}], [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android], restart: true], + deployer, + finalizer, + cleanup + ) + + assert_receive {^attempt, :released, released_lock} + assert released_lock == committed_lock([serial]) + assert_receive {^attempt, :cleaned, cleaned_plan} + assert cleaned_plan == payload_plan([serial]) + refute_receive {^attempt, _, _} + end + end + + test "malformed Android deployed statuses fail closed before release or iOS" do + parent = self() + serial = "serial-a" + + invalid_results = [ + %{status: nil, error: nil}, + %{status: :unauthorized, error: nil}, + %{status: :arbitrary_success, error: nil}, + %{status: :error, error: "reported target error"}, + %{status: :skipped, error: "reported target skip"}, + %{status: :connected, error: "stale error on a success status"} + ] + + Enum.each(invalid_results, fn invalid -> + attempt = make_ref() + + deployer = fn opts -> + case opts[:platforms] do + [:android] -> + committed_result( + {[ + %MobDev.Device{ + serial: serial, + platform: :android, + status: invalid.status, + error: invalid.error + } + ], [], []}, + [serial] + ) + + [:ios] -> + send(parent, {attempt, :ios_deployed}) + {[], [], []} + end + end + + finalizer = fn lock -> + send(parent, {attempt, :released, lock}) + :ok + end + + cleanup = fn plan -> + send(parent, {attempt, :cleaned, plan}) + :ok + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error} = failed], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android, :ios], restart: true], + deployer, + finalizer, + cleanup + ) + + assert failed.error =~ "Native Android target" + refute_receive {^attempt, :released, _lock} + refute_receive {^attempt, :ios_deployed} + assert_receive {^attempt, :cleaned, cleaned_plan} + assert cleaned_plan == payload_plan([serial]) + refute_receive {^attempt, :cleaned, _plan} + end) + end + + test "a partial Android set failure reports no target deployed before commit" do + parent = self() + serials = ["serial-a", "serial-b"] + + deployer = fn opts -> + case opts[:platforms] do + [:android] -> + { + {[ + %MobDev.Device{ + serial: "serial-a", + platform: :android, + status: :connected + } + ], + [ + %MobDev.Device{ + serial: "serial-b", + platform: :android, + status: :error, + error: "injected target failure" + } + ], []}, + native_lock(serials, %{state: :retained_failure}) + } + + [:ios] -> + send(parent, :ios_deployed) + {[], [], []} + end + end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + assert {[], failed, []} = + Deploy.deploy_after_native_build!( + true, + native_outcome(serials), + [platforms: [:android, :ios], restart: true], + deployer, + finalizer, + fn _plan -> :ok end + ) + + assert Enum.map(failed, & &1.serial) == serials + assert Enum.all?(failed, &(&1.status == :error)) + refute_received :finalizer_called + refute_received :ios_deployed + end + + test "native Android requires an exact held lease before the final pass" do + parent = self() + + deployer = fn _opts -> + send(parent, :deployer_called) + {[], [], []} + end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + invalid_outcomes = [ + native_outcome(["serial-a"], %{android_device_disposition: :failed}), + native_outcome(["serial-a"], %{android_device_disposition: :retained}), + native_outcome(["serial-a"], %{android_device_disposition: :artifact_only}), + native_outcome(["serial-a"]) |> Map.delete(:android_device_disposition), + native_outcome(["serial-a"], %{android_deploy_lock: nil}), + native_outcome(["serial-a"], %{android_payload_plan: nil}), + native_outcome(["serial-a"], %{ + android_deploy_lock: native_lock(["serial-b"]) + }), + native_outcome(["serial-a"], %{ + android_deploy_lock: native_lock(["serial-a"], %{state: :retained_failure}) + }), + native_outcome(["serial-a"], %{ + android_deploy_lock: native_lock(["serial-a"], %{phase: :acquired}) + }), + native_outcome(["serial-a"], %{ + android_deploy_lock: + native_lock(["serial-a"], %{target_digest: String.duplicate("0", 64)}) + }), + native_outcome(["serial-a"], %{ + android_deploy_lock: native_lock(["serial-a"], %{owner: "bad"}) + }), + native_outcome([], %{android_device_disposition: :held}) + ] + + for outcome <- invalid_outcomes do + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + [platforms: [:android]], + deployer, + finalizer + ) + end) + end + end + + refute_received :deployer_called + refute_received :finalizer_called + end + + test "a forged partial-update disposition uses the generic fail-closed path" do + parent = self() + serials = ["serial-a"] + + forged = %{ + ok?: false, + android_device_disposition: :partial_update, + android_serials: serials, + android_deploy_lock: native_lock(serials), + android_payload_plan: nil + } + + output = + ExUnit.CaptureIO.capture_io(fn -> + assert_raise Mix.Error, "Native build failed", fn -> + Deploy.deploy_after_native_build!( + true, + forged, + [platforms: [:android]], + fn _opts -> send(parent, :deployer_called) end, + fn _lock -> send(parent, :finalizer_called) end + ) + end + end) + + refute output =~ "APK update completed before runtime delivery failed" + refute_received :deployer_called + refute_received :finalizer_called + end + + test "a retained partial-update lease for another bundle uses the generic fail-closed path" do + parent = self() + serials = ["serial-a"] + + cross_bundle = %{ + ok?: false, + android_device_disposition: :partial_update, + android_serials: serials, + android_deploy_lock: + native_lock(serials, %{ + bundle_id: "com.other.app", + phase: :acquired, + state: :retained_failure + }), + android_payload_plan: nil + } + + output = + ExUnit.CaptureIO.capture_io(fn -> + assert_raise Mix.Error, "Native build failed", fn -> + Deploy.deploy_after_native_build!( + true, + cross_bundle, + [platforms: [:android]], + fn _opts -> send(parent, :deployer_called) end, + fn _lock -> send(parent, :finalizer_called) end + ) + end + end) + + refute output =~ "APK update completed before runtime delivery failed" + refute_received :deployer_called + refute_received :finalizer_called + end + + test "a retained partial-update outcome in an iOS-only deploy uses the generic fail-closed path" do + parent = self() + serials = ["serial-a"] + + outcome = %{ + ok?: false, + android_device_disposition: :partial_update, + android_serials: serials, + android_deploy_lock: native_lock(serials, %{phase: :acquired, state: :retained_failure}), + android_payload_plan: nil + } + + output = + ExUnit.CaptureIO.capture_io(fn -> + assert_raise Mix.Error, "Native build failed", fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + [platforms: [:ios]], + fn _opts -> send(parent, :deployer_called) end, + fn _lock -> send(parent, :finalizer_called) end + ) + end + end) + + refute output =~ "APK update completed before runtime delivery failed" + refute_received :deployer_called + refute_received :finalizer_called + end + + test "a partial Android update fails closed with recovery guidance" do + parent = self() + serials = ["serial-a"] + retained = native_lock(serials, %{phase: :acquired, state: :retained_ambiguous}) + + outcome = %{ + ok?: false, + android_device_disposition: :partial_update, + android_serials: serials, + android_deploy_lock: retained, + android_payload_plan: nil + } + + output = + ExUnit.CaptureIO.capture_io(fn -> + assert_raise Mix.Error, "Android native deploy partially applied", fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + [platforms: [:android]], + fn _opts -> send(parent, :deployer_called) end, + fn _lock -> send(parent, :finalizer_called) end + ) + end + end) + + assert output =~ "APK update completed before runtime delivery failed" + assert output =~ "mix mob.deploy_lock --device <exact-serial>" + assert output =~ "Do not retry blindly, uninstall, or clear app data" + refute_received :deployer_called + refute_received :finalizer_called + end + + test "native Android releases only after every canonical final result succeeds" do + parent = self() + serial = "serial-a" + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + + committed_result( + {[ + %MobDev.Device{serial: serial, platform: :android, status: :connected} + ], [], []}, + [serial] + ) + end + + finalizer = fn lock -> + send(parent, {:finalizer_called, lock}) + :ok + end + + cleanup = fn plan -> + send(parent, {:payload_cleaned, plan}) + :ok + end + + assert {[%MobDev.Device{serial: ^serial}], [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android], restart: true], + deployer, + finalizer, + cleanup + ) + + assert_receive {:deployer_called, deploy_opts} + assert deploy_opts[:android_deploy_lock] == native_lock([serial]) + assert deploy_opts[:android_payload_plan] == payload_plan([serial]) + assert_receive {:finalizer_called, lock} + assert lock == committed_lock([serial]) + assert_receive {:payload_cleaned, plan} + assert plan == payload_plan([serial]) + refute_received {:payload_cleaned, _} + end + + test "a successful device result cannot release the original native-ready lease" do + parent = self() + serial = "serial-a" + + deployer = fn _opts -> + { + {[ + %MobDev.Device{serial: serial, platform: :android, status: :connected} + ], [], []}, + native_lock([serial]) + } + end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + cleanup = fn _plan -> + send(parent, :payload_cleaned) + :ok + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android], restart: true], + deployer, + finalizer, + cleanup + ) + + refute_received :finalizer_called + assert_receive :payload_cleaned + refute_received :payload_cleaned + end + + test "ambiguous Android lease release fails and stops the later iOS pass" do + parent = self() + serial = "serial-a" + + deployer = fn opts -> + case opts[:platforms] do + [:android] -> + send(parent, :android_deployed) + + committed_result( + {[ + %MobDev.Device{serial: serial, platform: :android, status: :connected} + ], [], []}, + [serial] + ) + + [:ios] -> + send(parent, :ios_deployed) + {[], [], []} + end + end + + finalizer = fn lock -> + send(parent, :release_attempted) + {:error, "ambiguous", %{lock | state: :release_ambiguous}} + end + + cleanup = fn plan -> + send(parent, {:payload_cleaned, plan}) + :ok + end + + assert {[], [%MobDev.Device{serial: ^serial, status: :error}], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [platforms: [:android, :ios], restart: true], + deployer, + finalizer, + cleanup + ) + + assert_receive :android_deployed + assert_receive :release_attempted + assert_receive {:payload_cleaned, _plan} + refute_received {:payload_cleaned, _} + refute_received :ios_deployed + end + + test "the task cleans the staged payload exactly once on validation and callback failures" do + parent = self() + outcome = native_outcome(["serial-a"]) + + cleanup = fn plan -> + send(parent, {:payload_cleaned, plan}) + :ok + end + + never_deploy = fn _opts -> + send(parent, :deployer_called) + {{[], [], []}, nil} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + %{}, + never_deploy, + &successful_finalizer/1, + cleanup + ) + end) + end + + assert_receive {:payload_cleaned, plan} + assert plan == payload_plan(["serial-a"]) + refute_received {:payload_cleaned, _} + refute_received :deployer_called + + raising_deployer = fn _opts -> raise "injected deploy failure" end + + assert_raise RuntimeError, "injected deploy failure", fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + [platforms: [:android], restart: true], + raising_deployer, + &successful_finalizer/1, + cleanup + ) + end + + assert_receive {:payload_cleaned, ^plan} + refute_received {:payload_cleaned, _} + + malformed_outcome = %{ok?: true, android_payload_plan: plan} + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + malformed_outcome, + [platforms: [:android]], + never_deploy, + &successful_finalizer/1, + cleanup + ) + end) + end + + assert_receive {:payload_cleaned, ^plan} + refute_received {:payload_cleaned, _} + refute_received :deployer_called + end + + test "an improper Android serial list cleans the held payload once and fails closed" do + parent = self() + plan = payload_plan(["serial-a"]) + + outcome = + ["serial-a"] + |> native_outcome() + |> Map.put(:android_serials, ["serial-a" | :malformed_tail]) + + cleanup = fn received_plan -> + send(parent, {:payload_cleaned, received_plan}) + raise "secondary cleanup failure" + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + [platforms: [:android, :ios], restart: true], + fn _opts -> send(parent, :deployer_called) end, + fn _lock -> send(parent, :finalizer_called) end, + cleanup + ) + end) + end + + assert_receive {:payload_cleaned, ^plan} + refute_received {:payload_cleaned, _} + refute_received :deployer_called + refute_received :finalizer_called + end + + test "an improper platform list cleans the held payload once and fails closed" do + parent = self() + outcome = native_outcome(["serial-a"]) + plan = payload_plan(["serial-a"]) + + cleanup = fn received_plan -> + send(parent, {:payload_cleaned, received_plan}) + {:error, :secondary_cleanup_failure} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + outcome, + [platforms: [:android | :malformed_tail], restart: true], + fn _opts -> send(parent, :deployer_called) end, + fn _lock -> send(parent, :finalizer_called) end, + cleanup + ) + end) + end + + assert_receive {:payload_cleaned, ^plan} + refute_received {:payload_cleaned, _} + refute_received :deployer_called + refute_received :finalizer_called + end + + test "an untrusted payload shape cannot mask the primary native-build failure" do + parent = self() + + cleanup = fn _plan -> + send(parent, :cleanup_called) + :ok + end + + assert_raise Mix.Error, "Native build failed", fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome(["serial-a"], %{android_payload_plan: :forged}), + [platforms: [:android], restart: true], + fn _opts -> flunk("deployer must not run") end, + fn _lock -> flunk("finalizer must not run") end, + cleanup + ) + end + + refute_received :cleanup_called + end + + test "invalid or duplicated native platforms fail before any callback" do + parent = self() + + deployer = fn _opts -> + send(parent, :deployer_called) + {[], [], []} + end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + for platforms <- [[], [:android, :android], [:android, :other], "android"] do + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome(["serial-a"]), + [platforms: platforms], + deployer, + finalizer + ) + end) + end + end + + refute_received :deployer_called + refute_received :finalizer_called + end + + test "malformed native deploy options and restart values fail before any callback" do + parent = self() + + deployer = fn _opts -> + send(parent, :deployer_called) + {[], [], []} + end + + finalizer = fn _lock -> + send(parent, :finalizer_called) + :ok + end + + for deploy_opts <- [ + %{}, + [platforms: [:android], restart: nil], + [platforms: [:android], restart: "true"], + [platforms: [:android], restart: 1] + ] do + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome(["serial-a"]), + deploy_opts, + deployer, + finalizer + ) + end) + end + end + + refute_received :deployer_called + refute_received :finalizer_called + end + + test "the remaining iOS pass receives no Android lease metadata" do + parent = self() + serial = "serial-a" + ios_id = "ios-device" + ios_target = physical_ios_device(ios_id) + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + + case opts[:platforms] do + [:android] -> + committed_result( + {[ + %MobDev.Device{serial: serial, platform: :android, status: :connected} + ], [], []}, + [serial] + ) + + [:ios] -> + {[ios_target], [], []} + end + end + + assert {deployed, [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([serial]), + [ + platforms: [:android, :ios], + restart: true, + ios_device: ios_id, + ios_lister: fn -> [ios_target] end, + canonical_android_serials: ["stale"], + android_deploy_lock: %{stale: true} + ], + deployer, + &successful_finalizer/1, + fn _plan -> :ok end + ) + + assert Enum.map(deployed, &{&1.platform, &1.serial}) == [ + {:android, serial}, + {:ios, ios_id} + ] + + assert_receive {:deployer_called, android_opts} + assert android_opts[:platforms] == [:android] + assert android_opts[:canonical_android_serials] == [serial] + assert android_opts[:android_deploy_lock] == native_lock([serial]) + assert android_opts[:android_payload_plan] == payload_plan([serial]) + + assert_receive {:deployer_called, ios_opts} + assert ios_opts[:platforms] == [:ios] + refute Keyword.has_key?(ios_opts, :canonical_android_serials) + refute Keyword.has_key?(ios_opts, :android_deploy_lock) + refute Keyword.has_key?(ios_opts, :android_payload_plan) + end + + test "authoritative production iOS deployed identities remain green" do + devices = [ + physical_ios_device("physical-ios-device"), + ios_simulator("78354490-EF38-44D7-A437-DD941C20524D") + ] + + Enum.each(devices, fn device -> + requested_id = + if device.type == :simulator, do: MobDev.Device.display_id(device), else: device.serial + + deployer = fn _opts -> {[device], [], []} end + + assert {[^device], [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: requested_id, + ios_lister: fn -> [device] end + ], + deployer + ) + end) + end + + test "the supported nil iOS auto-target stays green only for one authoritative device" do + device = ios_simulator("78354490-EF38-44D7-A437-DD941C20524D") + + assert {[^device], [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: nil, + ios_lister: fn -> [device] end + ], + fn _opts -> {[device], [], []} end + ) + end + + test "invalid iOS discovery fails before deployer or device mutation callbacks" do + parent = self() + device = physical_ios_device("ios-device") + + invalid_listers = [ + {:empty, fn -> [] end}, + {:malformed_member, fn -> [:not_a_device] end}, + {:improper, fn -> [device | :malformed_tail] end}, + {:raised, fn -> raise "discovery failed" end}, + {:thrown, fn -> throw(:discovery_failed) end}, + {:not_callable, :not_a_lister} + ] + + Enum.each(invalid_listers, fn {scenario, ios_lister} -> + attempt = make_ref() + + device_deployer = fn target -> + send(parent, {attempt, :device_mutated, target}) + {:ok, target} + end + + deployer = fn opts -> + send(parent, {attempt, :deployer_called, opts}) + opts[:device_deployer].(device) + {[device], [], []} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: nil, + ios_lister: ios_lister, + device_deployer: device_deployer + ], + deployer + ) + end) + end + + refute_receive {^attempt, :deployer_called, _opts}, + 0, + "deployer ran for #{scenario} discovery" + + refute_receive {^attempt, :device_mutated, _target}, + 0, + "device callback ran for #{scenario} discovery" + end) + end + + test "an explicit iOS prefix collision fails before deployer or device mutation" do + parent = self() + first = ios_simulator("78354490-EF38-44D7-A437-DD941C20524D") + second = ios_simulator("78354490-AAAA-BBBB-CCCC-DDDDEEEEFFFF") + + device_deployer = fn target -> + send(parent, {:device_mutated, target}) + {:ok, target} + end + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + opts[:device_deployer].(first) + {[first], [], []} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: "78354490", + ios_lister: fn -> [first, second] end, + device_deployer: device_deployer + ], + deployer + ) + end) + end + + refute_received {:deployer_called, _opts} + refute_received {:device_mutated, _target} + end + + test "explicit iOS selection freezes the exact target before the deploy callback" do + parent = self() + selected = ios_simulator("78354490-EF38-44D7-A437-DD941C20524D") + unrelated = ios_simulator("AAAAAAAA-BBBB-CCCC-DDDD-EEEEFFFFFFFF") + selected_serial = selected.serial + + ios_lister = fn -> + send(parent, :original_ios_lister_called) + [selected, unrelated] + end + + device_deployer = fn target -> + send(parent, {:device_mutated, target}) + {:ok, target} + end + + deployer = fn opts -> + frozen_devices = opts[:ios_lister].() + send(parent, {:frozen_ios_opts, opts[:ios_device], frozen_devices}) + + deployed = + Enum.map(frozen_devices, fn target -> + assert {:ok, ^target} = opts[:device_deployer].(target) + target + end) + + {deployed, [], []} + end + + assert {[selected], [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: MobDev.Device.display_id(selected), + ios_lister: ios_lister, + device_deployer: device_deployer + ], + deployer + ) + + assert_received :original_ios_lister_called + refute_received :original_ios_lister_called + assert_received {:frozen_ios_opts, ^selected_serial, [^selected]} + assert_received {:device_mutated, ^selected} + refute_received {:device_mutated, _other} + end + + test "non-authoritative iOS deployed identities make the native command non-green" do + selected = physical_ios_device("ios-device") + + invalid_devices = [ + %{type: :physical, status: nil, error: nil}, + %{type: :physical, status: :unauthorized, error: nil}, + %{type: :physical, status: :arbitrary_success, error: nil}, + %{type: :physical, status: :error, error: "reported target error"}, + %{type: :physical, status: :skipped, error: "reported target skip"}, + %{type: :physical, status: :discovered, error: "stale success error"}, + %{type: :simulator, status: :booted, error: "stale success error"}, + %{type: :physical, status: :connected, error: nil}, + %{type: :physical, status: :tunneled, error: nil}, + %{type: :physical, status: :booted, error: nil}, + %{type: :simulator, status: :discovered, error: nil}, + %{type: nil, status: :discovered, error: nil} + ] + + Enum.each(invalid_devices, fn invalid -> + device = + struct!(MobDev.Device, + platform: :ios, + serial: "ios-device", + type: invalid.type, + status: invalid.status, + error: invalid.error + ) + + deployer = fn _opts -> {[device], [], []} end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: "ios-device", + ios_lister: fn -> [selected] end + ], + deployer + ) + end) + end + end) + end + + test "incomplete or ambiguous iOS accounting makes the native command non-green" do + requested = physical_ios_device("ios-device") + + other = %{requested | serial: "other-ios-device"} + + wrong_platform = %{ + requested + | platform: :android, + type: :physical, + status: :discovered + } + + skipped = %{requested | status: :skipped, error: "target disappeared"} + + invalid_results = [ + {[], [], []}, + {[], [], [skipped]}, + {[wrong_platform], [], []}, + {[other], [], []}, + {[requested, requested], [], []}, + {[requested, other], [], []} + ] + + Enum.each(invalid_results, fn result -> + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: requested.serial, + ios_lister: fn -> [requested] end + ], + fn _opts -> result end + ) + end) + end + end) + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: nil, + ios_lister: fn -> [requested] end + ], + fn _opts -> {[requested, other], [], []} end + ) + end) + end + end + + test "improper iOS result buckets raise a controlled native failure" do + device = physical_ios_device("ios-device") + + failed = %{device | status: :error, error: "reported target error"} + skipped = %{device | status: :skipped, error: "reported target skip"} + + improper_results = [ + {[device | :malformed_tail], [], []}, + {[], [failed | :malformed_tail], []}, + {[], [], [skipped | :malformed_tail]} + ] + + Enum.each(improper_results, fn result -> + Enum.each([:plain, :wrapped], fn shape -> + deployer = fn _opts -> + if shape == :wrapped, do: {result, %{opaque: :lease}}, else: result + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:ios], + restart: true, + ios_device: device.serial, + ios_lister: fn -> [device] end + ], + deployer + ) + end) + end + end) + end) + end + + test "native Android with no successful update target fails before the final pass" do + parent = self() + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + {[], [], []} + end + + assert_raise Mix.Error, "Native build failed", fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [platforms: [:android], device: nil], + deployer + ) + end) + end + + refute_received {:deployer_called, _} + end + + test "a successful iOS build still deploys when unavailable Android was skipped" do + parent = self() + + ios_device = physical_ios_device("ios-device") + + deployer = fn opts -> + send(parent, {:deployer_called, opts}) + {[ios_device], [], []} + end + + assert {[^ios_device], [], []} = + Deploy.deploy_after_native_build!( + true, + native_outcome([]), + [ + platforms: [:android, :ios], + device: nil, + ios_lister: fn -> [ios_device] end + ], + deployer + ) + + assert_receive {:deployer_called, opts} + assert opts[:platforms] == [:ios] + refute Keyword.has_key?(opts, :canonical_android_serials) + refute_received {:deployer_called, _} + end + end + + describe "ensure_deploy_succeeded!/1" do + test "raises when the final deployer reports any failed device" do + failed = device("serial-a", "runtime verification failed") + + assert_raise Mix.Error, "Deploy failed on 1 device(s)", fn -> + Deploy.ensure_deploy_succeeded!({[], [failed], []}) + end + end + + test "the production reporter prints the summary and raises after any failure" do + failed = device("serial-a", "runtime verification failed") + parent = self() + + output = + ExUnit.CaptureIO.capture_io(fn -> + try do + Deploy.report_deploy_result!({[], [failed], []}) + rescue + error in Mix.Error -> send(parent, {:report_error, error}) + end + end) + + assert output =~ "Failed on 1 device(s)" + assert_receive {:report_error, %Mix.Error{message: "Deploy failed on 1 device(s)"}} + end + + test "preserves successful, skipped-only, and no-device outcomes" do + deployed = device("serial-a") + skipped = device("serial-b", "app not installed") + + assert :ok = Deploy.ensure_deploy_succeeded!({[deployed], [], []}) + assert :ok = Deploy.ensure_deploy_succeeded!({[], [], [skipped]}) + assert :ok = Deploy.ensure_deploy_succeeded!({[], [], []}) + end + end +end diff --git a/test/mix/tasks/mob_deploy_lock_test.exs b/test/mix/tasks/mob_deploy_lock_test.exs new file mode 100644 index 0000000..8996ea3 --- /dev/null +++ b/test/mix/tasks/mob_deploy_lock_test.exs @@ -0,0 +1,146 @@ +defmodule Mix.Tasks.Mob.DeployLockTest do + use ExUnit.Case, async: true + + import ExUnit.CaptureIO + + alias Mix.Tasks.Mob.DeployLock + + @bundle "com.example.casein" + @serial "serial-a" + @owner "ownerproof000001" + @digest String.duplicate("a", 64) + + test "status is read-only and returns only the bounded lock category" do + {:ok, calls} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(calls, &[args | &1]) + {"clear", 0} + end + + assert {:ok, :clear} = DeployLock.inspect_or_cleanup(@bundle, @serial, false, runner) + + assert [["-s", @serial, "shell", command]] = Agent.get(calls, &Enum.reverse/1) + assert command =~ "printf clear" + refute command =~ "rm -rf" + refute command =~ "mv " + refute command =~ "mkdir " + end + + test "cleanup refuses clear, active, and ambiguous topology without a mutation" do + for state <- [:clear, :held, :ambiguous] do + {:ok, calls} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(calls, &[args | &1]) + {Atom.to_string(state), 0} + end + + assert {:error, {:cleanup_refused, ^state}} = + DeployLock.inspect_or_cleanup(@bundle, @serial, true, runner) + + history = Agent.get(calls, &Enum.reverse/1) + assert length(history) == 1 + refute Enum.any?(history, &(List.last(&1) =~ "rm -rf")) + end + end + + test "cleanup removes one exact committed tombstone and proves the final clear state" do + {:ok, calls} = Agent.start_link(fn -> [] end) + {:ok, step} = Agent.start_link(fn -> 0 end) + basename = ".mob_native_deploy_releasing_#{@owner}" + record = "1|#{@owner}|#{@digest}|final_committed" + + runner = fn args -> + Agent.update(calls, &[args | &1]) + + case Agent.get_and_update(step, &{&1, &1 + 1}) do + 0 -> {"released_tombstone", 0} + 1 -> {basename <> "\n" <> record, 0} + 2 -> {"", 0} + 3 -> {"clear", 0} + end + end + + assert {:ok, :cleaned} = + DeployLock.inspect_or_cleanup(@bundle, @serial, true, runner) + + history = Agent.get(calls, &Enum.reverse/1) + assert length(history) == 4 + assert Enum.all?(history, &(Enum.take(&1, 2) == ["-s", @serial])) + + cleanup = Enum.at(history, 2) |> List.last() + tombstone = "/data/data/#{@bundle}/files/.mob_native_deploy_releasing_#{@owner}" + assert cleanup =~ tombstone + assert cleanup =~ ~s(test "$value" = "#{record}") + assert cleanup =~ "rm #{tombstone}/record; rmdir #{tombstone}" + refute cleanup =~ "rm -rf" + end + + test "a lost cleanup reply remains ambiguous and is never retried" do + {:ok, calls} = Agent.start_link(fn -> 0 end) + basename = ".mob_native_deploy_releasing_#{@owner}" + record = "1|#{@owner}|#{@digest}|fast_committed" + + runner = fn _args -> + case Agent.get_and_update(calls, &{&1, &1 + 1}) do + 0 -> {"released_tombstone", 0} + 1 -> {basename <> "\n" <> record, 0} + 2 -> raise "transport lost after delete" + end + end + + assert {:error, :cleanup_ambiguous} = + DeployLock.inspect_or_cleanup(@bundle, @serial, true, runner) + + assert Agent.get(calls, & &1) == 3 + end + + test "a non-clear post-cleanup proof is reported as ambiguity, not refusal" do + {:ok, step} = Agent.start_link(fn -> 0 end) + basename = ".mob_native_deploy_releasing_#{@owner}" + record = "1|#{@owner}|#{@digest}|fast_committed" + + runner = fn _args -> + case Agent.get_and_update(step, &{&1, &1 + 1}) do + 0 -> {"released_tombstone", 0} + 1 -> {basename <> "\n" <> record, 0} + 2 -> {"", 0} + 3 -> {"held", 0} + end + end + + assert {:error, :post_cleanup_ambiguous} = + DeployLock.inspect_or_cleanup(@bundle, @serial, true, runner) + + assert Agent.get(step, & &1) == 4 + end + + test "malformed requests fail before invoking the runner" do + runner = fn _args -> flunk("runner must not be called") end + + assert {:error, :invalid_request} = + DeployLock.inspect_or_cleanup(@bundle, @serial, :yes, runner) + + assert {:error, :invalid_request} = + DeployLock.inspect_or_cleanup(@bundle, @serial, false, :not_a_runner) + end + + test "duplicate device switches fail before any status or cleanup command" do + error = + assert_raise Mix.Error, fn -> + capture_io(fn -> + DeployLock.run([ + "--device", + "serial-a", + "--device", + "serial-b", + "--cleanup-committed" + ]) + end) + end + + assert error.message == + "Exactly one Android device serial is required; pass --device once" + end +end diff --git a/test/mix/tasks/mob_deploy_zigler_staging_test.exs b/test/mix/tasks/mob_deploy_zigler_staging_test.exs new file mode 100644 index 0000000..d612eb1 --- /dev/null +++ b/test/mix/tasks/mob_deploy_zigler_staging_test.exs @@ -0,0 +1,159 @@ +defmodule Mix.Tasks.Mob.DeployZiglerStagingTest do + use ExUnit.Case, async: false + + alias Mix.Tasks.Mob.Deploy + + @staging_env "ZIGLER_STAGING_ROOT" + @module_stage "Elixir.Example.Nifs.GhosttyVt" + + setup do + previous_staging_root = System.fetch_env(@staging_env) + System.delete_env(@staging_env) + + tmp = + Path.join( + System.tmp_dir!(), + "mob_deploy_zigler_staging_#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(tmp) + + on_exit(fn -> + restore_env(previous_staging_root) + File.rm_rf!(tmp) + end) + + {:ok, tmp: tmp} + end + + test "native stages stay isolated across checkouts and cwd changes", %{tmp: tmp} do + checkout_a = Path.join(tmp, "checkout-a") + checkout_b = Path.join(tmp, "checkout-b") + build_path_a = Path.join(checkout_a, "_build/dev") + build_path_b = Path.join(checkout_b, "_build/dev") + staging_root_a = Path.join(build_path_a, "zigler-staging") + staging_root_b = Path.join(build_path_b, "zigler-staging") + staged_build_a = Path.join([staging_root_a, @module_stage, "build.zig"]) + staged_build_b = Path.join([staging_root_b, @module_stage, "build.zig"]) + include_a = Path.join(checkout_a, "native/ghostty/include") + include_b = Path.join(checkout_b, "native/ghostty/include") + + File.mkdir_p!(include_a) + File.mkdir_p!(include_b) + + compiler_a = fn "compile", args -> + assert args == ["--force"] + assert System.fetch_env!(@staging_env) == staging_root_a + File.mkdir_p!(Path.dirname(staged_build_a)) + File.write!(staged_build_a, include_a) + :ok + end + + assert :built_a = + Deploy.with_zigler_staging( + true, + fn -> + assert File.read!(staged_build_a) == include_a + :built_a + end, + build_path: build_path_a, + compiler: compiler_a + ) + + File.rm_rf!(checkout_a) + + compiler_b = fn "compile", args -> + assert args == ["--force"] + assert System.fetch_env!(@staging_env) == staging_root_b + refute System.fetch_env!(@staging_env) == staging_root_a + File.mkdir_p!(Path.dirname(staged_build_b)) + File.write!(staged_build_b, include_b) + :ok + end + + assert :built_b = + Deploy.with_zigler_staging( + true, + fn -> + File.cd!(tmp, fn -> + assert System.fetch_env!(@staging_env) == staging_root_b + assert File.read!(staged_build_b) == include_b + :built_b + end) + end, + build_path: build_path_b, + compiler: compiler_b + ) + + refute File.exists?(checkout_a) + assert File.read!(staged_build_b) == include_b + refute File.read!(staged_build_b) =~ checkout_a + assert System.fetch_env(@staging_env) == :error + end + + test "native compile honors an explicit staging root and restores it afterward", %{tmp: tmp} do + explicit_root = Path.join(tmp, "explicit-zigler-stage") + System.put_env(@staging_env, explicit_root) + + compiler = fn "compile", args -> + assert args == ["--force"] + assert File.dir?(explicit_root) + assert System.fetch_env!(@staging_env) == explicit_root + :ok + end + + assert :native_operation = + Deploy.with_zigler_staging( + true, + fn -> + assert System.fetch_env!(@staging_env) == explicit_root + :native_operation + end, + build_path: Path.join(tmp, "ignored-build-path"), + compiler: compiler + ) + + assert System.fetch_env!(@staging_env) == explicit_root + end + + test "native compile restores an unset staging root when the build raises", %{tmp: tmp} do + assert_raise RuntimeError, "injected native failure", fn -> + Deploy.with_zigler_staging(true, fn -> raise "injected native failure" end, + build_path: Path.join(tmp, "_build/dev"), + compiler: fn "compile", ["--force"] -> :ok end + ) + end + + assert System.fetch_env(@staging_env) == :error + end + + test "fast deploy remains incremental and does not create or change a staging root", %{tmp: tmp} do + build_path = Path.join(tmp, "_build/dev") + existing_root = Path.join(tmp, "existing-explicit-root") + System.put_env(@staging_env, existing_root) + + compiler = fn "compile", args -> + assert args == [] + assert System.fetch_env!(@staging_env) == existing_root + :ok + end + + assert :fast_operation = + Deploy.with_zigler_staging( + false, + fn -> + assert System.fetch_env!(@staging_env) == existing_root + :fast_operation + end, + build_path: build_path, + compiler: compiler + ) + + refute File.exists?(build_path) + refute File.exists?(existing_root) + assert System.fetch_env!(@staging_env) == existing_root + end + + defp restore_env({:ok, value}), do: System.put_env(@staging_env, value) + defp restore_env(:error), do: System.delete_env(@staging_env) +end diff --git a/test/mix/tasks/mob_doctor_test.exs b/test/mix/tasks/mob_doctor_test.exs new file mode 100644 index 0000000..9bf1640 --- /dev/null +++ b/test/mix/tasks/mob_doctor_test.exs @@ -0,0 +1,48 @@ +defmodule Mix.Tasks.Mob.DoctorTest do + use ExUnit.Case, async: true + + describe "__missing_plugin_options__/2 (pre-plugin build.zig detection)" do + # The real declaration shape every template uses. + @declared """ + const plugin_c_nifs = b.option([]const u8, "plugin_c_nifs", "...") orelse ""; + const plugin_zig_nifs = b.option([]const u8, "plugin_zig_nifs", "...") orelse ""; + const plugin_jni_sources = b.option([]const u8, "plugin_jni_sources", "...") orelse ""; + """ + + test "a plugin-aware build file is missing nothing" do + assert Mix.Tasks.Mob.Doctor.__missing_plugin_options__( + @declared, + ~w(plugin_c_nifs plugin_zig_nifs plugin_jni_sources) + ) == [] + end + + test "a pre-plugin build file is missing every option" do + pre_plugin = """ + const driver_tab = b.option([]const u8, "driver_tab", "...") orelse ""; + """ + + assert Mix.Tasks.Mob.Doctor.__missing_plugin_options__( + pre_plugin, + ~w(plugin_c_nifs plugin_zig_nifs plugin_jni_sources) + ) == ~w(plugin_c_nifs plugin_zig_nifs plugin_jni_sources) + end + + test "a partially upgraded build file reports only the absent options" do + partial = """ + const plugin_c_nifs = b.option([]const u8, "plugin_c_nifs", "...") orelse ""; + """ + + assert Mix.Tasks.Mob.Doctor.__missing_plugin_options__( + partial, + ~w(plugin_c_nifs plugin_zig_nifs plugin_jni_sources) + ) == ~w(plugin_zig_nifs plugin_jni_sources) + end + + test "an unquoted mention (a comment) does not count as declared" do + comment_only = "// TODO: add plugin_c_nifs support" + + assert Mix.Tasks.Mob.Doctor.__missing_plugin_options__(comment_only, ["plugin_c_nifs"]) == + ["plugin_c_nifs"] + end + end +end diff --git a/test/mix/tasks/mob_enable_test.exs b/test/mix/tasks/mob_enable_test.exs new file mode 100644 index 0000000..68befa1 --- /dev/null +++ b/test/mix/tasks/mob_enable_test.exs @@ -0,0 +1,333 @@ +defmodule Mix.Tasks.Mob.EnableTest do + use ExUnit.Case, async: true + + import Igniter.Test + + # The mob.enable task is now Igniter-driven (Phase 4 iter 1). It dispatches + # per-feature handlers in `MobDev.Enable.Igniter`. These tests exercise the + # task's validation + dispatch surface; per-handler text-mutation logic is + # covered in `MobDev.EnableTest`. + + describe "argument validation" do + test "no features → issue" do + enable([]) + |> assert_has_issue(&(&1 =~ "Usage:")) + end + + test "unknown feature → issue" do + enable(["typescript"]) + |> assert_has_issue(&(&1 =~ "Unknown feature")) + end + + test "all valid features are accepted (no validation issue)" do + # We only assert that validation passes — most handlers issue notices + # because the test project has no ios/, android/, assets/ etc. dirs. + # Coverage of the full @valid_features list catches "did the new + # feature get added to validation as well as dispatch?" regressions. + for feature <- ~w(camera photo_library location file_sharing notifications nxeigen) do + igniter = enable([feature]) + assert igniter.issues == [], "feature #{feature} was rejected: #{inspect(igniter.issues)}" + end + end + end + + describe "camera feature" do + test "patches Info.plist + AndroidManifest.xml when both exist" do + igniter = + test_project( + files: %{ + "ios/Test/Info.plist" => plist_skeleton(), + "android/app/src/main/AndroidManifest.xml" => android_manifest_skeleton() + } + ) + |> Igniter.compose_task("mob.enable", ["camera"]) + + plist = + Rewrite.Source.get(Rewrite.source!(igniter.rewrite, "ios/Test/Info.plist"), :content) + + manifest = + Rewrite.Source.get( + Rewrite.source!(igniter.rewrite, "android/app/src/main/AndroidManifest.xml"), + :content + ) + + assert plist =~ "NSCameraUsageDescription" + assert plist =~ "This app uses the camera." + assert manifest =~ ~s(android:name="android.permission.CAMERA") + end + + test "skips Info.plist patch when key is already present" do + plist = + plist_skeleton() + |> String.replace( + "</dict>", + "<key>NSCameraUsageDescription</key>\n <string>existing</string>\n</dict>" + ) + + igniter = + test_project(files: %{"ios/Test/Info.plist" => plist}) + |> Igniter.compose_task("mob.enable", ["camera"]) + + patched = + Rewrite.Source.get(Rewrite.source!(igniter.rewrite, "ios/Test/Info.plist"), :content) + + # The existing description survives; the task did not append a duplicate. + assert patched =~ "existing" + refute patched =~ "This app uses the camera." + end + end + + describe "photo_library feature" do + test "patches Info.plist only; notice about Android" do + igniter = + test_project(files: %{"ios/Test/Info.plist" => plist_skeleton()}) + |> Igniter.compose_task("mob.enable", ["photo_library"]) + + plist = + Rewrite.Source.get(Rewrite.source!(igniter.rewrite, "ios/Test/Info.plist"), :content) + + assert plist =~ "NSPhotoLibraryAddUsageDescription" + + assert Enum.any?(igniter.notices, &(&1 =~ "no Android manifest change needed")) + end + end + + describe "pythonx feature" do + test "adds :pythonx dep via Igniter (AST-aware)" do + igniter = + test_project() + |> Igniter.compose_task("mob.enable", ["pythonx"]) + + mix_exs = Rewrite.Source.get(Rewrite.source!(igniter.rewrite, "mix.exs"), :content) + # The dep was added via `Igniter.Project.Deps.add_dep` (parses + # the deps/0 AST and appends), not regex. + assert mix_exs =~ ":pythonx" + end + + test "generates lib/<app>/python_paths.ex as an Elixir module" do + igniter = + test_project() + |> Igniter.compose_task("mob.enable", ["pythonx"]) + + file = Rewrite.source!(igniter.rewrite, "lib/test/python_paths.ex") + content = Rewrite.Source.get(file, :content) + assert content =~ "defmodule Test.PythonPaths" + end + + test "is idempotent — re-adding doesn't duplicate the :pythonx dep" do + mix_exs = """ + defmodule Test.MixProject do + use Mix.Project + + def project, do: [app: :test, deps: deps()] + + defp deps do + [ + {:pythonx, "~> 0.4"} + ] + end + end + """ + + igniter = + test_project(files: %{"mix.exs" => mix_exs}) + |> Igniter.compose_task("mob.enable", ["pythonx"]) + + patched = Rewrite.Source.get(Rewrite.source!(igniter.rewrite, "mix.exs"), :content) + assert patched |> String.split(":pythonx") |> length() == 2 + end + + test "emits a 'next steps for pythonx' notice (library-named, not generic)" do + igniter = + test_project() + |> Igniter.compose_task("mob.enable", ["pythonx"]) + + # The notice header should match the canonical name. Catches the + # accidental "Next steps for python:" regression if someone reverts + # only half of the rename. + assert Enum.any?(igniter.notices, &(&1 =~ "Next steps for pythonx")) + end + end + + describe "mlx feature" do + test "adds :nx and :emlx deps via Igniter" do + igniter = + test_project() + |> Igniter.compose_task("mob.enable", ["mlx"]) + + mix_exs = Rewrite.Source.get(Rewrite.source!(igniter.rewrite, "mix.exs"), :content) + assert mix_exs =~ ":nx" + assert mix_exs =~ ":emlx" + end + + test "generates lib/<app>/ml_init.ex with EMLX configure/0" do + igniter = + test_project() + |> Igniter.compose_task("mob.enable", ["mlx"]) + + file = Rewrite.source!(igniter.rewrite, "lib/test/ml_init.ex") + content = Rewrite.Source.get(file, :content) + assert content =~ "defmodule Test.MLInit" + assert content =~ "EMLX.Backend" + assert content =~ "Nx.global_default_backend" + # Fallback path for when the NIF can't load. + assert content =~ "Nx.BinaryBackend" + end + + test "is idempotent — re-adding doesn't duplicate :emlx" do + mix_exs = """ + defmodule Test.MixProject do + use Mix.Project + + def project, do: [app: :test, deps: deps()] + + defp deps do + [ + {:nx, "~> 0.10"}, + {:emlx, "~> 0.2"} + ] + end + end + """ + + igniter = + test_project(files: %{"mix.exs" => mix_exs}) + |> Igniter.compose_task("mob.enable", ["mlx"]) + + patched = Rewrite.Source.get(Rewrite.source!(igniter.rewrite, "mix.exs"), :content) + # Each name should only appear once in the deps list (after the + # `:` separator). The `defmodule` doesn't count. + assert patched |> String.split(":emlx") |> length() == 2 + assert patched |> String.split(":nx,") |> length() == 2 + end + + test "adds a next-steps notice mentioning MLInit.configure" do + igniter = + test_project() + |> Igniter.compose_task("mob.enable", ["mlx"]) + + assert Enum.any?(igniter.notices, &(&1 =~ "Test.MLInit.configure()")) + end + end + + describe "nxeigen feature" do + test "adds :nx and :nx_eigen deps via Igniter" do + igniter = + test_project() + |> Igniter.compose_task("mob.enable", ["nxeigen"]) + + mix_exs = Rewrite.Source.get(Rewrite.source!(igniter.rewrite, "mix.exs"), :content) + assert mix_exs =~ ":nx" + assert mix_exs =~ ":nx_eigen" + end + + test "generates lib/<app>/nx_eigen_init.ex with NxEigen.Backend configure/0" do + igniter = + test_project() + |> Igniter.compose_task("mob.enable", ["nxeigen"]) + + file = Rewrite.source!(igniter.rewrite, "lib/test/nx_eigen_init.ex") + content = Rewrite.Source.get(file, :content) + assert content =~ "defmodule Test.NxEigenInit" + assert content =~ "NxEigen.Backend" + assert content =~ "Nx.global_default_backend" + # Fallback path for when the NIF can't load. + assert content =~ "Nx.BinaryBackend" + end + + # Idempotency for the nx_eigen dep is verified by + # `Igniter.Project.Deps.add_dep` upstream — it short-circuits when + # the dep tuple already exists. A test mirroring the mlx/pythonx + # idempotent test would inherit those tests' Rewrite.Error failure + # mode (test_project's mix.exs source isn't picked up by Rewrite + # the way the task expects), so we skip it. Re-add once those are + # fixed. + + test "adds a next-steps notice mentioning NxEigenInit.configure" do + igniter = + test_project() + |> Igniter.compose_task("mob.enable", ["nxeigen"]) + + assert Enum.any?(igniter.notices, &(&1 =~ "Test.NxEigenInit.configure()")) + end + + test "next-steps notice flags the cross-platform story (iOS + Android)" do + # Distinct from mlx which is iOS-only — this is the explicit + # selling point. Pin it so the message can't drift. + igniter = + test_project() + |> Igniter.compose_task("mob.enable", ["nxeigen"]) + + notice = Enum.find(igniter.notices, &(&1 =~ "nxeigen")) + + assert is_binary(notice), + "expected a notice mentioning nxeigen; got: #{inspect(igniter.notices)}" + + assert notice =~ ~r/iOS.*Android|Android.*iOS/ + end + end + + describe "missing platform dirs" do + test "no ios/ → adds a notice instead of failing" do + igniter = + test_project( + files: %{"android/app/src/main/AndroidManifest.xml" => android_manifest_skeleton()} + ) + |> Igniter.compose_task("mob.enable", ["camera"]) + + assert igniter.issues == [] + assert Enum.any?(igniter.notices, &(&1 =~ "no Info.plist")) + # Android manifest still got patched. + manifest = + Rewrite.Source.get( + Rewrite.source!(igniter.rewrite, "android/app/src/main/AndroidManifest.xml"), + :content + ) + + assert manifest =~ "android.permission.CAMERA" + end + + test "no android/ → adds a notice instead of failing" do + igniter = + test_project(files: %{"ios/Test/Info.plist" => plist_skeleton()}) + |> Igniter.compose_task("mob.enable", ["camera"]) + + assert igniter.issues == [] + assert Enum.any?(igniter.notices, &(&1 =~ "no AndroidManifest.xml")) + end + end + + # ── Helpers ──────────────────────────────────────────────────────────── + + defp enable(features) do + test_project() + |> Igniter.compose_task("mob.enable", features) + end + + defp plist_skeleton do + """ + <?xml version="1.0" encoding="UTF-8"?> + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> + <plist version="1.0"> + <dict> + <key>CFBundleIdentifier</key> + <string>com.example.test</string> + </dict> + </plist> + """ + end + + defp android_manifest_skeleton do + """ + <?xml version="1.0" encoding="utf-8"?> + <manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.example.test"> + + <application + android:label="Test" + android:icon="@mipmap/ic_launcher"> + </application> + </manifest> + """ + end +end diff --git a/test/mix/tasks/mob_install_test.exs b/test/mix/tasks/mob_install_test.exs new file mode 100644 index 0000000..d5c5b78 --- /dev/null +++ b/test/mix/tasks/mob_install_test.exs @@ -0,0 +1,198 @@ +defmodule Mix.Tasks.Mob.InstallTest do + # async: false — detect_android_sdk/0 tests mutate ANDROID_HOME, which is + # process-global. + use ExUnit.Case, async: false + + alias Mix.Tasks.Mob.Install + alias MobDev.OtpDownloader + + # ── replace_prop/3 ──────────────────────────────────────────────────────────── + + describe "replace_prop/3" do + test "replaces a matching key=value line" do + content = "mob.otp_release=/path/to/placeholder\nmob.mob_dir=/path/to/mob\n" + result = Install.replace_prop(content, "mob.otp_release", "/new/path") + assert result =~ "mob.otp_release=/new/path\n" + refute result =~ "placeholder" + end + + test "leaves other keys untouched" do + content = "mob.otp_release=/old\nmob.mob_dir=/my/mob\n" + result = Install.replace_prop(content, "mob.otp_release", "/new") + assert result =~ "mob.mob_dir=/my/mob" + end + + test "is a no-op when value is nil" do + content = "mob.otp_release=/old\n" + assert Install.replace_prop(content, "mob.otp_release", nil) == content + end + + test "replaces mob.otp_release_arm32 independently" do + content = "mob.otp_release=/arm64\nmob.otp_release_arm32=/placeholder\n" + result = Install.replace_prop(content, "mob.otp_release_arm32", "/arm32/real") + assert result =~ "mob.otp_release_arm32=/arm32/real" + assert result =~ "mob.otp_release=/arm64" + end + end + + # ── write_local_properties/2 ───────────────────────────────────────────────── + + describe "write_local_properties/2" do + setup do + dir = Path.join(System.tmp_dir!(), "mob_install_#{System.unique_integer([:positive])}") + File.mkdir_p!(Path.join(dir, "android")) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, dir: dir} + end + + defp props_path(dir), do: Path.join([dir, "android", "local.properties"]) + + defp write_placeholder_props(dir) do + File.write!(props_path(dir), """ + mob.otp_release=/path/to/otp-android + mob.otp_release_arm32=/path/to/otp-android-arm32 + mob.otp_release_x86_64=/path/to/otp-android-x86_64 + mob.mob_dir=/path/to/mob + """) + end + + test "writes arm64 OTP path when placeholder present", %{dir: dir} do + write_placeholder_props(dir) + Install.write_local_properties(dir, mob_dir: dir) + content = File.read!(props_path(dir)) + assert content =~ "mob.otp_release=#{OtpDownloader.android_otp_dir("arm64-v8a")}" + end + + test "writes arm32 OTP path when placeholder present", %{dir: dir} do + write_placeholder_props(dir) + Install.write_local_properties(dir, mob_dir: dir) + content = File.read!(props_path(dir)) + assert content =~ "mob.otp_release_arm32=#{OtpDownloader.android_otp_dir("armeabi-v7a")}" + end + + test "writes x86_64 OTP path when placeholder present", %{dir: dir} do + write_placeholder_props(dir) + Install.write_local_properties(dir, mob_dir: dir) + content = File.read!(props_path(dir)) + assert content =~ "mob.otp_release_x86_64=#{OtpDownloader.android_otp_dir("x86_64")}" + end + + test "arm64, arm32, and x86_64 paths written are distinct", %{dir: dir} do + write_placeholder_props(dir) + Install.write_local_properties(dir, mob_dir: dir) + content = File.read!(props_path(dir)) + arm64 = OtpDownloader.android_otp_dir("arm64-v8a") + arm32 = OtpDownloader.android_otp_dir("armeabi-v7a") + x86_64 = OtpDownloader.android_otp_dir("x86_64") + assert content =~ arm64 + assert content =~ arm32 + assert content =~ x86_64 + refute arm64 == arm32 + refute arm64 == x86_64 + end + + test "does nothing when local.properties is fully populated", %{dir: dir} do + # Both Mob paths and sdk.dir already set — write_local_properties should + # leave it byte-identical. + original = + "sdk.dir=/already/set/sdk\nmob.otp_release=/already/set\nmob.mob_dir=/already/set\n" + + File.write!(props_path(dir), original) + Install.write_local_properties(dir, mob_dir: dir) + assert File.read!(props_path(dir)) == original + end + + test "does nothing when local.properties does not exist", %{dir: dir} do + Install.write_local_properties(dir, mob_dir: dir) + refute File.exists?(props_path(dir)) + end + end + + # ── has_active_sdk_dir?/1 ──────────────────────────────────────────────────── + + describe "has_active_sdk_dir?/1" do + test "true for an uncommented sdk.dir= line" do + assert Install.has_active_sdk_dir?("sdk.dir=/Users/me/Android/sdk\n") + end + + test "true with leading whitespace" do + assert Install.has_active_sdk_dir?(" sdk.dir=/path\n") + end + + test "false for a commented placeholder" do + refute Install.has_active_sdk_dir?("# sdk.dir=/path/to/android/sdk\n") + end + + test "false when the line is missing entirely" do + refute Install.has_active_sdk_dir?("mob.mob_dir=/foo\n") + end + end + + # ── ensure_sdk_dir/2 ───────────────────────────────────────────────────────── + + describe "ensure_sdk_dir/2" do + test "no-op when sdk_path is nil" do + assert Install.ensure_sdk_dir("mob.mob_dir=/foo\n", nil) == "mob.mob_dir=/foo\n" + end + + test "replaces a commented placeholder with an active line" do + input = "# sdk.dir=/path/to/android/sdk\nmob.mob_dir=/foo\n" + result = Install.ensure_sdk_dir(input, "/Users/me/Android/sdk") + assert result =~ ~r/^sdk\.dir=\/Users\/me\/Android\/sdk$/m + refute result =~ "/path/to/android/sdk" + end + + test "updates an existing active sdk.dir= line" do + input = "sdk.dir=/old/path\nmob.mob_dir=/foo\n" + result = Install.ensure_sdk_dir(input, "/new/path") + assert result =~ "sdk.dir=/new/path" + refute result =~ "/old/path" + end + + test "prepends sdk.dir= when neither active nor commented line is present" do + input = "mob.mob_dir=/foo\n" + result = Install.ensure_sdk_dir(input, "/Users/me/Android/sdk") + assert String.starts_with?(result, "sdk.dir=/Users/me/Android/sdk\n") + end + end + + # ── detect_android_sdk/0 ────────────────────────────────────────────────────── + + describe "detect_android_sdk/0" do + test "returns ANDROID_HOME when set and the directory exists" do + tmp = + Path.join(System.tmp_dir!(), "mob_sdk_test_#{System.unique_integer([:positive])}") + + File.mkdir_p!(tmp) + + original = System.get_env("ANDROID_HOME") + System.put_env("ANDROID_HOME", tmp) + + try do + assert Install.detect_android_sdk() == tmp + after + if original, + do: System.put_env("ANDROID_HOME", original), + else: System.delete_env("ANDROID_HOME") + + File.rm_rf!(tmp) + end + end + + test "skips ANDROID_HOME when the directory doesn't exist" do + original = System.get_env("ANDROID_HOME") + System.put_env("ANDROID_HOME", "/nonexistent/path/asdfqwerty") + + try do + # detect_android_sdk falls through to platform defaults; whether they + # exist on the test host is host-dependent, so just assert we did NOT + # return the bogus override. + refute Install.detect_android_sdk() == "/nonexistent/path/asdfqwerty" + after + if original, + do: System.put_env("ANDROID_HOME", original), + else: System.delete_env("ANDROID_HOME") + end + end + end +end diff --git a/test/mix/tasks/mob_new_plugin_test.exs b/test/mix/tasks/mob_new_plugin_test.exs new file mode 100644 index 0000000..9749d73 --- /dev/null +++ b/test/mix/tasks/mob_new_plugin_test.exs @@ -0,0 +1,30 @@ +defmodule Mix.Tasks.Mob.NewPluginTest do + use ExUnit.Case, async: true + + alias Mix.Tasks.Mob.NewPlugin + + describe "invalid_option_message/1" do + test "empty invalid list is :ok" do + assert NewPlugin.invalid_option_message([]) == :ok + end + + test "a bad-typed switch value is reported, not silently dropped" do + assert {:error, msg} = NewPlugin.invalid_option_message([{"--tier", "abc"}]) + assert msg =~ "--tier abc" + assert msg =~ "invalid option" + end + + test "an unknown flag is reported" do + assert {:error, msg} = NewPlugin.invalid_option_message([{"--bogus", nil}]) + assert msg =~ "--bogus" + end + + test "multiple invalid options are all listed" do + assert {:error, msg} = + NewPlugin.invalid_option_message([{"--tier", "two"}, {"--nope", nil}]) + + assert msg =~ "--tier two" + assert msg =~ "--nope" + end + end +end diff --git a/test/mix/tasks/mob_plugin_keygen_test.exs b/test/mix/tasks/mob_plugin_keygen_test.exs new file mode 100644 index 0000000..b4398aa --- /dev/null +++ b/test/mix/tasks/mob_plugin_keygen_test.exs @@ -0,0 +1,76 @@ +defmodule Mix.Tasks.Mob.Plugin.KeygenTest do + use ExUnit.Case, async: false + + alias MobDev.Plugin.{Crypto, PrivateKeyStore, Verify} + + setup do + tmp_home = + Path.join(System.tmp_dir!(), "mob_keygen_home_#{System.unique_integer([:positive])}") + + File.mkdir_p!(tmp_home) + previous = Application.get_env(:mob_dev, :plugin_key_home) + Application.put_env(:mob_dev, :plugin_key_home, tmp_home) + + plugin_dir = + Path.join(System.tmp_dir!(), "mob_keygen_plugin_#{System.unique_integer([:positive])}") + + File.mkdir_p!(Path.join(plugin_dir, "priv")) + + manifest = %{name: :mob_keygen_demo, mob_version: "~> 0.6", plugin_spec_version: 1} + File.write!(Path.join(plugin_dir, "priv/mob_plugin.exs"), inspect(manifest)) + + on_exit(fn -> + if previous, + do: Application.put_env(:mob_dev, :plugin_key_home, previous), + else: Application.delete_env(:mob_dev, :plugin_key_home) + + File.rm_rf!(tmp_home) + File.rm_rf!(plugin_dir) + end) + + {:ok, home: tmp_home, plugin_dir: plugin_dir} + end + + test "writes priv key with mode 0600 and pub key in the plugin dir", %{plugin_dir: dir} do + Mix.Tasks.Mob.Plugin.Keygen.run(["--plugin", dir]) + + priv_path = PrivateKeyStore.key_path(:mob_keygen_demo) + assert File.exists?(priv_path) + %File.Stat{mode: mode} = File.stat!(priv_path) + assert Bitwise.band(mode, 0o777) == 0o600 + + pub_path = Path.join(dir, "priv/mob_plugin.pub") + assert File.exists?(pub_path) + + # Pubkey decodes back to a 32-byte key and matches what Verify reads. + assert {:ok, pub} = Verify.load_pubkey(dir) + assert byte_size(pub) == 32 + assert Crypto.fingerprint(pub) =~ ~r/^ed25519:/ + end + + test "refuses to overwrite an existing priv key without --force", %{plugin_dir: dir} do + Mix.Tasks.Mob.Plugin.Keygen.run(["--plugin", dir]) + + assert_raise Mix.Error, ~r/already exists/, fn -> + Mix.Tasks.Mob.Plugin.Keygen.run(["--plugin", dir]) + end + end + + test "--force allows a key rotation", %{plugin_dir: dir} do + Mix.Tasks.Mob.Plugin.Keygen.run(["--plugin", dir]) + {:ok, pub1} = Verify.load_pubkey(dir) + + Mix.Tasks.Mob.Plugin.Keygen.run(["--plugin", dir, "--force"]) + {:ok, pub2} = Verify.load_pubkey(dir) + + assert pub1 != pub2 + end + + test "errors when the plugin has no manifest", %{plugin_dir: dir} do + File.rm!(Path.join(dir, "priv/mob_plugin.exs")) + + assert_raise Mix.Error, ~r/needs a manifest/, fn -> + Mix.Tasks.Mob.Plugin.Keygen.run(["--plugin", dir]) + end + end +end diff --git a/test/mix/tasks/mob_plugin_sign_test.exs b/test/mix/tasks/mob_plugin_sign_test.exs new file mode 100644 index 0000000..11b28ab --- /dev/null +++ b/test/mix/tasks/mob_plugin_sign_test.exs @@ -0,0 +1,58 @@ +defmodule Mix.Tasks.Mob.Plugin.SignTest do + use ExUnit.Case, async: false + + alias MobDev.Plugin.{Manifest, Sign, Verify} + + setup do + tmp_home = + Path.join(System.tmp_dir!(), "mob_sign_home_#{System.unique_integer([:positive])}") + + File.mkdir_p!(tmp_home) + previous = Application.get_env(:mob_dev, :plugin_key_home) + Application.put_env(:mob_dev, :plugin_key_home, tmp_home) + + plugin_dir = + Path.join(System.tmp_dir!(), "mob_sign_plugin_#{System.unique_integer([:positive])}") + + File.mkdir_p!(Path.join(plugin_dir, "priv")) + + manifest = %{name: :mob_sign_demo, mob_version: "~> 0.6", plugin_spec_version: 1} + File.write!(Path.join(plugin_dir, "priv/mob_plugin.exs"), inspect(manifest)) + + on_exit(fn -> + if previous, + do: Application.put_env(:mob_dev, :plugin_key_home, previous), + else: Application.delete_env(:mob_dev, :plugin_key_home) + + File.rm_rf!(tmp_home) + File.rm_rf!(plugin_dir) + end) + + {:ok, plugin_dir: plugin_dir} + end + + test "end-to-end: keygen + sign produces a verifiable signature", %{plugin_dir: dir} do + Mix.Tasks.Mob.Plugin.Keygen.run(["--plugin", dir]) + Mix.Tasks.Mob.Plugin.Sign.run(["--plugin", dir]) + + assert File.exists?(Sign.signature_path(dir)) + + {:ok, manifest} = Manifest.load(dir) + assert :ok = Verify.verify_plugin(dir, manifest) + assert {:ok, 2} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "errors when no keygen has been run for the plugin", %{plugin_dir: dir} do + assert_raise Mix.Error, ~r/no private key/, fn -> + Mix.Tasks.Mob.Plugin.Sign.run(["--plugin", dir]) + end + end + + test "errors when the plugin has no manifest", %{plugin_dir: dir} do + File.rm!(Path.join(dir, "priv/mob_plugin.exs")) + + assert_raise Mix.Error, ~r/no priv\/mob_plugin\.exs/, fn -> + Mix.Tasks.Mob.Plugin.Sign.run(["--plugin", dir]) + end + end +end diff --git a/test/mix/tasks/mob_plugin_trust_test.exs b/test/mix/tasks/mob_plugin_trust_test.exs new file mode 100644 index 0000000..8be739d --- /dev/null +++ b/test/mix/tasks/mob_plugin_trust_test.exs @@ -0,0 +1,104 @@ +defmodule Mix.Tasks.Mob.Plugin.TrustTest do + use ExUnit.Case, async: false + + alias MobDev.Plugin.{Crypto, TrustStore} + + setup do + workdir = + Path.join(System.tmp_dir!(), "mob_trust_task_#{System.unique_integer([:positive])}") + + File.mkdir_p!(workdir) + File.write!(Path.join(workdir, "mob.exs"), "import Config\n") + + plugin_name = :mob_trust_demo + plugin_dir = Path.join(workdir, "deps/#{plugin_name}") + File.mkdir_p!(Path.join(plugin_dir, "priv")) + + manifest = %{ + name: plugin_name, + mob_version: "~> 0.6", + plugin_spec_version: 1, + version: "0.1.0", + ios: %{frameworks: ["UIKit"]}, + android: %{permissions: ["android.permission.INTERNET"]} + } + + File.write!(Path.join(plugin_dir, "priv/mob_plugin.exs"), inspect(manifest, limit: :infinity)) + + {_priv, pub} = Crypto.generate_keypair() + File.write!(Path.join(plugin_dir, "priv/mob_plugin.pub"), Base.encode64(pub) <> "\n") + + deps_paths = %{plugin_name => plugin_dir} + + on_exit(fn -> File.rm_rf!(workdir) end) + + Mix.shell(Mix.Shell.Process) + on_exit(fn -> Mix.shell(Mix.Shell.IO) end) + + {:ok, workdir: workdir, pub: pub, plugin_name: plugin_name, deps_paths: deps_paths} + end + + test "yes at prompt writes the trust entry", %{ + workdir: workdir, + pub: pub, + plugin_name: name, + deps_paths: deps + } do + send(self(), {:mix_shell_input, :yes?, true}) + + Mix.Tasks.Mob.Plugin.Trust.run_with_deps([Atom.to_string(name)], deps, workdir) + + expected = %{name => Crypto.fingerprint(pub)} + assert TrustStore.load_trusted_plugins(workdir) == expected + end + + test "is idempotent for the same key", %{ + workdir: workdir, + pub: pub, + plugin_name: name, + deps_paths: deps + } do + send(self(), {:mix_shell_input, :yes?, true}) + Mix.Tasks.Mob.Plugin.Trust.run_with_deps([Atom.to_string(name)], deps, workdir) + + first = File.read!(Path.join(workdir, "mob.exs")) + + send(self(), {:mix_shell_input, :yes?, true}) + Mix.Tasks.Mob.Plugin.Trust.run_with_deps([Atom.to_string(name)], deps, workdir) + + assert File.read!(Path.join(workdir, "mob.exs")) == first + assert TrustStore.load_trusted_plugins(workdir) == %{name => Crypto.fingerprint(pub)} + end + + test "no at prompt leaves mob.exs unchanged", %{ + workdir: workdir, + plugin_name: name, + deps_paths: deps + } do + send(self(), {:mix_shell_input, :yes?, false}) + + Mix.Tasks.Mob.Plugin.Trust.run_with_deps([Atom.to_string(name)], deps, workdir) + + assert TrustStore.load_trusted_plugins(workdir) == %{} + end + + test "raises when the plugin isn't a known dep", %{workdir: workdir} do + assert_raise Mix.Error, ~r/no dependency named/, fn -> + Mix.Tasks.Mob.Plugin.Trust.run_with_deps(["mob_unknown"], %{}, workdir) + end + end + + test "untrust removes the entry", %{ + workdir: workdir, + pub: pub, + plugin_name: name, + deps_paths: deps + } do + send(self(), {:mix_shell_input, :yes?, true}) + Mix.Tasks.Mob.Plugin.Trust.run_with_deps([Atom.to_string(name)], deps, workdir) + assert TrustStore.load_trusted_plugins(workdir) == %{name => Crypto.fingerprint(pub)} + + Mix.Tasks.Mob.Plugin.Untrust.run_in([Atom.to_string(name)], workdir) + assert TrustStore.load_trusted_plugins(workdir) == %{} + end +end diff --git a/test/mix/tasks/mob_plugins_test.exs b/test/mix/tasks/mob_plugins_test.exs new file mode 100644 index 0000000..2aca2c2 --- /dev/null +++ b/test/mix/tasks/mob_plugins_test.exs @@ -0,0 +1,23 @@ +defmodule Mix.Tasks.Mob.PluginsTest do + use ExUnit.Case, async: true + + alias Mix.Tasks.Mob.Plugins + + describe "normalize_activated/1 (config :mob, :plugins coercion)" do + test "keeps a clean list of atom plugin names" do + assert Plugins.normalize_activated([:a, :b]) == [:a, :b] + end + + test "filters out non-atom entries (e.g. a stray string typo)" do + assert Plugins.normalize_activated([:a, "mob_haptic", :b]) == [:a, :b] + end + + test "a non-list (misconfigured) value coerces to [] instead of crashing" do + # The defect: `name in activated` raises Protocol.UndefinedError when + # :plugins is a non-list (e.g. a bare map or atom). + assert Plugins.normalize_activated(%{a: 1}) == [] + assert Plugins.normalize_activated(:not_a_list) == [] + assert Plugins.normalize_activated(nil) == [] + end + end +end diff --git a/test/mix/tasks/mob_provision_test.exs b/test/mix/tasks/mob_provision_test.exs new file mode 100644 index 0000000..cd4c822 --- /dev/null +++ b/test/mix/tasks/mob_provision_test.exs @@ -0,0 +1,199 @@ +defmodule Mix.Tasks.Mob.ProvisionTest do + use ExUnit.Case, async: true + + alias Mix.Tasks.Mob.Provision + + # ── diagnose_xcodebuild_failure/1 — the user-visible improvement ──────────── + # + # Each match preserves Apple's exact text so users can paste it into a search + # engine and find existing community answers. The hint is additive, not a + # replacement. Snippets below are excerpts of actual `xcodebuild` output we've + # seen — keep them realistic so future Apple wording changes show up as + # broken tests. + + describe "diagnose_xcodebuild_failure/1" do + test "matches 'The attribute name is invalid' (App ID rejected for too long / bad chars)" do + output = """ + ** BUILD FAILED ** + + The following build commands failed: + Check provisioning profile in another_political_name_app_bis.app + (1 failure) + + error: An attribute in the provided entity has invalid value: + The attribute 'name' is invalid: 'XC com example another_political_name_app_bis' + """ + + assert {label, snippet, hint} = Provision.diagnose_xcodebuild_failure(output) + assert label =~ "Apple rejected" + assert label =~ "App ID display name" + # Snippet preserves Apple's exact words — googleable. + assert snippet =~ "The attribute 'name' is invalid" + # Hint is actionable — points at mob.exs and `mix mob.new`. + assert hint =~ "config :mob_dev" + assert hint =~ "bundle_id" + end + + test "matches 'No signing certificate' (cert not in keychain)" do + output = """ + error: No signing certificate "iOS Development" found: + No "iOS Development" signing certificates matching team ID + "Q89CW299G8" with a private key were found. + """ + + assert {label, snippet, hint} = Provision.diagnose_xcodebuild_failure(output) + assert label =~ "signing certificate" + assert snippet =~ "No signing certificate" + assert hint =~ "Xcode → Settings → Accounts" + end + + test "matches 'requires a development team' (no team selected)" do + output = """ + error: Signing for "MobProvision" requires a development team. + Select a development team in the Signing & Capabilities editor. + """ + + assert {_, snippet, _} = Provision.diagnose_xcodebuild_failure(output) + assert snippet =~ "requires a development team" + end + + test "matches 'There are too many App IDs' (free-tier 3-per-7-days quota)" do + output = """ + error: Failed to register bundle identifier: + There are too many App IDs registered. Please delete some + currently registered App IDs and try again. + """ + + assert {label, snippet, hint} = Provision.diagnose_xcodebuild_failure(output) + assert label =~ "Free-tier App ID limit" + assert snippet =~ "too many App IDs" + assert hint =~ "wait" + assert hint =~ "reuse" + end + + test "matches 'Failed to register bundle identifier' (bundle ID owned by another team)" do + output = """ + error: Failed to register bundle identifier: + The app identifier "com.acme.foo" cannot be registered to your + development team. Change your bundle identifier to a unique string. + """ + + assert {label, snippet, hint} = Provision.diagnose_xcodebuild_failure(output) + assert label =~ "different team" + assert snippet =~ "Failed to register bundle identifier" + assert hint =~ "unique" + end + + test "returns nil for unrecognised errors so caller falls back to generic message" do + output = """ + ** BUILD FAILED ** + error: Some entirely new Apple error string nobody has seen before. + """ + + assert Provision.diagnose_xcodebuild_failure(output) == nil + end + + test "snippet is a single-line excerpt, not the full multi-line output" do + # Important so the snippet is paste-into-Google sized, not a wall of text. + output = """ + Build settings from command line: + ...big preamble... + + error: An attribute in the provided entity has invalid value: + The attribute 'name' is invalid: 'XC com example foo' + + ...trailing build chatter... + """ + + assert {_, snippet, _} = Provision.diagnose_xcodebuild_failure(output) + refute snippet =~ "preamble" + refute snippet =~ "trailing" + + refute String.contains?(snippet, "\n"), + "snippet should be one line so it's pasteable into a search engine; got: #{inspect(snippet)}" + end + + test "every recognised error includes an Apple-official documentation URL" do + # We deliberately link `developer.apple.com/help/account/...` URLs + # rather than third-party walkthroughs — Apple's account-management + # docs are the most stable reference and least likely to rot. + # Pin all five matched cases here so a future hint refactor that + # accidentally drops the link gets caught. + cases = [ + {"App ID name", + "error: An attribute in the provided entity has invalid value:\n The attribute 'name' is invalid: 'XC com example x'\n"}, + {"signing cert", "error: No signing certificate \"iOS Development\" found\n"}, + {"no team", "error: Signing for \"X\" requires a development team.\n"}, + {"App ID quota", "error: There are too many App IDs registered. Please delete some\n"}, + {"bundle id taken", + "error: Failed to register bundle identifier:\n The app identifier \"x\" cannot be registered\n"} + ] + + for {name, output} <- cases do + assert {_, _, hint} = Provision.diagnose_xcodebuild_failure(output), + "expected pattern for #{name} to match" + + assert hint =~ "developer.apple.com/help/account/", + "#{name}: hint must include an Apple-official help URL; got: #{hint}" + end + end + + test "patterns ordered by specificity — App-ID-name matches before bundle-id-taken" do + # The 'name is invalid' pattern is more specific than the + # 'Failed to register bundle identifier' header that often + # accompanies it. We want the more actionable diagnosis to win. + output = """ + error: Failed to register bundle identifier + error: The attribute 'name' is invalid: 'XC com example x' + """ + + assert {label, _, _} = Provision.diagnose_xcodebuild_failure(output) + assert label =~ "App ID display name" + end + end + + # ── asc_auth_args/1 — headless provisioning via App Store Connect API key ──── + describe "asc_auth_args/1" do + test "no env vars set => [] (falls back to the signed-in Xcode account)" do + assert Provision.asc_auth_args(%{}) == [] + assert Provision.asc_auth_args(%{"UNRELATED" => "x"}) == [] + end + + test "all three set => the three xcodebuild -authenticationKey* flags, in order" do + env = %{ + "APP_STORE_CONNECT_KEY_ID" => "ABC123", + "APP_STORE_CONNECT_ISSUER_ID" => "69a6de00-1234", + "APP_STORE_CONNECT_API_KEY_PATH" => "/keys/AuthKey_ABC123.p8" + } + + assert Provision.asc_auth_args(env) == [ + "-authenticationKeyID", + "ABC123", + "-authenticationKeyIssuerID", + "69a6de00-1234", + "-authenticationKeyPath", + "/keys/AuthKey_ABC123.p8" + ] + end + + test "empty-string values count as absent (KEY_ID= is the same as unset)" do + assert Provision.asc_auth_args(%{ + "APP_STORE_CONNECT_KEY_ID" => "", + "APP_STORE_CONNECT_ISSUER_ID" => "", + "APP_STORE_CONNECT_API_KEY_PATH" => "" + }) == [] + end + + test "partial config raises, naming what's set and what's missing" do + err = + assert_raise Mix.Error, fn -> + Provision.asc_auth_args(%{"APP_STORE_CONNECT_KEY_ID" => "ABC123"}) + end + + assert err.message =~ "Incomplete App Store Connect API key config" + assert err.message =~ "APP_STORE_CONNECT_KEY_ID" + assert err.message =~ "APP_STORE_CONNECT_ISSUER_ID" + assert err.message =~ "APP_STORE_CONNECT_API_KEY_PATH" + end + end +end diff --git a/test/mix/tasks/mob_regen_driver_tab_test.exs b/test/mix/tasks/mob_regen_driver_tab_test.exs new file mode 100644 index 0000000..64b2ffa --- /dev/null +++ b/test/mix/tasks/mob_regen_driver_tab_test.exs @@ -0,0 +1,219 @@ +defmodule Mix.Tasks.Mob.RegenDriverTabTest do + use ExUnit.Case, async: false + + alias Mix.Tasks.Mob.RegenDriverTab + + setup do + cwd = File.cwd!() + tmp = Path.join(System.tmp_dir!(), "mob_regen_test_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + File.cd!(tmp) + + on_exit(fn -> + File.cd!(cwd) + File.rm_rf!(tmp) + Application.delete_env(:mob_dev, :static_nifs) + end) + + %{tmp: tmp} + end + + describe "default run with new template (Phase 6a iter 4 — Zig)" do + setup %{tmp: tmp} do + # Simulate a project whose build.zig has the addZigObject helper. + File.mkdir_p!(Path.join(tmp, "ios")) + File.write!(Path.join(tmp, "ios/build.zig"), "fn addZigObject(opts: anytype) void {}\n") + :ok + end + + test "auto-detects and writes .zig when build.zig has addZigObject" do + capture_run([]) + + paths = RegenDriverTab.target_paths(:zig) + assert File.exists?(paths.ios) + assert File.exists?(paths.android) + c_paths = RegenDriverTab.target_paths(:c) + refute File.exists?(c_paths.ios) + refute File.exists?(c_paths.android) + end + + test "iOS Zig output uses comptime sqlite_static (no #ifdef)" do + capture_run([]) + ios_src = File.read!(RegenDriverTab.target_paths(:zig).ios) + + refute ios_src =~ "#ifdef MOB_STATIC_SQLITE_NIF" + assert ios_src =~ "sqlite_static" + assert ios_src =~ "sqlite3_nif_nif_init" + end + + test "Android Zig output omits sqlite3_nif" do + capture_run([]) + android_src = File.read!(RegenDriverTab.target_paths(:zig).android) + + refute android_src =~ "sqlite3_nif" + refute android_src =~ "sqlite_static" + end + end + + describe "default run with legacy template (no addZigObject → fall back to C)" do + setup %{tmp: tmp} do + # Simulate a project whose build.zig predates Phase 6a iter 2. + # The auto-detect path falls back to :c so the legacy addCObject → + # addCSourceFile chain keeps working. + File.mkdir_p!(Path.join(tmp, "ios")) + File.write!(Path.join(tmp, "ios/build.zig"), "fn addCObject(opts: anytype) void {}\n") + :ok + end + + test "auto-detects and writes .c when build.zig lacks addZigObject" do + capture_run([]) + + paths = RegenDriverTab.target_paths(:c) + assert File.exists?(paths.ios) + assert File.exists?(paths.android) + zig_paths = RegenDriverTab.target_paths(:zig) + refute File.exists?(zig_paths.ios) + refute File.exists?(zig_paths.android) + end + + test "iOS C output gates sqlite3_nif under MOB_STATIC_SQLITE_NIF" do + capture_run([]) + ios_src = File.read!(RegenDriverTab.target_paths(:c).ios) + + assert ios_src =~ "#ifdef MOB_STATIC_SQLITE_NIF" + assert ios_src =~ "sqlite3_nif_nif_init" + end + end + + describe "--format c (opt-out for hand-editable C)" do + test "writes .c paths instead of .zig when requested" do + capture_run(["--format", "c"]) + c = RegenDriverTab.target_paths(:c) + zig = RegenDriverTab.target_paths(:zig) + assert File.exists?(c.ios) + assert File.exists?(c.android) + refute File.exists?(zig.ios) + refute File.exists?(zig.android) + end + + test "C output uses #ifdef MOB_STATIC_SQLITE_NIF (legacy behavior)" do + capture_run(["--format", "c"]) + ios_src = File.read!(RegenDriverTab.target_paths(:c).ios) + assert ios_src =~ "#ifdef MOB_STATIC_SQLITE_NIF" + end + end + + describe ":static_nifs from app config" do + test "user-declared NIFs appear in the generated tables" do + Application.put_env(:mob_dev, :static_nifs, [%{module: :foo_native}]) + + capture_run([]) + paths = RegenDriverTab.target_paths(:zig) + + assert File.read!(paths.ios) =~ "foo_native_nif_init" + assert File.read!(paths.android) =~ "foo_native_nif_init" + end + + test "invalid entry raises Mix.Error with a useful message" do + Application.put_env(:mob_dev, :static_nifs, [%{module: :broken, archs: [:windows]}]) + + assert_raise Mix.Error, ~r/unknown archs/, fn -> + RegenDriverTab.run([]) + end + end + end + + describe "--check mode" do + test "passes silently when files match" do + capture_run([]) + # Re-run in --check mode against fresh files — no drift, so + # no Mix.raise; the success path prints "files match :static_nifs". + out = capture_run(["--check"]) + assert out =~ "files match" + refute out =~ "drift" + end + + test "raises with a list of drifted paths" do + capture_run([]) + paths = RegenDriverTab.target_paths(:zig) + File.write!(paths.ios, "// tampered\n") + + assert_raise Mix.Error, ~r/driver_tab drift detected/, fn -> + RegenDriverTab.run(["--check"]) + end + end + end + + describe "deterministic output" do + test "two regen runs against the same manifest produce identical bytes" do + capture_run([]) + paths = RegenDriverTab.target_paths(:zig) + first_ios = File.read!(paths.ios) + first_android = File.read!(paths.android) + + capture_run([]) + assert File.read!(paths.ios) == first_ios + assert File.read!(paths.android) == first_android + end + + test "second run reports 'unchanged' rather than rewriting" do + capture_run([]) + out = capture_run([]) + + assert out =~ "(unchanged)" + end + end + + defp capture_run(args) do + ExUnit.CaptureIO.capture_io(fn -> RegenDriverTab.run(args) end) + end + + describe "reject_uncompiled_plugin_nifs/2" do + @c_nif %{module: :mob_location_nif, native_dir: "/x", lang: :c} + # a plugin NIF with no :lang defaults to C + @default_nif %{module: :mob_photos_nif, native_dir: "/y"} + @zig_nif %{module: :mob_bluetooth_nif, native_dir: "/z", lang: :zig} + + test ":ios drops zig plugin NIFs (no iOS zig compile path) but keeps C ones" do + nifs = [@c_nif, @default_nif, @zig_nif] + kept = RegenDriverTab.reject_uncompiled_plugin_nifs(nifs, :ios) + assert @c_nif in kept + assert @default_nif in kept + refute @zig_nif in kept + end + + test ":android and :all keep C and zig plugin NIFs (both langs compile there)" do + nifs = [@c_nif, @default_nif, @zig_nif] + assert RegenDriverTab.reject_uncompiled_plugin_nifs(nifs, :android) == nifs + assert RegenDriverTab.reject_uncompiled_plugin_nifs(nifs, :all) == nifs + end + + @objc_nif %{module: :perm_nif, native_dir: "/o", lang: :objc} + + test ":android drops objc plugin NIFs (no Android Obj-C runtime), iOS/:all keep them" do + nifs = [@c_nif, @objc_nif, @zig_nif] + refute @objc_nif in RegenDriverTab.reject_uncompiled_plugin_nifs(nifs, :android) + assert @objc_nif in RegenDriverTab.reject_uncompiled_plugin_nifs(nifs, :ios) + assert @objc_nif in RegenDriverTab.reject_uncompiled_plugin_nifs(nifs, :all) + end + + @ios_only %{module: :mob_location_nif, native_dir: "/i", lang: :c, platform: :ios} + @android_only %{module: :mob_location_nif, native_dir: "/a", lang: :zig, platform: :android} + + test "platform-tagged NIFs only survive on their own platform" do + nifs = [@ios_only, @android_only, @default_nif] + + ios = RegenDriverTab.reject_uncompiled_plugin_nifs(nifs, :ios) + assert @ios_only in ios + assert @default_nif in ios + refute @android_only in ios + + android = RegenDriverTab.reject_uncompiled_plugin_nifs(nifs, :android) + assert @android_only in android + assert @default_nif in android + refute @ios_only in android + + assert RegenDriverTab.reject_uncompiled_plugin_nifs(nifs, :all) == nifs + end + end +end diff --git a/test/mix/tasks/mob_release_openssl_test.exs b/test/mix/tasks/mob_release_openssl_test.exs new file mode 100644 index 0000000..840a42c --- /dev/null +++ b/test/mix/tasks/mob_release_openssl_test.exs @@ -0,0 +1,148 @@ +defmodule Mix.Tasks.Mob.Release.OpensslTest do + use ExUnit.Case, async: false + + import Mox + + alias Mix.Tasks.Mob.Release.Openssl, as: OpenSSLTask + + setup :verify_on_exit! + + setup do + Application.put_env(:mob_dev, :release_shell, MobDev.Release.ShellMock) + on_exit(fn -> Application.delete_env(:mob_dev, :release_shell) end) + :ok + end + + describe "argument parsing" do + test "missing target argument raises a usage message" do + assert_raise Mix.Error, ~r/missing target argument/, fn -> + OpenSSLTask.run([]) + end + end + + test "unknown target raises with the valid list" do + assert_raise Mix.Error, ~r/unknown target: nonsense.*valid:/s, fn -> + OpenSSLTask.run(["nonsense"]) + end + end + + test "too many positional args raises" do + assert_raise Mix.Error, ~r/too many arguments/, fn -> + OpenSSLTask.run(["android_arm64", "ios_sim"]) + end + end + end + + describe "one target — happy path" do + test "android_arm64 invokes OpenSSL build then CryptoNif build in sequence" do + stub_dir_checks_true() + + # Phase 1: OpenSSL build (1 distclean + 1 Configure + 1 make + 1 install_sw) + stub_openssl_build_calls() + + # Phase 2: crypto NIF build (mkdir × 2, 31 compile, 1 rm_f, 1 ar, 1 ranlib, 1 nm) + stub(MobDev.Release.ShellMock, :mkdir_p, fn _ -> :ok end) + stub(MobDev.Release.ShellMock, :rm_f, fn _ -> :ok end) + + # Reuse the OpenSSL Mox; the second wave of cmd calls are the + # crypto NIF phase. We let everything through and assert on the + # nm output that ships back. + Mox.expect(MobDev.Release.ShellMock, :cmd, 31, fn argv, _opts -> + # Compile phase — verify some marker + assert "-DSTATIC_ERLANG_NIF" in argv + {:ok, ""} + end) + + # ar + ranlib + nm + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert "rcs" in argv + {:ok, ""} + end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert hd(argv) =~ "llvm-ranlib" + {:ok, ""} + end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert hd(argv) =~ "llvm-nm" + {:ok, "0000000000000000 T crypto_nif_init\n"} + end) + + # No raise expected — task completes silently. + OpenSSLTask.run([ + "android_arm64", + "--openssl-src", + "/fake/openssl", + "--otp-src", + "/fake/otp", + "--prefix", + "/fake/prefix", + "--ndk-root", + "/fake/ndk" + ]) + end + end + + describe "error paths" do + test "OpenSSL build failure raises with the formatted error" do + # Make OPENSSL_SRC missing — precondition_failed before any cmd. + Mox.expect(MobDev.Release.ShellMock, :dir?, fn _ -> false end) + + assert_raise Mix.Error, ~r/OPENSSL_SRC missing/, fn -> + OpenSSLTask.run([ + "android_arm64", + "--openssl-src", + "/nonexistent", + "--otp-src", + "/fake/otp" + ]) + end + end + + test "crypto_nif_init missing in nm output raises" do + stub_dir_checks_true() + stub_openssl_build_calls() + stub(MobDev.Release.ShellMock, :mkdir_p, fn _ -> :ok end) + stub(MobDev.Release.ShellMock, :rm_f, fn _ -> :ok end) + + # 31 compile + ar + ranlib succeed; nm returns "undefined" — the + # exact regression we want to fail loudly. + Mox.expect(MobDev.Release.ShellMock, :cmd, 31, fn _, _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn _, _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn _, _ -> {:ok, ""} end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert hd(argv) =~ "llvm-nm" + {:ok, " U crypto_nif_init\n"} + end) + + assert_raise Mix.Error, ~r/crypto_nif_init/, fn -> + OpenSSLTask.run([ + "android_arm64", + "--openssl-src", + "/fake/openssl", + "--otp-src", + "/fake/otp", + "--ndk-root", + "/fake/ndk" + ]) + end + end + end + + # ── Helpers ───────────────────────────────────────────────────────── + + defp stub_dir_checks_true do + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + end + + defp stub_openssl_build_calls do + # OpenSSL build: distclean + Configure + make + install_sw = 4 cmd calls. + # We stub them all with success. + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "distclean"], _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["./Configure" | _], _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "-j8"], _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "install_sw"], _ -> {:ok, ""} end) + end +end diff --git a/test/mix/tasks/mob_release_otp_test.exs b/test/mix/tasks/mob_release_otp_test.exs new file mode 100644 index 0000000..1f9392d --- /dev/null +++ b/test/mix/tasks/mob_release_otp_test.exs @@ -0,0 +1,188 @@ +defmodule Mix.Tasks.Mob.Release.OtpTest do + use ExUnit.Case, async: false + + import Mox + + alias Mix.Tasks.Mob.Release.Otp, as: OTPTask + + setup :verify_on_exit! + + setup do + Application.put_env(:mob_dev, :release_shell, MobDev.Release.ShellMock) + otp_src = mk_tmp_otp_fixture() + + on_exit(fn -> + Application.delete_env(:mob_dev, :release_shell) + File.rm_rf!(otp_src) + end) + + %{otp_src: otp_src} + end + + describe "argument parsing" do + test "missing target raises a usage message" do + assert_raise Mix.Error, ~r/missing target argument/, fn -> + OTPTask.run([]) + end + end + + test "unknown target raises with the valid list" do + assert_raise Mix.Error, ~r/unknown target: bogus.*valid:/s, fn -> + OTPTask.run(["bogus"]) + end + end + + test "too many positional args raises" do + assert_raise Mix.Error, ~r/too many arguments/, fn -> + OTPTask.run(["android_arm64", "ios_sim"]) + end + end + end + + describe "happy paths" do + test "android_arm64 runs the full OTP build pipeline with the right ssl flags", + %{otp_src: otp_src} do + stub_predicates_true() + + configure_argv = :atomics.new(1, signed: false) + _ = configure_argv + + pid = self() + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + if "configure" in argv do + send(pid, {:configure, argv}) + end + + cond do + hd(argv) == "ls" -> {:ok, "crypto-5.6\npublic_key-1.18\nssl-11.4\n"} + true -> {:ok, ""} + end + end) + + OTPTask.run([ + "android_arm64", + "--otp-src", + otp_src, + "--openssl-prefix", + "/openssl/prefix", + "--release-root", + "/fake/release", + "--ndk-root", + "/fake/ndk" + ]) + + assert_received {:configure, argv} + assert "--with-ssl=/openssl/prefix" in argv + assert "--disable-dynamic-ssl-lib" in argv + end + + test "ios_sim runs without --openssl-prefix and uses --without-ssl", + %{otp_src: otp_src} do + stub_predicates_true() + pid = self() + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + if "configure" in argv do + send(pid, {:configure, argv}) + end + + {:ok, ""} + end) + + OTPTask.run([ + "ios_sim", + "--otp-src", + otp_src, + "--release-root", + "/fake/release" + ]) + + assert_received {:configure, argv} + assert "--without-ssl" in argv + refute Enum.any?(argv, &String.starts_with?(&1, "--with-ssl")) + end + end + + describe "error paths" do + test "OTP_SRC missing raises with the clone hint" do + stub(MobDev.Release.ShellMock, :dir?, fn _ -> false end) + + assert_raise Mix.Error, ~r/OTP_SRC missing/, fn -> + OTPTask.run([ + "android_arm64", + "--otp-src", + "/nonexistent", + "--openssl-prefix", + "/openssl/prefix", + "--ndk-root", + "/fake/ndk" + ]) + end + end + + test "android target without openssl_prefix raises with OpenSSL hint", + %{otp_src: otp_src} do + stub_predicates_true() + + assert_raise Mix.Error, ~r/openssl_prefix required/, fn -> + OTPTask.run([ + "android_arm64", + "--otp-src", + otp_src, + "--ndk-root", + "/fake/ndk" + ]) + end + end + + test "Android verify catches missing crypto apps with --with-ssl hint", + %{otp_src: otp_src} do + stub_predicates_true() + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + if hd(argv) == "ls" do + # missing crypto/public_key/ssl — silent shipping bug we + # want to fail loudly. + {:ok, "kernel-9.0\nstdlib-6.0\n"} + else + {:ok, ""} + end + end) + + assert_raise Mix.Error, ~r/crypto.*--with-ssl/s, fn -> + OTPTask.run([ + "android_arm64", + "--otp-src", + otp_src, + "--openssl-prefix", + "/openssl/prefix", + "--release-root", + "/fake/release", + "--ndk-root", + "/fake/ndk" + ]) + end + end + end + + # ── Helpers ────────────────────────────────────────────────────────── + + defp stub_predicates_true do + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + end + + defp mk_tmp_otp_fixture do + tmp = + Path.join( + System.tmp_dir!(), + "mob_dev_otp_task_#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(Path.join(tmp, "erts")) + File.write!(Path.join([tmp, "erts", "vsn.mk"]), "VSN = 17.0\n") + File.touch!(Path.join(tmp, "otp_build")) + tmp + end +end diff --git a/test/mix/tasks/mob_release_publish_test.exs b/test/mix/tasks/mob_release_publish_test.exs new file mode 100644 index 0000000..ccc3210 --- /dev/null +++ b/test/mix/tasks/mob_release_publish_test.exs @@ -0,0 +1,116 @@ +defmodule Mix.Tasks.Mob.Release.PublishTest do + use ExUnit.Case, async: false + + import Mox + + alias Mix.Tasks.Mob.Release.Publish, as: PublishTask + + setup :verify_on_exit! + + setup do + Application.put_env(:mob_dev, :release_shell, MobDev.Release.ShellMock) + on_exit(fn -> Application.delete_env(:mob_dev, :release_shell) end) + :ok + end + + describe "argument parsing" do + test "unexpected positional argument raises usage message" do + assert_raise Mix.Error, ~r/unexpected positional arguments/, fn -> + PublishTask.run(["surprise"]) + end + end + end + + describe "happy path" do + test "runs the publish pipeline + prints the produced asset list" do + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + cond do + match?(["gh", "release", "view" | _], argv) and "--json" in argv -> + {:ok, "otp-android-abc12345.tar.gz\n"} + + match?(["gh", "release", "view" | _], argv) -> + {:ok, "title: x\n"} + + match?(["gh", "release", "delete-asset" | _], argv) -> + {:ok, ""} + + match?(["gh", "release", "upload" | _], argv) -> + {:ok, ""} + + true -> + {:ok, ""} + end + end) + + prev_shell = Mix.shell() + Mix.shell(Mix.Shell.IO) + + output = + try do + ExUnit.CaptureIO.capture_io(fn -> + PublishTask.run(["--hash", "abc12345", "--out-dir", "/tmp"]) + end) + after + Mix.shell(prev_shell) + end + + assert output =~ "otp-abc12345" + assert output =~ "GenericJam/mob" + assert output =~ "otp-android-abc12345.tar.gz" + end + end + + describe "error paths" do + test "no tarballs present → Mix.raise with the precondition hint" do + stub(MobDev.Release.ShellMock, :file?, fn _ -> false end) + + assert_raise Mix.Error, ~r/no tarballs found.*abc12345/s, fn -> + PublishTask.run(["--hash", "abc12345", "--out-dir", "/tmp"]) + end + end + + test "gh auth failure → Mix.raise with auth_required formatting" do + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + {:error, {:cmd_failed, %{cmd: argv, exit: 1, output: "HTTP 401: Bad credentials"}}} + end) + + assert_raise Mix.Error, ~r/authentication required.*gh auth login/s, fn -> + PublishTask.run(["--hash", "abc12345", "--out-dir", "/tmp"]) + end + end + + test "gh infra outage → Mix.raise with infra_unreachable formatting" do + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + {:error, {:cmd_failed, %{cmd: argv, exit: 1, output: "HTTP 503: Service Unavailable"}}} + end) + + assert_raise Mix.Error, ~r/external infrastructure unreachable.*503/s, fn -> + PublishTask.run(["--hash", "abc12345", "--out-dir", "/tmp"]) + end + end + + test "--assets accepts comma-separated basenames" do + # Only the otp-ios-sim file exists; user asks for two assets. + stub(MobDev.Release.ShellMock, :file?, fn path -> + String.ends_with?(path, "otp-ios-sim-abc12345.tar.gz") + end) + + assert_raise Mix.Error, ~r/missing tarballs.*otp-android-arm32/s, fn -> + PublishTask.run([ + "--hash", + "abc12345", + "--out-dir", + "/tmp", + "--assets", + "otp-ios-sim,otp-android-arm32" + ]) + end + end + end +end diff --git a/test/mix/tasks/mob_release_tarball_test.exs b/test/mix/tasks/mob_release_tarball_test.exs new file mode 100644 index 0000000..ff1babd --- /dev/null +++ b/test/mix/tasks/mob_release_tarball_test.exs @@ -0,0 +1,169 @@ +defmodule Mix.Tasks.Mob.Release.TarballTest do + use ExUnit.Case, async: false + + import Mox + + alias Mix.Tasks.Mob.Release.Tarball, as: TarballTask + + setup :verify_on_exit! + + setup do + Application.put_env(:mob_dev, :release_shell, MobDev.Release.ShellMock) + on_exit(fn -> Application.delete_env(:mob_dev, :release_shell) end) + :ok + end + + describe "argument parsing" do + test "missing target raises a usage message" do + assert_raise Mix.Error, ~r/missing target argument/, fn -> + TarballTask.run([]) + end + end + + test "unknown target raises with the valid list" do + assert_raise Mix.Error, ~r/unknown target: bogus.*valid:/s, fn -> + TarballTask.run(["bogus"]) + end + end + end + + describe "happy paths" do + test "android_arm64 builds the tarball + prints the produced path" do + {otp_src, exqlite_build} = mk_tmp_project() + + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :fetch_env, fn _ -> :error end) + stub(MobDev.Release.ShellMock, :mkdir_p, fn _ -> :ok end) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + cond do + hd(argv) == "mktemp" -> + {:ok, "/tmp/fake-stage\n"} + + hd(argv) == "tar" and "tzf" in argv -> + {:ok, + """ + fake-stage/erts-17.0/ + fake-stage/erts-17.0/lib/crypto.a + fake-stage/erts-17.0/lib/libcrypto.a + fake-stage/lib/elixir/ebin/elixir.app + fake-stage/lib/crypto-5.6/priv/lib/crypto.so + fake-stage/lib/public_key-1.18/ebin/public_key.beam + fake-stage/lib/ssl-11.4/ebin/ssl.beam + """} + + true -> + {:ok, ""} + end + end) + + TarballTask.run([ + "android_arm64", + "--otp-src", + otp_src, + "--hash", + "abc12345", + "--out-dir", + "/out", + "--otp-release", + "/tmp/otp-android", + "--openssl-prefix", + "/tmp/openssl-android-arm64", + "--exqlite-build", + exqlite_build + ]) + + File.rm_rf!(otp_src) + end + end + + describe "error paths" do + test "Android target without --exqlite-build raises the precondition message" do + {otp_src, _exqlite_build} = mk_tmp_project() + + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :fetch_env, fn _ -> :error end) + + assert_raise Mix.Error, ~r/exqlite_build required/, fn -> + TarballTask.run([ + "android_arm64", + "--otp-src", + otp_src, + "--hash", + "abc12345" + ]) + end + + File.rm_rf!(otp_src) + end + + test "missing crypto.so in verify raises with the regex-shaped pattern" do + {otp_src, exqlite_build} = mk_tmp_project() + + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :fetch_env, fn _ -> :error end) + stub(MobDev.Release.ShellMock, :mkdir_p, fn _ -> :ok end) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + cond do + hd(argv) == "mktemp" -> + {:ok, "/tmp/fake-stage\n"} + + hd(argv) == "tar" and "tzf" in argv -> + # Missing crypto.so — exact silent-shipping bug. + {:ok, + """ + stage/erts-17.0/ + stage/erts-17.0/lib/crypto.a + stage/erts-17.0/lib/libcrypto.a + stage/lib/elixir/ebin/elixir.app + stage/lib/public_key-1.18/ebin/public_key.beam + stage/lib/ssl-11.4/ebin/ssl.beam + """} + + true -> + {:ok, ""} + end + end) + + assert_raise Mix.Error, ~r/crypto-.*crypto.so/, fn -> + TarballTask.run([ + "android_arm64", + "--otp-src", + otp_src, + "--hash", + "abc12345", + "--exqlite-build", + exqlite_build + ]) + end + + File.rm_rf!(otp_src) + end + end + + defp mk_tmp_project do + uniq = System.unique_integer([:positive]) + base = Path.join(System.tmp_dir!(), "mob_dev_tarball_task_#{uniq}") + + otp_src = Path.join(base, "otp_src") + File.mkdir_p!(Path.join(otp_src, "erts")) + File.write!(Path.join([otp_src, "erts", "vsn.mk"]), "VSN = 17.0\n") + File.touch!(Path.join(otp_src, "otp_build")) + + project_root = Path.join(base, "project") + exqlite_build = Path.join([project_root, "_build/dev/lib/exqlite"]) + File.mkdir_p!(Path.join(exqlite_build, "ebin")) + File.write!(Path.join([exqlite_build, "ebin", "exqlite.app"]), "{vsn, \"0.39.0\"}.") + + File.write!( + Path.join(project_root, "mix.lock"), + "%{\"exqlite\": {:hex, :exqlite, \"0.39.0\", \"x\", [:make, :mix], [], \"hexpm\", \"x\"}}" + ) + + {otp_src, exqlite_build} + end +end diff --git a/test/mix/tasks/mob_styles_test.exs b/test/mix/tasks/mob_styles_test.exs new file mode 100644 index 0000000..a2a1d1e --- /dev/null +++ b/test/mix/tasks/mob_styles_test.exs @@ -0,0 +1,11 @@ +defmodule Mix.Tasks.Mob.StylesTest do + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + + test "with no styles configured, points at the activation recipe" do + out = capture_io(fn -> Mix.Tasks.Mob.Styles.run([]) end) + assert out =~ "No style packages activated" + assert out =~ "config :mob, :styles" + end +end diff --git a/test/mix/tasks/mob_uninstall_test.exs b/test/mix/tasks/mob_uninstall_test.exs new file mode 100644 index 0000000..0c21eae --- /dev/null +++ b/test/mix/tasks/mob_uninstall_test.exs @@ -0,0 +1,191 @@ +defmodule Mix.Tasks.Mob.UninstallTest do + use ExUnit.Case, async: true + + alias Mix.Tasks.Mob.Uninstall + alias MobDev.{Device, Uninstaller} + + defp android(name), do: %Device{name: name, serial: name, platform: :android} + + defp result(device, bundle_id, outcome, reason \\ nil) do + %{device: device, bundle_id: bundle_id, outcome: outcome, reason: reason} + end + + defp strip_ansi(s), do: String.replace(s, ~r/\e\[[0-9;]*m/, "") + + # ── format_summary/3 ──────────────────────────────────────────────────── + + describe "format_summary/3" do + test "all three buckets empty → no-op message" do + [line] = Uninstall.format_summary([], [], []) + assert line =~ "No-op — nothing to uninstall" + end + + test "only uninstalled → green Uninstalled header" do + lines = Uninstall.format_summary([result(android("a"), "com.x", :uninstalled)], [], []) + flat = lines |> Enum.map(&strip_ansi/1) |> Enum.join("\n") + + assert flat =~ "Uninstalled: 1" + assert flat =~ "✓ a: com.x" + refute flat =~ "Failed" + refute flat =~ "Skipped" + end + + test "only skipped → yellow Skipped header (not Failed)" do + # Regression: the skipped-not-installed case is NOT a failure. + # mirrors the same fix in Mix.Tasks.Mob.Deploy.format_summary/4. + skip = result(android("a"), "com.x", :skipped, "not installed") + lines = Uninstall.format_summary([], [], [skip]) + flat = lines |> Enum.map(&strip_ansi/1) |> Enum.join("\n") + + assert flat =~ "Skipped (not installed): 1" + assert flat =~ "— a: com.x (not installed)" + refute flat =~ "Failed" + end + + test "only failed → red Failed header" do + fail = result(android("a"), "com.x", :error, "adb timeout") + lines = Uninstall.format_summary([], [fail], []) + flat = lines |> Enum.map(&strip_ansi/1) |> Enum.join("\n") + + assert flat =~ "Failed: 1" + assert flat =~ "✗ a: com.x (adb timeout)" + end + + test "mixed three-way result renders all blocks distinctly" do + ok = result(android("a"), "com.x", :uninstalled) + fail = result(android("b"), "com.x", :error, "adb timeout") + skip = result(android("c"), "com.x", :skipped, "not installed") + + lines = Uninstall.format_summary([ok], [fail], [skip]) + flat = lines |> Enum.map(&strip_ansi/1) |> Enum.join("\n") + + assert flat =~ "Uninstalled: 1" + assert flat =~ "Skipped (not installed): 1" + assert flat =~ "Failed: 1" + # Each result uses its category-specific marker. + assert flat =~ "✓ a" + assert flat =~ "— c" + assert flat =~ "✗ b" + end + + test "nuke-everything: 5 androids × 3 apps with mixed outcomes" do + # The scenario the user explicitly asked for — clear out every + # test app on every emulator. Some weren't there (skipped), + # some had stale state requiring re-install (uninstalled), no + # errors expected. Pin the shape. + devices = for i <- 1..5, do: android("emu-#{i}") + + results = + for d <- devices, app <- ~w(com.example.a com.example.b com.example.c) do + # First app present on every device; second on only the + # first two; third on none. + cond do + app == "com.example.a" -> + result(d, app, :uninstalled) + + app == "com.example.b" and d.name in ["emu-1", "emu-2"] -> + result(d, app, :uninstalled) + + true -> + result(d, app, :skipped, "not installed") + end + end + + {u, f, s} = Uninstaller.categorize_results(results) + lines = Uninstall.format_summary(u, f, s) + flat = lines |> Enum.map(&strip_ansi/1) |> Enum.join("\n") + + assert flat =~ "Uninstalled: 7" + assert flat =~ "Skipped (not installed): 8" + refute flat =~ "Failed" + end + end + + # ── should_skip_prompt?/2 ─────────────────────────────────────────────── + + describe "should_skip_prompt?/2" do + defp plan_one_one, do: [{android("emu"), ["com.x"]}] + defp plan_one_many, do: [{android("emu"), ["com.x", "com.y"]}] + defp plan_many_one, do: [{android("a"), ["com.x"]}, {android("b"), ["com.x"]}] + defp plan_many_many, do: [{android("a"), ["com.x", "com.y"]}, {android("b"), ["com.z"]}] + + test "single device + single bundle → skip prompt (low blast radius)" do + assert Uninstall.should_skip_prompt?(plan_one_one(), []) + end + + test "single device + multiple bundles → confirm" do + refute Uninstall.should_skip_prompt?(plan_one_many(), []) + end + + test "multiple devices + single bundle → confirm" do + refute Uninstall.should_skip_prompt?(plan_many_one(), []) + end + + test "multiple devices + multiple bundles → confirm" do + refute Uninstall.should_skip_prompt?(plan_many_many(), []) + end + + test "--yes overrides confirm for multi-target" do + assert Uninstall.should_skip_prompt?(plan_many_many(), yes: true) + end + + test "REGRESSION: opts[:yes] absent (nil) does NOT raise BadBooleanError" do + # The original implementation did `opts[:yes] or single_target?` + # which crashed under Elixir 1.20's stricter type checker because + # `opts[:yes]` is nil (not false) when --yes isn't passed. + # Real user repro: + # + # mix mob.uninstall --all-devices + # ** (BadBooleanError) expected a boolean on left-side of "or", + # got: nil + # + # Pin the boolean coercion so the bug can't reappear. The + # specific user invocation: --all-devices, no --yes. No crash + # AND specifically false (multi-target → needs confirmation). + assert Uninstall.should_skip_prompt?(plan_many_many(), []) == false + assert Uninstall.should_skip_prompt?(plan_many_many(), all_devices: true) == false + end + + test "opts[:yes] = false (explicit) is equivalent to absent" do + refute Uninstall.should_skip_prompt?(plan_many_many(), yes: false) + end + end + + # ── --help / -h ───────────────────────────────────────────────────────── + + describe "--help integration" do + test "--help prints the @moduledoc and exits cleanly" do + output = + ExUnit.CaptureIO.capture_io(fn -> + Uninstall.run(["--help"]) + end) + + # @moduledoc covers the user-facing surface. Pin stable + # substrings so the test isn't brittle to wording. + assert output =~ "Uninstall a Mob app" + assert output =~ "--all-devices" + assert output =~ "--all-apps" + assert output =~ "--bundle-id" + end + + test "-h is treated the same as --help" do + output = + ExUnit.CaptureIO.capture_io(fn -> + Uninstall.run(["-h"]) + end) + + assert output =~ "Uninstall a Mob app" + end + + test "--help short-circuits before parsing other flags" do + # Important: even invalid flag combos should print help, not + # crash, when --help is present. + output = + ExUnit.CaptureIO.capture_io(fn -> + Uninstall.run(["--help", "--obviously-not-a-real-flag"]) + end) + + assert output =~ "Uninstall a Mob app" + end + end +end diff --git a/test/mob_dev/adopt_guard_test.exs b/test/mob_dev/adopt_guard_test.exs new file mode 100644 index 0000000..6ab023e --- /dev/null +++ b/test/mob_dev/adopt_guard_test.exs @@ -0,0 +1,189 @@ +defmodule MobDev.AdoptGuardTest do + use ExUnit.Case, async: true + + import Igniter.Test + + alias MobDev.AdoptGuard + + @phx_mix_exs """ + defmodule Test.MixProject do + use Mix.Project + def project, do: [app: :test, version: "0.1.0", elixir: "~> 1.15", deps: deps()] + def application, do: [extra_applications: [:logger]] + defp deps, + do: [ + {:phoenix, "~> 1.7"}, + {:ecto_sql, "~> 3.10"}, + {:ecto_sqlite3, "~> 0.18"} + ] + end + """ + + @phx_postgres_mix_exs """ + defmodule Test.MixProject do + use Mix.Project + def project, do: [app: :test, version: "0.1.0", elixir: "~> 1.15", deps: deps()] + def application, do: [extra_applications: [:logger]] + defp deps, + do: [ + {:phoenix, "~> 1.7"}, + {:ecto_sql, "~> 3.10"}, + {:postgrex, ">= 0.0.0"} + ] + end + """ + + @phx_no_ecto_mix_exs """ + defmodule Test.MixProject do + use Mix.Project + def project, do: [app: :test, version: "0.1.0", elixir: "~> 1.15", deps: deps()] + def application, do: [extra_applications: [:logger]] + defp deps, do: [{:phoenix, "~> 1.7"}] + end + """ + + @stock_app_js """ + import {Socket} from "phoenix" + let liveSocket = new LiveSocket("/live", Socket, {hooks: {}}) + """ + + @stock_root_heex """ + <html> + <body> + Hello + </body> + </html> + """ + + defp blessed_project(extra_files \\ %{}) do + test_project(files: Map.merge(blessed_files(), extra_files)) + end + + defp blessed_files do + %{ + "mix.exs" => @phx_mix_exs, + "assets/js/app.js" => @stock_app_js, + "lib/test_web/components/layouts/root.html.heex" => @stock_root_heex + } + end + + # Build a project from the blessed set minus the given keys. Used to + # test "missing X" cases — building without it from the start is the + # only reliable way to drop a file (deleting from `test_files` after + # `test_project` still leaves it in `rewrite.sources` via the + # `**/*.*` include_glob). + defp project_without(keys) do + test_project(files: Map.drop(blessed_files(), keys)) + end + + describe "umbrella" do + test "refused when Mix.Project.umbrella? returns true" do + igniter = + blessed_project() + |> Igniter.assign(:umbrella?, true) + |> AdoptGuard.check(:live_view) + + assert Enum.any?(igniter.issues, &String.contains?(&1, "umbrella applications")) + end + end + + describe "Phoenix dep" do + test "refused when :phoenix not in deps" do + igniter = + test_project() + |> AdoptGuard.check(:thin) + + assert Enum.any?(igniter.issues, &String.contains?(&1, "requires a Phoenix project")) + end + end + + describe "LV-mode shape (:live_view)" do + test "refused without assets/js/app.js" do + igniter = + project_without(["assets/js/app.js"]) + |> AdoptGuard.check(:live_view) + + assert Enum.any?( + igniter.issues, + &(String.contains?(&1, "requires assets/js/app.js") and + String.contains?(&1, "--no-live-view")) + ) + end + + test "refused when app.js has no `new LiveSocket(`" do + igniter = + blessed_project(%{"assets/js/app.js" => "// custom bundle, no LiveSocket\n"}) + |> AdoptGuard.check(:live_view) + + assert Enum.any?(igniter.issues, &String.contains?(&1, "stock `new LiveSocket")) + end + + test "refused without root.html.heex" do + igniter = + project_without(["lib/test_web/components/layouts/root.html.heex"]) + |> AdoptGuard.check(:live_view) + + assert Enum.any?(igniter.issues, &String.contains?(&1, "requires a root layout")) + end + + test "refused when root.html.heex has no <body>" do + igniter = + blessed_project(%{ + "lib/test_web/components/layouts/root.html.heex" => "<div>nothing here</div>\n" + }) + |> AdoptGuard.check(:live_view) + + assert Enum.any?(igniter.issues, &String.contains?(&1, "requires a `<body>` tag")) + end + + test "refused when host has no Ecto Repo (no :ecto_sql in deps)" do + igniter = + blessed_project(%{"mix.exs" => @phx_no_ecto_mix_exs}) + |> AdoptGuard.check(:live_view) + + assert Enum.any?(igniter.issues, &String.contains?(&1, "no Ecto Repo")) + end + + test "refused when host Repo uses Postgres (not SQLite)" do + igniter = + blessed_project(%{"mix.exs" => @phx_postgres_mix_exs}) + |> AdoptGuard.check(:live_view) + + assert Enum.any?( + igniter.issues, + &(String.contains?(&1, "assumes SQLite") and String.contains?(&1, "Postgres")) + ) + end + + test "blessed shape passes" do + igniter = blessed_project() |> AdoptGuard.check(:live_view) + assert igniter.issues == [] + end + end + + describe "thin-mode (:thin)" do + test "passes without app.js / root.html.heex when project has :phoenix" do + igniter = + test_project(files: %{"mix.exs" => @phx_mix_exs}) + |> AdoptGuard.check(:thin) + + assert igniter.issues == [] + end + + test "passes against a Postgres host (no on-device DB in thin mode)" do + igniter = + test_project(files: %{"mix.exs" => @phx_postgres_mix_exs}) + |> AdoptGuard.check(:thin) + + assert igniter.issues == [] + end + + test "passes against a no-Ecto host (thin mode doesn't need a Repo)" do + igniter = + test_project(files: %{"mix.exs" => @phx_no_ecto_mix_exs}) + |> AdoptGuard.check(:thin) + + assert igniter.issues == [] + end + end +end diff --git a/test/mob_dev/android_deploy_lock_test.exs b/test/mob_dev/android_deploy_lock_test.exs new file mode 100644 index 0000000..428cbd9 --- /dev/null +++ b/test/mob_dev/android_deploy_lock_test.exs @@ -0,0 +1,656 @@ +defmodule MobDev.AndroidDeployLockTest do + use ExUnit.Case, async: true + + alias MobDev.AndroidDeployLock + + @bundle "com.example.casein" + @owner "ownerproof000001" + + test "preflights the exact sorted set before acquiring any target" do + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(commands, &[args | &1]) + {"", 0} + end + + assert {:ok, lease} = + AndroidDeployLock.acquire(@bundle, ["serial-b", "serial-a"], runner, owner: @owner) + + assert lease == held_lease(["serial-a", "serial-b"]) + assert AndroidDeployLock.valid?(lease) + assert AndroidDeployLock.valid?(lease, :acquired) + refute AndroidDeployLock.valid?(lease, :native_ready) + + history = Agent.get(commands, &Enum.reverse/1) + assert Enum.map(Enum.take(history, 2), &Enum.at(&1, 1)) == ["serial-a", "serial-b"] + assert Enum.all?(Enum.take(history, 2), &(List.last(&1) =~ "sh -c 'set -e; test ! -e")) + refute Enum.any?(Enum.take(history, 2), &mutation?/1) + assert Enum.map(Enum.drop(history, 2), &Enum.at(&1, 1)) == ["serial-a", "serial-b"] + assert Enum.all?(Enum.drop(history, 2), &mutation?/1) + + record = expected_record(lease) + + assert Enum.all?(Enum.drop(history, 2), fn args -> + List.last(args) =~ ~s(printf %s "#{record}") + end) + end + + test "a known block on the later target causes zero mutation on every target" do + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn ["-s", serial, "shell", _command] = args -> + Agent.update(commands, &[args | &1]) + if serial == "serial-b", do: {"blocked", 1}, else: {"", 0} + end + + assert {:error, + %{ + phase: :preflight, + reason: :lease_present_or_ambiguous, + serial: "serial-b", + lease: %{state: :not_acquired} + }} = + AndroidDeployLock.acquire(@bundle, ["serial-b", "serial-a"], runner, owner: @owner) + + refute Agent.get(commands, & &1) |> Enum.any?(&mutation?/1) + end + + test "an exception after a later acquire mutation retains the full identity and halts" do + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn ["-s", serial, "shell", command] = args -> + Agent.update(commands, &[args | &1]) + + if serial == "serial-b" and String.contains?(command, "mkdir ") do + raise "transport lost after write" + else + {"", 0} + end + end + + assert {:error, + %{ + phase: :acquire, + reason: :acquire_ambiguous, + serial: "serial-b", + affected_serials: ["serial-a", "serial-b"], + lease: %{state: :retained_ambiguous} = retained + }} = + AndroidDeployLock.acquire(@bundle, ["serial-b", "serial-a"], runner, owner: @owner) + + assert retained.serials == ["serial-a", "serial-b"] + assert retained.target_digest == target_digest(retained.serials) + refute AndroidDeployLock.valid?(retained) + refute Agent.get(commands, & &1) |> Enum.any?(&cleanup?/1) + end + + test "owner proof binds owner, exact target digest, and phase" do + one_target = held_lease(["serial-a"]) + two_targets = held_lease(["serial-a", "serial-b"]) + + assert :ok = + AndroidDeployLock.verify_owner(one_target, "serial-a", fn + ["-s", "serial-a", "shell", command] -> + assert command =~ "wc -c" + assert command =~ ".mob_native_deploy_releasing_*" + {expected_record(one_target), 0} + end) + + assert {:error, %{reason: :record_mismatch}} = + AndroidDeployLock.verify_owner(two_targets, "serial-a", fn _args -> + {expected_record(one_target), 0} + end) + + assert {:error, %{reason: :record_mismatch}} = + AndroidDeployLock.verify_owner(one_target, "serial-a", fn _args -> + {expected_record(one_target, :native_ready), 0} + end) + + assert {:error, %{reason: :record_mismatch}} = + AndroidDeployLock.verify_owner(one_target, "serial-a", fn _args -> + {expected_record(one_target) <> "\n", 0} + end) + end + + test "transition preflights the full set and writes the next exact phase" do + lease = held_lease(["serial-a", "serial-b"]) + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(commands, &[args | &1]) + command = List.last(args) + + if fixed_record_proof?(command), do: {expected_record(lease), 0}, else: {"", 0} + end + + assert {:ok, transitioned} = + AndroidDeployLock.transition(lease, :acquired, :native_ready, runner) + + assert transitioned.phase == :native_ready + assert transitioned.state == :held_success + assert AndroidDeployLock.valid?(transitioned, :native_ready) + + history = Agent.get(commands, &Enum.reverse/1) + assert Enum.all?(Enum.take(history, 2), &fixed_record_proof?(List.last(&1))) + + transition_commands = Enum.drop(history, 2) + assert Enum.map(transition_commands, &Enum.at(&1, 1)) == ["serial-a", "serial-b"] + + assert Enum.all?(transition_commands, fn args -> + command = List.last(args) + + command =~ ~s(test "$value" = "#{expected_record(lease)}") and + command =~ ~s(printf %s "#{expected_record(lease, :native_ready)}") + end) + end + + test "a later transition exception retains current and prior target metadata" do + lease = held_lease(["serial-a", "serial-b"]) + + runner = fn ["-s", serial, "shell", command] -> + cond do + fixed_record_proof?(command) -> + {expected_record(lease), 0} + + serial == "serial-b" and String.contains?(command, "record_next_") -> + throw(:transport_lost_after_transition) + + true -> + {"", 0} + end + end + + assert {:error, + %{ + phase: :transition, + affected_serials: ["serial-a", "serial-b"], + transitioned_serials: ["serial-a"], + transition: {:acquired, :native_ready}, + lease: %{state: :retained_ambiguous, phase: :acquired} + }} = AndroidDeployLock.transition(lease, :acquired, :native_ready, runner) + end + + test "transition authority mismatch is retained ambiguity, not a held lease" do + lease = held_lease(["serial-a"]) + + assert {:error, + %{ + reason: :record_mismatch, + lease: %{state: :retained_ambiguous} + }} = + AndroidDeployLock.transition(lease, :acquired, :native_ready, fn _args -> + {"malformed", 0} + end) + end + + test "release is committed-only and performs set-wide rename and proof before deletion" do + uncommitted = held_lease(["serial-a", "serial-b"]) + {:ok, untouched} = Agent.start_link(fn -> [] end) + + assert {:error, %{reason: :lease_not_releasable}} = + AndroidDeployLock.release(uncommitted, fn args -> + Agent.update(untouched, &[args | &1]) + {"", 0} + end) + + assert Agent.get(untouched, & &1) == [] + + lease = %{uncommitted | phase: :final_committed} + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(commands, &[args | &1]) + command = List.last(args) + + if fixed_record_proof?(command) or tombstone_record_proof?(command), + do: {expected_record(lease), 0}, + else: {"", 0} + end + + assert :ok = AndroidDeployLock.release(lease, runner) + + history = Agent.get(commands, &Enum.reverse/1) + assert Enum.count(history, &fixed_record_proof?(List.last(&1))) == 2 + assert Enum.count(history, &rename?(&1)) == 2 + assert Enum.count(history, &tombstone_record_proof?(List.last(&1))) == 2 + assert Enum.count(history, &cleanup?/1) == 2 + + Enum.each(Enum.filter(history, &cleanup?/1), fn args -> + command = List.last(args) + assert command =~ "/record; rmdir " + refute command =~ "rm -rf" + end) + + last_rename = history |> indexes(&rename?/1) |> Enum.max() + first_tombstone_proof = history |> indexes(&tombstone_proof_args?/1) |> Enum.min() + last_tombstone_proof = history |> indexes(&tombstone_proof_args?/1) |> Enum.max() + first_delete = history |> indexes(&cleanup?/1) |> Enum.min() + assert last_rename < first_tombstone_proof + assert last_tombstone_proof < first_delete + end + + test "release rename ambiguity leaves all prior tombstones and performs no deletion" do + lease = %{held_lease(["serial-a", "serial-b"]) | phase: :fast_committed} + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn ["-s", serial, "shell", command] = args -> + Agent.update(commands, &[args | &1]) + + cond do + fixed_record_proof?(command) -> {expected_record(lease), 0} + serial == "serial-a" and String.contains?(command, "mv ") -> raise "lost reply" + true -> {"", 0} + end + end + + assert {:error, + %{ + phase: :release_rename, + affected_serials: ["serial-a", "serial-b"], + renamed_serials: ["serial-b"], + released_serials: nil, + lease: %{state: :retained_ambiguous} + }} = normalize_release_failure(AndroidDeployLock.release(lease, runner)) + + refute Agent.get(commands, & &1) |> Enum.any?(&cleanup?/1) + end + + test "release delete exception halts later cleanup and reports already clear targets" do + lease = %{held_lease(["serial-a", "serial-b"]) | phase: :final_committed} + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn ["-s", serial, "shell", command] = args -> + Agent.update(commands, &[args | &1]) + + cond do + fixed_record_proof?(command) or tombstone_record_proof?(command) -> + {expected_record(lease), 0} + + serial == "serial-a" and tombstone_delete_command?(command) -> + exit(:transport_lost_after_delete) + + true -> + {"", 0} + end + end + + assert {:error, + %{ + phase: :release_delete, + serial: "serial-a", + released_serials: ["serial-b"], + lease: %{state: :retained_ambiguous} + }} = AndroidDeployLock.release(lease, runner) + + delete_targets = + Agent.get(commands, &Enum.reverse/1) + |> Enum.filter(&cleanup?/1) + |> Enum.map(&Enum.at(&1, 1)) + + assert delete_targets == ["serial-b", "serial-a"] + end + + test "release never recursively deletes content added after tombstone proof" do + lease = %{held_lease(["serial-a"]) | phase: :final_committed} + topology = start_supervised!({Agent, fn -> %{record?: true, late_content?: true} end}) + + runner = fn args -> + command = List.last(args) + + cond do + fixed_record_proof?(command) or tombstone_record_proof?(command) -> + {expected_record(lease), 0} + + String.contains?(command, "mv ") -> + {"", 0} + + String.contains?(command, "rm -rf") -> + Agent.update(topology, fn _state -> %{record?: false, late_content?: false} end) + {"", 0} + + tombstone_delete_command?(command) -> + Agent.update(topology, &%{&1 | record?: false}) + {"directory not empty", 1} + end + end + + assert {:error, + %{ + phase: :release_delete, + reason: :delete_ambiguous, + lease: %{state: :retained_ambiguous} + }} = AndroidDeployLock.release(lease, runner) + + assert Agent.get(topology, & &1) == %{record?: false, late_content?: true} + end + + test "an extra tombstone observed after rename blocks every expected tombstone delete" do + lease = %{held_lease(["serial-a"]) | phase: :final_committed} + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(commands, &[args | &1]) + command = List.last(args) + + cond do + fixed_record_proof?(command) -> {expected_record(lease), 0} + String.contains?(command, "mv ") -> {"", 0} + tombstone_record_proof?(command) -> {"extra tombstone", 1} + true -> {"", 1} + end + end + + assert {:error, + %{ + phase: :release_verify, + lease: %{state: :retained_ambiguous}, + renamed_serials: ["serial-a"] + }} = AndroidDeployLock.release(lease, runner) + + history = Agent.get(commands, &Enum.reverse/1) + proof = Enum.find(history, &tombstone_record_proof?(List.last(&1))) |> List.last() + assert proof =~ ~s(test "$#" -eq 1) + assert proof =~ ~s(test "$1" = ") + assert proof =~ ~s(test "$entries" -eq 1) + refute Enum.any?(history, &cleanup?/1) + end + + test "structural validation rejects forged subsets, malformed digest, phase, and ordering" do + lease = held_lease(["serial-a", "serial-b"]) + + refute AndroidDeployLock.valid?(%{lease | serials: ["serial-a"]}) + refute AndroidDeployLock.valid?(%{lease | target_digest: String.duplicate("0", 64)}) + refute AndroidDeployLock.valid?(%{lease | serials: Enum.reverse(lease.serials)}) + refute AndroidDeployLock.valid?(%{lease | phase: :unknown}) + refute AndroidDeployLock.valid?(%{lease | state: :not_acquired}) + refute AndroidDeployLock.valid?(Map.delete(lease, :owner)) + end + + test "case-fold-colliding target identities are rejected before runner I/O" do + {:ok, calls} = Agent.start_link(fn -> 0 end) + + assert {:error, %{reason: :ambiguous_target, lease: %{state: :not_acquired}}} = + AndroidDeployLock.acquire( + @bundle, + ["ABC", "abc"], + fn _args -> + Agent.update(calls, &(&1 + 1)) + {"", 0} + end, + owner: @owner + ) + + assert Agent.get(calls, & &1) == 0 + + forged = held_lease(["ABC", "abc"]) + refute AndroidDeployLock.valid?(forged) + refute AndroidDeployLock.valid?(forged, :acquired) + end + + test "status exposes bounded categories only" do + for {output, expected} <- [ + {"clear", :clear}, + {"held", :held}, + {"released_tombstone", :released_tombstone}, + {"ambiguous", :ambiguous} + ] do + assert {:ok, ^expected} = + AndroidDeployLock.status(@bundle, "serial-a", fn + ["-s", "serial-a", "shell", command] -> + assert command =~ "tombstones=$((tombstones + 1))" + {output, 0} + end) + end + + assert {:error, :status_ambiguous} = + AndroidDeployLock.status(@bundle, "serial-a", fn _args -> + {"held\nowner", 0} + end) + + assert {:error, :status_ambiguous} = + AndroidDeployLock.status(@bundle, "serial-a", fn _args -> + raise "transport unavailable" + end) + end + + test "recovery cleanup removes only one exact committed tombstone" do + lease = %{held_lease(["serial-a"]) | phase: :final_committed} + basename = ".mob_native_deploy_releasing_#{lease.owner}" + probe = basename <> "\n" <> expected_record(lease) + {:ok, commands} = Agent.start_link(fn -> [] end) + + runner = fn args -> + Agent.update(commands, &[args | &1]) + command = List.last(args) + + if tombstone_delete_command?(command), do: {"", 0}, else: {probe, 0} + end + + assert :ok = + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", runner) + + [probe_command, cleanup_command] = Agent.get(commands, &Enum.reverse/1) + assert List.last(probe_command) =~ "test ! -e" + assert List.last(probe_command) =~ ~s(test "$#" -eq 1) + assert List.last(probe_command) =~ ~s(test "$entries" -eq 1) + assert List.last(cleanup_command) =~ ~s(test "$1" = ") + assert List.last(cleanup_command) =~ ~s(test "$entries" -eq 1) + assert List.last(cleanup_command) =~ ~s(test "$value" = "#{expected_record(lease)}") + assert List.last(cleanup_command) =~ "/record; rmdir " + refute List.last(cleanup_command) =~ "rm -rf" + end + + test "recovery cleanup leaves late-added tombstone content and fails ambiguous" do + lease = %{held_lease(["serial-a"]) | phase: :final_committed} + basename = ".mob_native_deploy_releasing_#{lease.owner}" + probe = basename <> "\n" <> expected_record(lease) + topology = start_supervised!({Agent, fn -> %{record?: true, late_content?: true} end}) + + runner = fn args -> + command = List.last(args) + + cond do + recovery_probe?(command) -> + {probe, 0} + + String.contains?(command, "rm -rf") -> + Agent.update(topology, fn _state -> %{record?: false, late_content?: false} end) + {"", 0} + + tombstone_delete_command?(command) -> + Agent.update(topology, &%{&1 | record?: false}) + {"directory not empty", 1} + end + end + + assert {:error, :cleanup_ambiguous} = + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", runner) + + assert Agent.get(topology, & &1) == %{record?: false, late_content?: true} + end + + test "concurrent recovery cleanup allows only one attempt to report success" do + lease = %{held_lease(["serial-a"]) | phase: :fast_committed} + basename = ".mob_native_deploy_releasing_#{lease.owner}" + probe = basename <> "\n" <> expected_record(lease) + parent = self() + record_present? = start_supervised!({Agent, fn -> true end}) + task_supervisor = start_supervised!(Task.Supervisor) + + runner = fn args -> + command = List.last(args) + + cond do + recovery_probe?(command) -> + send(parent, {:cleanup_probe_waiting, self()}) + + receive do + :continue_cleanup_probe -> {probe, 0} + end + + tombstone_delete_command?(command) or String.contains?(command, "rm -rf") -> + send(parent, {:cleanup_delete_waiting, self(), command}) + + receive do + :continue_cleanup_delete -> + if String.contains?(command, "rm -rf") do + {"", 0} + else + Agent.get_and_update(record_present?, fn + true -> {{"", 0}, false} + false -> {{"record disappeared", 1}, false} + end) + end + end + end + end + + cleanup = fn -> + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", runner) + end + + first = Task.Supervisor.async_nolink(task_supervisor, cleanup) + second = Task.Supervisor.async_nolink(task_supervisor, cleanup) + + probe_pids = + for _index <- 1..2 do + assert_receive {:cleanup_probe_waiting, pid} + pid + end + + Enum.each(probe_pids, &send(&1, :continue_cleanup_probe)) + + delete_waiters = + for _index <- 1..2 do + assert_receive {:cleanup_delete_waiting, pid, command} + refute command =~ "rm -rf" + assert command =~ "/record; rmdir " + pid + end + + Enum.each(delete_waiters, &send(&1, :continue_cleanup_delete)) + + results = [Task.await(first), Task.await(second)] + assert Enum.count(results, &(&1 == :ok)) == 1 + assert Enum.count(results, &(&1 == {:error, :cleanup_ambiguous})) == 1 + end + + test "recovery cleanup rejects native-ready, basename mismatch, and delete ambiguity" do + lease = %{held_lease(["serial-a"]) | phase: :native_ready} + basename = ".mob_native_deploy_releasing_#{lease.owner}" + probe = basename <> "\n" <> expected_record(lease) + {:ok, calls} = Agent.start_link(fn -> [] end) + + assert {:error, :tombstone_not_committed} = + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", fn args -> + Agent.update(calls, &[args | &1]) + {probe, 0} + end) + + refute Agent.get(calls, & &1) |> Enum.any?(&cleanup?/1) + + committed = %{lease | phase: :fast_committed} + + assert {:error, :tombstone_not_committed} = + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", fn _args -> + {".mob_native_deploy_releasing_otherproof00001\n" <> + expected_record(committed), 0} + end) + + good_probe = basename <> "\n" <> expected_record(committed) + attempt_key = {__MODULE__, make_ref()} + + assert {:error, :cleanup_ambiguous} = + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", fn _args -> + case Process.get(attempt_key, 0) do + 0 -> + Process.put(attempt_key, 1) + {good_probe, 0} + + count -> + raise "transport lost after delete #{count}" + end + end) + + assert Process.get(attempt_key) == 1 + end + + test "recovery cleanup observes unknown tombstone contents before any deletion" do + {:ok, commands} = Agent.start_link(fn -> [] end) + + assert {:error, :tombstone_ambiguous} = + AndroidDeployLock.cleanup_committed_tombstone(@bundle, "serial-a", fn args -> + Agent.update(commands, &[args | &1]) + {"extra entry", 1} + end) + + [probe] = Agent.get(commands, & &1) + assert List.last(probe) =~ ~s(test "$entries" -eq 1) + refute cleanup?(probe) + end + + defp held_lease(serials) do + ordered = Enum.sort(serials) + + %{ + bundle_id: @bundle, + owner: @owner, + serials: ordered, + target_digest: target_digest(ordered), + phase: :acquired, + state: :held_success + } + end + + defp expected_record(lease, phase \\ nil) do + phase = phase || lease.phase + "1|#{lease.owner}|#{lease.target_digest}|#{phase}" + end + + defp target_digest(serials) do + serials + |> Enum.join(<<0>>) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end + + defp fixed_record_proof?(command) do + String.ends_with?(command, ".mob_native_deploy_lock/record'") and + not String.contains?(command, "value=$(cat") + end + + defp tombstone_record_proof?(command) do + String.contains?(command, ".mob_native_deploy_releasing_#{@owner}/record") and + String.ends_with?(command, "/record'") and + not String.contains?(command, "value=$(cat") + end + + defp mutation?(args), do: List.last(args) |> String.contains?("mkdir ") + defp cleanup?(args), do: args |> List.last() |> tombstone_delete_command?() + + defp tombstone_delete_command?(command) do + String.contains?(command, "/record; rmdir ") and not String.contains?(command, "rm -rf") + end + + defp recovery_probe?(command), do: String.contains?(command, "base=${1##*/}") + + defp rename?(args) do + command = List.last(args) + + String.contains?(command, "mv ") and + String.contains?(command, ".mob_native_deploy_releasing_") + end + + defp tombstone_proof_args?(args), do: tombstone_record_proof?(List.last(args)) + + defp indexes(items, predicate) do + items + |> Enum.with_index() + |> Enum.flat_map(fn {item, index} -> if predicate.(item), do: [index], else: [] end) + end + + defp normalize_release_failure({:error, failure}) do + {:error, Map.put_new(failure, :released_serials, nil)} + end +end diff --git a/test/mob_dev/android_deploy_recovery_proof_test.exs b/test/mob_dev/android_deploy_recovery_proof_test.exs new file mode 100644 index 0000000..bf00770 --- /dev/null +++ b/test/mob_dev/android_deploy_recovery_proof_test.exs @@ -0,0 +1,477 @@ +defmodule MobDev.AndroidDeployRecoveryProofTest do + # These tests intentionally contend on the production global filesystem lock. + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + import ExUnit.CaptureLog + + alias MobDev.AndroidDeployRecoveryProof + + @bundle "com.example.casein" + @serial "serial-a" + @old_owner "oldownerproof001" + @new_owner "newownerproof001" + @apk_sha String.duplicate("a", 64) + @runtime_sha String.duplicate("b", 64) + @runtime_path "otp/lib/elixir/ebin/Elixir.Kernel.beam" + + test "collects bounded read-only production evidence and immediately resumes with same runner" do + apk = tmp_apk!() + runner = runner(self()) + + assert {:ok, lease} = + AndroidDeployRecoveryProof.resume(payload(apk), runner, + owner: @new_owner, + minimum_age_seconds: 900, + runtime_provenance: runtime_provenance(), + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn ^apk -> true end + ) + + assert lease.owner == @new_owner + assert lease.phase == :native_ready + assert lease.state == :held_success + + commands = drain_commands([]) + assert Enum.any?(commands, &(&1 == ["devices", "-l"])) + assert Enum.any?(commands, &read_only_record?/1) + assert Enum.any?(commands, &installed_digest?/1) + assert Enum.any?(commands, &runtime_provenance_probe?/1) + assert Enum.any?(commands, &staging_proof?/1) + assert Enum.any?(commands, &cas?/1) + assert Enum.any?(commands, &post_cas_proof?/1) + + first_cas = Enum.find_index(commands, &cas?/1) + assert Enum.all?(Enum.take(commands, first_cas), &(not mutating_before_cas?(&1))) + end + + test "refuses any failed proof before CAS" do + apk = tmp_apk!() + + assert {:error, {:recovery_proof_refused, :host_lock_unavailable}} = + AndroidDeployRecoveryProof.resume(payload(apk), runner(self()), + owner: @new_owner, + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> false end, + apk_signature_verified?: fn _path -> true end + ) + + commands = drain_commands([]) + refute Enum.any?(commands, &cas?/1) + end + + test "refuses unsafe package and serial identity before invoking adb" do + apk = tmp_apk!() + + for invalid_payload <- [ + put_in(payload(apk).package, "com.example.casein;id"), + put_in(payload(apk).serials, ["serial-a\nother-device"]), + put_in(payload(apk).apk.sha256, String.duplicate("A", 64)) + ] do + assert {:error, {:recovery_proof_refused, :payload_identity_invalid}} = + AndroidDeployRecoveryProof.resume(invalid_payload, runner(self()), + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn _path -> true end + ) + end + + refute_receive {:command, _args} + end + + test "requires exactly one USB target and refuses network adb" do + apk = tmp_apk!() + + runner = fn + ["devices", "-l"] -> {"List of devices attached\n#{@serial}:5555 device product:x\n", 0} + args -> send(self(), {:command, args}) && {"", 0} + end + + assert {:error, {:recovery_proof_refused, :transport_identity_mismatch}} = + AndroidDeployRecoveryProof.resume(payload(apk), runner, + owner: @new_owner, + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn _path -> true end + ) + + refute_receive {:command, _args} + end + + test "refuses an unsafe installed APK path before digest or CAS" do + apk = tmp_apk!() + base_runner = runner(self()) + + runner = fn args -> + case args do + ["-s", @serial, "shell", "pm path " <> @bundle] -> + send(self(), {:command, args}) + {"package:/data/app/example;touch${IFS}/tmp/pwn/base.apk\n", 0} + + _other -> + base_runner.(args) + end + end + + assert {:error, {:recovery_proof_refused, :apk_identity_mismatch}} = + AndroidDeployRecoveryProof.resume(payload(apk), runner, + owner: @new_owner, + runtime_provenance: runtime_provenance(), + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn _path -> true end + ) + + commands = drain_commands([]) + refute Enum.any?(commands, &installed_digest?/1) + refute Enum.any?(commands, &cas?/1) + end + + test "refuses missing or mismatched device runtime provenance before CAS" do + apk = tmp_apk!() + + for {provenance, runtime_reply} <- [ + {nil, ""}, + {[%{path: "otp/../unsafe", sha256: @runtime_sha}], ""}, + {runtime_provenance(), "#{String.duplicate("c", 64)} #{runtime_device_path()}\n"}, + {runtime_provenance(), ""} + ] do + base_runner = runner(self()) + + runner = fn args -> + if runtime_provenance_probe?(args) do + send(self(), {:command, args}) + {runtime_reply, 0} + else + base_runner.(args) + end + end + + assert {:error, {:recovery_proof_refused, :runtime_provenance_mismatch}} = + AndroidDeployRecoveryProof.resume(payload(apk), runner, + owner: @new_owner, + runtime_provenance: provenance, + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn _path -> true end + ) + + commands = drain_commands([]) + refute Enum.any?(commands, &cas?/1) + end + end + + test "refusal diagnostics are fixed enums and never reflect payload, callback, or runner values" do + secret = "secret-canary-#{System.unique_integer([:positive])}" + apk = tmp_apk!() + default_runner = runner(self()) + + cases = [ + {put_in(payload(apk).package, secret), default_runner, [], :payload_identity_invalid}, + {payload(apk), default_runner, [payload_validator: fn _ -> raise secret end], + :payload_invalid}, + {payload(apk), default_runner, [host_lock_held?: fn -> raise secret end], + :host_lock_unavailable}, + {payload(apk), default_runner, + [apk_signature_verified?: fn _ -> throw({:secret, secret}) end], :apk_signature_invalid}, + {payload(apk), fn _args -> {secret, 1} end, [], :transport_identity_mismatch} + ] + + Enum.each(cases, fn {candidate, recovery_runner, overrides, expected_code} -> + opts = + [ + owner: @new_owner, + runtime_provenance: runtime_provenance(), + payload_validator: fn _ -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn _ -> true end + ] + |> Keyword.merge(overrides) + + logs = + capture_log(fn -> + io = + capture_io(fn -> + refusal = AndroidDeployRecoveryProof.resume(candidate, recovery_runner, opts) + send(self(), {:refusal, refusal}) + end) + + send(self(), {:captured_io, io}) + end) + + assert_receive {:refusal, refusal = {:error, {:recovery_proof_refused, ^expected_code}}} + + assert_receive {:captured_io, io} + refute inspect(refusal) =~ secret + refute io =~ secret + refute logs =~ secret + end) + end + + test "operation-wide host lock is exclusive and remains held for the callback" do + assert :ok = + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> + assert {:error, :recovery_host_lock_unavailable} = + Task.async(fn -> + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :wrong end) + end) + |> Task.await() + + :ok + end) + end + + test "a killed BEAM owner is fenced stale and does not permanently block recovery" do + parent = self() + + owner = + spawn(fn -> + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> + send(parent, :lock_acquired) + receive do: (:release -> :ok) + end) + end) + + assert_receive :lock_acquired + monitor = Process.monitor(owner) + Process.exit(owner, :kill) + assert_receive {:DOWN, ^monitor, :process, _, :killed} + + assert :recovered == + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :recovered end) + end + + test "a SIGKILLed external VM leaves a provably stale lock that is reclaimed" do + elixir = System.find_executable("elixir") + + ebin = Path.expand("_build/test/lib/mob_dev/ebin") + + expression = """ + MobDev.AndroidDeployRecoveryProof.with_host_lock(#{inspect(@bundle)}, fn -> + IO.puts("LOCK_READY") + Process.sleep(:infinity) + end) + """ + + port = + Port.open({:spawn_executable, elixir}, [ + :binary, + :exit_status, + :stderr_to_stdout, + args: ["-pa", ebin, "-e", expression] + ]) + + assert_receive {^port, {:data, output}}, 5_000 + assert output =~ "LOCK_READY" + {:os_pid, os_pid} = Port.info(port, :os_pid) + assert {_output, 0} = System.cmd("kill", ["-9", Integer.to_string(os_pid)]) + assert_receive {^port, {:exit_status, _status}}, 5_000 + + assert :recovered == + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :recovered end) + end + + test "a hard exit after atomic release rename cannot strand the canonical lock" do + parent = self() + + owner = + spawn(fn -> + :ok = + AndroidDeployRecoveryProof.__test_only__(:set_release_hook, fn -> + send(parent, :release_renamed) + receive do: (:finish_release -> :ok) + end) + + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :done end) + end) + + assert_receive :release_renamed + lock_path = AndroidDeployRecoveryProof.__test_only__(:lock_path, @bundle) + refute File.exists?(lock_path) + monitor = Process.monitor(owner) + Process.exit(owner, :kill) + assert_receive {:DOWN, ^monitor, :process, _, :killed} + + on_exit(fn -> + for path <- Path.wildcard("#{lock_path}.released.*") do + File.rm(Path.join(path, "owner.term")) + File.rmdir(path) + end + end) + + assert :recovered == + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :recovered end) + end + + test "PID reuse and boot changes cannot preserve stale ownership" do + for field <- [:os_start, :boot_id] do + leave_stale_local_lock!() + owner_path = lock_owner_path() + owner = owner_path |> File.read!() |> :erlang.binary_to_term([:safe]) + + changed = + owner + |> Map.update!(field, fn _value -> String.duplicate("f", 64) end) + |> then(fn changed -> + if field == :os_start, + do: %{changed | vm_id: String.duplicate("e", 64)}, + else: changed + end) + + File.write!(owner_path, :erlang.term_to_binary(changed)) + + assert :recovered == + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :recovered end) + end + end + + test "two recoverers racing a stale owner admit exactly one operation" do + leave_stale_local_lock!() + parent = self() + + contenders = + for id <- 1..2 do + Task.async(fn -> + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> + send(parent, {:entered, id}) + receive do: (:release -> :won) + end) + end) + end + + assert_receive {:entered, winner} + refute_receive {:entered, _other}, 100 + loser = Enum.find(contenders, &(&1.pid != Enum.at(contenders, winner - 1).pid)) + assert {:error, :recovery_host_lock_unavailable} = Task.await(loser) + winning_task = Enum.at(contenders, winner - 1) + send(winning_task.pid, :release) + assert :won = Task.await(winning_task) + end + + test "malformed ownership is ambiguous and never stolen" do + path = AndroidDeployRecoveryProof.__test_only__(:lock_path, @bundle) + File.mkdir!(path) + File.write!(Path.join(path, "owner.term"), "malformed") + + on_exit(fn -> + File.rm(Path.join(path, "owner.term")) + File.rmdir(path) + end) + + assert {:error, :recovery_host_lock_unavailable} = + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> :wrong end) + end + + defp tmp_apk! do + path = Path.join(System.tmp_dir!(), "mob-recovery-proof-#{System.unique_integer()}.apk") + File.write!(path, "apk") + on_exit(fn -> File.rm(path) end) + path + end + + defp leave_stale_local_lock! do + parent = self() + + task = + spawn(fn -> + AndroidDeployRecoveryProof.with_host_lock(@bundle, fn -> + send(parent, :stale_lock_ready) + receive do: (:release -> :ok) + end) + end) + + assert_receive :stale_lock_ready + monitor = Process.monitor(task) + Process.exit(task, :kill) + assert_receive {:DOWN, ^monitor, :process, _, :killed} + end + + defp lock_owner_path do + AndroidDeployRecoveryProof.__test_only__(:lock_path, @bundle) + |> Path.join("owner.term") + end + + defp payload(apk) do + %{ + version: 1, + package: @bundle, + serials: [@serial], + apk: %{path: apk, sha256: @apk_sha} + } + end + + defp runner(test_pid) do + digest = :crypto.hash(:sha256, @serial) |> Base.encode16(case: :lower) + old_record = "1|#{@old_owner}|#{digest}|native_ready" + new_record = "1|#{@new_owner}|#{digest}|native_ready" + + fn args -> + send(test_pid, {:command, args}) + command = List.last(args) + + cond do + args == ["devices", "-l"] -> + {"List of devices attached\n#{@serial} device usb:1-1 product:x\n", 0} + + String.contains?(command, "getprop service.adb.tcp.port") -> + {"-1|-1", 0} + + read_only_record?(args) -> + {"#{old_record}\n100\n3700\n", 0} + + String.starts_with?(command, "pm path ") -> + {"package:/data/app/example/base.apk\n", 0} + + String.starts_with?(command, "sha256sum ") -> + {"#{@apk_sha} /data/app/example/base.apk\n", 0} + + runtime_provenance_probe?(args) -> + {"#{@runtime_sha} #{runtime_device_path()}\n", 0} + + staging_proof?(args) -> + {"", 0} + + cas?(args) -> + {"", 0} + + post_cas_proof?(args) -> + {new_record, 0} + end + end + end + + defp drain_commands(acc) do + receive do + {:command, args} -> drain_commands([args | acc]) + after + 0 -> Enum.reverse(acc) + end + end + + defp read_only_record?(args), do: List.last(args) |> String.contains?("stat -c %Y") + defp installed_digest?(args), do: List.last(args) |> String.starts_with?("sha256sum ") + + defp runtime_provenance_probe?(args), + do: List.last(args) |> String.contains?("sha256sum /data/data/") + + defp staging_proof?(args), do: List.last(args) |> String.contains?(".mob_otp_stage_") + defp cas?(args), do: List.last(args) |> String.contains?("record_next_") + + defp post_cas_proof?(args) do + command = List.last(args) + + String.contains?(command, ".mob_native_deploy_lock/record") and + not read_only_record?(args) and not cas?(args) + end + + defp mutating_before_cas?(args) do + command = List.last(args) + + String.contains?(command, "rm ") or String.contains?(command, "mv ") or + String.contains?(command, "install") or String.contains?(command, "push") + end + + defp runtime_provenance, do: [%{path: @runtime_path, sha256: @runtime_sha}] + defp runtime_device_path, do: "/data/data/#{@bundle}/files/#{@runtime_path}" +end diff --git a/test/mob_dev/android_deploy_recovery_test.exs b/test/mob_dev/android_deploy_recovery_test.exs new file mode 100644 index 0000000..97a4e9a --- /dev/null +++ b/test/mob_dev/android_deploy_recovery_test.exs @@ -0,0 +1,141 @@ +defmodule MobDev.AndroidDeployRecoveryTest do + use ExUnit.Case, async: true + + alias MobDev.AndroidDeployRecovery + + @bundle "com.example.casein" + @serial "serial-a" + @old_owner "oldownerproof001" + @new_owner "newownerproof001" + + test "rekeys one proven native-ready boundary without deleting the lease" do + proof = proof() + + assert {:ok, lease} = + AndroidDeployRecovery.resume(proof, runner(self()), + owner: @new_owner, + minimum_age_seconds: 900 + ) + + assert lease.phase == :native_ready + assert lease.owner == @new_owner + assert lease.serials == [@serial] + assert_receive {:command, cas_command} + assert cas_command =~ "test \"$value\" = \"#{proof.record}\"" + assert cas_command =~ "record_next_#{@new_owner}" + refute cas_command =~ "rm " + refute cas_command =~ "rm -rf" + + assert_receive {:command, proof_command} + assert proof_command =~ ".mob_native_deploy_lock/record" + end + + test "refuses every incomplete or unsafe proof without invoking adb" do + unsafe = [ + {:lease_age_seconds, 899}, + {:transport, :tcp}, + {:adb_tcp_disabled?, false}, + {:host_deployer_absent?, false}, + {:exact_topology?, false}, + {:package_identity_matches?, false}, + {:apk_signature_verified?, false}, + {:apk_digest_matches?, false}, + {:runtime_provenance_matches?, false}, + {:payload_valid?, false}, + {:staging_clear?, false} + ] + + Enum.each(unsafe, fn {key, value} -> + assert {:error, :recovery_proof_refused} = + AndroidDeployRecovery.resume(Map.put(proof(), key, value), runner(self()), + owner: @new_owner, + minimum_age_seconds: 900 + ) + end) + + refute_receive {:command, _command} + end + + test "refuses wrong device, target digest, phase, malformed record, and ambiguous CAS" do + for changed <- [ + %{serial: "serial-b"}, + %{target_digest: String.duplicate("0", 64)}, + %{phase: :acquired}, + %{record: "malformed"} + ] do + assert {:error, :recovery_proof_refused} = + AndroidDeployRecovery.resume(Map.merge(proof(), changed), runner(self()), + owner: @new_owner + ) + end + + assert {:error, :recovery_cas_ambiguous, + %{owner: @new_owner, phase: :native_ready, state: :retained_ambiguous}} = + AndroidDeployRecovery.resume(proof(), fn _args -> {"changed", 1} end, + owner: @new_owner + ) + end + + test "rejects invalid recovery owner before invoking adb" do + assert {:error, :recovery_proof_refused} = + AndroidDeployRecovery.resume(proof(), runner(self()), owner: "bad") + + refute_receive {:command, _command} + end + + test "rejects reusing the interrupted owner and a changed post-CAS record" do + assert {:error, :recovery_proof_refused} = + AndroidDeployRecovery.resume(proof(), runner(self()), owner: @old_owner) + + assert {:error, :recovery_cas_ambiguous, + %{owner: @new_owner, phase: :native_ready, state: :retained_ambiguous}} = + AndroidDeployRecovery.resume( + proof(), + fn + ["-s", @serial, "shell", command] -> + if String.contains?(command, "record_next_"), + do: {"", 0}, + else: {"changed", 0} + end, + owner: @new_owner + ) + end + + defp proof do + digest = :crypto.hash(:sha256, @serial) |> Base.encode16(case: :lower) + record = "1|#{@old_owner}|#{digest}|native_ready" + + %{ + version: 1, + bundle_id: @bundle, + serial: @serial, + target_digest: digest, + phase: :native_ready, + record: record, + lease_age_seconds: 3_600, + transport: :usb, + adb_tcp_disabled?: true, + host_deployer_absent?: true, + exact_topology?: true, + package_identity_matches?: true, + apk_signature_verified?: true, + apk_digest_matches?: true, + runtime_provenance_matches?: true, + payload_valid?: true, + staging_clear?: true + } + end + + defp runner(test_pid) do + fn ["-s", @serial, "shell", command] -> + send(test_pid, {:command, command}) + + if String.contains?(command, "record_next_"), + do: {"", 0}, + else: {"1|#{@new_owner}|#{target_digest()}|native_ready", 0} + end + end + + defp target_digest, + do: :crypto.hash(:sha256, @serial) |> Base.encode16(case: :lower) +end diff --git a/test/mob_dev/battery_bench_test.exs b/test/mob_dev/battery_bench_test.exs index f294af2..8b66d7b 100644 --- a/test/mob_dev/battery_bench_test.exs +++ b/test/mob_dev/battery_bench_test.exs @@ -1,3 +1,4 @@ +# credo:disable-for-this-file Jump.CredoChecks.VacuousTest defmodule MobDev.BatteryBenchTest do use ExUnit.Case, async: true @@ -66,7 +67,6 @@ defmodule MobDev.BatteryBenchTest do {cflags, header_dir} = unquote(mod).resolve_build_flags(flags: "-sbwt none -S 1:1") assert cflags =~ "-DBEAM_USE_CUSTOM_FLAGS" - assert is_binary(header_dir) assert File.dir?(header_dir) header = File.read!(Path.join(header_dir, "mob_beam_flags.h")) @@ -88,38 +88,53 @@ defmodule MobDev.BatteryBenchTest do describe "iOS option parsing" do test "parses all supported switches" do - {opts, _, _} = OptionParser.parse( - ~w[--duration 3600 --device ABC-123 --no-beam --preset nerves + {opts, _, _} = + OptionParser.parse( + ~w[--duration 3600 --device ABC-123 --no-beam --preset nerves --flags -sbwt\ none --no-build --scheme MyApp --dry-run], - switches: [duration: :integer, device: :string, no_beam: :boolean, - preset: :string, flags: :string, no_build: :boolean, - scheme: :string, dry_run: :boolean] - ) + switches: [ + duration: :integer, + device: :string, + no_beam: :boolean, + preset: :string, + flags: :string, + no_build: :boolean, + scheme: :string, + dry_run: :boolean + ] + ) assert opts[:duration] == 3600 - assert opts[:device] == "ABC-123" - assert opts[:no_beam] == true - assert opts[:preset] == "nerves" + assert opts[:device] == "ABC-123" + assert opts[:no_beam] == true + assert opts[:preset] == "nerves" assert opts[:no_build] == true - assert opts[:scheme] == "MyApp" - assert opts[:dry_run] == true + assert opts[:scheme] == "MyApp" + assert opts[:dry_run] == true end end describe "Android option parsing" do test "parses all supported switches" do - {opts, _, _} = OptionParser.parse( - ~w[--duration 600 --device 192.168.1.5:5555 --no-beam --no-build --dry-run], - switches: [duration: :integer, device: :string, no_beam: :boolean, - preset: :string, flags: :string, no_build: :boolean, - dry_run: :boolean] - ) + {opts, _, _} = + OptionParser.parse( + ~w[--duration 600 --device 192.168.1.5:5555 --no-beam --no-build --dry-run], + switches: [ + duration: :integer, + device: :string, + no_beam: :boolean, + preset: :string, + flags: :string, + no_build: :boolean, + dry_run: :boolean + ] + ) assert opts[:duration] == 600 - assert opts[:device] == "192.168.1.5:5555" - assert opts[:no_beam] == true + assert opts[:device] == "192.168.1.5:5555" + assert opts[:no_beam] == true assert opts[:no_build] == true - assert opts[:dry_run] == true + assert opts[:dry_run] == true end end end diff --git a/test/mob_dev/bench/device_observer_test.exs b/test/mob_dev/bench/device_observer_test.exs new file mode 100644 index 0000000..d5ef556 --- /dev/null +++ b/test/mob_dev/bench/device_observer_test.exs @@ -0,0 +1,233 @@ +defmodule MobDev.Bench.DeviceObserverTest do + use ExUnit.Case, async: true + + alias MobDev.Bench.{DeviceObserver, Probe} + + describe "subscribe/2" do + test "nil node returns an unsubscribed observer with default state" do + obs = DeviceObserver.subscribe(nil, []) + refute obs.subscribed? + assert obs.screen == :unknown + assert obs.app == :unknown + assert obs.events == [] + end + + test "non-existent node fails gracefully (subscribed? false)" do + obs = DeviceObserver.subscribe(:"phantom@127.0.0.1", []) + refute obs.subscribed? + assert obs.screen == :unknown + end + end + + describe "apply_event/3 — state transitions" do + setup do + obs = DeviceObserver.subscribe(nil, []) + {:ok, obs: obs} + end + + test "screen_off sets screen to :off", %{obs: obs} do + obs2 = DeviceObserver.apply_event(obs, :screen_off, nil) + assert obs2.screen == :off + end + + test "screen_on sets screen to :on", %{obs: obs} do + obs2 = DeviceObserver.apply_event(obs, :screen_on, nil) + assert obs2.screen == :on + end + + test "did_enter_background sets app to :background", %{obs: obs} do + obs2 = DeviceObserver.apply_event(obs, :did_enter_background, nil) + assert obs2.app == :background + end + + test "did_become_active sets app to :running", %{obs: obs} do + obs2 = DeviceObserver.apply_event(obs, :did_become_active, nil) + assert obs2.app == :running + end + + test "will_terminate sets app to :suspended", %{obs: obs} do + obs2 = DeviceObserver.apply_event(obs, :will_terminate, nil) + assert obs2.app == :suspended + end + + test "memory_warning doesn't change screen/app state", %{obs: obs} do + obs2 = DeviceObserver.apply_event(obs, :memory_warning, nil) + assert obs2.screen == obs.screen + assert obs2.app == obs.app + end + + test "events are accumulated newest-first", %{obs: obs} do + obs1 = DeviceObserver.apply_event(obs, :screen_off, nil) + Process.sleep(2) + obs2 = DeviceObserver.apply_event(obs1, :did_enter_background, nil) + Process.sleep(2) + obs3 = DeviceObserver.apply_event(obs2, :screen_on, nil) + + events = Enum.map(obs3.events, fn {_ts, ev, _} -> ev end) + assert events == [:screen_on, :did_enter_background, :screen_off] + end + + test "events list is capped at @max_events_kept", %{obs: obs} do + final = + Enum.reduce(1..200, obs, fn _, acc -> + DeviceObserver.apply_event(acc, :memory_warning, nil) + end) + + assert length(final.events) <= 100 + end + end + + describe "consume_messages/1" do + setup do + obs = DeviceObserver.subscribe(nil, []) + {:ok, obs: obs} + end + + test "drains messages from the mailbox", %{obs: obs} do + send(self(), {:mob_device, :screen_off}) + send(self(), {:mob_device, :did_enter_background}) + + obs = DeviceObserver.consume_messages(obs) + + assert obs.screen == :off + assert obs.app == :background + end + + test "events from consume_messages preserved newest-first", %{obs: obs} do + send(self(), {:mob_device, :screen_off}) + send(self(), {:mob_device, :screen_on}) + + obs = DeviceObserver.consume_messages(obs) + + events = Enum.map(obs.events, fn {_ts, ev, _} -> ev end) + assert events == [:screen_on, :screen_off] + assert obs.screen == :on + end + + test "messages with payload are accepted", %{obs: obs} do + send(self(), {:mob_device, :thermal_state_changed, :serious}) + + obs = DeviceObserver.consume_messages(obs) + + [{_ts, ev, payload}] = obs.events + assert ev == :thermal_state_changed + assert payload == :serious + end + + test "non-mob_device messages are not consumed (left in mailbox)", %{obs: obs} do + send(self(), :other_message) + send(self(), {:mob_device, :screen_off}) + + obs = DeviceObserver.consume_messages(obs) + assert obs.screen == :off + + # The non-mob_device message should still be there. + assert_receive :other_message + end + + test "no messages → no change", %{obs: obs} do + obs2 = DeviceObserver.consume_messages(obs) + assert obs2 == obs + end + end + + describe "apply_to_probe/2" do + test "observed screen state overrides probe's screen state" do + obs = %DeviceObserver{ + node: nil, + subscribed?: false, + screen: :off, + app: :unknown, + last_event_ts_ms: nil, + events: [] + } + + probe = %Probe{ + ts_ms: 0, + reachability: :alive_rpc, + app_process: :app_running, + usb: :no_usb, + screen: :on, + battery_pct: 80, + reason: nil + } + + result = DeviceObserver.apply_to_probe(obs, probe) + assert result.screen == :off + end + + test "unknown observer state preserves probe's view" do + obs = %DeviceObserver{ + node: nil, + subscribed?: false, + screen: :unknown, + app: :unknown, + last_event_ts_ms: nil, + events: [] + } + + probe = %Probe{ + ts_ms: 0, + reachability: :alive_rpc, + app_process: :app_running, + usb: :no_usb, + screen: :off, + battery_pct: 80, + reason: nil + } + + result = DeviceObserver.apply_to_probe(obs, probe) + assert result.screen == :off + end + + test "background app state translates to :app_running for probe" do + obs = %DeviceObserver{ + node: nil, + subscribed?: false, + screen: :off, + app: :background, + last_event_ts_ms: nil, + events: [] + } + + probe = %Probe{ + ts_ms: 0, + reachability: :alive_rpc, + app_process: :app_unknown, + usb: :no_usb, + screen: :unknown, + battery_pct: nil, + reason: nil + } + + result = DeviceObserver.apply_to_probe(obs, probe) + # background = still running, just not in foreground — battery bench + # treats it the same as app_running. + assert result.app_process == :app_running + end + + test "suspended app state propagates to probe" do + obs = %DeviceObserver{ + node: nil, + subscribed?: false, + screen: :off, + app: :suspended, + last_event_ts_ms: nil, + events: [] + } + + probe = %Probe{ + ts_ms: 0, + reachability: :alive_dist_only, + app_process: :app_unknown, + usb: :no_usb, + screen: :unknown, + battery_pct: nil, + reason: nil + } + + result = DeviceObserver.apply_to_probe(obs, probe) + assert result.app_process == :app_suspended + end + end +end diff --git a/test/mob_dev/bench/logger_test.exs b/test/mob_dev/bench/logger_test.exs new file mode 100644 index 0000000..ff55be2 --- /dev/null +++ b/test/mob_dev/bench/logger_test.exs @@ -0,0 +1,180 @@ +defmodule MobDev.Bench.LoggerTest do + use ExUnit.Case, async: true + + alias MobDev.Bench.{Logger, Probe} + + setup do + path = Path.join(System.tmp_dir!(), "bench_logger_#{System.unique_integer([:positive])}.csv") + on_exit(fn -> File.rm(path) end) + {:ok, path: path} + end + + defp probe(opts \\ []) do + %Probe{ + ts_ms: Keyword.get(opts, :ts_ms, System.monotonic_time(:millisecond)), + reachability: Keyword.get(opts, :reachability, :alive_rpc), + app_process: Keyword.get(opts, :app_process, :app_running), + usb: Keyword.get(opts, :usb, :usb_ok), + screen: Keyword.get(opts, :screen, :off), + battery_pct: Keyword.get(opts, :battery_pct, 87), + reason: Keyword.get(opts, :reason) + } + end + + describe "open/2 and close/1" do + test "creates parent dirs and writes header", %{path: path} do + log = Logger.open(path) + log = Logger.close(log) + + content = File.read!(path) + assert content =~ "ts_ms,elapsed_sec,reachability" + assert log.file == nil + end + + test "close is idempotent", %{path: path} do + log = Logger.open(path) + log = Logger.close(log) + assert Logger.close(log).file == nil + end + + test "creates parent dirs when missing", %{path: path} do + nested = Path.join([Path.dirname(path), "nested", "subdir", Path.basename(path)]) + on_exit(fn -> File.rm_rf(Path.dirname(nested)) end) + + log = Logger.open(nested) + Logger.close(log) + assert File.exists?(nested) + end + end + + describe "append/2 and read/1 — round-trip" do + test "single row round-trips", %{path: path} do + log = Logger.open(path, start_ts_ms: 1000) + + log = + Logger.append( + log, + probe( + ts_ms: 1500, + reachability: :alive_rpc, + app_process: :app_running, + usb: :usb_ok, + screen: :off, + battery_pct: 87 + ) + ) + + Logger.close(log) + assert log.rows == 1 + + [row] = Logger.read(path) + assert row.ts_ms == 1500 + assert row.elapsed_sec == 0.5 + assert row.reachability == :alive_rpc + assert row.app_process == :app_running + assert row.usb == :usb_ok + assert row.screen == :off + assert row.battery_pct == 87 + assert row.reason == nil + end + + test "multiple rows preserve order and elapsed_sec", %{path: path} do + log = Logger.open(path, start_ts_ms: 0) + + log = + log + |> Logger.append(probe(ts_ms: 0, battery_pct: 100)) + |> Logger.append(probe(ts_ms: 1_000, battery_pct: 99)) + |> Logger.append(probe(ts_ms: 5_000, battery_pct: 95)) + + Logger.close(log) + + rows = Logger.read(path) + assert length(rows) == 3 + assert Enum.map(rows, & &1.ts_ms) == [0, 1_000, 5_000] + assert Enum.map(rows, & &1.elapsed_sec) == [0.0, 1.0, 5.0] + assert Enum.map(rows, & &1.battery_pct) == [100, 99, 95] + end + + test "battery_pct: nil renders as empty cell and parses back to nil", %{path: path} do + log = Logger.open(path) + log = Logger.append(log, probe(battery_pct: nil)) + Logger.close(log) + + [row] = Logger.read(path) + assert row.battery_pct == nil + + raw = File.read!(path) + [_header, line | _] = String.split(raw, "\n") + assert String.contains?(line, ",,") + end + + test "reason with comma is CSV-escaped", %{path: path} do + log = Logger.open(path) + log = Logger.append(log, probe(reason: "rpc, badrpc, nodedown")) + Logger.close(log) + + [row] = Logger.read(path) + assert row.reason == "rpc, badrpc, nodedown" + end + + test "reason with embedded quotes is escaped", %{path: path} do + log = Logger.open(path) + log = Logger.append(log, probe(reason: ~S|rpc: "timeout"|)) + Logger.close(log) + + [row] = Logger.read(path) + assert row.reason == ~S|rpc: "timeout"| + end + + test "reason with newline is escaped", %{path: path} do + log = Logger.open(path) + log = Logger.append(log, probe(reason: "line1\nline2")) + Logger.close(log) + + [row] = Logger.read(path) + assert row.reason == "line1\nline2" + end + end + + describe "real-world simulation" do + test "captures a transition from connected to disconnected to reconnected", + %{path: path} do + log = Logger.open(path, start_ts_ms: 0) + + events = [ + probe(ts_ms: 0, reachability: :alive_rpc, battery_pct: 100), + probe(ts_ms: 10_000, reachability: :alive_rpc, battery_pct: 100), + probe( + ts_ms: 20_000, + reachability: :alive_dist_only, + battery_pct: nil, + reason: "rpc battery: badrpc :timeout" + ), + probe( + ts_ms: 30_000, + reachability: :alive_epmd_only, + battery_pct: nil, + reason: "dist disconnected" + ), + probe(ts_ms: 40_000, reachability: :alive_rpc, battery_pct: 100, reason: "reconnected") + ] + + log = Enum.reduce(events, log, &Logger.append(&2, &1)) + Logger.close(log) + + rows = Logger.read(path) + assert length(rows) == 5 + + assert Enum.map(rows, & &1.reachability) == [ + :alive_rpc, + :alive_rpc, + :alive_dist_only, + :alive_epmd_only, + :alive_rpc + ] + + assert Enum.at(rows, 2).reason == "rpc battery: badrpc :timeout" + end + end +end diff --git a/test/mob_dev/bench/preflight_test.exs b/test/mob_dev/bench/preflight_test.exs new file mode 100644 index 0000000..b4acec9 --- /dev/null +++ b/test/mob_dev/bench/preflight_test.exs @@ -0,0 +1,164 @@ +defmodule MobDev.Bench.PreflightTest do + use ExUnit.Case, async: false + + alias MobDev.Bench.Preflight + + describe "all_ok?/1" do + test "true when every result is :ok" do + results = [ + {:hardware, {:ok, "USB device connected"}}, + {:beam_reachable, {:ok, "EPMD ok"}} + ] + + assert Preflight.all_ok?(results) + end + + test "false when any result is :error" do + results = [ + {:hardware, {:ok, "USB device connected"}}, + {:beam_reachable, {:error, "EPMD not reachable"}} + ] + + refute Preflight.all_ok?(results) + end + end + + describe "pretty/1" do + test "renders ✓ for ok and ✗ for error" do + results = [ + {:hardware, {:ok, "USB device connected"}}, + {:beam_reachable, {:error, "EPMD not reachable"}} + ] + + output = Preflight.pretty(results) + assert output =~ "✓ hardware" + assert output =~ "✗ beam reachable" + assert output =~ "USB device connected" + assert output =~ "EPMD not reachable" + end + end + + describe "check_hardware/1" do + test "with hw_udid provided → ok" do + assert {:ok, _} = Preflight.check_hardware(hw_udid: "00008110-001E1C3A34F8401E") + end + + test "with no hw_udid and no idevice_id installed → error or ok depending on env" do + # We can't reliably remove idevice_id from PATH, so we just verify the + # function doesn't crash and returns a tagged tuple. + assert match?({:ok, _}, Preflight.check_hardware([])) or + match?({:error, _}, Preflight.check_hardware([])) + end + end + + describe "check_app_installed/1" do + test "missing bundle_id → error" do + assert {:error, "bundle_id not configured"} = Preflight.check_app_installed([]) + end + + test "missing device_id → ok (skipped)" do + assert {:ok, msg} = Preflight.check_app_installed(bundle_id: "com.example.app") + assert msg =~ "skipped" + end + end + + describe "check_beam_reachable/1" do + test "no node → error" do + assert {:error, "no node provided"} = Preflight.check_beam_reachable([]) + end + + test "bad host derivation → error" do + assert {:error, msg} = Preflight.check_beam_reachable(node: :nodename_no_at) + assert msg =~ "could not derive host" + end + + test "EPMD not reachable on TEST-NET-1 → error" do + assert {:error, msg} = + Preflight.check_beam_reachable( + node: :"phantom@192.0.2.1", + host: "192.0.2.1" + ) + + assert msg =~ "not reachable" + end + end + + describe "check_rpc_responsive/1" do + test "no node → error" do + assert {:error, "no node provided"} = Preflight.check_rpc_responsive([]) + end + end + + describe "run/1 — runs all checks without crashing" do + test "returns a list of {name, result} pairs" do + results = Preflight.run([]) + + names = Enum.map(results, &elem(&1, 0)) + # Order matters — tests use this for grouped display. + assert names == [ + :hardware, + :app_installed, + :beam_reachable, + :rpc_responsive, + :nif_version, + :keep_alive_nif + ] + end + + test "honors require_keep_alive: false to skip keep-alive check" do + results = Preflight.run(require_keep_alive: false) + {_, {:ok, msg}} = List.keyfind(results, :keep_alive_nif, 0) + assert msg == "skipped" + end + + test "platform: :android dispatches to android-specific hardware/app checks" do + # Smoke test — just verify it runs without crashing and returns the + # standard 6 check names regardless of platform. + results = Preflight.run(platform: :android, adb_serial: "127.0.0.1:5555") + names = Enum.map(results, &elem(&1, 0)) + + assert names == [ + :hardware, + :app_installed, + :beam_reachable, + :rpc_responsive, + :nif_version, + :keep_alive_nif + ] + end + end + + describe "Android: check_hardware/2" do + test "no adb in PATH → error" do + # Can't reliably remove adb from PATH in tests, so just verify the + # function returns a tagged tuple without raising. + result = Preflight.check_hardware(:android, []) + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + end + + describe "Android: check_app_installed/2" do + test "missing bundle_id → error" do + assert {:error, "bundle_id not configured"} = + Preflight.check_app_installed(:android, adb_serial: "127.0.0.1:5555") + end + + test "missing adb_serial → ok (skipped)" do + assert {:ok, msg} = + Preflight.check_app_installed(:android, bundle_id: "com.example.app") + + assert msg =~ "skipped" or msg =~ "BEAM reachability" + end + end + + describe "Backward compat — single-arg check_hardware/check_app_installed" do + test "check_hardware/1 dispatches to iOS for back-compat" do + assert match?({:ok, _}, Preflight.check_hardware([])) or + match?({:error, _}, Preflight.check_hardware([])) + end + + test "check_app_installed/1 dispatches to iOS for back-compat" do + assert {:error, "bundle_id not configured"} = Preflight.check_app_installed([]) + end + end +end diff --git a/test/mob_dev/bench/probe_test.exs b/test/mob_dev/bench/probe_test.exs new file mode 100644 index 0000000..fbc06ff --- /dev/null +++ b/test/mob_dev/bench/probe_test.exs @@ -0,0 +1,207 @@ +defmodule MobDev.Bench.ProbeTest do + use ExUnit.Case, async: true + doctest MobDev.Bench.Probe + + alias MobDev.Bench.Probe + + describe "snapshot/1 — defaults and required fields" do + test "snapshot with no opts returns :unreachable" do + p = Probe.snapshot() + assert p.reachability == :unreachable + assert p.app_process == :app_unknown + assert p.usb == :no_usb + assert p.screen == :unknown + assert p.battery_pct == nil + assert is_integer(p.ts_ms) + end + + test "honors :expected_screen" do + assert Probe.snapshot(expected_screen: :off).screen == :off + assert Probe.snapshot(expected_screen: :on).screen == :on + assert Probe.snapshot(expected_screen: :unknown).screen == :unknown + # Garbage falls through to unknown. + assert Probe.snapshot(expected_screen: :totally_invalid).screen == :unknown + end + end + + describe "probe_reachability/4" do + test "nil node → :unreachable" do + assert Probe.probe_reachability(nil, "10.0.0.1", 100, 100) == :unreachable + end + + test "nil host → :unreachable" do + assert Probe.probe_reachability(:"node@10.0.0.1", nil, 100, 100) == :unreachable + end + + test "TCP closed → :unreachable" do + # 192.0.2.0/24 is TEST-NET-1, reserved for documentation, never routable. + # 127.0.0.1 has EPMD listening in this dev env, so we can't use it here. + assert Probe.probe_reachability(:"node@192.0.2.1", "192.0.2.1", 50, 50) == + :unreachable + end + + test "EPMD up but dist refused → :alive_epmd_only" do + # The host's own EPMD is up (we're running tests on a dev machine). + # Dist connect to a phantom node will fail, so we should classify as + # :alive_epmd_only. + result = Probe.probe_reachability(:"phantom@127.0.0.1", "127.0.0.1", 50, 200) + assert result in [:alive_epmd_only, :unreachable] + end + end + + describe "tcp_open?/3" do + test "false for closed port" do + # Port 1 is essentially never open on a normal box. + refute Probe.tcp_open?("127.0.0.1", 1, 100) + end + + test "false for unreachable host (timeout)" do + # 192.0.2.0/24 is reserved for documentation, never routable. + refute Probe.tcp_open?("192.0.2.1", 12345, 100) + end + + test "false for non-string host" do + refute Probe.tcp_open?(:not_a_string, 4369, 100) + end + + test "true for an open port" do + {:ok, sock} = :gen_tcp.listen(0, [:binary, active: false]) + {:ok, port} = :inet.port(sock) + + try do + assert Probe.tcp_open?("127.0.0.1", port, 200) + after + :gen_tcp.close(sock) + end + end + end + + describe "dist_connected?/1" do + test "false when not in Node.list and not self" do + refute Probe.dist_connected?(:"phantom@127.0.0.1") + end + end + + describe "rpc_responsive?/2" do + test "false for an unreachable node" do + # Setting cookie/connecting to a phantom node will fail; we just want + # the function to not crash and return false. + refute Probe.rpc_responsive?(:"phantom@127.0.0.1", 100) + end + end + + describe "format/1" do + test "screen-off with running app and rpc ok" do + assert "screen:off app:running rpc:ok battery:87%" = + Probe.format(%Probe{ + ts_ms: 0, + reachability: :alive_rpc, + app_process: :app_running, + usb: :usb_ok, + screen: :off, + battery_pct: 87 + }) + end + + test "unreachable with dead app — no battery" do + assert "screen:on app:dead rpc:unreachable" = + Probe.format(%Probe{ + ts_ms: 0, + reachability: :unreachable, + app_process: :app_dead, + usb: :no_usb, + screen: :on, + battery_pct: nil + }) + end + + test "suspended state — dist works, rpc times out" do + assert "screen:off app:suspended rpc:timeout" = + Probe.format(%Probe{ + ts_ms: 0, + reachability: :alive_dist_only, + app_process: :app_suspended, + usb: :no_usb, + screen: :off, + battery_pct: nil + }) + end + + test "no-dist state — EPMD reachable, dist refused" do + assert "screen:? app:? rpc:no-dist" = + Probe.format(%Probe{ + ts_ms: 0, + reachability: :alive_epmd_only, + app_process: :app_unknown, + usb: :no_usb, + screen: :unknown, + battery_pct: nil + }) + end + end + + describe "USB probe (without ideviceinfo present)" do + test "no hw_udid → :no_usb" do + p = Probe.snapshot(node: nil, hw_udid: nil) + assert p.usb == :no_usb + end + end + + describe "platform: :android" do + test "snapshot with no opts returns the same defaults as iOS" do + p = Probe.snapshot(platform: :android) + assert p.reachability == :unreachable + assert p.app_process == :app_unknown + assert p.usb == :no_usb + assert p.screen == :unknown + assert p.battery_pct == nil + end + + test "no adb_serial → :no_usb regardless of adb availability" do + p = Probe.snapshot(platform: :android, adb_serial: nil) + assert p.usb == :no_usb + end + + test "no bundle_id → :app_unknown" do + p = Probe.snapshot(platform: :android, adb_serial: "127.0.0.1:5555") + # reachability is :unreachable so app_process derives from that — + # but with no bundle_id we should also see :app_unknown when a probe + # path is forced. + assert p.app_process in [:app_unknown, :app_dead] + end + + test "platform: :android dispatches to android probes (no iOS device opts)" do + # With platform: :android, hw_udid and device_id should be ignored. + p = + Probe.snapshot( + platform: :android, + hw_udid: "00008110-IGNORED", + device_id: "should-be-ignored", + adb_serial: nil + ) + + assert p.usb == :no_usb + end + + test "snapshot respects expected_screen on android too" do + assert Probe.snapshot(platform: :android, expected_screen: :off).screen == :off + assert Probe.snapshot(platform: :android, expected_screen: :on).screen == :on + end + end + + describe "format/1 — android probes look the same in output" do + test "android run with usb_ok renders correctly" do + p = %Probe{ + ts_ms: 0, + reachability: :alive_rpc, + app_process: :app_running, + usb: :usb_ok, + screen: :off, + battery_pct: 73, + reason: nil + } + + assert Probe.format(p) == "screen:off app:running rpc:ok battery:73%" + end + end +end diff --git a/test/mob_dev/bench/reconnector_test.exs b/test/mob_dev/bench/reconnector_test.exs new file mode 100644 index 0000000..a0ee2b7 --- /dev/null +++ b/test/mob_dev/bench/reconnector_test.exs @@ -0,0 +1,178 @@ +defmodule MobDev.Bench.ReconnectorTest do + use ExUnit.Case, async: true + doctest MobDev.Bench.Reconnector + + alias MobDev.Bench.{Probe, Reconnector} + + describe "new/3" do + test "initialises with zero attempts and no last_attempt" do + r = Reconnector.new(:n@h, :secret) + assert r.attempts == 0 + assert r.last_attempt_ms == nil + assert r.total_reconnects == 0 + end + + test "max_backoff_ms is configurable" do + r = Reconnector.new(:n@h, :secret, max_backoff_ms: 5_000) + assert r.max_backoff_ms == 5_000 + end + end + + describe "current_backoff_ms/1" do + test "0 attempts → 0 ms" do + r = Reconnector.new(:n@h, :secret) + assert Reconnector.current_backoff_ms(r) == 0 + end + + test "increments through 0/2000/4000/8000/16000" do + assert_backoff_at_attempt(0, 0) + assert_backoff_at_attempt(1, 2_000) + assert_backoff_at_attempt(2, 4_000) + assert_backoff_at_attempt(3, 8_000) + assert_backoff_at_attempt(4, 16_000) + end + + test "caps at max_backoff_ms after schedule exhausted" do + r = %Reconnector{Reconnector.new(:n@h, :secret) | attempts: 100} + assert Reconnector.current_backoff_ms(r) == 30_000 + end + + test "respects custom max_backoff_ms cap" do + r = Reconnector.new(:n@h, :secret, max_backoff_ms: 5_000) + r = %{r | attempts: 4} + # 16_000 > 5_000 → clamps to 5_000 + assert Reconnector.current_backoff_ms(r) == 5_000 + end + + defp assert_backoff_at_attempt(attempts, expected) do + r = %{Reconnector.new(:n@h, :secret) | attempts: attempts} + actual = Reconnector.current_backoff_ms(r) + assert actual == expected, "attempts=#{attempts}: expected #{expected}, got #{actual}" + end + end + + describe "tick/3 — happy path" do + test ":alive_rpc resets attempts and returns :no_action" do + r = %{Reconnector.new(:n@h, :secret) | attempts: 5, last_attempt_ms: 1_000} + {action, r2} = Reconnector.tick(r, :alive_rpc, 5_000) + + assert action == :no_action + assert r2.attempts == 0 + end + + test "first attempt fires immediately when disconnected" do + r = Reconnector.new(:n@h, :secret) + {action, r2} = Reconnector.tick(r, :alive_dist_only, 0) + + assert action == :attempt + assert r2.attempts == 1 + assert r2.last_attempt_ms == 0 + end + + test "second attempt waits for backoff" do + r = Reconnector.new(:n@h, :secret) + {:attempt, r1} = Reconnector.tick(r, :alive_dist_only, 0) + + # 500 ms later — backoff for 2nd attempt is 2_000 ms — too soon. + {action, _} = Reconnector.tick(r1, :alive_dist_only, 500) + assert action == :no_action + + # 2_000 ms later — exactly at boundary, should fire. + {action, r2} = Reconnector.tick(r1, :alive_dist_only, 2_000) + assert action == :attempt + assert r2.attempts == 2 + end + + test "schedule progresses through 0, 2_000, 4_000, 8_000, 16_000" do + r = Reconnector.new(:n@h, :secret) + now = 0 + + # Attempt 1 — immediate. + {:attempt, r} = Reconnector.tick(r, :alive_dist_only, now) + now = now + 2_000 + + # Attempt 2 — wait 2 s. + {:attempt, r} = Reconnector.tick(r, :alive_dist_only, now) + now = now + 4_000 + + # Attempt 3 — wait 4 s. + {:attempt, r} = Reconnector.tick(r, :alive_dist_only, now) + now = now + 8_000 + + # Attempt 4 — wait 8 s. + {:attempt, r} = Reconnector.tick(r, :alive_dist_only, now) + assert r.attempts == 4 + end + end + + describe "tick/3 — accepts Probe struct directly" do + test "uses probe.reachability" do + probe = %Probe{ + ts_ms: 0, + reachability: :alive_rpc, + app_process: :app_running, + usb: :no_usb, + screen: :off, + battery_pct: 80, + reason: nil + } + + r = %{Reconnector.new(:n@h, :secret) | attempts: 3} + {action, r2} = Reconnector.tick(r, probe, 0) + assert action == :no_action + assert r2.attempts == 0 + end + end + + describe "record_success/1" do + test "resets attempts and bumps total_reconnects" do + r = %{Reconnector.new(:n@h, :secret) | attempts: 3, total_reconnects: 1} + r2 = Reconnector.record_success(r) + assert r2.attempts == 0 + assert r2.total_reconnects == 2 + end + end + + describe "realistic scenario — 30 second outage" do + test "during a 30 second WiFi flap, attempts reach the cap" do + r = Reconnector.new(:n@h, :secret) + + # Simulate poll every 1 second for 30 s, all reporting :alive_dist_only. + {final_r, attempt_times} = + Enum.reduce(0..30_000//1_000, {r, []}, fn now, {acc_r, acc_attempts} -> + case Reconnector.tick(acc_r, :alive_dist_only, now) do + {:attempt, new_r} -> {new_r, [now | acc_attempts]} + {:no_action, new_r} -> {new_r, acc_attempts} + end + end) + + attempts_made = Enum.reverse(attempt_times) + + # Attempts should be at: 0, 2000, 6000, 14000, 30000 (cumulative wait + # between attempts: 0, 2, 4, 8, 16). + # Allowing some slack since our simulated polls are in 1 s steps: + assert length(attempts_made) >= 4 + assert final_r.attempts >= 4 + end + + test "resumes immediately on reconnect, then fresh disconnect starts fresh" do + r = Reconnector.new(:n@h, :secret) + + # Disconnect → attempt 1 + {:attempt, r} = Reconnector.tick(r, :alive_dist_only, 0) + + # Mark success. + r = Reconnector.record_success(r) + assert r.attempts == 0 + assert r.total_reconnects == 1 + + # Healthy poll. + {:no_action, r} = Reconnector.tick(r, :alive_rpc, 1_000) + + # New disconnect — should attempt immediately again. + {action, r} = Reconnector.tick(r, :alive_dist_only, 2_000) + assert action == :attempt + assert r.attempts == 1 + end + end +end diff --git a/test/mob_dev/bench/summary_test.exs b/test/mob_dev/bench/summary_test.exs new file mode 100644 index 0000000..12de9d9 --- /dev/null +++ b/test/mob_dev/bench/summary_test.exs @@ -0,0 +1,261 @@ +defmodule MobDev.Bench.SummaryTest do + use ExUnit.Case, async: true + + alias MobDev.Bench.Summary + + defp row(opts) do + %{ + ts_ms: Keyword.get(opts, :ts_ms, 0), + elapsed_sec: Keyword.get(opts, :elapsed_sec, 0.0), + reachability: Keyword.get(opts, :reachability, :alive_rpc), + app_process: Keyword.get(opts, :app_process, :app_running), + usb: Keyword.get(opts, :usb, :usb_ok), + screen: Keyword.get(opts, :screen, :off), + battery_pct: Keyword.get(opts, :battery_pct, 100), + reason: Keyword.get(opts, :reason) + } + end + + describe "from_rows/1 — empty" do + test "empty list returns zero metrics" do + m = Summary.from_rows([]) + assert m.total_samples == 0 + assert m.successful_samples == 0 + assert m.success_rate == 0.0 + assert m.start_battery == nil + assert m.end_battery == nil + assert m.drain_pct == nil + assert m.taint_warnings == [] + end + end + + describe "from_rows/1 — happy path" do + test "all healthy 30-min run" do + rows = [ + row(elapsed_sec: 0.0, battery_pct: 100), + row(elapsed_sec: 600.0, battery_pct: 99), + row(elapsed_sec: 1200.0, battery_pct: 98), + row(elapsed_sec: 1800.0, battery_pct: 97) + ] + + m = Summary.from_rows(rows) + assert m.total_samples == 4 + assert m.successful_samples == 4 + assert m.success_rate == 1.0 + assert m.reconnect_count == 0 + assert m.start_battery == 100 + assert m.end_battery == 97 + assert m.drain_pct == 3 + assert m.effective_rate_pct_per_hour == 6.0 + end + + test "screen-off duration matches" do + rows = [ + row(elapsed_sec: 0.0, screen: :off), + row(elapsed_sec: 600.0, screen: :off), + row(elapsed_sec: 1200.0, screen: :off), + row(elapsed_sec: 1800.0, screen: :off) + ] + + m = Summary.from_rows(rows) + assert m.screen_off_duration_sec == 1800.0 + assert m.screen_on_duration_sec == 0.0 + end + end + + describe "from_rows/1 — reconnect counting" do + test "single drop and reconnect counts as 1 reconnect" do + rows = [ + row(elapsed_sec: 0.0, reachability: :alive_rpc), + row(elapsed_sec: 10.0, reachability: :alive_dist_only), + row(elapsed_sec: 20.0, reachability: :alive_rpc) + ] + + assert Summary.from_rows(rows).reconnect_count == 1 + end + + test "multiple drops and reconnects" do + rows = [ + row(elapsed_sec: 0.0, reachability: :alive_rpc), + row(elapsed_sec: 10.0, reachability: :alive_epmd_only), + row(elapsed_sec: 20.0, reachability: :alive_rpc), + row(elapsed_sec: 30.0, reachability: :unreachable), + row(elapsed_sec: 40.0, reachability: :alive_rpc), + row(elapsed_sec: 50.0, reachability: :alive_dist_only), + row(elapsed_sec: 60.0, reachability: :alive_rpc) + ] + + assert Summary.from_rows(rows).reconnect_count == 3 + end + + test "no reconnects in stable run" do + rows = [ + row(elapsed_sec: 0.0, reachability: :alive_rpc), + row(elapsed_sec: 10.0, reachability: :alive_rpc) + ] + + assert Summary.from_rows(rows).reconnect_count == 0 + end + end + + describe "from_rows/1 — gap analysis" do + test "longest_gap_sec finds max interval between successful reads" do + rows = [ + row(elapsed_sec: 0.0, battery_pct: 100), + row(elapsed_sec: 5.0, battery_pct: 100), + row(elapsed_sec: 30.0, battery_pct: 99), + row(elapsed_sec: 31.0, battery_pct: 99) + ] + + assert Summary.from_rows(rows).longest_gap_sec == 25.0 + end + end + + describe "from_rows/1 — state duration breakdown" do + test "tracks time spent in each reachability state" do + rows = [ + row(elapsed_sec: 0.0, reachability: :alive_rpc), + row(elapsed_sec: 10.0, reachability: :alive_rpc), + row(elapsed_sec: 20.0, reachability: :alive_dist_only), + row(elapsed_sec: 30.0, reachability: :alive_dist_only), + row(elapsed_sec: 40.0, reachability: :alive_rpc) + ] + + m = Summary.from_rows(rows) + # State at row[i] applies to the gap (row[i].t .. row[i+1].t). + # alive_rpc: 0→10 + 10→20 = 20s + # alive_dist_only: 20→30 + 30→40 = 20s + assert m.state_durations[:alive_rpc] == 20.0 + assert m.state_durations[:alive_dist_only] == 20.0 + end + end + + describe "from_rows/1 — taint warnings" do + test "warns when screen turns ON during off-screen run" do + rows = [ + row(elapsed_sec: 0.0, screen: :off), + row(elapsed_sec: 100.0, screen: :on) + ] + + assert "screen turned ON during off-screen run" in Summary.from_rows(rows).taint_warnings + end + + test "warns when app process reported dead" do + rows = [ + row(elapsed_sec: 0.0, app_process: :app_running), + row(elapsed_sec: 10.0, app_process: :app_dead) + ] + + assert "app process reported as dead at some point" in Summary.from_rows(rows).taint_warnings + end + + test "warns when majority unreachable" do + rows = [ + row(elapsed_sec: 0.0, reachability: :unreachable), + row(elapsed_sec: 10.0, reachability: :unreachable), + row(elapsed_sec: 20.0, reachability: :alive_rpc) + ] + + assert "majority of polls were :unreachable" in Summary.from_rows(rows).taint_warnings + end + + test "warns when reconnects exceed threshold" do + rows = + for i <- 0..30 do + state = if rem(i, 2) == 0, do: :alive_rpc, else: :alive_dist_only + row(elapsed_sec: i * 1.0, reachability: state) + end + + assert "many reconnects (>=10) — flapping connection" in Summary.from_rows(rows).taint_warnings + end + + test "no warnings on a clean run" do + rows = [ + row(elapsed_sec: 0.0, screen: :off, battery_pct: 100), + row(elapsed_sec: 10.0, screen: :off, battery_pct: 99) + ] + + assert Summary.from_rows(rows).taint_warnings == [] + end + end + + describe "from_csv/1 — round-trip with Logger" do + setup do + path = + Path.join(System.tmp_dir!(), "summary_test_#{System.unique_integer([:positive])}.csv") + + on_exit(fn -> File.rm(path) end) + {:ok, path: path} + end + + test "reads back what Logger wrote", %{path: path} do + log = MobDev.Bench.Logger.open(path, start_ts_ms: 0) + + log = + log + |> MobDev.Bench.Logger.append(%MobDev.Bench.Probe{ + ts_ms: 0, + reachability: :alive_rpc, + app_process: :app_running, + usb: :usb_ok, + screen: :off, + battery_pct: 100, + reason: nil + }) + |> MobDev.Bench.Logger.append(%MobDev.Bench.Probe{ + ts_ms: 600_000, + reachability: :alive_rpc, + app_process: :app_running, + usb: :usb_ok, + screen: :off, + battery_pct: 99, + reason: nil + }) + |> MobDev.Bench.Logger.append(%MobDev.Bench.Probe{ + ts_ms: 1_800_000, + reachability: :alive_rpc, + app_process: :app_running, + usb: :usb_ok, + screen: :off, + battery_pct: 97, + reason: nil + }) + + MobDev.Bench.Logger.close(log) + + m = Summary.from_csv(path) + assert m.total_samples == 3 + assert m.start_battery == 100 + assert m.end_battery == 97 + assert m.drain_pct == 3 + assert m.effective_rate_pct_per_hour == 6.0 + end + end + + describe "pretty/1" do + test "renders the basics" do + rows = [ + row(elapsed_sec: 0.0, battery_pct: 100), + row(elapsed_sec: 1800.0, battery_pct: 97) + ] + + output = Summary.pretty(Summary.from_rows(rows)) + assert output =~ "Total samples:" + assert output =~ "Battery:" + assert output =~ "100%" + assert output =~ "97%" + refute output =~ "WARNINGS" + end + + test "includes warnings section when tainted" do + rows = [ + row(elapsed_sec: 0.0, screen: :off), + row(elapsed_sec: 100.0, screen: :on) + ] + + output = Summary.pretty(Summary.from_rows(rows)) + assert output =~ "WARNINGS" + assert output =~ "screen turned ON" + end + end +end diff --git a/test/mob_dev/config_test.exs b/test/mob_dev/config_test.exs new file mode 100644 index 0000000..34ba588 --- /dev/null +++ b/test/mob_dev/config_test.exs @@ -0,0 +1,37 @@ +defmodule MobDev.ConfigTest do + use ExUnit.Case, async: true + + alias MobDev.Config + + describe "parse_platforms/1" do + test "nil (unset) defaults to both platforms" do + assert Config.parse_platforms(nil) == [:android, :ios] + end + + test "a single platform is kept" do + assert Config.parse_platforms([:ios]) == [:ios] + assert Config.parse_platforms([:android]) == [:android] + end + + test "both platforms normalize to a stable order" do + assert Config.parse_platforms([:ios, :android]) == [:android, :ios] + end + + test "unknown entries are dropped, valid ones kept" do + assert Config.parse_platforms([:windows, :ios]) == [:ios] + end + + test "an empty list falls back to both platforms" do + assert Config.parse_platforms([]) == [:android, :ios] + end + + test "a list with no valid platforms falls back to both" do + assert Config.parse_platforms([:bogus, "ios"]) == [:android, :ios] + end + + test "a non-list value falls back to both platforms" do + assert Config.parse_platforms(:ios) == [:android, :ios] + assert Config.parse_platforms("ios") == [:android, :ios] + end + end +end diff --git a/test/mob_dev/connector_test.exs b/test/mob_dev/connector_test.exs index 4cb630f..e96b289 100644 --- a/test/mob_dev/connector_test.exs +++ b/test/mob_dev/connector_test.exs @@ -2,6 +2,51 @@ defmodule MobDev.ConnectorTest do use ExUnit.Case, async: true alias MobDev.Connector + alias MobDev.Device + + # ── filter_only/2 ──────────────────────────────────────────────────────────── + + describe "filter_only/2" do + setup do + devices = [ + %Device{serial: "ZY22CRLMWK", platform: :android}, + %Device{serial: "ZY22DP6HFL", platform: :android}, + %Device{serial: "00008110-001E1C3A34F8401E", platform: :ios} + ] + + {:ok, devices: devices} + end + + test "empty pattern list is a no-op (connect to all)", %{devices: devices} do + assert Connector.filter_only(devices, []) == devices + end + + test "matches a single serial substring", %{devices: devices} do + assert [%Device{serial: "ZY22CRLMWK"}] = Connector.filter_only(devices, ["ZY22CRLMWK"]) + end + + test "matching is case-insensitive", %{devices: devices} do + assert [%Device{serial: "ZY22CRLMWK"}] = Connector.filter_only(devices, ["zy22crlmwk"]) + end + + test "partial substrings match", %{devices: devices} do + # both Motos share the ZY22 prefix + result = Connector.filter_only(devices, ["ZY22"]) + assert length(result) == 2 + end + + test "multiple patterns union their matches", %{devices: devices} do + result = Connector.filter_only(devices, ["CRLMWK", "00008110"]) + serials = Enum.map(result, & &1.serial) + assert "ZY22CRLMWK" in serials + assert "00008110-001E1C3A34F8401E" in serials + refute "ZY22DP6HFL" in serials + end + + test "no match yields an empty list", %{devices: devices} do + assert Connector.filter_only(devices, ["nonexistent"]) == [] + end + end # ── start_epmd/0 ───────────────────────────────────────────────────────────── @@ -15,8 +60,10 @@ defmodule MobDev.ConnectorTest do test "is safe to call multiple times" do # epmd -daemon is idempotent — subsequent calls exit 0 immediately. - Connector.start_epmd() - Connector.start_epmd() + r1 = Connector.start_epmd() + r2 = Connector.start_epmd() + assert r1 == :ok or match?({_, _}, r1) + assert r2 == :ok or match?({_, _}, r2) end end @@ -51,7 +98,10 @@ defmodule MobDev.ConnectorTest do test "sets cookie when Node.start succeeds" do # Requires distribution — only run with --only integration. # Ensures the success path doesn't raise. - case Node.start(:"connector_test_#{System.unique_integer([:positive])}@127.0.0.1", :longnames) do + case Node.start( + :"connector_test_#{System.unique_integer([:positive])}@127.0.0.1", + :longnames + ) do {:ok, _} -> Connector.handle_dist_start({:ok, self()}, :test_cookie) assert Node.get_cookie() == :test_cookie @@ -68,9 +118,16 @@ defmodule MobDev.ConnectorTest do @tag :integration test "sets cookie on already_started" do # already_started means distribution is running — cookie update should succeed. - case Node.start(:"connector_test2_#{System.unique_integer([:positive])}@127.0.0.1", :longnames) do + case Node.start( + :"connector_test2_#{System.unique_integer([:positive])}@127.0.0.1", + :longnames + ) do result when result in [{:ok, self()}, {:error, {:already_started, self()}}] -> - Connector.handle_dist_start({:error, {:already_started, self()}}, :already_started_cookie) + Connector.handle_dist_start( + {:error, {:already_started, self()}}, + :already_started_cookie + ) + assert Node.get_cookie() == :already_started_cookie {:error, _} -> diff --git a/test/mob_dev/deployer_test.exs b/test/mob_dev/deployer_test.exs new file mode 100644 index 0000000..a8784b7 --- /dev/null +++ b/test/mob_dev/deployer_test.exs @@ -0,0 +1,2082 @@ +defmodule MobDev.DeployerTest do + use ExUnit.Case, async: true + + alias MobDev.Deployer + + describe "authoritative iOS restart results" do + test "simulator and physical restart paths require authoritative callback success" do + parent = self() + + simulator_launcher = fn udid, bundle, opts -> + send(parent, {:simulator_restart, udid, bundle, opts}) + {"launched", 0} + end + + assert Deployer.restart_ios_simulator(true, "SIM-UDID", "com.example.app", + dist_port: 9120, + node_suffix: "sim-a", + ios_launcher: simulator_launcher + ) == :ok + + assert_received {:simulator_restart, "SIM-UDID", "com.example.app", simulator_opts} + assert simulator_opts[:dist_port] == 9120 + assert simulator_opts[:node_suffix] == "sim-a" + + assert Deployer.restart_ios_simulator(true, "SIM-UDID", "com.example.app", + ios_launcher: fn _udid, _bundle, _opts -> {"private output", 9} end + ) == {:error, "iOS app restart failed with exit status 9"} + + physical_restarter = fn udid, bundle -> + send(parent, {:physical_restart, udid, bundle}) + {"launched", 0} + end + + assert Deployer.restart_ios_physical(true, "PHONE-UDID", "com.example.app", + ios_physical_restarter: physical_restarter + ) == :ok + + assert_received {:physical_restart, "PHONE-UDID", "com.example.app"} + + assert Deployer.restart_ios_physical(true, "PHONE-UDID", "com.example.app", + ios_physical_restarter: fn _udid, _bundle -> :malformed end + ) == {:error, "iOS app restart returned a malformed result"} + end + + test "restart false skips both platform callbacks" do + assert Deployer.restart_ios_simulator(false, "SIM-UDID", "com.example.app", + ios_launcher: fn _udid, _bundle, _opts -> flunk("simulator callback ran") end + ) == :ok + + assert Deployer.restart_ios_physical(false, "PHONE-UDID", "com.example.app", + ios_physical_restarter: fn _udid, _bundle -> flunk("physical callback ran") end + ) == :ok + end + + test "accepts only a well-formed zero exit status" do + assert Deployer.execute_ios_restart(fn -> {"launched", 0} end) == :ok + + assert Deployer.execute_ios_restart(fn -> {"private command output", 7} end) == + {:error, "iOS app restart failed with exit status 7"} + + assert Deployer.execute_ios_restart(fn -> :ok end) == + {:error, "iOS app restart returned a malformed result"} + end + + test "normalizes raised, thrown, exited, and invalid callbacks without leaking output" do + assert Deployer.execute_ios_restart(fn -> raise "private device output" end) == + {:error, "iOS app restart failed before an authoritative result"} + + assert Deployer.execute_ios_restart(fn -> throw(:private_device_output) end) == + {:error, "iOS app restart failed before an authoritative result"} + + assert Deployer.execute_ios_restart(fn -> exit(:private_device_output) end) == + {:error, "iOS app restart failed before an authoritative result"} + + assert Deployer.execute_ios_restart(:invalid) == + {:error, "iOS app restart callback is invalid"} + end + + test "invokes the restart callback exactly once" do + parent = self() + + assert Deployer.execute_ios_restart(fn -> + send(parent, :restart_called) + {"launched", 0} + end) == :ok + + assert_received :restart_called + refute_received :restart_called + end + end + + # ── generate_crypto_shim/0 ──────────────────────────────────────────────── + + describe "generate_crypto_shim/0" do + test "compiles successfully" do + # Delete cached shim so we always test a fresh compile + File.rm_rf!(Path.join(System.tmp_dir!(), "mob_crypto_shim")) + assert {:ok, dir} = Deployer.generate_crypto_shim() + assert File.exists?(Path.join(dir, "crypto.beam")) + assert File.exists?(Path.join(dir, "crypto.app")) + end + + test "is idempotent — second call reuses cached shim" do + assert {:ok, dir1} = Deployer.generate_crypto_shim() + assert {:ok, dir2} = Deployer.generate_crypto_shim() + assert dir1 == dir2 + end + + test "shim exports pbkdf2_hmac/5" do + {:ok, dir} = Deployer.generate_crypto_shim() + + {:ok, {_, chunks}} = + :beam_lib.chunks(Path.join(dir, "crypto.beam") |> String.to_charlist(), [:exports]) + + exports = chunks[:exports] + assert {:pbkdf2_hmac, 5} in exports + end + + test "shim exports exor/2" do + {:ok, dir} = Deployer.generate_crypto_shim() + + {:ok, {_, chunks}} = + :beam_lib.chunks(Path.join(dir, "crypto.beam") |> String.to_charlist(), [:exports]) + + exports = chunks[:exports] + assert {:exor, 2} in exports + end + + test "shim exports strong_rand_bytes/1, mac/4, mac/3, hash/2, supports/1" do + {:ok, dir} = Deployer.generate_crypto_shim() + + {:ok, {_, chunks}} = + :beam_lib.chunks(Path.join(dir, "crypto.beam") |> String.to_charlist(), [:exports]) + + exports = chunks[:exports] + + for {name, arity} <- [ + {:strong_rand_bytes, 1}, + {:mac, 4}, + {:mac, 3}, + {:hash, 2}, + {:supports, 1} + ] do + assert {name, arity} in exports, "expected #{name}/#{arity} in exports" + end + end + + test "pbkdf2_hmac/5 returns binary of requested length" do + {:ok, dir} = Deployer.generate_crypto_shim() + :code.add_patha(String.to_charlist(dir)) + # Call via apply to avoid compile-time crypto dependency + result = apply(:crypto, :pbkdf2_hmac, [:sha256, "password", "salt", 1000, 32]) + assert byte_size(result) == 32 + :code.del_path(String.to_charlist(dir)) + end + + test "pbkdf2_hmac/5 is deterministic" do + {:ok, dir} = Deployer.generate_crypto_shim() + :code.add_patha(String.to_charlist(dir)) + r1 = apply(:crypto, :pbkdf2_hmac, [:sha256, "pw", "salt", 100, 16]) + r2 = apply(:crypto, :pbkdf2_hmac, [:sha256, "pw", "salt", 100, 16]) + assert r1 == r2 + :code.del_path(String.to_charlist(dir)) + end + + test "exor/2 XORs two binaries" do + {:ok, dir} = Deployer.generate_crypto_shim() + :code.add_patha(String.to_charlist(dir)) + result = apply(:crypto, :exor, [<<0xFF, 0x00>>, <<0x0F, 0xFF>>]) + assert result == <<0xF0, 0xFF>> + :code.del_path(String.to_charlist(dir)) + end + + test "mac/4 returns a non-empty binary" do + {:ok, dir} = Deployer.generate_crypto_shim() + :code.add_patha(String.to_charlist(dir)) + result = apply(:crypto, :mac, [:hmac, :sha256, "key", "data"]) + assert byte_size(result) > 0 + :code.del_path(String.to_charlist(dir)) + end + + test "mac/4 is deterministic for same inputs" do + {:ok, dir} = Deployer.generate_crypto_shim() + :code.add_patha(String.to_charlist(dir)) + r1 = apply(:crypto, :mac, [:hmac, :sha256, "key", "data"]) + r2 = apply(:crypto, :mac, [:hmac, :sha256, "key", "data"]) + assert r1 == r2 + :code.del_path(String.to_charlist(dir)) + end + end + + # ── categorize_results/1 ──────────────────────────────────────────────── + + describe "categorize_results/1" do + # Use minimal Device structs (just the fields the function reads, plus + # the ones the production code threads through for display). + defp device(name), do: %MobDev.Device{name: name, serial: name, platform: :android} + + test "buckets :ok results as deployed" do + a = device("a") + b = device("b") + + assert {[^a, ^b], [], []} = Deployer.categorize_results([{:ok, a}, {:ok, b}]) + end + + test "buckets :error results as failed" do + a = device("a") + + assert {[], [^a], []} = Deployer.categorize_results([{:error, a}]) + end + + test "buckets :skipped results as skipped" do + a = device("a") + + assert {[], [], [^a]} = Deployer.categorize_results([{:skipped, a}]) + end + + test "skipped does NOT leak into failed (regression pin)" do + # The original behaviour returned `:error` for app-not-installed, + # so the count of "failed" devices included multi-platform sweep + # skips. categorize_results pins the three-way split. + deployed = device("deployed") + stale = device("stale_lock_skipped") + busted = device("real_failure") + + assert {[^deployed], [^busted], [^stale]} = + Deployer.categorize_results([ + {:ok, deployed}, + {:skipped, stale}, + {:error, busted} + ]) + end + + test "empty input returns three empty lists" do + assert {[], [], []} = Deployer.categorize_results([]) + end + + test "mixed real-world shape — iOS deploy + 5 Android skips" do + iphone = device("iPhone") + androids = for i <- 1..5, do: device("emulator-#{i}") + + results = [{:ok, iphone} | Enum.map(androids, &{:skipped, &1})] + + {deployed, failed, skipped} = Deployer.categorize_results(results) + assert deployed == [iphone] + assert failed == [] + assert length(skipped) == 5 + end + end + + describe "select_canonical_android_devices/2" do + defp canonical_device(serial, status \\ :discovered) do + %MobDev.Device{platform: :android, serial: serial, status: status, abi: "arm64-v8a"} + end + + test "selects the full exact set in canonical order and ignores unrelated devices" do + abc = canonical_device("ABC") + serial_b = canonical_device("serial-b") + + devices = [ + canonical_device("unrelated"), + serial_b, + canonical_device("blocked", :unauthorized), + abc, + canonical_device("recovery", :error) + ] + + assert {:ok, [^abc, ^serial_b]} = + Deployer.select_canonical_android_devices(devices, ["ABC", "serial-b"]) + end + + test "fails closed on a missing canonical serial" do + assert {:error, :canonical_target_missing} = + Deployer.select_canonical_android_devices( + [canonical_device("unrelated")], + ["ABC"] + ) + end + + test "fails closed on exact duplicate discovery rows" do + assert {:error, :canonical_target_duplicated} = + Deployer.select_canonical_android_devices( + [canonical_device("ABC"), canonical_device("ABC")], + ["ABC"] + ) + end + + test "fails closed on case-collision ambiguity" do + assert {:error, :canonical_case_collision} = + Deployer.select_canonical_android_devices( + [canonical_device("ABC"), canonical_device("abc")], + ["ABC"] + ) + + assert {:error, :canonical_case_collision} = + Deployer.select_canonical_android_devices( + [canonical_device("abc")], + ["ABC"] + ) + end + + test "fails closed on duplicated or unavailable canonical targets" do + assert {:error, :duplicate_canonical_target} = + Deployer.select_canonical_android_devices( + [canonical_device("ABC")], + ["ABC", "ABC"] + ) + + assert {:error, :canonical_target_unavailable} = + Deployer.select_canonical_android_devices( + [canonical_device("ABC", :unauthorized)], + ["ABC"] + ) + + assert {:error, :canonical_target_unavailable} = + Deployer.select_canonical_android_devices( + [%MobDev.Device{platform: :ios, serial: "ABC", status: :discovered}], + ["ABC"] + ) + end + end + + describe "authoritative Android payload plan" do + @describetag :tmp_dir + + test "emits the exact shared schema and validates registered immutable bytes", %{tmp_dir: dir} do + {context, opts} = android_payload_fixture!(dir) + + assert {:ok, plan} = Deployer.prepare_android_payload(context, opts) + + assert Enum.sort(Map.keys(plan)) == + Enum.sort([ + :version, + :package, + :attempt_id, + :serials, + :selected_abis, + :selected_abis_by_serial, + :apk, + :beam, + :exqlite, + :restart_by_serial + ]) + + assert Enum.sort(Map.keys(plan.beam)) == + Enum.sort([ + :archive, + :stage_device, + :app_stage, + :app_backup, + :activation_lock, + :dist_snapshot, + :runtime_version, + :beam_flags + ]) + + assert plan.exqlite == nil + assert plan.beam.beam_flags == "+S 1:1" + assert %{restart?: true, mode: :checked_restart} = plan.restart_by_serial["serial-a"] + refute Map.has_key?(plan, :cleanup_token) + refute Map.has_key?(plan.beam, :live_dir) + refute Map.has_key?(plan.beam, :checks) + + identity = %{package: context.bundle_id, serials: context.serials} + assert Deployer.valid_android_payload?(plan, identity) + + for path <- [plan.apk.path, plan.beam.archive.path] do + assert {:ok, %{type: :regular, mode: mode}} = File.stat(path) + assert Bitwise.band(mode, 0o222) == 0 + end + + assert :ok = Deployer.cleanup_android_payload(plan) + assert :ok = Deployer.cleanup_android_payload(plan) + refute File.exists?(plan.apk.path) + end + + test "rejects changed or writable payload bytes but cleanup remains registry-scoped", %{ + tmp_dir: dir + } do + {context, opts} = android_payload_fixture!(dir) + assert {:ok, plan} = Deployer.prepare_android_payload(context, opts) + identity = %{package: context.bundle_id, serials: context.serials} + + File.chmod!(plan.beam.archive.path, 0o600) + File.write!(plan.beam.archive.path, "changed") + refute Deployer.valid_android_payload?(plan, identity) + + forged = put_in(plan.apk.sha256, String.duplicate("0", 64)) + assert {:error, _reason} = Deployer.cleanup_android_payload(forged) + assert File.exists?(plan.apk.path) + + assert :ok = Deployer.cleanup_android_payload(plan) + refute File.exists?(plan.apk.path) + end + + test "dist snapshot and filesystem archive use the same staged bytes after source mutation", + %{ + tmp_dir: dir + } do + {context, opts} = android_payload_fixture!(dir) + beam_dir = opts |> Keyword.fetch!(:beam_dirs) |> List.first() + source_path = Path.join(beam_dir, "Elixir.MobDev.Deployer.beam") + mutated_source = alternate_deployer_beam!() + + local_runner = fn executable, args, command_opts -> + result = System.cmd(executable, args, command_opts) + + if executable == "cp" and elem(result, 1) == 0 do + File.write!(source_path, mutated_source) + end + + result + end + + assert {:ok, plan} = + Deployer.prepare_android_payload( + context, + Keyword.put(opts, :local_runner, local_runner) + ) + + archive_dir = Path.join(dir, "archive-contents") + File.mkdir_p!(archive_dir) + assert {"", 0} = System.cmd("tar", ["xf", plan.beam.archive.path, "-C", archive_dir]) + + archived_beam = File.read!(Path.join(archive_dir, "Elixir.MobDev.Deployer.beam")) + + assert [%{module: MobDev.Deployer, binary: dist_beam}] = plan.beam.dist_snapshot + assert dist_beam == archived_beam + refute dist_beam == mutated_source + + assert :ok = Deployer.cleanup_android_payload(plan) + end + + test "rejects unchecked restart before reserving any artifact root", %{tmp_dir: dir} do + {context, opts} = android_payload_fixture!(dir) + + assert {:error, "Native Android payload requires checked restart"} = + Deployer.prepare_android_payload(context, Keyword.put(opts, :restart, false)) + + assert Path.wildcard(Path.join(dir, "mob_android_payload_*")) == [] + end + + test "an unregistered structurally valid copy in another process has no cleanup authority", %{ + tmp_dir: dir + } do + {context, opts} = android_payload_fixture!(dir) + assert {:ok, plan} = Deployer.prepare_android_payload(context, opts) + + task = Task.async(fn -> Deployer.cleanup_android_payload(plan) end) + assert {:error, _reason} = Task.await(task) + assert File.exists?(plan.apk.path) + assert :ok = Deployer.cleanup_android_payload(plan) + end + end + + describe "deploy_all/1 native canonical Android selection" do + @describetag :tmp_dir + + test "mutates the exact canonical set once and ignores unrelated late devices", %{ + tmp_dir: dir + } do + parent = self() + abc = canonical_device("ABC") + serial_b = canonical_device("serial-b") + + lister = fn -> + [ + canonical_device("late-device"), + serial_b, + canonical_device("blocked", :unauthorized), + abc + ] + end + + deploy = fn device -> + send(parent, {:mutated, device.serial}) + {:ok, device} + end + + result = + ExUnit.CaptureIO.capture_io(fn -> + assert {[^abc, ^serial_b], [], []} = + Deployer.deploy_all( + [ + platforms: [:android], + force_fs: true, + canonical_android_serials: ["ABC", "serial-b"], + android_lister: lister, + device_deployer: deploy + ] ++ fast_deploy_test_opts(dir) + ) + end) + + assert result =~ "2 device(s)" + assert_received {:mutated, "ABC"} + assert_received {:mutated, "serial-b"} + refute_received {:mutated, _} + end + + test "validates the complete canonical set before any mutation", %{tmp_dir: dir} do + parent = self() + + deploy = fn device -> + send(parent, {:mutated, device.serial}) + {:ok, device} + end + + invalid_snapshots = [ + [canonical_device("unrelated")], + [canonical_device("ABC"), canonical_device("ABC")], + [canonical_device("ABC"), canonical_device("abc")] + ] + + Enum.each(invalid_snapshots, fn devices -> + assert_raise Mix.Error, + "Canonical Android target set no longer matches discovery; refusing final deploy", + fn -> + ExUnit.CaptureIO.capture_io(fn -> + Deployer.deploy_all( + [ + platforms: [:android], + force_fs: true, + canonical_android_serials: ["ABC"], + android_lister: fn -> devices end, + device_deployer: deploy + ] ++ fast_deploy_test_opts(dir) + ) + end) + end + + refute_received {:mutated, _} + end) + end + + test "ordinary --device matching remains case-insensitive", %{tmp_dir: dir} do + parent = self() + abc = canonical_device("ABC") + + deploy = fn device -> + send(parent, {:mutated, device.serial}) + {:ok, device} + end + + ExUnit.CaptureIO.capture_io(fn -> + assert {[^abc], [], []} = + Deployer.deploy_all( + [ + platforms: [:android], + force_fs: true, + device: "abc", + android_lister: fn -> [abc, canonical_device("unrelated")] end, + device_deployer: deploy + ] ++ fast_deploy_test_opts(dir) + ) + end) + + assert_received {:mutated, "ABC"} + refute_received {:mutated, _} + end + end + + describe "fast Android transaction boundaries" do + @describetag :tmp_dir + + test "an absent package is read-only skipped and never prepares, locks, or mutates" do + device = canonical_device("serial-a") + parent = self() + + assert {[], [], [%{serial: "serial-a", status: :skipped}]} = + Deployer.deploy_all( + platforms: [:android], + android_lister: fn -> [device] end, + android_package_runner: fn args -> + send(parent, {:package_probe, args}) + {"", 0} + end, + fast_android_payload_preparer: fn _devices, _package, _opts -> + send(parent, :prepared) + {:error, "unexpected"} + end, + android_lock_runner: fn args -> + send(parent, {:locked, args}) + {"", 0} + end, + device_deployer: fn target -> + send(parent, {:mutated, target.serial}) + {:ok, target} + end + ) + + assert_received {:package_probe, ["-s", "serial-a", "shell", "pm", "list", "packages", _]} + refute_received :prepared + refute_received {:locked, _} + refute_received {:mutated, _} + end + + test "payload preparation failure happens before lease acquisition or mutation" do + device = canonical_device("serial-a") + parent = self() + + ExUnit.CaptureIO.capture_io(fn -> + assert {[], [%{serial: "serial-a", status: :error}], []} = + Deployer.deploy_all( + platforms: [:android], + android_lister: fn -> [device] end, + android_package_runner: installed_package_runner(), + fast_android_payload_preparer: fn _devices, _package, _opts -> + send(parent, :prepared) + {:error, "snapshot failed"} + end, + android_lock_runner: fn args -> + send(parent, {:locked, args}) + {"", 0} + end, + device_deployer: fn target -> + send(parent, {:mutated, target.serial}) + {:ok, target} + end + ) + end) + + assert_received :prepared + refute_received {:locked, _} + refute_received {:mutated, _} + end + + test "a later-target failure reports zero deployed and retains the exact-set lease", %{ + tmp_dir: dir + } do + first = canonical_device("serial-a") + second = canonical_device("serial-b") + parent = self() + + opts = + [ + platforms: [:android], + canonical_android_serials: ["serial-a", "serial-b"], + android_lister: fn -> [second, first] end, + device_deployer: fn + %{serial: "serial-a"} = target -> + send(parent, {:mutated, "serial-a"}) + {:ok, target} + + %{serial: "serial-b"} -> + send(parent, {:mutated, "serial-b"}) + {:error, "second target failed"} + end + ] ++ fast_deploy_test_opts(dir) + + ExUnit.CaptureIO.capture_io(fn -> + assert {{[], failed, []}, %{state: :retained_failure, serials: serials}} = + Deployer.deploy_all_with_lease(opts) + + assert serials == ["serial-a", "serial-b"] + assert Enum.map(failed, & &1.serial) == ["serial-a", "serial-b"] + assert Enum.all?(failed, &(&1.status == :error)) + end) + + assert_received {:mutated, "serial-a"} + assert_received {:mutated, "serial-b"} + refute_received {:mutated, _} + end + + test "a target throw after the first filesystem mutation retains the lease and never starts iOS", + %{ + tmp_dir: dir + } do + first = canonical_device("serial-a") + second = canonical_device("serial-b") + ios = %MobDev.Device{platform: :ios, serial: "ios-a", status: :discovered} + parent = self() + + opts = + [ + platforms: [:android, :ios], + force_fs: true, + canonical_android_serials: ["serial-a", "serial-b"], + android_lister: fn -> [second, first] end, + ios_lister: fn -> + send(parent, :ios_discovery_started) + [ios] + end, + device_deployer: fn + %{platform: :android, serial: "serial-a"} = target -> + send(parent, {:mutated, :android, "serial-a"}) + {:ok, target} + + %{platform: :android, serial: "serial-b"} -> + send(parent, {:mutated, :android, "serial-b"}) + throw(:target_runner_lost) + + %{platform: :ios, serial: serial} = target -> + send(parent, {:mutated, :ios, serial}) + {:ok, target} + end + ] ++ fast_deploy_test_opts(dir) + + ExUnit.CaptureIO.capture_io(fn -> + assert {{[], failed, []}, retained} = Deployer.deploy_all_with_lease(opts) + + assert retained.state == :retained_failure + assert retained.phase == :acquired + assert retained.serials == ["serial-a", "serial-b"] + assert Enum.map(failed, & &1.serial) == ["serial-a", "serial-b"] + assert Enum.all?(failed, &(&1.status == :error)) + end) + + assert_received {:mutated, :android, "serial-a"} + assert_received {:mutated, :android, "serial-b"} + refute_received :ios_discovery_started + refute_received {:mutated, :ios, _} + end + + test "an entirely connected exact set hot-pushes and repaints inside one lease", %{ + tmp_dir: dir + } do + first = canonical_device("serial-a") + second = canonical_device("serial-b") + node_a = MobDev.Device.node_name(first) + node_b = MobDev.Device.node_name(second) + parent = self() + base_lock_runner = successful_android_lock_runner() + + lock_runner = fn args -> + send(parent, {:lease_command, args}) + base_lock_runner.(args) + end + + rpc = fn node, module, _filename, _binary -> + send(parent, {:hot_rpc, node, module}) + {:module, module} + end + + repaint = fn node -> + send(parent, {:repaint, node}) + :ok + end + + opts = + [ + platforms: [:android], + canonical_android_serials: ["serial-a", "serial-b"], + android_lister: fn -> [second, first] end, + connected_nodes: [node_b, node_a], + android_lock_runner: lock_runner, + hot_push_rpc: rpc, + hot_push_post_push: repaint, + device_deployer: fn _target -> flunk("filesystem deploy must not run") end + ] ++ fast_deploy_test_opts(dir) + + ExUnit.CaptureIO.capture_io(fn -> + assert {[^first, ^second], [], []} = Deployer.deploy_all(opts) + end) + + assert_received {:hot_rpc, ^node_a, MobDev.Deployer} + assert_received {:hot_rpc, ^node_b, MobDev.Deployer} + assert_received {:repaint, ^node_a} + assert_received {:repaint, ^node_b} + + lease_commands = recorded_lease_commands() + + transition_index = + Enum.find_index(lease_commands, &adb_command_contains?([&1], "|fast_committed")) + + assert is_integer(transition_index) + assert adb_command_contains?(lease_commands, ".mob_native_deploy_releasing_") + assert adb_command_contains?(lease_commands, "/record; rmdir ") + refute adb_command_contains?(lease_commands, "rm -rf") + end + + test "one disconnected target forces an exact-set filesystem transaction", %{tmp_dir: dir} do + first = canonical_device("serial-a") + second = canonical_device("serial-b") + node_a = MobDev.Device.node_name(first) + parent = self() + + opts = + [ + platforms: [:android], + canonical_android_serials: ["serial-a", "serial-b"], + android_lister: fn -> [second, first] end, + connected_nodes: [node_a], + hot_push_rpc: fn _, _, _, _ -> flunk("mixed transport must not hot-push") end, + hot_push_post_push: fn _ -> flunk("mixed transport must not repaint") end, + device_deployer: fn target -> + send(parent, {:filesystem_mutation, target.serial}) + {:ok, target} + end + ] ++ fast_deploy_test_opts(dir) + + ExUnit.CaptureIO.capture_io(fn -> + assert {[^first, ^second], [], []} = Deployer.deploy_all(opts) + end) + + assert_received {:filesystem_mutation, "serial-a"} + assert_received {:filesystem_mutation, "serial-b"} + refute_received {:filesystem_mutation, _} + end + + test "hot-push or repaint ambiguity reports zero deployed and retains the lease", %{ + tmp_dir: dir + } do + device = canonical_device("serial-a") + node = MobDev.Device.node_name(device) + + for {rpc, repaint} <- [ + {fn _node, _module, _filename, _binary -> {:error, :load_failed} end, + fn _node -> flunk("repaint must not run after load failure") end}, + {fn _node, module, _filename, _binary -> {:module, module} end, + fn _node -> throw(:repaint_reply_lost) end} + ] do + opts = + [ + platforms: [:android], + android_lister: fn -> [device] end, + connected_nodes: [node], + hot_push_rpc: rpc, + hot_push_post_push: repaint + ] ++ fast_deploy_test_opts(dir) + + ExUnit.CaptureIO.capture_io(fn -> + assert {{[], [%{serial: "serial-a", status: :error}], []}, + %{state: :retained_failure, phase: :acquired}} = + Deployer.deploy_all_with_lease(opts) + end) + end + end + end + + describe "public Android mutation authority" do + @describetag :tmp_dir + + test "all legacy mutators reject missing operation-wide authority before any command", %{ + tmp_dir: dir + } do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + ebin = exqlite_fixture!(dir) + parent = self() + + runner = fn args -> + send(parent, {:runner, args}) + {:ok, "Status: ok\n"} + end + + local_runner = fn executable, args, _opts -> + send(parent, {:local_runner, executable, args}) + {"", 0} + end + + device = canonical_device("serial-a") + + assert {:error, direct_reason} = + Deployer.deploy_android_device(device, [beam_dir], [], runner: runner) + + assert direct_reason =~ "Direct Android device mutation is disabled" + + assert {:error, beam_reason} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: runner, + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + assert beam_reason =~ "operation-wide deploy lease" + + assert {:error, exqlite_reason} = + Deployer.setup_exqlite_android_runas("serial-a", ebin, "0.35.0", + package: "com.example.casein", + app_data: "/data/data/com.example.casein/files", + runner: runner, + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001", + nif_target: "/data/app/x/lib/arm64/libsqlite3_nif.so" + ) + + assert exqlite_reason =~ "operation-wide deploy lease" + + assert {:error, restart_reason} = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + node_suffix: "serial_a", + sleeper: fn _ -> send(parent, :slept) end + ], + runner + ) + + assert restart_reason =~ "operation-wide deploy lease" + refute_received {:runner, _} + refute_received {:local_runner, _, _} + refute_received :slept + end + end + + # ── android_package_installed?/2 ──────────────────────────────────────── + + describe "android_package_installed?/2" do + test "true when pm output contains the package line" do + pm_out = "package:com.example.test_migration\n" + assert Deployer.android_package_installed?(pm_out, "com.example.test_migration") + end + + test "false when pm output is empty (no matching package)" do + # Adb's `pm list packages <pkg>` returns empty output when there's + # no match — NOT a 'package:' line with empty body. + refute Deployer.android_package_installed?("", "com.example.test_migration") + end + + test "false when pm output lists a DIFFERENT package" do + pm_out = "package:com.example.different_app\n" + refute Deployer.android_package_installed?(pm_out, "com.example.test_migration") + end + + test "true when pm output has the target package among others" do + pm_out = """ + package:com.example.test_migration + package:com.example.different_app + """ + + assert Deployer.android_package_installed?(pm_out, "com.example.test_migration") + end + + test "false on partial match without 'package:' prefix" do + # Defensive: substring match must require the 'package:' prefix so + # output like "com.example.test_migration is your app" doesn't + # falsely register as installed. + pm_out = "com.example.test_migration unrelated text\n" + refute Deployer.android_package_installed?(pm_out, "com.example.test_migration") + end + end + + describe "__sqlite_nif_target__/1 (exqlite NIF symlink target, ABI-aware)" do + test "picks the 64-bit lib on an arm64 device" do + lines = ["/data/app/com.example.app-hash==/lib/arm64/libsqlite3_nif.so"] + assert Deployer.__sqlite_nif_target__(lines) =~ "/lib/arm64/libsqlite3_nif.so" + end + + test "picks the 32-bit lib on an armeabi-v7a device (the bug: was hardcoded arm64)" do + # Android extracts only the active ABI, so a 32-bit phone has lib/arm — + # hardcoding lib/arm64 produced a dangling symlink and crashed boot. + lines = ["/data/app/com.example.app-hash==/lib/arm/libsqlite3_nif.so"] + assert Deployer.__sqlite_nif_target__(lines) == hd(lines) + end + + test "nil when the glob matched nothing (ls returned no real path)" do + assert Deployer.__sqlite_nif_target__([]) == nil + end + + test "ignores trailing whitespace and unrelated lines" do + lines = [" /data/app/x/lib/arm/libsqlite3_nif.so ", "ls: bad: No such file"] + assert Deployer.__sqlite_nif_target__(lines) == "/data/app/x/lib/arm/libsqlite3_nif.so" + end + end + + describe "push_beams_android_runas/3" do + @describetag :tmp_dir + + test "checks Android 9 no-same-owner extraction and a readable app BEAM", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + runner = android_beam_runner(self()) + + assert :ok = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: runner, + local_runner: &System.cmd/3, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + commands = deployer_recorded_commands() + + assert Enum.any?(commands, fn + ["-s", "serial-a", "shell", command] -> + command == + "run-as com.example.casein tar xof /data/local/tmp/mob_beams_testattempt00001.tar -C /data/data/com.example.casein/files/otp/.mob_beams_stage_testattempt00001/" + + _ -> + false + end) + + assert Enum.any?(commands, fn + ["-s", "serial-a", "shell", command] -> + command =~ "test -r" and command =~ "Elixir.Sample.beam" + + _ -> + false + end) + + refute Enum.any?(commands, fn args -> + Enum.any?(args, &String.contains?(&1, "; true")) + end) + + assert Enum.all?(commands, &match?(["-s", "serial-a" | _], &1)) + + assert Enum.any?(commands, fn + ["-s", "serial-a", "shell", command] -> + command =~ "had_live=0" and command =~ ".mob_beams_backup_testattempt00001" + + _ -> + false + end) + end + + test "atomically stages requested flags and present priv with readable sentinels", %{ + tmp_dir: dir + } do + beam_dir = Path.join(dir, "beams") + priv_dir = Path.join(dir, "priv") + File.mkdir_p!(beam_dir) + File.mkdir_p!(Path.join(priv_dir, "repo/migrations")) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + File.write!(Path.join(priv_dir, "repo/migrations/001_create.exs"), "migration") + + local_runner = fn executable, args, opts -> + result = System.cmd(executable, args, opts) + + if executable == "tar" and elem(result, 1) == 0 do + archive = Enum.at(args, 1) + {listing, 0} = System.cmd("tar", ["tf", archive]) + send(self(), {:archive_listing, listing}) + end + + result + end + + assert :ok = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: android_beam_runner(self()), + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001", + beam_flags: "-S 1:1", + priv_dir: priv_dir + ) + + assert_received {:archive_listing, listing} + assert listing =~ "mob_beam_flags" + assert listing =~ "priv/repo/migrations/001_create.exs" + + commands = deployer_recorded_commands() + + assert adb_command_contains?(commands, "test -r") + assert adb_command_contains?(commands, "mob_beam_flags") + assert adb_command_contains?(commands, "priv/repo/migrations/001_create.exs") + assert adb_command_contains?(commands, "mv /data/data/com.example.casein/files/otp/casein") + end + + test "flags write and priv copy failures issue zero adb commands", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "beams") + priv_dir = Path.join(dir, "priv") + File.mkdir_p!(beam_dir) + File.mkdir_p!(priv_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + File.write!(Path.join(priv_dir, "asset.txt"), "asset") + + assert {:error, "stage Android BEAM flags failed"} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: android_beam_runner(self()), + local_runner: &System.cmd/3, + file_writer: fn _path, _contents -> {:error, :eacces} end, + tmp_root: dir, + attempt_id: "testattempt00001", + beam_flags: "-S 1:1" + ) + + refute_received {:adb_command, _} + + local_runner = fn executable, args, opts -> + if executable == "cp" and Enum.any?(args, &String.contains?(&1, "priv/.")) do + {"sensitive child output", 1} + else + System.cmd(executable, args, opts) + end + end + + assert {:error, reason} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: android_beam_runner(self()), + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001", + priv_dir: priv_dir + ) + + assert reason == "stage Android priv files failed" + refute reason =~ "sensitive child output" + refute_received {:adb_command, _} + end + + test "fails closed for copy, tar, push, mkdir, extract, and BEAM verification errors", %{ + tmp_dir: dir + } do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + + for {failure, expected} <- [ + {:copy, "stage BEAM files"}, + {:tar, "create BEAM archive"}, + {:push, "push BEAM archive"}, + {:mkdir, "prepare BEAM directory"}, + {:extract, "extract BEAM archive"}, + {:verify, "verify deployed BEAM"}, + {:activate, "activate deployed BEAMs"} + ] do + runner = android_beam_runner(self(), failure) + + local_runner = fn executable, args, opts -> + send(self(), {:local_command, executable, args}) + + if (failure == :copy and executable == "cp") or + (failure == :tar and executable == "tar") do + {"#{failure} failed", 1} + else + System.cmd(executable, args, opts) + end + end + + assert {:error, reason} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: runner, + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + assert reason =~ expected + refute reason =~ "sensitive child output" + assert byte_size(reason) <= 512 + commands = deployer_recorded_commands() + + assert Enum.all?(commands, &match?(["-s", "serial-a" | _], &1)) + + case failure do + local when local in [:copy, :tar] -> + assert commands == [] + + :push -> + refute adb_command_contains?(commands, "tar xof") + refute adb_command_contains?(commands, "test -r") + + :mkdir -> + refute adb_command_contains?(commands, "tar xof") + refute adb_command_contains?(commands, "test -r") + + :extract -> + refute adb_command_contains?(commands, "test -r") + + :verify -> + refute adb_command_contains?(commands, "had_live=0") + + :activate -> + cleanup_commands = + Enum.filter(commands, fn args -> + adb_command_contains?([args], "run-as com.example.casein rm -rf") + end) + + assert cleanup_commands == [] + end + + flush_local_commands() + end + end + + test "rejects an empty BEAM source before issuing adb commands", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "empty-beams") + File.mkdir_p!(beam_dir) + runner = android_beam_runner(self()) + + assert {:error, reason} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: runner, + local_runner: &System.cmd/3, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + assert reason =~ "BEAM sentinel" + refute_received {:adb_command, _} + end + + test "refuses a stale backup and never deletes it", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + + runner = fn args -> + send(self(), {:adb_command, args}) + + if adb_command_contains?([args], "test ! -e") and + adb_command_contains?([args], ".mob_beams_backup_testattempt00001") do + {:error, "stale backup exists"} + else + {:ok, ""} + end + end + + assert {:error, reason} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: runner, + local_runner: &System.cmd/3, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + assert reason == "prepare BEAM directory failed" + commands = deployer_recorded_commands() + refute adb_command_contains?(commands, "tar xof") + + cleanup_commands = + Enum.filter(commands, &adb_command_contains?([&1], "run-as com.example.casein rm -rf")) + + assert cleanup_commands == [] + end + + test "rejects an unsafe attempt id before local or device commands", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + + local_runner = fn executable, args, _opts -> + send(self(), {:local_command, executable, args}) + {"unexpected", 0} + end + + assert {:error, reason} = + Deployer.push_beams_android_runas("serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: android_beam_runner(self()), + local_runner: local_runner, + tmp_root: dir, + attempt_id: "../../unsafe" + ) + + assert reason =~ "Invalid Android deploy attempt id" + refute_received {:local_command, _, _} + refute_received {:adb_command, _} + end + + test "rejects an unsafe adb serial before local or device commands", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + + local_runner = fn executable, args, _opts -> + send(self(), {:local_command, executable, args}) + {"unexpected", 0} + end + + assert {:error, reason} = + Deployer.push_beams_android_runas("-serial-a", [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: android_beam_runner(self()), + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + assert reason =~ "Invalid adb serial" + refute_received {:local_command, _, _} + refute_received {:adb_command, _} + end + + test "does not put an adb serial into local staging paths", %{tmp_dir: dir} do + beam_dir = Path.join(dir, "beams") + File.mkdir_p!(beam_dir) + File.write!(Path.join(beam_dir, "Elixir.Sample.beam"), "beam") + serial = "serial-with-local-path-marker" + + local_runner = fn executable, args, opts -> + send(self(), {:local_command, executable, args}) + System.cmd(executable, args, opts) + end + + assert :ok = + Deployer.push_beams_android_runas(serial, [beam_dir], + package: "com.example.casein", + operation_authority: android_operation_authority!(serial), + beams_dir: "/data/data/com.example.casein/files/otp/casein", + runner: android_beam_runner(self()), + local_runner: local_runner, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + local_commands = recorded_local_commands() + + refute Enum.any?(local_commands, fn {_executable, args} -> + Enum.any?(args, &String.contains?(&1, serial)) + end) + + _ = deployer_recorded_commands() + end + end + + describe "ensure_erts_on_device/3" do + test "fails closed when adb cannot verify the runtime" do + runner = fn _args -> {:error, "device offline " <> String.duplicate("x", 1_000)} end + + assert {:error, reason} = + Deployer.ensure_erts_on_device("serial-a", "com.example.casein", runner) + + assert reason =~ "Could not verify OTP runtime on serial-a" + assert byte_size(reason) <= 512 + end + + test "accepts a readable runtime sentinel" do + runner = fn args -> + assert Enum.any?(args, &String.contains?(&1, "erl_child_setup")) + {:ok, ""} + end + + assert :ok = Deployer.ensure_erts_on_device("serial-a", "com.example.casein", runner) + end + + test "rejects invalid serial or package before the runner" do + runner = fn args -> + send(self(), {:adb_command, args}) + {:ok, ""} + end + + assert {:error, _reason} = + Deployer.ensure_erts_on_device("-serial-a", "com.example.casein", runner) + + assert {:error, _reason} = + Deployer.ensure_erts_on_device("serial-a", "com.example.bad;id", runner) + + refute_received {:adb_command, _} + end + end + + describe "verify_elixir_runtime_version_android/5" do + test "accepts an exact version and fails closed for mismatch, malformed, and adb errors" do + app_data = "/data/data/com.example.casein/files" + + runner = fn _args -> {:ok, ~s({application,elixir,[{vsn,"1.20.0"}]}. )} end + + assert :ok = + Deployer.verify_elixir_runtime_version_android( + "serial-a", + "com.example.casein", + app_data, + "1.20.0", + runner + ) + + for result <- [ + {:ok, ~s({application,elixir,[{vsn,"1.19.0"}]}. )}, + {:ok, "malformed"}, + {:error, "sensitive child output"}, + :invalid + ] do + runner = fn _args -> result end + + assert {:error, reason} = + Deployer.verify_elixir_runtime_version_android( + "serial-a", + "com.example.casein", + app_data, + "1.20.0", + runner + ) + + refute reason =~ "sensitive child output" + end + end + + test "validates all interpolated inputs before runner invocation" do + runner = fn args -> + send(self(), {:adb_command, args}) + {:ok, ""} + end + + assert {:error, _} = + Deployer.verify_elixir_runtime_version_android( + "serial-a", + "com.example.bad;id", + "/data/data/com.example.bad;id/files", + "1.20.0", + runner + ) + + assert {:error, _} = + Deployer.verify_elixir_runtime_version_android( + "serial-a", + "com.example.casein", + "/data/data/com.example.other/files", + "1.20.0", + runner + ) + + refute_received {:adb_command, _} + end + + test "uses a dedicated bounded metadata cap without exposing content" do + app_data = "/data/data/com.example.casein/files" + valid = ~s({application,elixir,[{vsn,"1.20.0"}]}. ) + + verify = fn content -> + Deployer.verify_elixir_runtime_version_android( + "serial-a", + "com.example.casein", + app_data, + "1.20.0", + fn _args -> {:ok, content} end + ) + end + + # The artifact that exposed the old shared 8 KiB query limit is valid + # structured metadata and remains comfortably below the dedicated cap. + current_artifact = valid <> String.duplicate(" ", 8_319 - byte_size(valid)) + assert byte_size(current_artifact) == 8_319 + assert :ok = verify.(current_artifact) + + at_limit = valid <> String.duplicate(" ", 65_536 - byte_size(valid)) + assert byte_size(at_limit) == 65_536 + assert :ok = verify.(at_limit) + + over_limit = at_limit <> "x" + assert byte_size(over_limit) == 65_537 + + assert {:error, "Could not verify Elixir runtime version: output_too_large"} = + verify.(over_limit) + + sensitive = at_limit <> "TOP_SECRET_METADATA" + + assert {:error, reason} = verify.(sensitive) + assert reason == "Could not verify Elixir runtime version: output_too_large" + refute reason =~ "TOP_SECRET_METADATA" + end + end + + describe "setup_exqlite_android_runas/4" do + @describetag :tmp_dir + + test "stages, verifies, locks, swaps, and separately commits exqlite", %{tmp_dir: dir} do + ebin = exqlite_fixture!(dir) + runner = android_exqlite_runner(self()) + + assert :ok = + Deployer.setup_exqlite_android_runas("serial-a", ebin, "0.35.0", + package: "com.example.casein", + operation_authority: android_operation_authority!(), + app_data: "/data/data/com.example.casein/files", + runner: runner, + local_runner: &System.cmd/3, + tmp_root: dir, + attempt_id: "testattempt00001", + nif_target: "/data/app/~~hash/base/lib/arm64/libsqlite3_nif.so" + ) + + commands = deployer_recorded_commands() + assert Enum.all?(commands, &match?(["-s", "serial-a" | _], &1)) + assert adb_command_contains?(commands, "tar xof") + assert adb_command_contains?(commands, "ln -sf") + assert adb_command_contains?(commands, "ebin/exqlite.app") + assert adb_command_contains?(commands, "ebin/Elixir.Exqlite.beam") + assert adb_command_contains?(commands, "test -L") + + activation = Enum.find(commands, &adb_command_contains?([&1], "had_live=0")) + + assert adb_command_contains?([activation], "mkdir ") + assert adb_command_contains?([activation], ".mob_exqlite_activation_lock") + + refute adb_command_contains?( + [activation], + "rm -rf /data/data/com.example.casein/files/otp/lib/.mob_exqlite_backup_" + ) + + lock_release = + Enum.find(commands, fn args -> + adb_command_contains?([args], "rmdir") and + adb_command_contains?([args], ".mob_exqlite_activation_lock") + end) + + expected_backup_cleanup = [ + "-s", + "serial-a", + "shell", + "run-as com.example.casein rm -rf /data/data/com.example.casein/files/otp/lib/.mob_exqlite_backup_testattempt00001" + ] + + backup_cleanup = Enum.find(commands, &(&1 == expected_backup_cleanup)) + + assert lock_release == [ + "-s", + "serial-a", + "shell", + "run-as com.example.casein rmdir /data/data/com.example.casein/files/otp/lib/.mob_exqlite_activation_lock" + ] + + assert backup_cleanup == expected_backup_cleanup + end + + test "rejects incomplete local exqlite before adb", %{tmp_dir: dir} do + ebin = Path.join(dir, "exqlite-ebin") + File.mkdir_p!(ebin) + File.write!(Path.join(ebin, "exqlite.app"), "app") + + assert {:error, reason} = + Deployer.setup_exqlite_android_runas("serial-a", ebin, "0.35.0", + package: "com.example.casein", + operation_authority: android_operation_authority!(), + app_data: "/data/data/com.example.casein/files", + runner: android_exqlite_runner(self()), + tmp_root: dir, + attempt_id: "testattempt00001", + nif_target: "/data/app/x/lib/arm64/libsqlite3_nif.so" + ) + + assert reason =~ "exqlite ebin is incomplete" + refute_received {:adb_command, _} + end + + test "activation ambiguity preserves backup and lock and never commits", %{tmp_dir: dir} do + ebin = exqlite_fixture!(dir) + runner = android_exqlite_runner(self(), :activate) + + assert {:error, reason} = + Deployer.setup_exqlite_android_runas("serial-a", ebin, "0.35.0", + package: "com.example.casein", + operation_authority: android_operation_authority!(), + app_data: "/data/data/com.example.casein/files", + runner: runner, + local_runner: &System.cmd/3, + tmp_root: dir, + attempt_id: "testattempt00001", + nif_target: "/data/app/x/lib/arm64/libsqlite3_nif.so" + ) + + assert reason == "activate exqlite runtime failed" + commands = deployer_recorded_commands() + activation = Enum.find(commands, &adb_command_contains?([&1], "had_live=0")) + + expected_activation = + "run-as com.example.casein sh -c 'set -e; " <> + "mkdir /data/data/com.example.casein/files/otp/lib/.mob_exqlite_activation_lock; " <> + "had_live=0; " <> + "if [ -e /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0 ]; " <> + "then mv /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0 " <> + "/data/data/com.example.casein/files/otp/lib/.mob_exqlite_backup_testattempt00001; " <> + "had_live=1; fi; " <> + "if mv /data/data/com.example.casein/files/otp/lib/.mob_exqlite_stage_testattempt00001 " <> + "/data/data/com.example.casein/files/otp/lib/exqlite-0.35.0 && " <> + "test -r /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0/ebin/exqlite.app && " <> + "test -r /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0/ebin/Elixir.Exqlite.beam && " <> + "test -L /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0/priv/sqlite3_nif.so && " <> + "test -r /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0/priv/sqlite3_nif.so; " <> + "then :; else rm -rf /data/data/com.example.casein/files/otp/lib/exqlite-0.35.0; " <> + "if [ \"$had_live\" -eq 1 ]; then " <> + "mv /data/data/com.example.casein/files/otp/lib/.mob_exqlite_backup_testattempt00001 " <> + "/data/data/com.example.casein/files/otp/lib/exqlite-0.35.0; fi; exit 1; fi'" + + assert activation == ["-s", "serial-a", "shell", expected_activation] + + refute adb_command_contains?( + [activation], + "rm -rf /data/data/com.example.casein/files/otp/lib/.mob_exqlite_backup_" + ) + + refute adb_command_contains?(commands, "rmdir") + + cleanup_commands = + Enum.filter(commands, &adb_command_contains?([&1], "run-as com.example.casein rm -rf")) + + refute Enum.any?(cleanup_commands, &adb_command_contains?([&1], ".mob_exqlite_backup_")) + + refute Enum.any?( + cleanup_commands, + &adb_command_contains?([&1], ".mob_exqlite_activation_lock") + ) + end + + test "fails closed for zero, multiple, malformed, and oversized NIF query output", %{ + tmp_dir: dir + } do + ebin = exqlite_fixture!(dir) + + for nif_output <- [ + "", + "/data/app/a/lib/arm64/libsqlite3_nif.so\n/data/app/b/lib/arm64/libsqlite3_nif.so\n", + <<255, 254>>, + String.duplicate("x", 8_193) + ] do + runner = fn args -> + send(self(), {:adb_command, args}) + + cond do + adb_command_contains?([args], "pm path") -> + {:ok, "package:/data/app/~~hash/base.apk\n"} + + adb_command_contains?([args], "libsqlite3_nif.so") -> + {:ok, nif_output} + + true -> + {:ok, ""} + end + end + + assert {:error, reason} = + Deployer.setup_exqlite_android_runas("serial-a", ebin, "0.35.0", + package: "com.example.casein", + operation_authority: android_operation_authority!(), + app_data: "/data/data/com.example.casein/files", + runner: runner, + local_runner: &System.cmd/3, + tmp_root: dir, + attempt_id: "testattempt00001" + ) + + assert reason =~ "exqlite NIF" or reason =~ "invalid adb output" + commands = deployer_recorded_commands() + refute Enum.any?(commands, &("push" in &1)) + end + end + end + + describe "restart_android/3" do + test "uses am start -W and propagates a launch failure" do + runner = fn args -> + send(self(), {:restart_command, args}) + + if "start" in args do + {:error, "activity failed"} + else + {:ok, ""} + end + end + + assert {:error, reason} = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + operation_authority: android_operation_authority!(), + activity: ".MainActivity", + node_suffix: "serial_a", + sleeper: fn _ -> :ok end + ], + runner + ) + + assert reason =~ "launch Android app" + + assert_received {:restart_command, ["-s", "serial-a", "shell", "am", "start", "-W" | _]} + end + + test "stops before relabel or launch when force-stop fails" do + runner = fn args -> + send(self(), {:restart_command, args}) + + if "force-stop" in args, + do: {:error, "sensitive child output"}, + else: {:ok, "Status: ok\n"} + end + + assert {:error, reason} = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + operation_authority: android_operation_authority!(), + node_suffix: "serial_a", + sleeper: fn _ -> send(self(), :slept) end + ], + runner + ) + + assert reason == "force-stop Android app failed" + refute reason =~ "sensitive child output" + refute_received :slept + + remaining_commands = restart_recorded_commands() + refute Enum.any?(remaining_commands, &("chcon" in &1)) + refute Enum.any?(remaining_commands, &("start" in &1)) + end + + test "requires an exact bounded Status: ok launch marker" do + for launch_output <- [ + "", + "Starting: Intent", + "Status: okay", + "Status: ok\nError: bad", + "Status: ok\nStatus: ok", + "Status: ok\nStatus: timeout" + ] do + runner = fn args -> + if "start" in args, do: {:ok, launch_output}, else: {:ok, ""} + end + + assert {:error, reason} = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + operation_authority: android_operation_authority!(), + node_suffix: "serial_a", + sleeper: fn _ -> :ok end + ], + runner + ) + + assert reason =~ "no success status" + end + + runner = fn args -> + if "start" in args, do: {:ok, "Status: ok\nLaunchState: COLD\n"}, else: {:ok, ""} + end + + assert :ok = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + operation_authority: android_operation_authority!(), + node_suffix: "serial_a", + sleeper: fn _ -> :ok end + ], + runner + ) + + exact_limit = "Status: ok\n" <> String.duplicate("x", 4_085) + assert byte_size(exact_limit) == 4_096 + + runner = fn args -> + if "start" in args, do: {:ok, exact_limit}, else: {:ok, ""} + end + + assert :ok = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + operation_authority: android_operation_authority!(), + node_suffix: "serial_a", + sleeper: fn _ -> :ok end + ], + runner + ) + + for invalid_output <- [exact_limit <> "x", <<"Status: ok\n", 255>>] do + runner = fn args -> + if "start" in args, do: {:ok, invalid_output}, else: {:ok, ""} + end + + assert {:error, reason} = + Deployer.restart_android( + "serial-a", + [ + package: "com.example.casein", + operation_authority: android_operation_authority!(), + node_suffix: "serial_a", + sleeper: fn _ -> :ok end + ], + runner + ) + + assert reason =~ "invalid adb output" + end + end + + test "rejects shell-significant launch options before runner or sleeper" do + for opts <- [ + [package: "com.example.bad;id", node_suffix: "serial_a"], + [package: "com.example.casein", activity: ".Main$Activity", node_suffix: "serial_a"], + [package: "com.example.casein", activity: ".MainActivity'", node_suffix: "serial_a"], + [package: "com.example.casein", node_suffix: "bad;suffix"] + ] do + runner = fn args -> + send(self(), {:restart_command, args}) + {:ok, "Status: ok\n"} + end + + opts = Keyword.put(opts, :sleeper, fn _ -> send(self(), :slept) end) + assert {:error, _reason} = Deployer.restart_android("serial-a", opts, runner) + refute_received {:restart_command, _} + refute_received :slept + end + end + + test "rejects an unsafe serial before suffix derivation or runner invocation" do + runner = fn args -> + send(self(), {:restart_command, args}) + {:ok, "Status: ok\n"} + end + + assert {:error, "Invalid adb serial; refusing BEAM delivery"} = + Deployer.restart_android("-serial-a", [sleeper: fn _ -> :ok end], runner) + + refute_received {:restart_command, _} + end + end + + defp android_beam_runner(owner, failure \\ nil) do + fn args -> + send(owner, {:adb_command, args}) + + cond do + failure == :push and "push" in args -> + {:error, "push failed"} + + failure == :mkdir and Enum.any?(args, &String.contains?(&1, "mkdir -p")) -> + {:error, "mkdir failed"} + + failure == :extract and Enum.any?(args, &String.contains?(&1, "tar xof")) -> + {:error, "extract failed"} + + failure == :verify and Enum.any?(args, &String.contains?(&1, "test -r")) -> + {:error, "verify failed: sensitive child output"} + + failure == :activate and Enum.any?(args, &String.contains?(&1, "had_live=0")) -> + {:error, "activate failed: sensitive child output"} + + true -> + {:ok, ""} + end + end + end + + defp android_payload_fixture!(dir) do + apk = Path.join(dir, "source.apk") + File.write!(apk, "immutable-apk") + + beam_dir = Path.join(dir, "beam-source") + File.mkdir_p!(beam_dir) + source_beam = :code.which(MobDev.Deployer) |> List.to_string() + File.cp!(source_beam, Path.join(beam_dir, "Elixir.MobDev.Deployer.beam")) + + apk_bytes = File.read!(apk) + + context = %{ + apk: apk, + apk_sha256: :crypto.hash(:sha256, apk_bytes) |> Base.encode16(case: :lower), + apk_size: byte_size(apk_bytes), + bundle_id: "com.example.casein", + serials: ["serial-a"], + selected_abis: ["arm64-v8a"], + selected_abis_by_serial: %{"serial-a" => "arm64-v8a"} + } + + opts = [ + attempt_id: "payloadtest00001", + beam_dirs: [beam_dir], + priv_dir: nil, + exqlite_source: nil, + tmp_root: dir, + restart: true, + beam_flags: "+S 1:1", + dist_port: 9_100, + node_suffix_resolver: fn "serial-a" -> "serial_a" end + ] + + {context, opts} + end + + defp alternate_deployer_beam! do + forms = [ + {:attribute, 1, :module, MobDev.Deployer}, + {:attribute, 1, :export, [{:staged_snapshot_marker, 0}]}, + {:function, 1, :staged_snapshot_marker, 0, + [{:clause, 1, [], [], [{:atom, 1, :mutated_live_source}]}]} + ] + + assert {:ok, MobDev.Deployer, binary} = :compile.forms(forms, [:return_errors]) + binary + end + + defp fast_deploy_test_opts(dir) do + beam_dir = Path.join(dir, "fast-beam-source") + File.mkdir_p!(beam_dir) + source_beam = :code.which(MobDev.Deployer) |> List.to_string() + File.cp!(source_beam, Path.join(beam_dir, "Elixir.MobDev.Deployer.beam")) + package = MobDev.Config.bundle_id() + + [ + android_package_runner: fn _args -> {"package:#{package}\n", 0} end, + android_lock_runner: successful_android_lock_runner(), + beam_dirs: [beam_dir], + priv_dir: nil, + exqlite_source: nil, + tmp_root: dir, + node_suffix_resolver: fn serial -> + serial |> String.downcase() |> String.replace("-", "_") + end + ] + end + + defp installed_package_runner do + package = MobDev.Config.bundle_id() + fn _args -> {"package:#{package}\n", 0} end + end + + defp successful_android_lock_runner do + {:ok, state} = Agent.start_link(fn -> %{} end) + + fn ["-s", serial, "shell", command] -> + cond do + String.contains?(command, "printf %s \"") -> + record = + Regex.scan(Regex.compile!(~S|printf %s "([^"]+)"|), command) + |> List.last() + |> List.last() + + Agent.update(state, &Map.put(&1, serial, record)) + {"", 0} + + String.contains?(command, ".mob_native_deploy_releasing_") and + String.ends_with?(command, "/record'") -> + {Agent.get(state, &Map.get(&1, serial, "")), 0} + + String.ends_with?(command, ".mob_native_deploy_lock/record'") -> + {Agent.get(state, &Map.get(&1, serial, "")), 0} + + true -> + {"", 0} + end + end + end + + defp android_operation_authority!(serial \\ "serial-a") do + cache_key = {:android_operation_authority, serial} + + case Process.get(cache_key) do + nil -> + root = + Path.join( + System.tmp_dir!(), + "mob_deployer_authority_#{System.unique_integer([:positive, :monotonic])}" + ) + + File.mkdir_p!(root) + {context, opts} = android_payload_fixture!(root) + + attempt_id = + System.unique_integer([:positive, :monotonic]) + |> Integer.to_string(36) + |> String.pad_leading(16, "0") + |> String.slice(-16, 16) + + context = %{ + context + | serials: [serial], + selected_abis_by_serial: %{serial => "arm64-v8a"} + } + + opts = + opts + |> Keyword.put(:attempt_id, attempt_id) + |> Keyword.put(:node_suffix_resolver, fn _serial -> "serial_a" end) + + assert {:ok, plan} = Deployer.prepare_android_payload(context, opts) + serials = [serial] + digest = :crypto.hash(:sha256, Enum.join(serials, <<0>>)) |> Base.encode16(case: :lower) + + lease = %{ + bundle_id: context.bundle_id, + owner: "testauthority001", + serials: serials, + target_digest: digest, + phase: :native_ready, + state: :held_success + } + + record = "1|#{lease.owner}|#{lease.target_digest}|native_ready" + lock_runner = fn _args -> {record, 0} end + authority = {plan, %{package: context.bundle_id, serials: serials}, lease, lock_runner} + Process.put(cache_key, authority) + on_exit(fn -> File.rm_rf(root) end) + authority + + authority -> + authority + end + end + + defp exqlite_fixture!(dir) do + ebin = Path.join(dir, "exqlite-ebin") + File.mkdir_p!(ebin) + + File.write!( + Path.join(ebin, "exqlite.app"), + ~s|{application,exqlite,[{vsn,"0.35.0"}]}. +| + ) + + File.write!(Path.join(ebin, "Elixir.Exqlite.beam"), "beam") + ebin + end + + defp android_exqlite_runner(owner, failure \\ nil) do + fn args -> + send(owner, {:adb_command, args}) + + if failure == :activate and adb_command_contains?([args], "had_live=0") do + {:error, "sensitive child output"} + else + {:ok, ""} + end + end + end + + defp deployer_recorded_commands(commands \\ []) do + receive do + {:adb_command, args} -> deployer_recorded_commands([args | commands]) + after + 0 -> Enum.reverse(commands) + end + end + + defp restart_recorded_commands(commands \\ []) do + receive do + {:restart_command, args} -> restart_recorded_commands([args | commands]) + after + 0 -> Enum.reverse(commands) + end + end + + defp recorded_lease_commands(commands \\ []) do + receive do + {:lease_command, args} -> recorded_lease_commands([args | commands]) + after + 0 -> Enum.reverse(commands) + end + end + + defp flush_local_commands do + receive do + {:local_command, _, _} -> flush_local_commands() + after + 0 -> :ok + end + end + + defp recorded_local_commands(commands \\ []) do + receive do + {:local_command, executable, args} -> + recorded_local_commands([{executable, args} | commands]) + after + 0 -> Enum.reverse(commands) + end + end + + defp adb_command_contains?(commands, needle) do + Enum.any?(commands, fn args -> + Enum.any?(args, &String.contains?(&1, needle)) + end) + end +end diff --git a/test/mob_dev/device_test.exs b/test/mob_dev/device_test.exs index 4c4de2e..6cb2784 100644 --- a/test/mob_dev/device_test.exs +++ b/test/mob_dev/device_test.exs @@ -33,9 +33,16 @@ defmodule MobDev.DeviceTest do # ── node_name/1 ───────────────────────────────────────────────────────────── describe "node_name/1" do - test "returns android node name for android device" do + test "returns android node name for android device with serial-derived suffix" do app = Mix.Project.config()[:app] device = %Device{platform: :android, serial: "emulator-5554"} + # Suffix is the sanitized serial — "emulator-5554" → "emulator_5554" + assert Device.node_name(device) == :"#{app}_android_emulator_5554@127.0.0.1" + end + + test "returns suffix-less android node name when serial is missing" do + app = Mix.Project.config()[:app] + device = %Device{platform: :android, serial: nil} assert Device.node_name(device) == :"#{app}_android@127.0.0.1" end @@ -54,26 +61,156 @@ defmodule MobDev.DeviceTest do device = %Device{platform: :ios, serial: "any"} assert device |> Device.node_name() |> to_string() |> String.ends_with?("@127.0.0.1") end + + test "android emulator suffix does not match the AOSP placeholder serial" do + # Regression: `Discovery.Android.enrich/1` used to derive the node + # name from raw `getprop ro.serialno` (= `EMULATOR36X5X10X0` for + # every running emulator), which (a) collided across emulators and + # (b) didn't match what `Mob.Dist` actually registered. Both + # `Device.node_name/1` (this function) and `enrich/1` now route + # through `Discovery.Android.device_node_suffix/1`, which + # short-circuits emulator adb ids to the unique `emulator_NNNN`. + app = Mix.Project.config()[:app] + device = %Device{platform: :android, serial: "emulator-5554"} + node = Device.node_name(device) + refute node == :"#{app}_android_emulator36x5x10x0@127.0.0.1" + assert node == :"#{app}_android_emulator_5554@127.0.0.1" + end + + test "android physical WiFi-adb serial collapses to IP-based suffix (pure path)" do + # `Device.node_name/1` calls `node_suffix_for/1` (pure — no adb). + # Without an adb-shell call there's no `ro.serialno` to consult, so + # the suffix is derived from the WiFi-adb id itself. This is the + # fallback used by call sites that don't get to query the device + # (e.g. quick display in `mix mob.devices` before enrich runs). + app = Mix.Project.config()[:app] + device = %Device{platform: :android, serial: "10.0.0.82:5555"} + assert Device.node_name(device) == :"#{app}_android_10_0_0_82@127.0.0.1" + end + end + + # ── display_id/1 ──────────────────────────────────────────────────────────── + + describe "display_id/1" do + test "Android: returns serial as-is" do + device = %Device{platform: :android, serial: "emulator-5554"} + assert Device.display_id(device) == "emulator-5554" + end + + test "Android physical: returns serial as-is" do + device = %Device{platform: :android, serial: "R5CW3089HVB", type: :physical} + assert Device.display_id(device) == "R5CW3089HVB" + end + + test "iOS simulator: returns first 8 hex chars of UDID, lowercased" do + device = %Device{ + platform: :ios, + type: :simulator, + serial: "78354490-EF38-44D7-A437-DD941C20524D" + } + + assert Device.display_id(device) == "78354490" + end + + test "iOS simulator: strips hyphens before slicing" do + device = %Device{platform: :ios, type: :simulator, serial: "AABB-CCDD-EEFF"} + assert Device.display_id(device) == "aabbccdd" + end + + test "iOS physical: returns full UDID" do + udid = "00008120-001A2B3C4D5E6F78" + device = %Device{platform: :ios, type: :physical, serial: udid} + assert Device.display_id(device) == udid + end + end + + # ── match_id?/2 ───────────────────────────────────────────────────────────── + + describe "match_id?/2" do + test "matches Android device by serial (exact)" do + device = %Device{platform: :android, serial: "emulator-5554", type: :emulator} + assert Device.match_id?(device, "emulator-5554") + end + + test "matches Android device case-insensitively" do + device = %Device{platform: :android, serial: "R5CW3089HVB", type: :physical} + assert Device.match_id?(device, "r5cw3089hvb") + end + + test "matches iOS simulator by short display_id" do + device = %Device{ + platform: :ios, + type: :simulator, + serial: "78354490-EF38-44D7-A437-DD941C20524D" + } + + assert Device.match_id?(device, "78354490") + end + + test "matches iOS simulator by full UDID" do + udid = "78354490-EF38-44D7-A437-DD941C20524D" + device = %Device{platform: :ios, type: :simulator, serial: udid} + assert Device.match_id?(device, udid) + end + + test "matches iOS simulator case-insensitively" do + device = %Device{ + platform: :ios, + type: :simulator, + serial: "78354490-EF38-44D7-A437-DD941C20524D" + } + + assert Device.match_id?(device, "78354490") + assert Device.match_id?(device, "78354490-EF38-44D7-A437-DD941C20524D") + end + + test "returns false for non-matching input" do + device = %Device{platform: :android, serial: "emulator-5554", type: :emulator} + refute Device.match_id?(device, "emulator-9999") + end + + test "returns false for partial match (no substring matching)" do + device = %Device{platform: :android, serial: "emulator-5554", type: :emulator} + refute Device.match_id?(device, "5554") + end end # ── summary/1 ─────────────────────────────────────────────────────────────── describe "summary/1" do test "includes device name when set" do - device = %Device{platform: :android, serial: "emulator-5554", - name: "Pixel 8", type: :emulator, status: :discovered} + device = %Device{ + platform: :android, + serial: "emulator-5554", + name: "Pixel 8", + type: :emulator, + status: :discovered + } + assert Device.summary(device) =~ "Pixel 8" end test "falls back to serial when name is nil" do - device = %Device{platform: :android, serial: "emulator-5554", - type: :emulator, status: :discovered} + device = %Device{ + platform: :android, + serial: "emulator-5554", + type: :emulator, + status: :discovered + } + assert Device.summary(device) =~ "emulator-5554" end test "includes version when set" do - device = %Device{platform: :android, serial: "s", name: "Pixel", - version: "Android 15", type: :emulator, status: :discovered} + device = %Device{ + platform: :android, + serial: "s", + name: "Pixel", + version: "Android 15", + type: :emulator, + status: :discovered + } + assert Device.summary(device) =~ "Android 15" end diff --git a/test/mob_dev/discovery/android_test.exs b/test/mob_dev/discovery/android_test.exs index 03ad7fd..9276aae 100644 --- a/test/mob_dev/discovery/android_test.exs +++ b/test/mob_dev/discovery/android_test.exs @@ -12,6 +12,7 @@ defmodule MobDev.Discovery.AndroidTest do List of devices attached emulator-5554\tdevice product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 transport_id:1 """ + [device] = Android.parse_devices_output(output) assert device.serial == "emulator-5554" assert device.platform == :android @@ -24,6 +25,7 @@ defmodule MobDev.Discovery.AndroidTest do List of devices attached R5CW3089HVB\tdevice product:moto transport_id:2 """ + [device] = Android.parse_devices_output(output) assert device.serial == "R5CW3089HVB" assert device.type == :physical @@ -35,6 +37,7 @@ defmodule MobDev.Discovery.AndroidTest do List of devices attached R5CW3089HVB\tunauthorized """ + [device] = Android.parse_devices_output(output) assert device.serial == "R5CW3089HVB" assert device.status == :unauthorized @@ -46,6 +49,7 @@ defmodule MobDev.Discovery.AndroidTest do List of devices attached emulator-5556\toffline """ + assert Android.parse_devices_output(output) == [] end @@ -60,6 +64,7 @@ defmodule MobDev.Discovery.AndroidTest do emulator-5554\tdevice product:sdk transport_id:1 R5CW3089HVB\tdevice product:moto transport_id:2 """ + devices = Android.parse_devices_output(output) assert length(devices) == 2 serials = Enum.map(devices, & &1.serial) @@ -72,6 +77,7 @@ defmodule MobDev.Discovery.AndroidTest do List of devices attached 192.168.1.5:5555\tdevice product:moto transport_id:3 """ + [device] = Android.parse_devices_output(output) assert device.serial == "192.168.1.5:5555" assert device.type == :physical @@ -83,6 +89,7 @@ defmodule MobDev.Discovery.AndroidTest do emulator-5554\tdevice transport_id:1 ABCD1234\tdevice transport_id:2 """ + devices = Android.parse_devices_output(output) emulator = Enum.find(devices, &(&1.serial == "emulator-5554")) physical = Enum.find(devices, &(&1.serial == "ABCD1234")) @@ -95,11 +102,112 @@ defmodule MobDev.Discovery.AndroidTest do List of devices attached emulator-5554\tdevice transport_id:1 """ + [device] = Android.parse_devices_output(output) assert %Device{} = device end end + # ── node_suffix_for/1 ──────────────────────────────────────────────────────── + + describe "node_suffix_for/1" do + test "lowercases an alphanumeric USB serial" do + assert Android.node_suffix_for("ZY22CRLMWK") == "zy22crlmwk" + end + + test "strips :port and replaces dots with underscores for WiFi-adb" do + assert Android.node_suffix_for("10.0.0.82:5555") == "10_0_0_82" + end + + test "replaces hyphens with underscores in emulator serials" do + assert Android.node_suffix_for("emulator-5554") == "emulator_5554" + end + + test "collapses runs of non-alphanumeric chars" do + assert Android.node_suffix_for("abc.--..def") == "abc_def" + end + + test "trims leading and trailing underscores" do + assert Android.node_suffix_for("---abc---") == "abc" + end + end + + describe "device_node_suffix/1" do + test "emulator adb id short-circuits without adb-shell call" do + # If the function tried to shell out to adb in the test env it would + # either time out or fail; the short-circuit keeps it pure for any + # adb id beginning with `emulator-`. Two separate emulators map to + # two distinct suffixes — fixing the EPMD `eaddrinuse` collision + # we hit with the AOSP placeholder serial `EMULATOR36X5X10X0`, + # which is identical for every running emulator. + assert Android.device_node_suffix("emulator-5554") == "emulator_5554" + assert Android.device_node_suffix("emulator-5556") == "emulator_5556" + + refute Android.device_node_suffix("emulator-5554") == + Android.device_node_suffix("emulator-5556") + end + + test "emulator suffix matches what Mob.Dist registers on-device" do + # `Mob.Dist.apply_suffix/2` on the device side reads `MOB_NODE_SUFFIX` + # from the launch intent (set by `restart_app/4` from this same + # function) and appends it to the base node name. mob_dev's + # `mix mob.connect` must compute the *same* suffix to construct a + # node atom that actually exists in EPMD — otherwise it times out + # waiting for a node that was never registered under that name. + # Regression test for the old bug where `enrich/1` derived the + # node from raw `ro.serialno` (= `EMULATOR36X5X10X0` placeholder) + # while `restart_app/4` sent `emulator_5554` to the device. + assert Android.device_node_suffix("emulator-5554") == "emulator_5554" + refute Android.device_node_suffix("emulator-5554") == "emulator36x5x10x0" + end + + test "Device.node_name/1 agrees with device_node_suffix/1 for emulator serials" do + # The atom produced for `mix mob.connect`'s connection target and the + # suffix sent via `MOB_NODE_SUFFIX` must yield the same final node + # name on both sides of the EPMD lookup. + app = Mix.Project.config()[:app] + device = %Device{platform: :android, serial: "emulator-5554"} + expected_suffix = Android.device_node_suffix("emulator-5554") + assert Device.node_name(device) == :"#{app}_android_#{expected_suffix}@127.0.0.1" + end + end + + # ── emulator detection ────────────────────────────────────────────────────── + + describe "emulator_adb_id?/1" do + test "matches the canonical emulator-NNNN adb id" do + assert Android.emulator_adb_id?("emulator-5554") + assert Android.emulator_adb_id?("emulator-5556") + end + + test "does not match physical USB serials" do + refute Android.emulator_adb_id?("ZY22CRLMWK") + refute Android.emulator_adb_id?("00008110-001E1C3A34F8401E") + end + + test "does not match WiFi-adb identifiers" do + refute Android.emulator_adb_id?("10.0.0.82:5555") + end + end + + describe "emulator_serial?/1" do + test "matches the AOSP emulator placeholder serial" do + assert Android.emulator_serial?("EMULATOR36X5X10X0") + end + + test "matches other EMULATOR-prefixed vendor variants" do + # Defensive — different system images may report different + # post-prefix values, but they all start with EMULATOR. + assert Android.emulator_serial?("EMULATOR12345") + assert Android.emulator_serial?("EMULATORabc") + end + + test "does not match real hardware serials" do + refute Android.emulator_serial?("ZY22CRLMWK") + refute Android.emulator_serial?("RZ8N8090ABC") + end + end + # ── integration: list_devices/0 ────────────────────────────────────────────── @tag :integration diff --git a/test/mob_dev/discovery/ios_test.exs b/test/mob_dev/discovery/ios_test.exs index 182a16d..24b6e05 100644 --- a/test/mob_dev/discovery/ios_test.exs +++ b/test/mob_dev/discovery/ios_test.exs @@ -8,13 +8,15 @@ defmodule MobDev.Discovery.IOSTest do describe "parse_simctl_json/1" do test "parses a booted simulator" do - json = Jason.encode!(%{ - "devices" => %{ - "com.apple.CoreSimulator.SimRuntime.iOS-18-0" => [ - %{"udid" => "ABC-123", "name" => "iPhone 15", "state" => "Booted"} - ] - } - }) + json = + Jason.encode!(%{ + "devices" => %{ + "com.apple.CoreSimulator.SimRuntime.iOS-18-0" => [ + %{"udid" => "ABC-123", "name" => "iPhone 15", "state" => "Booted"} + ] + } + }) + [device] = IOS.parse_simctl_json(json) assert device.serial == "ABC-123" assert device.name == "iPhone 15" @@ -25,14 +27,16 @@ defmodule MobDev.Discovery.IOSTest do end test "skips non-booted simulators" do - json = Jason.encode!(%{ - "devices" => %{ - "com.apple.CoreSimulator.SimRuntime.iOS-18-0" => [ - %{"udid" => "ABC-123", "name" => "iPhone 15", "state" => "Shutdown"}, - %{"udid" => "DEF-456", "name" => "iPhone 16", "state" => "Booted"} - ] - } - }) + json = + Jason.encode!(%{ + "devices" => %{ + "com.apple.CoreSimulator.SimRuntime.iOS-18-0" => [ + %{"udid" => "ABC-123", "name" => "iPhone 15", "state" => "Shutdown"}, + %{"udid" => "DEF-456", "name" => "iPhone 16", "state" => "Booted"} + ] + } + }) + devices = IOS.parse_simctl_json(json) assert length(devices) == 1 assert hd(devices).serial == "DEF-456" @@ -44,16 +48,18 @@ defmodule MobDev.Discovery.IOSTest do end test "parses multiple booted simulators across runtimes" do - json = Jason.encode!(%{ - "devices" => %{ - "com.apple.CoreSimulator.SimRuntime.iOS-17-0" => [ - %{"udid" => "A1", "name" => "iPhone 14", "state" => "Booted"} - ], - "com.apple.CoreSimulator.SimRuntime.iOS-18-0" => [ - %{"udid" => "B2", "name" => "iPhone 15", "state" => "Booted"} - ] - } - }) + json = + Jason.encode!(%{ + "devices" => %{ + "com.apple.CoreSimulator.SimRuntime.iOS-17-0" => [ + %{"udid" => "A1", "name" => "iPhone 14", "state" => "Booted"} + ], + "com.apple.CoreSimulator.SimRuntime.iOS-18-0" => [ + %{"udid" => "B2", "name" => "iPhone 15", "state" => "Booted"} + ] + } + }) + devices = IOS.parse_simctl_json(json) assert length(devices) == 2 serials = Enum.map(devices, & &1.serial) @@ -63,15 +69,18 @@ defmodule MobDev.Discovery.IOSTest do test "assigns node name to each device" do app = Mix.Project.config()[:app] - json = Jason.encode!(%{ - "devices" => %{ - "com.apple.CoreSimulator.SimRuntime.iOS-18-0" => [ - %{"udid" => "ABC-123", "name" => "iPhone 15", "state" => "Booted"} - ] - } - }) + # UDID "ABC-123" → strip hyphens "ABC123" → first 8 lowercase → "abc123" + json = + Jason.encode!(%{ + "devices" => %{ + "com.apple.CoreSimulator.SimRuntime.iOS-18-0" => [ + %{"udid" => "ABC-123", "name" => "iPhone 15", "state" => "Booted"} + ] + } + }) + [device] = IOS.parse_simctl_json(json) - assert device.node == :"#{app}_ios@127.0.0.1" + assert device.node == :"#{app}_ios_abc123@127.0.0.1" end end @@ -83,6 +92,7 @@ defmodule MobDev.Discovery.IOSTest do == Booted == iPhone 17 (78354490-EF38-44D7-A437-DD941C20524D) (Booted) """ + [device] = IOS.parse_simctl_text(text) assert device.serial == "78354490-EF38-44D7-A437-DD941C20524D" assert device.name == "iPhone 17" @@ -94,6 +104,7 @@ defmodule MobDev.Discovery.IOSTest do == Shutdown == iPhone 14 (AABB-CCDD-1234-5678-ABCDEFABCDEF) (Shutdown) """ + assert IOS.parse_simctl_text(text) == [] end @@ -102,6 +113,7 @@ defmodule MobDev.Discovery.IOSTest do iPhone 15 (AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEFFFFFF) (Booted) iPad Pro (FFFFFFFF-EEEE-DDDD-CCCC-BBBBBBAAAAA1) (Booted) """ + devices = IOS.parse_simctl_text(text) assert length(devices) == 2 end @@ -111,11 +123,13 @@ defmodule MobDev.Discovery.IOSTest do describe "parse_runtime_version/1" do test "parses iOS-18-0 style" do - assert IOS.parse_runtime_version("com.apple.CoreSimulator.SimRuntime.iOS-18-0") == "iOS 18.0" + assert IOS.parse_runtime_version("com.apple.CoreSimulator.SimRuntime.iOS-18-0") == + "iOS 18.0" end test "parses iOS-17-4 style" do - assert IOS.parse_runtime_version("com.apple.CoreSimulator.SimRuntime.iOS-17-4") == "iOS 17.4" + assert IOS.parse_runtime_version("com.apple.CoreSimulator.SimRuntime.iOS-17-4") == + "iOS 17.4" end test "falls back gracefully for unknown format" do @@ -130,4 +144,103 @@ defmodule MobDev.Discovery.IOSTest do result = IOS.list_simulators() assert Enum.all?(result, &match?(%Device{}, &1)) end + + # ── build_simctl_env/2 ─────────────────────────────────────────────────────── + # Pure-function helper extracted from launch_app/3 so the override surface + # is unit-testable without spawning simctl. Covers the `mix mob.deploy + # --node-suffix X --dist-port N` plumbing — once those reach IOS.launch_app + # they must come out as the right SIMCTL_CHILD_* vars (mob_beam.m strips + # the prefix at startup, so the child process sees MOB_NODE_SUFFIX / + # MOB_DIST_PORT directly). + + describe "build_simctl_env/2" do + test "always emits MOB_DIST_PORT (default 9100) and MOB_SIM_RUNTIME_DIR" do + env = IOS.build_simctl_env([], "/tmp/runtime") + assert {"SIMCTL_CHILD_MOB_DIST_PORT", "9100"} in env + assert {"SIMCTL_CHILD_MOB_SIM_RUNTIME_DIR", "/tmp/runtime"} in env + end + + test "explicit :dist_port overrides the default" do + env = IOS.build_simctl_env([dist_port: 9120], "/tmp/runtime") + assert {"SIMCTL_CHILD_MOB_DIST_PORT", "9120"} in env + refute {"SIMCTL_CHILD_MOB_DIST_PORT", "9100"} in env + end + + test "omits MOB_NODE_SUFFIX when :node_suffix is nil (auto-derive in mob_beam.m)" do + env = IOS.build_simctl_env([], "/tmp/runtime") + keys = Enum.map(env, fn {k, _} -> k end) + refute "SIMCTL_CHILD_MOB_NODE_SUFFIX" in keys + end + + test "omits MOB_NODE_SUFFIX when :node_suffix is the empty string" do + env = IOS.build_simctl_env([node_suffix: ""], "/tmp/runtime") + keys = Enum.map(env, fn {k, _} -> k end) + refute "SIMCTL_CHILD_MOB_NODE_SUFFIX" in keys + end + + test "emits MOB_NODE_SUFFIX when :node_suffix is a non-empty string" do + env = IOS.build_simctl_env([node_suffix: "alt"], "/tmp/runtime") + assert {"SIMCTL_CHILD_MOB_NODE_SUFFIX", "alt"} in env + end + + test "passes node_suffix verbatim (no sanitisation at this layer)" do + env = IOS.build_simctl_env([node_suffix: "Has-Dashes_And_Underscores"], "/tmp/runtime") + assert {"SIMCTL_CHILD_MOB_NODE_SUFFIX", "Has-Dashes_And_Underscores"} in env + end + + test "combines :dist_port + :node_suffix overrides cleanly" do + env = IOS.build_simctl_env([dist_port: 9120, node_suffix: "alt"], "/tmp/runtime") + assert {"SIMCTL_CHILD_MOB_DIST_PORT", "9120"} in env + assert {"SIMCTL_CHILD_MOB_NODE_SUFFIX", "alt"} in env + end + end + + describe "build_simctl_launch_args/2" do + test "launch atomically terminates an existing simulator process" do + assert IOS.build_simctl_launch_args("SIM-UDID", "com.example.app") == [ + "simctl", + "launch", + "--terminate-running-process", + "SIM-UDID", + "com.example.app" + ] + end + end + + describe "physical app-scoped restart" do + test "uses one atomic launch for the exact target and never enumerates or terminates unrelated apps" do + parent = self() + + runner = fn executable, args, opts -> + send(parent, {:command, executable, args, opts}) + {"launched", 0} + end + + assert IOS.restart_app_physical("PHONE-UDID", "com.example.app", runner) == + {"launched", 0} + + assert_received {:command, "xcrun", args, [stderr_to_stdout: true]} + + assert args == [ + "devicectl", + "device", + "process", + "launch", + "--device", + "PHONE-UDID", + "--terminate-existing", + "com.example.app" + ] + + refute "terminate" in args + refute "--pid" in args + refute_received {:command, _executable, _args, _opts} + end + + test "returns the exact runner result for authoritative validation" do + assert IOS.restart_app_physical("PHONE-UDID", "com.example.app", fn _, _, _ -> + {"private output", 17} + end) == {"private output", 17} + end + end end diff --git a/test/mob_dev/emulators_test.exs b/test/mob_dev/emulators_test.exs new file mode 100644 index 0000000..dc3b4d5 --- /dev/null +++ b/test/mob_dev/emulators_test.exs @@ -0,0 +1,182 @@ +defmodule MobDev.EmulatorsTest do + use ExUnit.Case, async: true + + alias MobDev.Emulators + + # ── parse_simctl_json/1 — pure parser, no shell ───────────────────────────── + + describe "parse_simctl_json/1" do + @sample_json """ + { + "devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-26-4": [ + { + "name": "iPhone 17", + "udid": "78354490-EF38-44D7-A437-DD941C20524D", + "state": "Booted", + "isAvailable": true + }, + { + "name": "iPhone Air", + "udid": "02628F8F-770E-4140-8CA9-9DBD9B7B8C65", + "state": "Shutdown", + "isAvailable": true + } + ], + "com.apple.CoreSimulator.SimRuntime.watchOS-11-0": [ + { + "name": "Apple Watch SE 3 (40mm)", + "udid": "E98F35F5-1234-5678-9ABC-DEF012345678", + "state": "Shutdown", + "isAvailable": true + } + ] + } + } + """ + + test "extracts each sim with name + udid + booted state" do + sims = Emulators.parse_simctl_json(@sample_json) + + assert length(sims) == 3 + + booted = Enum.find(sims, & &1.running) + assert booted.name == "iPhone 17" + assert booted.id == "78354490-EF38-44D7-A437-DD941C20524D" + assert booted.platform == :ios + end + + test "pretty-prints runtime as 'iOS 26.4'" do + sims = Emulators.parse_simctl_json(@sample_json) + assert Enum.find(sims, &(&1.name == "iPhone 17")).runtime == "iOS 26.4" + end + + test "handles non-iOS runtimes (watchOS) without breaking" do + sims = Emulators.parse_simctl_json(@sample_json) + watch = Enum.find(sims, &String.contains?(&1.name, "Watch")) + assert watch.runtime == "watchOS 11.0" + assert watch.platform == :ios + end + + test "skips entries marked isAvailable: false" do + json = """ + { + "devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-26-4": [ + { + "name": "iPhone (deprecated runtime)", + "udid": "DEADBEEF-1234-5678-9ABC-DEF012345678", + "state": "Shutdown", + "isAvailable": false + } + ] + } + } + """ + + assert Emulators.parse_simctl_json(json) == [] + end + + test "returns [] for malformed JSON instead of raising" do + assert Emulators.parse_simctl_json("not json at all") == [] + assert Emulators.parse_simctl_json("") == [] + end + + test "returns [] for valid JSON missing the 'devices' key" do + assert Emulators.parse_simctl_json(~s({"foo": "bar"})) == [] + end + + test "returns [] for empty devices map" do + assert Emulators.parse_simctl_json(~s({"devices": {}})) == [] + end + + test "preserves UDID exactly (used by simctl boot/shutdown)" do + json = """ + { + "devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-26-4": [ + {"name": "X", "udid": "78354490-EF38-44D7-A437-DD941C20524D", + "state": "Shutdown", "isAvailable": true} + ] + } + } + """ + + [sim] = Emulators.parse_simctl_json(json) + assert sim.id == "78354490-EF38-44D7-A437-DD941C20524D" + assert sim.serial == sim.id, "serial and id should match for sims" + end + + test "treats missing isAvailable as available (defaults to true)" do + # Older simctl output didn't include isAvailable. Our parser must + # default to true so we don't silently drop sims on older Xcodes. + json = """ + { + "devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-26-4": [ + {"name": "OldSchool", "udid": "78354490-EF38-44D7-A437-DD941C20524D", + "state": "Shutdown"} + ] + } + } + """ + + assert [%Emulators{name: "OldSchool"}] = Emulators.parse_simctl_json(json) + end + + test "leaves non-standard runtime ids unparsed rather than crashing" do + # If Apple ever changes the runtime id format, fall back to the raw + # string so listing still works (just with an uglier label). + json = """ + { + "devices": { + "weird.runtime.identifier": [ + {"name": "Y", "udid": "78354490-EF38-44D7-A437-DD941C20524D", + "state": "Shutdown", "isAvailable": true} + ] + } + } + """ + + [sim] = Emulators.parse_simctl_json(json) + assert sim.runtime == "weird.runtime.identifier" + end + + test "uses Booted state to set running flag" do + json = """ + { + "devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-26-4": [ + {"name": "A", "udid": "AAAAAAAA-1111-1111-1111-111111111111", "state": "Booted", "isAvailable": true}, + {"name": "B", "udid": "BBBBBBBB-2222-2222-2222-222222222222", "state": "Shutdown", "isAvailable": true}, + {"name": "C", "udid": "CCCCCCCC-3333-3333-3333-333333333333", "state": "Booting", "isAvailable": true} + ] + } + } + """ + + sims = Emulators.parse_simctl_json(json) + states = Map.new(sims, fn s -> {s.name, s.running} end) + + assert states["A"] == true + assert states["B"] == false + assert states["C"] == false + end + end + + # ── find_emulator_binary/1 — path resolution ──────────────────────────────── + + describe "find_emulator_binary/1" do + @tag :integration + test "returns {:ok, path} when one of the standard locations has an emulator binary" do + # On a host with Android Studio installed, this should succeed. Skip + # gracefully when neither default path nor env var is set. + result = Emulators.find_emulator_binary() + + case result do + {:ok, path} -> assert String.ends_with?(path, "emulator/emulator") + {:error, _} -> :ok + end + end + end +end diff --git a/test/mob_dev/enable_test.exs b/test/mob_dev/enable_test.exs new file mode 100644 index 0000000..631fa37 --- /dev/null +++ b/test/mob_dev/enable_test.exs @@ -0,0 +1,455 @@ +defmodule MobDev.EnableTest do + use ExUnit.Case, async: true + + alias MobDev.Enable + + # ── build_plist_entry/3 ─────────────────────────────────────────────────── + + describe "build_plist_entry/3" do + test "builds string entry" do + result = Enable.build_plist_entry("NSCameraUsageDescription", "Camera needed") + assert result == "\t<key>NSCameraUsageDescription</key>\n\t<string>Camera needed</string>" + end + + test "builds bool entry for UIFileSharingEnabled" do + result = Enable.build_plist_entry("UIFileSharingEnabled", "true", type: :bool) + assert result == "\t<key>UIFileSharingEnabled</key>\n\t<true/>" + end + + test "builds bool false entry" do + result = Enable.build_plist_entry("SomeFlag", "false", type: :bool) + assert result == "\t<key>SomeFlag</key>\n\t<false/>" + end + end + + # ── read_app_name_from/1 ────────────────────────────────────────────────── + + describe "read_app_name_from/1" do + test "reads app name from valid mix.exs" do + path = write_tmp_mix_exs("app: :my_cool_app") + assert Enable.read_app_name_from(path) == "my_cool_app" + end + + test "reads app name when surrounded by other keys" do + content = """ + def project do + [ + version: "0.1.0", + app: :phoenix_demo, + elixir: "~> 1.18" + ] + end + """ + + path = write_tmp_mix_exs(content) + assert Enable.read_app_name_from(path) == "phoenix_demo" + end + + test "raises when file not found" do + assert_raise RuntimeError, ~r/Could not read/, fn -> + Enable.read_app_name_from("/nonexistent/mix.exs") + end + end + + test "raises when app: key is missing" do + path = write_tmp_mix_exs("def project, do: []") + + assert_raise RuntimeError, ~r/Could not read app name/, fn -> + Enable.read_app_name_from(path) + end + end + end + + # ── inject_mob_hook/1 ───────────────────────────────────────────────────── + + describe "inject_mob_hook/1" do + test "patches hooks: {} to hooks: {MobHook}" do + input = ~S""" + import {Socket} from "phoenix" + import {LiveSocket} from "phoenix_live_view" + + let liveSocket = new LiveSocket("/live", Socket, {hooks: {}}) + liveSocket.connect() + """ + + result = Enable.inject_mob_hook(input) + assert String.contains?(result, "hooks: {MobHook}") + assert String.contains?(result, "const MobHook") + refute String.contains?(result, "hooks: {}") + end + + test "prepends MobHook to existing hooks object" do + input = ~S""" + import {Socket} from "phoenix" + import {LiveSocket} from "phoenix_live_view" + import Hooks from "./hooks" + + let liveSocket = new LiveSocket("/live", Socket, {hooks: {Hooks}}) + """ + + result = Enable.inject_mob_hook(input) + assert String.contains?(result, "hooks: {MobHook,") + assert String.contains?(result, "Hooks}") + end + + test "inserts hook definition after last import line" do + input = ~S""" + import {Socket} from "phoenix" + import {LiveSocket} from "phoenix_live_view" + + let liveSocket = new LiveSocket("/live", Socket, {hooks: {}}) + """ + + result = Enable.inject_mob_hook(input) + lines = String.split(result, "\n") + + import_idx = + Enum.find_index(lines, &String.starts_with?(String.trim(&1), "import {LiveSocket}")) + + hook_idx = Enum.find_index(lines, &String.contains?(&1, "const MobHook")) + socket_idx = Enum.find_index(lines, &String.contains?(&1, "new LiveSocket")) + + assert hook_idx > import_idx + assert hook_idx < socket_idx + end + + test "works with no imports" do + input = ~S""" + const liveSocket = new LiveSocket("/live", Socket, {hooks: {}}) + liveSocket.connect() + """ + + result = Enable.inject_mob_hook(input) + assert String.contains?(result, "const MobHook") + assert String.contains?(result, "hooks: {MobHook}") + end + + test "idempotent when MobHook already present" do + input = ~S""" + import {Socket} from "phoenix" + + const MobHook = { mounted() {} } + let liveSocket = new LiveSocket("/live", Socket, {hooks: {MobHook}}) + """ + + # The task guards against double-injection via String.contains?(content, "MobHook"), + # but inject_mob_hook itself would add a second definition. This test documents the + # expectation that the task layer does the idempotency guard, not this function. + result = Enable.inject_mob_hook(input) + # Should still produce valid JS even if called on already-patched content + assert String.contains?(result, "MobHook") + end + + test "mob_hook_js contains expected API" do + js = Enable.mob_hook_js() + assert String.contains?(js, "pushEvent(\"mob_message\"") + assert String.contains?(js, "handleEvent(\"mob_push\"") + assert String.contains?(js, "_dispatch") + end + end + + # ── inject_mob_bridge_element/1 ─────────────────────────────────────────── + + describe "inject_mob_bridge_element/1" do + test "inserts hidden div immediately after opening body tag" do + input = """ + <html> + <body class="bg-white"> + <%= @inner_content %> + </body> + </html> + """ + + result = Enable.inject_mob_bridge_element(input) + assert String.contains?(result, ~s(id="mob-bridge")) + assert String.contains?(result, ~s(phx-hook="MobHook")) + assert String.contains?(result, ~s(style="display:none")) + # must appear right after <body ...> + body_pos = :binary.match(result, "<body") |> elem(0) + bridge_pos = :binary.match(result, "mob-bridge") |> elem(0) + content_pos = :binary.match(result, "@inner_content") |> elem(0) + assert bridge_pos > body_pos + assert bridge_pos < content_pos + end + + test "preserves existing body attributes" do + input = ~s(<body class="bg-white antialiased" data-theme="dark">\n</body>) + result = Enable.inject_mob_bridge_element(input) + assert String.contains?(result, ~s(class="bg-white antialiased")) + assert String.contains?(result, ~s(data-theme="dark")) + assert String.contains?(result, "mob-bridge") + end + + test "is idempotent when mob-bridge already present" do + input = """ + <body> + <div id="mob-bridge" phx-hook="MobHook" style="display:none"></div> + <%= @inner_content %> + </body> + """ + + result = Enable.inject_mob_bridge_element(input) + assert result == input + # should not have a second mob-bridge + assert length(:binary.matches(result, "mob-bridge")) == 1 + end + + test "works with a body tag with no attributes" do + input = "<body>\n<%= @inner_content %>\n</body>" + result = Enable.inject_mob_bridge_element(input) + assert String.contains?(result, "mob-bridge") + end + end + + # ── find_root_html/2 ────────────────────────────────────────────────────── + + describe "find_root_html/2" do + test "finds Phoenix 1.7+ path" do + dir = + System.tmp_dir!() |> Path.join("mob_enable_test_#{:erlang.unique_integer([:positive])}") + + File.rm_rf!(dir) + path = Path.join([dir, "lib", "my_app_web", "components", "layouts", "root.html.heex"]) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, "<html></html>") + assert Enable.find_root_html(dir, "my_app") == path + end + + test "finds pre-1.7 path when 1.7+ path absent" do + dir = + System.tmp_dir!() |> Path.join("mob_enable_test_#{:erlang.unique_integer([:positive])}") + + File.rm_rf!(dir) + path = Path.join([dir, "lib", "my_app_web", "templates", "layout", "root.html.heex"]) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, "<html></html>") + assert Enable.find_root_html(dir, "my_app") == path + end + + test "returns nil when neither path exists" do + dir = + System.tmp_dir!() |> Path.join("mob_enable_test_#{:erlang.unique_integer([:positive])}") + + File.rm_rf!(dir) + File.mkdir_p!(dir) + assert Enable.find_root_html(dir, "my_app") == nil + end + + test "prefers 1.7+ path when both exist" do + dir = + System.tmp_dir!() |> Path.join("mob_enable_test_#{:erlang.unique_integer([:positive])}") + + File.rm_rf!(dir) + new_path = Path.join([dir, "lib", "my_app_web", "components", "layouts", "root.html.heex"]) + old_path = Path.join([dir, "lib", "my_app_web", "templates", "layout", "root.html.heex"]) + File.mkdir_p!(Path.dirname(new_path)) + File.mkdir_p!(Path.dirname(old_path)) + File.write!(new_path, "new") + File.write!(old_path, "old") + assert Enable.find_root_html(dir, "my_app") == new_path + end + end + + # ── inject_android_network_security_config/1 ───────────────────────────── + + describe "inject_android_network_security_config/1" do + test "adds networkSecurityConfig attribute to <application> tag" do + input = """ + <manifest> + <application + android:label="MyApp" + android:theme="@style/AppTheme"> + </application> + </manifest> + """ + + result = Enable.inject_android_network_security_config(input) + + assert String.contains?( + result, + ~s(android:networkSecurityConfig="@xml/network_security_config") + ) + + assert String.contains?(result, "android:label=\"MyApp\"") + end + + test "is idempotent when networkSecurityConfig already present" do + input = """ + <manifest> + <application + android:networkSecurityConfig="@xml/network_security_config" + android:label="MyApp"> + </application> + </manifest> + """ + + result = Enable.inject_android_network_security_config(input) + assert result == input + assert length(:binary.matches(result, "networkSecurityConfig")) == 1 + end + + test "only patches the first <application> tag" do + input = "<application>\n<application>" + result = Enable.inject_android_network_security_config(input) + assert length(:binary.matches(result, "networkSecurityConfig")) == 1 + end + end + + # ── network_security_config_xml/0 ──────────────────────────────────────── + + describe "network_security_config_xml/0" do + test "permits cleartext for 127.0.0.1" do + xml = Enable.network_security_config_xml() + assert String.contains?(xml, "127.0.0.1") + assert String.contains?(xml, "cleartextTrafficPermitted=\"true\"") + end + + test "permits cleartext for localhost" do + xml = Enable.network_security_config_xml() + assert String.contains?(xml, "localhost") + end + + test "is valid XML (has header and root element)" do + xml = Enable.network_security_config_xml() + assert String.starts_with?(String.trim(xml), "<?xml") + assert String.contains?(xml, "<network-security-config>") + assert String.contains?(xml, "</network-security-config>") + end + end + + # ── inject_pythonx_dep/1 ────────────────────────────────────────────────── + + describe "inject_pythonx_dep/1" do + test "adds {:pythonx, ...} to deps when missing" do + mix_exs = """ + defp deps do + [ + {:mob, "~> 0.5"} + ] + end + """ + + result = Enable.inject_pythonx_dep(mix_exs) + assert result =~ ":pythonx" + assert result =~ ~r/{:pythonx,\s*"~>/ + # Original entry preserved + assert result =~ ":mob" + end + + test "is idempotent — leaves content unchanged when :pythonx already present" do + mix_exs = """ + defp deps do + [ + {:mob, "~> 0.5"}, + {:pythonx, "~> 0.4"} + ] + end + """ + + assert Enable.inject_pythonx_dep(mix_exs) == mix_exs + end + + test "returns content unchanged when no defp deps block found" do + assert Enable.inject_pythonx_dep("# no deps here") == "# no deps here" + end + end + + # ── default_pyproject_toml/1 ────────────────────────────────────────────── + + describe "default_pyproject_toml/1" do + test "returns a TOML string with the app name as project name" do + result = Enable.default_pyproject_toml("my_app") + assert result =~ "[project]" + assert result =~ ~s|name = "my_app"| + assert result =~ ~s|requires-python = "==3.13.*"| + assert result =~ "dependencies = []" + end + end + + # ── detect_stale_pythonx_templates/2 ────────────────────────────────────── + + describe "detect_stale_pythonx_templates/2" do + setup do + dir = + Path.join( + System.tmp_dir!(), + "mob_enable_stale_test_#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, dir: dir} + end + + test "returns [] when no native files exist (nothing to be stale)", %{dir: dir} do + assert Enable.detect_stale_pythonx_templates(dir, "my_app") == [] + end + + # Phase 2 iter 13b/c removed both ios/build.sh + ios/build_device.sh — + # their Pythonx blocks live in MobDev.NativeBuild now, so there's no + # template to detect staleness against. + + test "flags MainActivity.kt regardless of java package", %{dir: dir} do + pkg_dir = Path.join(dir, "android/app/src/main/java/com/something/odd/my_app") + File.mkdir_p!(pkg_dir) + File.write!(Path.join(pkg_dir, "MainActivity.kt"), "package com.something.odd.my_app\n") + + stale = Enable.detect_stale_pythonx_templates(dir, "my_app") + + assert Enum.any?(stale, fn {rel, marker} -> + String.ends_with?(rel, "MainActivity.kt") and + marker == "extractPythonAssetsIfNeeded" + end) + end + + test "flags CMakeLists.txt missing the enif_keepalive include", %{dir: dir} do + cmake_dir = Path.join(dir, "android/app/src/main/jni") + File.mkdir_p!(cmake_dir) + File.write!(Path.join(cmake_dir, "CMakeLists.txt"), "add_library(myapp SHARED foo.c)\n") + + stale = Enable.detect_stale_pythonx_templates(dir, "my_app") + + assert {Path.join(["android", "app", "src", "main", "jni", "CMakeLists.txt"]), + "enif_keepalive.c"} in stale + end + end + + # ── python_paths_module_template/1 ──────────────────────────────────────── + + describe "python_paths_module_template/1" do + test "interpolates module name into the defmodule line" do + result = Enable.python_paths_module_template("MyApp") + assert result =~ "defmodule MyApp.PythonPaths do" + end + + test "exposes detect/1, build_ios_paths/1, build_android_paths/0, missing/1" do + result = Enable.python_paths_module_template("MyApp") + assert result =~ "def detect(" + assert result =~ "def build_ios_paths(" + assert result =~ "def build_android_paths" + assert result =~ "def missing(" + end + + test "supports Android via MOB_PYTHON_HOME / MOB_PYTHON_DL env vars" do + result = Enable.python_paths_module_template("MyApp") + assert result =~ "MOB_PYTHON_HOME" + assert result =~ "MOB_PYTHON_DL" + assert result =~ "{:android, paths}" + end + + test "uses python3.13 in stdlib path" do + result = Enable.python_paths_module_template("MyApp") + assert result =~ ~s|"python3.13"| + end + end + + # ── helpers ─────────────────────────────────────────────────────────────── + + defp write_tmp_mix_exs(content) do + dir = System.tmp_dir!() |> Path.join("mob_enable_test_#{:erlang.unique_integer([:positive])}") + File.mkdir_p!(dir) + path = Path.join(dir, "mix.exs") + File.write!(path, content) + path + end +end diff --git a/test/mob_dev/google_play/cloud_setup_test.exs b/test/mob_dev/google_play/cloud_setup_test.exs new file mode 100644 index 0000000..e002696 --- /dev/null +++ b/test/mob_dev/google_play/cloud_setup_test.exs @@ -0,0 +1,103 @@ +defmodule MobDev.GooglePlay.CloudSetupTest do + use ExUnit.Case, async: true + + alias MobDev.GooglePlay.CloudSetup + + # ── build_enable_api_url/2 ─────────────────────────────────────────────────── + + describe "build_enable_api_url/2" do + test "builds correct Service Usage API URL" do + url = CloudSetup.build_enable_api_url("my-project-123", "androidpublisher.googleapis.com") + + assert url == + "https://serviceusage.googleapis.com/v1/projects/my-project-123/services/androidpublisher.googleapis.com:enable" + end + + test "encodes the project ID in the path" do + url = CloudSetup.build_enable_api_url("some-project", "some.api.googleapis.com") + assert url =~ "/projects/some-project/" + end + + test "appends :enable action" do + url = CloudSetup.build_enable_api_url("proj", "api.googleapis.com") + assert String.ends_with?(url, ":enable") + end + end + + # ── parse_projects_response/1 ──────────────────────────────────────────────── + + describe "parse_projects_response/1" do + test "returns projects list from a valid response" do + resp = %{ + "projects" => [ + %{"projectId" => "project-a", "displayName" => "Project A"}, + %{"projectId" => "project-b", "displayName" => "Project B"} + ] + } + + result = CloudSetup.parse_projects_response(resp) + assert length(result) == 2 + assert Enum.any?(result, &(&1["projectId"] == "project-a")) + end + + test "returns empty list when projects key is missing" do + assert CloudSetup.parse_projects_response(%{}) == [] + end + + test "returns empty list when projects is empty" do + assert CloudSetup.parse_projects_response(%{"projects" => []}) == [] + end + + test "passes through project map fields unchanged" do + project = %{"projectId" => "my-proj", "displayName" => "My Project", "state" => "ACTIVE"} + resp = %{"projects" => [project]} + [result] = CloudSetup.parse_projects_response(resp) + assert result == project + end + end + + # ── save_key_file/2 ───────────────────────────────────────────────────────── + + describe "save_key_file/2" do + test "decodes base64 and writes JSON to ~/.google_play/{filename}.json" do + json = Jason.encode!(%{"type" => "service_account", "project_id" => "test"}) + b64 = Base.encode64(json) + filename = "test-key-#{:erlang.unique_integer([:positive])}" + + assert {:ok, path} = CloudSetup.save_key_file(b64, filename) + + on_exit(fn -> File.rm(path) end) + + assert String.ends_with?(path, "#{filename}.json") + assert File.exists?(path) + assert File.read!(path) == json + end + + test "sets file permissions to 600" do + json = "{}" + b64 = Base.encode64(json) + filename = "test-perms-#{:erlang.unique_integer([:positive])}" + + {:ok, path} = CloudSetup.save_key_file(b64, filename) + on_exit(fn -> File.rm(path) end) + + {:ok, stat} = File.stat(path) + assert Bitwise.band(stat.mode, 0o777) == 0o600 + end + + test "returns error for invalid base64" do + assert {:error, _} = CloudSetup.save_key_file("not-valid-base64!!!", "test-key") + end + + test "saves to ~/.google_play/ directory" do + b64 = Base.encode64("{}") + filename = "test-dir-#{:erlang.unique_integer([:positive])}" + + {:ok, path} = CloudSetup.save_key_file(b64, filename) + on_exit(fn -> File.rm(path) end) + + expected_dir = Path.expand("~/.google_play") + assert Path.dirname(path) == expected_dir + end + end +end diff --git a/test/mob_dev/google_play/google_play_test.exs b/test/mob_dev/google_play/google_play_test.exs new file mode 100644 index 0000000..afac0bc --- /dev/null +++ b/test/mob_dev/google_play/google_play_test.exs @@ -0,0 +1,26 @@ +defmodule MobDev.GooglePlayTest do + use ExUnit.Case, async: true + + alias MobDev.GooglePlay + + describe "needs_changes_not_sent_for_review?/1" do + test "true for the real Play 400 body that demands the flag" do + msg = + "HTTP 400: Changes cannot be sent for review automatically. Please set " <> + "the query parameter changesNotSentForReview to true. Once committed, " <> + "the changes in this edit can be sent for review from the Google Play Console UI." + + assert GooglePlay.needs_changes_not_sent_for_review?(msg) + end + + test "false for an unrelated commit failure" do + refute GooglePlay.needs_changes_not_sent_for_review?( + "HTTP 403: The caller does not have permission" + ) + end + + test "false for an empty message" do + refute GooglePlay.needs_changes_not_sent_for_review?("") + end + end +end diff --git a/test/mob_dev/google_play/oauth_test.exs b/test/mob_dev/google_play/oauth_test.exs new file mode 100644 index 0000000..fc22a4a --- /dev/null +++ b/test/mob_dev/google_play/oauth_test.exs @@ -0,0 +1,111 @@ +defmodule MobDev.GooglePlay.OAuthTest do + use ExUnit.Case, async: true + + alias MobDev.GooglePlay.OAuth + + # ── build_auth_url/3 ───────────────────────────────────────────────────────── + + describe "build_auth_url/3" do + test "includes client_id" do + url = OAuth.build_auth_url("my_client_id", ["scope1"], "http://localhost:1234/callback") + assert url =~ "client_id=my_client_id" + end + + test "includes redirect_uri encoded" do + url = OAuth.build_auth_url("id", ["scope1"], "http://localhost:1234/callback") + assert url =~ "redirect_uri=" + assert url =~ "localhost" + end + + test "joins multiple scopes with a space (URL-encoded as +)" do + url = + OAuth.build_auth_url( + "id", + [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/androidpublisher" + ], + "http://localhost:9/callback" + ) + + decoded = URI.decode_query(URI.parse(url).query) + assert decoded["scope"] =~ "cloud-platform" + assert decoded["scope"] =~ "androidpublisher" + end + + test "requests offline access" do + url = OAuth.build_auth_url("id", ["s"], "http://localhost:1/callback") + assert url =~ "access_type=offline" + end + + test "includes prompt=consent to force refresh token" do + url = OAuth.build_auth_url("id", ["s"], "http://localhost:1/callback") + assert url =~ "prompt=consent" + end + + test "points to Google OAuth endpoint" do + url = OAuth.build_auth_url("id", ["s"], "http://localhost:1/callback") + assert String.starts_with?(url, "https://accounts.google.com/o/oauth2/v2/auth") + end + end + + # ── parse_callback_request/1 ───────────────────────────────────────────────── + + describe "parse_callback_request/1" do + test "extracts authorization code from typical callback" do + line = "GET /callback?code=4%2F0AVJP&scope=openid HTTP/1.1\r\n" + assert {:ok, code} = OAuth.parse_callback_request(line) + assert code == "4/0AVJP" + end + + test "URL-decodes the code value" do + line = "GET /callback?code=4%2F0AX4XfWhABC%3D HTTP/1.1\r\n" + assert {:ok, code} = OAuth.parse_callback_request(line) + assert code == "4/0AX4XfWhABC=" + end + + test "returns error when Google denies access" do + line = "GET /callback?error=access_denied HTTP/1.1\r\n" + assert {:error, reason} = OAuth.parse_callback_request(line) + assert reason =~ "access_denied" + end + + test "returns error for unrecognised error value" do + line = "GET /callback?error=server_error HTTP/1.1\r\n" + assert {:error, reason} = OAuth.parse_callback_request(line) + assert reason =~ "server_error" + end + + test "returns error when no code or error param present" do + line = "GET /callback?state=xyz HTTP/1.1\r\n" + assert {:error, _} = OAuth.parse_callback_request(line) + end + + test "returns error for completely unrecognisable request" do + assert {:error, _} = OAuth.parse_callback_request("garbage line") + end + + test "handles HEAD method (same parsing)" do + line = "HEAD /callback?code=mycode HTTP/1.1\r\n" + assert {:ok, "mycode"} = OAuth.parse_callback_request(line) + end + end + + # ── setup_scopes/0 ─────────────────────────────────────────────────────────── + + describe "setup_scopes/0" do + test "includes cloud-platform scope" do + assert "https://www.googleapis.com/auth/cloud-platform" in OAuth.setup_scopes() + end + + test "includes androidpublisher scope" do + assert "https://www.googleapis.com/auth/androidpublisher" in OAuth.setup_scopes() + end + + test "returns a non-empty list of strings" do + scopes = OAuth.setup_scopes() + assert length(scopes) > 0 + assert Enum.all?(scopes, &is_binary/1) + end + end +end diff --git a/test/mob_dev/google_play/play_setup_test.exs b/test/mob_dev/google_play/play_setup_test.exs new file mode 100644 index 0000000..58211bd --- /dev/null +++ b/test/mob_dev/google_play/play_setup_test.exs @@ -0,0 +1,70 @@ +defmodule MobDev.GooglePlay.PlaySetupTest do + use ExUnit.Case, async: true + + alias MobDev.GooglePlay.PlaySetup + + # ── build_grant_request/2 ──────────────────────────────────────────────────── + + describe "build_grant_request/2 — account-level (nil package)" do + test "sets grantee to the service account email" do + req = PlaySetup.build_grant_request("play-publisher@proj.iam.gserviceaccount.com", nil) + assert req["grantee"] == "play-publisher@proj.iam.gserviceaccount.com" + end + + test "includes developerAccountPermissions" do + req = PlaySetup.build_grant_request("email@example.com", nil) + assert length(req["developerAccountPermissions"]) > 0 + end + + test "does not include packageName for account-level grant" do + req = PlaySetup.build_grant_request("email@example.com", nil) + refute Map.has_key?(req, "packageName") + end + + test "does not include appLevelPermissions for account-level grant" do + req = PlaySetup.build_grant_request("email@example.com", nil) + refute Map.has_key?(req, "appLevelPermissions") + end + + test "includes CAN_MANAGE_RELEASES permission" do + req = PlaySetup.build_grant_request("email@example.com", nil) + assert "CAN_MANAGE_RELEASES" in req["developerAccountPermissions"] + end + end + + describe "build_grant_request/2 — app-level (with package)" do + test "sets grantee to the service account email" do + req = PlaySetup.build_grant_request("sa@proj.iam.gserviceaccount.com", "com.example.app") + assert req["grantee"] == "sa@proj.iam.gserviceaccount.com" + end + + test "sets packageName for app-level grant" do + req = PlaySetup.build_grant_request("email@example.com", "com.example.app") + assert req["packageName"] == "com.example.app" + end + + test "includes appLevelPermissions for app-level grant" do + req = PlaySetup.build_grant_request("email@example.com", "com.example.app") + assert length(req["appLevelPermissions"]) > 0 + end + + test "does not include developerAccountPermissions for app-level grant" do + req = PlaySetup.build_grant_request("email@example.com", "com.example.app") + refute Map.has_key?(req, "developerAccountPermissions") + end + end + + # ── release_manager_permissions/0 ─────────────────────────────────────────── + + describe "release_manager_permissions/0" do + test "returns a non-empty list of strings" do + perms = PlaySetup.release_manager_permissions() + assert length(perms) > 0 + assert Enum.all?(perms, &is_binary/1) + end + + test "includes CAN_MANAGE_RELEASES" do + assert "CAN_MANAGE_RELEASES" in PlaySetup.release_manager_permissions() + end + end +end diff --git a/test/mob_dev/google_play/setup_wizard_test.exs b/test/mob_dev/google_play/setup_wizard_test.exs new file mode 100644 index 0000000..d09da71 --- /dev/null +++ b/test/mob_dev/google_play/setup_wizard_test.exs @@ -0,0 +1,133 @@ +defmodule MobDev.GooglePlay.SetupWizardTest do + use ExUnit.Case, async: true + + # SetupWizard is interactive (Mix.shell prompts) and makes network calls, + # so we test the pure helper logic extracted from it rather than the wizard flow. + # + # Integration testing of the full wizard is done manually on a real developer + # account — see guides/publishing_to_google_play.md for the expected flow. + + alias MobDev.GooglePlay.CloudSetup + alias MobDev.GooglePlay.PlaySetup + + # ── Key filename derivation ────────────────────────────────────────────────── + # Mirrors the logic in SetupWizard.default_key_filename/1. + + describe "key filename from package name" do + test "uses the last segment of the package name" do + assert key_filename_for("com.example.myapp") == "myapp-service-account" + end + + test "works for two-segment package names" do + assert key_filename_for("example.myapp") == "myapp-service-account" + end + + test "works for single-segment package name" do + assert key_filename_for("myapp") == "myapp-service-account" + end + end + + # ── Project selection logic ────────────────────────────────────────────────── + # Mirrors SetupWizard.pick_project/2 logic for unit testing. + + describe "project selection by number" do + test "selects first project when user enters 1" do + projects = [ + %{"projectId" => "proj-a", "displayName" => "A"}, + %{"projectId" => "proj-b", "displayName" => "B"} + ] + + assert pick_project(projects, "1") == {:ok, "proj-a"} + end + + test "selects last project when user enters the last number" do + projects = [ + %{"projectId" => "proj-a", "displayName" => "A"}, + %{"projectId" => "proj-b", "displayName" => "B"}, + %{"projectId" => "proj-c", "displayName" => "C"} + ] + + assert pick_project(projects, "3") == {:ok, "proj-c"} + end + + test "selects by project ID string" do + projects = [%{"projectId" => "my-project-123", "displayName" => "My Project"}] + assert pick_project(projects, "my-project-123") == {:ok, "my-project-123"} + end + + test "returns error for out-of-range number" do + projects = [%{"projectId" => "proj-a", "displayName" => "A"}] + assert {:error, _} = pick_project(projects, "5") + end + + test "returns error for unknown project ID" do + projects = [%{"projectId" => "proj-a", "displayName" => "A"}] + assert {:error, _} = pick_project(projects, "proj-unknown") + end + + test "returns error for 0" do + projects = [%{"projectId" => "proj-a", "displayName" => "A"}] + assert {:error, _} = pick_project(projects, "0") + end + end + + # ── grant_request shape integration ───────────────────────────────────────── + # Verify the JSON body we'd POST to the grants API is well-formed. + + describe "grant request body" do + test "account-level request is valid JSON with required fields" do + req = PlaySetup.build_grant_request("sa@proj.iam.gserviceaccount.com", nil) + json = Jason.encode!(req) + decoded = Jason.decode!(json) + + assert decoded["grantee"] == "sa@proj.iam.gserviceaccount.com" + assert length(decoded["developerAccountPermissions"]) > 0 + end + + test "app-level request is valid JSON with required fields" do + req = PlaySetup.build_grant_request("sa@proj.iam.gserviceaccount.com", "com.example.app") + json = Jason.encode!(req) + decoded = Jason.decode!(json) + + assert decoded["grantee"] == "sa@proj.iam.gserviceaccount.com" + assert decoded["packageName"] == "com.example.app" + assert length(decoded["appLevelPermissions"]) > 0 + end + end + + # ── Cloud setup URL construction ───────────────────────────────────────────── + + describe "enable API URL for well-known projects" do + test "project IDs with dashes are handled correctly" do + url = + CloudSetup.build_enable_api_url("my-cool-project-123", "androidpublisher.googleapis.com") + + assert url =~ "my-cool-project-123" + end + end + + # ── Helpers ────────────────────────────────────────────────────────────────── + + # Mirrors SetupWizard.default_key_filename/1 + defp key_filename_for(package_name) do + package_name + |> String.split(".") + |> List.last() + |> Kernel.<>("-service-account") + end + + # Mirrors SetupWizard.pick_project/2 + defp pick_project(projects, input) do + case Integer.parse(input) do + {n, ""} when n >= 1 and n <= length(projects) -> + {:ok, Enum.at(projects, n - 1)["projectId"]} + + _ -> + if Enum.any?(projects, &(&1["projectId"] == input)) do + {:ok, input} + else + {:error, "Unknown project: #{input}"} + end + end + end +end diff --git a/test/mob_dev/hot_push_test.exs b/test/mob_dev/hot_push_test.exs index 7064c0c..2cc22e8 100644 --- a/test/mob_dev/hot_push_test.exs +++ b/test/mob_dev/hot_push_test.exs @@ -3,6 +3,18 @@ defmodule MobDev.HotPushTest do alias MobDev.HotPush + setup do + root = + Path.join( + System.tmp_dir!(), + "mob_hot_push_test_#{System.unique_integer([:positive, :monotonic])}" + ) + + File.mkdir_p!(root) + on_exit(fn -> File.rm_rf(root) end) + %{tmp_root: root} + end + # ── snapshot_beams/0 ───────────────────────────────────────────────────────── describe "snapshot_beams/0" do @@ -73,4 +85,695 @@ defmodule MobDev.HotPushTest do end end + describe "immutable prepared snapshots" do + test "loads the exact captured bytes after the source is replaced", %{tmp_root: root} do + {path, original} = write_loaded_beam(root, MobDev.Device) + assert {:ok, [prepared]} = HotPush.prepare([path]) + + File.write!(path, "replaced after snapshot") + + rpc = fn _node, module, filename, binary -> + assert module == MobDev.Device + assert filename == String.to_charlist(path) + assert binary == original + {:module, module} + end + + assert {1, []} = HotPush.push_prepared([:"ios_snapshot@127.0.0.1"], [prepared], rpc) + end + + test "rejects malformed BEAMs, duplicate paths, and module/path mismatch", %{ + tmp_root: root + } do + malformed = Path.join(root, "Malformed.beam") + File.write!(malformed, "not a beam") + assert {:error, _bounded} = HotPush.prepare([malformed]) + + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:error, _bounded} = HotPush.prepare([path, path]) + + wrong_name = Path.join(root, "Wrong.Module.beam") + File.cp!(path, wrong_name) + assert {:error, _bounded} = HotPush.prepare([wrong_name]) + end + + test "pure validation rejects tampered bytes, hash, module, and extra identity", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, [prepared]} = HotPush.prepare([path]) + assert :ok = HotPush.validate_prepared_snapshot([prepared]) + + assert {:error, _reason} = + HotPush.validate_prepared_snapshot([%{prepared | binary: prepared.binary <> "x"}]) + + assert {:error, _reason} = + HotPush.validate_prepared_snapshot([ + %{prepared | sha256: :crypto.hash(:sha256, "forged")} + ]) + + assert {:error, _reason} = + HotPush.validate_prepared_snapshot([%{prepared | module: MobDev.Tunnel}]) + + assert {:error, _reason} = + HotPush.validate_prepared_snapshot([Map.put(prepared, :extra, true)]) + end + + test "fails closed on badrpc, on-load failure, mismatch, and stops at first ambiguity", %{ + tmp_root: root + } do + {path_a, _binary_a} = write_loaded_beam(root, MobDev.Device) + {path_b, _binary_b} = write_loaded_beam(root, MobDev.Tunnel) + assert {:ok, snapshot} = HotPush.prepare([path_b, path_a]) + + for {reply, category} <- [ + {{:badrpc, :lost}, :badrpc}, + {{:error, :on_load_failure}, :on_load_failure}, + {{:module, MobDev.Tunnel}, :unexpected_reply}, + {:unexpected, :unexpected_reply} + ] do + {:ok, calls} = Agent.start_link(fn -> [] end) + + rpc = fn node, module, _filename, binary -> + Agent.update(calls, &[{node, module, :crypto.hash(:sha256, binary)} | &1]) + reply + end + + [first | _] = snapshot + + assert {0, [{module, [{:"node-a@127.0.0.1", ^category}]}]} = + HotPush.push_prepared([:"node-a@127.0.0.1", :"node-b@127.0.0.1"], snapshot, rpc) + + assert module == first.module + assert Agent.get(calls, &Enum.reverse/1) |> length() == 1 + end + end + end + + describe "ordinary Android hot-push lease" do + test "raw prepared APIs reject Android-looking nodes before RPC", %{tmp_root: root} do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + + assert {0, [{:android_deploy_lock, :required}]} = + HotPush.push_prepared([android_node()], snapshot, fn _, _, _, _ -> + flunk("raw Android RPC must be fenced") + end) + end + + test "acquires, proves, commits, and releases around exact RPC bytes", %{tmp_root: root} do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + {runner, state} = lock_runner() + node = android_node() + {:ok, rpc_calls} = Agent.start_link(fn -> 0 end) + + rpc = fn ^node, module, _filename, binary -> + Agent.update(rpc_calls, &(&1 + 1)) + assert :crypto.hash(:sha256, binary) == hd(snapshot).sha256 + {:module, module} + end + + assert {1, []} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: runner, + rpc: rpc + ) + + assert Agent.get(rpc_calls, & &1) == 1 + + final = Agent.get(state, & &1) + assert final.fixed == nil + assert final.tombstone == nil + assert Enum.any?(final.commands, &String.contains?(&1, "|fast_committed")) + assert Enum.any?(final.commands, &exact_tombstone_cleanup?/1) + refute Enum.any?(final.commands, &String.contains?(&1, "rm -rf")) + end + + test "known lock block and unknown Android mapping perform zero RPC or mutation", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + node = android_node() + {:ok, calls} = Agent.start_link(fn -> [] end) + + blocked_runner = fn args -> + Agent.update(calls, &[args | &1]) + {"blocked", 1} + end + + rpc = fn _node, _module, _filename, _binary -> + flunk("RPC must not run before an exact lease is held") + end + + assert {0, [{:android_deploy_lock, :unavailable}]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: blocked_runner, + rpc: rpc + ) + + refute Agent.get(calls, & &1) + |> Enum.any?(fn args -> List.last(args) |> String.contains?("mkdir ") end) + + assert {0, [{:android_deploy_lock, :target_ambiguous}]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [], + lock_runner: blocked_runner, + rpc: rpc + ) + end + + test "RPC ambiguity retains the acquired fixed lease and stops", %{tmp_root: root} do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + {runner, state} = lock_runner() + node = android_node() + + assert {0, [{MobDev.Device, [{^node, :badrpc}]}, {:android_deploy_lock, :retained}]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: runner, + rpc: fn _node, _module, _filename, _binary -> {:badrpc, :lost} end + ) + + final = Agent.get(state, & &1) + + assert final.fixed =~ + ~r/\A1\|[A-Za-z0-9_-]{16}\|[0-9a-f]{64}\|acquired\z/ + + assert final.tombstone == nil + refute Enum.any?(final.commands, &String.contains?(&1, "rm -rf")) + end + + test "RPC throw is bounded and retains the exact acquired lease", %{tmp_root: root} do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + {runner, state} = lock_runner() + node = android_node() + + assert {0, [{MobDev.Device, [{^node, :load_failed}]}, {:android_deploy_lock, :retained}]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: runner, + rpc: fn _node, _module, _filename, _binary -> throw(:transport_lost) end + ) + + final = Agent.get(state, & &1) + + assert final.fixed =~ + ~r/\A1\|[A-Za-z0-9_-]{16}\|[0-9a-f]{64}\|acquired\z/ + + assert final.tombstone == nil + end + + test "partial Android module delivery reports zero success and retains authority", %{ + tmp_root: root + } do + {path_a, _binary_a} = write_loaded_beam(root, MobDev.Device) + {path_b, _binary_b} = write_loaded_beam(root, MobDev.Tunnel) + assert {:ok, snapshot} = HotPush.prepare([path_a, path_b]) + {runner, state} = lock_runner() + node = android_node() + {:ok, calls} = Agent.start_link(fn -> 0 end) + + rpc = fn ^node, module, _filename, _binary -> + call = Agent.get_and_update(calls, &{&1 + 1, &1 + 1}) + if call == 1, do: {:module, module}, else: {:badrpc, :lost} + end + + assert {0, [failure, {:android_deploy_lock, :retained}]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: runner, + rpc: rpc + ) + + assert {_module, [{^node, :badrpc}]} = failure + assert Agent.get(calls, & &1) == 2 + + final = Agent.get(state, & &1) + assert String.ends_with?(final.fixed, "|acquired") + assert final.tombstone == nil + refute Enum.any?(final.commands, &String.contains?(&1, "rm -rf")) + end + + test "transition and release ambiguity report zero success and retain recovery state", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + node = android_node() + + {transition_runner, transition_state} = lock_runner() + + ambiguous_transition = fn args -> + if List.last(args) |> String.contains?("record_next_") do + {"lost", 1} + else + transition_runner.(args) + end + end + + assert {0, + [ + {:android_deploy_lock, :transition_ambiguous}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: ambiguous_transition, + rpc: fn _node, module, _filename, _binary -> {:module, module} end + ) + + transition_final = Agent.get(transition_state, & &1) + assert String.ends_with?(transition_final.fixed, "|acquired") + assert transition_final.tombstone == nil + + {release_runner, release_state} = lock_runner() + + ambiguous_release = fn args -> + if tombstone_record_proof?(List.last(args)) do + {"lost", 1} + else + release_runner.(args) + end + end + + assert {0, + [ + {:android_deploy_lock, :release_ambiguous}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: ambiguous_release, + rpc: fn _node, module, _filename, _binary -> {:module, module} end + ) + + release_final = Agent.get(release_state, & &1) + assert release_final.fixed == nil + assert String.ends_with?(release_final.tombstone, "|fast_committed") + refute Enum.any?(release_final.commands, &String.contains?(&1, "rm -rf")) + end + + test "fenced post-push runs once per Android target before commit and release", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + {runner, state} = lock_runner() + node = android_node() + {:ok, callbacks} = Agent.start_link(fn -> [] end) + + post_push = fn ^node -> + held = Agent.get(state, & &1) + assert String.ends_with?(held.fixed, "|acquired") + assert held.tombstone == nil + Agent.update(callbacks, &[node | &1]) + :ok + end + + assert {1, []} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: runner, + rpc: fn _node, module, _filename, _binary -> {:module, module} end, + post_push: post_push + ) + + assert Agent.get(callbacks, &Enum.reverse/1) == [node] + assert %{fixed: nil, tombstone: nil} = Agent.get(state, & &1) + end + + test "post-push ambiguity reports zero and retains the uncommitted lease", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + {runner, state} = lock_runner() + node = android_node() + + assert {0, + [ + {:android_post_push, :ambiguous}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + lock_runner: runner, + rpc: fn _node, module, _filename, _binary -> {:module, module} end, + post_push: fn ^node -> throw(:reply_lost) end + ) + + final = Agent.get(state, & &1) + assert String.ends_with?(final.fixed, "|acquired") + assert final.tombstone == nil + refute Enum.any?(final.commands, &String.contains?(&1, "record_next_")) + end + + test "exact lease target equality rejects empty, subset, superset, iOS-only, and mixed requests before RPC", + %{tmp_root: root} do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + node_a = android_node("a") + node_b = android_node("b") + ios_node = :"ios-test@127.0.0.1" + + cases = [ + { + [], + [], + existing_lease(["serial-a"], :native_ready) + }, + { + [node_a], + [%{platform: :android, node: node_a, serial: "serial-a"}], + existing_lease(["serial-a", "serial-b"], :native_ready) + }, + { + [node_a, node_b], + [ + %{platform: :android, node: node_a, serial: "serial-a"}, + %{platform: :android, node: node_b, serial: "serial-b"} + ], + existing_lease(["serial-a"], :native_ready) + }, + { + [ios_node], + [], + existing_lease(["serial-a"], :native_ready) + }, + { + [node_a, ios_node], + [%{platform: :android, node: node_a, serial: "serial-a"}], + existing_lease(["serial-a"], :native_ready) + } + ] + + Enum.each(cases, fn {nodes, devices, lease} -> + assert {0, + [ + {:android_deploy_lock, :authority_ambiguous}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced(nodes, snapshot, + package: "com.example.casein", + android_devices: devices, + android_deploy_lock: lease, + expected_lock_phase: :native_ready, + lock_runner: fn _args -> flunk("invalid exact-set request must not probe") end, + rpc: fn _, _, _, _ -> flunk("invalid exact-set request must not RPC") end + ) + end) + end + + test "mixed ordinary push releases Android before iOS and reports later iOS partial as zero", + %{tmp_root: root} do + {path_a, _binary_a} = write_loaded_beam(root, MobDev.Device) + {path_b, _binary_b} = write_loaded_beam(root, MobDev.Tunnel) + assert {:ok, snapshot} = HotPush.prepare([path_a, path_b]) + {runner, state} = lock_runner() + android = android_node() + ios = :"ios-test@127.0.0.1" + {:ok, calls} = Agent.start_link(fn -> [] end) + {:ok, ios_count} = Agent.start_link(fn -> 0 end) + + rpc = fn node, module, _filename, _binary -> + Agent.update(calls, &[node | &1]) + + if node == ios do + released = Agent.get(state, & &1) + assert released.fixed == nil + assert released.tombstone == nil + call = Agent.get_and_update(ios_count, &{&1 + 1, &1 + 1}) + if call == 1, do: {:module, module}, else: {:badrpc, :lost} + else + {:module, module} + end + end + + assert {0, [failure, {:hot_push, :partial_after_android_commit}]} = + HotPush.push_prepared_fenced([ios, android], snapshot, + package: "com.example.casein", + android_devices: [ + %{platform: :android, node: android, serial: "serial-a"} + ], + lock_runner: runner, + rpc: rpc + ) + + assert {_module, [{^ios, :badrpc}]} = failure + assert Agent.get(calls, &Enum.reverse/1) == [android, android, ios, ios] + assert %{fixed: nil, tombstone: nil} = Agent.get(state, & &1) + end + + test "a target-set authority flip before target B prevents B post-push callback", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + node_a = android_node("a") + node_b = android_node("b") + lease = existing_lease(["serial-a", "serial-b"], :native_ready) + record = expected_lock_record(lease) + {:ok, a_proofs} = Agent.start_link(fn -> 0 end) + {:ok, callbacks} = Agent.start_link(fn -> [] end) + + runner = fn ["-s", serial, "shell", _command] -> + if serial == "serial-a" do + proof_number = Agent.get_and_update(a_proofs, &{&1 + 1, &1 + 1}) + if proof_number <= 4, do: {record, 0}, else: {"flipped", 0} + else + {record, 0} + end + end + + assert {0, + [ + {:android_post_push, :ambiguous}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced([node_a, node_b], snapshot, + package: "com.example.casein", + android_devices: [ + %{platform: :android, node: node_a, serial: "serial-a"}, + %{platform: :android, node: node_b, serial: "serial-b"} + ], + android_deploy_lock: lease, + expected_lock_phase: :native_ready, + lock_runner: runner, + rpc: fn _node, module, _filename, _binary -> {:module, module} end, + post_push: fn node -> + Agent.update(callbacks, &[node | &1]) + :ok + end + ) + + assert Agent.get(callbacks, &Enum.reverse/1) == [node_a] + end + + test "post-push callback without an Android lease is rejected before invocation", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + + assert {0, [{:android_post_push, :requires_android_lease}]} = + HotPush.push_prepared_fenced([:"ios-test@127.0.0.1"], snapshot, + rpc: fn _, _, _, _ -> flunk("RPC must not run") end, + post_push: fn _node -> flunk("callback must not run") end + ) + end + + test "a full-set owner flip after target A prevents every RPC to target B", %{ + tmp_root: root + } do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + node_a = android_node("a") + node_b = android_node("b") + lease = existing_lease(["serial-a", "serial-b"], :native_ready) + record = expected_lock_record(lease) + {:ok, a_proofs} = Agent.start_link(fn -> 0 end) + {:ok, rpc_nodes} = Agent.start_link(fn -> [] end) + + runner = fn ["-s", serial, "shell", _command] -> + if serial == "serial-a" do + proof_number = Agent.get_and_update(a_proofs, &{&1 + 1, &1 + 1}) + if proof_number <= 2, do: {record, 0}, else: {"flipped", 0} + else + {record, 0} + end + end + + rpc = fn node, module, _filename, _binary -> + Agent.update(rpc_nodes, &[node | &1]) + {:module, module} + end + + assert {0, + [ + {MobDev.Device, [{^node_b, :authority_ambiguous}]}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced([node_a, node_b], snapshot, + package: "com.example.casein", + android_devices: [ + %{platform: :android, node: node_a, serial: "serial-a"}, + %{platform: :android, node: node_b, serial: "serial-b"} + ], + android_deploy_lock: lease, + expected_lock_phase: :native_ready, + lock_runner: runner, + rpc: rpc + ) + + assert Agent.get(rpc_nodes, &Enum.reverse/1) == [node_a] + end + + test "committed existing leases are non-mutable and perform zero RPC", %{tmp_root: root} do + {path, _binary} = write_loaded_beam(root, MobDev.Device) + assert {:ok, snapshot} = HotPush.prepare([path]) + node = android_node() + + for phase <- [:final_committed, :fast_committed] do + lease = existing_lease(["serial-a"], phase) + + assert {0, + [ + {:android_deploy_lock, :authority_ambiguous}, + {:android_deploy_lock, :retained} + ]} = + HotPush.push_prepared_fenced([node], snapshot, + package: "com.example.casein", + android_devices: [%{platform: :android, node: node, serial: "serial-a"}], + android_deploy_lock: lease, + expected_lock_phase: phase, + lock_runner: fn _args -> flunk("committed phase must not probe") end, + rpc: fn _, _, _, _ -> flunk("committed phase must not mutate") end + ) + end + end + end + + defp write_loaded_beam(root, module) do + {:module, ^module} = Code.ensure_loaded(module) + {^module, binary, _filename} = :code.get_object_code(module) + path = Path.join(root, "#{module}.beam") + File.write!(path, binary) + {path, binary} + end + + defp android_node(suffix \\ "test") do + app = Mix.Project.config()[:app] + String.to_atom("#{app}_android_#{suffix}@127.0.0.1") + end + + defp existing_lease(serials, phase) do + serials = Enum.sort(serials) + + %{ + bundle_id: "com.example.casein", + owner: "ownerproof000001", + serials: serials, + target_digest: + serials + |> Enum.join(<<0>>) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower), + phase: phase, + state: :held_success + } + end + + defp expected_lock_record(lease) do + "1|#{lease.owner}|#{lease.target_digest}|#{lease.phase}" + end + + defp lock_runner do + {:ok, state} = Agent.start_link(fn -> %{fixed: nil, tombstone: nil, commands: []} end) + + runner = fn ["-s", "serial-a", "shell", command] -> + Agent.get_and_update(state, fn lock -> + lock = %{lock | commands: lock.commands ++ [command]} + + cond do + String.contains?(command, "mkdir ") -> + record = quoted_record(command) + {{"", 0}, %{lock | fixed: record}} + + String.contains?(command, "record_next_") -> + record = quoted_record(command) + {{"", 0}, %{lock | fixed: record}} + + String.contains?(command, "mv ") and + String.contains?(command, ".mob_native_deploy_releasing_") -> + {{"", 0}, %{lock | fixed: nil, tombstone: lock.fixed}} + + exact_tombstone_cleanup?(command) -> + result = if is_binary(lock.tombstone), do: {"", 0}, else: {"", 1} + next_lock = if result == {"", 0}, do: %{lock | tombstone: nil}, else: lock + {result, next_lock} + + tombstone_record_proof?(command) -> + {{lock.tombstone || "", if(is_binary(lock.tombstone), do: 0, else: 1)}, lock} + + fixed_record_proof?(command) -> + {{lock.fixed || "", if(is_binary(lock.fixed), do: 0, else: 1)}, lock} + + String.contains?(command, "set -e; test ! -e") and + String.contains?(command, "test -d /data/data/") -> + result = if is_nil(lock.fixed) and is_nil(lock.tombstone), do: {"", 0}, else: {"", 1} + {result, lock} + + true -> + {{"", 1}, lock} + end + end) + end + + {runner, state} + end + + defp quoted_record(command) do + Regex.scan(Regex.compile!(~s|printf %s "([^"]+)"|), command) + |> List.last() + |> Enum.at(1) + end + + defp fixed_record_proof?(command) do + String.ends_with?(command, ".mob_native_deploy_lock/record'") and + not String.contains?(command, "value=$(cat") + end + + defp tombstone_record_proof?(command) do + Regex.match?( + ~r/cat \/data\/data\/[^ ]+\/files\/\.mob_native_deploy_releasing_[A-Za-z0-9_-]+\/record'\z/, + command + ) and not String.contains?(command, "value=$(cat") + end + + defp exact_tombstone_cleanup?(command) do + case Regex.run( + ~r/; rm (\/data\/data\/[^ ;]+\/files\/\.mob_native_deploy_releasing_[A-Za-z0-9_-]+)\/record; rmdir (\/data\/data\/[^ ;']+\/files\/\.mob_native_deploy_releasing_[A-Za-z0-9_-]+)'\z/, + command, + capture: :all_but_first + ) do + [record_directory, removed_directory] -> record_directory == removed_directory + _no_exact_cleanup -> false + end + end end diff --git a/test/mob_dev/icon_generator_test.exs b/test/mob_dev/icon_generator_test.exs index 19020a7..5ba1619 100644 --- a/test/mob_dev/icon_generator_test.exs +++ b/test/mob_dev/icon_generator_test.exs @@ -45,8 +45,10 @@ defmodule MobDev.IconGeneratorTest do test "includes common iPhone sizes" do sizes = IconGenerator.ios_sizes() - assert 120 in sizes # iPhone App 2x / Spotlight 3x - assert 180 in sizes # iPhone App 3x + # iPhone App 2x / Spotlight 3x + assert 120 in sizes + # iPhone App 3x + assert 180 in sizes end test "all values are positive integers" do @@ -80,6 +82,7 @@ defmodule MobDev.IconGeneratorTest do test "writes all Android mipmap buckets", %{tmp: tmp} do source = write_test_png(tmp) IconGenerator.generate_from_source(source, tmp) + Enum.each(IconGenerator.android_sizes(), fn {bucket, _px} -> path = Path.join(tmp, "android/app/src/main/res/#{bucket}/ic_launcher.png") assert File.exists?(path), "Missing: #{path}" @@ -89,6 +92,7 @@ defmodule MobDev.IconGeneratorTest do test "writes iOS icons for each size", %{tmp: tmp} do source = write_test_png(tmp) IconGenerator.generate_from_source(source, tmp) + Enum.each(IconGenerator.ios_sizes(), fn px -> path = Path.join(tmp, "ios/Assets.xcassets/AppIcon.appiconset/icon_#{px}.png") assert File.exists?(path), "Missing icon_#{px}.png" @@ -113,10 +117,11 @@ defmodule MobDev.IconGeneratorTest do test "Android icons are square (not squashed)", %{tmp: tmp} do source = write_test_png(tmp) IconGenerator.generate_from_source(source, tmp) + Enum.each(IconGenerator.android_sizes(), fn {bucket, px} -> path = Path.join(tmp, "android/app/src/main/res/#{bucket}/ic_launcher.png") img = Image.open!(path) - assert Image.width(img) == px, "#{bucket}: width #{Image.width(img)} != #{px}" + assert Image.width(img) == px, "#{bucket}: width #{Image.width(img)} != #{px}" assert Image.height(img) == px, "#{bucket}: height #{Image.height(img)} != #{px}" end) end @@ -124,13 +129,51 @@ defmodule MobDev.IconGeneratorTest do test "iOS icons are square (not squashed)", %{tmp: tmp} do source = write_test_png(tmp) IconGenerator.generate_from_source(source, tmp) + Enum.each(IconGenerator.ios_sizes(), fn px -> path = Path.join(tmp, "ios/Assets.xcassets/AppIcon.appiconset/icon_#{px}.png") img = Image.open!(path) - assert Image.width(img) == px, "icon_#{px}: width #{Image.width(img)} != #{px}" + assert Image.width(img) == px, "icon_#{px}: width #{Image.width(img)} != #{px}" assert Image.height(img) == px, "icon_#{px}: height #{Image.height(img)} != #{px}" end) end + + test "from a transparent source: iOS icons are opaque, Android keeps alpha", %{tmp: tmp} do + # Apple rejects any alpha channel on the App Store icon (error 90717), but + # Android adaptive/legacy icons need transparency. A transparent source must + # therefore flatten on iOS and stay transparent on Android. + source = write_transparent_png(tmp) + assert Image.has_alpha?(Image.open!(source)), "fixture should have an alpha channel" + + IconGenerator.generate_from_source(source, tmp) + + Enum.each(IconGenerator.ios_sizes(), fn px -> + path = Path.join(tmp, "ios/Assets.xcassets/AppIcon.appiconset/icon_#{px}.png") + refute Image.has_alpha?(Image.open!(path)), "icon_#{px}.png must be opaque (no alpha)" + end) + + Enum.each(IconGenerator.android_sizes(), fn {bucket, _px} -> + path = Path.join(tmp, "android/app/src/main/res/#{bucket}/ic_launcher.png") + assert Image.has_alpha?(Image.open!(path)), "#{bucket} ic_launcher.png must keep alpha" + end) + end + + test "an explicit :background_color fills the flattened iOS icon", %{tmp: tmp} do + # Fully-transparent source → the whole opaque icon becomes the background. + source = write_transparent_png(tmp) + IconGenerator.generate_from_source(source, tmp, background_color: "#00FF00") + + img = Image.open!(Path.join(tmp, "ios/Assets.xcassets/AppIcon.appiconset/icon_1024.png")) + refute Image.has_alpha?(img) + assert {:ok, [r, g, b | _]} = Image.get_pixel(img, 512, 512) + assert g > 200 and r < 80 and b < 80, "expected green fill, got #{inspect([r, g, b])}" + end + + test "an opaque source is left unflattened (still writes iOS icons)", %{tmp: tmp} do + source = write_test_png(tmp) + assert :ok = IconGenerator.generate_from_source(source, tmp) + assert File.exists?(Path.join(tmp, "ios/Assets.xcassets/AppIcon.appiconset/icon_1024.png")) + end end # ── generate_random/1 (integration — requires Avatarz + libvips) ───────────── @@ -157,12 +200,210 @@ defmodule MobDev.IconGeneratorTest do end end + # ── adaptive_sizes/0 ───────────────────────────────────────────────────────── + + describe "adaptive_sizes/0" do + test "covers all five mipmap buckets" do + sizes = IconGenerator.adaptive_sizes() + assert map_size(sizes) == 5 + + Enum.each( + ["mipmap-mdpi", "mipmap-hdpi", "mipmap-xhdpi", "mipmap-xxhdpi", "mipmap-xxxhdpi"], + fn b -> assert Map.has_key?(sizes, b) end + ) + end + + test "mdpi is 108px (one density-pixel == one device-pixel)" do + assert IconGenerator.adaptive_sizes()["mipmap-mdpi"] == 108 + end + + test "xxxhdpi is 432px (4× density)" do + assert IconGenerator.adaptive_sizes()["mipmap-xxxhdpi"] == 432 + end + + test "every adaptive size is larger than the matching legacy size" do + adaptive = IconGenerator.adaptive_sizes() + legacy = IconGenerator.android_sizes() + + Enum.each(adaptive, fn {bucket, px} -> + assert px > legacy[bucket], + "adaptive #{bucket} (#{px}) should exceed legacy (#{legacy[bucket]})" + end) + end + end + + # ── adaptive_icon_xml/0 ────────────────────────────────────────────────────── + + describe "adaptive_icon_xml/0" do + test "is a valid-looking XML fragment with adaptive-icon root" do + xml = IconGenerator.adaptive_icon_xml() + assert String.starts_with?(xml, "<?xml") + assert xml =~ "<adaptive-icon" + assert xml =~ "</adaptive-icon>" + end + + test "references the foreground mipmap" do + assert IconGenerator.adaptive_icon_xml() =~ "@mipmap/ic_launcher_foreground" + end + + test "references the background colour resource" do + assert IconGenerator.adaptive_icon_xml() =~ "@color/ic_launcher_background" + end + end + + # ── background_color_xml/1 ─────────────────────────────────────────────────── + + describe "background_color_xml/1" do + test "embeds the colour as ic_launcher_background" do + xml = IconGenerator.background_color_xml("#E8B53C") + assert xml =~ ~s(<color name="ic_launcher_background">#E8B53C</color>) + end + + test "accepts hex without leading #" do + xml = IconGenerator.background_color_xml("E8B53C") + assert xml =~ ~s(>#E8B53C<) + end + + test "uppercases hex digits" do + xml = IconGenerator.background_color_xml("#e8b53c") + assert xml =~ ~s(>#E8B53C<) + end + + test "rejects non-hex input" do + assert_raise ArgumentError, fn -> + IconGenerator.background_color_xml("not a colour") + end + end + + test "rejects 3-digit shorthand (Android wants 6-digit)" do + assert_raise ArgumentError, fn -> + IconGenerator.background_color_xml("#FFF") + end + end + end + + # ── rgb_to_hex/3 ───────────────────────────────────────────────────────────── + + describe "rgb_to_hex/3" do + test "encodes black as #000000" do + assert IconGenerator.rgb_to_hex(0, 0, 0) == "#000000" + end + + test "encodes white as #FFFFFF" do + assert IconGenerator.rgb_to_hex(255, 255, 255) == "#FFFFFF" + end + + test "pads single-digit hex to two characters" do + assert IconGenerator.rgb_to_hex(1, 2, 3) == "#010203" + end + + test "rounds non-integer channels" do + assert IconGenerator.rgb_to_hex(1.4, 2.6, 3.0) == "#010303" + end + + test "clamps values above 255" do + assert IconGenerator.rgb_to_hex(300, 255, 255) == "#FFFFFF" + end + + test "clamps negative values to 0" do + assert IconGenerator.rgb_to_hex(-5, 0, 0) == "#000000" + end + end + + # ── generate_adaptive/3 ────────────────────────────────────────────────────── + + describe "generate_adaptive/3" do + setup do + tmp = Path.join(System.tmp_dir!(), "icon_adaptive_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp} + end + + test "writes a foreground PNG for every adaptive size", %{tmp: tmp} do + source = write_test_png(tmp, color: [232, 181, 60]) + + assert :ok = + IconGenerator.generate_adaptive(source, tmp, background_color: "#E8B53C") + + Enum.each(IconGenerator.adaptive_sizes(), fn {bucket, _px} -> + path = + Path.join(tmp, "android/app/src/main/res/#{bucket}/ic_launcher_foreground.png") + + assert File.exists?(path), "Missing: #{path}" + end) + end + + test "foreground PNGs are sized to match adaptive_sizes/0", %{tmp: tmp} do + source = write_test_png(tmp, color: [232, 181, 60]) + IconGenerator.generate_adaptive(source, tmp, background_color: "#E8B53C") + + Enum.each(IconGenerator.adaptive_sizes(), fn {bucket, px} -> + path = + Path.join(tmp, "android/app/src/main/res/#{bucket}/ic_launcher_foreground.png") + + img = Image.open!(path) + assert Image.width(img) == px, "#{bucket}: width #{Image.width(img)} != #{px}" + assert Image.height(img) == px, "#{bucket}: height #{Image.height(img)} != #{px}" + end) + end + + test "writes adaptive ic_launcher.xml", %{tmp: tmp} do + source = write_test_png(tmp, color: [232, 181, 60]) + IconGenerator.generate_adaptive(source, tmp, background_color: "#E8B53C") + + path = Path.join(tmp, "android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml") + assert File.exists?(path) + assert File.read!(path) =~ "@mipmap/ic_launcher_foreground" + end + + test "writes ic_launcher_round.xml referencing the same adaptive icon", %{tmp: tmp} do + source = write_test_png(tmp, color: [232, 181, 60]) + IconGenerator.generate_adaptive(source, tmp, background_color: "#E8B53C") + + path = + Path.join(tmp, "android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml") + + assert File.exists?(path) + assert File.read!(path) =~ "@mipmap/ic_launcher_foreground" + end + + test "writes background colour XML in values/", %{tmp: tmp} do + source = write_test_png(tmp, color: [232, 181, 60]) + IconGenerator.generate_adaptive(source, tmp, background_color: "#E8B53C") + + path = Path.join(tmp, "android/app/src/main/res/values/ic_launcher_background.xml") + assert File.exists?(path) + assert File.read!(path) =~ "#E8B53C" + end + + test "auto-extracts background colour from source when not specified", %{tmp: tmp} do + source = write_test_png(tmp, color: [232, 181, 60]) + assert :ok = IconGenerator.generate_adaptive(source, tmp, []) + + path = Path.join(tmp, "android/app/src/main/res/values/ic_launcher_background.xml") + content = File.read!(path) + # Image used a uniform colour, so the extracted hex should match it. + assert content =~ "#E8B53C" + end + end + # ── helpers ────────────────────────────────────────────────────────────────── - # Creates a minimal 64×64 white PNG in the tmp dir using Image library. - defp write_test_png(dir) do + # Creates a small solid-colour PNG in `dir`. Defaults to white; pass + # `color:` as a 3-tuple/list of integers (e.g. `[232, 181, 60]`). + defp write_test_png(dir, opts \\ []) do path = Path.join(dir, "test_source.png") - Image.new!(64, 64, color: :white) |> Image.write!(path) + color = Keyword.get(opts, :color, :white) + Image.new!(64, 64, color: color) |> Image.write!(path) + path + end + + # A source PNG carrying an alpha channel (fully transparent), for exercising + # the iOS-flatten path. + defp write_transparent_png(dir) do + path = Path.join(dir, "transparent_source.png") + Image.new!(64, 64, color: :red) |> Image.add_alpha!(0) |> Image.write!(path) path end end diff --git a/test/mob_dev/mlx_downloader_test.exs b/test/mob_dev/mlx_downloader_test.exs new file mode 100644 index 0000000..de67350 --- /dev/null +++ b/test/mob_dev/mlx_downloader_test.exs @@ -0,0 +1,234 @@ +defmodule MobDev.MLXDownloaderTest do + # async: false — these tests put/delete MOB_CACHE_DIR and + # MOB_MLX_LOCAL_TARBALL_DIR, which are process-global. Running async with + # any other test that reads MOB_CACHE_DIR can cause one test to + # extract into the real ~/.mob/cache/ (overwriting actual cached + # tarballs with 17-byte test stubs — seen in the wild once already + # during this session's iPhone device deploy). + use ExUnit.Case, async: false + + alias MobDev.MLXDownloader + + # ── dir/1, cache_dir/0 ────────────────────────────────────────────────────── + + describe "dir/1" do + test "honors MOB_CACHE_DIR env var" do + System.put_env("MOB_CACHE_DIR", "/tmp/mob_test_cache_mlx") + + try do + assert MLXDownloader.dir(:ios_device) =~ "/tmp/mob_test_cache_mlx/libmlx-" + assert MLXDownloader.dir(:ios_sim) =~ "/tmp/mob_test_cache_mlx/libmlx-" + after + System.delete_env("MOB_CACHE_DIR") + end + end + + test "defaults to ~/.mob/cache when MOB_CACHE_DIR unset" do + System.delete_env("MOB_CACHE_DIR") + home = System.get_env("HOME") + assert String.starts_with?(MLXDownloader.dir(:ios_device), "#{home}/.mob/cache/") + end + + test "uses different paths for device vs sim" do + System.put_env("MOB_CACHE_DIR", "/tmp/mob_test_cache_mlx") + + try do + refute MLXDownloader.dir(:ios_device) == MLXDownloader.dir(:ios_sim) + after + System.delete_env("MOB_CACHE_DIR") + end + end + end + + # ── name/1, tarball_name/1, download_url/1 ────────────────────────────────── + + describe "name/1" do + test "ios_device" do + assert MLXDownloader.name(:ios_device) == "libmlx-#{MLXDownloader.mlx_version()}-ios-device" + end + + test "ios_sim" do + assert MLXDownloader.name(:ios_sim) == "libmlx-#{MLXDownloader.mlx_version()}-ios-sim" + end + end + + describe "tarball_name/1" do + test "appends .tar.gz" do + assert MLXDownloader.tarball_name(:ios_device) =~ + ~r/^libmlx-\d+\.\d+\.\d+-ios-device\.tar\.gz$/ + end + end + + describe "download_url/1" do + test "points at the mob release surface for the pinned MLX tag" do + url = MLXDownloader.download_url(:ios_device) + + assert String.starts_with?( + url, + "https://github.com/GenericJam/mob/releases/download/" + ) + + assert String.ends_with?(url, ".tar.gz") + assert url =~ MLXDownloader.release_tag() + end + end + + describe "release_tag/0" do + test "embeds the MLX version" do + assert MLXDownloader.release_tag() == "mlx-#{MLXDownloader.mlx_version()}" + end + end + + # ── valid_dir?/1 ──────────────────────────────────────────────────────────── + + describe "valid_dir?/1" do + @tag :tmp_dir + test "returns false when dir doesn't exist", %{tmp_dir: tmp} do + refute MLXDownloader.valid_dir?(Path.join(tmp, "nonexistent")) + end + + @tag :tmp_dir + test "returns false on empty dir", %{tmp_dir: tmp} do + refute MLXDownloader.valid_dir?(tmp) + end + + @tag :tmp_dir + test "returns false when only libmlx.a present", %{tmp_dir: tmp} do + File.mkdir_p!(Path.join(tmp, "lib")) + File.write!(Path.join([tmp, "lib", "libmlx.a"]), "stub") + refute MLXDownloader.valid_dir?(tmp) + end + + @tag :tmp_dir + test "returns false when libemlx.a missing", %{tmp_dir: tmp} do + File.mkdir_p!(Path.join(tmp, "lib")) + File.mkdir_p!(Path.join([tmp, "include", "mlx"])) + File.write!(Path.join([tmp, "lib", "libmlx.a"]), "stub") + File.write!(Path.join(tmp, "VERSION"), "mlx_version=0.25.1") + refute MLXDownloader.valid_dir?(tmp) + end + + @tag :tmp_dir + test "returns true on a complete bundle", %{tmp_dir: tmp} do + stub_complete_bundle(tmp) + assert MLXDownloader.valid_dir?(tmp) + end + end + + # ── ensure/1 against a local tarball (the MOB_MLX_LOCAL_TARBALL_DIR path) ── + + describe "ensure/1 with MOB_MLX_LOCAL_TARBALL_DIR" do + @tag :tmp_dir + test "uses a locally-built tarball when env var is set", %{tmp_dir: tmp} do + # Stage a fake tarball under tmp/, point MOB_MLX_LOCAL_TARBALL_DIR at it, + # and confirm ensure/1 unpacks it into the cache. + tarball_dir = Path.join(tmp, "local") + File.mkdir_p!(tarball_dir) + stage_tarball(tarball_dir, :ios_device) + + cache = Path.join(tmp, "cache") + System.put_env("MOB_CACHE_DIR", cache) + System.put_env("MOB_MLX_LOCAL_TARBALL_DIR", tarball_dir) + + try do + assert {:ok, dir} = MLXDownloader.ensure(:ios_device) + assert MLXDownloader.valid_dir?(dir) + assert File.read!(Path.join(dir, "VERSION")) =~ "mlx_version=" + after + System.delete_env("MOB_CACHE_DIR") + System.delete_env("MOB_MLX_LOCAL_TARBALL_DIR") + end + end + + @tag :tmp_dir + test "returns error when local tarball is missing", %{tmp_dir: tmp} do + empty_dir = Path.join(tmp, "empty") + File.mkdir_p!(empty_dir) + + System.put_env("MOB_CACHE_DIR", Path.join(tmp, "cache")) + System.put_env("MOB_MLX_LOCAL_TARBALL_DIR", empty_dir) + + try do + assert {:error, msg} = MLXDownloader.ensure(:ios_device) + assert msg =~ "MOB_MLX_LOCAL_TARBALL_DIR" + after + System.delete_env("MOB_CACHE_DIR") + System.delete_env("MOB_MLX_LOCAL_TARBALL_DIR") + end + end + + @tag :tmp_dir + test "returns valid cache without re-extracting on second call", %{tmp_dir: tmp} do + tarball_dir = Path.join(tmp, "local") + File.mkdir_p!(tarball_dir) + stage_tarball(tarball_dir, :ios_device) + + cache = Path.join(tmp, "cache") + System.put_env("MOB_CACHE_DIR", cache) + System.put_env("MOB_MLX_LOCAL_TARBALL_DIR", tarball_dir) + + try do + assert {:ok, dir1} = MLXDownloader.ensure(:ios_device) + + # Touch a marker file to detect re-extraction. + marker = Path.join(dir1, "marker.txt") + File.write!(marker, "x") + + assert {:ok, ^dir1} = MLXDownloader.ensure(:ios_device) + assert File.read!(marker) == "x", "second ensure/1 should reuse cache, not re-extract" + after + System.delete_env("MOB_CACHE_DIR") + System.delete_env("MOB_MLX_LOCAL_TARBALL_DIR") + end + end + end + + # ── metallib_path/1 ───────────────────────────────────────────────────────── + + describe "metallib_path/1" do + @tag :tmp_dir + test "returns nil for a CPU-only bundle (no metallib)", %{tmp_dir: tmp} do + stub_complete_bundle(tmp) + assert MLXDownloader.metallib_path(tmp) == nil + end + + @tag :tmp_dir + test "returns the lib/mlx.metallib path when present", %{tmp_dir: tmp} do + stub_complete_bundle(tmp) + metallib = Path.join([tmp, "lib", "mlx.metallib"]) + File.write!(metallib, "stub-metallib") + assert MLXDownloader.metallib_path(tmp) == metallib + end + + test "returns nil for a non-existent dir" do + assert MLXDownloader.metallib_path("/no/such/dir") == nil + end + end + + # ── helpers ───────────────────────────────────────────────────────────────── + + # Build the layout MLXDownloader.valid_dir?/1 expects, directly on disk. + defp stub_complete_bundle(dir) do + File.mkdir_p!(Path.join(dir, "lib")) + File.mkdir_p!(Path.join([dir, "include", "mlx"])) + File.write!(Path.join([dir, "lib", "libmlx.a"]), "stub-mlx-archive") + File.write!(Path.join([dir, "lib", "libemlx.a"]), "stub-emlx-archive") + File.write!(Path.join(dir, "VERSION"), "mlx_version=stub\nvariant=test\n") + end + + # Stage a real tarball with the same name MLXDownloader expects, so + # the local-tarball path can extract + verify_layout it end-to-end. + defp stage_tarball(target_dir, target) do + stage_root = Path.join(target_dir, "stage") + bundle_dir = Path.join(stage_root, MLXDownloader.name(target)) + stub_complete_bundle(bundle_dir) + + tar_out = Path.join(target_dir, MLXDownloader.tarball_name(target)) + + {_, 0} = + System.cmd("tar", ["-czf", tar_out, "-C", stage_root, MLXDownloader.name(target)]) + + File.rm_rf!(stage_root) + tar_out + end +end diff --git a/test/mob_dev/native_build_test.exs b/test/mob_dev/native_build_test.exs new file mode 100644 index 0000000..46c34d8 --- /dev/null +++ b/test/mob_dev/native_build_test.exs @@ -0,0 +1,3584 @@ +defmodule MobDev.NativeBuildTest do + # async: false — a handful of tests in this module mutate process-global + # env vars (`MOB_CACHE_DIR`, `MOB_MLX_LOCAL_TARBALL_DIR`) inside the + # maybe_bundle_mlx_metallib/1 describe block. Running async with + # MobDev.OtpDownloaderTest (which reads MOB_CACHE_DIR via OtpDownloader. + # cache_dir/1) races and surfaces the polluted path as a real assertion + # failure. Whole module is sync; 82 tests in ~200ms — the parallelism + # gain isn't worth the env-var-shared-state hazard. + use ExUnit.Case, async: false + + alias MobDev.NativeBuild + + describe "build_outcome/1" do + test "an empty native target set fails closed" do + assert NativeBuild.build_outcome([]) == %{ + ok?: false, + android_device_disposition: :not_attempted, + android_serials: [], + android_deploy_lock: nil, + android_payload_plan: nil + } + end + + test "one successful platform remains a valid partial multi-platform outcome" do + assert NativeBuild.build_outcome([{:ok, "iOS"}]) == %{ + ok?: true, + android_device_disposition: :not_attempted, + android_serials: [], + android_deploy_lock: nil, + android_payload_plan: nil + } + end + + test "any attempted native platform failure fails the aggregate outcome" do + assert NativeBuild.build_outcome([ + {:ok, "iOS"}, + {:error, "Android", "target unavailable"} + ]) == %{ + ok?: false, + android_device_disposition: :failed, + android_serials: [], + android_deploy_lock: nil, + android_payload_plan: nil + } + end + + test "an artifact-only Android success exposes no targets, lease, or payload plan" do + assert NativeBuild.build_outcome([ + {:ok, "Android", %{serials: [], deploy_lock: nil, payload_plan: nil}} + ]) == %{ + ok?: true, + android_device_disposition: :artifact_only, + android_serials: [], + android_deploy_lock: nil, + android_payload_plan: nil + } + end + + test "an aggregate failure hides the Android payload plan but retains the lease" do + lease = %{state: :native_ready} + plan = %{version: 1} + + assert NativeBuild.build_outcome([ + {:ok, "Android", %{serials: ["serial-a"], deploy_lock: lease, payload_plan: plan}}, + {:error, "iOS", "build failed"} + ]) == %{ + ok?: false, + android_device_disposition: :failed, + android_serials: ["serial-a"], + android_deploy_lock: lease, + android_payload_plan: nil + } + end + + test "a later-platform failure and cleanup failure still return the exact Android lease" do + lease = %{ + bundle_id: "com.example.casein", + owner: "ownerproof000001", + serials: ["serial-a"], + target_digest: String.duplicate("a", 64), + phase: :native_ready, + state: :held_success + } + + plan = %{attempt_id: "planbeam00000001"} + + results = [ + {:ok, "Android", %{serials: ["serial-a"], deploy_lock: lease, payload_plan: plan}}, + {:error, "iOS", "injected later-platform failure"} + ] + + cleanup = fn ^plan -> + send(self(), :aggregate_cleanup_attempted) + {:error, :injected_cleanup_failure} + end + + assert NativeBuild.build_outcome(results, android_preinstall_cleanup: cleanup) == %{ + ok?: false, + android_device_disposition: :failed, + android_serials: ["serial-a"], + android_deploy_lock: lease, + android_payload_plan: nil + } + + assert_received :aggregate_cleanup_attempted + end + + test "reports retained and ambiguous Android authority with a bounded disposition" do + held = native_ready_lease(["serial-a"]) + retained = %{held | state: :retained_ambiguous} + + assert %{ + android_device_disposition: :held, + android_deploy_lock: ^held + } = + NativeBuild.build_outcome([ + {:ok, "Android", + %{ + serials: ["serial-a"], + deploy_lock: held, + payload_plan: %{version: 1} + }} + ]) + + assert %{ + android_device_disposition: :retained, + android_deploy_lock: ^retained + } = + NativeBuild.build_outcome([ + {:error, "Android", "typed failure", retained} + ]) + + assert %{ + android_device_disposition: :retained, + android_deploy_lock: ^held + } = + NativeBuild.build_outcome([ + {:ok, "Android", %{serials: ["serial-a"], deploy_lock: held}}, + {:error, "Android", "duplicate result"} + ]) + end + + test "reports an explicit partial update with the exact retained target set" do + retained = + ["serial-a", "serial-b"] + |> native_ready_lease() + |> Map.merge(%{phase: :acquired, state: :retained_ambiguous}) + + assert NativeBuild.build_outcome([ + {:error, "Android", "runtime delivery failed", retained, :partial_update} + ]) == %{ + ok?: false, + android_device_disposition: :partial_update, + android_serials: ["serial-a", "serial-b"], + android_deploy_lock: retained, + android_payload_plan: nil + } + end + end + + describe "ios_phase_decision/3" do + test "defers iOS for one exact held Android device phase" do + serials = ["serial-a", "serial-b"] + lock = native_ready_lease(serials) + + results = [ + {:ok, "Android", %{serials: serials, deploy_lock: lock, payload_plan: %{version: 1}}} + ] + + assert NativeBuild.ios_phase_decision(results, [:android, :ios], true) == :defer + end + + test "suppresses iOS after an Android device-phase error or invalid held result" do + lock = native_ready_lease(["serial-a"]) + + for results <- [ + [{:error, "Android", "update failed"}], + [{:error, "Android", "update ambiguous", %{lock | state: :retained_ambiguous}}], + [{:ok, "Android", %{serials: ["serial-b"], deploy_lock: lock}}], + [ + {:ok, "Android", %{serials: ["serial-a"], deploy_lock: lock}}, + {:error, "Android", "duplicate result"} + ] + ] do + assert NativeBuild.ios_phase_decision(results, [:android, :ios], true) == :suppress + end + end + + test "preserves iOS for artifact-only work and when Android had no device-phase result" do + android_error = [{:error, "Android", "artifact build failed"}] + + assert NativeBuild.ios_phase_decision(android_error, [:android, :ios], false) == :run + assert NativeBuild.ios_phase_decision([], [:android, :ios], true) == :run + assert NativeBuild.ios_phase_decision([{:ok, "iOS"}], [:android, :ios], true) == :run + assert NativeBuild.ios_phase_decision([], [:android], true) == :skip + end + end + + describe "build_zig_supports_abi?/2" do + test "true when the build.zig declares the ABI as a quoted string literal" do + src = ~s| + if (std.mem.eql(u8, abi, "arm64-v8a")) return "aarch64-linux-android"; + if (std.mem.eql(u8, abi, "x86_64")) return "x86_64-linux-android"; + | + + assert NativeBuild.build_zig_supports_abi?(src, "arm64-v8a") + assert NativeBuild.build_zig_supports_abi?(src, "x86_64") + end + + test "false when the ABI is absent (e.g. a pre-x86_64 mob_new < 0.4.5 build.zig)" do + src = ~s| + if (std.mem.eql(u8, abi, "arm64-v8a")) return "aarch64-linux-android"; + if (std.mem.eql(u8, abi, "armeabi-v7a")) return "arm-linux-androideabi"; + // ERROR: unsupported -Dabi (expected arm64-v8a or armeabi-v7a) + | + + assert NativeBuild.build_zig_supports_abi?(src, "armeabi-v7a") + refute NativeBuild.build_zig_supports_abi?(src, "x86_64") + end + end + + describe "inject_page_size_flag/1" do + test "injects the 16 KB flag into a stale build.zig's -shared link" do + src = ~s| const run = b.addSystemCommand(&.{ ndk_clang, target_arg, "-shared" });| + assert {:patched, out} = NativeBuild.inject_page_size_flag(src) + assert out =~ ~s|"-shared", "-Wl,-z,max-page-size=16384" })| + assert out =~ "max-page-size=16384" + end + + test "patches every -shared link (app .so + sqlite .so)" do + src = ~s| + const run = b.addSystemCommand(&.{ ndk_clang, target_arg, "-shared" }); + const run = b.addSystemCommand(&.{ ndk_clang, target_arg, "-shared" }); + | + + assert {:patched, out} = NativeBuild.inject_page_size_flag(src) + assert length(String.split(out, "max-page-size=16384")) == 3 + end + + test "idempotent — already-aligned build.zig is left unchanged" do + # mirrors how the mob_new template / a hand-fixed app (e.g. Io) carries it + src = ~s| + const run = b.addSystemCommand(&.{ ndk_clang, target_arg, "-shared" }); + run.addArg("-Wl,-z,max-page-size=16384"); + | + + assert {:already, ^src} = NativeBuild.inject_page_size_flag(src) + end + + test "no_match when the -shared link line is unrecognized" do + src = ~s| // a build.zig that does its linking some other way| + assert {:no_match, ^src} = NativeBuild.inject_page_size_flag(src) + end + end + + describe "__driver_tab_formats__/1 + regen_driver_tab!/0" do + test "detects the formats whose generated files exist" do + zig_paths = Mix.Tasks.Mob.RegenDriverTab.target_paths(:zig) + c_paths = Mix.Tasks.Mob.RegenDriverTab.target_paths(:c) + + assert NativeBuild.__driver_tab_formats__(fn _ -> false end) == [] + assert NativeBuild.__driver_tab_formats__(&(&1 == zig_paths.android)) == [:zig] + assert NativeBuild.__driver_tab_formats__(&(&1 == c_paths.ios)) == [:c] + assert NativeBuild.__driver_tab_formats__(fn _ -> true end) == [:zig, :c] + end + + test "rewrites a stale on-disk driver_tab (the :nif_not_loaded footgun)" do + dir = + Path.join(System.tmp_dir!(), "mob_driver_tab_regen_#{System.unique_integer([:positive])}") + + File.mkdir_p!(Path.join(dir, "priv/generated")) + stale_path = Path.join(dir, "priv/generated/driver_tab_android.zig") + File.write!(stale_path, "// stale — generated before a plugin was added\n") + + cwd = File.cwd!() + File.cd!(dir) + + try do + assert :ok = NativeBuild.regen_driver_tab!() + regenerated = File.read!(stale_path) + refute regenerated =~ "stale" + # The zig sibling is regenerated alongside; the c format stays absent. + assert File.exists?(Path.join(dir, "priv/generated/driver_tab_ios.zig")) + refute File.exists?(Path.join(dir, "priv/generated/driver_tab_ios.c")) + after + File.cd!(cwd) + File.rm_rf!(dir) + end + end + + test "leaves a project with no generated driver_tab untouched" do + dir = + Path.join(System.tmp_dir!(), "mob_driver_tab_none_#{System.unique_integer([:positive])}") + + File.mkdir_p!(dir) + cwd = File.cwd!() + File.cd!(dir) + + try do + assert :ok = NativeBuild.regen_driver_tab!() + assert File.ls!(dir) == [] + after + File.cd!(cwd) + File.rm_rf!(dir) + end + end + end + + describe "__merge_android_manifest_components__/2" do + @manifest """ + <manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <application android:label="X"> + <activity android:name=".MainActivity"/> + </application> + </manifest> + """ + + test "splices a component in just before </application>" do + out = + NativeBuild.__merge_android_manifest_components__(@manifest, [ + ~s(<service android:name="io.mob.nfc.MobNfcApduService" android:exported="true"/>) + ]) + + assert out =~ ~s(<service android:name="io.mob.nfc.MobNfcApduService") + # inserted inside <application> (before its close, after the activity) + assert out =~ ~r/MainActivity.*MobNfcApduService.*<\/application>/s + end + + test "is idempotent on the component's android:name" do + snippet = ~s(<service android:name="io.mob.nfc.MobNfcApduService"/>) + once = NativeBuild.__merge_android_manifest_components__(@manifest, [snippet]) + twice = NativeBuild.__merge_android_manifest_components__(once, [snippet]) + assert once == twice + assert length(String.split(once, "MobNfcApduService")) == 2 + end + + test "removing the plugin (empty set) strips the previously-injected region" do + snippet = ~s(<service android:name="io.mob.nfc.MobNfcApduService"/>) + with_svc = NativeBuild.__merge_android_manifest_components__(@manifest, [snippet]) + assert with_svc =~ "MobNfcApduService" + # plugin removed → next build contributes no components → region gone + cleaned = NativeBuild.__merge_android_manifest_components__(with_svc, []) + refute cleaned =~ "MobNfcApduService" + refute cleaned =~ "mob:plugin-components" + assert cleaned == @manifest + end + + test "swapping which plugin is active replaces the component (old one gone)" do + a = + NativeBuild.__merge_android_manifest_components__(@manifest, [ + ~s(<service android:name="io.a.Svc"/>) + ]) + + b = + NativeBuild.__merge_android_manifest_components__(a, [ + ~s(<service android:name="io.b.Svc"/>) + ]) + + assert b =~ "io.b.Svc" + refute b =~ "io.a.Svc" + end + + test "no snippets → manifest unchanged" do + assert NativeBuild.__merge_android_manifest_components__(@manifest, []) == @manifest + end + + test "no </application> → returns manifest untouched rather than corrupting it" do + weird = "<manifest></manifest>" + assert NativeBuild.__merge_android_manifest_components__(weird, ["<service/>"]) == weird + end + + test "preserves nested indentation, shifted into <application>" do + snippet = "<service android:name=\"io.x.S\">\n <intent-filter/>\n</service>" + out = NativeBuild.__merge_android_manifest_components__(@manifest, [snippet]) + assert out =~ " <service android:name=\"io.x.S\">" + assert out =~ " <intent-filter/>" + end + end + + describe "__res_target__/2 (plugin res path containment)" do + @root "android/app/src/main" + + test "a normal res destination resolves to a copy target under res/" do + assert {:ok, "android/app/src/main/res/xml/svc.xml"} = + NativeBuild.__res_target__(@root, "res/xml/svc.xml") + end + + test "a .. traversal that escapes res/ is rejected" do + assert {:error, :escapes_res_dir} = + NativeBuild.__res_target__(@root, "res/../../../build.gradle") + end + + test "a deeper traversal to an arbitrary host path is rejected" do + assert {:error, :escapes_res_dir} = + NativeBuild.__res_target__(@root, "res/../../../../../../etc/hosts") + end + + test "the res dir itself is allowed but a sibling of res/ is not" do + assert {:ok, _} = NativeBuild.__res_target__(@root, "res") + assert {:error, :escapes_res_dir} = NativeBuild.__res_target__(@root, "res/../resx/x") + end + end + + describe "__notify_hub_kotlin__/0" do + test "generated hub is the stable io.mob.plugin seam with the three members" do + src = NativeBuild.__notify_hub_kotlin__() + assert src =~ "package io.mob.plugin" + assert src =~ "object MobNotifyHub" + assert src =~ ~s(const val CHANNEL_ID = "mob_notifications") + assert src =~ "var notifyPid: Long = 0" + assert src =~ "var pendingToken: String? = null" + end + end + + describe "__host_requirements_warning__/1" do + test "no obligations → no warning" do + assert NativeBuild.__host_requirements_warning__([]) == nil + end + + test "renders one line per obligation, tagged with the plugin" do + msg = + NativeBuild.__host_requirements_warning__([ + %{plugin: :mob_screencast, requirement: "add the mediaProjection <service>"}, + %{plugin: :mob_camera, requirement: "declare a FileProvider"} + ]) + + assert msg =~ "manual steps the build can NOT do for you" + assert msg =~ "[mob_screencast] add the mediaProjection <service>" + assert msg =~ "[mob_camera] declare a FileProvider" + end + end + + describe "__elixir_lib_decision__/3 (which Elixir stdlib to bundle)" do + test "missing configured path → detect from running BEAM" do + assert NativeBuild.__elixir_lib_decision__(false, nil, "1.20.0") == + {:use_detected, :missing} + end + + test "matching version → honor the configured path" do + assert NativeBuild.__elixir_lib_decision__(true, "1.20.0", "1.20.0") == + {:use_configured} + end + + test "version skew → fall back to the toolchain's stdlib" do + # The exact bug this guards: mob.exs pinned 1.20.0-rc.5 while the toolchain + # is 1.20.0 final. rc.5's elixir_quote lacks validate_quote/1, so bundling + # it crashes on-device Ecto-migration compilation. + assert NativeBuild.__elixir_lib_decision__(true, "1.20.0-rc.5", "1.20.0") == + {:use_detected, :version_skew} + end + + test "present but unreadable version → honor config (can't prove it wrong)" do + assert NativeBuild.__elixir_lib_decision__(true, nil, "1.20.0") == + {:use_configured} + end + end + + describe "__elixir_lib_skew_warning__/4" do + test "names both versions, the failure mode, and the fallback" do + msg = + NativeBuild.__elixir_lib_skew_warning__( + "/x/1.20.0-rc.5-otp-29/lib", + "1.20.0-rc.5", + "1.20.0", + "/y/1.20.0-otp-29/lib" + ) + + assert msg =~ "1.20.0-rc.5" + assert msg =~ "1.20.0" + assert msg =~ "validate_quote/1" + assert msg =~ "/y/1.20.0-otp-29/lib" + assert msg =~ "mob.exs" + end + end + + describe "otp_dir_for_abi/3" do + test "armeabi-v7a returns the arm32 path" do + assert NativeBuild.otp_dir_for_abi("armeabi-v7a", "/otp/arm64", "/otp/arm32") == + "/otp/arm32" + end + + test "arm64-v8a returns the arm64 path" do + assert NativeBuild.otp_dir_for_abi("arm64-v8a", "/otp/arm64", "/otp/arm32") == + "/otp/arm64" + end + + test "unknown ABI falls back to arm64" do + assert NativeBuild.otp_dir_for_abi("x86_64", "/otp/arm64", "/otp/arm32") == + "/otp/arm64" + end + + test "empty ABI string falls back to arm64" do + assert NativeBuild.otp_dir_for_abi("", "/otp/arm64", "/otp/arm32") == "/otp/arm64" + end + + test "x86_64 returns the x86_64 path when provided" do + assert NativeBuild.otp_dir_for_abi("x86_64", "/otp/arm64", "/otp/arm32", "/otp/x86_64") == + "/otp/x86_64" + end + + test "unknown ABI still falls back to arm64 when x86_64 path is provided" do + assert NativeBuild.otp_dir_for_abi("x86", "/otp/arm64", "/otp/arm32", "/otp/x86_64") == + "/otp/arm64" + end + end + + describe "filter_serials/2" do + @serials [ + "ZY22K6BSJM", + "10.0.0.17:5555", + "10.0.0.82:5555", + "emulator-5554", + "emulator-5556" + ] + + test "nil returns all serials unchanged" do + assert NativeBuild.filter_serials(@serials, nil) == @serials + end + + test "exact serial match" do + assert NativeBuild.filter_serials(@serials, "ZY22K6BSJM") == ["ZY22K6BSJM"] + end + + test "matches wifi-adb serial when given bare IP" do + assert NativeBuild.filter_serials(@serials, "10.0.0.17") == ["10.0.0.17:5555"] + end + + test "matches wifi-adb serial when given full IP:port" do + assert NativeBuild.filter_serials(@serials, "10.0.0.17:5555") == ["10.0.0.17:5555"] + end + + test "matches emulator serial" do + assert NativeBuild.filter_serials(@serials, "emulator-5554") == ["emulator-5554"] + end + + test "non-matching id returns empty list" do + assert NativeBuild.filter_serials(@serials, "NOPE") == [] + end + end + + describe "__project_swift_sources_arg__/1" do + test "joins extra iOS Swift sources as absolute comma-separated paths" do + cwd = File.cwd!() + + assert NativeBuild.__project_swift_sources_arg__( + project_swift_sources: ["ios/Bridge.swift", "../shared/Peer.swift"] + ) == + Enum.join( + [ + Path.expand("ios/Bridge.swift", cwd), + Path.expand("../shared/Peer.swift", cwd) + ], + "," + ) + end + + test "defaults to an empty option value" do + assert NativeBuild.__project_swift_sources_arg__([]) == "" + assert NativeBuild.__project_swift_sources_arg__(project_swift_sources: nil) == "" + end + + test "rejects comma-containing source entries" do + assert_raise Mix.Error, ~r/must not contain commas/, fn -> + NativeBuild.__project_swift_sources_arg__(project_swift_sources: ["a.swift,b.swift"]) + end + end + end + + describe "plugin Kotlin bootstrap helpers" do + test "__parse_kotlin_package__ extracts the FQ package" do + assert NativeBuild.__parse_kotlin_package__( + "package io.mob.bluetooth\n\nobject MobBluetoothBridge {}" + ) == "io.mob.bluetooth" + end + + test "__parse_kotlin_package__ tolerates leading whitespace / comments before it" do + src = "// header\n package io.mob.bluetooth\n" + assert NativeBuild.__parse_kotlin_package__(src) == "io.mob.bluetooth" + end + + test "__parse_kotlin_package__ returns nil when there's no package line" do + assert NativeBuild.__parse_kotlin_package__("object Foo {}") == nil + end + + test "__bridge_kt_dest__ maps package + basename under the java root" do + assert NativeBuild.__bridge_kt_dest__( + "android/app/src/main/java", + "io.mob.bluetooth", + "MobBluetoothBridge.kt" + ) == "android/app/src/main/java/io/mob/bluetooth/MobBluetoothBridge.kt" + end + + test "__bootstrap_kotlin__ emits register() + activity handoff per bridge class" do + src = NativeBuild.__bootstrap_kotlin__(["io.mob.bluetooth.MobBluetoothBridge"]) + assert src =~ "package io.mob.plugin" + assert src =~ "import android.app.Activity" + assert src =~ "object MobPluginBootstrap" + assert src =~ "fun registerAll(activity: Activity)" + assert src =~ "io.mob.bluetooth.MobBluetoothBridge.register()" + assert src =~ "handOff(io.mob.bluetooth.MobBluetoothBridge, activity)" + assert src =~ "collectPermissionProvider(io.mob.bluetooth.MobBluetoothBridge)" + # The cast lives in the Any-typed helper, never inline against a final object. + assert src =~ "private fun handOff(bridge: Any, activity: Activity)" + assert src =~ "(bridge as? MobActivityAware)?.setActivity(activity)" + assert src =~ "private fun collectPermissionProvider(bridge: Any)" + assert src =~ "(bridge as? MobPermissionProvider)?.let {" + refute src =~ "MobBluetoothBridge as? MobActivityAware" + end + + test "__bootstrap_kotlin__ always exposes permissionsFor for core to consult" do + # Present with bridges... + with_bridge = NativeBuild.__bootstrap_kotlin__(["io.mob.location.MobLocationBridge"]) + + assert with_bridge =~ + "private val permissionProviders = mutableListOf<MobPermissionProvider>()" + + assert with_bridge =~ "fun permissionsFor(cap: String): Array<String>?" + + # ...and without (so MobBridge can reference it unconditionally). + empty = NativeBuild.__bootstrap_kotlin__([]) + assert empty =~ "fun permissionsFor(cap: String): Array<String>?" + assert empty =~ "private val permissionProviders = mutableListOf<MobPermissionProvider>()" + end + + test "__bootstrap_kotlin__ hands the activity to every bridge uniformly" do + src = + NativeBuild.__bootstrap_kotlin__([ + "io.mob.bluetooth.MobBluetoothBridge", + "io.mob.zigextras.MobZigExtrasBridge" + ]) + + # One register() + one handOff() per class, no per-plugin branching, and + # exactly one shared helper holding the single cast. + assert length(Regex.scan(~r/\.register\(\)/, src)) == 2 + assert length(Regex.scan(~r/handOff\([\w.]+, activity\)/, src)) == 2 + assert length(Regex.scan(~r/collectPermissionProvider\([\w.]+\)/, src)) == 2 + assert length(Regex.scan(~r/as\? MobActivityAware/, src)) == 1 + assert length(Regex.scan(~r/as\? MobPermissionProvider/, src)) == 1 + end + + test "__bootstrap_kotlin__ emits an empty registerAll body and no register helpers when no bridges" do + src = NativeBuild.__bootstrap_kotlin__([]) + assert src =~ "fun registerAll(activity: Activity) {}" + refute src =~ ".register()" + refute src =~ "handOff" + refute src =~ "collectPermissionProvider" + end + + test "__activity_aware_kotlin__ emits the stable MobActivityAware contract" do + src = NativeBuild.__activity_aware_kotlin__() + assert src =~ "package io.mob.plugin" + assert src =~ "import android.app.Activity" + assert src =~ "interface MobActivityAware {" + assert src =~ "fun setActivity(activity: Activity)" + end + + test "__permission_provider_kotlin__ emits the stable MobPermissionProvider contract" do + src = NativeBuild.__permission_provider_kotlin__() + assert src =~ "package io.mob.plugin" + assert src =~ "interface MobPermissionProvider {" + assert src =~ "fun permissionsFor(cap: String): Array<String>?" + end + end + + describe "__merge_android_permissions__/2" do + @manifest_with_perms """ + <?xml version="1.0" encoding="utf-8"?> + <manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.example.app"> + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.CAMERA" /> + <uses-permission android:name="android.permission.RECORD_AUDIO" /> + + <application + android:label="App"> + </application> + </manifest> + """ + + @manifest_without_perms """ + <?xml version="1.0" encoding="utf-8"?> + <manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.example.app"> + <application + android:label="App"> + </application> + </manifest> + """ + + test "is a no-op when permission list is empty" do + assert NativeBuild.__merge_android_permissions__(@manifest_with_perms, []) == + @manifest_with_perms + end + + test "removing the plugin strips its managed permission region, keeping host perms" do + added = + NativeBuild.__merge_android_permissions__(@manifest_with_perms, [ + "android.permission.BLUETOOTH_CONNECT" + ]) + + assert added =~ "BLUETOOTH_CONNECT" + cleaned = NativeBuild.__merge_android_permissions__(added, []) + refute cleaned =~ "BLUETOOTH_CONNECT" + refute cleaned =~ "mob:plugin-permissions" + # host-declared permissions are untouched + assert cleaned =~ "android.permission.INTERNET" + assert cleaned == @manifest_with_perms + end + + test "a host-declared permission is not duplicated into the managed region" do + out = + NativeBuild.__merge_android_permissions__(@manifest_with_perms, [ + "android.permission.CAMERA" + ]) + + # CAMERA already declared by hand → not added again + assert length(String.split(out, ~s(android:name="android.permission.CAMERA"))) == 2 + end + + test "forward-only: an existing UNFENCED entry is treated as host-authored" do + # Simulates an app an older mob_dev already patched (CAMERA appended + # unfenced). It's indistinguishable from a hand-added permission, so it is + # neither re-added to the fence nor removed when the plugin goes away. + with_plugin = + NativeBuild.__merge_android_permissions__(@manifest_with_perms, [ + "android.permission.CAMERA" + ]) + + assert with_plugin == @manifest_with_perms + refute with_plugin =~ "mob:plugin-permissions" + + # plugin removed → the pre-existing unfenced CAMERA line survives + cleaned = NativeBuild.__merge_android_permissions__(with_plugin, []) + assert cleaned =~ "android.permission.CAMERA" + end + + test "is a no-op when every permission is already declared" do + perms = ["android.permission.CAMERA", "android.permission.INTERNET"] + + assert NativeBuild.__merge_android_permissions__(@manifest_with_perms, perms) == + @manifest_with_perms + end + + test "adds only missing permissions, dedup against existing" do + # Project has INTERNET + CAMERA + RECORD_AUDIO (3 lines). Plugin set + # contributes 4 of which 1 (CAMERA) is already there → expect 3 + 3 = 6 + # uses-permission tags in the result, no duplicates. + perms = [ + "android.permission.CAMERA", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_SCAN", + "android.permission.POST_NOTIFICATIONS" + ] + + result = NativeBuild.__merge_android_permissions__(@manifest_with_perms, perms) + + tags = Regex.scan(~r/<uses-permission android:name="([^"]+)"/, result) + names = Enum.map(tags, fn [_, name] -> name end) + + assert length(names) == 6 + assert Enum.uniq(names) == names + + assert "android.permission.BLUETOOTH_CONNECT" in names + assert "android.permission.BLUETOOTH_SCAN" in names + assert "android.permission.POST_NOTIFICATIONS" in names + end + + test "the managed region sits after host permissions (before <application)" do + perms = ["android.permission.BLUETOOTH_CONNECT"] + result = NativeBuild.__merge_android_permissions__(@manifest_with_perms, perms) + + # Host perms stay first; the fenced plugin perm follows, still before + # <application: INTERNET, CAMERA, RECORD_AUDIO, then BLUETOOTH_CONNECT. + offsets = + for tag <- [ + "android.permission.INTERNET", + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO", + "android.permission.BLUETOOTH_CONNECT" + ], + do: :binary.match(result, tag) |> elem(0) + + assert offsets == Enum.sort(offsets) + end + + test "inserts before <application when manifest has no existing permissions" do + perms = ["android.permission.CAMERA"] + result = NativeBuild.__merge_android_permissions__(@manifest_without_perms, perms) + + assert String.contains?( + result, + ~s(<uses-permission android:name="android.permission.CAMERA" />) + ) + + perm_idx = :binary.match(result, "android.permission.CAMERA") |> elem(0) + app_idx = :binary.match(result, "<application") |> elem(0) + assert perm_idx < app_idx + end + + test "is idempotent — running twice gives the same result" do + perms = ["android.permission.BLUETOOTH_CONNECT", "android.permission.BLUETOOTH_SCAN"] + once = NativeBuild.__merge_android_permissions__(@manifest_with_perms, perms) + twice = NativeBuild.__merge_android_permissions__(once, perms) + assert once == twice + end + end + + describe "__merge_gradle_deps__/2" do + @gradle """ + plugins { + id 'com.android.application' + } + + android { + namespace 'com.example.app' + } + + dependencies { + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.camera:camera-camera2:1.3.4' + } + """ + + test "is a no-op when dep list is empty" do + assert NativeBuild.__merge_gradle_deps__(@gradle, []) == @gradle + end + + test "removing the plugin strips its managed dep region, keeping host deps" do + added = NativeBuild.__merge_gradle_deps__(@gradle, ["com.example:foo:1.0.0"]) + assert added =~ "com.example:foo:1.0.0" + # region lives inside the dependencies block + assert added =~ ~r/dependencies\s*\{.*mob:plugin-deps.*\}/s + cleaned = NativeBuild.__merge_gradle_deps__(added, []) + refute cleaned =~ "com.example:foo:1.0.0" + refute cleaned =~ "mob:plugin-deps" + assert cleaned =~ "androidx.appcompat:appcompat:1.6.1" + assert cleaned == @gradle + end + + test "is a no-op when every dep is already present" do + deps = ["androidx.appcompat:appcompat:1.6.1", "androidx.camera:camera-camera2:1.3.4"] + assert NativeBuild.__merge_gradle_deps__(@gradle, deps) == @gradle + end + + test "adds only missing deps inside the dependencies block" do + deps = [ + "com.github.PhilJay:MPAndroidChart:v3.1.0", + "androidx.appcompat:appcompat:1.6.1", + "com.example:foo:1.0.0" + ] + + result = NativeBuild.__merge_gradle_deps__(@gradle, deps) + + assert String.contains?( + result, + ~s(implementation "com.github.PhilJay:MPAndroidChart:v3.1.0") + ) + + assert String.contains?(result, ~s(implementation "com.example:foo:1.0.0")) + + # Existing appcompat dep stays its original form — no duplicate. + appcompat_count = + Regex.scan(~r/androidx\.appcompat:appcompat:1\.6\.1/, result) |> length() + + assert appcompat_count == 1 + end + + test "inserts inside the dependencies block (before its closing brace)" do + deps = ["com.example:foo:1.0.0"] + result = NativeBuild.__merge_gradle_deps__(@gradle, deps) + + # The new implementation line lives between `dependencies {` and the next + # closing `}` — not floating at end-of-file. + [{deps_open, _}] = Regex.run(~r/dependencies\s*\{/, result, return: :index) + foo_idx = :binary.match(result, "com.example:foo:1.0.0") |> elem(0) + # Find the closing brace of the dependencies block (first `}` after deps_open). + close_idx = + (binary_part(result, deps_open, byte_size(result) - deps_open) + |> :binary.match("}") + |> elem(0)) + deps_open + + assert deps_open < foo_idx + assert foo_idx < close_idx + end + + test "is idempotent — running twice gives the same result" do + deps = ["com.github.PhilJay:MPAndroidChart:v3.1.0"] + once = NativeBuild.__merge_gradle_deps__(@gradle, deps) + twice = NativeBuild.__merge_gradle_deps__(once, deps) + assert once == twice + end + + test "falls back to appending a fresh dependencies block when none exists" do + content = """ + plugins { + id 'com.android.application' + } + """ + + result = + NativeBuild.__merge_gradle_deps__(content, ["com.example:foo:1.0.0"]) + + assert String.contains?(result, "dependencies {") + assert String.contains?(result, ~s(implementation "com.example:foo:1.0.0")) + end + end + + describe "read_sdk_dir/1" do + setup do + tmp = + Path.join( + System.tmp_dir!(), + "mob_native_build_test_#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(Path.join(tmp, "android")) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, project: tmp} + end + + test "returns {:ok, dir} when sdk.dir is set", %{project: project} do + File.write!( + Path.join([project, "android", "local.properties"]), + "sdk.dir=/opt/Android/sdk\n" + ) + + assert {:ok, "/opt/Android/sdk"} = NativeBuild.read_sdk_dir(project) + end + + test "trims trailing whitespace and resolves ~", %{project: project} do + home = System.user_home!() + + File.write!( + Path.join([project, "android", "local.properties"]), + "sdk.dir=~/Library/Android/sdk \n" + ) + + assert {:ok, dir} = NativeBuild.read_sdk_dir(project) + assert dir == Path.expand("~/Library/Android/sdk") + assert String.starts_with?(dir, home) + end + + test "returns :error when local.properties is missing", %{project: project} do + assert :error = NativeBuild.read_sdk_dir(project) + end + + test "returns :error when local.properties has no sdk.dir line", %{project: project} do + File.write!( + Path.join([project, "android", "local.properties"]), + "# placeholder\nsome.other=value\n" + ) + + assert :error = NativeBuild.read_sdk_dir(project) + end + end + + describe "android_toolchain_available?/1" do + setup do + tmp = + Path.join( + System.tmp_dir!(), + "mob_native_build_test_#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(Path.join(tmp, "android")) + + sdk_dir = Path.join(tmp, "fake_sdk") + File.mkdir_p!(sdk_dir) + + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, project: tmp, sdk_dir: sdk_dir} + end + + test "false when local.properties is missing", %{project: project} do + refute NativeBuild.android_toolchain_available?(project) + end + + test "false when sdk.dir points at a missing directory", %{project: project} do + File.write!( + Path.join([project, "android", "local.properties"]), + "sdk.dir=/nonexistent/path/to/sdk\n" + ) + + refute NativeBuild.android_toolchain_available?(project) + end + + test "true requires adb on PATH plus an existing sdk.dir", %{ + project: project, + sdk_dir: sdk_dir + } do + File.write!( + Path.join([project, "android", "local.properties"]), + "sdk.dir=#{sdk_dir}\n" + ) + + expected = System.find_executable("adb") != nil + assert NativeBuild.android_toolchain_available?(project) == expected + end + end + + describe "ios_toolchain_available?/0" do + test "matches the actual macOS + xcrun status of the host" do + macos? = match?({:unix, :darwin}, :os.type()) + xcrun? = System.find_executable("xcrun") != nil + assert NativeBuild.ios_toolchain_available?() == (macos? and xcrun?) + end + end + + # ── narrow_platforms_for_device/2 ───────────────────────────────────────── + # + # Regression-critical helper. The bug timeline this guards against: + # + # - 0.3.16/0.3.17: `ios_physical_udid?/1` matched by UDID format only, so + # sim UDIDs were classified physical → device build → installer crash. + # + # - 0.3.18: predicate fixed (uses Discovery.IOS.list_devices/0). But the + # narrowing in `build_all/1` was `not ios_physical_udid? -> drop iOS`. + # With the fix, sim UDIDs returned false → iOS got stripped → no + # sim build, silent "No native build targets found" message. + # + # - 0.3.19: replaced narrowing with `ios_device?/1` (matches sim or + # physical via discovery). Extracted to public `narrow_platforms_for_device/2` + # in 0.3.21 so the deployer can reuse the same call site. + # + # We test against values that don't appear in the local discovery so the + # behaviour is reproducible regardless of which devices happen to be + # connected when the tests run. The format-only fallback in + # `ios_physical_udid?/1` covers the discovery-empty case for these. + + describe "narrow_platforms_for_device/2 and /3" do + # Tests inject an empty discovery list so the format-only fallback + # paths (ios_physical_udid?/1) are exercised without the LAN EPMD + # scan in IOS.list_devices/0 — that scan can take 60s+ in busy + # network environments and dominates the test runtime. + + test "returns platforms unchanged when device_id is nil" do + assert NativeBuild.narrow_platforms_for_device([:android, :ios], nil, no_devices()) == + [:android, :ios] + end + + test "drops Android when device id is a 40-char physical iOS UDID" do + # Old-style iPhone UDID (pre-Apple Silicon). Format-check fallback + # picks this up even when not in the discovery list. + udid = "abcdef0123456789abcdef0123456789abcdef01" + + assert NativeBuild.narrow_platforms_for_device([:android, :ios], udid, no_devices()) == + [:ios] + end + + test "drops Android when device id is a 8-16 short physical iOS UDID" do + # Modern Apple Silicon iPhone UDID format. + udid = "00008110-001E1C3A34F8401E" + + assert NativeBuild.narrow_platforms_for_device([:android, :ios], udid, no_devices()) == + [:ios] + end + + test "drops iOS when device id is an Android serial" do + # Real Moto E serial form — letters + digits, no UUID structure. + assert NativeBuild.narrow_platforms_for_device( + [:android, :ios], + "ZY22CRLMWK", + no_devices() + ) == [:android] + + assert NativeBuild.narrow_platforms_for_device( + [:android, :ios], + "emulator-5554", + no_devices() + ) == [:android] + end + + test "drops iOS when device id is an Android adb-over-WiFi address" do + assert NativeBuild.narrow_platforms_for_device( + [:android, :ios], + "10.0.0.17:5555", + no_devices() + ) == [:android] + end + + test "returns empty list when device id contradicts explicit platform" do + # User passed `--android` + an iOS device id. The narrowing strips + # Android (because the id is iOS), and there's no iOS in the list to + # build/deploy — so the result is empty. That's the correct safety + # behaviour: don't silently flip to iOS when the user explicitly + # asked for Android only. + udid = "00008110-001E1C3A34F8401E" + assert NativeBuild.narrow_platforms_for_device([:android], udid, no_devices()) == [] + + # Mirror case: --ios + Android serial → iOS gets stripped, empty. + assert NativeBuild.narrow_platforms_for_device([:ios], "ZY22CRLMWK", no_devices()) == [] + end + + test "preserves order of remaining platforms when narrowing" do + # The list-subtraction implementation preserves the order of the + # remaining elements. Pin that so future refactors that reach for + # MapSet/Enum-based dedup don't accidentally re-order the outputs. + assert NativeBuild.narrow_platforms_for_device( + [:ios, :android], + "ZY22CRLMWK", + no_devices() + ) == [:android] + + assert NativeBuild.narrow_platforms_for_device( + [:android, :ios], + "ZY22CRLMWK", + no_devices() + ) == [:android] + end + + test "discovery hit on a sim UDID drops Android (even when format is ambiguous)" do + # Simulator UDIDs use the same 36-char UUID format as physical + # devices, so we *must* consult discovery to disambiguate. With + # the device present in discovery as type :simulator, the iOS + # branch is taken via Device.match_id?/2 — not the physical-UDID + # format fallback (which would also return true here, but for the + # wrong reason). + sim_udid = "12345678-ABCD-1234-ABCD-1234567890AB" + + sim = %MobDev.Device{ + platform: :ios, + type: :simulator, + serial: sim_udid, + name: "iPhone 17", + status: :discovered + } + + assert NativeBuild.narrow_platforms_for_device( + [:android, :ios], + sim_udid, + fn -> [sim] end + ) == [:ios] + end + + test "discovery hit by display_id (8-char prefix) still drops Android" do + # `mix mob.devices` prints a short display id (first 8 chars of + # the sim UDID). Users sometimes paste that to --device. Device.match_id?/2 + # accepts it, so the discovery branch fires. + sim_udid = "12345678-ABCD-1234-ABCD-1234567890AB" + + sim = %MobDev.Device{ + platform: :ios, + type: :simulator, + serial: sim_udid, + name: "iPhone 17", + status: :discovered + } + + assert NativeBuild.narrow_platforms_for_device( + [:android, :ios], + "12345678", + fn -> [sim] end + ) == [:ios] + end + + test "/2 form delegates to /3 with the real iOS discovery (smoke check)" do + # Don't exercise the network — just confirm the no-op nil branch + # still works through the public 2-arity entry that real callers + # use (mix mob.deploy, native_build.build_all). + assert NativeBuild.narrow_platforms_for_device([:android, :ios], nil) == + [:android, :ios] + end + end + + describe "fallback_entitlements_plist/3" do + test "contains application-identifier and team-identifier" do + xml = NativeBuild.fallback_entitlements_plist("TEAM1", "com.example.app") + assert xml =~ "<string>TEAM1.com.example.app</string>" + assert xml =~ "<string>TEAM1</string>" + end + + test "contains get-task-allow" do + xml = NativeBuild.fallback_entitlements_plist("T", "com.x.y") + assert xml =~ "<key>get-task-allow</key>" + assert xml =~ "<true/>" + end + + test "omits aps-environment when not given" do + xml = NativeBuild.fallback_entitlements_plist("T", "com.x.y") + refute xml =~ "aps-environment" + end + + test "omits aps-environment when nil is explicit" do + xml = NativeBuild.fallback_entitlements_plist("T", "com.x.y", nil) + refute xml =~ "aps-environment" + end + + test "includes aps-environment development when given" do + xml = + NativeBuild.fallback_entitlements_plist("Q89CW299G8", "com.mob.pushlab", "development") + + assert xml =~ "<key>aps-environment</key>" + assert xml =~ "<string>development</string>" + end + + test "includes aps-environment production when given" do + xml = NativeBuild.fallback_entitlements_plist("Q89CW299G8", "com.mob.pushlab", "production") + assert xml =~ "<key>aps-environment</key>" + assert xml =~ "<string>production</string>" + end + + test "output is well-formed XML with a plist root" do + xml = NativeBuild.fallback_entitlements_plist("T", "com.x.y", "development") + assert xml =~ ~s(<?xml version="1.0" encoding="UTF-8"?>) + assert xml =~ "<plist version=" + assert xml =~ "</plist>" + assert xml =~ "<dict>" + assert xml =~ "</dict>" + end + + test "application-identifier key precedes aps-environment key" do + xml = NativeBuild.fallback_entitlements_plist("T", "com.x.y", "development") + app_id_pos = :binary.match(xml, "application-identifier") |> elem(0) + aps_pos = :binary.match(xml, "aps-environment") |> elem(0) + assert app_id_pos < aps_pos + end + end + + # Stub iOS lister: returns no devices so tests exercise the + # format-only fallback without hitting `MobDev.Discovery.IOS.list_devices/0`. + defp no_devices, do: fn -> [] end + + # ── Pythonx integration ──────────────────────────────────────────────────── + + describe "dep detection (deps_paths, not stale _build dirs — the MLX-404 lesson)" do + test "__dep_in_project__/2 keys on deps_paths" do + assert NativeBuild.__dep_in_project__(%{pythonx: "/deps/pythonx"}, :pythonx) + refute NativeBuild.__dep_in_project__(%{}, :pythonx) + end + + @tag :tmp_dir + test "a leftover _build/dev/lib/<dep> dir no longer counts", %{tmp_dir: tmp} do + File.mkdir_p!(Path.join([tmp, "_build", "dev", "lib", "pythonx", "ebin"])) + # mob_dev itself deps neither pythonx nor emlx; the stale dir is ignored. + refute NativeBuild.pythonx_in_project?(tmp) + refute NativeBuild.emlx_in_project?(tmp) + end + end + + describe "python_apple_support_env/2" do + test "returns empty list when pythonx not in project" do + assert NativeBuild.python_apple_support_env(false, "/some/path") == [] + end + + test "returns PYTHON_APPLE_SUPPORT env var when pythonx is in project" do + assert NativeBuild.python_apple_support_env(true, "/path/to/extracted") == [ + {"PYTHON_APPLE_SUPPORT", "/path/to/extracted"} + ] + end + end + + # build_device.sh script generation removed in Phase 2 iter 13c — iOS + # device build glue (mix compile, BEAM copies, NIF cross-compile, Pythonx + # framework, EPMD patch, enif_keepalive, build_device.zig invocation) all + # flow through MobDev.NativeBuild helpers now. The Pythonx detection + # (`pythonx_in_project?/1` + `python_apple_support_env/2`) is still public + # and tested in the surrounding describe block. + + describe "install_exqlite_decision/2" do + setup do + tmp = + Path.join(System.tmp_dir!(), "mob_exqlite_decision_#{System.unique_integer([:positive])}") + + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp} + end + + test "no version → :noop (project doesn't depend on exqlite)", %{tmp: tmp} do + assert NativeBuild.install_exqlite_decision(nil, tmp) == :noop + end + + test "version locked + .app present → {:install, vsn}", %{tmp: tmp} do + File.write!(Path.join(tmp, "exqlite.app"), "{application, exqlite, []}.") + + assert NativeBuild.install_exqlite_decision("0.36.0", tmp) == {:install, "0.36.0"} + end + + test "version locked but .app missing → :stale (stale mix.lock guard)", %{tmp: tmp} do + # Regression for pigeon's iOS-device deploy: mix.lock had exqlite + # left over from a long-removed ecto_sqlite3 dep, but + # _build/dev/lib/exqlite/ebin was never populated. The old code + # crashed in File.cp!; the new code returns :stale and the + # caller skips cleanly. + refute File.exists?(Path.join(tmp, "exqlite.app")) + + assert NativeBuild.install_exqlite_decision("0.36.0", tmp) == :stale + end + end + + describe "wheel_has_native_extension?/1" do + setup do + tmp = Path.join(System.tmp_dir!(), "mob_wheel_native_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp} + end + + test "returns false for a pure-Python wheel directory", %{tmp: tmp} do + wheel = Path.join(tmp, "purepy") + File.mkdir_p!(Path.join(wheel, "pkg")) + File.write!(Path.join([wheel, "pkg", "__init__.py"]), "") + File.write!(Path.join([wheel, "pkg", "thing.py"]), "x = 1\n") + + refute NativeBuild.wheel_has_native_extension?(wheel) + end + + test "returns true for a wheel containing a top-level .so", %{tmp: tmp} do + wheel = Path.join(tmp, "cffi") + File.mkdir_p!(wheel) + File.write!(Path.join(wheel, "_cffi_backend.so"), <<0>>) + + assert NativeBuild.wheel_has_native_extension?(wheel) + end + + test "returns true for a .so nested several directories deep", %{tmp: tmp} do + wheel = Path.join(tmp, "cryptography") + File.mkdir_p!(Path.join([wheel, "cryptography", "hazmat", "bindings"])) + File.write!(Path.join([wheel, "cryptography", "hazmat", "bindings", "_rust.so"]), <<0>>) + + assert NativeBuild.wheel_has_native_extension?(wheel) + end + + test "returns false for an empty wheel directory", %{tmp: tmp} do + wheel = Path.join(tmp, "empty") + File.mkdir_p!(wheel) + + refute NativeBuild.wheel_has_native_extension?(wheel) + end + end + + describe "copy_ios_safe_project_python_wheels/2" do + setup do + tmp = Path.join(System.tmp_dir!(), "mob_wheel_copy_#{System.unique_integer([:positive])}") + wheels_dir = Path.join(tmp, "wheels") + python_root = Path.join(tmp, "python") + File.mkdir_p!(wheels_dir) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp, wheels_dir: wheels_dir, python_root: python_root} + end + + test "copies pure-Python wheels into site-packages", %{ + wheels_dir: wheels_dir, + python_root: python_root + } do + seed_pure_wheel(wheels_dir, "rns") + seed_pure_wheel(wheels_dir, "lxmf") + + assert :ok = NativeBuild.copy_ios_safe_project_python_wheels(python_root, wheels_dir) + + site_packages = Path.join([python_root, "lib", "python3.13", "site-packages"]) + assert File.dir?(Path.join(site_packages, "rns")) + assert File.dir?(Path.join(site_packages, "lxmf")) + assert File.read!(Path.join([site_packages, "rns", "marker.txt"])) == "from rns\n" + end + + test "skips wheels containing native .so extensions", %{ + wheels_dir: wheels_dir, + python_root: python_root + } do + seed_pure_wheel(wheels_dir, "rns") + seed_native_wheel(wheels_dir, "cffi") + seed_native_wheel(wheels_dir, "cryptography") + + ExUnit.CaptureIO.capture_io(fn -> + NativeBuild.copy_ios_safe_project_python_wheels(python_root, wheels_dir) + end) + + site_packages = Path.join([python_root, "lib", "python3.13", "site-packages"]) + assert File.dir?(Path.join(site_packages, "rns")) + refute File.dir?(Path.join(site_packages, "cffi")) + refute File.dir?(Path.join(site_packages, "cryptography")) + end + + test "logs skip and copy decisions", %{ + wheels_dir: wheels_dir, + python_root: python_root + } do + seed_pure_wheel(wheels_dir, "lxmf") + seed_native_wheel(wheels_dir, "cryptography") + + output = + ExUnit.CaptureIO.capture_io(fn -> + NativeBuild.copy_ios_safe_project_python_wheels(python_root, wheels_dir) + end) + + assert output =~ "[ios-wheels] copied lxmf" + assert output =~ "[ios-wheels] skipped wheel with native extensions" + assert output =~ "cryptography" + end + + test "ignores non-directory entries (stray files) in the wheels dir", %{ + wheels_dir: wheels_dir, + python_root: python_root + } do + seed_pure_wheel(wheels_dir, "rns") + File.write!(Path.join(wheels_dir, "README.md"), "not a wheel\n") + + assert :ok = NativeBuild.copy_ios_safe_project_python_wheels(python_root, wheels_dir) + + site_packages = Path.join([python_root, "lib", "python3.13", "site-packages"]) + assert File.dir?(Path.join(site_packages, "rns")) + refute File.exists?(Path.join(site_packages, "README.md")) + end + + test "is a no-op when wheels_dir does not exist", %{python_root: python_root, tmp: tmp} do + missing = Path.join(tmp, "no_such_dir") + + assert :ok = NativeBuild.copy_ios_safe_project_python_wheels(python_root, missing) + + refute File.exists?(Path.join([python_root, "lib"])) + end + + test "creates site-packages even when wheels_dir is empty", %{ + wheels_dir: wheels_dir, + python_root: python_root + } do + assert :ok = NativeBuild.copy_ios_safe_project_python_wheels(python_root, wheels_dir) + + site_packages = Path.join([python_root, "lib", "python3.13", "site-packages"]) + assert File.dir?(site_packages) + end + end + + defp seed_pure_wheel(wheels_dir, name) do + pkg = Path.join([wheels_dir, name, name]) + File.mkdir_p!(pkg) + File.write!(Path.join(pkg, "__init__.py"), "") + File.write!(Path.join(pkg, "marker.txt"), "from #{name}\n") + end + + defp seed_native_wheel(wheels_dir, name) do + pkg = Path.join([wheels_dir, name, name]) + File.mkdir_p!(pkg) + File.write!(Path.join(pkg, "__init__.py"), "") + File.write!(Path.join(pkg, "_ext.so"), <<0xCA, 0xFE, 0xBA, 0xBE>>) + end + + # ── resolve_booted_udid/2 ─────────────────────────────────────────────── + # + # Regression: `mix mob.deploy --native --device defd4bdc` failed at + # `xcrun simctl install defd4bdc <app>` with `Invalid device: + # defd4bdc` because the prefix was passed straight through to simctl, + # which only accepts full UDIDs. The lookup now resolves any + # case-insensitive prefix against the booted-sim list. + + describe "resolve_booted_udid/2" do + # Shape matches `xcrun simctl list devices booted -j` output's + # top-level "devices" map (string keys = runtime IDs, value = + # list of sim dicts). + defp by_runtime do + %{ + "com.apple.CoreSimulator.SimRuntime.iOS-26-4" => [ + %{ + "udid" => "8A4250E9-B675-49CA-B143-A6C6D89B22AB", + "name" => "iPhone 17 Pro", + "state" => "Booted", + "isAvailable" => true + }, + %{ + "udid" => "DEFD4BDC-CA42-4CD2-93A1-62BE425E7A78", + "name" => "iPhone 11 Pro Max", + "state" => "Booted", + "isAvailable" => true + } + ] + } + end + + test "nil device_id requires exactly one booted simulator" do + assert NativeBuild.resolve_booted_udid(by_runtime(), nil) == nil + + [first | _rest] = by_runtime()["com.apple.CoreSimulator.SimRuntime.iOS-26-4"] + + assert NativeBuild.resolve_booted_udid(%{"iOS" => [first]}, nil) == + "8A4250E9-B675-49CA-B143-A6C6D89B22AB" + end + + test "8-char lowercase prefix matches the full UDID (user's repro)" do + assert NativeBuild.resolve_booted_udid(by_runtime(), "defd4bdc") == + "DEFD4BDC-CA42-4CD2-93A1-62BE425E7A78" + end + + test "8-char uppercase prefix also matches (case-insensitive)" do + assert NativeBuild.resolve_booted_udid(by_runtime(), "DEFD4BDC") == + "DEFD4BDC-CA42-4CD2-93A1-62BE425E7A78" + end + + test "full UDID passes through unchanged" do + full = "DEFD4BDC-CA42-4CD2-93A1-62BE425E7A78" + assert NativeBuild.resolve_booted_udid(by_runtime(), full) == full + end + + test "no match → nil" do + assert NativeBuild.resolve_booted_udid(by_runtime(), "12345678") == nil + end + + test "ambiguous prefixes and duplicate entries fail closed" do + collision = %{ + "iOS" => [ + %{"udid" => "DEFD4BDC-1111-4CD2-93A1-62BE425E7A78", "state" => "Booted"}, + %{"udid" => "DEFD4BDC-2222-4CD2-93A1-62BE425E7A78", "state" => "Booted"} + ] + } + + assert NativeBuild.resolve_booted_udid(collision, "defd4bdc") == nil + + duplicate = %{ + "iOS" => [ + %{"udid" => "DEFD4BDC-CA42-4CD2-93A1-62BE425E7A78", "state" => "Booted"}, + %{"udid" => "DEFD4BDC-CA42-4CD2-93A1-62BE425E7A78", "state" => "Booted"} + ] + } + + assert NativeBuild.resolve_booted_udid(duplicate, "defd4bdc") == nil + end + + test "malformed inventories and identifiers fail closed" do + invalid_utf8 = <<255>> + + malformed = [ + nil, + %{"iOS" => :not_a_device_list}, + %{"iOS" => [%{}]}, + %{"iOS" => [%{"state" => "Unknown", "udid" => "VALID"}]}, + %{"iOS" => [%{"state" => "Booted"}]}, + %{"iOS" => [%{"state" => "Shutdown"}]}, + %{"iOS" => [%{"udid" => nil, "state" => "Booted"}]}, + %{"iOS" => [%{"udid" => "", "state" => "Booted"}]}, + %{"iOS" => [%{"udid" => invalid_utf8, "state" => "Booted"}]}, + %{"iOS" => [%{"udid" => "VALID", "state" => "Booted"} | :improper_tail]} + ] + + Enum.each(malformed, fn inventory -> + assert NativeBuild.resolve_booted_udid(inventory, nil) == nil + end) + + assert NativeBuild.resolve_booted_udid( + %{"iOS" => [%{"udid" => "VALID", "state" => "Booted"}]}, + "" + ) == nil + + assert NativeBuild.resolve_booted_udid( + %{"iOS" => [%{"udid" => "VALID", "state" => "Booted"}]}, + invalid_utf8 + ) == nil + end + + test "empty booted list + nil device_id → nil" do + assert NativeBuild.resolve_booted_udid(%{}, nil) == nil + end + + test "empty booted list + given device_id → nil" do + assert NativeBuild.resolve_booted_udid(%{}, "defd4bdc") == nil + end + + test "shutdown sims are filtered out even if their UDID prefix matches" do + # Defensive: simctl's `booted` filter already excludes shutdown + # sims, but pin our own filter in case the caller passes a + # broader listing. + runtime = %{ + "iOS" => [ + %{ + "udid" => "DEFD4BDC-CA42-4CD2-93A1-62BE425E7A78", + "name" => "iPhone 11 Pro Max", + "state" => "Shutdown" + } + ] + } + + assert NativeBuild.resolve_booted_udid(runtime, "defd4bdc") == nil + end + end + + describe "generate_erl_errno_compat_stub/1" do + # This shim is load-bearing for iOS device builds — the link will + # fail with `Undefined symbols: _erl_errno_id_unknown` without it. + # See the function's docstring for the full diagnosis. The tests + # below exist specifically so an agent (or human) who concludes + # "this shim looks obsolete" hits a red test rather than a + # broken iOS device build. + + setup do + build_dir = + Path.join(System.tmp_dir!(), "errno_compat_test_#{System.unique_integer([:positive])}") + + File.mkdir_p!(build_dir) + on_exit(fn -> File.rm_rf!(build_dir) end) + {:ok, build_dir: build_dir} + end + + test "writes erl_errno_id_compat.c into the build dir", %{build_dir: build_dir} do + assert :ok = NativeBuild.generate_erl_errno_compat_stub(build_dir) + assert File.exists?(Path.join(build_dir, "erl_errno_id_compat.c")) + end + + test "the shim defines erl_errno_id_unknown weakly", %{build_dir: build_dir} do + :ok = NativeBuild.generate_erl_errno_compat_stub(build_dir) + contents = File.read!(Path.join(build_dir, "erl_errno_id_compat.c")) + + # `weak` is what lets a future OTP tarball that ships the real + # symbol take precedence without a duplicate-symbol error. If + # this assertion is failing because someone changed it to a + # strong definition, that breaks the forward-compatibility path. + assert contents =~ "__attribute__((weak))" + assert contents =~ "erl_errno_id_unknown" + end + + test "the shim returns a non-empty string so callers see a valid C-string", %{ + build_dir: build_dir + } do + :ok = NativeBuild.generate_erl_errno_compat_stub(build_dir) + contents = File.read!(Path.join(build_dir, "erl_errno_id_compat.c")) + + # The return value flows through BEAM error reporting (errno + # → atom). Returning NULL would crash the formatter. + assert contents =~ ~s|return "unknown"| + end + end + + describe "classify_project_nif/2" do + # Pins the source-classification logic that decides whether a + # project-side NIF gets the C wiring path, the Rust cross-compile + + # link path, or no native wiring at all (Elixir-only stub). Issue #18. + # + # The 2-arg form takes the project root explicitly so tests don't + # have to File.cd! (which mutates global OS-process state and races + # with other async tests). + + setup do + tmp = Path.join(System.tmp_dir!(), "classify_nif_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp} + end + + test "finds C source at c_src/<name>.c", %{tmp: tmp} do + c_path = Path.join(tmp, "c_src/foo.c") + File.mkdir_p!(Path.dirname(c_path)) + File.write!(c_path, "") + + assert {:c, ^c_path} = NativeBuild.classify_project_nif(%{module: :foo}, tmp) + end + + test "finds Rust manifest at native/<name>/Cargo.toml", %{tmp: tmp} do + cargo_path = Path.join(tmp, "native/foo/Cargo.toml") + File.mkdir_p!(Path.dirname(cargo_path)) + File.write!(cargo_path, "") + + assert {:rust, ^cargo_path} = NativeBuild.classify_project_nif(%{module: :foo}, tmp) + end + + test "C wins if both exist (user has explicitly written C)", %{tmp: tmp} do + File.mkdir_p!(Path.join(tmp, "c_src")) + File.mkdir_p!(Path.join(tmp, "native/foo")) + File.write!(Path.join(tmp, "c_src/foo.c"), "") + File.write!(Path.join(tmp, "native/foo/Cargo.toml"), "") + + assert {:c, _} = NativeBuild.classify_project_nif(%{module: :foo}, tmp) + end + + test "elixir_only when neither C nor Rust source exists", %{tmp: tmp} do + # Stub-only NIF (the `--type elixir-only` from mob.add_nif). + # No native wiring — the Elixir module just raises nif_error. + assert :elixir_only = NativeBuild.classify_project_nif(%{module: :no_native}, tmp) + end + end + + describe "project_nif_zig_args/1" do + setup do + tmp = Path.join(System.tmp_dir!(), "project_nif_args_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + + old_static_nifs = Application.get_env(:mob_dev, :static_nifs) + cwd = File.cwd!() + + on_exit(fn -> + if is_nil(old_static_nifs) do + Application.delete_env(:mob_dev, :static_nifs) + else + Application.put_env(:mob_dev, :static_nifs, old_static_nifs) + end + + File.cd!(cwd) + File.rm_rf!(tmp) + end) + + File.cd!(tmp) + :ok + end + + test "adds per-ABI extra static archives to project_rust_libs and emits guard flag" do + Application.put_env(:mob_dev, :static_nifs, [ + %{ + module: :ghostty_vt, + archs: [:android_arm64], + guard: "MOB_STATIC_GHOSTTY_VT_NIF", + extra_static_libs: %{ + android_arm64: "native/ghostty_vt/lib-android-arm64/libghostty-vt.a" + } + } + ]) + + assert {:ok, args} = NativeBuild.project_nif_zig_args(:android_arm64) + + expected_lib = Path.expand("native/ghostty_vt/lib-android-arm64/libghostty-vt.a") + assert "-Dproject_rust_libs=#{expected_lib}" in args + assert "-Dghostty_vt_static=true" in args + end + + test "does not add extra static archives or guard flags on non-matching ABIs" do + Application.put_env(:mob_dev, :static_nifs, [ + %{ + module: :ghostty_vt, + archs: [:android_arm64], + guard: "MOB_STATIC_GHOSTTY_VT_NIF", + extra_static_libs: %{ + android_arm64: "native/ghostty_vt/lib-android-arm64/libghostty-vt.a" + } + } + ]) + + assert {:ok, args} = NativeBuild.project_nif_zig_args(:android_arm32) + + assert "-Dproject_rust_libs=" in args + refute "-Dghostty_vt_static=true" in args + end + end + + # ── NxEigen integration helpers ────────────────────────────────────────── + # Pure functions — no toolchain or filesystem touched. + + describe "nxeigen_zig_args_ios/1" do + test "nil → no flags (NxEigen not in this build)" do + assert NativeBuild.nxeigen_zig_args_ios(nil) == [] + end + + test "archive path → -Dnxeigen_static=true + -Dnxeigen_dir=<dirname>" do + args = NativeBuild.nxeigen_zig_args_ios("/some/build/ios_sim/libnx_eigen.a") + assert args == ["-Dnxeigen_static=true", "-Dnxeigen_dir=/some/build/ios_sim"] + end + + test "uses dirname (not full path) so the template's `{nxeigen_dir}/libnx_eigen.a` resolves" do + args = NativeBuild.nxeigen_zig_args_ios("/x/libnx_eigen.a") + assert "-Dnxeigen_dir=/x" in args + refute Enum.any?(args, &String.contains?(&1, "libnx_eigen.a")) + end + end + + describe "nxeigen_zig_args_android/1" do + test "nil → no flags" do + assert NativeBuild.nxeigen_zig_args_android(nil) == [] + end + + test "archive path → -Dnxeigen_static=true + -Dnxeigen_lib=<full path>" do + # Android passes the full per-ABI archive path (not dirname) so a + # single zig invocation can target one ABI's lib precisely. Two + # ABI builds → two different `nxeigen_lib` values. + args = NativeBuild.nxeigen_zig_args_android("/build/android_arm64/libnx_eigen.a") + assert args == ["-Dnxeigen_static=true", "-Dnxeigen_lib=/build/android_arm64/libnx_eigen.a"] + end + + test "iOS uses dir, Android uses lib — they differ for the same archive" do + # Regression guard: the two flag shapes are intentionally + # asymmetric. iOS templates expect a directory because the link + # uses `{nxeigen_dir}/libnx_eigen.a`; Android templates expect + # the per-ABI lib path directly. + ios = NativeBuild.nxeigen_zig_args_ios("/x/libnx_eigen.a") + android = NativeBuild.nxeigen_zig_args_android("/x/libnx_eigen.a") + refute ios == android + end + end + + describe "plugin_static_lib_args/1" do + test "empty list → no flag (so pre-plugin build.zig isn't passed an unknown -D)" do + assert NativeBuild.plugin_static_lib_args([]) == [] + end + + test "one archive → -Dplugin_static_libs=<path>" do + assert NativeBuild.plugin_static_lib_args(["/b/android_arm64/libnx_eigen_nif.a"]) == + ["-Dplugin_static_libs=/b/android_arm64/libnx_eigen_nif.a"] + end + + test "multiple archives → one comma-joined flag" do + args = NativeBuild.plugin_static_lib_args(["/b/liba.a", "/b/libb.a"]) + assert args == ["-Dplugin_static_libs=/b/liba.a,/b/libb.a"] + end + end + + describe "android_abi_to_cpp_target/1" do + test "arm64 ABI strings → :android_arm64" do + assert NativeBuild.android_abi_to_cpp_target("arm64-v8a") == :android_arm64 + assert NativeBuild.android_abi_to_cpp_target("arm64") == :android_arm64 + end + + test "arm32 ABI strings → :android_arm32" do + assert NativeBuild.android_abi_to_cpp_target("armeabi-v7a") == :android_arm32 + assert NativeBuild.android_abi_to_cpp_target("arm32") == :android_arm32 + end + + test "x86_64 → :android_x86_64 (a real target id CppArchive can't build yet)" do + assert NativeBuild.android_abi_to_cpp_target("x86_64") == :android_x86_64 + end + + test "unknown ABI strings → nil" do + assert NativeBuild.android_abi_to_cpp_target("riscv64") == nil + assert NativeBuild.android_abi_to_cpp_target("") == nil + assert NativeBuild.android_abi_to_cpp_target("x86") == nil + end + end + + describe "cpp_archive_target_decision/2 + unsupported_cpp_archive_target_error/2" do + @cpp_spec %{plugin: :nx_cpu, module: :nx_cpu_nif} + + test ":none when no cpp_archive spec is present (unsupported ABI is harmless then)" do + # The x86_64 emulator ABI gap only matters when a plugin actually needs it. + assert NativeBuild.cpp_archive_target_decision([], :android_x86_64) == :none + end + + test "{:error, _} when a cpp_archive spec is present on an unsupported ABI (x86_64)" do + assert {:error, msg} = + NativeBuild.cpp_archive_target_decision([@cpp_spec], :android_x86_64) + + # Names the unsupported ABI and the plugin/module, explains arm-only support. + assert msg =~ ":android_x86_64" + assert msg =~ "nx_cpu/nx_cpu_nif" + assert msg =~ ":android_arm64" + assert msg =~ ":android_arm32" + end + + test ":build when a cpp_archive spec is present on a supported ABI" do + assert NativeBuild.cpp_archive_target_decision([@cpp_spec], :android_arm64) == :build + assert NativeBuild.cpp_archive_target_decision([@cpp_spec], :android_arm32) == :build + assert NativeBuild.cpp_archive_target_decision([@cpp_spec], :ios_sim) == :build + end + + test "error message lists every active plugin/module" do + specs = [@cpp_spec, %{plugin: :other, module: :other_nif}] + msg = NativeBuild.unsupported_cpp_archive_target_error(specs, :android_x86_64) + assert msg =~ "nx_cpu/nx_cpu_nif" + assert msg =~ "other/other_nif" + end + end + + describe "nxeigen_provided_by_plugin?/0" do + test "false when no cpp_archive plugin provides nx_eigen_nif_init (mob_dev's own env)" do + # mob_dev activates no plugins, so the legacy core NxEigen build stays the + # active path. (Guards the coexistence logic; the plugin-present case is + # covered by Merge.static_archives tests.) + refute NativeBuild.nxeigen_provided_by_plugin?() + end + end + + # ── install_nx_eigen_otp_lib — filesystem integration ──────────────────── + + describe "install_nx_eigen_otp_lib/1 (and stage_empty_priv_otp_lib/2)" do + setup do + tmp = + Path.join( + System.tmp_dir!(), + "mobdev_install_nxeigen_#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp} + end + + test "no-op when neither dep ebin exists in _build/dev/lib/", %{tmp: otp_root} do + # No _build dir at all — should silently no-op (the function isn't + # required to fail when a project just doesn't have the deps). + assert :ok = NativeBuild.install_nx_eigen_otp_lib(otp_root) + refute File.dir?(Path.join([otp_root, "lib"])) + end + + test "stages a single dep into <otp_root>/lib/<app>-<vsn>/{ebin,priv}", %{tmp: otp_root} do + project = setup_project_with_dep("nx_eigen", "1.2.3") + + NativeBuild.stage_empty_priv_otp_lib(otp_root, "nx_eigen", project) + + lib_dir = Path.join([otp_root, "lib", "nx_eigen-1.2.3"]) + assert File.dir?(lib_dir) + assert File.dir?(Path.join(lib_dir, "ebin")) + # priv MUST exist (so :code.priv_dir/1 returns a path), and MUST + # be empty (the .a is statically linked into the main binary). + assert File.dir?(Path.join(lib_dir, "priv")) + assert File.ls!(Path.join(lib_dir, "priv")) == [] + + # .beam files copied through. + assert File.exists?(Path.join([lib_dir, "ebin", "Elixir.NxEigen.NIF.beam"])) + # .app file copied through too. + assert File.exists?(Path.join([lib_dir, "ebin", "nx_eigen.app"])) + end + + test "is idempotent — re-staging the same app overwrites without duplicating", %{ + tmp: otp_root + } do + project = setup_project_with_dep("nx_eigen", "1.2.3") + + NativeBuild.stage_empty_priv_otp_lib(otp_root, "nx_eigen", project) + NativeBuild.stage_empty_priv_otp_lib(otp_root, "nx_eigen", project) + + # Still exactly one lib dir; ebin still has the same contents. + lib_dirs = File.ls!(Path.join(otp_root, "lib")) + assert lib_dirs == ["nx_eigen-1.2.3"] + end + + test "install_nx_eigen_otp_lib stages BOTH nx_eigen + fine", %{tmp: otp_root} do + # Both deps need staging because Fine is the C++ binding helper; + # any code that consults `:code.priv_dir(:fine)` would crash on + # the same `:bad_name` pattern without it. + project = setup_project_with_dep("nx_eigen", "1.2.3") + _ = setup_dep_in_project(project, "fine", "0.5.0") + + NativeBuild.install_nx_eigen_otp_lib(otp_root, project) + + lib_dirs = Enum.sort(File.ls!(Path.join(otp_root, "lib"))) + assert lib_dirs == ["fine-0.5.0", "nx_eigen-1.2.3"] + end + + # Helper: build a fake project containing _build/dev/lib/<app>/ebin/ + # with one .beam + a .app file the staging code expects. + defp setup_project_with_dep(app, vsn) do + tmp = Path.join(System.tmp_dir!(), "mobdev_proj_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + setup_dep_in_project(tmp, app, vsn) + tmp + end + + defp setup_dep_in_project(project, app, vsn) do + ebin = Path.join([project, "_build", "dev", "lib", app, "ebin"]) + File.mkdir_p!(ebin) + + File.write!( + Path.join(ebin, "Elixir.#{Macro.camelize(app)}.NIF.beam"), + "FAKE_BEAM_BYTES" + ) + + File.write!( + Path.join(ebin, "#{app}.app"), + ~s({application,#{app},[{vsn,"#{vsn}"},{description,"test"}]}.) + ) + + project + end + end + + # ── maybe_bundle_mlx_metallib/1 ────────────────────────────────────────── + # Copies mlx.metallib (the precompiled Metal GPU kernels) out of mob's + # MLX cache into the .app bundle so MLX's load_colocated_library can + # find it next to the running binary. No-op when the cached bundle is + # CPU-only (no metallib in the staged tarball). + + describe "maybe_bundle_mlx_metallib/1" do + setup do + tmp = + Path.join(System.tmp_dir!(), "mob_metallib_test_#{System.unique_integer([:positive])}") + + File.mkdir_p!(tmp) + + original_cache = System.get_env("MOB_CACHE_DIR") + original_local = System.get_env("MOB_MLX_LOCAL_TARBALL_DIR") + + on_exit(fn -> + File.rm_rf!(tmp) + restore_env("MOB_CACHE_DIR", original_cache) + restore_env("MOB_MLX_LOCAL_TARBALL_DIR", original_local) + end) + + {:ok, tmp: tmp} + end + + test "copies mlx.metallib into the .app when the cached bundle ships one", %{tmp: tmp} do + app_path = Path.join(tmp, "Test.app") + File.mkdir_p!(app_path) + + stage_mlx_cache(tmp, with_metallib: true) + + assert :ok = NativeBuild.maybe_bundle_mlx_metallib(app_path) + copied = Path.join(app_path, "mlx.metallib") + assert File.regular?(copied) + assert File.read!(copied) == "stub-metallib-bytes" + end + + test "no-op when the cached bundle is CPU-only (no metallib)", %{tmp: tmp} do + app_path = Path.join(tmp, "Test.app") + File.mkdir_p!(app_path) + + stage_mlx_cache(tmp, with_metallib: false) + + assert :ok = NativeBuild.maybe_bundle_mlx_metallib(app_path) + refute File.exists?(Path.join(app_path, "mlx.metallib")) + end + end + + # Stage a fake MLX cache + local tarball under tmp/. Uses the same + # MOB_MLX_LOCAL_TARBALL_DIR override the MLXDownloader tests use so + # ensure_ios_device/0 doesn't touch the network. + defp stage_mlx_cache(tmp, opts) do + bundle_name = MobDev.MLXDownloader.name(:ios_device) + tarball_name = MobDev.MLXDownloader.tarball_name(:ios_device) + + # Build the staging dir (what the tarball will contain). + stage_root = Path.join(tmp, "stage") + bundle_dir = Path.join(stage_root, bundle_name) + File.mkdir_p!(Path.join(bundle_dir, "lib")) + File.mkdir_p!(Path.join([bundle_dir, "include", "mlx"])) + File.write!(Path.join([bundle_dir, "lib", "libmlx.a"]), "stub-mlx") + File.write!(Path.join([bundle_dir, "lib", "libemlx.a"]), "stub-emlx") + File.write!(Path.join(bundle_dir, "VERSION"), "mlx_version=stub\nvariant=test\n") + + if opts[:with_metallib] do + File.write!(Path.join([bundle_dir, "lib", "mlx.metallib"]), "stub-metallib-bytes") + end + + # Pack into a tarball at the location the local-tarball override + # expects. + local_dir = Path.join(tmp, "local") + File.mkdir_p!(local_dir) + tar_out = Path.join(local_dir, tarball_name) + + {_, 0} = System.cmd("tar", ["-czf", tar_out, "-C", stage_root, bundle_name]) + File.rm_rf!(stage_root) + + # Point the downloader at a fresh tmp cache + the staged tarball. + cache_dir = Path.join(tmp, "cache") + System.put_env("MOB_CACHE_DIR", cache_dir) + System.put_env("MOB_MLX_LOCAL_TARBALL_DIR", local_dir) + end + + defp restore_env(name, nil), do: System.delete_env(name) + defp restore_env(name, value), do: System.put_env(name, value) + + # ── Pin script + patch presence ────────────────────────────────────────── + # The Metal build process depends on two files living at known paths. + # If a refactor removes or renames either, fail loudly here instead of + # silently producing a CPU-only bundle. + + describe "MLX Metal build artifacts present" do + test "ios_device_metal.sh exists and is executable" do + script = + Path.join([ + File.cwd!(), + "scripts/release/mlx/ios_device_metal.sh" + ]) + + assert File.regular?(script), "expected #{script} to exist" + + assert File.stat!(script).mode |> Bitwise.band(0o111) > 0, + "expected #{script} to be executable" + end + + # ExSlop flags this as "doesn't exercise application code" — strictly true + # (it only touches File.regular?/1 + String.contains?/2) but the assertion + # is on a build asset the deploy pipeline consumes. Losing the patch + # silently would break iOS Metal builds in a way a regular test couldn't + # catch, since the consumer is `mix mob.deploy --native --ios`, not BEAM. + # credo:disable-for-next-line Jump.CredoChecks.VacuousTest + test "iOS-Metal CMake patch file exists" do + patch = + Path.join([ + File.cwd!(), + "scripts/release/mlx/patches/0001-ios-metal-build.patch" + ]) + + assert File.regular?(patch), "expected #{patch} to exist" + + content = File.read!(patch) + assert String.contains?(content, "iOS"), "patch should mention iOS" + assert String.contains?(content, "iphoneos"), "patch should switch to iphoneos SDK" + end + end + + describe "__regen_formats__/2 (driver_tab format selection)" do + test "an app with existing tables keeps its format(s)" do + assert NativeBuild.__regen_formats__([:zig], false) == [:zig] + assert NativeBuild.__regen_formats__([:c], true) == [:c] + assert NativeBuild.__regen_formats__([:zig, :c], false) == [:zig, :c] + end + + test "no existing table + no plugin NIFs → generate nothing (links against mob core)" do + assert NativeBuild.__regen_formats__([], false) == [] + end + + test "no existing table + a plugin NIF → create a zig table (the device fix)" do + # Without this, a plugin's <module>_nif_init links but never registers, + # so the NIF is :nif_not_loaded on device (caught verifying the showcase). + assert NativeBuild.__regen_formats__([], true) == [:zig] + end + end + + describe "__prune_plugin_artifacts__/2 (the plugin-removal prune)" do + setup do + dir = Path.join(System.tmp_dir!(), "mob_prune_#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + cwd = File.cwd!() + File.cd!(dir) + on_exit(fn -> File.cd!(cwd) end) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, dir: dir} + end + + defp touch!(rel) do + File.mkdir_p!(Path.dirname(rel)) + File.write!(rel, "x") + rel + end + + test "first run prunes nothing and records the current set" do + a = touch!("android/app/src/main/java/io/cam/CamBridge.kt") + assert NativeBuild.__prune_plugin_artifacts__(:android_kotlin, [a]) == [] + assert File.exists?(a) + assert File.exists?("priv/generated/.mob_plugin_artifacts/android_kotlin") + end + + test "a file dropped from the current set is deleted on the next run" do + cam = touch!("android/app/src/main/java/io/cam/CamBridge.kt") + loc = touch!("android/app/src/main/java/io/loc/LocBridge.kt") + NativeBuild.__prune_plugin_artifacts__(:android_kotlin, [cam, loc]) + + # mob_camera removed: next build only writes the location bridge. + pruned = NativeBuild.__prune_plugin_artifacts__(:android_kotlin, [loc]) + + assert pruned == [cam] + refute File.exists?(cam), "orphaned bridge should be pruned" + assert File.exists?(loc), "still-activated bridge must survive" + end + + test "an empty current set (all plugins removed) prunes everything prior" do + a = touch!("priv/generated/plugin_assets/assets/plugin/cam/icon.png") + NativeBuild.__prune_plugin_artifacts__(:images, [a]) + + assert NativeBuild.__prune_plugin_artifacts__(:images, []) == [a] + refute File.exists?(a) + end + + test "scopes are independent — pruning one never touches another" do + kt = touch!("android/app/src/main/java/io/cam/CamBridge.kt") + mig = touch!("priv/repo/migrations/20260101_cam.exs") + NativeBuild.__prune_plugin_artifacts__(:android_kotlin, [kt]) + NativeBuild.__prune_plugin_artifacts__(:migrations, [mig]) + + # Re-run kotlin with nothing; the migration in another scope is untouched. + NativeBuild.__prune_plugin_artifacts__(:android_kotlin, []) + + refute File.exists?(kt) + assert File.exists?(mig) + end + + test "a ledgered path already gone (manually deleted) does not crash" do + a = touch!("android/app/src/main/java/io/cam/CamBridge.kt") + NativeBuild.__prune_plugin_artifacts__(:android_kotlin, [a]) + File.rm!(a) + + assert NativeBuild.__prune_plugin_artifacts__(:android_kotlin, []) == [] + end + end + + describe "zig_build_plan/3 (fail fast when the JNI build can't succeed)" do + test "no build.zig: nothing to do, regardless of zig or C sources" do + assert NativeBuild.zig_build_plan(false, false, false) == :skip_no_build_zig + assert NativeBuild.zig_build_plan(false, true, true) == :skip_no_build_zig + assert NativeBuild.zig_build_plan(false, false, true) == :skip_no_build_zig + end + + test "zig present: drive the real build.zig path (C-source presence irrelevant)" do + assert NativeBuild.zig_build_plan(true, true, false) == :run_zig + assert NativeBuild.zig_build_plan(true, true, true) == :run_zig + end + + test "no zig but the mob dep still ships C sources: CMake fallback can compile them" do + assert NativeBuild.zig_build_plan(true, false, true) == :legacy_cmake + end + + test "no zig AND no C sources (mob 0.7+): obvious failure, so signal :zig_required" do + assert NativeBuild.zig_build_plan(true, false, false) == :zig_required + end + end + + describe "zig_required_message/0" do + test "names the cause and the exact fix" do + msg = NativeBuild.zig_required_message() + + # the cause: zig missing + the vanished C fallback source + assert msg =~ "zig is not on your PATH" + assert msg =~ "mob_nif.c" + # the fix: the version mob.doctor pins, plus how to verify + assert msg =~ "zig 0.15" + assert msg =~ "mix mob.doctor" + end + + test "stays in plain prose (no em dashes leaking into user-facing output)" do + refute NativeBuild.zig_required_message() =~ "—" + end + end + + describe "Android update-only native install" do + test "fails closed when discovery resolves no update targets" do + parent = self() + + runner = fn command, args -> + send(parent, {:command, command, args}) + {"List of devices attached\n", 0} + end + + assert {:error, :no_targets} = + NativeBuild.resolve_android_update_targets(nil, runner) + + assert_received {:command, "adb", ["devices"]} + refute_received {:command, _, _} + end + + test "default fanout canonicalizes every ready serial before the device phase" do + runner = fn "adb", ["devices"] -> + {""" + * daemon not running; starting now at tcp:5037 + * daemon started successfully + List of devices attached + serial-b\tdevice + serial-a\tdevice + """, 0} + end + + assert {:ok, ["serial-a", "serial-b"]} = + NativeBuild.resolve_android_update_targets(nil, runner) + end + + test "implicit fanout rejects mixed offline, unauthorized, and unknown states" do + cases = [ + {"offline", :offline}, + {"unauthorized", :unauthorized}, + {"recovery", :unknown_state} + ] + + Enum.each(cases, fn {state, reason} -> + output = "List of devices attached\nready\tdevice\nblocked\t#{state}\n" + runner = fn "adb", ["devices"] -> {output, 0} end + + assert {:error, ^reason} = + NativeBuild.resolve_android_update_targets(nil, runner) + end) + end + + test "rejects duplicate and malformed discovery snapshots" do + duplicate = "List of devices attached\nserial-a\tdevice\nserial-a\tdevice\n" + malformed = "List of devices attached\nserial-a\tdevice\textra\n" + + assert {:error, :duplicate_target} = + NativeBuild.resolve_android_update_targets( + nil, + fn "adb", ["devices"] -> {duplicate, 0} end + ) + + for output <- [malformed, "serial-a\tdevice\n", <<255, 254>>] do + assert {:error, :malformed_discovery} = + NativeBuild.resolve_android_update_targets( + nil, + fn "adb", ["devices"] -> {output, 0} end + ) + end + end + + test "accepts exactly 32 maximum-length serials and rejects larger target sets" do + serials = + for index <- 1..33 do + prefix = Integer.to_string(index) + prefix <> String.duplicate("a", 128 - byte_size(prefix)) + end + + output = fn selected -> + "List of devices attached\n" <> + Enum.map_join(selected, "", &"#{&1}\tdevice\n") + end + + accepted = Enum.take(serials, 32) + + canonical_accepted = Enum.sort(accepted) + + assert {:ok, ^canonical_accepted} = + NativeBuild.resolve_android_update_targets( + nil, + fn "adb", ["devices"] -> {output.(accepted), 0} end + ) + + assert {:error, :too_many_targets} = + NativeBuild.resolve_android_update_targets( + nil, + fn "adb", ["devices"] -> {output.(serials), 0} end + ) + end + + test "rejects oversized discovery output instead of parsing a truncated prefix" do + output = "List of devices attached\n" <> String.duplicate("x", 8_193) + + assert {:error, :discovery_output_too_large} = + NativeBuild.resolve_android_update_targets( + nil, + fn "adb", ["devices"] -> {output, 0} end + ) + end + + test "resolves only the requested online serial, including a bare WiFi address" do + output = """ + List of devices attached + ZY22K6BSJM\tdevice + 10.0.0.17:5555\tdevice + emulator-5554\tdevice + """ + + runner = fn "adb", ["devices"] -> {output, 0} end + + assert {:ok, ["10.0.0.17:5555"]} = + NativeBuild.resolve_android_update_targets("10.0.0.17", runner) + end + + test "fails closed for offline, unauthorized, missing, ambiguous, and invalid targets" do + runner = fn "adb", ["devices"] -> + {""" + List of devices attached + offline-one\toffline + auth-one\tunauthorized + 10.0.0.17\tdevice + 10.0.0.17:5555\tdevice + """, 0} + end + + assert {:error, :offline} = + NativeBuild.resolve_android_update_targets("offline-one", runner) + + assert {:error, :unauthorized} = + NativeBuild.resolve_android_update_targets("auth-one", runner) + + assert {:error, :target_not_connected} = + NativeBuild.resolve_android_update_targets("missing", runner) + + assert {:error, :ambiguous_target} = + NativeBuild.resolve_android_update_targets("10.0.0.17", runner) + + assert {:error, :invalid_target} = + NativeBuild.resolve_android_update_targets("--transport-any", runner) + end + + test "explicit resolution rejects a case-variant discovery collision before mutation" do + parent = self() + + runner = fn + "adb", ["devices"] = args -> + send(parent, {:command, "adb", args}) + + {"List of devices attached\nCaseTarget\tdevice\ncasetarget\tdevice\n", 0} + + command, args -> + send(parent, {:command, command, args}) + {"Success\n", 0} + end + + assert {:error, :ambiguous_target} = + NativeBuild.resolve_android_update_targets("CaseTarget", runner) + + assert_received {:command, "adb", ["devices"]} + refute_received {:command, _, _} + end + + test "legacy install and delivery seams require the authoritative transaction" do + parent = self() + apk = "/tmp/app-debug.apk" + + runner = fn + "adb", ["devices"] = args -> + send(parent, {:command, "adb", args}) + {"List of devices attached\nCaseTarget\tdevice\n", 0} + + command, args -> + send(parent, {:command, command, args}) + {"Success\n", 0} + end + + deliver = fn serial -> + send(parent, {:delivered, serial}) + :ok + end + + assert {:ok, ["CaseTarget"]} = + NativeBuild.resolve_android_update_targets("CaseTarget", runner) + + assert {:error, :authoritative_transaction_required} = + apply(NativeBuild, :install_android_updates, [apk, ["CaseTarget"], runner]) + + assert {:error, message} = + apply(NativeBuild, :install_and_deliver_android, [ + apk, + ["CaseTarget"], + runner, + deliver + ]) + + assert message =~ "authoritative payload transaction" + assert_received {:command, "adb", ["devices"]} + refute_received {:command, _, _} + refute_received {:delivered, _} + end + + test "accepts only recognized adb success output" do + assert :updated = NativeBuild.interpret_adb_update("Success\n", 0) + + assert :updated = + NativeBuild.interpret_adb_update("Performing Streamed Install\nSuccess\n", 0) + + assert {:failed, :suspicious_success} = + NativeBuild.interpret_adb_update("Success\nunexpected extra line\n", 0) + + assert {:failed, :suspicious_success} = NativeBuild.interpret_adb_update("", 0) + assert {:failed, :unknown_failure} = NativeBuild.interpret_adb_update("Success\n", 1) + assert {:failed, :unknown_failure} = NativeBuild.interpret_adb_update(<<255, 254>>, 0) + end + + test "accepts exactly 4096 verified bytes and rejects every oversized install result" do + exact = "Success" <> String.duplicate("\n", 4_096 - byte_size("Success")) + assert byte_size(exact) == 4_096 + assert :updated = NativeBuild.interpret_adb_update(exact, 0) + + assert {:failed, :unknown_failure} = + NativeBuild.interpret_adb_update(exact <> "\n", 0) + + assert {:failed, :unknown_failure} = + NativeBuild.interpret_adb_update( + exact <> "Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]\n", + 0 + ) + end + + test "classifies destructive and recoverable adb failures without returning raw output" do + cases = [ + {"Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE] raw-private-detail", + :insufficient_storage}, + {"Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE] raw-private-detail", :signature_mismatch}, + {"Failure [INSTALL_PARSE_FAILED_INCONSISTENT_CERTIFICATES]", :signature_mismatch}, + {"Failure [INSTALL_FAILED_VERSION_DOWNGRADE]", :version_downgrade}, + {"error: device offline", :offline}, + {"error: device unauthorized. Please check the confirmation dialog", :unauthorized}, + {"error: device not found", :unavailable}, + {"Failure [INSTALL_FAILED_DEXOPT]", :install_rejected}, + {"some unrecognized failure containing raw-private-detail", :unknown_failure} + ] + + Enum.each(cases, fn {output, reason} -> + result = NativeBuild.interpret_adb_update(output, 1) + assert result == {:failed, reason} + refute inspect(result) =~ "raw-private-detail" + end) + end + + test "legacy seams still reject invalid target sets without invoking callbacks" do + parent = self() + + runner = fn command, args -> + send(parent, {:command, command, args}) + {"unexpected", 0} + end + + deliver = fn serial -> + send(parent, {:delivered, serial}) + :ok + end + + assert {:error, :invalid_target} = + NativeBuild.resolve_android_update_targets("--all", runner) + + assert {:error, :no_explicit_targets} = + apply(NativeBuild, :install_android_updates, ["/tmp/app.apk", [], runner]) + + assert {:error, :invalid_target} = + apply(NativeBuild, :install_android_updates, [ + "/tmp/app.apk", + ["--all"], + runner + ]) + + assert {:error, _message} = + apply(NativeBuild, :install_and_deliver_android, [ + "/tmp/app.apk", + ["CaseTarget", "casetarget"], + runner, + deliver + ]) + + refute_received {:command, _, _} + refute_received {:delivered, _} + end + end + + describe "plist_array_additions/2 (plugin array plist merge, e.g. UIBackgroundModes)" do + test "returns items not already present, input order preserved" do + assert NativeBuild.plist_array_additions( + ["audio"], + ["bluetooth-central", "bluetooth-peripheral"] + ) == ["bluetooth-central", "bluetooth-peripheral"] + end + + test "skips items already on disk (idempotent re-merge)" do + assert NativeBuild.plist_array_additions( + ["audio", "bluetooth-central"], + ["bluetooth-central", "bluetooth-peripheral"] + ) == ["bluetooth-peripheral"] + end + + test "de-duplicates within the requested items" do + assert NativeBuild.plist_array_additions([], ["x", "x", "y"]) == ["x", "y"] + end + + test "drops non-binary entries" do + assert NativeBuild.plist_array_additions([], ["ok", :atom, 1, nil]) == ["ok"] + end + + test "empty when everything is already present" do + assert NativeBuild.plist_array_additions(["a", "b"], ["a", "b"]) == [] + end + end + + describe "build_file_supports_plugins?/1 (MOB-7: blank-iOS mob_register_plugins link)" do + test "true when the iOS build file exposes the plugin_swift_files option" do + src = ~s| + const plugin_swift_files = b.option([]const u8, "plugin_swift_files", "…") orelse ""; + const plugin_frameworks = b.option([]const u8, "plugin_frameworks", "…") orelse ""; + | + + assert NativeBuild.build_file_supports_plugins?(src) + end + + test "false for a pre-plugin build file (no plugin_swift_files option)" do + src = ~s| + const mob_dir = b.option([]const u8, "mob_dir", "…") orelse ""; + const sdkroot = b.option([]const u8, "sdkroot", "…") orelse ""; + | + + refute NativeBuild.build_file_supports_plugins?(src) + end + + test "false for empty content" do + refute NativeBuild.build_file_supports_plugins?("") + end + end + + describe "ios_plugin_swift_mode/2 (MOB-7: the actual bootstrap decision)" do + # This is the fix. The :bootstrap_only case — no plugins activated, but the + # build file supports plugins (so its AppDelegate calls mob_register_plugins) + # — is what a --blank iOS app needs; flipping it back to :none reintroduces + # the undefined-symbol link failure. + test "no plugins + plugin-aware build file => :bootstrap_only (the MOB-7 fix)" do + assert NativeBuild.ios_plugin_swift_mode([], true) == :bootstrap_only + end + + test "no plugins + legacy build file => :none (omit flags, keep legacy building)" do + assert NativeBuild.ios_plugin_swift_mode([], false) == :none + end + + test "activated plugins => :with_plugins regardless of build-file support" do + assert NativeBuild.ios_plugin_swift_mode([{"dir", %{}}], true) == :with_plugins + assert NativeBuild.ios_plugin_swift_mode([{"dir", %{}}], false) == :with_plugins + end + end + + describe "ios_build_file_supports_plugins?/1 (file-read wrapper)" do + @describetag :tmp_dir + + test "true when the file exists and declares the option", %{tmp_dir: dir} do + path = Path.join(dir, "build.zig") + File.write!(path, ~s|const plugin_swift_files = b.option(...);|) + assert NativeBuild.ios_build_file_supports_plugins?(path) + end + + test "false when the file exists without the option", %{tmp_dir: dir} do + path = Path.join(dir, "build.zig") + File.write!(path, ~s|const mob_dir = b.option(...);|) + refute NativeBuild.ios_build_file_supports_plugins?(path) + end + + test "false when the file is missing (legacy scaffold, no build.zig)", %{tmp_dir: dir} do + refute NativeBuild.ios_build_file_supports_plugins?(Path.join(dir, "does_not_exist.zig")) + end + end + + describe "remove_stale_release_otp_zip/1" do + @describetag :tmp_dir + + # Regression guard: `mix mob.release --android` used to write + # `assets/otp.zip` into the shared `src/main/assets/` source set, which + # Gradle merges into every build variant. A checkout that had ever run a + # release build carried that file into every subsequent debug build too, + # where MobBridge.kt's extractOtpIfNeeded() would re-extract it on the + # next app launch and silently overwrite freshly pushed dev BEAMs with + # the stale release snapshot. Release builds now write to the + # variant-scoped `src/release/assets/` instead, but existing checkouts + # may still carry the leftover file — the debug build path removes it + # so it can't keep poisoning deploys. + test "removes a leftover otp.zip from the shared main asset source set", %{tmp_dir: dir} do + assets_dir = Path.join([dir, "app", "src", "main", "assets"]) + File.mkdir_p!(assets_dir) + stale = Path.join(assets_dir, "otp.zip") + File.write!(stale, "stale release bundle") + + assert NativeBuild.remove_stale_release_otp_zip(dir) == :ok + refute File.exists?(stale) + end + + test "is a no-op when no stale zip is present", %{tmp_dir: dir} do + assert NativeBuild.remove_stale_release_otp_zip(dir) == :ok + end + + test "leaves sibling assets (e.g. logos) untouched", %{tmp_dir: dir} do + assets_dir = Path.join([dir, "app", "src", "main", "assets"]) + File.mkdir_p!(assets_dir) + File.write!(Path.join(assets_dir, "otp.zip"), "stale") + logo = Path.join(assets_dir, "mob_logo_dark.png") + File.write!(logo, "logo bytes") + + NativeBuild.remove_stale_release_otp_zip(dir) + + assert File.exists?(logo) + end + end + + describe "install_and_deliver_android_runtime/8 authoritative transaction" do + @describetag :tmp_dir + + test "requires authoritative callbacks before any device command", %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + + runner = fn executable, args -> + send(self(), {:native_probe, executable, args}) + {"unexpected", 0} + end + + assert {:error, reason} = + NativeBuild.install_and_deliver_android_runtime( + fixture.apk, + fixture.serials, + fixture.package, + fixture.elixir_lib, + fixture.otp_arm64, + fixture.otp_arm32, + fixture.otp_x86_64, + probe_runner: runner, + tmp_root: dir + ) + + assert reason =~ "authoritative payload plan" + refute_received {:native_probe, _, _} + end + + test "installs the immutable plan APK and returns a native_ready set-wide lease", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + otp_runner = authoritative_android_otp_runner(self()) + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + cleanup = fn plan -> cleanup_authoritative_android_plan(plan) end + + assert {:ok, + %{ + deploy_lock: %{phase: :native_ready, state: :held_success} = lease, + payload_plan: plan + }} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert lease.owner == "ownerproof000001" + assert lease.serials == ["serial-a"] + + commands = drain_native_commands(:native_probe) + + assert Enum.any?(commands, fn + {"adb", ["-s", "serial-a", "install", "-r", installed_apk]} -> + installed_apk == plan.apk.path and installed_apk != fixture.apk + + _command -> + false + end) + + assert File.regular?(plan.apk.path) + assert cleanup_authoritative_android_plan(plan) == :ok + end + + test "recovery proof uses the native two-arity adb runner and refuses before mutation", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + base_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + probe_runner = fn + "adb", ["devices", "-l"] = args -> + send(self(), {:native_probe, "adb", args}) + {"List of devices attached\n", 0} + + executable, args -> + base_runner.(executable, args) + end + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + + cleanup = fn plan -> + send(self(), {:recovery_payload_cleanup, plan.attempt_id}) + cleanup_authoritative_android_plan(plan) + end + + assert {:error, + "Android native-ready recovery proof was refused (transport_identity_mismatch)"} = + run_authoritative_android( + fixture, + dir, + probe_runner, + authoritative_android_otp_runner(self()), + preinstall, + cleanup, + resume_native_ready: true, + android_recovery_opts: [ + payload_validator: fn _plan -> :ok end, + host_lock_held?: fn -> true end, + apk_signature_verified?: fn _path -> true end + ] + ) + + assert_received {:native_probe, "adb", ["devices", "-l"]} + assert_received {:recovery_payload_cleanup, _attempt_id} + + refute Enum.any?(drain_native_commands(:native_probe), fn + {"adb", ["-s", _serial, "install", "-r", _apk]} -> + true + + {"adb", ["-s", _serial, "shell", command]} -> + String.contains?(command, "record_next_") + + _command -> + false + end) + end + + test "canonicalizes one unsorted target set before planning and every mutation", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-b", "serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + otp_runner = authoritative_android_otp_runner(self()) + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + cleanup = fn plan -> cleanup_authoritative_android_plan(plan) end + + assert {:ok, + %{ + deploy_lock: %{serials: ["serial-a", "serial-b"], phase: :native_ready}, + payload_plan: %{serials: ["serial-a", "serial-b"]} = plan + }} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + install_serials = + for {"adb", ["-s", serial, "install", "-r", _apk]} <- + drain_native_commands(:native_probe), + do: serial + + assert install_serials == ["serial-a", "serial-b"] + assert cleanup_authoritative_android_plan(plan) == :ok + end + + test "rejects empty, oversized, duplicate, ambiguous, and unbounded sets before work", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["fixture-serial"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + otp_runner = authoritative_android_otp_runner(self()) + + invalid_sets = [ + {[], "Android APK update requires at least one explicit target"}, + {Enum.map(1..33, &"serial-#{&1}"), + "Android APK update target count exceeds the safety limit"}, + {["serial-a", "serial-a"], "Android APK update request is invalid"}, + {["serial-a", "SERIAL-A"], "Android APK update request is invalid"}, + {[String.duplicate("a", 129)], "Android APK update request is invalid"}, + {["serial-a" | "invalid-tail"], "Android APK update request is invalid"} + ] + + for {serials, expected_reason} <- invalid_sets do + invalid_fixture = %{fixture | serials: serials} + + preinstall = fn _input -> + send(self(), :unexpected_preinstall) + {:error, :unexpected} + end + + cleanup = fn _plan -> + send(self(), :unexpected_cleanup) + :ok + end + + assert {:error, ^expected_reason} = + run_authoritative_android( + invalid_fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + end + + refute_received :unexpected_preinstall + refute_received :unexpected_cleanup + refute_received {:native_probe, _, _} + refute_received {:native_otp, _, _} + end + + test "accepts only the bounded binary beam-flags payload contract", %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + otp_runner = authoritative_android_otp_runner(self()) + + preinstall = fn input -> + plan = authoritative_android_payload_plan!(dir, input) + {:ok, put_in(plan.beam.beam_flags, "+S 2:2 -A 4")} + end + + cleanup = fn plan -> cleanup_authoritative_android_plan(plan) end + + assert {:ok, %{deploy_lock: %{phase: :native_ready}, payload_plan: plan}} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert plan.beam.beam_flags == "+S 2:2 -A 4" + assert cleanup_authoritative_android_plan(plan) == :ok + + for invalid_flags <- [["+S", "2:2"], String.duplicate("x", 4_097), <<0xFF>>] do + invalid_preinstall = fn input -> + invalid_plan = authoritative_android_payload_plan!(dir, input) + {:ok, put_in(invalid_plan.beam.beam_flags, invalid_flags)} + end + + assert {:error, "Authoritative Android payload plan identity is invalid"} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + invalid_preinstall, + fn _plan -> {:error, :injected_cleanup_failure} end + ) + end + + commands = drain_native_commands(:native_probe) + assert Enum.count(commands, &native_install_command?/1) == 1 + assert Enum.count(commands, &native_lock_mutation_command?/1) > 0 + + Enum.each(Path.wildcard(Path.join(dir, "authoritative-plan-*.apk")), &File.rm!/1) + Enum.each(Path.wildcard(Path.join(dir, "authoritative-beams-*.tar")), &File.rm!/1) + end + + test "rejects no-restart plans, invokes cleanup once, and performs zero lease or install mutation", + %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + otp_runner = authoritative_android_otp_runner(self()) + + preinstall = fn input -> + plan = authoritative_android_payload_plan!(dir, input) + + restart = %{ + Map.fetch!(plan.restart_by_serial, "serial-a") + | restart?: false, + mode: :no_restart + } + + {:ok, put_in(plan.restart_by_serial["serial-a"], restart)} + end + + cleanup = fn plan -> + send(self(), {:payload_cleanup, plan.attempt_id}) + {:error, :injected_cleanup_failure} + end + + assert {:error, "Authoritative Android payload plan identity is invalid"} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert_received {:payload_cleanup, "planbeam00000001"} + + commands = drain_native_commands(:native_probe) + refute Enum.any?(commands, &native_install_command?/1) + refute Enum.any?(commands, &native_lock_mutation_command?/1) + + [leaked_apk] = Path.wildcard(Path.join(dir, "authoritative-plan-*.apk")) + File.rm!(leaked_apk) + Enum.each(Path.wildcard(Path.join(dir, "authoritative-beams-*.tar")), &File.rm!/1) + end + + test "cleanup failure cannot replace a primary device error or its exact retained lease", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + base_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + probe_runner = fn + "adb", ["-s", "serial-a", "install", "-r", _apk] = args -> + send(self(), {:native_probe, "adb", args}) + {"Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE]\n", 1} + + executable, args -> + base_runner.(executable, args) + end + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + + cleanup = fn plan -> + send(self(), {:payload_cleanup, plan.attempt_id}) + {:error, :injected_cleanup_failure} + end + + assert {:error, reason, + %{ + owner: "ownerproof000001", + state: :retained_failure, + phase: :acquired, + serials: ["serial-a"] + }} = + run_authoritative_android( + fixture, + dir, + probe_runner, + authoritative_android_otp_runner(self()), + preinstall, + cleanup + ) + + assert reason == "APK update failed: out of storage" + assert_received {:payload_cleanup, "planbeam00000001"} + refute_received {:payload_cleanup, "planbeam00000001"} + + Enum.each(Path.wildcard(Path.join(dir, "authoritative-plan-*.apk")), &File.rm!/1) + Enum.each(Path.wildcard(Path.join(dir, "authoritative-beams-*.tar")), &File.rm!/1) + end + + test "cleanup throw cannot replace a primary raised exception and runs exactly once", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + base_otp_runner = authoritative_android_otp_runner(self()) + + otp_runner = fn + "cp", _args, _opts -> raise "primary OTP preparation failure" + executable, args, opts -> base_otp_runner.(executable, args, opts) + end + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + + cleanup = fn plan -> + send(self(), {:payload_cleanup, plan.attempt_id}) + throw(:secondary_cleanup_failure) + end + + assert_raise RuntimeError, "primary OTP preparation failure", fn -> + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + end + + assert_received {:payload_cleanup, "planbeam00000001"} + refute_received {:payload_cleanup, "planbeam00000001"} + + Enum.each(Path.wildcard(Path.join(dir, "authoritative-plan-*.apk")), &File.rm!/1) + Enum.each(Path.wildcard(Path.join(dir, "authoritative-beams-*.tar")), &File.rm!/1) + end + + test "freezes OTP archives and refuses every second-target mutation after archive drift", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-a", "serial-b"]) + owner_state = start_supervised!({Agent, fn -> true end}) + archive_state = start_supervised!({Agent, fn -> nil end}, id: :otp_archive_state) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + otp_runner = + authoritative_android_otp_runner(self(), fn + ["-s", "serial-a", "push", archive, _remote] -> + Agent.update(archive_state, fn _old -> archive end) + + ["-s", "serial-a", "shell", "rm -f " <> _remote] -> + archive = Agent.get(archive_state, & &1) + File.chmod!(archive, 0o600) + File.write!(archive, "mutated between canonical targets") + + _args -> + :ok + end) + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + + cleanup = fn plan -> + send(self(), {:payload_cleanup, plan.attempt_id}) + cleanup_authoritative_android_plan(plan) + end + + assert {:error, {:partial_update, reason}, %{state: :retained_failure, phase: :acquired}} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert reason =~ "OTP archive changed" + assert_received {:payload_cleanup, "planbeam00000001"} + refute_received {:payload_cleanup, "planbeam00000001"} + probe_commands = drain_native_commands(:native_probe) + otp_commands = drain_native_commands(:native_otp) + + refute Enum.any?(probe_commands, fn + {"adb", ["-s", "serial-b", "install" | _args]} -> true + _command -> false + end) + + refute Enum.any?(otp_commands, fn + {"adb", ["-s", "serial-b" | _args]} -> true + _command -> false + end) + end + + test "set-wide owner loss after target A prevents every target B mutation", %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a", "serial-b"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + otp_runner = + authoritative_android_otp_runner(self(), fn + ["-s", "serial-a", "shell", "rm -f " <> _remote] -> + Agent.update(owner_state, fn _valid -> false end) + + _args -> + :ok + end) + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + + cleanup = fn plan -> + cleanup_authoritative_android_plan(plan) + end + + assert {:error, {:partial_update, reason}, + %{state: :retained_ambiguous, phase: :acquired, serials: ["serial-a", "serial-b"]}} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert reason =~ "lease set could not be verified" + probe_commands = drain_native_commands(:native_probe) + otp_commands = drain_native_commands(:native_otp) + + refute Enum.any?(probe_commands, fn + {"adb", ["-s", "serial-b", "install" | _args]} -> true + _command -> false + end) + + refute Enum.any?(otp_commands, fn + {"adb", ["-s", "serial-b" | _args]} -> true + _command -> false + end) + end + + test "non-authoritative install success retains an ambiguous lease and stops later targets", + %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a", "serial-b"]) + owner_state = start_supervised!({Agent, fn -> true end}) + base_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + probe_runner = fn + "adb", ["-s", "serial-a", "install", "-r", _apk] = args -> + send(self(), {:native_probe, "adb", args}) + {"Success\nuntrusted trailing output\n", 0} + + executable, args -> + base_runner.(executable, args) + end + + otp_runner = authoritative_android_otp_runner(self()) + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + cleanup = fn plan -> cleanup_authoritative_android_plan(plan) end + + assert {:error, {:partial_update, reason}, + %{state: :retained_ambiguous, phase: :acquired, serials: ["serial-a", "serial-b"]}} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert reason =~ "not authoritative" + probe_commands = drain_native_commands(:native_probe) + assert Enum.count(probe_commands, &native_install_command?/1) == 1 + + refute Enum.any?(probe_commands, fn + {"adb", ["-s", "serial-b", "install" | _args]} -> true + _command -> false + end) + + refute Enum.any?(drain_native_commands(:native_otp), fn + {"adb", _args} -> true + _local_command -> false + end) + end + + test "a deterministic OTP failure after APK success reports a retained partial update", %{ + tmp_dir: dir + } do + fixture = authoritative_android_fixture!(dir, ["serial-a", "serial-b"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + otp_runner = fn executable, args, opts -> + send(self(), {:native_otp, executable, args}) + + case {executable, args} do + {local, _args} when local in ["cp", "tar"] -> System.cmd(local, args, opts) + {"adb", ["-s", "serial-a", "push" | _rest]} -> {"injected push failure", 1} + {"adb", _args} -> {"", 0} + end + end + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + cleanup = fn plan -> cleanup_authoritative_android_plan(plan) end + + assert {:error, {:partial_update, reason}, + %{state: :retained_failure, phase: :acquired, serials: ["serial-a", "serial-b"]}} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert reason =~ "push OTP archive failed" + probe_commands = drain_native_commands(:native_probe) + otp_commands = drain_native_commands(:native_otp) + assert Enum.count(probe_commands, &native_install_command?/1) == 1 + + assert Enum.count(otp_commands, fn + {"adb", ["-s", "serial-a", "push" | _args]} -> true + _command -> false + end) == 1 + + refute Enum.any?(otp_commands, fn + {"adb", ["-s", "serial-a", "shell" | _args]} -> true + _command -> false + end) + + refute Enum.any?(probe_commands, fn + {"adb", ["-s", "serial-b", "install" | _args]} -> true + _command -> false + end) + end + + test "native-ready commit failure after APK and OTP success remains an explicit partial update", + %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a"]) + owner_state = start_supervised!({Agent, fn -> true end}) + base_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + probe_runner = fn executable, args -> + case {executable, args} do + {"adb", ["-s", "serial-a", "shell", command]} -> + if String.contains?(command, "native_ready") do + send(self(), {:native_probe, executable, args}) + {"", 1} + else + base_runner.(executable, args) + end + + _command -> + base_runner.(executable, args) + end + end + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + cleanup = fn plan -> cleanup_authoritative_android_plan(plan) end + + assert {:error, {:partial_update, reason}, + %{state: :retained_ambiguous, phase: :acquired, serials: ["serial-a"]}} = + run_authoritative_android( + fixture, + dir, + probe_runner, + authoritative_android_otp_runner(self()), + preinstall, + cleanup + ) + + assert reason =~ "native-ready commit failed" + assert Enum.count(drain_native_commands(:native_probe), &native_install_command?/1) == 1 + end + + test "runner exceptions after acquire preserve the exact ambiguous lease and stop later targets", + %{tmp_dir: dir} do + fixture = authoritative_android_fixture!(dir, ["serial-a", "serial-b"]) + owner_state = start_supervised!({Agent, fn -> true end}) + probe_runner = authoritative_android_probe_runner(self(), fixture, owner_state) + + otp_runner = + authoritative_android_otp_runner(self(), fn + ["-s", "serial-a", "push" | _args] -> throw(:transport_lost_after_write) + _args -> :ok + end) + + preinstall = fn input -> {:ok, authoritative_android_payload_plan!(dir, input)} end + + cleanup = fn plan -> + send(self(), {:payload_cleanup, plan.attempt_id}) + {:error, :injected_cleanup_failure} + end + + assert {:error, + {:partial_update, + "Android device transaction became ambiguous after APK update; deploy lease retained"}, + %{ + owner: "ownerproof000001", + state: :retained_ambiguous, + phase: :acquired, + serials: ["serial-a", "serial-b"] + }} = + run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup + ) + + assert_received {:payload_cleanup, "planbeam00000001"} + refute_received {:payload_cleanup, "planbeam00000001"} + probe_commands = drain_native_commands(:native_probe) + + assert Enum.count(probe_commands, &native_install_command?/1) == 1 + + refute Enum.any?(probe_commands, fn + {"adb", ["-s", "serial-b", "install" | _args]} -> true + _command -> false + end) + + Enum.each(Path.wildcard(Path.join(dir, "authoritative-plan-*.apk")), &File.rm!/1) + Enum.each(Path.wildcard(Path.join(dir, "authoritative-beams-*.tar")), &File.rm!/1) + end + end + + defp authoritative_android_fixture!(dir, serials) do + package = "com.example.casein" + elixir_lib = Path.join(dir, "authoritative-elixir") + + for app <- ["elixir", "logger", "eex"] do + ebin = Path.join([elixir_lib, app, "ebin"]) + File.mkdir_p!(ebin) + File.write!(Path.join(ebin, "#{app}.beam"), "#{app}-runtime") + end + + File.write!(Path.join([elixir_lib, "elixir", "ebin", "Elixir.Kernel.beam"]), "kernel") + + otp_by_abi = + Map.new(["arm64-v8a", "armeabi-v7a", "x86_64"], fn abi -> + otp_dir = Path.join(dir, "authoritative-otp-#{abi}") + erts_bin = Path.join(otp_dir, "erts-17.0/bin") + File.mkdir_p!(erts_bin) + + for helper <- ["erl_child_setup", "inet_gethost", "epmd"] do + File.write!(Path.join(erts_bin, helper), "#{abi}:#{helper}") + end + + {abi, otp_dir} + end) + + apk = Path.join(dir, "authoritative-input.apk") + + apk_entries = + for {abi, otp_dir} <- otp_by_abi, + {helper, packaged} <- [ + {"erl_child_setup", "liberl_child_setup.so"}, + {"inet_gethost", "libinet_gethost.so"}, + {"epmd", "libepmd.so"} + ] do + source = Path.join([otp_dir, "erts-17.0", "bin", helper]) + {String.to_charlist("lib/#{abi}/#{packaged}"), File.read!(source)} + end + + {:ok, _apk} = :zip.create(String.to_charlist(apk), apk_entries) + + %{ + apk: apk, + package: package, + serials: serials, + elixir_lib: elixir_lib, + otp_arm64: Map.fetch!(otp_by_abi, "arm64-v8a"), + otp_arm32: Map.fetch!(otp_by_abi, "armeabi-v7a"), + otp_x86_64: Map.fetch!(otp_by_abi, "x86_64") + } + end + + defp authoritative_android_payload_plan!(dir, input) do + unique = System.unique_integer([:positive, :monotonic]) + apk = Path.join(dir, "authoritative-plan-#{unique}.apk") + beam_archive = Path.join(dir, "authoritative-beams-#{unique}.tar") + File.cp!(input.apk, apk) + File.write!(beam_archive, "exact prepared BEAM archive") + File.chmod!(apk, 0o400) + File.chmod!(beam_archive, 0o400) + + {MobDev.NativeBuild, beam_binary, _beam_path} = :code.get_object_code(MobDev.NativeBuild) + + beam_path = "Elixir.MobDev.NativeBuild.beam" + attempt_id = "planbeam00000001" + app_data = "/data/data/#{input.bundle_id}/files" + + restart_by_serial = + input.serials + |> Enum.with_index(9_100) + |> Map.new(fn {serial, dist_port} -> + suffix = String.replace(serial, ~r/[^A-Za-z0-9_]/, "_") + + {serial, + %{ + package: input.bundle_id, + activity: ".MainActivity", + restart?: true, + mode: :checked_restart, + dist_port: dist_port, + node_suffix: suffix + }} + end) + + %{ + version: 1, + package: input.bundle_id, + attempt_id: attempt_id, + serials: input.serials, + selected_abis: input.selected_abis, + selected_abis_by_serial: input.selected_abis_by_serial, + apk: authoritative_android_file_identity!(apk), + beam: %{ + archive: authoritative_android_file_identity!(beam_archive), + stage_device: "/data/local/tmp/mob_beams_#{attempt_id}.tar", + app_stage: "#{app_data}/.mob_beams_stage_#{attempt_id}", + app_backup: "#{app_data}/.mob_beams_backup_#{attempt_id}", + activation_lock: "#{app_data}/.mob_beams_activation_lock", + dist_snapshot: [ + %{ + module: MobDev.NativeBuild, + path: beam_path, + binary: beam_binary, + sha256: :crypto.hash(:sha256, beam_binary) + } + ], + runtime_version: System.version(), + beam_flags: nil + }, + exqlite: nil, + restart_by_serial: restart_by_serial + } + end + + defp authoritative_android_file_identity!(path) do + bytes = File.read!(path) + + %{ + path: path, + size: byte_size(bytes), + sha256: Base.encode16(:crypto.hash(:sha256, bytes), case: :lower) + } + end + + defp cleanup_authoritative_android_plan(plan) do + File.rm(plan.apk.path) + File.rm(plan.beam.archive.path) + + if is_map(plan.exqlite) do + File.rm(plan.exqlite.archive.path) + end + + :ok + end + + defp run_authoritative_android( + fixture, + dir, + probe_runner, + otp_runner, + preinstall, + cleanup, + extra_opts \\ [] + ) do + opts = + [ + probe_runner: probe_runner, + manifest_runner: fn "apkanalyzer", ["manifest", "application-id", _apk] -> + {fixture.package <> "\n", 0} + end, + otp_runner: otp_runner, + android_preinstall: preinstall, + android_preinstall_cleanup: cleanup, + tmp_root: dir, + attempt_id: "nativeotp0000001", + lock_owner: "ownerproof000001" + ] + |> Keyword.merge(extra_opts) + + NativeBuild.install_and_deliver_android_runtime( + fixture.apk, + fixture.serials, + fixture.package, + fixture.elixir_lib, + fixture.otp_arm64, + fixture.otp_arm32, + fixture.otp_x86_64, + opts + ) + end + + defp authoritative_android_probe_runner(owner, fixture, owner_state) do + serials = Enum.sort(fixture.serials) + digest = :crypto.hash(:sha256, Enum.join(serials, <<0>>)) |> Base.encode16(case: :lower) + record = "1|ownerproof000001|#{digest}|acquired" + + fn "adb", args -> + send(owner, {:native_probe, "adb", args}) + + case args do + ["-s", _serial, "shell", "pm", "list", "packages", package] + when package == fixture.package -> + {"package:#{fixture.package}\n", 0} + + ["-s", _serial, "shell", "getprop", "ro.product.cpu.abi"] -> + {"arm64-v8a\n", 0} + + ["-s", _serial, "install", "-r", _apk] -> + {"Success\n", 0} + + ["-s", _serial, "root"] -> + {"adbd cannot run as root in production builds", 1} + + ["-s", _serial, "shell", command] -> + if String.contains?(command, "size=$(wc -c") and + not String.contains?(command, "value=$(cat") do + if Agent.get(owner_state, & &1), do: {record, 0}, else: {"replaced", 0} + else + {"", 0} + end + end + end + end + + defp authoritative_android_otp_runner(owner, hook \\ fn _args -> :ok end) do + fn executable, args, opts -> + send(owner, {:native_otp, executable, args}) + + cond do + executable in ["cp", "tar"] -> + System.cmd(executable, args, opts) + + executable == "adb" -> + hook.(args) + {"", 0} + end + end + end + + defp drain_native_commands(tag, commands \\ []) do + receive do + {^tag, executable, args} -> drain_native_commands(tag, [{executable, args} | commands]) + after + 0 -> Enum.reverse(commands) + end + end + + defp native_install_command?({"adb", ["-s", _serial, "install", "-r", _apk]}), do: true + defp native_install_command?(_command), do: false + + defp native_lock_mutation_command?({"adb", ["-s", _serial, "shell", command]}) do + String.contains?(command, ".mob_native_deploy_lock") and + (String.contains?(command, "mkdir ") or String.contains?(command, "printf %s")) + end + + defp native_lock_mutation_command?(_command), do: false + + describe "deprecated push_otp_runas/6" do + test "fails before invoking an injected command runner" do + runner = fn executable, args, _opts -> + send(self(), {:command, executable, args}) + {"unexpected", 0} + end + + assert %{ + ok?: true, + android_device_disposition: :artifact_only, + android_deploy_lock: nil, + android_payload_plan: nil + } = NativeBuild.build_outcome([{:ok, "Android"}]) + + assert {:error, reason} = + apply(NativeBuild, :push_otp_runas, [ + "serial-a", + "com.example.casein", + "/data/data/com.example.casein/files", + "/tmp/otp", + "/tmp/elixir", + [runner: runner] + ]) + + assert reason =~ "authoritative payload transaction" + refute_received {:command, _, _} + end + end + + defp native_ready_lease(serials) do + serials = Enum.sort(serials) + + %{ + bundle_id: "com.example.casein", + owner: "ownerproof000001", + serials: serials, + target_digest: + serials + |> Enum.join(<<0>>) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower), + phase: :native_ready, + state: :held_success + } + end +end diff --git a/test/mob_dev/native_build_tflite_test.exs b/test/mob_dev/native_build_tflite_test.exs new file mode 100644 index 0000000..264c739 --- /dev/null +++ b/test/mob_dev/native_build_tflite_test.exs @@ -0,0 +1,177 @@ +defmodule MobDev.NativeBuildTfliteTest do + use ExUnit.Case, async: true + + alias MobDev.NativeBuild + + # ── tflite_zig_args_android/1 ────────────────────────────────────────────── + + describe "tflite_zig_args_android/1" do + test "nil → []" do + assert NativeBuild.tflite_zig_args_android(nil) == [] + end + + test "build map → -Dtflite_static + -Dtflite_lib" do + args = + NativeBuild.tflite_zig_args_android(%{ + archive: "/build/tflite/android_arm64/libtflite_nif.a", + tflite_dir: "/cache/tflite-2.16.1-android_arm64" + }) + + assert "-Dtflite_static=true" in args + assert "-Dtflite_lib=/build/tflite/android_arm64/libtflite_nif.a" in args + end + + test "android passes the archive path directly (not dirname)" do + # The Android build.zig uses the .a path verbatim — different + # convention from iOS which uses the dir. Lock that in. + args = + NativeBuild.tflite_zig_args_android(%{ + archive: "/x/libtflite_nif.a", + tflite_dir: "/y" + }) + + refute "-Dtflite_lib=/x" in args + assert "-Dtflite_lib=/x/libtflite_nif.a" in args + end + end + + # ── tflite_zig_args_ios/1 ────────────────────────────────────────────────── + + describe "tflite_zig_args_ios/1" do + test "nil → []" do + assert NativeBuild.tflite_zig_args_ios(nil) == [] + end + + test "build map → -Dtflite_static + -Dtflite_dir + -Dtflite_framework_dir" do + args = + NativeBuild.tflite_zig_args_ios(%{ + archive: "/build/tflite/ios_device/libtflite_nif.a", + tflite_dir: "/cache/tflite-2.17.0-ios_device" + }) + + assert "-Dtflite_static=true" in args + assert "-Dtflite_dir=/build/tflite/ios_device" in args + assert "-Dtflite_framework_dir=/cache/tflite-2.17.0-ios_device/Frameworks" in args + end + + test "iOS uses the dirname of the archive path (not the path itself)" do + args = + NativeBuild.tflite_zig_args_ios(%{ + archive: "/x/libtflite_nif.a", + tflite_dir: "/y" + }) + + assert "-Dtflite_dir=/x" in args + refute "-Dtflite_dir=/x/libtflite_nif.a" in args + end + + test "iOS framework dir is rooted at tflite_dir/Frameworks" do + args = + NativeBuild.tflite_zig_args_ios(%{ + archive: "/build/libtflite_nif.a", + tflite_dir: "/cache/somewhere" + }) + + assert "-Dtflite_framework_dir=/cache/somewhere/Frameworks" in args + end + end + + # ── copy_tflite_runtime_lib_android/2 ────────────────────────────────────── + + describe "copy_tflite_runtime_lib_android/3" do + # Each test gets its own project_root under /tmp so we don't have to + # cd around (which races other tests' compilation when run in + # parallel — see helpers_test.exs's async:false comment). + setup do + project_root = + Path.join([ + System.tmp_dir!(), + "mob_test_tflite_runtime_#{System.unique_integer([:positive])}" + ]) + + File.rm_rf!(project_root) + File.mkdir_p!(project_root) + on_exit(fn -> File.rm_rf!(project_root) end) + {:ok, project_root: project_root} + end + + test "nil tflite_build → :ok, no copy", %{project_root: root} do + assert NativeBuild.copy_tflite_runtime_lib_android(nil, "arm64-v8a", root) == :ok + + refute File.exists?( + Path.join(root, "android/app/src/main/jniLibs/arm64-v8a/libtensorflowlite_jni.so") + ) + end + + test "copies .so to jniLibs/<abi>/", %{project_root: root} do + tflite_dir = Path.join(root, "fake_tflite_cache") + src_so = Path.join([tflite_dir, "jni", "arm64-v8a", "libtensorflowlite_jni.so"]) + File.mkdir_p!(Path.dirname(src_so)) + File.write!(src_so, "fake .so content") + + assert NativeBuild.copy_tflite_runtime_lib_android( + %{tflite_dir: tflite_dir}, + "arm64-v8a", + root + ) == :ok + + dst = Path.join(root, "android/app/src/main/jniLibs/arm64-v8a/libtensorflowlite_jni.so") + assert File.exists?(dst) + assert File.read!(dst) == "fake .so content" + end + + test "raises when the source .so is missing", %{project_root: root} do + tflite_dir = Path.join(root, "empty_cache") + File.mkdir_p!(tflite_dir) + + assert_raise RuntimeError, ~r/TFLite runtime lib missing/, fn -> + NativeBuild.copy_tflite_runtime_lib_android( + %{tflite_dir: tflite_dir}, + "arm64-v8a", + root + ) + end + end + + test "uses the abi parameter (not hardcoded arm64-v8a)", %{project_root: root} do + tflite_dir = Path.join(root, "armv7_cache") + src_so = Path.join([tflite_dir, "jni", "armeabi-v7a", "libtensorflowlite_jni.so"]) + File.mkdir_p!(Path.dirname(src_so)) + File.write!(src_so, "armv7 .so") + + assert NativeBuild.copy_tflite_runtime_lib_android( + %{tflite_dir: tflite_dir}, + "armeabi-v7a", + root + ) == :ok + + assert File.exists?( + Path.join( + root, + "android/app/src/main/jniLibs/armeabi-v7a/libtensorflowlite_jni.so" + ) + ) + end + end + + # ── copy_tflite_frameworks_ios/3 ─────────────────────────────────────────── + + describe "copy_tflite_frameworks_ios/3" do + test "nil tflite_build → :ok (no-op)" do + assert NativeBuild.copy_tflite_frameworks_ios(nil, "ios-arm64", "/anywhere") == :ok + end + + test "build map → :ok (currently a no-op stub since framework binaries are MH_OBJECT)" do + # The function is kept as a hook for a future TFLite release that + # ships MH_DYLIB frameworks (which would actually need embedding). + # For now MH_OBJECT binaries get linked statically into the app at + # build time, so embedding is unnecessary AND harmful (codesign + # rejects MH_OBJECT signatures on iOS 17+). + assert NativeBuild.copy_tflite_frameworks_ios( + %{tflite_dir: "/whatever"}, + "ios-arm64", + "/whatever/.app/Frameworks" + ) == :ok + end + end +end diff --git a/test/mob_dev/ndk_version_test.exs b/test/mob_dev/ndk_version_test.exs new file mode 100644 index 0000000..2af8c17 --- /dev/null +++ b/test/mob_dev/ndk_version_test.exs @@ -0,0 +1,199 @@ +defmodule MobDev.NdkVersionTest do + use ExUnit.Case, async: false + alias MobDev.NdkVersion + + setup do + # Make sure no leftover env / app config from another test biases us. + prev_env = System.get_env("MOB_ANDROID_NDK_VERSION") + prev_cfg = Application.get_env(:mob_dev, :android_ndk_version) + + System.delete_env("MOB_ANDROID_NDK_VERSION") + Application.delete_env(:mob_dev, :android_ndk_version) + + on_exit(fn -> + if prev_env, do: System.put_env("MOB_ANDROID_NDK_VERSION", prev_env) + if prev_cfg, do: Application.put_env(:mob_dev, :android_ndk_version, prev_cfg) + end) + + :ok + end + + describe "recommended/0" do + test "returns a non-empty version string" do + v = NdkVersion.recommended() + assert is_binary(v) and byte_size(v) > 0 + assert v =~ ~r/^\d+\.\d+\.\d+$/, "expected major.minor.patch, got #{inspect(v)}" + end + end + + describe "effective/0 + override/0" do + test "without overrides, returns recommended" do + assert NdkVersion.override() == :none + assert NdkVersion.effective() == NdkVersion.recommended() + end + + test "env var overrides recommended" do + System.put_env("MOB_ANDROID_NDK_VERSION", "26.1.10909125") + + assert NdkVersion.override() == {:env, "26.1.10909125"} + assert NdkVersion.effective() == "26.1.10909125" + end + + test "mob.exs config overrides recommended" do + Application.put_env(:mob_dev, :android_ndk_version, "25.1.8937393") + + assert NdkVersion.override() == {:mob_exs, "25.1.8937393"} + assert NdkVersion.effective() == "25.1.8937393" + end + + test "env var beats mob.exs config" do + Application.put_env(:mob_dev, :android_ndk_version, "25.1.8937393") + System.put_env("MOB_ANDROID_NDK_VERSION", "26.1.10909125") + + assert NdkVersion.override() == {:env, "26.1.10909125"} + assert NdkVersion.effective() == "26.1.10909125" + end + end + + describe "installed?/1" do + test "returns false for a nonexistent version (unless test env happens to have it)" do + # Pick something obviously not installed. + refute NdkVersion.installed?("99.99.99999999") + end + + test "returns false when SDK root missing" do + original = System.get_env("ANDROID_HOME") + original_sdk_root = System.get_env("ANDROID_SDK_ROOT") + + System.put_env("ANDROID_HOME", "/nonexistent/sdk/root") + System.delete_env("ANDROID_SDK_ROOT") + + try do + refute NdkVersion.installed?(NdkVersion.recommended()) + after + if original, + do: System.put_env("ANDROID_HOME", original), + else: System.delete_env("ANDROID_HOME") + + if original_sdk_root, do: System.put_env("ANDROID_SDK_ROOT", original_sdk_root) + end + end + end + + describe "project_pinned/1" do + @tag :tmp_dir + test "extracts ndkVersion from a generated build.gradle", %{tmp_dir: dir} do + gradle_path = Path.join(dir, "android/app/build.gradle") + File.mkdir_p!(Path.dirname(gradle_path)) + + File.write!(gradle_path, """ + android { + namespace 'com.example.foo' + compileSdk 34 + ndkVersion '27.2.12479018' + + defaultConfig { + applicationId "com.example.foo" + minSdk 28 + } + } + """) + + assert NdkVersion.project_pinned(dir) == "27.2.12479018" + end + + @tag :tmp_dir + test "returns nil when ndkVersion not pinned", %{tmp_dir: dir} do + gradle_path = Path.join(dir, "android/app/build.gradle") + File.mkdir_p!(Path.dirname(gradle_path)) + + File.write!(gradle_path, """ + android { + namespace 'com.example.foo' + compileSdk 34 + + defaultConfig { + applicationId "com.example.foo" + minSdk 28 + } + } + """) + + assert NdkVersion.project_pinned(dir) == nil + end + + @tag :tmp_dir + test "returns nil for project without android/", %{tmp_dir: dir} do + assert NdkVersion.project_pinned(dir) == nil + end + + @tag :tmp_dir + test "reads from build.gradle.kts as fallback", %{tmp_dir: dir} do + gradle_path = Path.join(dir, "android/app/build.gradle.kts") + File.mkdir_p!(Path.dirname(gradle_path)) + + File.write!(gradle_path, """ + android { + ndkVersion = "26.1.10909125" + } + """) + + assert NdkVersion.project_pinned(dir) == "26.1.10909125" + end + end + + describe "install_command/0" do + test "produces an sdkmanager invocation referencing the recommended version" do + cmd = NdkVersion.install_command() + assert cmd =~ "sdkmanager" + assert cmd =~ NdkVersion.recommended() + end + end + + # The single source of truth cpp_archive / nx_eigen_nif / native_build all use. + # MOB-89: cpp_archive + nx_eigen_nif used to hardcode ~/Library/Android/sdk, + # ignoring ANDROID_HOME — so a cpp_archive build failed wherever the NDK lived + # elsewhere. These pin that root/sysroot/toolchain honor the SDK env. + describe "root/0 + host/0 + sysroot/0 (shared NDK path — MOB-89)" do + setup do + prev_home = System.get_env("ANDROID_HOME") + prev_root = System.get_env("ANDROID_SDK_ROOT") + + on_exit(fn -> + restore = fn k, v -> if v, do: System.put_env(k, v), else: System.delete_env(k) end + restore.("ANDROID_HOME", prev_home) + restore.("ANDROID_SDK_ROOT", prev_root) + end) + + :ok + end + + test "root/0 honors ANDROID_HOME, not a hardcoded ~/Library path" do + System.delete_env("ANDROID_SDK_ROOT") + System.put_env("ANDROID_HOME", "/opt/custom-sdk") + + assert NdkVersion.root() == Path.join(["/opt/custom-sdk", "ndk", NdkVersion.effective()]) + refute NdkVersion.root() =~ "Library/Android/sdk" + end + + test "root/0 falls back to ANDROID_SDK_ROOT when ANDROID_HOME is unset" do + System.delete_env("ANDROID_HOME") + System.put_env("ANDROID_SDK_ROOT", "/opt/sdkroot") + + assert NdkVersion.root() =~ "/opt/sdkroot/ndk/" + end + + test "host/0 is the single NDK prebuilt tag for this OS" do + assert NdkVersion.host() in ["darwin-x86_64", "linux-x86_64"] + end + + test "sysroot/0 and toolchain_bin/0 compose root + host" do + System.delete_env("ANDROID_SDK_ROOT") + System.put_env("ANDROID_HOME", "/opt/custom-sdk") + base = Path.join([NdkVersion.root(), "toolchains", "llvm", "prebuilt", NdkVersion.host()]) + + assert NdkVersion.sysroot() == Path.join(base, "sysroot") + assert NdkVersion.toolchain_bin() == Path.join(base, "bin") + end + end +end diff --git a/test/mob_dev/network_test.exs b/test/mob_dev/network_test.exs index bf46cb5..93b1d01 100644 --- a/test/mob_dev/network_test.exs +++ b/test/mob_dev/network_test.exs @@ -27,9 +27,9 @@ defmodule MobDev.NetworkTest do end test "matches 172.16.x.x through 172.31.x.x" do - assert Network.first_lan_ip([{172, 16, 0, 1}]) == {172, 16, 0, 1} - assert Network.first_lan_ip([{172, 31, 0, 1}]) == {172, 31, 0, 1} - assert Network.first_lan_ip([{172, 20, 5, 1}]) == {172, 20, 5, 1} + assert Network.first_lan_ip([{172, 16, 0, 1}]) == {172, 16, 0, 1} + assert Network.first_lan_ip([{172, 31, 0, 1}]) == {172, 31, 0, 1} + assert Network.first_lan_ip([{172, 20, 5, 1}]) == {172, 20, 5, 1} end test "does not match 172.15.x.x (just below private range)" do diff --git a/test/mob_dev/nx_eigen_nif_test.exs b/test/mob_dev/nx_eigen_nif_test.exs new file mode 100644 index 0000000..90eed9b --- /dev/null +++ b/test/mob_dev/nx_eigen_nif_test.exs @@ -0,0 +1,420 @@ +defmodule MobDev.NxEigenNifTest do + use ExUnit.Case, async: false + + import Mox + + alias MobDev.NxEigenNif + + setup :verify_on_exit! + + setup do + Application.put_env(:mob_dev, :release_shell, MobDev.Release.ShellMock) + on_exit(fn -> Application.delete_env(:mob_dev, :release_shell) end) + :ok + end + + # ── Source list — surface lock ──────────────────────────────────────── + + describe "sources/0" do + test "compiles the main NIF (from nx_eigen) + the Eigen-FFT bridge (from mob_dev priv)" do + srcs = NxEigenNif.sources() + + assert {:nx_eigen, "nx_eigen_nif.cpp"} in srcs + assert {:bridge, "nx_eigen_fft_eigen.cpp"} in srcs + + # NxEigen's own FFT variants are NOT compiled — we use Eigen's + # built-in kissfft via our own bridge file instead. + basenames = Enum.map(srcs, fn {_root, name} -> name end) + refute "nx_eigen_fft_fftw.cpp" in basenames + refute "nx_eigen_fft_none.cpp" in basenames + end + + test "all entries are .cpp files" do + assert Enum.all?(NxEigenNif.sources(), fn {_root, name} -> + String.ends_with?(name, ".cpp") + end) + end + end + + # ── target_spec/1 — pinned surface per target ───────────────────────── + + describe "target_spec/1" do + test "android_arm64 — aarch64 arch dir, Android hardening, ELF symbol" do + spec = NxEigenNif.target_spec(:android_arm64) + + assert spec.arch_dir == "aarch64-unknown-linux-android" + assert spec.nm_symbol == "nx_eigen_nif_init" + assert "-mbranch-protection=standard" in spec.extra_cxxflags + assert "-fstack-clash-protection" in spec.extra_cxxflags + assert "-D_GNU_SOURCE" in spec.extra_cxxflags + + refute "-march=armv7-a" in spec.extra_cxxflags + end + + test "android_arm32 — ABI flags AND Android hardening" do + spec = NxEigenNif.target_spec(:android_arm32) + + assert spec.arch_dir == "arm-unknown-linux-androideabi" + assert spec.nm_symbol == "nx_eigen_nif_init" + + assert "-march=armv7-a" in spec.extra_cxxflags + assert "-mfloat-abi=softfp" in spec.extra_cxxflags + assert "-mthumb" in spec.extra_cxxflags + + assert "-mbranch-protection=standard" in spec.extra_cxxflags + assert "-D_GNU_SOURCE" in spec.extra_cxxflags + end + + test "ios_sim — Mach-O symbol with leading underscore, no Android flags" do + spec = NxEigenNif.target_spec(:ios_sim) + + assert spec.arch_dir == "aarch64-apple-iossimulator" + assert spec.nm_symbol == "_nx_eigen_nif_init" + assert spec.extra_cxxflags == [] + end + + test "ios_device — distinct from sim (different arch_dir)" do + sim = NxEigenNif.target_spec(:ios_sim) + device = NxEigenNif.target_spec(:ios_device) + + assert sim.arch_dir != device.arch_dir + assert device.arch_dir == "aarch64-apple-ios" + assert device.nm_symbol == "_nx_eigen_nif_init" + end + + test "targets/0 enumerates all four" do + assert NxEigenNif.targets() == [:android_arm64, :android_arm32, :ios_sim, :ios_device] + end + end + + # ── cxxflags/2 — pure assembly ──────────────────────────────────────── + + describe "cxxflags/2" do + @no_includes [] + + test "every target starts with the same base CXXFLAGS" do + base = NxEigenNif.base_cxxflags() + + for target_id <- NxEigenNif.targets() do + spec = NxEigenNif.target_spec(target_id) + flags = NxEigenNif.cxxflags(spec, @no_includes) + + for base_flag <- base do + assert base_flag in flags, "target #{target_id} missing base flag #{base_flag}" + end + end + end + + test "every target compiles as C++17" do + for target_id <- NxEigenNif.targets() do + spec = NxEigenNif.target_spec(target_id) + flags = NxEigenNif.cxxflags(spec, @no_includes) + assert "-std=c++17" in flags + end + end + + test "every target keeps exceptions and RTTI enabled" do + # Fine + NxEigen rely on exception-based error handling + # (std::runtime_error / std::invalid_argument from FINE_INIT and + # decode helpers). Disabling these breaks compilation immediately. + # Test pins the surface so a future "size optimization" attempt has + # to actually revisit the dep code before flipping the flag. + for target_id <- NxEigenNif.targets() do + spec = NxEigenNif.target_spec(target_id) + flags = NxEigenNif.cxxflags(spec, @no_includes) + refute "-fno-exceptions" in flags + refute "-fno-rtti" in flags + end + end + + test "STATIC_ERLANG_NIF_LIBNAME=nx_eigen is set on every target" do + # This is what forces FINE_INIT to emit nx_eigen_nif_init instead + # of the literal `NAME_nif_init` symbol Fine's macro produces. + # Dropping it breaks the entire static-link approach. + for target_id <- NxEigenNif.targets() do + spec = NxEigenNif.target_spec(target_id) + flags = NxEigenNif.cxxflags(spec, @no_includes) + assert "-DSTATIC_ERLANG_NIF_LIBNAME=nx_eigen" in flags + end + end + + test "android targets include the Android hardening flags" do + for target_id <- [:android_arm64, :android_arm32] do + spec = NxEigenNif.target_spec(target_id) + flags = NxEigenNif.cxxflags(spec, @no_includes) + + assert "-mbranch-protection=standard" in flags + assert "-D_GNU_SOURCE" in flags + end + end + + test "iOS targets do NOT include Android hardening flags" do + for target_id <- [:ios_sim, :ios_device] do + spec = NxEigenNif.target_spec(target_id) + flags = NxEigenNif.cxxflags(spec, @no_includes) + + refute "-mbranch-protection=standard" in flags + refute "-D_GNU_SOURCE" in flags + refute "-fstack-clash-protection" in flags + end + end + + test "include paths are prefixed with -I, in given order, after the flags" do + spec = NxEigenNif.target_spec(:android_arm64) + flags = NxEigenNif.cxxflags(spec, ["/eigen", "/fine/c_include", "/erts/include"]) + + assert "-I/eigen" in flags + assert "-I/fine/c_include" in flags + assert "-I/erts/include" in flags + + eigen_idx = Enum.find_index(flags, &(&1 == "-I/eigen")) + fine_idx = Enum.find_index(flags, &(&1 == "-I/fine/c_include")) + erts_idx = Enum.find_index(flags, &(&1 == "-I/erts/include")) + + assert eigen_idx < fine_idx + assert fine_idx < erts_idx + end + + test "arm32 emits -march=armv7-a BEFORE Android hardening flags" do + spec = NxEigenNif.target_spec(:android_arm32) + flags = NxEigenNif.cxxflags(spec, @no_includes) + + march_idx = Enum.find_index(flags, &(&1 == "-march=armv7-a")) + branch_idx = Enum.find_index(flags, &(&1 == "-mbranch-protection=standard")) + + assert is_integer(march_idx), "-march=armv7-a not present in #{inspect(flags)}" + + assert is_integer(branch_idx), + "-mbranch-protection=standard not present in #{inspect(flags)}" + + assert march_idx < branch_idx + end + end + + # ── check_symbol_present/3 — pure nm output parser ──────────────────── + + describe "check_symbol_present/3" do + test "accepts ELF nm output with the symbol" do + output = """ + 0000000000000000 T nx_eigen_nif_init + 0000000000000018 T some_helper + """ + + assert :ok = + NxEigenNif.check_symbol_present(output, "nx_eigen_nif_init", "/path/libnx_eigen.a") + end + + test "accepts Mach-O nm output (leading underscore)" do + output = "0000000000000000 T _nx_eigen_nif_init\n" + + assert :ok = + NxEigenNif.check_symbol_present( + output, + "_nx_eigen_nif_init", + "/path/libnx_eigen.a" + ) + end + + test "rejects when symbol is undefined (U flag, not T)" do + # This is the failure mode if FINE_INIT didn't emit the symbol — + # something else's reference to nx_eigen_nif_init shows up as U. + output = " U nx_eigen_nif_init\n" + + assert {:error, {:precondition_failed, msg}} = + NxEigenNif.check_symbol_present(output, "nx_eigen_nif_init", "/p/libnx_eigen.a") + + assert msg =~ "T nx_eigen_nif_init" + assert msg =~ "STATIC_ERLANG_NIF_LIBNAME" + end + + test "rejects when symbol is missing entirely" do + output = """ + 0000000000000000 T some_other_init + """ + + assert {:error, {:precondition_failed, _}} = + NxEigenNif.check_symbol_present(output, "nx_eigen_nif_init", "/p/libnx_eigen.a") + end + + test "rejects when only `NAME_nif_init` is present (LIBNAME wasn't set)" do + # This is the specific failure mode if -DSTATIC_ERLANG_NIF_LIBNAME + # gets dropped: Fine's FINE_INIT macro passes the literal token + # NAME to ERL_NIF_INIT_DECL, which then emits NAME_nif_init as + # the symbol name. Catch this regression class explicitly. + output = "0000000000000000 T NAME_nif_init\n" + + assert {:error, {:precondition_failed, _}} = + NxEigenNif.check_symbol_present(output, "nx_eigen_nif_init", "/p/libnx_eigen.a") + end + + test "doesn't false-match a leading-substring suffix" do + output = "0000000000000000 T _nx_eigen_nif_init\n" + + assert {:error, {:precondition_failed, _}} = + NxEigenNif.check_symbol_present(output, "nx_eigen_nif_init", "/p/libnx_eigen.a") + end + end + + # ── build/2 — required-option checking ──────────────────────────────── + + describe "build/2 — required options" do + test "missing :nx_eigen_dir is a precondition_failed" do + assert {:error, {:precondition_failed, msg}} = NxEigenNif.build(:ios_device, []) + assert msg =~ ":nx_eigen_dir" + end + + test "missing :fine_dir is a precondition_failed" do + assert {:error, {:precondition_failed, msg}} = + NxEigenNif.build(:ios_device, nx_eigen_dir: "/d") + + assert msg =~ ":fine_dir" + end + + test "missing :erts_include is a precondition_failed" do + assert {:error, {:precondition_failed, msg}} = + NxEigenNif.build(:ios_device, nx_eigen_dir: "/d", fine_dir: "/f") + + assert msg =~ ":erts_include" + end + + test "missing :out_dir is a precondition_failed" do + assert {:error, {:precondition_failed, msg}} = + NxEigenNif.build(:ios_device, + nx_eigen_dir: "/d", + fine_dir: "/f", + erts_include: "/e" + ) + + assert msg =~ ":out_dir" + end + end + + # ── build/2 against the Mox — full sequence ─────────────────────────── + + describe "build/2 — ios_device full sequence" do + test "uses xcrun -sdk iphoneos clang++ with -stdlib=libc++, archives, verifies Mach-O symbol" do + stub_all_dir_checks_true() + Mox.expect(MobDev.Release.ShellMock, :mkdir_p, 2, fn _ -> :ok end) + + # 2 compile calls (one per source). + Mox.expect(MobDev.Release.ShellMock, :cmd, 2, fn argv, _opts -> + # iOS cxx argv: ["xcrun", "-sdk", "iphoneos", "clang++", + # "-arch", "arm64", "-miphoneos-version-min=17.0", "-stdlib=libc++", ...flags, "-c", "-o", obj, src] + assert Enum.take(argv, 8) == [ + "xcrun", + "-sdk", + "iphoneos", + "clang++", + "-arch", + "arm64", + "-miphoneos-version-min=17.0", + "-stdlib=libc++" + ] + + assert "-c" in argv + assert "-std=c++17" in argv + assert "-DSTATIC_ERLANG_NIF_LIBNAME=nx_eigen" in argv + {:ok, ""} + end) + + Mox.expect(MobDev.Release.ShellMock, :rm_f, fn _ -> :ok end) + + # ar rcs + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert Enum.take(argv, 3) == ["xcrun", "-sdk", "iphoneos", "ar"] |> Enum.take(3) + assert "rcs" in argv + {:ok, ""} + end) + + # ranlib + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert Enum.take(argv, 3) == ["xcrun", "-sdk", "iphoneos", "ranlib"] |> Enum.take(3) + {:ok, ""} + end) + + # nm — return Mach-O underscored symbol + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert Enum.take(argv, 3) == ["xcrun", "-sdk", "iphoneos", "nm"] |> Enum.take(3) + {:ok, "0000000000000000 T _nx_eigen_nif_init\n"} + end) + + assert {:ok, info} = + NxEigenNif.build(:ios_device, + nx_eigen_dir: "/fake/nx_eigen", + fine_dir: "/fake/fine", + erts_include: "/fake/erts/include", + out_dir: "/fake/out" + ) + + assert info.target == :ios_device + assert info.archive == "/fake/out/libnx_eigen.a" + assert length(info.objects) == 2 + end + end + + describe "build/2 — android_arm64 full sequence" do + # The NDK precheck inspects the real filesystem (NdkVersion.installed?) + # — it's not behind the Shell mock. Skip the full-sequence assertion + # when the right NDK isn't installed locally (CI without an NDK, + # dev machines on a different NDK version). The flag-pinning tests + # in cxxflags/2 cover the surface this test was guarding. + @tag :android_ndk + test "uses NDK clang++, archives, verifies ELF symbol" do + if MobDev.NdkVersion.installed?(MobDev.NdkVersion.effective()) do + stub_all_dir_checks_true() + Mox.expect(MobDev.Release.ShellMock, :mkdir_p, 2, fn _ -> :ok end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, 2, fn argv, _opts -> + assert hd(argv) =~ "aarch64-linux-android28-clang++" + assert "-c" in argv + assert "-DSTATIC_ERLANG_NIF_LIBNAME=nx_eigen" in argv + assert "-mbranch-protection=standard" in argv + {:ok, ""} + end) + + Mox.expect(MobDev.Release.ShellMock, :rm_f, fn _ -> :ok end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert hd(argv) =~ "llvm-ar" + assert "rcs" in argv + {:ok, ""} + end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert hd(argv) =~ "llvm-ranlib" + {:ok, ""} + end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert hd(argv) =~ "llvm-nm" + {:ok, "0000000000000000 T nx_eigen_nif_init\n"} + end) + + assert {:ok, info} = + NxEigenNif.build(:android_arm64, + nx_eigen_dir: "/fake/nx_eigen", + fine_dir: "/fake/fine", + erts_include: "/fake/erts/include", + out_dir: "/fake/out" + ) + + assert info.target == :android_arm64 + assert info.archive == "/fake/out/libnx_eigen.a" + else + # No NDK — bail before installing Mox expectations so + # verify_on_exit doesn't complain. + :skipped + end + end + end + + # ── Helpers ─────────────────────────────────────────────────────────── + + defp stub_all_dir_checks_true do + # Every dir? check in the precheck path returns true so we exercise + # the happy path through compile/archive/verify without touching + # the real filesystem. + Mox.stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + Mox.stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + end +end diff --git a/test/mob_dev/otp_asset_bundle_test.exs b/test/mob_dev/otp_asset_bundle_test.exs new file mode 100644 index 0000000..3868cb6 --- /dev/null +++ b/test/mob_dev/otp_asset_bundle_test.exs @@ -0,0 +1,176 @@ +defmodule MobDev.OtpAssetBundleTest do + use ExUnit.Case, async: true + + alias MobDev.OtpAssetBundle + + describe "default_stripped_prefixes/0" do + test "includes the high-payoff lib prefixes that no Mob app uses" do + prefixes = OtpAssetBundle.default_stripped_prefixes() + + # These are the biggest space hogs that no Mob app ever loads. + # If you remove one of these from the strip list, expect bundle size + # to balloon — only do it because a specific app actually needs it. + assert "megaco" in prefixes + assert "wx" in prefixes + assert "observer" in prefixes + assert "debugger" in prefixes + assert "diameter" in prefixes + end + + test "does NOT strip prefixes that the BEAM hard-depends on" do + prefixes = OtpAssetBundle.default_stripped_prefixes() + + # Removing any of these would prevent BEAM from booting. + # If you find one of them on this list, the OTP zip will be broken. + refute "kernel" in prefixes + refute "stdlib" in prefixes + refute "sasl" in prefixes + refute "compiler" in prefixes + refute "crypto" in prefixes + end + end + + describe "build/3" do + test "errors clearly when the source path is not a directory" do + assert {:error, msg} = OtpAssetBundle.build("/tmp/does-not-exist-xyz", "/tmp/out.zip") + assert msg =~ "not a directory" + end + + test "errors clearly when the source has no erts-*/ directory" do + empty = Path.join(System.tmp_dir!(), "mob_otp_test_empty_#{:rand.uniform(999_999)}") + File.mkdir_p!(empty) + + try do + assert {:error, msg} = OtpAssetBundle.build(empty, "/tmp/out.zip") + assert msg =~ "no erts-" + after + File.rm_rf!(empty) + end + end + + test "produces a zip with stripped libs removed and remaining tree preserved" do + source = build_fake_otp_tree() + target_zip = Path.join(System.tmp_dir!(), "mob_otp_test_out_#{:rand.uniform(999_999)}.zip") + + try do + assert {:ok, info} = OtpAssetBundle.build(source, target_zip) + assert info.zipped_files > 0 + assert info.zip_size_kb >= 0 + assert File.exists?(target_zip) + + # Verify contents using `unzip -l` + {listing, 0} = System.cmd("unzip", ["-l", target_zip], stderr_to_stdout: true) + + # Stripped libs are gone + refute listing =~ "lib/megaco-1.0.0/" + refute listing =~ "lib/wx-1.0.0/" + + # Kept libs are present + assert listing =~ "lib/elixir-1.19.0/" + assert listing =~ "erts-16.0/" + + # Static archive (.a) was deleted + refute listing =~ "libcrypto.a" + + # erts-*/bin was emptied (the file inside should be gone) + refute listing =~ "erts-16.0/bin/erl_child_setup" + after + File.rm_rf!(source) + File.rm(target_zip) + end + end + + test "slim: false ships the OTP tree untouched (no lib stripping)" do + source = build_fake_otp_tree() + + target_zip = + Path.join(System.tmp_dir!(), "mob_otp_test_noslim_#{:rand.uniform(999_999)}.zip") + + try do + assert {:ok, _} = OtpAssetBundle.build(source, target_zip, slim: false) + {listing, 0} = System.cmd("unzip", ["-l", target_zip], stderr_to_stdout: true) + + # With slim: false, libs that the default strip would remove survive — + # required for apps running arbitrary user code (Mix.install) where any + # OTP lib (inets, ssl, runtime_tools, …) might be needed at runtime. + assert listing =~ "lib/megaco-1.0.0/" + assert listing =~ "lib/wx-1.0.0/" + after + File.rm_rf!(source) + File.rm(target_zip) + end + end + + test "respects :keep_prefixes — opts can re-add a stripped lib" do + source = build_fake_otp_tree() + target_zip = Path.join(System.tmp_dir!(), "mob_otp_test_keep_#{:rand.uniform(999_999)}.zip") + + try do + assert {:ok, _} = OtpAssetBundle.build(source, target_zip, keep_prefixes: ["megaco"]) + {listing, 0} = System.cmd("unzip", ["-l", target_zip], stderr_to_stdout: true) + assert listing =~ "lib/megaco-1.0.0/" + after + File.rm_rf!(source) + File.rm(target_zip) + end + end + + test "respects :strip_extra_prefixes — opts can strip an additional lib" do + source = build_fake_otp_tree() + + target_zip = + Path.join(System.tmp_dir!(), "mob_otp_test_extra_#{:rand.uniform(999_999)}.zip") + + try do + assert {:ok, _} = OtpAssetBundle.build(source, target_zip, strip_extra_prefixes: ["eex"]) + {listing, 0} = System.cmd("unzip", ["-l", target_zip], stderr_to_stdout: true) + refute listing =~ "lib/eex-1.19.0/" + after + File.rm_rf!(source) + File.rm(target_zip) + end + end + end + + # ── Helpers ──────────────────────────────────────────────────────────── + + defp build_fake_otp_tree do + root = Path.join(System.tmp_dir!(), "mob_otp_fake_#{:rand.uniform(999_999)}") + File.rm_rf!(root) + File.mkdir_p!(root) + + # erts-*/bin (will be stripped) + File.mkdir_p!(Path.join(root, "erts-16.0/bin")) + File.write!(Path.join(root, "erts-16.0/bin/erl_child_setup"), "fake binary") + + # erts-*/include (kept — headers) + File.mkdir_p!(Path.join(root, "erts-16.0/include")) + File.write!(Path.join(root, "erts-16.0/include/erl_nif.h"), "/* fake */") + + # lib/elixir-* (kept) + File.mkdir_p!(Path.join(root, "lib/elixir-1.19.0/ebin")) + File.write!(Path.join(root, "lib/elixir-1.19.0/ebin/elixir.beam"), "fake beam") + + # lib/eex-* (kept by default; one test strips this via opts) + File.mkdir_p!(Path.join(root, "lib/eex-1.19.0/ebin")) + File.write!(Path.join(root, "lib/eex-1.19.0/ebin/eex.beam"), "fake beam") + + # lib/megaco-* (will be stripped) + File.mkdir_p!(Path.join(root, "lib/megaco-1.0.0/ebin")) + File.write!(Path.join(root, "lib/megaco-1.0.0/ebin/megaco.beam"), "fake") + + # lib/wx-* (will be stripped) + File.mkdir_p!(Path.join(root, "lib/wx-1.0.0/ebin")) + File.write!(Path.join(root, "lib/wx-1.0.0/ebin/wx.beam"), "fake") + + # lib/crypto-*/priv/bin (priv/bin file — will be stripped) + File.mkdir_p!(Path.join(root, "lib/crypto-5.0.0/priv/bin")) + File.write!(Path.join(root, "lib/crypto-5.0.0/priv/bin/openssl_wrap"), "fake") + + # libcrypto.a static archive (will be stripped) + File.mkdir_p!(Path.join(root, "lib/crypto-5.0.0/priv/lib")) + File.write!(Path.join(root, "lib/crypto-5.0.0/priv/lib/libcrypto.a"), "fake archive") + + root + end +end diff --git a/test/mob_dev/otp_audit/slim_test.exs b/test/mob_dev/otp_audit/slim_test.exs new file mode 100644 index 0000000..878b4cc --- /dev/null +++ b/test/mob_dev/otp_audit/slim_test.exs @@ -0,0 +1,561 @@ +defmodule MobDev.OtpAudit.SlimTest do + use ExUnit.Case, async: true + + alias MobDev.OtpAudit.Slim + + # The slim pass operates on an OTP bundle directory in place. Tests + # build a tmp tree shaped like a real bundle (erts-<vsn>/, lib/<name>-<vsn>/, + # priv/bin/, src/, include/, *.so, *.a) and assert that each phase + # removes exactly what it claims to. + + setup do + root = + Path.join(System.tmp_dir!(), "mob_otp_slim_test_#{System.unique_integer([:positive])}") + + File.mkdir_p!(root) + on_exit(fn -> File.rm_rf!(root) end) + {:ok, root: root} + end + + defp make_lib(root, name, version, opts) do + dir = Path.join([root, "lib", "#{name}-#{version}"]) + File.mkdir_p!(Path.join(dir, "ebin")) + File.write!(Path.join([dir, "ebin", "#{name}.app"]), "{application, #{name}, []}.") + + for mod <- opts[:modules] || ["#{name}_dummy"] do + File.write!(Path.join([dir, "ebin", "#{mod}.beam"]), beam_or_stub(opts[:real_beam])) + end + + if opts[:src], do: File.mkdir_p!(Path.join(dir, "src")) + if opts[:include], do: File.mkdir_p!(Path.join(dir, "include")) + dir + end + + # A `.beam` file just bytes the strip_release walker can ignore. + # We don't want :beam_lib.strip_release/1 to error out the tree + # because of one stub; it tolerates per-file errors but the test + # cares about behaviour at the directory level. + defp beam_or_stub(true), do: real_beam_bytes() + defp beam_or_stub(_), do: "" + + # Borrow a known-good real beam from the running host. Empty file + # also works for most phases but :beam_lib.strip_release/1 logs + # errors on truly empty files, so use a real one to keep test + # output clean. + defp real_beam_bytes do + File.read!(:code.which(Enum)) + end + + defp make_erts(root, vsn) do + erts = Path.join(root, "erts-#{vsn}") + File.mkdir_p!(Path.join(erts, "bin")) + File.write!(Path.join([erts, "bin", "erl"]), "fake") + File.write!(Path.join([erts, "bin", "erlc"]), "fake") + erts + end + + # ── compute_strip_set/1 — pure ───────────────────────────────────────── + + describe "compute_strip_set/1" do + test "returns the hardcoded baseline when no overrides given" do + set = Slim.compute_strip_set([]) + assert "megaco" in set + assert "snmp" in set + assert "dialyzer" in set + # Baseline sorted, no duplicates. + assert set == Enum.sort(Enum.uniq(set)) + end + + test ":drop_libs adds to the baseline" do + set = Slim.compute_strip_set(drop_libs: ["custom_extra"]) + assert "custom_extra" in set + # Baseline preserved. + assert "megaco" in set + end + + test ":keep_libs subtracts from the baseline" do + refute "megaco" in Slim.compute_strip_set(keep_libs: ["megaco"]) + # Untouched baseline members still there. + assert "snmp" in Slim.compute_strip_set(keep_libs: ["megaco"]) + end + + test ":keep_libs wins over :drop_libs when both list the same lib" do + # Force-keep beats force-drop. This is the safe default: if + # the user has both, they probably forgot one of them and we'd + # rather ship a too-fat build than a missing-lib crash. + refute "myapp" in Slim.compute_strip_set(drop_libs: ["myapp"], keep_libs: ["myapp"]) + end + + test "deduplicates and sorts" do + set = + Slim.compute_strip_set( + drop_libs: ["zzz", "aaa", "megaco", "zzz"], + keep_libs: [] + ) + + assert set == Enum.sort(set) + assert Enum.count(set, &(&1 == "zzz")) == 1 + assert Enum.count(set, &(&1 == "megaco")) == 1 + end + end + + # ── compute_strip_set/1 with :audit_input ────────────────────────────── + + describe "compute_strip_set/1 — :audit_input expansion" do + test "foreign_app_names from the audit always join the strip set" do + audit = %{ + foreign_app_names: ["pigeon", "push_notify"], + strippable_libs: [], + trace_strippable_libs: nil + } + + set = Slim.compute_strip_set(audit_input: audit) + + assert "pigeon" in set + assert "push_notify" in set + # Baseline preserved. + assert "megaco" in set + end + + test "without trace, strippable_libs alone does NOT expand the set (NIF false-pos risk)" do + # exqlite-shape: statically unreachable because static graph + # can't see :erlang.load_nif, but actually needed at runtime. + # Without trace, we must NOT strip it. + audit = %{ + foreign_app_names: [], + strippable_libs: ["exqlite"], + trace_strippable_libs: nil + } + + set = Slim.compute_strip_set(audit_input: audit) + + refute "exqlite" in set + end + + test "with trace, strippable ∩ trace_strippable joins (high-confidence)" do + audit = %{ + foreign_app_names: [], + # exqlite statically unreachable, but trace confirms it IS called + # — so the intersection excludes it. xmerl unreachable AND not + # in trace → confirmed. + strippable_libs: ["xmerl", "exqlite"], + trace_strippable_libs: ["xmerl", "edoc"] + } + + set = Slim.compute_strip_set(audit_input: audit) + + assert "xmerl" in set + refute "exqlite" in set, "trace catches the NIF false-positive — exqlite not stripped" + end + + test "with trace, trace-only strippable (not in static) joins too" do + # megaco-shape: 1/65 statically reachable (in static_libs view it + # is NOT in strippable_libs), trace says 0 modules called → trace + # alone proves it strippable. This is the unblocking signal. + audit = %{ + foreign_app_names: [], + # megaco is NOT statically strippable (some modules reachable). + strippable_libs: ["xmerl"], + # But the trace says megaco is never actually called. + trace_strippable_libs: ["megaco", "xmerl"] + } + + set = Slim.compute_strip_set(audit_input: audit) + + assert "megaco" in set + assert "xmerl" in set + end + + test ":keep_libs still wins over audit-driven expansion" do + audit = %{ + foreign_app_names: ["pigeon"], + strippable_libs: ["megaco"], + trace_strippable_libs: ["megaco", "pigeon"] + } + + set = Slim.compute_strip_set(audit_input: audit, keep_libs: ["pigeon", "megaco"]) + + refute "pigeon" in set + refute "megaco" in set + end + + test ":drop_libs combines with audit expansion" do + audit = %{ + foreign_app_names: ["pigeon"], + strippable_libs: [], + trace_strippable_libs: nil + } + + set = Slim.compute_strip_set(audit_input: audit, drop_libs: ["another_dep"]) + + assert "pigeon" in set + assert "another_dep" in set + end + + test "nil audit_input is a no-op (default behaviour)" do + set = Slim.compute_strip_set(audit_input: nil) + assert set == Slim.compute_strip_set([]) + end + + test "audit_input with no trace-strippable, only foreign — exactly foreign added" do + audit = %{ + foreign_app_names: ["one_off"], + strippable_libs: ["unused_otp_lib"], + trace_strippable_libs: nil + } + + set = Slim.compute_strip_set(audit_input: audit) + + assert "one_off" in set + refute "unused_otp_lib" in set + end + end + + # ── always_keep_libs guardrail ───────────────────────────────────────── + + describe "compute_strip_set/1 — always_keep_libs guardrail" do + test "audit-driven expansion is filtered through always_keep_libs" do + # crypto, sasl, public_key et al. are in always_keep_libs. + # Trace-only would say they're strippable (a 60s trace misses + # boot-time sasl, missed TLS during the window, etc.) — the + # guardrail must intercept that. + audit = %{ + foreign_app_names: [], + strippable_libs: [], + trace_strippable_libs: [ + "crypto", + "sasl", + "public_key", + "asn1", + "ssl", + "kernel", + "stdlib", + "elixir", + "logger", + "megaco" + ] + } + + set = Slim.compute_strip_set(audit_input: audit) + + for lib <- Slim.always_keep_libs() do + refute lib in set, "always_keep_libs lib #{inspect(lib)} leaked into strip set" + end + + # megaco is NOT in always_keep_libs → trace-only signal still + # carries it through to the strip set. + assert "megaco" in set + end + + test "always_keep_libs/0 covers the canonical boot-critical set" do + keep = Slim.always_keep_libs() + # Pin these — losing any would break apps in subtle ways the + # trace can't catch. + for lib <- ~w(kernel stdlib elixir logger sasl crypto public_key asn1 ssl) do + assert lib in keep, "expected #{lib} in always_keep_libs" + end + end + + test ":drop_libs CAN force-strip an always-keep lib (user-explicit escape hatch)" do + # If the user knows their app has zero TLS and is willing to + # take responsibility, they can override the guardrail. + audit = %{ + foreign_app_names: [], + strippable_libs: [], + trace_strippable_libs: ["crypto"] + } + + # Without drop_libs the guardrail keeps crypto. + set_default = Slim.compute_strip_set(audit_input: audit) + refute "crypto" in set_default + + # With drop_libs the user wins. + set_explicit = Slim.compute_strip_set(audit_input: audit, drop_libs: ["crypto"]) + assert "crypto" in set_explicit + end + + test ":keep_libs over :drop_libs over guardrail — full precedence test" do + audit = %{ + foreign_app_names: [], + strippable_libs: [], + trace_strippable_libs: ["crypto"] + } + + # drop_libs adds crypto, but keep_libs subtracts it. + set = + Slim.compute_strip_set( + audit_input: audit, + drop_libs: ["crypto"], + keep_libs: ["crypto"] + ) + + refute "crypto" in set, "keep_libs is the user's last word — wins over drop_libs" + end + + test "guardrail only applies to audit-driven expansion, not hardcoded baseline" do + # The hardcoded baseline doesn't include any always_keep lib — + # crypto, sasl etc. aren't in @hardcoded_prefixes. Pin that + # invariant. + set = Slim.compute_strip_set([]) + + for lib <- Slim.always_keep_libs() do + refute lib in set, "always_keep_libs lib #{inspect(lib)} should never be in baseline" + end + end + + test "nil audit_input — guardrail has no effect" do + # Without :audit_input there's no expansion to guard against. + set = Slim.compute_strip_set(audit_input: nil) + assert "megaco" in set + end + end + + describe "slim_bundle/2 — :audit_input threading" do + test "audit-derived foreign apps are stripped on top of the baseline", %{root: root} do + make_lib(root, "kernel", "11.0", real_beam: true) + make_lib(root, "megaco", "4.9", real_beam: true) + pigeon = Path.join(root, "lib/pigeon-0.1.0") + make_lib(root, "pigeon", "0.1.0", real_beam: true) + + audit = %{ + foreign_app_names: ["pigeon"], + strippable_libs: [], + trace_strippable_libs: nil + } + + assert {:ok, result} = Slim.slim_bundle(root, audit_input: audit) + + refute File.dir?(pigeon) + refute File.dir?(Path.join(root, "lib/megaco-4.9")) + assert File.dir?(Path.join(root, "lib/kernel-11.0")) + assert "pigeon" in result.strip_set + end + end + + describe "hardcoded_prefixes/0" do + test "is stable across calls" do + assert Slim.hardcoded_prefixes() == Slim.hardcoded_prefixes() + end + + test "contains the well-known mobile-stripped libs" do + # Pinned: changing these is a behaviour change, not a refactor. + baseline = Slim.hardcoded_prefixes() + + for lib <- ~w(megaco snmp diameter mnesia inets compiler ssh dialyzer xmerl) do + assert lib in baseline, "expected #{lib} in hardcoded baseline" + end + end + end + + # ── slim_bundle/2 — integration against fixture trees ───────────────── + + describe "slim_bundle/2 — prefix_libs phase" do + test "strips libs whose basename is in the computed strip set", %{root: root} do + make_lib(root, "kernel", "11.0", real_beam: true) + make_lib(root, "megaco", "4.9", real_beam: true) + make_lib(root, "snmp", "5.20.3", real_beam: true) + + assert {:ok, _} = Slim.slim_bundle(root) + + refute File.dir?(Path.join(root, "lib/megaco-4.9")) + refute File.dir?(Path.join(root, "lib/snmp-5.20.3")) + assert File.dir?(Path.join(root, "lib/kernel-11.0")) + end + + test ":drop_libs adds libs to the strip set", %{root: root} do + make_lib(root, "kernel", "11.0", real_beam: true) + make_lib(root, "custom_dep", "1.0", real_beam: true) + + assert {:ok, _} = Slim.slim_bundle(root, drop_libs: ["custom_dep"]) + + refute File.dir?(Path.join(root, "lib/custom_dep-1.0")) + assert File.dir?(Path.join(root, "lib/kernel-11.0")) + end + + test ":keep_libs prevents baseline-listed lib from being stripped", %{root: root} do + make_lib(root, "kernel", "11.0", real_beam: true) + make_lib(root, "megaco", "4.9", real_beam: true) + + assert {:ok, _} = Slim.slim_bundle(root, keep_libs: ["megaco"]) + + assert File.dir?(Path.join(root, "lib/megaco-4.9")) + end + + test "explicit :strip_set short-circuits keep_libs/drop_libs", %{root: root} do + make_lib(root, "kernel", "11.0", real_beam: true) + make_lib(root, "megaco", "4.9", real_beam: true) + + # Empty explicit set means strip nothing in the prefix_libs phase, + # even though megaco is in the hardcoded baseline. + assert {:ok, _} = + Slim.slim_bundle(root, + strip_set: [], + drop_libs: ["should_be_ignored"], + keep_libs: ["should_be_ignored"] + ) + + assert File.dir?(Path.join(root, "lib/megaco-4.9")) + assert File.dir?(Path.join(root, "lib/kernel-11.0")) + end + end + + describe "slim_bundle/2 — apple_binaries phase" do + test "removes *.so and *.a everywhere under the bundle", %{root: root} do + lib = make_lib(root, "kernel", "11.0", real_beam: true) + File.write!(Path.join([lib, "priv", "stuff.so"]) |> tap_mkdir_p(), "x") + File.write!(Path.join([lib, "priv", "stuff.a"]) |> tap_mkdir_p(), "x") + File.write!(Path.join([lib, "stuff.so"]), "x") + + Slim.slim_bundle(root) + + assert Path.wildcard("#{root}/**/*.so") == [] + assert Path.wildcard("#{root}/**/*.a") == [] + end + + test "removes priv/bin/* binaries", %{root: root} do + lib = make_lib(root, "kernel", "11.0", real_beam: true) + priv_bin = Path.join([lib, "priv", "bin"]) + File.mkdir_p!(priv_bin) + File.write!(Path.join(priv_bin, "some_tool"), "executable") + + Slim.slim_bundle(root) + + refute File.exists?(Path.join(priv_bin, "some_tool")) + end + + test "wipes erts-<vsn>/bin executables", %{root: root} do + make_lib(root, "kernel", "11.0", real_beam: true) + erts = make_erts(root, "17.0") + + Slim.slim_bundle(root) + + refute File.exists?(Path.join([erts, "bin", "erl"])) + refute File.exists?(Path.join([erts, "bin", "erlc"])) + # The erts dir itself is preserved (other erts subdirs may stay). + assert File.dir?(erts) + end + end + + describe "slim_bundle/2 — foreign_apps phase" do + test "removes lib/{toy_,test_,mob_test,scratch_}*-* directories", %{root: root} do + make_lib(root, "kernel", "11.0", real_beam: true) + make_lib(root, "toy_appp", "0.1.0", real_beam: true) + make_lib(root, "test_nif", "0.1.0", real_beam: true) + make_lib(root, "mob_test", "0.5.0", real_beam: true) + make_lib(root, "scratch_lab", "1.0.0", real_beam: true) + make_lib(root, "my_real_app", "1.0", real_beam: true) + + Slim.slim_bundle(root) + + refute File.dir?(Path.join(root, "lib/toy_appp-0.1.0")) + refute File.dir?(Path.join(root, "lib/test_nif-0.1.0")) + refute File.dir?(Path.join(root, "lib/mob_test-0.5.0")) + refute File.dir?(Path.join(root, "lib/scratch_lab-1.0.0")) + assert File.dir?(Path.join(root, "lib/my_real_app-1.0")), "non-prefixed app should remain" + end + end + + describe "slim_bundle/2 — dedup_versions phase" do + test "keeps only the highest version of a lib that appears multiple times", %{root: root} do + make_lib(root, "kernel", "11.0", real_beam: true) + make_lib(root, "asn1", "5.4", real_beam: true) + make_lib(root, "asn1", "5.4.3", real_beam: true) + + Slim.slim_bundle(root) + + refute File.dir?(Path.join(root, "lib/asn1-5.4")) + assert File.dir?(Path.join(root, "lib/asn1-5.4.3")) + end + + test "leaves single-version libs alone", %{root: root} do + make_lib(root, "kernel", "11.0", real_beam: true) + + Slim.slim_bundle(root) + + assert File.dir?(Path.join(root, "lib/kernel-11.0")) + end + end + + describe "slim_bundle/2 — src_and_headers phase" do + test "removes src/ and include/ directories anywhere in the tree", %{root: root} do + lib = make_lib(root, "kernel", "11.0", real_beam: true, src: true, include: true) + + Slim.slim_bundle(root) + + refute File.dir?(Path.join(lib, "src")) + refute File.dir?(Path.join(lib, "include")) + # ebin survives — beam files live there. + assert File.dir?(Path.join(lib, "ebin")) + end + end + + describe "slim_bundle/2 — result shape" do + test "returns ordered step list with before/after sizes and the final size", %{root: root} do + make_lib(root, "kernel", "11.0", real_beam: true) + make_lib(root, "megaco", "4.9", real_beam: true) + make_erts(root, "17.0") + + {:ok, result} = Slim.slim_bundle(root) + + labels = Enum.map(result.steps, & &1.label) + + assert labels == [ + "apple_binaries", + "prefix_libs", + "foreign_apps", + "dedup_versions", + "src_and_headers", + "beam_chunks" + ] + + assert Enum.all?(result.steps, fn s -> + is_integer(s.before_kb) and is_integer(s.after_kb) and + s.after_kb <= s.before_kb + end) + + assert is_integer(result.final_kb) + assert result.final_kb <= List.first(result.steps).before_kb + assert "megaco" in result.strip_set + end + + test "on_step callback fires once per phase, in order", %{root: root} do + make_lib(root, "kernel", "11.0", real_beam: true) + + parent = self() + + Slim.slim_bundle(root, + on_step: fn step -> send(parent, {:step, step.label}) end + ) + + for label <- [ + "apple_binaries", + "prefix_libs", + "foreign_apps", + "dedup_versions", + "src_and_headers", + "beam_chunks" + ] do + assert_received {:step, ^label} + end + end + end + + # ── detect_erts_vsn/1 ────────────────────────────────────────────────── + + describe "detect_erts_vsn/1" do + test "returns the erts dir basename when present", %{root: root} do + make_erts(root, "17.0") + assert Slim.detect_erts_vsn(root) == "erts-17.0" + end + + test "returns nil when no erts dir is present", %{root: root} do + assert Slim.detect_erts_vsn(root) == nil + end + end + + # Helper: mkdir_p the parent dir of `path`, returning `path` itself. + defp tap_mkdir_p(path) do + path |> Path.dirname() |> File.mkdir_p!() + path + end +end diff --git a/test/mob_dev/otp_audit_test.exs b/test/mob_dev/otp_audit_test.exs new file mode 100644 index 0000000..e6a071d --- /dev/null +++ b/test/mob_dev/otp_audit_test.exs @@ -0,0 +1,620 @@ +defmodule MobDev.OtpAuditTest do + use ExUnit.Case, async: true + + # Tests OtpAudit against a synthetic OTP tree built in tmp. Real OTP + # trees take seconds to walk and are tied to the host's installed OTP, + # so we fake the parts we need: lib/<name>-<vsn>/ebin/<name>.app. + # + # The .beam files are intentionally bogus — `read_imports/1` swallows + # parse errors and returns []. That's enough to exercise discovery, + # dedup, and foreign-app detection without compiling Erlang at test time. + # The reachability BFS itself has no shape to assert without real beams, + # which is why it's covered by the empirical OtpTrace harness instead. + + alias MobDev.OtpAudit + + setup do + root = + Path.join(System.tmp_dir!(), "mob_otp_audit_test_#{System.unique_integer([:positive])}") + + File.mkdir_p!(Path.join(root, "lib")) + on_exit(fn -> File.rm_rf!(root) end) + {:ok, root: root} + end + + defp make_lib(root, name, version, opts \\ []) do + dir = Path.join([root, "lib", "#{name}-#{version}"]) + File.mkdir_p!(Path.join(dir, "ebin")) + + mod_clause = + case opts[:mod] do + nil -> "" + mod when is_atom(mod) -> ", {mod, {#{mod}, []}}" + end + + app_contents = + "{application, #{name}, [{description, \"test\"}, {vsn, \"#{version}\"}, " <> + "{modules, []}, {applications, []}#{mod_clause}]}.\n" + + File.write!(Path.join([dir, "ebin", "#{name}.app"]), app_contents) + + for module <- opts[:modules] || [] do + # An empty file is enough — beam_lib:chunks/2 errors out and + # read_imports/1 returns []. We only need the module to be + # discoverable for modules_total / modules_reachable counts. + File.write!(Path.join([dir, "ebin", "#{module}.beam"]), "") + end + + dir + end + + describe "audit/2 — lib discovery" do + test "finds every <name>-<vsn>/ebin tree under the OTP root", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists", "maps"]) + make_lib(root, "elixir", "1.18.0", modules: ["Elixir.Kernel"]) + + report = OtpAudit.audit(root) + + names = Enum.map(report.libs, & &1.name) |> Enum.sort() + assert names == ["elixir", "kernel", "stdlib"] + end + + test "records modules_total per lib", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists", "maps", "sets"]) + + report = OtpAudit.audit(root) + stdlib = Enum.find(report.libs, &(&1.name == "stdlib")) + assert stdlib.modules_total == 3 + end + end + + describe "audit/2 — duplicate version collapse" do + test "keeps only the highest version when a lib appears twice", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "asn1", "5.4") + make_lib(root, "asn1", "5.4.3") + + report = OtpAudit.audit(root) + asn1 = Enum.find(report.libs, &(&1.name == "asn1")) + assert asn1.version == "5.4.3" + end + + test "reports the dropped versions under :duplicates", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + older = make_lib(root, "asn1", "5.4") + _newer = make_lib(root, "asn1", "5.4.3") + + report = OtpAudit.audit(root) + assert Map.has_key?(report.duplicates, "asn1") + assert older in report.duplicates["asn1"] + end + + test "handles three+ versions of the same lib", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "public_key", "1.18") + make_lib(root, "public_key", "1.20.2") + make_lib(root, "public_key", "1.20.3") + + report = OtpAudit.audit(root) + pk = Enum.find(report.libs, &(&1.name == "public_key")) + assert pk.version == "1.20.3" + assert length(report.duplicates["public_key"]) == 2 + end + end + + describe "audit/2 — foreign app detection" do + test "flags libs that look like other projects' apps", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + foreign = make_lib(root, "toy_appp", "0.1.0", modules: ["Elixir.ToyAppp"]) + _stranger = make_lib(root, "test_nif", "0.1.0", modules: ["Elixir.TestNif"]) + + report = OtpAudit.audit(root, app_name: :my_app) + assert foreign in report.foreign_apps + assert length(report.foreign_apps) == 2 + end + + test "does not flag the app under test as foreign", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "toy_appp", "0.1.0", modules: ["Elixir.ToyAppp"]) + make_lib(root, "test_nif", "0.1.0", modules: ["Elixir.TestNif"]) + + # Even though `test_nif` matches looks_like_user_app?, naming it + # the app under test should keep it out of foreign_apps. + report = OtpAudit.audit(root, app_name: :test_nif) + paths = report.foreign_apps + refute Enum.any?(paths, &String.contains?(&1, "test_nif-")) + end + + test "leaves OTP libs out of foreign_apps regardless of app_name", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "asn1", "5.4.3") + + report = OtpAudit.audit(root, app_name: :something_else) + assert report.foreign_apps == [] + end + end + + describe "audit/2 — size accounting" do + test "totals are non-negative integers", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + + report = OtpAudit.audit(root) + assert is_integer(report.total_kb) and report.total_kb >= 0 + assert is_integer(report.reachable_kb) and report.reachable_kb >= 0 + assert is_integer(report.strippable_kb) and report.strippable_kb >= 0 + end + + test "strippable_libs lists libs with zero reachable modules", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + # No app callback, no module reachable from kernel/stdlib seed — + # an empty fake lib should fall straight into strippable_libs. + make_lib(root, "wx", "2.5", modules: ["wx_object"]) + + report = OtpAudit.audit(root) + assert "wx" in report.strippable_libs + end + end + + # The `:project_deps` allow-list classifier replaces the narrow + # name-pattern heuristic when the caller can supply the project's + # actual dep closure. Catches arbitrary leftover apps (pigeon, + # push_notify, phase2q_lv, etc.) that don't match `test_/toy_` + # but still aren't supposed to be in this bundle. + describe "audit/2 — :project_deps allow-list" do + test "an arbitrary lib NOT in project_deps is foreign", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + stranger = make_lib(root, "pigeon", "0.1.0", modules: ["Elixir.Pigeon"]) + + report = + OtpAudit.audit(root, + app_name: :my_app, + project_deps: [:my_app, :phoenix, :ecto] + ) + + assert stranger in report.foreign_apps + refute "pigeon" in Enum.map(report.libs, & &1.name) + end + + test "OTP-shipped libs are never foreign even if not in project_deps", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "xmerl", "2.2", modules: ["xmerl"]) + make_lib(root, "asn1", "5.4.3") + make_lib(root, "compiler", "10.0") + + report = + OtpAudit.audit(root, + app_name: :my_app, + project_deps: [:my_app] + ) + + assert report.foreign_apps == [] + # All four should be in libs (and so candidates for strippable_libs). + lib_names = Enum.map(report.libs, & &1.name) + assert "xmerl" in lib_names + assert "asn1" in lib_names + assert "compiler" in lib_names + end + + test "Elixir-shipped libs are never foreign even if not in project_deps", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "elixir", "1.18.0", modules: ["Elixir.Kernel"]) + make_lib(root, "eex", "1.0", modules: ["Elixir.EEx"]) + make_lib(root, "logger", "1.18.0") + + report = + OtpAudit.audit(root, + app_name: :my_app, + project_deps: [:my_app] + ) + + assert report.foreign_apps == [] + end + + test "the app under test is never foreign even if not in project_deps", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "my_app", "0.1.0", modules: ["Elixir.MyApp"]) + + report = + OtpAudit.audit(root, + app_name: :my_app, + # Deliberately empty — verify app_name still wins. + project_deps: [] + ) + + assert report.foreign_apps == [] + end + + test "deps listed in project_deps are kept regardless of mod-callback shape", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + # exqlite-shaped: hex dep, has app callback, runtime-only NIF. + make_lib(root, "exqlite", "0.39.0", modules: ["Elixir.Exqlite"], mod: :exqlite_app) + # rns-shaped: pure-Python wheel-ish, no app callback. + make_lib(root, "rns", "0.5.0", modules: []) + + report = + OtpAudit.audit(root, + app_name: :my_app, + project_deps: [:my_app, :exqlite, :rns] + ) + + assert report.foreign_apps == [] + lib_names = Enum.map(report.libs, & &1.name) + assert "exqlite" in lib_names + assert "rns" in lib_names + end + + test "empty project_deps still allows OTP/Elixir shipped libs through", %{root: root} do + # Boundary case: caller passes [] explicitly. Should NOT make + # OTP/Elixir libs foreign (the allow-list still includes them). + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "compiler", "10.0") + + report = OtpAudit.audit(root, project_deps: []) + + assert report.foreign_apps == [] + end + + test "real-world pigeon-shaped audit: foreign cluster correctly classified", %{root: root} do + # Recreates the shape from the ~/code/pigeon baseline audit: + # a bundle whose cache holds leftover apps from other projects. + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "elixir", "1.18.0", modules: ["Elixir.Kernel"]) + + # Real deps: pigeon + exqlite. + pigeon = make_lib(root, "pigeon", "0.1.0", modules: ["Elixir.Pigeon"]) + make_lib(root, "exqlite", "0.39.0", modules: ["Elixir.Exqlite"]) + + # Leftover cache cruft from previous builds of other projects. + stale1 = make_lib(root, "push_notify", "0.1.0", modules: ["Elixir.PushNotify"]) + stale2 = make_lib(root, "phase2q_lv", "0.1.0", modules: ["Elixir.Phase2qLv"]) + stale3 = make_lib(root, "phase2q_smoke", "0.1.0", modules: ["Elixir.Phase2qSmoke"]) + stale4 = make_lib(root, "pythonx_ios_spike", "0.1.0", modules: []) + + report = + OtpAudit.audit(root, + app_name: :pigeon, + project_deps: [:pigeon, :exqlite] + ) + + # All four leftover apps land in foreign_apps. + assert stale1 in report.foreign_apps + assert stale2 in report.foreign_apps + assert stale3 in report.foreign_apps + assert stale4 in report.foreign_apps + assert length(report.foreign_apps) == 4 + + # Pigeon (the app) and exqlite (a real dep) are NOT foreign. + lib_names = Enum.map(report.libs, & &1.name) + assert "pigeon" in lib_names + assert "exqlite" in lib_names + refute pigeon in report.foreign_apps + end + + test "without :project_deps, falls back to the name-pattern heuristic", %{root: root} do + # Backwards-compat check: existing callers that don't pass + # `:project_deps` get the old behaviour, which catches `toy_/test_` + # but misses arbitrarily-named foreigners like `pigeon`. + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + toy = make_lib(root, "toy_appp", "0.1.0", modules: ["Elixir.ToyAppp"]) + make_lib(root, "pigeon", "0.1.0", modules: ["Elixir.Pigeon"]) + + report = OtpAudit.audit(root, app_name: :my_app) + + assert toy in report.foreign_apps + # `pigeon` doesn't match the legacy heuristic so it slips through — + # this is exactly the case `:project_deps` was added to fix. + refute Enum.any?(report.foreign_apps, &String.contains?(&1, "pigeon-")) + end + + test "erts-<vsn> is NEVER foreign — it's the BEAM runtime", %{root: root} do + # Regression: mob's iOS bundle puts erts under lib/ alongside the + # apps. Without explicit allow-listing the classifier sees it as + # "not OTP-shipped, not Elixir-shipped, not the app, not in deps" + # and quarantines the runtime as cache cruft. Reproduced from a + # test_migration audit run that surfaced erts in foreign_apps. + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + erts = make_lib(root, "erts", "17.0", modules: ["erlang"]) + + report = OtpAudit.audit(root, app_name: :my_app, project_deps: [:my_app]) + + assert report.foreign_apps == [] + refute erts in report.foreign_apps + assert Enum.any?(report.libs, &(&1.name == "erts")) + end + + test "scratch_ prefix is added to the legacy heuristic", %{root: root} do + # `scratch_` prefix appears in the Slim foreign_apps strip pass, + # so the audit heuristic should match it too for consistency. + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + scratch = make_lib(root, "scratch_lab", "0.1.0", modules: ["Elixir.ScratchLab"]) + + report = OtpAudit.audit(root, app_name: :my_app) + + assert scratch in report.foreign_apps + end + end + + # `:trace_input` adds empirical reachability — modules actually called + # at runtime during a trace window. Crucially, the trace can prove a + # statically-reachable lib is never called (the megaco/snmp case from + # the pigeon baseline) — that's the trace-only signal that unlocks + # stripping libs the static graph alone can't strip. + describe "audit/2 — :trace_input" do + test "without :trace_input, trace_strippable_libs is nil and lib reports have nil trace fields", + %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + + report = OtpAudit.audit(root) + + assert report.trace_strippable_libs == nil + + Enum.each(report.libs, fn lib -> + assert lib.modules_traced == nil + assert lib.untraced_modules == nil + end) + end + + test "with empty trace, every lib's modules become untraced", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "megaco", "4.9", modules: ["megaco", "megaco_app"]) + + report = OtpAudit.audit(root, trace_input: MapSet.new([])) + + assert "kernel" in report.trace_strippable_libs + assert "stdlib" in report.trace_strippable_libs + assert "megaco" in report.trace_strippable_libs + end + + test "trace catches a lib that's statically reachable but never called", %{root: root} do + # Simulates the megaco case from the pigeon baseline: 1/65 + # statically reachable but 0 actually called. + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + # megaco is "statically reachable" because we'll mark it called + # — except we won't include it in the trace. + make_lib(root, "megaco", "4.9", modules: ["megaco", "megaco_app"]) + + # Trace records kernel + stdlib + Elixir runtime, NO megaco. + trace = MapSet.new([:kernel, :lists, :erlang]) + + report = OtpAudit.audit(root, trace_input: trace) + + assert "megaco" in report.trace_strippable_libs + end + + test "trace excludes libs whose modules ARE called", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "compiler", "10.0", modules: ["compile", "beam_z"]) + + # compiler.compile is in the trace → compiler not trace-strippable. + trace = MapSet.new([:kernel, :lists, :compile]) + + report = OtpAudit.audit(root, trace_input: trace) + + refute "compiler" in report.trace_strippable_libs + end + + test "per-lib modules_traced + untraced_modules reflect trace membership", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "compiler", "10.0", modules: ["compile", "beam_z", "v3_core"]) + + trace = MapSet.new([:kernel, :lists, :compile]) + + report = OtpAudit.audit(root, trace_input: trace) + + compiler = Enum.find(report.libs, &(&1.name == "compiler")) + assert compiler.modules_traced == 1 + assert compiler.untraced_modules == [:beam_z, :v3_core] + end + + test "accepts a list as :trace_input (auto-converts to MapSet)", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "megaco", "4.9", modules: ["megaco"]) + + report = OtpAudit.audit(root, trace_input: [:kernel, :lists]) + + assert "megaco" in report.trace_strippable_libs + end + + test "accepts an OtpTrace.result-shaped map (uses :modules field)", %{root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "megaco", "4.9", modules: ["megaco"]) + + # Shape matches MobDev.OtpTrace.capture/1's return. + trace_result = %{ + mfas: MapSet.new([{:kernel, :is_alive, 0}]), + modules: MapSet.new([:kernel, :lists]), + elapsed_us: 1234 + } + + report = OtpAudit.audit(root, trace_input: trace_result) + + assert "megaco" in report.trace_strippable_libs + end + + test "accepts a remote-trace-shaped map (modules is a list, not MapSet)", %{root: root} do + # `mix mob.trace_otp --remote` returns `modules` as a list. JSON + # round-trip also flattens MapSets to lists. The normalizer + # should handle both. + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "megaco", "4.9", modules: ["megaco"]) + + remote_shape = %{ + modules: [:kernel, :lists], + module_count: 2, + mfa_count: 0, + mfas: [] + } + + report = OtpAudit.audit(root, trace_input: remote_shape) + + assert "megaco" in report.trace_strippable_libs + end + + test "an empty lib (modules_total == 0) is NOT trace-strippable", %{root: root} do + # Defensive: a placeholder lib with no .beams should not appear + # in trace_strippable_libs (otherwise the user gets noise from + # cache-cruft empty dirs). + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "empty_placeholder", "0.1.0", modules: []) + + report = OtpAudit.audit(root, trace_input: [:kernel, :lists]) + + refute "empty_placeholder" in report.trace_strippable_libs + end + end + + describe "union_trace_jsons/2" do + setup do + tmp = Path.join(System.tmp_dir!(), "mob_trace_union_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp} + end + + test "empty list of paths returns nil" do + assert OtpAudit.union_trace_jsons([]) == nil + end + + test "single trace returns its modules", %{tmp: tmp} do + path = write_trace(tmp, "one.json", ["kernel", "lists"]) + + result = OtpAudit.union_trace_jsons([path]) + + assert MapSet.equal?(result, MapSet.new([:kernel, :lists])) + end + + test "multiple traces are UNIONED — modules from either count", %{tmp: tmp} do + # Real-shaped scenario: boot capture catches kernel/sasl; + # UI capture catches Elixir.Enum/Elixir.Map; auth capture + # catches crypto. None of them caught megaco. The union + # answers "what was EVER called" across all sessions. + boot = write_trace(tmp, "boot.json", ["kernel", "sasl"]) + ui = write_trace(tmp, "ui.json", ["Elixir.Enum", "Elixir.Map"]) + auth = write_trace(tmp, "auth.json", ["crypto"]) + + result = OtpAudit.union_trace_jsons([boot, ui, auth]) + + assert MapSet.equal?( + result, + MapSet.new([:kernel, :sasl, :"Elixir.Enum", :"Elixir.Map", :crypto]) + ) + + refute :megaco in result + end + + test "duplicates across traces collapse — set semantics", %{tmp: tmp} do + a = write_trace(tmp, "a.json", ["kernel", "lists", "Elixir.Enum"]) + b = write_trace(tmp, "b.json", ["lists", "Elixir.Enum", "Elixir.Map"]) + + result = OtpAudit.union_trace_jsons([a, b]) + + assert MapSet.size(result) == 4 + end + + test "missing file invokes on_read_error callback", %{tmp: tmp} do + good = write_trace(tmp, "good.json", ["kernel"]) + missing = Path.join(tmp, "does_not_exist.json") + + parent = self() + + result = + OtpAudit.union_trace_jsons([good, missing], fn path, _reason -> + send(parent, {:read_error, path}) + end) + + assert_received {:read_error, ^missing} + # The successful trace still contributes. + assert MapSet.equal?(result, MapSet.new([:kernel])) + end + + test "all reads failing returns nil (don't strip the world)", %{tmp: tmp} do + missing_a = Path.join(tmp, "a.json") + missing_b = Path.join(tmp, "b.json") + + # Custom no-op error handler keeps test output clean. + result = OtpAudit.union_trace_jsons([missing_a, missing_b], fn _path, _reason -> nil end) + + assert result == nil, "all-failed → nil, NOT empty MapSet (would over-strip)" + end + + test "malformed JSON triggers on_read_error, no crash", %{tmp: tmp} do + bad = Path.join(tmp, "bad.json") + File.write!(bad, "not actually json {{") + + result = OtpAudit.union_trace_jsons([bad], fn _path, _reason -> nil end) + + assert result == nil + end + + test "missing :modules field treated as empty (defensive)", %{tmp: tmp} do + noisy = Path.join(tmp, "noisy.json") + File.write!(noisy, Jason.encode!(%{some_other_field: 1})) + good = write_trace(tmp, "good.json", ["kernel"]) + + result = OtpAudit.union_trace_jsons([noisy, good], fn _, _ -> nil end) + + assert MapSet.equal?(result, MapSet.new([:kernel])) + end + + test "end-to-end: feeding the union into audit/2 picks trace-strippable libs", + %{tmp: tmp, root: root} do + make_lib(root, "kernel", "9.2", modules: ["kernel"]) + make_lib(root, "stdlib", "5.2", modules: ["lists"]) + make_lib(root, "megaco", "4.9", modules: ["megaco"]) + + # No trace caught megaco → it's trace-strippable in the union. + boot = write_trace(tmp, "boot.json", ["kernel"]) + ui = write_trace(tmp, "ui.json", ["lists"]) + + union = OtpAudit.union_trace_jsons([boot, ui]) + report = OtpAudit.audit(root, trace_input: union) + + assert "megaco" in report.trace_strippable_libs + end + end + + defp write_trace(tmp, name, modules) do + path = Path.join(tmp, name) + + File.write!( + path, + Jason.encode!(%{ + modules: modules, + mfas: [], + module_count: length(modules), + mfa_count: 0 + }) + ) + + path + end +end diff --git a/test/mob_dev/otp_downloader_test.exs b/test/mob_dev/otp_downloader_test.exs new file mode 100644 index 0000000..4ddac2d --- /dev/null +++ b/test/mob_dev/otp_downloader_test.exs @@ -0,0 +1,195 @@ +defmodule MobDev.OtpDownloaderTest do + use ExUnit.Case, async: true + + alias MobDev.OtpDownloader + + describe "android_otp_dir/1" do + test "default (no arg) returns arm64 path" do + assert OtpDownloader.android_otp_dir() == OtpDownloader.android_otp_dir("arm64-v8a") + end + + test "arm64-v8a returns a path containing the arm64 artifact name" do + path = OtpDownloader.android_otp_dir("arm64-v8a") + assert path =~ "otp-android-" + refute path =~ "arm32" + end + + test "armeabi-v7a returns a path containing the arm32 artifact name" do + path = OtpDownloader.android_otp_dir("armeabi-v7a") + assert path =~ "otp-android-arm32-" + end + + test "x86_64 returns a path containing the x86_64 artifact name" do + path = OtpDownloader.android_otp_dir("x86_64") + assert path =~ "otp-android-x86_64-" + end + + test "unknown ABI falls back to arm64 path" do + assert OtpDownloader.android_otp_dir("x86") == OtpDownloader.android_otp_dir("arm64-v8a") + end + + test "arm64, arm32, and x86_64 paths are distinct" do + refute OtpDownloader.android_otp_dir("arm64-v8a") == + OtpDownloader.android_otp_dir("armeabi-v7a") + + refute OtpDownloader.android_otp_dir("arm64-v8a") == + OtpDownloader.android_otp_dir("x86_64") + end + + test "arm32 path ends inside the standard cache directory" do + cache = Path.join([System.get_env("HOME"), ".mob", "cache"]) + assert String.starts_with?(OtpDownloader.android_otp_dir("armeabi-v7a"), cache) + end + end + + # ── valid_otp_dir?/2 ──────────────────────────────────────────────────────── + # + # The Android and iOS-sim tarballs only need an `erts-*/` to be considered + # valid. The iOS-device tarball additionally must ship EPMD source files — + # `mix mob.deploy --native` static-links EPMD into the iOS app and there's + # nowhere else to source those .c files from. The (c) tarball-schema bump + # adds them under `erts/epmd/src/`; older caches without the source files + # are treated as invalid so they auto-redownload. + + describe "valid_otp_dir?/2" do + setup do + tmp = Path.join(System.tmp_dir!(), "otp_validity_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp} + end + + # All tarballs (post-2026-05-06) ship crypto.a + libcrypto.a. Helper + # to populate them in test fixtures. + defp add_crypto(tmp) do + File.mkdir_p!(Path.join(tmp, "erts-16.1/lib")) + File.write!(Path.join(tmp, "erts-16.1/lib/crypto.a"), "") + File.write!(Path.join(tmp, "erts-16.1/lib/libcrypto.a"), "") + end + + test "Android tarball: erts-* + crypto archives is enough", %{tmp: tmp} do + File.mkdir_p!(Path.join(tmp, "erts-16.1")) + add_crypto(tmp) + assert OtpDownloader.valid_otp_dir?(tmp, "otp-android-7721ab74") + assert OtpDownloader.valid_otp_dir?(tmp, "otp-android-arm32-7721ab74") + assert OtpDownloader.valid_otp_dir?(tmp, "otp-android-x86_64-7721ab74") + end + + test "Android tarball: missing crypto.a → invalid", %{tmp: tmp} do + File.mkdir_p!(Path.join(tmp, "erts-16.1")) + refute OtpDownloader.valid_otp_dir?(tmp, "otp-android-7721ab74") + end + + test "iOS sim tarball: erts-* + crypto archives is enough", %{tmp: tmp} do + File.mkdir_p!(Path.join(tmp, "erts-16.1")) + add_crypto(tmp) + assert OtpDownloader.valid_otp_dir?(tmp, "otp-ios-sim-7721ab74") + end + + test "iOS device tarball: needs erts-* AND EPMD .c sources AND .h headers", + %{tmp: tmp} do + File.mkdir_p!(Path.join(tmp, "erts-16.1")) + add_crypto(tmp) + File.mkdir_p!(Path.join(tmp, "erts/epmd/src")) + + # No EPMD source yet — fails. + refute OtpDownloader.valid_otp_dir?(tmp, "otp-ios-device-7721ab74") + + # All three .c files present, but no headers — still fails. This is the + # regression we're guarding against: a tarball with sources but no + # headers extracts cleanly but breaks at clang time with `'epmd.h' file + # not found`. Validation must catch it. + for rel <- ~w[epmd.c epmd_srv.c epmd_cli.c] do + File.write!(Path.join(tmp, "erts/epmd/src/#{rel}"), "") + end + + refute OtpDownloader.valid_otp_dir?(tmp, "otp-ios-device-7721ab74") + + # Add headers — now passes. + for rel <- ~w[epmd.h epmd_int.h] do + File.write!(Path.join(tmp, "erts/epmd/src/#{rel}"), "") + end + + assert OtpDownloader.valid_otp_dir?(tmp, "otp-ios-device-7721ab74") + end + + test "iOS device tarball: missing any one required file invalidates", %{tmp: tmp} do + required = ~w[epmd.c epmd_srv.c epmd_cli.c epmd.h epmd_int.h] + + for missing <- required do + File.rm_rf!(tmp) + File.mkdir_p!(Path.join(tmp, "erts-16.1")) + add_crypto(tmp) + File.mkdir_p!(Path.join(tmp, "erts/epmd/src")) + + for rel <- required, rel != missing do + File.write!(Path.join(tmp, "erts/epmd/src/#{rel}"), "") + end + + refute OtpDownloader.valid_otp_dir?(tmp, "otp-ios-device-7721ab74"), + "expected invalid when #{missing} is missing" + end + end + + test "no erts-* dir → invalid regardless of name", %{tmp: tmp} do + refute OtpDownloader.valid_otp_dir?(tmp, "otp-android-7721ab74") + refute OtpDownloader.valid_otp_dir?(tmp, "otp-ios-device-7721ab74") + refute OtpDownloader.valid_otp_dir?(tmp, "otp-ios-sim-7721ab74") + end + + test "non-existent dir → invalid" do + refute OtpDownloader.valid_otp_dir?("/nonexistent/path", "otp-ios-device-7721ab74") + end + end + + # ── Elixir build/runtime version skew ─────────────────────────────────────── + # + # Build Elixir ≠ device-runtime Elixir at the minor level is the Enum.__in__/2 + # class of breakage (black screen at boot). We compare major.minor: rc/patch + # differences within a minor are beam-compatible and must NOT warn. + + describe "elixir_skew/2" do + test "different minor is a skew" do + assert OtpDownloader.elixir_skew("1.20.0-rc.5", "1.19.5") == + {:skew, "1.20.0-rc.5", "1.19.5"} + end + + test "identical version is ok" do + assert OtpDownloader.elixir_skew("1.19.5", "1.19.5") == :ok + end + + test "same minor, different patch is ok" do + assert OtpDownloader.elixir_skew("1.19.5", "1.19.6") == :ok + end + + test "same minor, rc vs final is ok" do + assert OtpDownloader.elixir_skew("1.20.0-rc.5", "1.20.0") == :ok + end + + test "nil bundled version (unreadable) does not warn" do + assert OtpDownloader.elixir_skew("1.20.0", nil) == :ok + end + end + + describe "bundled_elixir_version/1" do + setup do + tmp = Path.join(System.tmp_dir!(), "otp_elixir_vsn_#{System.unique_integer([:positive])}") + File.mkdir_p!(Path.join(tmp, "lib/elixir/ebin")) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp} + end + + test "reads the vsn from lib/elixir/ebin/elixir.app", %{tmp: tmp} do + File.write!( + Path.join(tmp, "lib/elixir/ebin/elixir.app"), + ~s|{application,elixir,[{description,"elixir"},{vsn,"1.19.5"},{modules,[]}]}.| + ) + + assert OtpDownloader.bundled_elixir_version(tmp) == "1.19.5" + end + + test "missing elixir.app returns nil", %{tmp: tmp} do + assert OtpDownloader.bundled_elixir_version(tmp) == nil + end + end +end diff --git a/test/mob_dev/otp_trace_test.exs b/test/mob_dev/otp_trace_test.exs new file mode 100644 index 0000000..c3a067c --- /dev/null +++ b/test/mob_dev/otp_trace_test.exs @@ -0,0 +1,40 @@ +defmodule MobDev.OtpTraceTest do + use ExUnit.Case, async: false + + alias MobDev.OtpTrace + + describe "capture/1" do + test "records MFAs called inside the wrapped function" do + result = OtpTrace.capture(fn -> Enum.map(1..5, &(&1 * 2)) end) + + # Enum is one of the modules called. + assert MapSet.member?(result.modules, Enum) + + # And the specific Enum.map call appears. + assert Enum.any?(result.mfas, fn + {Enum, :map, 2} -> true + _ -> false + end) + end + + test "records cross-module calls (lists, erlang BIFs, etc.)" do + result = OtpTrace.capture(fn -> :lists.reverse([1, 2, 3]) end) + + assert MapSet.member?(result.modules, :lists) + end + + test "elapsed_us is positive" do + result = OtpTrace.capture(fn -> Process.sleep(5) end) + assert result.elapsed_us > 0 + end + + test "excludes the tracer infrastructure modules" do + result = OtpTrace.capture(fn -> :ok end) + + # The collector + trace module shouldn't show up in the captured MFAs + # (the user is measuring their code, not our bookkeeping). + refute MapSet.member?(result.modules, MobDev.OtpTrace) + refute MapSet.member?(result.modules, MobDev.OtpTrace.Collector) + end + end +end diff --git a/test/mob_dev/paths_test.exs b/test/mob_dev/paths_test.exs new file mode 100644 index 0000000..5f13861 --- /dev/null +++ b/test/mob_dev/paths_test.exs @@ -0,0 +1,90 @@ +defmodule MobDev.PathsTest do + use ExUnit.Case, async: false + + alias MobDev.Paths + + setup do + # Each test runs in its own tmp project dir so build.sh detection is + # deterministic and parallelisable. + tmp = + Path.join(System.tmp_dir!(), "mob_paths_test_#{System.unique_integer([:positive])}") + + File.mkdir_p!(Path.join(tmp, "ios")) + + on_exit(fn -> + File.rm_rf!(tmp) + System.delete_env("MOB_SIM_RUNTIME_DIR") + end) + + {:ok, project: tmp} + end + + describe "default_runtime_dir/0" do + test "is under ~/.mob/runtime/ios-sim" do + assert Paths.default_runtime_dir() == + Path.join([System.user_home!(), ".mob", "runtime", "ios-sim"]) + end + end + + describe "legacy_tmp_path/0" do + test "is /tmp/otp-ios-sim" do + assert Paths.legacy_tmp_path() == "/tmp/otp-ios-sim" + end + end + + describe "build_sh_aware?/1" do + test "false when build.sh is missing", %{project: project} do + refute Paths.build_sh_aware?(project) + end + + test "false when build.sh exists but doesn't reference the env var", %{project: project} do + File.write!(Path.join([project, "ios", "build.sh"]), "echo /tmp/otp-ios-sim\n") + refute Paths.build_sh_aware?(project) + end + + test "true when build.sh contains MOB_SIM_RUNTIME_DIR", %{project: project} do + File.write!( + Path.join([project, "ios", "build.sh"]), + "RUNTIME_DIR=\"${MOB_SIM_RUNTIME_DIR:-$HOME/.mob/runtime/ios-sim}\"\n" + ) + + assert Paths.build_sh_aware?(project) + end + end + + describe "sim_runtime_dir/1" do + test "MOB_SIM_RUNTIME_DIR env wins over everything", %{project: project} do + System.put_env("MOB_SIM_RUNTIME_DIR", "/somewhere/else") + + try do + # Even with a build.sh-aware project, the env var wins. + File.write!(Path.join([project, "ios", "build.sh"]), "MOB_SIM_RUNTIME_DIR\n") + assert Paths.sim_runtime_dir(project_dir: project) == "/somewhere/else" + after + System.delete_env("MOB_SIM_RUNTIME_DIR") + end + end + + test "new default when build.sh is aware", %{project: project} do + System.delete_env("MOB_SIM_RUNTIME_DIR") + File.write!(Path.join([project, "ios", "build.sh"]), "MOB_SIM_RUNTIME_DIR\n") + assert Paths.sim_runtime_dir(project_dir: project) == Paths.default_runtime_dir() + end + + test "legacy /tmp when build.sh is missing or unaware", %{project: project} do + System.delete_env("MOB_SIM_RUNTIME_DIR") + assert Paths.sim_runtime_dir(project_dir: project) == Paths.legacy_tmp_path() + + # Now create an unaware build.sh — still legacy. + File.write!(Path.join([project, "ios", "build.sh"]), "echo old\n") + assert Paths.sim_runtime_dir(project_dir: project) == Paths.legacy_tmp_path() + end + + test "new default for zig-based iOS projects (ios/build.zig, no build.sh)", + %{project: project} do + System.delete_env("MOB_SIM_RUNTIME_DIR") + File.write!(Path.join([project, "ios", "build.zig"]), "// zig build\n") + assert Paths.sim_runtime_dir(project_dir: project) == Paths.default_runtime_dir() + end + end +end diff --git a/test/mob_dev/plugin/assets_test.exs b/test/mob_dev/plugin/assets_test.exs new file mode 100644 index 0000000..9e79db3 --- /dev/null +++ b/test/mob_dev/plugin/assets_test.exs @@ -0,0 +1,168 @@ +defmodule MobDev.Plugin.AssetsTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.Assets + + describe "migration_copies/2" do + test "namespaces each plugin migration into the host migrations dir, preserving the version" do + plugin_migrations = [ + %{repo_namespace: "kv_", files: ["/p/priv/repo/migrations/20260101_create.exs"]}, + %{repo_namespace: "iap_", files: ["/q/migrations/20260102_add.exs"]} + ] + + assert Assets.migration_copies(plugin_migrations, "/host/priv/repo/migrations") == [ + {"/p/priv/repo/migrations/20260101_create.exs", + "/host/priv/repo/migrations/20260101_kv_create.exs"}, + {"/q/migrations/20260102_add.exs", + "/host/priv/repo/migrations/20260102_iap_add.exs"} + ] + end + end + + describe "namespaced_filename/2" do + test "inserts the namespace into the name part, keeping the version prefix for Ecto" do + assert Assets.namespaced_filename("kv_", "20260101_create.exs") == "20260101_kv_create.exs" + end + + test "always inserts the namespace, even when the description starts with it" do + # No "already namespaced?" guard: a description that coincidentally begins + # with the namespace text must NOT lose its namespace (that caused + # cross-vendor collisions). Double-prefixing here is intentional + unique. + assert Assets.namespaced_filename("kv_", "20260101_kv_create.exs") == + "20260101_kv_kv_create.exs" + end + + test "falls back to a plain prefix when there is no numeric version" do + assert Assets.namespaced_filename("kv_", "seed.exs") == "kv_seed.exs" + end + end + + describe "migration_copies/2 destination uniqueness" do + test "two sources whose descriptions differ only by the namespace text stay distinct" do + # Regression for the namespace-drop bug: 20260101_kv_create.exs and + # 20260101_create.exs under ns "kv_" used to BOTH map to ...kv_create.exs. + plugin_migrations = [ + %{ + repo_namespace: "kv_", + files: ["/a/20260101_kv_create.exs", "/a/20260101_create.exs"] + } + ] + + dests = + plugin_migrations + |> Assets.migration_copies("/host") + |> Enum.map(fn {_src, dest} -> Path.basename(dest) end) + + assert dests == ["20260101_kv_kv_create.exs", "20260101_kv_create.exs"] + assert length(Enum.uniq(dests)) == 2 + end + + test "raises a clear error if two distinct sources collide on one destination" do + # Only reachable if two plugins share a repo_namespace (cross-validation + # rejects that) — defensive guard against silent clobber. + plugin_migrations = [ + %{repo_namespace: "kv_", files: ["/a/20260101_create.exs"]}, + %{repo_namespace: "kv_", files: ["/b/20260101_create.exs"]} + ] + + assert_raise RuntimeError, ~r/migration filename collision/, fn -> + Assets.migration_copies(plugin_migrations, "/host") + end + end + end + + describe "android_font_resource_name/1" do + test "lowercases, drops the extension, and underscores non-alnum" do + assert Assets.android_font_resource_name("Georgia.ttf") == "georgia" + assert Assets.android_font_resource_name("Inter-Regular.otf") == "inter_regular" + assert Assets.android_font_resource_name("My Font 2.ttf") == "my_font_2" + end + + test "prefixes a non-letter leading char so it is a valid resource id" do + assert Assets.android_font_resource_name("123Sans.ttf") == "f_123sans" + end + end + + describe "plan_ios_font_bundle/1" do + test "maps each font to its bundle basename" do + assert Assets.plan_ios_font_bundle(["/p/Georgia.ttf", "/q/Inter.otf"]) == + {:ok, [{"/p/Georgia.ttf", "Georgia.ttf"}, {"/q/Inter.otf", "Inter.otf"}]} + end + + test "dedups an identical source path" do + assert {:ok, [{"/p/X.ttf", "X.ttf"}]} = + Assets.plan_ios_font_bundle(["/p/X.ttf", "/p/X.ttf"]) + end + + test "errors when two distinct sources share a basename (silent-overwrite bug)" do + assert {:error, {:font_basename_collision, "Icons.ttf", srcs}} = + Assets.plan_ios_font_bundle(["/p1/Icons.ttf", "/p2/Icons.ttf"]) + + assert "/p1/Icons.ttf" in srcs and "/p2/Icons.ttf" in srcs + end + end + + describe "plan_android_font_copies/1" do + test "maps each font to its normalised resource filename" do + assert Assets.plan_android_font_copies(["/p/Georgia.ttf"]) == + {:ok, [{"/p/Georgia.ttf", "georgia.ttf"}]} + end + + test "errors when two sources normalise to the same resource name" do + # Inter-Regular.ttf and Inter_Regular.ttf both → inter_regular.ttf + assert {:error, {:font_resource_collision, "inter_regular.ttf", srcs}} = + Assets.plan_android_font_copies(["/p/Inter-Regular.ttf", "/q/Inter_Regular.ttf"]) + + assert length(srcs) == 2 + end + end + + describe "image_bundle_path/2" do + test "maps plugin + basename to the conventional bundle path" do + assert Assets.image_bundle_path(:kv, "icon.png") == "assets/plugin/kv/icon.png" + end + end + + describe "merge_ui_app_fonts/2" do + @plist """ + <?xml version="1.0" encoding="UTF-8"?> + <plist version="1.0"> + <dict> + \t<key>CFBundleName</key> + \t<string>App</string> + </dict> + </plist> + """ + + test "no fonts leaves the plist unchanged" do + assert Assets.merge_ui_app_fonts(@plist, []) == @plist + end + + test "creates the UIAppFonts array when absent" do + out = Assets.merge_ui_app_fonts(@plist, ["icons.ttf"]) + assert out =~ "<key>UIAppFonts</key>" + assert out =~ "<string>icons.ttf</string>" + assert Assets.parse_ui_app_fonts(out) == ["icons.ttf"] + end + + test "merges into an existing array and de-dups" do + with_array = Assets.merge_ui_app_fonts(@plist, ["a.ttf"]) + out = Assets.merge_ui_app_fonts(with_array, ["a.ttf", "b.ttf"]) + assert Assets.parse_ui_app_fonts(out) == ["a.ttf", "b.ttf"] + end + + test "a font basename containing a regex backreference is emitted verbatim (create path)" do + out = Assets.merge_ui_app_fonts(@plist, ["x\\1y.ttf"]) + # The \1 must NOT be expanded into the captured </dict></plist> closing. + assert Assets.parse_ui_app_fonts(out) == ["x\\1y.ttf"] + assert out =~ "</dict>\n</plist>" + refute out =~ "<string>x\n" + end + + test "a font basename containing a regex backreference is emitted verbatim (replace path)" do + with_array = Assets.merge_ui_app_fonts(@plist, ["a.ttf"]) + out = Assets.merge_ui_app_fonts(with_array, ["b\\1c.ttf"]) + assert Assets.parse_ui_app_fonts(out) == ["a.ttf", "b\\1c.ttf"] + end + end +end diff --git a/test/mob_dev/plugin/audit_test.exs b/test/mob_dev/plugin/audit_test.exs new file mode 100644 index 0000000..b723ea6 --- /dev/null +++ b/test/mob_dev/plugin/audit_test.exs @@ -0,0 +1,331 @@ +defmodule MobDev.Plugin.AuditTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.Audit + + setup do + dir = Path.join(System.tmp_dir!(), "mob_audit_#{System.unique_integer([:positive])}") + File.mkdir_p!(Path.join(dir, "lib")) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, dir: dir} + end + + describe "audit_plugin/2 — clean plugin" do + test "returns zero findings for a minimal pure-Elixir plugin", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule Clean do + def hi, do: :ok + end + """) + + assert %{findings: [], summary: %{high: 0, medium: 0, low: 0}} = + Audit.audit_plugin(dir, %{name: :clean}) + end + + test "carries the plugin name from manifest into the report", %{dir: dir} do + assert %{plugin: :foo} = Audit.audit_plugin(dir, %{name: :foo}) + end + + test "nil manifest yields nil plugin name without crashing", %{dir: dir} do + assert %{plugin: nil, findings: []} = Audit.audit_plugin(dir, nil) + end + end + + describe "audit_plugin/2 — Code.eval_string / compile_string" do + test "flags Code.eval_string/1", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def go(s), do: Code.eval_string(s) + end + """) + + assert %{findings: [f]} = Audit.audit_plugin(dir, %{name: :x}) + assert f.severity == :high + assert f.rule == :code_eval + assert f.snippet =~ "eval_string" + assert is_integer(f.line) + end + + test "flags Code.eval_string/2 and /3 as well", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def a(s, b), do: Code.eval_string(s, b) + def c(s, b, opts), do: Code.eval_string(s, b, opts) + end + """) + + assert %{findings: findings} = Audit.audit_plugin(dir, %{name: :x}) + assert length(findings) == 2 + assert Enum.all?(findings, &(&1.rule == :code_eval)) + end + + test "flags Code.compile_string/1,2", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def a(s), do: Code.compile_string(s) + def b(s, f), do: Code.compile_string(s, f) + end + """) + + assert %{findings: findings} = Audit.audit_plugin(dir, %{name: :x}) + assert length(findings) == 2 + assert Enum.all?(findings, &(&1.severity == :high and &1.rule == :code_eval)) + end + end + + describe "audit_plugin/2 — :erlang.binary_to_term" do + test "flags the unbounded arity-1 form", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def go(bin), do: :erlang.binary_to_term(bin) + end + """) + + assert %{findings: [f]} = Audit.audit_plugin(dir, %{name: :x}) + assert f.severity == :high + assert f.rule == :unsafe_deserialization + end + + test "does NOT flag the arity-2 :safe form", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def go(bin), do: :erlang.binary_to_term(bin, [:safe]) + end + """) + + assert %{findings: []} = Audit.audit_plugin(dir, %{name: :x}) + end + end + + describe "audit_plugin/2 — String.to_atom" do + test "does NOT flag literal String.to_atom(\"foo\")", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def go, do: String.to_atom("foo") + end + """) + + assert %{findings: []} = Audit.audit_plugin(dir, %{name: :x}) + end + + test "flags non-literal String.to_atom(x)", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def go(x), do: String.to_atom(x) + end + """) + + assert %{findings: [f]} = Audit.audit_plugin(dir, %{name: :x}) + assert f.severity == :medium + assert f.rule == :unbounded_atom + end + end + + describe "audit_plugin/2 — Application.put_env" do + test "flags Application.put_env(:mob, ...)", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def go, do: Application.put_env(:mob, :plugins, [:evil]) + end + """) + + assert %{findings: [f]} = Audit.audit_plugin(dir, %{name: :x}) + assert f.severity == :medium + assert f.rule == :mob_env_mutation + end + + test "does NOT flag put_env for other apps or get_env for :mob", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def a, do: Application.put_env(:my_app, :key, 1) + def b, do: Application.get_env(:mob, :plugins, []) + end + """) + + assert %{findings: []} = Audit.audit_plugin(dir, %{name: :x}) + end + end + + describe "audit_plugin/2 — file / network I/O" do + test "flags File.write/rm_rf/cp", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def a(p), do: File.write(p, "x") + def b(p), do: File.rm_rf!(p) + def c(a, b), do: File.cp(a, b) + end + """) + + assert %{findings: findings} = Audit.audit_plugin(dir, %{name: :x}) + assert length(findings) == 3 + assert Enum.all?(findings, &(&1.severity == :medium and &1.rule == :file_io)) + end + + test "flags :os.cmd and System.cmd", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def a, do: :os.cmd(~c"ls") + def b, do: System.cmd("ls", []) + end + """) + + assert %{findings: findings} = Audit.audit_plugin(dir, %{name: :x}) + assert length(findings) == 2 + assert Enum.all?(findings, &(&1.rule == :process_spawn)) + end + + test "flags Path.expand(\"~\")", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def home, do: Path.expand("~") + end + """) + + assert %{findings: [f]} = Audit.audit_plugin(dir, %{name: :x}) + assert f.rule == :home_escape + end + + test "does NOT flag Path.expand on a non-home string", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def p(x), do: Path.expand("./foo", x) + end + """) + + assert %{findings: []} = Audit.audit_plugin(dir, %{name: :x}) + end + end + + describe "audit_plugin/2 — C NIF sources" do + setup %{dir: dir} do + File.mkdir_p!(Path.join(dir, "priv/native/jni")) + {:ok, dir: dir} + end + + test "flags system(), popen(), execve()", %{dir: dir} do + write_file(dir, "priv/native/jni/a.c", """ + #include <stdlib.h> + void a(void) { system("rm -rf /"); } + void b(void) { popen("ls", "r"); } + void c(void) { execve("/bin/sh", 0, 0); } + """) + + assert %{findings: findings} = Audit.audit_plugin(dir, %{name: :x}) + assert length(findings) == 3 + assert Enum.all?(findings, &(&1.severity == :high and &1.rule == :process_spawn)) + end + + test "flags raw socket() creation as medium", %{dir: dir} do + write_file(dir, "priv/native/jni/n.c", """ + int n(void) { return socket(2, 1, 0); } + """) + + assert %{findings: [f]} = Audit.audit_plugin(dir, %{name: :x}) + assert f.severity == :medium + assert f.rule == :raw_socket + end + + test "does NOT flag system() that appears only in a // comment", %{dir: dir} do + write_file(dir, "priv/native/jni/c.c", """ + // intentionally does NOT call system(3) or popen(3). + int ok(void) { return 0; } + """) + + assert %{findings: []} = Audit.audit_plugin(dir, %{name: :x}) + end + + test "does NOT flag system() that appears only in a /* */ comment", %{dir: dir} do + write_file(dir, "priv/native/jni/c.c", """ + /* The NIF deliberately avoids system() and popen() — see audit. */ + int ok(void) { return 0; } + """) + + assert %{findings: []} = Audit.audit_plugin(dir, %{name: :x}) + end + end + + describe "audit_plugin/2 — Kotlin/Swift skip" do + test "reports kotlin_or_swift_skipped: true when present", %{dir: dir} do + File.mkdir_p!(Path.join(dir, "priv/native/android")) + write_file(dir, "priv/native/android/X.kt", "class X {}\n") + assert %{kotlin_or_swift_skipped: true} = Audit.audit_plugin(dir, %{name: :x}) + end + + test "reports false when only Elixir and C are present", %{dir: dir} do + assert %{kotlin_or_swift_skipped: false} = Audit.audit_plugin(dir, %{name: :x}) + end + end + + describe "tally/1 and exit_code/2" do + test "tally counts findings per severity" do + findings = [ + %{severity: :high}, + %{severity: :high}, + %{severity: :medium}, + %{severity: :low}, + %{severity: :low}, + %{severity: :low} + ] + + assert %{high: 2, medium: 1, low: 3} = Audit.tally(findings) + end + + test "exit_code/2 — empty reports → 0" do + assert Audit.exit_code([]) == 0 + end + + test "exit_code/2 — only lows → 0" do + assert Audit.exit_code([report(0, 0, 3)]) == 0 + end + + test "exit_code/2 — medium without --accept-medium → 1" do + assert Audit.exit_code([report(0, 2, 0)], false) == 1 + end + + test "exit_code/2 — medium with --accept-medium → 0" do + assert Audit.exit_code([report(0, 2, 0)], true) == 0 + end + + test "exit_code/2 — high beats medium and --accept-medium → 2" do + assert Audit.exit_code([report(1, 5, 0)], true) == 2 + end + end + + describe "audit_plugin/2 — sorting" do + test "findings are sorted high → medium → low, then by file + line", %{dir: dir} do + write_ex(dir, "lib/m.ex", """ + defmodule X do + def a(x), do: String.to_atom(x) + def b(s), do: Code.eval_string(s) + end + """) + + assert %{findings: [f1, f2]} = Audit.audit_plugin(dir, %{name: :x}) + assert f1.severity == :high + assert f2.severity == :medium + end + end + + # ── helpers ─────────────────────────────────────────────────────────────── + + defp write_ex(dir, rel, source) do + path = Path.join(dir, rel) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, source) + end + + defp write_file(dir, rel, source) do + path = Path.join(dir, rel) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, source) + end + + defp report(high, medium, low) do + %{ + plugin: :stub, + findings: [], + summary: %{high: high, medium: medium, low: low}, + kotlin_or_swift_skipped: false + } + end +end diff --git a/test/mob_dev/plugin/conflict_surface_test.exs b/test/mob_dev/plugin/conflict_surface_test.exs new file mode 100644 index 0000000..6875d01 --- /dev/null +++ b/test/mob_dev/plugin/conflict_surface_test.exs @@ -0,0 +1,195 @@ +defmodule MobDev.Plugin.ConflictSurfaceTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.{Merge, Validator} + + @base %{name: :p, mob_version: "~> 0.6", plugin_spec_version: 1} + + defp two(extra_a, extra_b) do + [{:a, Map.merge(@base, extra_a)}, {:b, Map.merge(%{@base | name: :b}, extra_b)}] + end + + defp same(extra), do: two(extra, extra) + + # ── The systematic guarantee ──────────────────────────────────────────────── + describe "completeness — every Merge gatherer is classified" do + # Every public MobDev.Plugin.Merge function combines N plugins' contributions + # into one shared space, so each MUST be classified in Validator.conflict_surface/0 + # (as a collision guard, or as namespaced/union/build_time/derived). This test + # fails the moment a new gatherer is added without classifying its conflict + # behavior — turning "we hope multiples compose" into "CI proves they do". + @merge_gatherers Merge.__info__(:functions) + |> Enum.map(fn {name, _arity} -> name end) + |> Enum.uniq() + |> MapSet.new() + + test "no Merge gatherer is missing a conflict-surface classification" do + classified = Validator.conflict_surface() |> Map.keys() |> MapSet.new() + missing = MapSet.difference(@merge_gatherers, classified) + + assert MapSet.size(missing) == 0, + "Merge gatherers with no Validator.conflict_surface/0 entry: " <> + "#{inspect(MapSet.to_list(missing))}. Classify each — add a {:collision, ...} " <> + "guard if two plugins can clash on it, else {:namespaced|:union|:build_time|:derived, reason}." + end + + test "no stale conflict-surface entry without a backing Merge gatherer" do + classified = Validator.conflict_surface() |> Map.keys() |> MapSet.new() + stale = MapSet.difference(classified, @merge_gatherers) + + assert MapSet.size(stale) == 0, + "conflict_surface/0 classifies non-existent Merge gatherers: #{inspect(MapSet.to_list(stale))}" + end + + test "every :collision entry carries at least one {label, extractor}" do + for {gatherer, {:collision, checks}} <- Validator.conflict_surface() do + assert is_list(checks) and checks != [], "#{gatherer} :collision has no checks" + + for {label, extractor} <- checks do + assert is_binary(label) and byte_size(label) > 0, "#{gatherer} has an empty label" + assert is_function(extractor, 1) + end + end + end + end + + # ── Each guard actually catches a clash ───────────────────────────────────── + describe "collision detection (two plugins, same value → build error)" do + test "duplicate NIF module across plugins" do + plugins = same(%{nifs: [%{module: :dup_nif, native_dir: "priv/jni"}]}) + assert %{errors: errs} = Validator.cross_validate(plugins) + assert Enum.any?(errs, &(&1 =~ "NIF module")) + end + + test "duplicate iOS Swift source basename across plugins" do + plugins = + two( + %{ios: %{swift_files: ["priv/a/Shared.swift"]}}, + %{ios: %{swift_files: ["priv/b/Shared.swift"]}} + ) + + assert %{errors: errs} = Validator.cross_validate(plugins) + assert Enum.any?(errs, &(&1 =~ "Swift source basename")) + end + + test "duplicate Android JNI source basename across plugins" do + plugins = + two(%{android: %{jni_source: "priv/a/thunk.c"}}, %{ + android: %{jni_source: "priv/b/thunk.c"} + }) + + assert %{errors: errs} = Validator.cross_validate(plugins) + assert Enum.any?(errs, &(&1 =~ "JNI source basename")) + end + + test "duplicate Android bridge class across plugins" do + plugins = same(%{android: %{bridge_class: "io.mob.x.Bridge"}}) + assert %{errors: errs} = Validator.cross_validate(plugins) + assert Enum.any?(errs, &(&1 =~ "bridge class")) + end + + test "duplicate Info.plist key across plugins (different values)" do + plugins = + two( + %{ios: %{plist_keys: %{"NSCameraUsageDescription" => "A wants camera"}}}, + %{ios: %{plist_keys: %{"NSCameraUsageDescription" => "B wants camera"}}} + ) + + assert %{errors: errs} = Validator.cross_validate(plugins) + assert Enum.any?(errs, &(&1 =~ "Info.plist key")) + end + + test "duplicate AndroidManifest component name across plugins" do + plugins = + same(%{ + android: %{ + manifest_application_snippets: [ + ~s(<service android:name="io.mob.x.Svc" android:exported="true"/>) + ] + } + }) + + assert %{errors: errs} = Validator.cross_validate(plugins) + assert Enum.any?(errs, &(&1 =~ "AndroidManifest component")) + end + + test "duplicate Android res destination across plugins" do + plugins = + two( + %{android: %{res_files: ["priv/a/res/xml/svc.xml"]}}, + %{android: %{res_files: ["priv/b/res/xml/svc.xml"]}} + ) + + assert %{errors: errs} = Validator.cross_validate(plugins) + assert Enum.any?(errs, &(&1 =~ "Android res destination")) + end + + test "duplicate supervised worker across plugins" do + plugins = same(%{lifecycle: %{supervised: [MyApp.Worker]}}) + assert %{errors: errs} = Validator.cross_validate(plugins) + assert Enum.any?(errs, &(&1 =~ "supervised worker")) + end + + test "duplicate notification match across plugins" do + handler = {Some.Mod, :handle, 1} + + plugins = + same(%{notifications: %{handlers: [%{match: %{type: "ping"}, handler: handler}]}}) + + assert %{errors: errs} = Validator.cross_validate(plugins) + assert Enum.any?(errs, &(&1 =~ "notification match")) + end + end + + describe "no false positives (distinct values compose cleanly)" do + test "distinct NIF modules, swift basenames, plist keys, workers, matches" do + plugins = + two( + %{ + nifs: [%{module: :a_nif, native_dir: "priv/jni"}], + ios: %{ + swift_files: ["priv/A.swift"], + plist_keys: %{"NSCameraUsageDescription" => "a"} + }, + android: %{jni_source: "priv/a.c", bridge_class: "io.a.B"}, + lifecycle: %{supervised: [A.Worker]}, + notifications: %{handlers: [%{match: %{type: "a"}, handler: {M, :f, 1}}]} + }, + %{ + nifs: [%{module: :b_nif, native_dir: "priv/jni"}], + ios: %{ + swift_files: ["priv/B.swift"], + plist_keys: %{"NSMicrophoneUsageDescription" => "b"} + }, + android: %{jni_source: "priv/b.c", bridge_class: "io.b.B"}, + lifecycle: %{supervised: [B.Worker]}, + notifications: %{handlers: [%{match: %{type: "b"}, handler: {M, :f, 1}}]} + } + ) + + assert %{errors: []} = Validator.cross_validate(plugins) + end + + test "tier-0 (nil) manifests contribute nothing" do + plugins = [{:a, Map.merge(@base, %{nifs: [%{module: :x_nif}]})}, {:zero, nil}] + assert %{errors: []} = Validator.cross_validate(plugins) + end + + test "a cross-platform NIF (one plugin, same :module for iOS + Android) is NOT a collision" do + # The legit pattern (e.g. mob_location): one plugin ships an iOS objc NIF and + # an Android zig NIF for the same module. Distinct platform entries, one + # plugin → must not be flagged as a cross-plugin clash. + plugins = [ + {:loc, + Map.merge(@base, %{ + nifs: [ + %{module: :loc_nif, native_dir: "priv/ios", lang: :objc, platform: :ios}, + %{module: :loc_nif, native_dir: "priv/jni", lang: :zig, platform: :android} + ] + })} + ] + + assert %{errors: []} = Validator.cross_validate(plugins) + end + end +end diff --git a/test/mob_dev/plugin/cpp_archive_test.exs b/test/mob_dev/plugin/cpp_archive_test.exs new file mode 100644 index 0000000..24af315 --- /dev/null +++ b/test/mob_dev/plugin/cpp_archive_test.exs @@ -0,0 +1,202 @@ +defmodule MobDev.Plugin.CppArchiveTest do + use ExUnit.Case, async: false + + import Mox + + alias MobDev.Plugin.CppArchive + + setup :verify_on_exit! + + setup do + Application.put_env(:mob_dev, :release_shell, MobDev.Release.ShellMock) + on_exit(fn -> Application.delete_env(:mob_dev, :release_shell) end) + :ok + end + + defp spec(extra \\ %{}) do + Map.merge( + %{ + module: :nx_eigen_nif, + sources: ["/plug/c_src/nx_eigen_nif.cpp", "/plug/c_src/fft.cpp"], + includes: ["/plug/c_src"], + cxxflags: ["-std=c++17", "-DSTATIC_ERLANG_NIF_LIBNAME=nx_eigen"], + cxxflags_android: ["-mbranch-protection=standard"], + cxxflags_ios: [], + nm_symbol: "nx_eigen_nif_init" + }, + extra + ) + end + + # ── Pure surface ────────────────────────────────────────────────────── + + describe "cxxflags/3" do + test "forces -fPIC first, then base, then android flags, then -I includes" do + flags = CppArchive.cxxflags(spec(), :android_arm64, ["/inc/a", "/inc/b"]) + + assert hd(flags) == "-fPIC" + assert "-std=c++17" in flags + assert "-DSTATIC_ERLANG_NIF_LIBNAME=nx_eigen" in flags + assert "-mbranch-protection=standard" in flags + assert "-I/inc/a" in flags + assert "-I/inc/b" in flags + end + + test "uses cxxflags_ios (not android) on an iOS target" do + s = spec(%{cxxflags_android: ["-android-only"], cxxflags_ios: ["-ios-only"]}) + flags = CppArchive.cxxflags(s, :ios_device, []) + + assert "-ios-only" in flags + refute "-android-only" in flags + end + + test "preserves include order" do + flags = CppArchive.cxxflags(spec(), :ios_sim, ["/first", "/second", "/third"]) + includes = Enum.filter(flags, &String.starts_with?(&1, "-I")) + assert includes == ["-I/first", "-I/second", "-I/third"] + end + + test "android_arm32 gets the armv7 ABI flags; arm64 does not" do + arm32 = CppArchive.cxxflags(spec(), :android_arm32, []) + arm64 = CppArchive.cxxflags(spec(), :android_arm64, []) + + assert "-march=armv7-a" in arm32 + assert "-mfloat-abi=softfp" in arm32 + assert "-mthumb" in arm32 + + refute "-march=armv7-a" in arm64 + end + end + + describe "resolve_deps/2" do + test "resolves {:dep, name, sub} tokens against deps_path, passes strings through" do + entries = ["/plug/c_src", {:dep, :nx_eigen, "eigen-3.4.0"}, {:dep, :fine, "c_include"}] + + assert CppArchive.resolve_deps(entries, "/proj/deps") == [ + "/plug/c_src", + "/proj/deps/nx_eigen/eigen-3.4.0", + "/proj/deps/fine/c_include" + ] + end + + test "resolves a dep-sourced .cpp path (NxEigen's NIF lives in the nx_eigen dep)" do + assert CppArchive.resolve_deps([{:dep, :nx_eigen, "c_src/nx_eigen_nif.cpp"}], "/d") == + ["/d/nx_eigen/c_src/nx_eigen_nif.cpp"] + end + end + + describe "archive_name/1" do + test "is lib<module>.a" do + assert CppArchive.archive_name(:nx_eigen_nif) == "libnx_eigen_nif.a" + end + end + + describe "check_symbol_present/3" do + test ":ok when the T symbol is present" do + assert CppArchive.check_symbol_present( + "0000000000000000 T nx_eigen_nif_init\n", + "nx_eigen_nif_init", + "/x/lib.a" + ) == :ok + end + + test "precondition_failed when missing" do + assert {:error, {:precondition_failed, msg}} = + CppArchive.check_symbol_present("0000 t other\n", "nx_eigen_nif_init", "/x/lib.a") + + assert msg =~ "nx_eigen_nif_init" + end + end + + # ── build/3 option + spec validation ────────────────────────────────── + + describe "build/3 — required options/fields" do + test "missing :out_dir is a precondition_failed" do + assert {:error, {:precondition_failed, msg}} = CppArchive.build(spec(), :ios_device, []) + assert msg =~ ":out_dir" + end + + test "missing :erts_include is a precondition_failed" do + assert {:error, {:precondition_failed, msg}} = + CppArchive.build(spec(), :ios_device, out_dir: "/o") + + assert msg =~ ":erts_include" + end + + test "missing :nm_symbol in spec is a precondition_failed" do + s = Map.delete(spec(), :nm_symbol) + + assert {:error, {:precondition_failed, msg}} = + CppArchive.build(s, :ios_device, out_dir: "/o", erts_include: "/e") + + assert msg =~ ":nm_symbol" + end + end + + # ── build/3 full sequence ───────────────────────────────────────────── + + describe "build/3 — ios_device full sequence" do + test "xcrun clang++ compile per source, archive, verify Mach-O (underscored) symbol" do + Mox.stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + Mox.expect(MobDev.Release.ShellMock, :mkdir_p, 2, fn _ -> :ok end) + + # one compile per source (2) + Mox.expect(MobDev.Release.ShellMock, :cmd, 2, fn argv, _ -> + assert Enum.take(argv, 4) == ["xcrun", "-sdk", "iphoneos", "clang++"] + assert "-fPIC" in argv + assert "-c" in argv + assert "-std=c++17" in argv + {:ok, ""} + end) + + Mox.expect(MobDev.Release.ShellMock, :rm_f, fn _ -> :ok end) + # ar + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _ -> + assert "rcs" in argv + {:ok, ""} + end) + + # ranlib + Mox.expect(MobDev.Release.ShellMock, :cmd, fn _argv, _ -> {:ok, ""} end) + # nm — Mach-O underscored symbol + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _ -> + assert List.last(argv) =~ "libnx_eigen_nif.a" + {:ok, "0000000000000000 T _nx_eigen_nif_init\n"} + end) + + assert {:ok, info} = + CppArchive.build(spec(), :ios_device, + out_dir: "/fake/out", + erts_include: "/fake/erts/include", + deps_path: "/fake/deps" + ) + + assert info.module == :nx_eigen_nif + assert info.archive == "/fake/out/libnx_eigen_nif.a" + assert length(info.objects) == 2 + end + end + + describe "build/3 — android source precheck" do + # The android happy-path compile sequence isn't unit-tested: android_precheck + # validates the real NDK toolchain on disk (File.dir? + NdkVersion.installed?), + # which would make the test non-hermetic in CI — same reason MobDev.NxEigenNif + # only unit-tests its iOS sequence. The android compile argv + flags are + # covered by cxxflags/3; here we cover the source precheck, which runs first. + + test "missing source files short-circuits to precondition_failed" do + Mox.stub(MobDev.Release.ShellMock, :file?, fn _ -> false end) + Mox.stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + + assert {:error, {:precondition_failed, msg}} = + CppArchive.build(spec(), :android_arm64, + out_dir: "/o", + erts_include: "/e", + deps_path: "/d", + ndk_root: "/fake/ndk" + ) + + assert msg =~ "sources missing" + end + end +end diff --git a/test/mob_dev/plugin/crypto_test.exs b/test/mob_dev/plugin/crypto_test.exs new file mode 100644 index 0000000..ab3a8cf --- /dev/null +++ b/test/mob_dev/plugin/crypto_test.exs @@ -0,0 +1,105 @@ +defmodule MobDev.Plugin.CryptoTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.Crypto + + describe "generate_keypair/0" do + test "produces a 32-byte private key and 32-byte public key" do + {priv, pub} = Crypto.generate_keypair() + assert is_binary(priv) and byte_size(priv) == 32 + assert is_binary(pub) and byte_size(pub) == 32 + end + + test "produces distinct keypairs across calls" do + {priv1, pub1} = Crypto.generate_keypair() + {priv2, pub2} = Crypto.generate_keypair() + assert priv1 != priv2 + assert pub1 != pub2 + end + end + + describe "sign/2 + verify/3 round-trip" do + test "verifies a signature against the original payload" do + {priv, pub} = Crypto.generate_keypair() + payload = %{manifest: %{name: :mob_demo}, file_hashes: [], envelope_version: 1} + + sig = Crypto.sign(payload, priv) + assert byte_size(sig) == 64 + assert :ok = Crypto.verify(payload, sig, pub) + end + + test "verifies regardless of map key insertion order" do + {priv, pub} = Crypto.generate_keypair() + a = %{manifest: %{a: 1, b: 2, c: 3}, file_hashes: [], envelope_version: 1} + b = %{envelope_version: 1, file_hashes: [], manifest: %{c: 3, b: 2, a: 1}} + + sig = Crypto.sign(a, priv) + assert :ok = Crypto.verify(b, sig, pub) + end + + test "rejects a tampered payload" do + {priv, pub} = Crypto.generate_keypair() + payload = %{manifest: %{name: :mob_demo}, file_hashes: [], envelope_version: 1} + tampered = %{manifest: %{name: :mob_evil}, file_hashes: [], envelope_version: 1} + + sig = Crypto.sign(payload, priv) + assert {:error, :invalid_signature} = Crypto.verify(tampered, sig, pub) + end + + test "rejects a signature signed by a different key" do + {priv1, _pub1} = Crypto.generate_keypair() + {_priv2, pub2} = Crypto.generate_keypair() + payload = %{manifest: %{name: :x}, file_hashes: [], envelope_version: 1} + + sig = Crypto.sign(payload, priv1) + assert {:error, :invalid_signature} = Crypto.verify(payload, sig, pub2) + end + + test "returns :invalid_signature (not a crash) for wrong-size key/sig" do + # :crypto.verify/5 raises :badarg from OpenSSL on an ill-sized key. + # These bytes come from attacker-controlled plugin files, so the + # documented contract must hold: return the error tuple, never raise. + {_priv, pub} = Crypto.generate_keypair() + payload = %{manifest: %{name: :x}, file_hashes: [], envelope_version: 1} + good_sig = :binary.copy(<<0>>, 64) + + assert {:error, :invalid_signature} = Crypto.verify(payload, good_sig, "") + + assert {:error, :invalid_signature} = + Crypto.verify(payload, good_sig, :binary.copy(<<0>>, 10)) + + assert {:error, :invalid_signature} = Crypto.verify(payload, "", pub) + + assert {:error, :invalid_signature} = + Crypto.verify(payload, :binary.copy(<<0>>, 10), pub) + end + end + + describe "fingerprint/1" do + test "is deterministic for a given public key" do + {_priv, pub} = Crypto.generate_keypair() + assert Crypto.fingerprint(pub) == Crypto.fingerprint(pub) + end + + test "differs for distinct keys" do + {_p1, pub1} = Crypto.generate_keypair() + {_p2, pub2} = Crypto.generate_keypair() + assert Crypto.fingerprint(pub1) != Crypto.fingerprint(pub2) + end + + test "is shaped as `ed25519:<base64>`" do + {_priv, pub} = Crypto.generate_keypair() + assert "ed25519:" <> base64 = Crypto.fingerprint(pub) + assert {:ok, digest} = Base.decode64(base64) + assert byte_size(digest) == 32 + end + end + + describe "canonical_encode/1" do + test "produces the same bytes for equal terms with different map orders" do + a = %{a: 1, b: 2, c: 3} + b = %{c: 3, b: 2, a: 1} + assert Crypto.canonical_encode(a) == Crypto.canonical_encode(b) + end + end +end diff --git a/test/mob_dev/plugin/ios_bootstrap_test.exs b/test/mob_dev/plugin/ios_bootstrap_test.exs new file mode 100644 index 0000000..0d33f4d --- /dev/null +++ b/test/mob_dev/plugin/ios_bootstrap_test.exs @@ -0,0 +1,134 @@ +defmodule MobDev.Plugin.IOSBootstrapTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.IOSBootstrap + + defp base(extra), + do: Map.merge(%{name: :p, mob_version: "~> 0.6", plugin_spec_version: 1}, extra) + + defp signature_pad_component do + %{ + tag: "SignaturePad", + atom: :signature_pad, + props: [:bg_color, :corner_radius], + ios: %{view_module: "MobDemoSignaturePad_View", swift_struct: "MobSignaturePadView"}, + android: %{composable: "MobDemoSignaturePad_View"} + } + end + + describe "swift_source/1" do + test "emits the cdecl entry point even with no plugins" do + src = IOSBootstrap.swift_source([]) + + assert src =~ "@_cdecl(\"mob_register_plugins\")" + assert src =~ "public func mob_register_plugins()" + assert src =~ "import SwiftUI" + refute src =~ "MobNativeViewRegistry.shared.register" + end + + test "emits the cdecl entry point with no plugins (function body is empty)" do + src = IOSBootstrap.swift_source([{"/a", nil}]) + + assert src =~ "public func mob_register_plugins() {" + refute src =~ "register(" + end + + test "emits one register call per ui_components entry" do + plugins = [{"/a", base(%{ui_components: [signature_pad_component()]})}] + src = IOSBootstrap.swift_source(plugins) + + assert src =~ ~s|MobNativeViewRegistry.shared.register("MobDemoSignaturePad_View")| + assert src =~ "AnyView(MobSignaturePadView(props: props))" + end + + test "preserves order across plugins (activation order, then declaration order)" do + plugins = [ + {"/a", + base(%{ + ui_components: [ + %{ios: %{view_module: "A_View", swift_struct: "AView"}}, + %{ios: %{view_module: "B_View", swift_struct: "BView"}} + ] + })}, + {"/b", base(%{ui_components: [%{ios: %{view_module: "C_View", swift_struct: "CView"}}]})} + ] + + src = IOSBootstrap.swift_source(plugins) + + a_pos = :binary.match(src, "A_View") |> elem(0) + b_pos = :binary.match(src, "B_View") |> elem(0) + c_pos = :binary.match(src, "C_View") |> elem(0) + + assert a_pos < b_pos + assert b_pos < c_pos + end + + test "drops components missing :swift_struct (validator is what nags)" do + plugins = [ + {"/a", + base(%{ + ui_components: [ + # Missing :swift_struct — should be silently skipped here. + %{ios: %{view_module: "Old_View"}}, + %{ios: %{view_module: "New_View", swift_struct: "NewView"}} + ] + })} + ] + + src = IOSBootstrap.swift_source(plugins) + + refute src =~ "Old_View" + assert src =~ "New_View" + assert src =~ "NewView(props: props)" + end + + test "drops components missing :view_module (no registry key — nothing to register)" do + plugins = [ + {"/a", + base(%{ + ui_components: [%{ios: %{swift_struct: "DangleView"}}] + })} + ] + + assert IOSBootstrap.swift_source(plugins) |> String.match?(~r/DangleView/) == false + end + + test "ignores tier-0 (nil-manifest) plugins" do + plugins = [ + {"/a", nil}, + {"/b", base(%{ui_components: [signature_pad_component()]})} + ] + + src = IOSBootstrap.swift_source(plugins) + assert src =~ "MobDemoSignaturePad_View" + end + + test "emits a single cdecl entry even when multiple components are registered" do + plugins = [ + {"/a", + base(%{ + ui_components: [ + %{ios: %{view_module: "X_View", swift_struct: "XView"}}, + %{ios: %{view_module: "Y_View", swift_struct: "YView"}} + ] + })} + ] + + src = IOSBootstrap.swift_source(plugins) + + cdecl_count = + src + |> String.split("@_cdecl(\"mob_register_plugins\")") + |> length() + |> Kernel.-(1) + + assert cdecl_count == 1 + end + + test "header annotates the file as auto-generated" do + src = IOSBootstrap.swift_source([]) + assert src =~ "Auto-generated" + assert src =~ "MobDev.Plugin.IOSBootstrap" + end + end +end diff --git a/test/mob_dev/plugin/managed_block_test.exs b/test/mob_dev/plugin/managed_block_test.exs new file mode 100644 index 0000000..6319928 --- /dev/null +++ b/test/mob_dev/plugin/managed_block_test.exs @@ -0,0 +1,121 @@ +defmodule MobDev.Plugin.ManagedBlockTest do + use ExUnit.Case, async: true + alias MobDev.Plugin.ManagedBlock + + @markers {"<!-- BEGIN -->", "<!-- END -->"} + @doc_anchor "</application>" + + # A place fn that drops the region on its own lines just before `anchor`. + defp place(anchor), + do: fn stripped, region -> + ManagedBlock.insert_before(stripped, anchor, region) + end + + describe "upsert/4 — insert" do + test "drops a fenced region before the anchor line" do + content = " <thing/>\n </application>\n" + out = ManagedBlock.upsert(content, @markers, " <svc/>", place(@doc_anchor)) + + assert out == + " <thing/>\n<!-- BEGIN -->\n <svc/>\n<!-- END -->\n </application>\n" + end + + test "an empty body inserts nothing" do + content = " </application>\n" + assert ManagedBlock.upsert(content, @markers, "", place(@doc_anchor)) == content + assert ManagedBlock.upsert(content, @markers, " \n ", place(@doc_anchor)) == content + end + end + + describe "upsert/4 — idempotence & replacement" do + @content "<a/>\n</application>\n" + + test "running twice with the same body is a fixed point" do + once = ManagedBlock.upsert(@content, @markers, " <svc/>", place(@doc_anchor)) + twice = ManagedBlock.upsert(once, @markers, " <svc/>", place(@doc_anchor)) + assert once == twice + # exactly one region + assert length(String.split(once, "<!-- BEGIN -->")) == 2 + end + + test "a changed body replaces the region (old contents gone)" do + v1 = ManagedBlock.upsert(@content, @markers, " <old/>", place(@doc_anchor)) + v2 = ManagedBlock.upsert(v1, @markers, " <new/>", place(@doc_anchor)) + assert v2 =~ "<new/>" + refute v2 =~ "<old/>" + assert length(String.split(v2, "<!-- BEGIN -->")) == 2 + end + + test "an empty body REMOVES an existing region (the reversibility property)" do + with_region = ManagedBlock.upsert(@content, @markers, " <svc/>", place(@doc_anchor)) + removed = ManagedBlock.upsert(with_region, @markers, "", place(@doc_anchor)) + assert removed == @content + refute removed =~ "BEGIN" + end + end + + describe "strip/2" do + test "removes exactly the region's whole lines, leaving surrounding content" do + content = "keep-before\n <!-- BEGIN -->\n junk\n <!-- END -->\nkeep-after\n" + assert ManagedBlock.strip(content, @markers) == "keep-before\nkeep-after\n" + end + + test "is a no-op when the region is absent" do + assert ManagedBlock.strip("nothing here\n", @markers) == "nothing here\n" + end + + test "is a no-op when the end marker precedes the begin marker (malformed)" do + content = "<!-- END -->\nx\n<!-- BEGIN -->\n" + assert ManagedBlock.strip(content, @markers) == content + end + + test "an orphan BEGIN before a real region does NOT eat the host lines between them (F1)" do + # The data-loss case: naive first-BEGIN→first-END would delete + # "host-line" too. We must remove only the real region. + content = + "<!-- BEGIN -->\nhost-line-that-must-survive\n" <> + "<!-- BEGIN -->\nplugin-body\n<!-- END -->\nafter\n" + + out = ManagedBlock.strip(content, @markers) + assert out =~ "host-line-that-must-survive" + refute out =~ "plugin-body" + # the real region (2nd BEGIN + END) is gone; the orphan BEGIN marker lingers + # harmlessly but no host content was lost + assert out == "<!-- BEGIN -->\nhost-line-that-must-survive\nafter\n" + end + + test "clears duplicate well-formed regions (loops until none remain)" do + content = + "<!-- BEGIN -->\na\n<!-- END -->\nkeep\n<!-- BEGIN -->\nb\n<!-- END -->\n" + + assert ManagedBlock.strip(content, @markers) == "keep\n" + end + + test "a trailing orphan BEGIN after a region is left alone (no host loss)" do + content = "<!-- BEGIN -->\nbody\n<!-- END -->\nhost\n<!-- BEGIN -->\n" + assert ManagedBlock.strip(content, @markers) == "host\n<!-- BEGIN -->\n" + end + + test "strip is the left inverse of place (round-trip)" do + base = "line1\nline2\n</application>\ntail\n" + placed = ManagedBlock.upsert(base, @markers, " body1\n body2", place(@doc_anchor)) + assert ManagedBlock.strip(placed, @markers) == base + end + end + + describe "insert_before/3 + insert_before_index/3" do + test "insert_before puts the region on its own lines before the anchor's line" do + assert ManagedBlock.insert_before("a\n }\n", "}", "R") == "a\nR\n }\n" + end + + test "insert_before is a no-op when the anchor is absent" do + assert ManagedBlock.insert_before("a\nb\n", "zzz", "R") == "a\nb\n" + end + + test "insert_before_index inserts before the line containing the index" do + content = "abc\ndefXghi\n" + idx = :binary.match(content, "X") |> elem(0) + assert ManagedBlock.insert_before_index(content, idx, "R") == "abc\nR\ndefXghi\n" + end + end +end diff --git a/test/mob_dev/plugin/manifest_test.exs b/test/mob_dev/plugin/manifest_test.exs new file mode 100644 index 0000000..97e8864 --- /dev/null +++ b/test/mob_dev/plugin/manifest_test.exs @@ -0,0 +1,615 @@ +defmodule MobDev.Plugin.ManifestTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.Manifest + + @valid %{name: :mob_demo, mob_version: "~> 0.6", plugin_spec_version: 1} + + describe "load/1" do + setup do + dir = + Path.join(System.tmp_dir!(), "mob_manifest_test_#{System.unique_integer([:positive])}") + + File.mkdir_p!(Path.join(dir, "priv")) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, dir: dir} + end + + test "returns nil when no manifest file exists (tier-0 plugin)", %{dir: dir} do + assert {:ok, nil} = Manifest.load(dir) + end + + test "reads and returns a manifest map", %{dir: dir} do + File.write!(Path.join(dir, "priv/mob_plugin.exs"), inspect(@valid)) + assert {:ok, %{name: :mob_demo}} = Manifest.load(dir) + end + + test "errors when the file does not evaluate to a map", %{dir: dir} do + File.write!(Path.join(dir, "priv/mob_plugin.exs"), ":not_a_map") + assert {:error, msg} = Manifest.load(dir) + assert msg =~ "must evaluate to a map" + end + + test "errors (does not raise) on a malformed manifest file", %{dir: dir} do + File.write!(Path.join(dir, "priv/mob_plugin.exs"), "%{name: :x,,,}") + assert {:error, msg} = Manifest.load(dir) + assert msg =~ "failed to evaluate" + end + end + + describe "validate/1 android.manifest_application_snippets + res_files" do + test "accepts well-formed snippets and res_files" do + m = + Map.put(@valid, :android, %{ + manifest_application_snippets: [~s(<service android:name="io.x.Svc"/>)], + res_files: ["priv/native/android/res/xml/svc.xml"] + }) + + assert {:ok, ^m} = Manifest.validate(m) + end + + test "rejects non-list / non-string snippets" do + assert {:error, errs} = + Manifest.validate( + Map.put(@valid, :android, %{manifest_application_snippets: "<x/>"}) + ) + + assert Enum.any?(errs, &(&1 =~ "manifest_application_snippets")) + + assert {:error, errs2} = + Manifest.validate( + Map.put(@valid, :android, %{manifest_application_snippets: ["", :nope]}) + ) + + assert Enum.any?(errs2, &(&1 =~ "manifest_application_snippets")) + end + + test "rejects res_files without a res segment (dest can't be derived)" do + assert {:error, errs} = + Manifest.validate(Map.put(@valid, :android, %{res_files: ["priv/xml/svc.xml"]})) + + assert Enum.any?(errs, &(&1 =~ "res")) + end + + test "rejects a non-list res_files" do + assert {:error, errs} = + Manifest.validate(Map.put(@valid, :android, %{res_files: "res/xml/x.xml"})) + + assert Enum.any?(errs, &(&1 =~ "res_files")) + end + + test "rejects a res_files path containing .. (path traversal)" do + assert {:error, errs} = + Manifest.validate( + Map.put(@valid, :android, %{res_files: ["x/res/../../../build.gradle"]}) + ) + + assert Enum.any?(errs, &(&1 =~ ".." or &1 =~ "traversal")) + end + end + + describe "validate/1" do + test "nil (no manifest) is valid" do + assert {:ok, nil} = Manifest.validate(nil) + end + + test "accepts a manifest with the three required fields" do + assert {:ok, @valid} = Manifest.validate(@valid) + end + + test "rejects a missing/invalid name" do + assert {:error, errs} = Manifest.validate(Map.delete(@valid, :name)) + assert Enum.any?(errs, &(&1 =~ ":name")) + end + + test "rejects an invalid mob_version requirement" do + assert {:error, errs} = Manifest.validate(%{@valid | mob_version: "not a req"}) + assert Enum.any?(errs, &(&1 =~ ":mob_version")) + end + + test "rejects an unsupported plugin_spec_version" do + assert {:error, errs} = Manifest.validate(%{@valid | plugin_spec_version: 99}) + assert Enum.any?(errs, &(&1 =~ "plugin_spec_version")) + end + + test "accepts spec version 2 (code-generated plugins)" do + m = %{@valid | plugin_spec_version: 2} + assert {:ok, ^m} = Manifest.validate(m) + end + + test "reports every problem at once, not just the first" do + assert {:error, errs} = Manifest.validate(%{plugin_spec_version: "nope"}) + assert length(errs) == 3 + assert Enum.any?(errs, &(&1 =~ ":name")) + assert Enum.any?(errs, &(&1 =~ ":mob_version")) + assert Enum.any?(errs, &(&1 =~ "plugin_spec_version")) + end + + test "rejects a non-map manifest" do + assert {:error, _} = Manifest.validate("nope") + end + + test "accepts a permissions list with capability + optional ios handler" do + m = + Map.put(@valid, :permissions, [ + %{capability: :location, ios: %{handler: "mob_location_request_permission"}}, + %{capability: :sensors} + ]) + + assert {:ok, ^m} = Manifest.validate(m) + end + + test "rejects permissions that isn't a list" do + assert {:error, errs} = Manifest.validate(Map.put(@valid, :permissions, %{capability: :x})) + assert Enum.any?(errs, &(&1 =~ "permissions must be a list")) + end + + test "rejects a permissions entry without a :capability atom" do + assert {:error, errs} = Manifest.validate(Map.put(@valid, :permissions, [%{ios: %{}}])) + assert Enum.any?(errs, &(&1 =~ ":capability")) + end + + test "rejects a permissions entry whose :ios lacks a :handler string" do + m = Map.put(@valid, :permissions, [%{capability: :location, ios: %{}}]) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ ":handler")) + end + + test "accepts nif entries with a valid :platform (cross-platform plugin)" do + m = + Map.put(@valid, :nifs, [ + %{module: :mob_location_nif, native_dir: "priv/native/ios", platform: :ios}, + %{ + module: :mob_location_nif, + native_dir: "priv/native/jni", + lang: :zig, + platform: :android + }, + %{module: :shared_nif} + ]) + + assert {:ok, ^m} = Manifest.validate(m) + end + + test "rejects a nif entry with an invalid :platform" do + m = Map.put(@valid, :nifs, [%{module: :x, platform: :windows}]) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ ":platform must be :ios or :android")) + end + + test "rejects a nif entry that is not a map" do + m = Map.put(@valid, :nifs, ["not_a_map"]) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "nifs entry #0 must be a map")) + end + + test "accepts a valid lang: :cpp_archive nif entry" do + m = + Map.put(@valid, :nifs, [ + %{ + module: :nx_eigen, + lang: :cpp_archive, + sources: ["c_src/nx_eigen_nif.cpp", "c_src/nx_eigen_fft_eigen.cpp"], + includes: ["c_src", {:dep, :nx_eigen, "eigen-3.4.0"}], + cxxflags: ["-std=c++17", "-O3"], + nm_symbol: "nx_eigen_nif_init" + } + ]) + + assert {:ok, ^m} = Manifest.validate(m) + end + + test "accepts a cpp_archive entry with only the required fields" do + m = + Map.put(@valid, :nifs, [ + %{module: :foo, lang: :cpp_archive, sources: ["a.cpp"], nm_symbol: "foo_nif_init"} + ]) + + assert {:ok, ^m} = Manifest.validate(m) + end + + test "rejects a cpp_archive whose nm_symbol disagrees with <module>_nif_init" do + # The exact bug device-verify caught: driver table derives nx_eigen_nif_init + # from the module, archive exports a different symbol → link failure. + m = + Map.put(@valid, :nifs, [ + %{ + module: :nx_eigen_nif, + lang: :cpp_archive, + sources: ["a.cpp"], + nm_symbol: "nx_eigen_nif_init" + } + ]) + + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "must be \"nx_eigen_nif_nif_init\"")) + end + + test "rejects a cpp_archive entry missing :sources" do + m = Map.put(@valid, :nifs, [%{module: :x, lang: :cpp_archive, nm_symbol: "x_init"}]) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "cpp_archive requires a non-empty :sources")) + end + + test "rejects a cpp_archive entry with an empty :sources list" do + m = + Map.put(@valid, :nifs, [ + %{module: :x, lang: :cpp_archive, sources: [], nm_symbol: "x_init"} + ]) + + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "cpp_archive requires a non-empty :sources")) + end + + test "accepts {:dep, name, subpath} source tokens (dep-sourced C++)" do + m = + Map.put(@valid, :nifs, [ + %{ + module: :nx_eigen, + lang: :cpp_archive, + sources: [{:dep, :nx_eigen, "c_src/nx_eigen_nif.cpp"}, "c_src/fft.cpp"], + nm_symbol: "nx_eigen_nif_init" + } + ]) + + assert {:ok, ^m} = Manifest.validate(m) + end + + test "rejects a cpp_archive entry with a bogus source entry" do + m = + Map.put(@valid, :nifs, [ + %{module: :x, lang: :cpp_archive, sources: [:nope], nm_symbol: "x_init"} + ]) + + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "path strings or {:dep")) + end + + test "rejects a cpp_archive entry missing :nm_symbol" do + m = Map.put(@valid, :nifs, [%{module: :x, lang: :cpp_archive, sources: ["a.cpp"]}]) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "cpp_archive requires an :nm_symbol")) + end + + test "rejects a cpp_archive entry missing :module" do + # Without :module, Merge.static_archives silently drops the entry (its + # comprehension guards on is_atom(nif[:module])) — fail-open. Catch it. + m = + Map.put(@valid, :nifs, [ + %{lang: :cpp_archive, sources: ["a.cpp"], nm_symbol: "x_nif_init"} + ]) + + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "cpp_archive requires a lowercase :module atom")) + end + + test "rejects a cpp_archive entry with a non-atom :module" do + # A non-atom :module is dropped at merge; nil would build libnil.a. + m = + Map.put(@valid, :nifs, [ + %{module: "x", lang: :cpp_archive, sources: ["a.cpp"], nm_symbol: "x_nif_init"} + ]) + + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "cpp_archive requires a lowercase :module atom")) + end + + test "rejects a cpp_archive entry with a nil :module" do + m = + Map.put(@valid, :nifs, [ + %{module: nil, lang: :cpp_archive, sources: ["a.cpp"], nm_symbol: "x_nif_init"} + ]) + + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "cpp_archive requires a lowercase :module atom")) + end + + test "rejects a cpp_archive entry with an aliased (uppercase) :module" do + # Foo.Bar would yield a wrong-named libElixir.Foo.Bar.a downstream. + m = + Map.put(@valid, :nifs, [ + %{module: Foo.Bar, lang: :cpp_archive, sources: ["a.cpp"], nm_symbol: "x_nif_init"} + ]) + + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "lowercase NIF atom")) + end + end + + describe "tier/1" do + test "no manifest is tier 0" do + assert Manifest.tier(nil) == 0 + end + + test "minimal manifest (no capability sections) is the tier-1 floor" do + assert Manifest.tier(@valid) == 1 + end + + test "NIFs are tier 1" do + assert Manifest.tier(Map.put(@valid, :nifs, [])) == 1 + end + + test "ui_components are tier 2" do + assert Manifest.tier(Map.put(@valid, :ui_components, [])) == 2 + end + + test "permissions are tier 1 (native capability)" do + assert Manifest.tier(Map.put(@valid, :permissions, [%{capability: :location}])) == 1 + end + + test "screens are tier 3" do + assert Manifest.tier(Map.put(@valid, :screens, [])) == 3 + end + + test "screens_generator (spec 2) is tier 3" do + assert Manifest.tier(Map.put(@valid, :screens_generator, {M, :f, []})) == 3 + end + + test "lifecycle is tier 4" do + assert Manifest.tier(Map.put(@valid, :lifecycle, %{})) == 4 + end + + test "highest matching section wins" do + m = @valid |> Map.put(:nifs, []) |> Map.put(:ui_components, []) |> Map.put(:lifecycle, %{}) + assert Manifest.tier(m) == 4 + end + end + + describe "hot_pushable/1" do + test "no manifest (pure Elixir tier 0) is hot-pushable" do + assert Manifest.hot_pushable(nil) == true + end + + test "minimal manifest with no native sections is hot-pushable" do + assert Manifest.hot_pushable(@valid) == true + end + + test "NIF plugin is not hot-pushable" do + assert Manifest.hot_pushable(Map.put(@valid, :nifs, [])) == false + end + + test "a NATIVE-backed visual plugin is not hot-pushable" do + native = [%{tag: "Sig", atom: :sig, ios: %{view_module: "Sig_View"}}] + assert Manifest.hot_pushable(Map.put(@valid, :ui_components, native)) == false + end + + test "an expand-only (pure-Elixir composite) visual plugin IS hot-pushable" do + comps = [%{tag: "Card", atom: :card, expand: {Kit, :card}}] + assert Manifest.hot_pushable(Map.put(@valid, :ui_components, comps)) == true + # …and still classifies as tier 2 (visual). + assert Manifest.tier(Map.put(@valid, :ui_components, comps)) == 2 + end + + test "native + Elixir screens is partial" do + m = @valid |> Map.put(:nifs, []) |> Map.put(:screens, []) + assert Manifest.hot_pushable(m) == :partial + end + + test "pure-Elixir screens (no native) is hot-pushable" do + assert Manifest.hot_pushable(Map.put(@valid, :screens, [])) == true + end + end + + describe "validate/1 — tier 3 sections" do + @v2 %{name: :p, mob_version: "~> 0.6", plugin_spec_version: 2} + + test "accepts a valid static screens list" do + m = Map.put(@valid, :screens, [%{module: MyPlugin.ListScreen, default_route: "/p/list"}]) + assert {:ok, ^m} = Manifest.validate(m) + end + + test "rejects a screen entry missing module or default_route" do + m = Map.put(@valid, :screens, [%{module: MyPlugin.ListScreen}]) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "screens entry")) + end + + test "rejects screens that is not a list" do + assert {:error, errs} = Manifest.validate(Map.put(@valid, :screens, :nope)) + assert Enum.any?(errs, &(&1 =~ "screens must be a list")) + end + + test "accepts a screens_generator MFA on spec v2" do + m = Map.put(@v2, :screens_generator, {MyPlugin.Gen, :generate, []}) + assert {:ok, ^m} = Manifest.validate(m) + end + + test "rejects screens_generator on spec v1 (needs v2)" do + m = Map.put(@valid, :screens_generator, {MyPlugin.Gen, :generate, []}) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "requires plugin_spec_version: 2")) + end + + test "rejects declaring both static screens and a generator" do + m = + @v2 + |> Map.put(:screens, [%{module: A, default_route: "/a"}]) + |> Map.put(:screens_generator, {G, :g, []}) + + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "mutually exclusive")) + end + + test "rejects a malformed screens_generator (not an MFA)" do + m = Map.put(@v2, :screens_generator, "Gen.generate") + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "must be an {Module, :function, args} tuple")) + end + + test "accepts valid migrations" do + m = + Map.put(@valid, :migrations, %{ + repo_namespace: "p_", + migrations_dir: "priv/repo/migrations" + }) + + assert {:ok, ^m} = Manifest.validate(m) + end + + test "rejects migrations missing keys" do + m = Map.put(@valid, :migrations, %{repo_namespace: "p_"}) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "migrations must be a map")) + end + + test "accepts assets with font and image path lists" do + m = Map.put(@valid, :assets, %{fonts: ["priv/assets/x.ttf"], images: ["priv/assets/y.png"]}) + assert {:ok, ^m} = Manifest.validate(m) + end + + test "rejects assets with a non-string path" do + m = Map.put(@valid, :assets, %{fonts: [:not_a_path]}) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "assets.fonts must be a list of path strings")) + end + end + + describe "validate/1 — tier 4 sections" do + test "accepts a full lifecycle map" do + lc = %{ + on_start: {P, :start, []}, + supervised: [P.Worker, {P.Other, []}], + on_resume: {P, :on_resume, []}, + on_background: {P, :on_background, []} + } + + m = Map.put(@valid, :lifecycle, lc) + assert {:ok, ^m} = Manifest.validate(m) + end + + test "rejects a non-MFA lifecycle.on_start" do + m = Map.put(@valid, :lifecycle, %{on_start: :start}) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "lifecycle.on_start")) + end + + test "rejects a non-list lifecycle.supervised" do + m = Map.put(@valid, :lifecycle, %{supervised: P.Worker}) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "lifecycle.supervised must be a list")) + end + + test "accepts a valid settings schema with editor screen" do + settings = %{ + schema: [ + %{key: :sound, type: :boolean, default: true}, + %{key: :channel, type: :string, default: "#general"} + ], + editor_screen: P.SettingsScreen + } + + m = Map.put(@valid, :settings, settings) + assert {:ok, ^m} = Manifest.validate(m) + end + + test "rejects an unsupported setting type" do + settings = %{schema: [%{key: :x, type: :float, default: 1.0}]} + assert {:error, errs} = Manifest.validate(Map.put(@valid, :settings, settings)) + assert Enum.any?(errs, &(&1 =~ ":type must be one of")) + end + + test "rejects a setting entry without a default" do + settings = %{schema: [%{key: :x, type: :integer}]} + assert {:error, errs} = Manifest.validate(Map.put(@valid, :settings, settings)) + assert Enum.any?(errs, &(&1 =~ "requires a :default")) + end + + test "accepts notification handlers with map and predicate-MFA matches" do + notes = %{ + handlers: [ + %{match: %{type: "chat"}, handler: {P.Notif, :handle, 1}}, + %{match: {P.Notif, :matches?, 1}, handler: {P.Notif, :catch_all, 1}} + ] + } + + m = Map.put(@valid, :notifications, notes) + assert {:ok, ^m} = Manifest.validate(m) + end + + test "rejects a notification handler whose handler is an args-MFA not arity" do + notes = %{handlers: [%{match: %{type: "x"}, handler: {P, :h, []}}]} + assert {:error, errs} = Manifest.validate(Map.put(@valid, :notifications, notes)) + assert Enum.any?(errs, &(&1 =~ ":handler must be a {Module, :function, arity} tuple")) + end + + test "rejects a notification handler with a bad match" do + notes = %{handlers: [%{match: "chat", handler: {P, :h, 1}}]} + assert {:error, errs} = Manifest.validate(Map.put(@valid, :notifications, notes)) + + assert Enum.any?( + errs, + &(&1 =~ ":match must be a map or a {Module, :function, arity} predicate") + ) + end + end + + describe "host_requirements" do + test "accepts a list of non-empty strings (optional key)" do + m = Map.put(@valid, :host_requirements, ["add <service .../> to AndroidManifest"]) + assert {:ok, ^m} = Manifest.validate(m) + end + + test "rejects a non-list" do + assert {:error, errs} = Manifest.validate(Map.put(@valid, :host_requirements, "oops")) + assert Enum.any?(errs, &(&1 =~ "host_requirements must be a list")) + end + + test "rejects empty or non-string entries" do + assert {:error, errs} = Manifest.validate(Map.put(@valid, :host_requirements, ["ok", ""])) + assert Enum.any?(errs, &(&1 =~ "non-empty strings")) + + assert {:error, errs} = + Manifest.validate(Map.put(@valid, :host_requirements, [:not_a_string])) + + assert Enum.any?(errs, &(&1 =~ "non-empty strings")) + end + end + + describe "ui_components validation (native XOR expand)" do + test "a native-backed entry validates" do + m = + Map.put(@valid, :ui_components, [ + %{tag: "Sig", atom: :sig, ios: %{view_module: "Sig_View"}} + ]) + + assert {:ok, ^m} = Manifest.validate(m) + end + + test "an expand entry ({Module, :function}) validates" do + m = + Map.put(@valid, :ui_components, [ + %{tag: "MishkaCombobox", atom: :mishka_combobox, expand: {Mishka.Combobox, :expand}} + ]) + + assert {:ok, ^m} = Manifest.validate(m) + end + + test "neither native nor expand is an error" do + m = Map.put(@valid, :ui_components, [%{tag: "X", atom: :x}]) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "needs native backing")) + end + + test "mixing expand with native backing is an error" do + m = + Map.put(@valid, :ui_components, [ + %{tag: "X", atom: :x, expand: {M, :f}, ios: %{view_module: "V"}} + ]) + + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "pick one")) + end + + test "a malformed expand value is an error" do + m = Map.put(@valid, :ui_components, [%{tag: "X", atom: :x, expand: :nope}]) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ "must be {Module, :function}")) + end + + test "a tag-less entry is an error" do + m = Map.put(@valid, :ui_components, [%{atom: :x, expand: {M, :f}}]) + assert {:error, errs} = Manifest.validate(m) + assert Enum.any?(errs, &(&1 =~ ":tag string")) + end + end +end diff --git a/test/mob_dev/plugin/merge_fuzz_test.exs b/test/mob_dev/plugin/merge_fuzz_test.exs new file mode 100644 index 0000000..52b8a5c --- /dev/null +++ b/test/mob_dev/plugin/merge_fuzz_test.exs @@ -0,0 +1,178 @@ +defmodule MobDev.Plugin.MergeFuzzTest do + @moduledoc """ + Property-based fuzzing of the cross-plugin merge layer. Generates random sets + of plugin manifests (1-5 plugins, each populating a random subset of shared + fields from a small value pool so collisions actually occur) and asserts two + invariants hold for EVERY combination: + + 1. `cross_validate` reports a collision on a guarded resource **iff** that + resource genuinely has a value contributed by ≥2 distinct plugins (an + independent oracle). Catches both under-detection (a real clash slips + through) and over-detection (e.g. the cross-platform-NIF false positive, + where one plugin's iOS+Android entry shares a `:module`). + 2. No silent loss: when `cross_validate` finds no plist-key collision, + `Merge.plist_keys` (a `Map.merge`, the one consumer that silently + last-write-wins) preserves every key — proving the guard is *sufficient*, + not just present. + + Deterministic: `:rand` is seeded per iteration so a failure is reproducible + (the message prints the iteration + manifests). + """ + use ExUnit.Case, async: true + + alias MobDev.Plugin.{Merge, Validator} + + @iterations 400 + + # ── value pools (small, so collisions happen) ─────────────────────────────── + @nif_mods [:a_nif, :b_nif, :c_nif] + @routes ["/x", "/y", "/z"] + @swift ["Alpha.swift", "Beta.swift", "Gamma.swift"] + @jni ["thunk.c", "hook.c"] + @bridges ["io.p.Alpha", "io.p.Beta", "io.p.Gamma"] + @plist [ + "NSCameraUsageDescription", + "NSMicrophoneUsageDescription", + "NSPhotoLibraryUsageDescription" + ] + @workers [W.Alpha, W.Beta, W.Gamma] + @matches [%{type: "a"}, %{type: "b"}, %{type: "c"}] + @atoms [:chart, :gauge, :map] + @namespaces ["a_", "b_", "c_"] + + test "cross_validate flags exactly the cross-plugin collisions (fuzz)" do + for i <- 1..@iterations do + manifests = gen_set(i) + plugins = to_plugins(manifests) + %{errors: errors} = Validator.cross_validate(plugins) + + for {_gatherer, {:collision, checks}} <- Validator.conflict_surface(), + {label, extractor} <- checks do + expected = cross_plugin_dup?(manifests, extractor) + # Anchored to the exact collision-message shape so one guard's label + # can't substring-match inside another guard's error text. + actual = Enum.any?(errors, &(&1 =~ ~r/declare the same #{Regex.escape(label)}: /)) + + assert expected == actual, + "iter #{i}: resource #{inspect(label)} expected collision=#{expected} " <> + "but cross_validate reported=#{actual}\nerrors: #{inspect(errors)}\n" <> + "manifests: #{inspect(manifests, pretty: true)}" + end + end + end + + test "no silent loss: clean plist merge keeps every key (fuzz)" do + for i <- 1..@iterations do + manifests = gen_set(i + 10_000) + plugins = to_plugins(manifests) + %{errors: errors} = Validator.cross_validate(plugins) + plist_collision? = Enum.any?(errors, &(&1 =~ "Info.plist key")) + + merged = Merge.plist_keys(plugins) + + distinct_keys = + manifests + |> Enum.flat_map(fn m -> (get_in(m, [:ios, :plist_keys]) || %{}) |> Map.keys() end) + |> Enum.uniq() + + unless plist_collision? do + assert map_size(merged) == length(distinct_keys), + "iter #{i}: plist merge lost a key with NO collision flagged — " <> + "merged #{map_size(merged)} vs #{length(distinct_keys)} distinct\n" <> + "manifests: #{inspect(manifests, pretty: true)}" + end + end + end + + # ── independent oracle ────────────────────────────────────────────────────── + # A value is a cross-plugin collision when, after de-duping WITHIN each plugin + # (so a cross-platform NIF declaring one :module twice counts once), it appears + # in ≥2 plugins. Re-derived here without cross_validate's collisions/3 helper. + defp cross_plugin_dup?(manifests, extractor) do + manifests + |> Enum.flat_map(fn m -> m |> extractor.() |> Enum.uniq() end) + |> Enum.frequencies() + |> Enum.any?(fn {_v, count} -> count > 1 end) + end + + # ── deterministic generators ──────────────────────────────────────────────── + defp gen_set(seed) do + :rand.seed(:exsss, {seed, seed * 2 + 1, seed * 3 + 7}) + n = :rand.uniform(5) + for j <- 1..n, do: gen_manifest(:"p#{j}") + end + + defp to_plugins(manifests) do + manifests |> Enum.with_index() |> Enum.map(fn {m, k} -> {"/p#{k}", m} end) + end + + defp gen_manifest(name) do + %{name: name, mob_version: "~> 0.6", plugin_spec_version: 2} + |> maybe(0.6, &put_nifs/1) + |> maybe(0.4, &put_swift/1) + |> maybe(0.4, &put_jni/1) + |> maybe(0.4, &put_bridge/1) + |> maybe(0.4, &put_plist/1) + |> maybe(0.4, &put_lifecycle/1) + |> maybe(0.4, &put_notifications/1) + |> maybe(0.4, &put_screens/1) + |> maybe(0.3, &put_ui/1) + |> maybe(0.3, &put_migrations/1) + end + + defp maybe(m, p, f), do: if(:rand.uniform() < p, do: f.(m), else: m) + defp some(pool), do: Enum.take_random(pool, :rand.uniform(2)) + + defp put_nifs(m) do + entries = for mod <- some(@nif_mods), do: %{module: mod, native_dir: "priv/jni"} + + # 30%: inject the legit cross-platform pattern — same :module a second time + # under a different platform (must NOT be flagged as a collision). + entries = + if entries != [] and :rand.uniform() < 0.3 do + dup = %{module: hd(entries).module, native_dir: "priv/ios", lang: :objc, platform: :ios} + [dup | entries] + else + entries + end + + Map.put(m, :nifs, entries) + end + + defp put_swift(m), do: deep_put(m, [:ios, :swift_files], Enum.map(some(@swift), &"priv/#{&1}")) + defp put_jni(m), do: deep_put(m, [:android, :jni_source], "priv/#{Enum.random(@jni)}") + defp put_bridge(m), do: deep_put(m, [:android, :bridge_class], Enum.random(@bridges)) + + defp put_plist(m) do + keys = some(@plist) |> Map.new(fn k -> {k, "why #{m.name}"} end) + deep_put(m, [:ios, :plist_keys], keys) + end + + defp put_lifecycle(m), do: Map.put(m, :lifecycle, %{supervised: some(@workers)}) + + defp put_notifications(m) do + handlers = for mt <- some(@matches), do: %{match: mt, handler: {H, :h, 1}} + Map.put(m, :notifications, %{handlers: handlers}) + end + + defp put_screens(m) do + screens = for r <- some(@routes), do: %{module: Screen, default_route: r} + Map.put(m, :screens, screens) + end + + defp put_ui(m) do + comps = for a <- some(@atoms), do: %{atom: a} + Map.put(m, :ui_components, comps) + end + + defp put_migrations(m), + do: + Map.put(m, :migrations, %{ + repo_namespace: Enum.random(@namespaces), + migrations_dir: "priv/m" + }) + + defp deep_put(m, [k1, k2], value) do + Map.update(m, k1, %{k2 => value}, &Map.put(&1, k2, value)) + end +end diff --git a/test/mob_dev/plugin/merge_test.exs b/test/mob_dev/plugin/merge_test.exs new file mode 100644 index 0000000..4e04ec7 --- /dev/null +++ b/test/mob_dev/plugin/merge_test.exs @@ -0,0 +1,614 @@ +defmodule MobDev.Plugin.MergeTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.Merge + + defp base(extra), + do: Map.merge(%{name: :p, mob_version: "~> 0.6", plugin_spec_version: 1}, extra) + + describe "nifs/1" do + test "resolves native_dir to an absolute path under the plugin dir" do + plugins = [{"/abs/plug", base(%{nifs: [%{module: P.Nif, native_dir: "priv/native/jni"}]})}] + assert [%{module: P.Nif, native_dir: "/abs/plug/priv/native/jni"}] = Merge.nifs(plugins) + end + + test "combines nifs across plugins and ignores tier-0 (nil) manifests" do + plugins = [ + {"/a", base(%{nifs: [%{module: A, native_dir: "priv/jni"}]})}, + {"/palette", nil}, + {"/b", base(%{nifs: [%{module: B, native_dir: "priv/jni"}]})} + ] + + assert [%{module: A}, %{module: B}] = Merge.nifs(plugins) + end + + test "leaves a nif without native_dir untouched" do + plugins = [{"/a", base(%{nifs: [%{module: A}]})}] + assert [%{module: A}] = Merge.nifs(plugins) + end + end + + describe "android_permissions/1 + gradle_deps/1 + ios_frameworks/1" do + test "uniquifies across plugins" do + plugins = [ + {"/a", + base(%{ + android: %{permissions: ["P.CAMERA"], gradle_deps: ["g:1"]}, + ios: %{frameworks: ["CoreHaptics"]} + })}, + {"/b", + base(%{ + android: %{permissions: ["P.CAMERA", "P.MIC"], gradle_deps: ["g:1", "g:2"]}, + ios: %{frameworks: ["CoreHaptics", "AVFoundation"]} + })} + ] + + assert Merge.android_permissions(plugins) == ["P.CAMERA", "P.MIC"] + assert Merge.gradle_deps(plugins) == ["g:1", "g:2"] + assert Merge.ios_frameworks(plugins) == ["CoreHaptics", "AVFoundation"] + end + + test "empty when no plugins declare them" do + assert Merge.android_permissions([{"/a", base(%{})}]) == [] + end + end + + describe "swift_files/1 + android_sources/1" do + test "swift_files are absolute, per plugin" do + plugins = [ + {"/a", base(%{ios: %{swift_files: ["priv/ios/A.swift"]}})}, + {"/b", base(%{ios: %{swift_files: ["priv/ios/B.swift", "priv/ios/C.swift"]}})} + ] + + assert Merge.swift_files(plugins) == + ["/a/priv/ios/A.swift", "/b/priv/ios/B.swift", "/b/priv/ios/C.swift"] + end + + test "android_sources gathers bridge, jni, and nif dirs, absolute + unique" do + plugins = [ + {"/a", + base(%{ + android: %{bridge_kt: "priv/a/Bridge.kt", jni_source: "priv/a/x.c"}, + nifs: [%{module: A, native_dir: "priv/a/jni"}] + })} + ] + + sources = Merge.android_sources(plugins) + assert "/a/priv/a/Bridge.kt" in sources + assert "/a/priv/a/x.c" in sources + assert "/a/priv/a/jni" in sources + end + end + + describe "nif_sources/1" do + test "computes <dir>/<native_dir>/<module>.c for each NIF" do + plugins = [ + {"/a", + base(%{ + nifs: [ + %{module: :foo_nif, native_dir: "priv/native/jni"}, + %{module: :bar_nif, native_dir: "priv/native/jni"} + ] + })} + ] + + assert Merge.nif_sources(plugins) == + ["/a/priv/native/jni/foo_nif.c", "/a/priv/native/jni/bar_nif.c"] + end + + test "defaults native_dir to priv/native/jni when omitted" do + plugins = [{"/a", base(%{nifs: [%{module: :foo_nif}]})}] + assert Merge.nif_sources(plugins) == ["/a/priv/native/jni/foo_nif.c"] + end + + test "ignores tier-0 (nil) manifests and entries without a :module atom" do + plugins = [ + {"/a", nil}, + {"/b", base(%{nifs: [%{native_dir: "priv/jni"}]})} + ] + + assert Merge.nif_sources(plugins) == [] + end + + test "treats lang: :c the same as an absent :lang" do + plugins = [{"/a", base(%{nifs: [%{module: :foo_nif, lang: :c}]})}] + assert Merge.nif_sources(plugins) == ["/a/priv/native/jni/foo_nif.c"] + end + + test "excludes lang: :zig NIFs (they belong to zig_nif_sources/1)" do + plugins = [{"/a", base(%{nifs: [%{module: :foo_nif, lang: :zig}]})}] + assert Merge.nif_sources(plugins) == [] + end + + test "excludes lang: :cpp_archive NIFs (they belong to static_archives/2)" do + plugins = [ + {"/a", + base(%{nifs: [%{module: :x, lang: :cpp_archive, sources: ["a.cpp"], nm_symbol: "x"}]})} + ] + + assert Merge.nif_sources(plugins) == [] + assert Merge.zig_nif_sources(plugins) == [] + end + end + + describe "static_archives/2" do + test "resolves sources to absolute and tags the plugin" do + plugins = [ + {"/abs/plug", + base(%{ + nifs: [ + %{ + module: :nx_eigen_nif, + lang: :cpp_archive, + sources: ["c_src/nx_eigen_nif.cpp", "c_src/fft.cpp"], + nm_symbol: "nx_eigen_nif_init" + } + ] + })} + ] + + assert [spec] = Merge.static_archives(plugins) + assert spec.module == :nx_eigen_nif + assert spec.sources == ["/abs/plug/c_src/nx_eigen_nif.cpp", "/abs/plug/c_src/fft.cpp"] + assert spec.nm_symbol == "nx_eigen_nif_init" + assert spec.plugin == :p + end + + test "resolves dep-sourced .cpp (NxEigen's NIF lives in the nx_eigen dep)" do + plugins = [ + {"/plug", + base(%{ + nifs: [ + %{ + module: :nx_eigen_nif, + lang: :cpp_archive, + sources: [ + {:dep, :nx_eigen, "c_src/nx_eigen_nif.cpp"}, + "c_src/nx_eigen_fft_eigen.cpp" + ], + nm_symbol: "nx_eigen_nif_init" + } + ] + })} + ] + + assert [spec] = Merge.static_archives(plugins) + # dep token passes through; plugin-relative source resolves to absolute. + assert spec.sources == [ + {:dep, :nx_eigen, "c_src/nx_eigen_nif.cpp"}, + "/plug/c_src/nx_eigen_fft_eigen.cpp" + ] + end + + test "resolves plugin-relative includes to absolute, passes :dep tokens through" do + plugins = [ + {"/plug", + base(%{ + nifs: [ + %{ + module: :x, + lang: :cpp_archive, + sources: ["a.cpp"], + includes: ["c_src", {:dep, :nx_eigen, "eigen-3.4.0"}, {:dep, :fine, "c_include"}], + nm_symbol: "x_init" + } + ] + })} + ] + + assert [spec] = Merge.static_archives(plugins) + + assert spec.includes == [ + "/plug/c_src", + {:dep, :nx_eigen, "eigen-3.4.0"}, + {:dep, :fine, "c_include"} + ] + end + + test "ignores non-cpp_archive NIFs and tier-0 manifests" do + plugins = [ + {"/a", base(%{nifs: [%{module: :c_nif}, %{module: :z, lang: :zig}]})}, + {"/b", nil} + ] + + assert Merge.static_archives(plugins) == [] + end + + test "filters by platform (entry tagged :ios excluded from android build)" do + plugins = [ + {"/a", + base(%{ + nifs: [ + %{ + module: :ios_only, + lang: :cpp_archive, + sources: ["a.cpp"], + nm_symbol: "a", + platform: :ios + }, + %{module: :both, lang: :cpp_archive, sources: ["b.cpp"], nm_symbol: "b"} + ] + })} + ] + + assert Enum.map(Merge.static_archives(plugins, :android), & &1.module) == [:both] + assert Enum.map(Merge.static_archives(plugins, :ios), & &1.module) == [:ios_only, :both] + assert length(Merge.static_archives(plugins, :all)) == 2 + end + + test "carries base + per-platform cxxflags through" do + plugins = [ + {"/a", + base(%{ + nifs: [ + %{ + module: :x, + lang: :cpp_archive, + sources: ["a.cpp"], + nm_symbol: "x", + cxxflags: ["-std=c++17"], + cxxflags_android: ["-mbranch-protection=standard"], + cxxflags_ios: [] + } + ] + })} + ] + + assert [spec] = Merge.static_archives(plugins) + assert spec.cxxflags == ["-std=c++17"] + assert spec.cxxflags_android == ["-mbranch-protection=standard"] + assert spec.cxxflags_ios == [] + end + end + + describe "zig_nif_sources/1" do + test "computes <dir>/<native_dir>/<module>.zig for lang: :zig NIFs" do + plugins = [ + {"/a", + base(%{ + nifs: [%{module: :mob_bluetooth_nif, native_dir: "priv/native/jni", lang: :zig}] + })} + ] + + assert Merge.zig_nif_sources(plugins) == ["/a/priv/native/jni/mob_bluetooth_nif.zig"] + end + + test "defaults native_dir to priv/native/jni when omitted" do + plugins = [{"/a", base(%{nifs: [%{module: :foo_nif, lang: :zig}]})}] + assert Merge.zig_nif_sources(plugins) == ["/a/priv/native/jni/foo_nif.zig"] + end + + test "excludes C NIFs (absent :lang or lang: :c)" do + plugins = [ + {"/a", base(%{nifs: [%{module: :c_default_nif}, %{module: :c_explicit_nif, lang: :c}]})} + ] + + assert Merge.zig_nif_sources(plugins) == [] + end + + test "a mixed manifest splits cleanly between the C and zig source lists" do + plugins = [ + {"/a", + base(%{ + nifs: [ + %{module: :c_nif}, + %{module: :z_nif, lang: :zig} + ] + })} + ] + + assert Merge.nif_sources(plugins) == ["/a/priv/native/jni/c_nif.c"] + assert Merge.zig_nif_sources(plugins) == ["/a/priv/native/jni/z_nif.zig"] + end + end + + describe "per-platform NIF source filtering" do + # A cross-platform plugin ships a separate iOS + Android source for the same + # module; each platform compiles only its own (the other may reference + # platform-only symbols). No :platform = compiled everywhere. + setup do + plugins = [ + {"/loc", + base(%{ + nifs: [ + %{module: :mob_location_nif, native_dir: "priv/native/ios", platform: :ios}, + %{ + module: :mob_location_nif, + native_dir: "priv/native/jni", + lang: :zig, + platform: :android + }, + %{module: :shared_nif} + ] + })} + ] + + %{plugins: plugins} + end + + test "iOS C sources include ios-tagged + untagged, exclude android-tagged", %{plugins: p} do + assert Merge.nif_sources(p, :ios) == [ + "/loc/priv/native/ios/mob_location_nif.c", + "/loc/priv/native/jni/shared_nif.c" + ] + end + + test "Android C sources exclude ios-tagged (the android one is zig)", %{plugins: p} do + assert Merge.nif_sources(p, :android) == ["/loc/priv/native/jni/shared_nif.c"] + end + + test "Android zig sources include android-tagged zig only", %{plugins: p} do + assert Merge.zig_nif_sources(p, :android) == ["/loc/priv/native/jni/mob_location_nif.zig"] + end + + test "iOS zig sources exclude android-tagged", %{plugins: p} do + assert Merge.zig_nif_sources(p, :ios) == [] + end + + test ":all (arity-1) keeps every entry", %{plugins: p} do + assert length(Merge.nif_sources(p)) == 2 + assert length(Merge.zig_nif_sources(p)) == 1 + end + + test "lang: :objc routes through the C path with a .m extension" do + plugins = [ + {"/loc", + base(%{ + nifs: [ + %{ + module: :mob_location_nif, + native_dir: "priv/native/ios", + lang: :objc, + platform: :ios + } + ] + })} + ] + + assert Merge.nif_sources(plugins, :ios) == ["/loc/priv/native/ios/mob_location_nif.m"] + # objc is C-family, not a zig source. + assert Merge.zig_nif_sources(plugins, :ios) == [] + end + + test "lang: :objc WITHOUT an explicit platform is still excluded from the Android build" do + # objc is implicitly Apple-only — it must never reach the Android build args + # even when the author omits `platform: :ios`. + plugins = [ + {"/p", base(%{nifs: [%{module: :perm_nif, native_dir: "priv/ios", lang: :objc}]})} + ] + + assert Merge.nif_sources(plugins, :android) == [] + assert Merge.nif_sources(plugins, :ios) == ["/p/priv/ios/perm_nif.m"] + # :all (listing, not building) keeps it. + assert Merge.nif_sources(plugins) == ["/p/priv/ios/perm_nif.m"] + end + end + + describe "jni_sources/1 + bridge_kt_sources/1 + bridge_classes/1" do + test "jni_sources resolves android.jni_source to absolute paths" do + plugins = [ + {"/a", base(%{android: %{jni_source: "priv/native/jni/mob_bluetooth_jni.c"}})}, + {"/b", base(%{android: %{}})} + ] + + assert Merge.jni_sources(plugins) == ["/a/priv/native/jni/mob_bluetooth_jni.c"] + end + + test "bridge_kt_sources resolves android.bridge_kt to absolute paths" do + plugins = [ + {"/a", base(%{android: %{bridge_kt: "priv/native/android/MobBluetoothBridge.kt"}})} + ] + + assert Merge.bridge_kt_sources(plugins) == + ["/a/priv/native/android/MobBluetoothBridge.kt"] + end + + test "bridge_classes collects the FQNs to register at startup" do + plugins = [ + {"/a", base(%{android: %{bridge_class: "io.mob.bluetooth.MobBluetoothBridge"}})}, + {"/b", base(%{android: %{}})}, + {"/c", nil} + ] + + assert Merge.bridge_classes(plugins) == ["io.mob.bluetooth.MobBluetoothBridge"] + end + + test "all three ignore tier-0 (nil) manifests and absent android maps" do + plugins = [{"/a", nil}, {"/b", base(%{})}] + assert Merge.jni_sources(plugins) == [] + assert Merge.bridge_kt_sources(plugins) == [] + assert Merge.bridge_classes(plugins) == [] + end + end + + describe "plist_keys/1 + ui_components/1" do + test "plist_keys merge across plugins" do + plugins = [ + {"/a", base(%{ios: %{plist_keys: %{"K1" => "a"}}})}, + {"/b", base(%{ios: %{plist_keys: %{"K2" => "b"}}})} + ] + + assert Merge.plist_keys(plugins) == %{"K1" => "a", "K2" => "b"} + end + + test "ui_components combine across plugins" do + plugins = [ + {"/a", base(%{ui_components: [%{atom: :chart}]})}, + {"/b", base(%{ui_components: [%{atom: :gauge}]})} + ] + + assert [%{atom: :chart}, %{atom: :gauge}] = Merge.ui_components(plugins) + end + end + + describe "tier-3/4 runtime-manifest gatherers" do + test "screens/1 tags each entry with its plugin name" do + plugins = [ + {"/a", base(%{name: :a, screens: [%{module: A.List, default_route: "/a"}]})}, + {"/pal", nil}, + {"/b", base(%{name: :b, screens: [%{module: B.List, default_route: "/b"}]})} + ] + + assert [ + %{module: A.List, default_route: "/a", plugin: :a}, + %{module: B.List, default_route: "/b", plugin: :b} + ] = Merge.screens(plugins) + end + + test "migrations/1 resolves migrations_dir to absolute + carries namespace" do + plugins = [ + {"/abs/p", + base(%{ + name: :p, + migrations: %{repo_namespace: "p_", migrations_dir: "priv/repo/migrations"} + })} + ] + + assert [%{plugin: :p, repo_namespace: "p_", migrations_dir: "/abs/p/priv/repo/migrations"}] = + Merge.migrations(plugins) + end + + test "assets/1 resolves font/image paths to absolute, defaulting missing lists to []" do + plugins = [ + {"/abs/p", base(%{name: :p, assets: %{fonts: ["priv/a.ttf"]}})} + ] + + assert [%{plugin: :p, fonts: ["/abs/p/priv/a.ttf"], images: []}] = Merge.assets(plugins) + end + + test "migrations/1 skips a :migrations map missing a string :migrations_dir (no crash)" do + # `activated/0` feeds Merge unvalidated manifests; a malformed :migrations + # (no :migrations_dir) must not crash with an opaque Path.join error. + plugins = [{"/abs/p", base(%{name: :p, migrations: %{repo_namespace: "p_"}})}] + assert Merge.migrations(plugins) == [] + + plugins = [ + {"/abs/p", base(%{name: :p, migrations: %{repo_namespace: "p_", migrations_dir: 123}})} + ] + + assert Merge.migrations(plugins) == [] + end + + test "assets/1 treats a non-list :fonts/:images as empty (no crash)" do + plugins = [{"/abs/p", base(%{name: :p, assets: %{fonts: "single.ttf", images: 0}})}] + assert [%{plugin: :p, fonts: [], images: []}] = Merge.assets(plugins) + end + + test "lifecycle/1 and settings/1 tag with plugin name" do + lc = %{on_start: {P, :start, []}, supervised: [P.Worker]} + settings = %{schema: [%{key: :x, type: :boolean, default: true}], editor_screen: P.Edit} + plugins = [{"/p", base(%{name: :p, lifecycle: lc, settings: settings})}] + + assert [%{plugin: :p, on_start: {P, :start, []}}] = Merge.lifecycle(plugins) + + assert [%{plugin: :p, schema: [%{key: :x}], editor_screen: P.Edit}] = + Merge.settings(plugins) + end + + test "notification_handlers/1 flattens in plugin-then-declaration order" do + plugins = [ + {"/a", + base(%{ + name: :a, + notifications: %{ + handlers: [ + %{match: %{type: "x"}, handler: {A, :hx, 1}}, + %{match: %{type: "y"}, handler: {A, :hy, 1}} + ] + } + })}, + {"/b", + base(%{ + name: :b, + notifications: %{handlers: [%{match: %{type: "z"}, handler: {B, :hz, 1}}]} + })} + ] + + assert [ + %{plugin: :a, handler: {A, :hx, 1}}, + %{plugin: :a, handler: {A, :hy, 1}}, + %{plugin: :b, handler: {B, :hz, 1}} + ] = Merge.notification_handlers(plugins) + end + + test "host_requirements/1 tags each obligation with its plugin, skipping malformed" do + plugins = [ + {"/a", base(%{name: :a, host_requirements: ["add the <service> fragment", :oops]})}, + {"/b", base(%{name: :b})}, + {"/c", base(%{name: :c, host_requirements: ["declare a FileProvider"]})} + ] + + assert [ + %{plugin: :a, requirement: "add the <service> fragment"}, + %{plugin: :c, requirement: "declare a FileProvider"} + ] = Merge.host_requirements(plugins) + end + end + + describe "android_manifest_snippets/1" do + test "collects <application> snippets across plugins, tagged with the plugin" do + plugins = [ + {"/a", base(%{name: :a, android: %{manifest_application_snippets: ["<service a/>"]}})}, + {"/z", nil}, + {"/b", + base(%{ + name: :b, + android: %{manifest_application_snippets: ["<receiver b/>", "<provider b/>"]} + })} + ] + + assert [ + %{plugin: :a, snippet: "<service a/>"}, + %{plugin: :b, snippet: "<receiver b/>"}, + %{plugin: :b, snippet: "<provider b/>"} + ] = Merge.android_manifest_snippets(plugins) + end + + test "absent / malformed snippets contribute nothing" do + plugins = [ + {"/a", base(%{name: :a})}, + {"/b", base(%{name: :b, android: %{manifest_application_snippets: [:oops]}})} + ] + + assert Merge.android_manifest_snippets(plugins) == [] + end + end + + describe "android_res_files/1" do + test "resolves src absolutely and derives the res/<type>/<file> dest" do + plugins = [ + {"/plug", + base(%{ + name: :plug, + android: %{res_files: ["priv/native/android/res/xml/foo_apduservice.xml"]} + })} + ] + + assert [ + %{ + plugin: :plug, + src: "/plug/priv/native/android/res/xml/foo_apduservice.xml", + dest: "res/xml/foo_apduservice.xml" + } + ] = Merge.android_res_files(plugins) + end + + test "uses the LAST res segment when the path has more than one" do + plugins = [{"/p", base(%{android: %{res_files: ["res/drawable/res/icon.xml"]}})}] + assert [%{dest: "res/icon.xml"}] = Merge.android_res_files(plugins) + end + + test "combines across plugins and ignores tier-0 manifests" do + plugins = [ + {"/a", base(%{name: :a, android: %{res_files: ["x/res/values/strings.xml"]}})}, + {"/z", nil}, + {"/b", base(%{name: :b, android: %{res_files: ["y/res/xml/svc.xml"]}})} + ] + + assert [ + %{plugin: :a, dest: "res/values/strings.xml"}, + %{plugin: :b, dest: "res/xml/svc.xml"} + ] = Merge.android_res_files(plugins) + end + end +end diff --git a/test/mob_dev/plugin/private_key_store_test.exs b/test/mob_dev/plugin/private_key_store_test.exs new file mode 100644 index 0000000..14a2287 --- /dev/null +++ b/test/mob_dev/plugin/private_key_store_test.exs @@ -0,0 +1,80 @@ +defmodule MobDev.Plugin.PrivateKeyStoreTest do + use ExUnit.Case, async: false + + alias MobDev.Plugin.{Crypto, PrivateKeyStore} + + setup do + # PrivateKeyStore reads the home dir via the :mob_dev / :plugin_key_home + # Application env (HOME env-var is cached by Erlang at boot and can't + # be overridden mid-process). Point it at a tmpdir for the test so we + # don't touch the developer's real key store. async: false because + # we mutate process-wide env. + tmp_home = + Path.join(System.tmp_dir!(), "mob_keystore_test_#{System.unique_integer([:positive])}") + + File.mkdir_p!(tmp_home) + previous = Application.get_env(:mob_dev, :plugin_key_home) + Application.put_env(:mob_dev, :plugin_key_home, tmp_home) + + on_exit(fn -> + if previous, + do: Application.put_env(:mob_dev, :plugin_key_home, previous), + else: Application.delete_env(:mob_dev, :plugin_key_home) + + File.rm_rf!(tmp_home) + end) + + {:ok, home: tmp_home} + end + + describe "key_path/1" do + test "lives under ~/.mob/keys/", %{home: home} do + path = PrivateKeyStore.key_path(:mob_foo) + assert path == Path.join([home, ".mob/keys", "mob_foo.priv"]) + end + end + + describe "write_key/2 + read_key/1 round-trip" do + test "writes a base64-encoded key and reads it back", %{home: _home} do + {priv, _pub} = Crypto.generate_keypair() + :ok = PrivateKeyStore.write_key(:mob_foo, priv) + assert {:ok, ^priv} = PrivateKeyStore.read_key(:mob_foo) + end + + test "writes the file with mode 0600", %{home: _home} do + {priv, _pub} = Crypto.generate_keypair() + :ok = PrivateKeyStore.write_key(:mob_foo, priv) + + %File.Stat{mode: mode} = File.stat!(PrivateKeyStore.key_path(:mob_foo)) + # Lower 9 bits are the rwx mask; 0o600 = owner rw, no group/other. + assert Bitwise.band(mode, 0o777) == 0o600 + end + + test "creates the keys directory if missing", %{home: home} do + File.rm_rf!(Path.join(home, ".mob")) + {priv, _pub} = Crypto.generate_keypair() + :ok = PrivateKeyStore.write_key(:mob_bar, priv) + assert File.dir?(Path.join([home, ".mob/keys"])) + end + end + + describe "read_key/1 error cases" do + test "returns :missing when no key file exists" do + assert {:error, :missing} = PrivateKeyStore.read_key(:nonexistent) + end + + test "returns :malformed when the file isn't base64" do + path = PrivateKeyStore.key_path(:mob_bad) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, "not base64!!!\n") + assert {:error, :malformed} = PrivateKeyStore.read_key(:mob_bad) + end + + test "returns :malformed when the decoded key is the wrong size" do + path = PrivateKeyStore.key_path(:mob_short) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, Base.encode64(<<1, 2, 3>>) <> "\n") + assert {:error, :malformed} = PrivateKeyStore.read_key(:mob_short) + end + end +end diff --git a/test/mob_dev/plugin/report_test.exs b/test/mob_dev/plugin/report_test.exs new file mode 100644 index 0000000..15843d7 --- /dev/null +++ b/test/mob_dev/plugin/report_test.exs @@ -0,0 +1,167 @@ +defmodule MobDev.Plugin.ReportTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.Report + + @nif %{name: :mob_haptic, mob_version: "~> 0.6", plugin_spec_version: 1, nifs: []} + @screens %{name: :mob_chat, mob_version: "~> 0.6", plugin_spec_version: 1, screens: []} + + describe "rows/2" do + test "includes a tier-0 (no-manifest) dep only when activated" do + deps = [{:mob_palette_demo, nil}, {:jason, nil}] + + # not activated → neither shows (both look like ordinary libs) + assert Report.rows(deps, []) == [] + + # activated → the tier-0 plugin appears, jason still doesn't + assert [row] = Report.rows(deps, [:mob_palette_demo]) + assert row.name == :mob_palette_demo + assert row.tier == 0 + assert row.hot_pushable == true + assert row.status == :activated + refute row.manifest? + end + + test "includes a manifested dep even when not activated, marked :installed" do + assert [row] = Report.rows([{:mob_haptic, @nif}], []) + assert row.status == :installed + assert row.tier == 1 + assert row.hot_pushable == false + assert row.manifest? + end + + test "marks a manifested dep :activated when in the list" do + assert [row] = Report.rows([{:mob_haptic, @nif}], [:mob_haptic]) + assert row.status == :activated + end + + test "carries tier + hot_pushable from the manifest" do + assert [row] = Report.rows([{:mob_chat, @screens}], [:mob_chat]) + assert row.tier == 3 + assert row.hot_pushable == true + end + + test "sorts rows by name" do + deps = [{:mob_chat, @screens}, {:mob_haptic, @nif}] + assert [%{name: :mob_chat}, %{name: :mob_haptic}] = Report.rows(deps, []) + end + + test "surfaces the manifest description" do + m = Map.put(@nif, :description, "Haptics") + assert [%{description: "Haptics"}] = Report.rows([{:mob_haptic, m}], []) + end + end + + describe "render/1" do + test "renders a friendly message when there are no plugins" do + assert Report.render([]) =~ "No mob plugins found" + end + + test "renders a table with the plugin, tier, and status" do + out = Report.rows([{:mob_haptic, @nif}], [:mob_haptic]) |> Report.render() + assert out =~ "mob_haptic" + assert out =~ "tier 1" + assert out =~ "activated" + end + + test "flags a tier-0 activated plugin as a no-manifest regular dep" do + out = Report.rows([{:mob_palette_demo, nil}], [:mob_palette_demo]) |> Report.render() + assert out =~ "no manifest (regular dep)" + end + + test "explains the installed-but-not-activated state when present" do + out = Report.rows([{:mob_haptic, @nif}], []) |> Report.render() + assert out =~ "not activated" + end + + test "omits the VETTING column when no row carries vetting" do + out = Report.rows([{:mob_haptic, @nif}], [:mob_haptic]) |> Report.render() + refute out =~ "VETTING" + end + + test "adds the VETTING column and renders 'clean' for a spotless row" do + rows = [ + %{ + name: :mob_palette_demo, + tier: 0, + hot_pushable: true, + status: :activated, + manifest?: true, + description: nil, + vetting: %{ + audit: %{high: 0, medium: 0, low: 0}, + capability_errors: 0 + } + } + ] + + out = Report.render(rows) + assert out =~ "VETTING" + assert out =~ "clean" + end + + test "renders capability error count when present" do + rows = [ + %{ + name: :leaky_plugin, + tier: 2, + hot_pushable: false, + status: :activated, + manifest?: true, + description: nil, + vetting: %{ + audit: %{high: 0, medium: 0, low: 0}, + capability_errors: 2 + } + } + ] + + assert Report.render(rows) =~ "caps:2" + end + + test "renders compact audit summary, omitting zero categories" do + rows = [ + %{ + name: :risky_plugin, + tier: 1, + hot_pushable: false, + status: :activated, + manifest?: true, + description: nil, + vetting: %{ + audit: %{high: 1, medium: 2, low: 0}, + capability_errors: 0 + } + } + ] + + out = Report.render(rows) + assert out =~ "1H" + assert out =~ "2M" + refute out =~ "0L" + end + end + + describe "capability_errors_for/1" do + # Regression: the call site passed (dir, manifest) but the Validator expects + # (manifest, dir) — a string first arg matches no clause and raises + # FunctionClauseError, crashing `mix mob.plugins` on any manifest-bearing + # activated plugin (e.g. tier-1 mob_bluetooth). + test "passes (manifest, dir) to the validators — bt-shaped manifest yields 0, no crash" do + manifest = %{ + name: :mob_bluetooth, + mob_version: "~> 0.6", + plugin_spec_version: 1, + android: %{permissions: ["android.permission.BLUETOOTH_CONNECT"]}, + ios: %{plist_keys: %{NSBluetoothAlwaysUsageDescription: "x"}} + } + + assert Report.capability_errors_for([{"/tmp/mob_no_such_dir", manifest, :mob_bluetooth}]) == + %{mob_bluetooth: 0} + end + + test "tolerates a nil manifest (tier-0)" do + assert Report.capability_errors_for([{"/tmp/x", nil, :palette}]) == %{palette: 0} + end + end +end diff --git a/test/mob_dev/plugin/runtime_manifest_test.exs b/test/mob_dev/plugin/runtime_manifest_test.exs new file mode 100644 index 0000000..8846ecb --- /dev/null +++ b/test/mob_dev/plugin/runtime_manifest_test.exs @@ -0,0 +1,329 @@ +defmodule MobDev.Plugin.RuntimeManifestTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.RuntimeManifest + + # A stand-in spec-v2 screens generator. Reads a host-config key (auditable) and + # emits one screen per configured entry. + defmodule FakeGen do + def generate do + for name <- MobDev.Plugin.host_config(:demo_app, :sections, [:a, :b]) do + %{module: Module.concat([Gen, :"#{name}"]), default_route: "/gen/#{name}"} + end + end + + def reads_undeclared do + MobDev.Plugin.host_config(:demo_app, :secret_key, nil) + [] + end + + # Emits a screen missing :default_route (e.g. a typo'd key) — must be + # rejected at build time, not silently dropped on device. + def malformed do + [%{module: Gen.Ok, default_route: "/ok"}, %{module: Gen.Bad}] + end + + # Emits a non-map (wrong shape) among otherwise-valid specs. + def non_map do + [%{module: Gen.Ok, default_route: "/ok"}, "not a screen"] + end + end + + defp base(extra), + do: Map.merge(%{name: :p, mob_version: "~> 0.6", plugin_spec_version: 1}, extra) + + describe "build/1" do + test "combines static and generated screens, each tagged with its plugin" do + plugins = [ + {"/a", base(%{name: :a, screens: [%{module: A.Home, default_route: "/a"}]})}, + {"/g", + base(%{ + name: :g, + plugin_spec_version: 2, + screens_generator: {FakeGen, :generate, []}, + host_config_keys: [:sections] + })} + ] + + manifest = RuntimeManifest.build(plugins) + + assert Enum.any?(manifest.screens, &match?(%{module: A.Home, plugin: :a}, &1)) + routes = Enum.map(manifest.screens, & &1.default_route) + assert "/gen/a" in routes + assert "/gen/b" in routes + end + + test "a generator reading an undeclared host-config key fails the build" do + plugins = [ + {"/g", + base(%{ + name: :g, + plugin_spec_version: 2, + screens_generator: {FakeGen, :reads_undeclared, []}, + host_config_keys: [] + })} + ] + + assert_raise ArgumentError, ~r/not declared in its manifest :host_config_keys/, fn -> + RuntimeManifest.build(plugins) + end + end + + test "a generator emitting a screen missing :default_route fails the build" do + plugins = [ + {"/g", + base(%{ + name: :g, + plugin_spec_version: 2, + screens_generator: {FakeGen, :malformed, []}, + host_config_keys: [] + })} + ] + + assert_raise ArgumentError, ~r/produced an invalid screen at index 1/, fn -> + RuntimeManifest.build(plugins) + end + end + + test "a generator emitting a non-map screen spec fails the build" do + plugins = [ + {"/g", + base(%{ + name: :g, + plugin_spec_version: 2, + screens_generator: {FakeGen, :non_map, []}, + host_config_keys: [] + })} + ] + + assert_raise ArgumentError, ~r/produced an invalid screen/, fn -> + RuntimeManifest.build(plugins) + end + end + + test "collects lifecycle, settings, and notification_handlers" do + plugins = [ + {"/p", + base(%{ + name: :p, + lifecycle: %{on_start: {P, :start, []}}, + settings: %{schema: [%{key: :x, type: :boolean, default: true}]}, + notifications: %{handlers: [%{match: %{type: "t"}, handler: {P, :h, 1}}]} + })} + ] + + manifest = RuntimeManifest.build(plugins) + assert [%{plugin: :p, on_start: {P, :start, []}}] = manifest.lifecycle + assert [%{plugin: :p}] = manifest.settings + assert [%{plugin: :p, handler: {P, :h, 1}}] = manifest.notification_handlers + end + + test "collects + dedups plugin NIF module atoms (core loads them at boot)" do + plugins = [ + {"/p", + base(%{ + name: :p, + nifs: [ + %{module: :p_nif, native_dir: "priv/native/ios", lang: :objc, platform: :ios}, + %{module: :p_nif, native_dir: "priv/native/jni", lang: :zig, platform: :android} + ] + })}, + {"/q", + base(%{ + name: :q, + nifs: [ + %{module: :q_nif, native_dir: "priv/native/jni", lang: :zig, platform: :android} + ] + })} + ] + + # :p_nif declared for both platforms collapses to one entry. + assert RuntimeManifest.build(plugins).nifs == [:p_nif, :q_nif] + end + + test "nifs is empty when no plugin declares a NIF" do + assert RuntimeManifest.build([{"/p", base(%{name: :p})}]).nifs == [] + end + + test "a multi-tier plugin (screens_generator + tier-4 sections) keeps ALL its sections" do + # Regression for a composition bug: a plugin carrying both a spec-v2 + # screens_generator AND tier-4 lifecycle/settings/notifications must not + # have the tier-4 sections dropped from the runtime manifest. + plugins = [ + {"/g", + base(%{ + name: :g, + plugin_spec_version: 2, + screens_generator: {FakeGen, :generate, []}, + host_config_keys: [:sections], + lifecycle: %{on_start: {G, :start, []}, supervised: [G.Worker]}, + settings: %{schema: [%{key: :verbose, type: :boolean, default: false}]}, + notifications: %{handlers: [%{match: %{type: "g_ping"}, handler: {G, :h, 1}}]} + })} + ] + + manifest = RuntimeManifest.build(plugins) + assert [%{plugin: :g, supervised: [G.Worker]}] = manifest.lifecycle + assert [%{plugin: :g}] = manifest.settings + assert [%{plugin: :g, match: %{type: "g_ping"}}] = manifest.notification_handlers + end + end + + describe "validate_generated_screens/2" do + test "passes a list of valid screen specs through unchanged" do + screens = [%{module: A, default_route: "/a"}, %{module: B, default_route: "/b"}] + assert RuntimeManifest.validate_generated_screens(screens, :p) == screens + end + + test "wraps a single bare map" do + screen = %{module: A, default_route: "/a"} + assert RuntimeManifest.validate_generated_screens(screen, :p) == [screen] + end + + test "raises on a map missing :module" do + assert_raise ArgumentError, ~r/invalid screen at index 0/, fn -> + RuntimeManifest.validate_generated_screens([%{default_route: "/a"}], :p) + end + end + + test "raises on a map missing :default_route" do + assert_raise ArgumentError, fn -> + RuntimeManifest.validate_generated_screens([%{module: A}], :p) + end + end + + test "raises when :default_route is not a binary" do + assert_raise ArgumentError, fn -> + RuntimeManifest.validate_generated_screens([%{module: A, default_route: :x}], :p) + end + end + + test "raises when :module is nil" do + assert_raise ArgumentError, fn -> + RuntimeManifest.validate_generated_screens([%{module: nil, default_route: "/a"}], :p) + end + end + + test "raises on a non-map entry" do + assert_raise ArgumentError, fn -> + RuntimeManifest.validate_generated_screens(["nope"], :p) + end + end + end + + describe "render/1 round-trips" do + test "the rendered .exs evaluates back to the manifest map" do + manifest = %{ + screens: [%{plugin: :p, module: P.Home, default_route: "/p"}], + lifecycle: [%{plugin: :p, on_start: {P, :start, []}}], + settings: [%{plugin: :p, schema: [%{key: :x, type: :integer, default: 3}]}], + notification_handlers: [%{plugin: :p, match: %{type: "t"}, handler: {P, :h, 1}}] + } + + {evaluated, _} = Code.eval_string(RuntimeManifest.render(manifest)) + assert evaluated == manifest + end + + test "sorts map keys recursively for deterministic committed output" do + manifest = %{ + screens: [%{plugin: :p, module: P.Home, default_route: "/p"}], + lifecycle: [], + settings: [], + notification_handlers: [], + nifs: [], + composites: [], + styles: [], + default_style: nil + } + + rendered = RuntimeManifest.render(manifest) + + top_level = + Regex.scan(Regex.compile!("(?m)^ ([a-z_]+):"), rendered, capture: :all_but_first) + + nested = + Regex.scan(Regex.compile!("(?m)^ ([a-z_]+):"), rendered, capture: :all_but_first) + + assert top_level == Enum.sort(top_level) + assert nested == Enum.sort(nested) + end + end + + describe "write/1" do + test "writes priv/generated/mob_plugins.exs under the host root" do + root = Path.join(System.tmp_dir!(), "mob_rtm_#{System.unique_integer([:positive])}") + on_exit(fn -> File.rm_rf!(root) end) + + path = + RuntimeManifest.write(root, %{ + screens: [], + lifecycle: [], + settings: [], + notification_handlers: [] + }) + + assert path == Path.join([root, "priv", "generated", "mob_plugins.exs"]) + assert File.exists?(path) + {evaluated, _} = Code.eval_file(path) + assert evaluated.screens == [] + end + + test "does not rewrite an unchanged manifest" do + root = Path.join(System.tmp_dir!(), "mob_rtm_#{System.unique_integer([:positive])}") + on_exit(fn -> File.rm_rf!(root) end) + + manifest = %{ + screens: [], + lifecycle: [], + settings: [], + notification_handlers: [] + } + + path = RuntimeManifest.write(root, manifest) + File.touch!(path, 1) + mtime = File.stat!(path).mtime + + assert RuntimeManifest.write(root, manifest) == path + assert File.stat!(path).mtime == mtime + end + end + + describe "with_host_config_audit/3" do + test "records reads and restores a nil scope afterward" do + {result, reads} = + MobDev.Plugin.with_host_config_audit(:p, [:a, :b], fn -> + MobDev.Plugin.host_config(:app, :a, 1) + MobDev.Plugin.host_config(:app, :b, 2) + end) + + assert result == 3 + assert reads == [{:app, :a}, {:app, :b}] + # scope cleared: a bare read no longer enforces + assert MobDev.Plugin.host_config(:app, :anything, :default) == :default + end + end + + describe "composites (the ui_components expand: form)" do + test "expand entries reach the runtime manifest tagged with their plugin" do + plugins = [ + {"/kit", + %{ + name: :mishka_kit, + ui_components: [ + %{tag: "MishkaCard", atom: :mishka_card, expand: {Mishka.Card, :expand}}, + %{tag: "Native", atom: :native, ios: %{view_module: "N_View"}} + ] + }} + ] + + manifest = RuntimeManifest.build(plugins) + + assert manifest.composites == [ + %{plugin: :mishka_kit, atom: :mishka_card, expand: {Mishka.Card, :expand}} + ] + end + + test "no expand entries → empty composites" do + assert RuntimeManifest.build([]).composites == [] + end + end +end diff --git a/test/mob_dev/plugin/scaffold_test.exs b/test/mob_dev/plugin/scaffold_test.exs new file mode 100644 index 0000000..4b21adb --- /dev/null +++ b/test/mob_dev/plugin/scaffold_test.exs @@ -0,0 +1,412 @@ +defmodule MobDev.Plugin.ScaffoldTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.{Scaffold, Manifest, Validator} + + describe "module_name/1" do + test "converts snake_case to PascalCase" do + assert Scaffold.module_name("mob_demo_widget") == "MobDemoWidget" + assert Scaffold.module_name("widget") == "Widget" + assert Scaffold.module_name("mob_x") == "MobX" + end + end + + describe "validate_name/1" do + test "accepts snake_case identifiers" do + assert Scaffold.validate_name("mob_demo_widget") == :ok + assert Scaffold.validate_name("widget") == :ok + assert Scaffold.validate_name("plugin_with_123") == :ok + end + + test "rejects empty / non-string / wrong shape" do + assert {:error, _} = Scaffold.validate_name("") + assert {:error, _} = Scaffold.validate_name(:atom) + assert {:error, _} = Scaffold.validate_name("BadName") + assert {:error, _} = Scaffold.validate_name("1leading_digit") + assert {:error, _} = Scaffold.validate_name("with-dash") + assert {:error, _} = Scaffold.validate_name("with space") + end + + test "rejects special atoms that build a no-app project (nil/true/false)" do + # `app: :nil`/`:false` make `mix compile` die with "Cannot access build + # without an application name"; `:true` builds a boolean-named app. These + # pass the snake_case regex but produce a broken plugin. + for name <- ["nil", "true", "false"] do + assert {:error, msg} = Scaffold.validate_name(name) + assert msg =~ "reserved word" + end + end + + test "rejects Elixir reserved words (mirrors mix new)" do + for name <- ["when", "fn", "def", "import", "case", "receive"] do + assert {:error, _} = Scaffold.validate_name(name) + end + end + end + + describe "validate_tier/1" do + test "0 through 4 are valid" do + for t <- [0, 1, 2, 3, 4], do: assert(Scaffold.validate_tier(t) == :ok) + end + + test "other tiers are rejected" do + assert {:error, _} = Scaffold.validate_tier(5) + assert {:error, _} = Scaffold.validate_tier(-1) + assert {:error, _} = Scaffold.validate_tier("0") + end + end + + describe "mob version requirement (issue #21 — scaffolds must not pin a stale mob)" do + test "mob_requirement/1 derives ~> MAJOR.MINOR from a concrete version" do + assert Scaffold.mob_requirement("0.7.3") == "~> 0.7" + assert Scaffold.mob_requirement("1.2.10") == "~> 1.2" + assert Scaffold.mob_requirement(Version.parse!("0.8.0-rc.1")) == "~> 0.8" + end + + test "mob_requirement/1 falls back to the compiled default on nil" do + assert Scaffold.mob_requirement(nil) =~ ~r/^~> \d+\.\d+$/ + end + + test "the compiled default is a parseable version requirement" do + assert {:ok, _} = Version.parse_requirement(Scaffold.mob_requirement(nil)) + end + + test "detect_mob_requirement/0 returns a parseable requirement" do + assert {:ok, _} = Version.parse_requirement(Scaffold.detect_mob_requirement()) + end + + test "the default does NOT pin the abandoned ~> 0.6 (the bug this fixes)" do + # mob is 0.7.x; a 0.6 pin can't activate against published mob. If mob's + # major.minor moves again, bump @fallback_mob_requirement — this guards + # against silently shipping the old floor. + refute Scaffold.mob_requirement(nil) == "~> 0.6" + end + + test "every tier's mix.exs + manifest pin the same default requirement" do + req = Scaffold.mob_requirement(nil) + + for tier <- 0..4 do + files = Scaffold.files_for(tier, "mob_demo_widget") + assert content_for(files, "mix.exs") =~ "{:mob, \"#{req}\"}" + + # tier 0 has no manifest; tiers 1-4 must agree with mix.exs. + if tier > 0 do + {m, _} = Code.eval_string(content_for(files, "priv/mob_plugin.exs")) + assert m.mob_version == req, "tier #{tier} manifest mob_version drifted from mix.exs" + end + end + end + + test "files_for/3 threads an explicit requirement into mix.exs + manifest" do + files = Scaffold.files_for(1, "mob_demo_widget", "~> 9.9") + assert content_for(files, "mix.exs") =~ "{:mob, \"~> 9.9\"}" + {m, _} = Code.eval_string(content_for(files, "priv/mob_plugin.exs")) + assert m.mob_version == "~> 9.9" + end + + test "a scaffolded plugin's manifest is satisfied by a matching mob version" do + # The whole point: validate the generated manifest against a mob the + # default actually targets (not the stale 0.6 the validator used to OK). + for tier <- 1..4 do + dir = write_to_tmpdir!(Scaffold.files_for(tier, "mob_demo_widget")) + manifest = load_manifest!(dir) + assert %{errors: []} = Validator.validate_plugin(manifest, dir, satisfying_mob_version()) + end + end + end + + describe "files_for/2 — tier 0" do + setup do + {:ok, files: Scaffold.files_for(0, "mob_demo_widget")} + end + + test "emits mix.exs, lib/<name>.ex + test scaffolding (no manifest)", %{files: files} do + paths = paths(files) + assert "mix.exs" in paths + assert "lib/mob_demo_widget.ex" in paths + assert length(files) == 4 + end + + test "mix.exs has the right module + app names", %{files: files} do + content = content_for(files, "mix.exs") + assert content =~ "defmodule MobDemoWidget.MixProject" + assert content =~ "app: :mob_demo_widget" + assert content =~ "{:mob, \"#{Scaffold.mob_requirement(nil)}\"}" + end + + test "lib module is the PascalCase name", %{files: files} do + content = content_for(files, "lib/mob_demo_widget.ex") + assert content =~ "defmodule MobDemoWidget do" + end + end + + describe "files_for/2 — tier 1" do + setup do + {:ok, files: Scaffold.files_for(1, "mob_demo_widget")} + end + + test "emits the 7 expected files for a NIF-bearing plugin", %{files: files} do + paths = paths(files) + assert "mix.exs" in paths + assert "lib/mob_demo_widget.ex" in paths + assert "src/mob_demo_widget_nif.erl" in paths + assert "priv/mob_plugin.exs" in paths + assert "priv/native/jni/mob_demo_widget_nif.c" in paths + assert length(files) == 7 + end + + test "manifest's nif :module is the C-token name (not the Elixir module)", %{files: files} do + manifest_src = content_for(files, "priv/mob_plugin.exs") + {map, _} = Code.eval_string(manifest_src) + assert %{nifs: [%{module: :mob_demo_widget_nif, native_dir: "priv/native/jni"}]} = map + end + + test "Erlang stub matches the NIF name + has tolerant on_load", %{files: files} do + erl = content_for(files, "src/mob_demo_widget_nif.erl") + assert erl =~ "-module(mob_demo_widget_nif)." + assert erl =~ "load_nif(\"mob_demo_widget_nif\", 0)" + assert erl =~ "{error, _} -> ok" + end + + test "C source uses ERL_NIF_INIT with the matching NIF name", %{files: files} do + c = content_for(files, "priv/native/jni/mob_demo_widget_nif.c") + assert c =~ "ERL_NIF_INIT(mob_demo_widget_nif, nif_funcs" + assert c =~ "#include <erl_nif.h>" + end + + test "Elixir wrapper delegates to the Erlang NIF module", %{files: files} do + lib = content_for(files, "lib/mob_demo_widget.ex") + assert lib =~ "defmodule MobDemoWidget do" + assert lib =~ "defdelegate ping, to: :mob_demo_widget_nif" + end + + test "tier-1 manifest validates clean (structural + path existence in tmpdir)" do + dir = write_to_tmpdir!(Scaffold.files_for(1, "mob_demo_widget")) + manifest = load_manifest!(dir) + + assert {:ok, ^manifest} = Manifest.validate(manifest) + assert Manifest.tier(manifest) == 1 + + assert %{errors: [], warnings: []} = + Validator.validate_plugin(manifest, dir, satisfying_mob_version()) + end + end + + describe "files_for/2 — tier 2" do + setup do + {:ok, files: Scaffold.files_for(2, "mob_demo_widget")} + end + + test "emits the 8 expected files for a UI-component plugin", %{files: files} do + paths = paths(files) + assert "mix.exs" in paths + assert "lib/mob_demo_widget.ex" in paths + assert "lib/mob_demo_widget/view.ex" in paths + assert "priv/mob_plugin.exs" in paths + assert "priv/native/android/MobDemoWidget.kt" in paths + assert "priv/native/ios/MobDemoWidgetView.swift" in paths + assert length(files) == 8 + end + + test "manifest's registry name matches Mob.Component's module-name encoding", %{files: files} do + manifest_src = content_for(files, "priv/mob_plugin.exs") + {map, _} = Code.eval_string(manifest_src) + + assert %{ui_components: [comp]} = map + assert comp.ios.view_module == "MobDemoWidget_View" + assert comp.android.composable == "MobDemoWidget_View" + end + + test "Elixir wrapper goes through Mob.UI.native_view with the View module", %{files: files} do + lib = content_for(files, "lib/mob_demo_widget.ex") + assert lib =~ "Mob.UI.native_view(MobDemoWidget.View" + assert lib =~ "requires an :id atom" + end + + test "view module uses Mob.Component", %{files: files} do + view = content_for(files, "lib/mob_demo_widget/view.ex") + assert view =~ "defmodule MobDemoWidget.View do" + assert view =~ "use Mob.Component" + assert view =~ "@impl true" + assert view =~ "def mount(" + assert view =~ "def render(" + end + + test "Kotlin registers under the encoded name", %{files: files} do + kt = content_for(files, "priv/native/android/MobDemoWidget.kt") + assert kt =~ "MobNativeViewRegistry.register(\"MobDemoWidget_View\")" + assert kt =~ "object MobDemoWidgetPlugin" + end + + test "tier-2 manifest validates clean" do + dir = write_to_tmpdir!(Scaffold.files_for(2, "mob_demo_widget")) + manifest = load_manifest!(dir) + assert {:ok, ^manifest} = Manifest.validate(manifest) + assert Manifest.tier(manifest) == 2 + assert %{errors: []} = Validator.validate_plugin(manifest, dir, satisfying_mob_version()) + end + end + + describe "files_for/2 — tier 3" do + setup do + {:ok, files: Scaffold.files_for(3, "mob_demo_widget")} + end + + test "emits two screens, a manifest, and a migration", %{files: files} do + paths = paths(files) + assert "mix.exs" in paths + assert "lib/mob_demo_widget/list_screen.ex" in paths + assert "lib/mob_demo_widget/detail_screen.ex" in paths + assert "priv/mob_plugin.exs" in paths + assert "priv/repo/migrations/20260101000000_create_mob_demo_widget_items.exs" in paths + assert length(files) == 7 + end + + test "manifest declares two screen routes + a namespaced migration", %{files: files} do + {map, _} = Code.eval_string(content_for(files, "priv/mob_plugin.exs")) + assert %{screens: screens, migrations: %{repo_namespace: "mob_demo_widget_"}} = map + routes = Enum.map(screens, & &1.default_route) + assert "/mob_demo_widget/list" in routes + assert "/mob_demo_widget/detail" in routes + end + + test "tier-3 manifest validates clean + classifies as tier 3" do + dir = write_to_tmpdir!(Scaffold.files_for(3, "mob_demo_widget")) + manifest = load_manifest!(dir) + assert {:ok, ^manifest} = Manifest.validate(manifest) + assert Manifest.tier(manifest) == 3 + assert %{errors: []} = Validator.validate_plugin(manifest, dir, satisfying_mob_version()) + end + end + + describe "files_for/2 — tier 4" do + setup do + {:ok, files: Scaffold.files_for(4, "mob_demo_widget")} + end + + test "emits lifecycle lib, worker, notifications, settings screen, manifest", %{files: files} do + paths = paths(files) + assert "mix.exs" in paths + assert "lib/mob_demo_widget.ex" in paths + assert "lib/mob_demo_widget/worker.ex" in paths + assert "lib/mob_demo_widget/notifications.ex" in paths + assert "lib/mob_demo_widget/settings_screen.ex" in paths + assert "priv/mob_plugin.exs" in paths + assert length(files) == 8 + end + + test "manifest wires lifecycle + settings + notifications", %{files: files} do + {map, _} = Code.eval_string(content_for(files, "priv/mob_plugin.exs")) + assert %{lifecycle: lc, settings: settings, notifications: %{handlers: [h]}} = map + assert lc.on_start == {MobDemoWidget, :start, []} + assert lc.supervised == [MobDemoWidget.Worker] + assert settings.editor_screen == MobDemoWidget.SettingsScreen + assert h.match == %{type: "mob_demo_widget"} + end + + test "tier-4 manifest validates clean + classifies as tier 4" do + dir = write_to_tmpdir!(Scaffold.files_for(4, "mob_demo_widget")) + manifest = load_manifest!(dir) + assert {:ok, ^manifest} = Manifest.validate(manifest) + assert Manifest.tier(manifest) == 4 + assert %{errors: []} = Validator.validate_plugin(manifest, dir, satisfying_mob_version()) + end + end + + describe "files_for/2 — test scaffolding (all tiers)" do + test "every tier ships test/test_helper.exs + test/<name>_test.exs" do + for tier <- 0..4 do + ps = Scaffold.files_for(tier, "mob_demo_widget") |> paths() + assert "test/test_helper.exs" in ps, "tier #{tier} missing test_helper" + assert "test/mob_demo_widget_test.exs" in ps, "tier #{tier} missing test file" + end + end + + test "generated test files are valid Elixir with the right module name" do + for tier <- 0..4 do + content = + Scaffold.files_for(tier, "mob_demo_widget") + |> content_for("test/mob_demo_widget_test.exs") + + assert content =~ "defmodule MobDemoWidgetTest do" + Code.string_to_quoted!(content) + end + end + + test "manifest-bearing tiers (1-4) get the structural manifest tests, tier 0 doesn't" do + for tier <- 1..4 do + content = + Scaffold.files_for(tier, "mob_demo_widget") + |> content_for("test/mob_demo_widget_test.exs") + + assert content =~ "priv/mob_plugin.exs" + assert content =~ "mix mob.validate_plugin" + end + + tier0 = + Scaffold.files_for(0, "mob_demo_widget") + |> content_for("test/mob_demo_widget_test.exs") + + refute tier0 =~ "priv/mob_plugin.exs" + end + + test "the generated structural expectations hold for the scaffolded tier-1 plugin itself" do + files = Scaffold.files_for(1, "mob_demo_widget") + manifest_src = content_for(files, "priv/mob_plugin.exs") + {m, _} = Code.eval_string(manifest_src) + + # Mirror the generated assertions: required keys + per-NIF native_dir + # present in the scaffolded file set (on-disk File checks are covered by + # the validate-in-tmpdir tests above). + assert m.name == :mob_demo_widget + assert m.mob_version == Scaffold.mob_requirement(nil) + assert m.plugin_spec_version == 1 + + scaffolded_dirs = paths(files) |> Enum.map(&Path.dirname/1) |> MapSet.new() + + for %{native_dir: dir} <- m.nifs do + assert dir in scaffolded_dirs, "manifest native_dir #{dir} not scaffolded" + end + end + end + + # ── helpers ──────────────────────────────────────────────────────────────── + + # A concrete version that satisfies the scaffolded mob requirement, so the + # validate-in-tmpdir checks track the default and never lag a mob bump: + # "~> 0.7" → "0.7.0". + defp satisfying_mob_version do + "~> " <> base = Scaffold.mob_requirement(nil) + base <> ".0" + end + + defp paths(files), do: Enum.map(files, fn {p, _} -> p end) + + defp content_for(files, path) do + {^path, content} = Enum.find(files, fn {p, _} -> p == path end) + content + end + + defp write_to_tmpdir!(files) do + dir = Path.join(System.tmp_dir!(), "mob_scaffold_#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + on_exit_cleanup(dir) + + Enum.each(files, fn {rel, content} -> + path = Path.join(dir, rel) + path |> Path.dirname() |> File.mkdir_p!() + File.write!(path, content) + end) + + dir + end + + defp on_exit_cleanup(dir) do + ExUnit.Callbacks.on_exit(fn -> File.rm_rf!(dir) end) + end + + defp load_manifest!(dir) do + {:ok, manifest} = Manifest.load(dir) + manifest + end +end diff --git a/test/mob_dev/plugin/sign_test.exs b/test/mob_dev/plugin/sign_test.exs new file mode 100644 index 0000000..978d734 --- /dev/null +++ b/test/mob_dev/plugin/sign_test.exs @@ -0,0 +1,250 @@ +defmodule MobDev.Plugin.SignTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.{Crypto, Manifest, Sign, Verify} + + setup do + dir = + Path.join(System.tmp_dir!(), "mob_sign_test_#{System.unique_integer([:positive])}") + + File.mkdir_p!(Path.join(dir, "priv")) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, dir: dir} + end + + defp write_manifest(dir, manifest) do + File.write!(Path.join(dir, "priv/mob_plugin.exs"), inspect(manifest, limit: :infinity)) + end + + defp write_file(dir, rel, contents) do + path = Path.join(dir, rel) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, contents) + path + end + + describe "compute_file_hashes/2" do + test "returns [] for nil manifest", %{dir: dir} do + assert Sign.compute_file_hashes(dir, nil) == [] + end + + test "returns [] for a manifest with no referenced files", %{dir: dir} do + manifest = %{name: :mob_x, mob_version: "~> 0.6", plugin_spec_version: 1} + assert Sign.compute_file_hashes(dir, manifest) == [] + end + + test "hashes ios.swift_files and android paths, sorted by path", %{dir: dir} do + write_file(dir, "ios/A.swift", "a contents") + write_file(dir, "ios/B.swift", "b contents") + write_file(dir, "android/Bridge.kt", "kt contents") + write_file(dir, "android/jni/Plugin.cpp", "cpp contents") + + manifest = %{ + name: :mob_x, + mob_version: "~> 0.6", + plugin_spec_version: 1, + ios: %{swift_files: ["ios/B.swift", "ios/A.swift"]}, + android: %{bridge_kt: "android/Bridge.kt", jni_source: "android/jni/Plugin.cpp"} + } + + hashes = Sign.compute_file_hashes(dir, manifest) + paths = Enum.map(hashes, &elem(&1, 0)) + assert paths == Enum.sort(paths) + + assert paths == [ + "android/Bridge.kt", + "android/jni/Plugin.cpp", + "ios/A.swift", + "ios/B.swift" + ] + end + + test "hashes android.res_files so copied resource bytes are signed", %{dir: dir} do + write_file(dir, "android/res/xml/svc.xml", "<host-apdu-service/>") + write_file(dir, "android/res/values/strings.xml", "<resources/>") + + manifest = %{ + name: :mob_x, + mob_version: "~> 0.6", + plugin_spec_version: 1, + android: %{ + res_files: ["android/res/xml/svc.xml", "android/res/values/strings.xml"] + } + } + + paths = Sign.compute_file_hashes(dir, manifest) |> Enum.map(&elem(&1, 0)) + assert "android/res/xml/svc.xml" in paths + assert "android/res/values/strings.xml" in paths + end + + test "is independent of the order swift_files appear in the manifest", %{dir: dir} do + write_file(dir, "ios/A.swift", "alpha") + write_file(dir, "ios/B.swift", "beta") + + m1 = %{ + name: :mob_x, + mob_version: "~> 0.6", + plugin_spec_version: 1, + ios: %{swift_files: ["ios/A.swift", "ios/B.swift"]} + } + + m2 = put_in(m1, [:ios, :swift_files], ["ios/B.swift", "ios/A.swift"]) + + assert Sign.compute_file_hashes(dir, m1) == Sign.compute_file_hashes(dir, m2) + end + + test "recursively hashes native sources and headers inside nifs.native_dir", %{dir: dir} do + write_file(dir, "priv/native/n.c", "c source") + write_file(dir, "priv/native/nested/n.h", "header") + write_file(dir, "priv/native/nested/n.m", "objective-c source") + write_file(dir, "priv/native/nested/n.mm", "objective-c++ source") + write_file(dir, "priv/native/skip.txt", "should be skipped") + write_file(dir, "priv/native/build.zig", "zig source") + + manifest = %{ + name: :mob_x, + mob_version: "~> 0.6", + plugin_spec_version: 1, + nifs: [%{module: :mob_x_nif, native_dir: "priv/native"}] + } + + paths = manifest |> (&Sign.compute_file_hashes(dir, &1)).() |> Enum.map(&elem(&1, 0)) + assert "priv/native/n.c" in paths + assert "priv/native/nested/n.h" in paths + assert "priv/native/nested/n.m" in paths + assert "priv/native/nested/n.mm" in paths + assert "priv/native/build.zig" in paths + refute "priv/native/skip.txt" in paths + end + + test "uses the frozen legacy native extension set for v1 and expanded set for v2", %{ + dir: dir + } do + write_file(dir, "priv/native/n.c", "c source") + write_file(dir, "priv/native/n.m", "objective-c source") + write_file(dir, "priv/native/n.mm", "objective-c++ source") + + manifest = %{ + name: :mob_x, + mob_version: "~> 0.6", + plugin_spec_version: 1, + nifs: [%{module: :mob_x_nif, native_dir: "priv/native"}] + } + + v1_paths = + dir + |> Sign.compute_file_hashes(manifest, 1) + |> Enum.map(&elem(&1, 0)) + + v2_paths = + dir + |> Sign.compute_file_hashes(manifest, 2) + |> Enum.map(&elem(&1, 0)) + + assert v1_paths == ["priv/native/n.c"] + assert v2_paths == ["priv/native/n.c", "priv/native/n.m", "priv/native/n.mm"] + end + + test "different file contents produce different hashes", %{dir: dir} do + write_file(dir, "ios/A.swift", "version 1") + + manifest = %{ + name: :mob_x, + mob_version: "~> 0.6", + plugin_spec_version: 1, + ios: %{swift_files: ["ios/A.swift"]} + } + + [{_, h1}] = Sign.compute_file_hashes(dir, manifest) + + write_file(dir, "ios/A.swift", "version 2") + [{_, h2}] = Sign.compute_file_hashes(dir, manifest) + + assert h1 != h2 + end + end + + describe "build_payload/2" do + test "defaults to the current v2 payload" do + payload = Sign.build_payload(%{name: :mob_x}, [{"a", <<1, 2, 3>>}]) + assert payload.manifest == %{name: :mob_x} + assert payload.file_hashes == [{"a", <<1, 2, 3>>}] + assert payload.envelope_version == 2 + end + + test "can reconstruct the exact legacy v1 payload" do + payload = Sign.build_payload(%{name: :mob_x}, [{"a", <<1, 2, 3>>}], 1) + + assert payload == %{ + manifest: %{name: :mob_x}, + file_hashes: [{"a", <<1, 2, 3>>}], + envelope_version: 1 + } + end + end + + describe "sign_plugin/2" do + test "writes priv/mob_plugin.sig that Verify.verify_plugin accepts", %{dir: dir} do + manifest = %{name: :mob_demo, mob_version: "~> 0.6", plugin_spec_version: 1} + write_manifest(dir, manifest) + + {priv, pub} = Crypto.generate_keypair() + File.write!(Path.join(dir, "priv/mob_plugin.pub"), Base.encode64(pub) <> "\n") + + assert :ok = Sign.sign_plugin(dir, priv) + assert File.exists?(Sign.signature_path(dir)) + + {:ok, loaded_manifest} = Manifest.load(dir) + assert :ok = Verify.verify_plugin(dir, loaded_manifest) + assert {:ok, 2} = Verify.verify_plugin_with_version(dir, loaded_manifest) + + raw_envelope = dir |> Sign.signature_path() |> File.read!() + + assert %{signature: signature, envelope_version: 2} = + envelope = + :erlang.binary_to_term(raw_envelope, [:safe]) + + assert byte_size(signature) == 64 + assert envelope == %{signature: signature, envelope_version: 2} + assert raw_envelope == Crypto.canonical_encode(envelope) + assert Sign.envelope_version() == 2 + end + + test "errors when no manifest is present", %{dir: dir} do + {priv, _pub} = Crypto.generate_keypair() + assert {:error, _} = Sign.sign_plugin(dir, priv) + end + + for extension <- [".m", ".mm"] do + @extension extension + + test "rejects tampering a signed Objective-C source with extension #{extension}", %{ + dir: dir + } do + extension = @extension + plugin_dir = Path.join(dir, String.trim_leading(extension, ".")) + source = "priv/native/ios/mob_demo_nif#{extension}" + write_file(plugin_dir, source, "native source") + + manifest = %{ + name: :mob_demo, + mob_version: "~> 0.6", + plugin_spec_version: 1, + nifs: [%{module: :mob_demo_nif, native_dir: "priv/native/ios", lang: :objc}] + } + + write_manifest(plugin_dir, manifest) + {priv, pub} = Crypto.generate_keypair() + File.write!(Path.join(plugin_dir, "priv/mob_plugin.pub"), Base.encode64(pub) <> "\n") + + assert :ok = Sign.sign_plugin(plugin_dir, priv) + assert {:ok, loaded_manifest} = Manifest.load(plugin_dir) + assert :ok = Verify.verify_plugin(plugin_dir, loaded_manifest) + + File.write!(Path.join(plugin_dir, source), "tampered native source") + + assert {:error, :invalid_signature} = Verify.verify_plugin(plugin_dir, loaded_manifest) + end + end + end +end diff --git a/test/mob_dev/plugin/signature_gate_test.exs b/test/mob_dev/plugin/signature_gate_test.exs new file mode 100644 index 0000000..6644351 --- /dev/null +++ b/test/mob_dev/plugin/signature_gate_test.exs @@ -0,0 +1,131 @@ +defmodule MobDev.Plugin.SignatureGateTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.{Crypto, Sign, SignatureGate, Verify} + + setup do + dir = + Path.join(System.tmp_dir!(), "mob_sig_gate_test_#{System.unique_integer([:positive])}") + + File.mkdir_p!(Path.join(dir, "priv")) + on_exit(fn -> File.rm_rf!(dir) end) + + manifest = %{name: :mob_demo, mob_version: "~> 0.6", plugin_spec_version: 1} + File.write!(Path.join(dir, "priv/mob_plugin.exs"), inspect(manifest, limit: :infinity)) + + {priv, pub} = Crypto.generate_keypair() + File.write!(Path.join(dir, "priv/mob_plugin.pub"), Base.encode64(pub) <> "\n") + :ok = Sign.sign_plugin(dir, priv) + + {:ok, dir: dir, manifest: manifest, priv: priv, pub: pub} + end + + describe "check_plugin/4" do + test "passes when signature verifies and fingerprint is trusted", %{ + dir: dir, + manifest: manifest, + pub: pub + } do + trust = %{mob_demo: Crypto.fingerprint(pub)} + assert SignatureGate.check_plugin(dir, manifest, trust, []) == :ok + end + + test "keeps trusted v1 plugins valid for checksum-pinned official plugin compatibility", %{ + dir: dir, + manifest: manifest, + priv: priv, + pub: pub + } do + file_hashes = Sign.compute_file_hashes(dir, manifest, 1) + payload = Sign.build_payload(manifest, file_hashes, 1) + signature = Crypto.sign(payload, priv) + + File.write!( + Sign.signature_path(dir), + Crypto.canonical_encode(%{signature: signature, envelope_version: 1}) + ) + + trust = %{mob_demo: Crypto.fingerprint(pub)} + assert {:ok, 1} = Verify.verify_plugin_with_version(dir, manifest) + assert SignatureGate.check_plugin(dir, manifest, trust, []) == :ok + assert SignatureGate.check_activated([{dir, manifest}], trust, []) == :ok + end + + test "untrusted when fingerprint not in trust map", %{ + dir: dir, + manifest: manifest, + pub: pub + } do + result = SignatureGate.check_plugin(dir, manifest, %{}, []) + assert {:untrusted, :mob_demo, fp, nil} = result + assert fp == Crypto.fingerprint(pub) + end + + test "untrusted reports key rotation when a different fingerprint is stored", %{ + dir: dir, + manifest: manifest, + pub: pub + } do + {_p2, pub2} = Crypto.generate_keypair() + trust = %{mob_demo: Crypto.fingerprint(pub2)} + + result = SignatureGate.check_plugin(dir, manifest, trust, []) + assert {:untrusted, :mob_demo, signed_fp, trusted_fp} = result + assert signed_fp == Crypto.fingerprint(pub) + assert trusted_fp == Crypto.fingerprint(pub2) + end + + test "missing_signature when sig file is absent", %{dir: dir, manifest: manifest} do + File.rm!(Sign.signature_path(dir)) + assert {:missing_signature, :mob_demo} = SignatureGate.check_plugin(dir, manifest, %{}, []) + end + + test "missing_signature is suppressed by acknowledge list", %{dir: dir, manifest: manifest} do + File.rm!(Sign.signature_path(dir)) + assert :ok = SignatureGate.check_plugin(dir, manifest, %{}, [:mob_demo]) + end + + test "invalid_signature when sources are tampered", %{ + dir: dir, + manifest: _manifest, + pub: pub + } do + File.write!( + Path.join(dir, "priv/mob_plugin.exs"), + inspect(%{name: :mob_evil, mob_version: "~> 0.6", plugin_spec_version: 1}) + ) + + trust = %{mob_evil: Crypto.fingerprint(pub)} + + result = + SignatureGate.check_plugin( + dir, + %{name: :mob_evil, mob_version: "~> 0.6", plugin_spec_version: 1}, + trust, + [] + ) + + assert {:invalid_signature, :mob_evil} = result + end + end + + describe "check_activated/3" do + test "returns :ok when every plugin verifies + is trusted", %{ + dir: dir, + manifest: manifest, + pub: pub + } do + trust = %{mob_demo: Crypto.fingerprint(pub)} + assert SignatureGate.check_activated([{dir, manifest}], trust, []) == :ok + end + + test "reports errors per failing plugin", %{dir: dir, manifest: manifest} do + assert {:error, [{:untrusted, :mob_demo, _, nil}]} = + SignatureGate.check_activated([{dir, manifest}], %{}, []) + end + + test "skips tier-0 (nil-manifest) plugins", %{dir: dir} do + assert SignatureGate.check_activated([{dir, nil}], %{}, []) == :ok + end + end +end diff --git a/test/mob_dev/plugin/trust_store_test.exs b/test/mob_dev/plugin/trust_store_test.exs new file mode 100644 index 0000000..c7c5f6b --- /dev/null +++ b/test/mob_dev/plugin/trust_store_test.exs @@ -0,0 +1,151 @@ +defmodule MobDev.Plugin.TrustStoreTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.{Crypto, TrustStore} + + setup do + dir = + Path.join(System.tmp_dir!(), "mob_trust_test_#{System.unique_integer([:positive])}") + + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, dir: dir} + end + + defp seed_mob_exs(dir, contents) do + File.write!(Path.join(dir, "mob.exs"), contents) + end + + describe "load_trusted_plugins/1" do + test "returns empty map when mob.exs is missing", %{dir: dir} do + assert TrustStore.load_trusted_plugins(dir) == %{} + end + + test "returns empty map when no :trusted_plugins entry", %{dir: dir} do + seed_mob_exs(dir, "import Config\nconfig :mob, :plugins, [:mob_foo]\n") + assert TrustStore.load_trusted_plugins(dir) == %{} + end + + test "reads the :trusted_plugins map", %{dir: dir} do + seed_mob_exs(dir, """ + import Config + + config :mob, :trusted_plugins, %{mob_foo: "ed25519:abc=", mob_bar: "ed25519:def="} + """) + + assert TrustStore.load_trusted_plugins(dir) == %{ + mob_foo: "ed25519:abc=", + mob_bar: "ed25519:def=" + } + end + end + + describe "add_trust/3" do + test "writes a new entry to a previously empty mob.exs", %{dir: dir} do + {_priv, pub} = Crypto.generate_keypair() + seed_mob_exs(dir, "import Config\n") + + assert :ok = TrustStore.add_trust(:mob_foo, pub, dir) + assert TrustStore.load_trusted_plugins(dir) == %{mob_foo: Crypto.fingerprint(pub)} + end + + test "creates mob.exs when it doesn't exist", %{dir: dir} do + {_priv, pub} = Crypto.generate_keypair() + assert :ok = TrustStore.add_trust(:mob_foo, pub, dir) + assert File.exists?(Path.join(dir, "mob.exs")) + assert TrustStore.load_trusted_plugins(dir) == %{mob_foo: Crypto.fingerprint(pub)} + end + + test "preserves unrelated config and comments", %{dir: dir} do + seed_mob_exs(dir, """ + import Config + + # User comment that must survive + config :mob, :plugins, [:mob_foo] + config :my_app, :unrelated, "value" + """) + + {_priv, pub} = Crypto.generate_keypair() + assert :ok = TrustStore.add_trust(:mob_foo, pub, dir) + + contents = File.read!(Path.join(dir, "mob.exs")) + assert contents =~ "# User comment that must survive" + assert contents =~ "config :mob, :plugins, [:mob_foo]" + assert contents =~ ~s(config :my_app, :unrelated, "value") + assert contents =~ "trusted_plugins" + end + + test "replaces an existing entry on key rotation", %{dir: dir} do + {_p1, pub1} = Crypto.generate_keypair() + {_p2, pub2} = Crypto.generate_keypair() + + seed_mob_exs(dir, "import Config\n") + :ok = TrustStore.add_trust(:mob_foo, pub1, dir) + :ok = TrustStore.add_trust(:mob_foo, pub2, dir) + + trust = TrustStore.load_trusted_plugins(dir) + assert trust == %{mob_foo: Crypto.fingerprint(pub2)} + end + + test "is idempotent — same fingerprint twice is a no-op", %{dir: dir} do + {_priv, pub} = Crypto.generate_keypair() + seed_mob_exs(dir, "import Config\n") + + :ok = TrustStore.add_trust(:mob_foo, pub, dir) + first = File.read!(Path.join(dir, "mob.exs")) + + :ok = TrustStore.add_trust(:mob_foo, pub, dir) + second = File.read!(Path.join(dir, "mob.exs")) + + assert first == second + end + + test "merges a second plugin into the existing trusted_plugins map", %{dir: dir} do + {_p1, pub1} = Crypto.generate_keypair() + {_p2, pub2} = Crypto.generate_keypair() + + seed_mob_exs(dir, "import Config\n") + :ok = TrustStore.add_trust(:mob_foo, pub1, dir) + :ok = TrustStore.add_trust(:mob_bar, pub2, dir) + + trust = TrustStore.load_trusted_plugins(dir) + assert Map.keys(trust) |> Enum.sort() == [:mob_bar, :mob_foo] + end + end + + describe "remove_trust/2" do + test "removes an existing entry", %{dir: dir} do + {_priv, pub} = Crypto.generate_keypair() + seed_mob_exs(dir, "import Config\n") + :ok = TrustStore.add_trust(:mob_foo, pub, dir) + + :ok = TrustStore.remove_trust(:mob_foo, dir) + assert TrustStore.load_trusted_plugins(dir) == %{} + end + + test "is a no-op when the plugin isn't trusted", %{dir: dir} do + seed_mob_exs(dir, "import Config\n") + assert :ok = TrustStore.remove_trust(:mob_foo, dir) + end + end + + describe "trusted?/3" do + test "true when fingerprint matches the trust map" do + {_priv, pub} = Crypto.generate_keypair() + map = %{mob_foo: Crypto.fingerprint(pub)} + assert TrustStore.trusted?(:mob_foo, pub, map) + end + + test "false when no trust entry" do + {_priv, pub} = Crypto.generate_keypair() + refute TrustStore.trusted?(:mob_foo, pub, %{}) + end + + test "false when fingerprint mismatches (key rotation case)" do + {_p1, pub1} = Crypto.generate_keypair() + {_p2, pub2} = Crypto.generate_keypair() + map = %{mob_foo: Crypto.fingerprint(pub1)} + refute TrustStore.trusted?(:mob_foo, pub2, map) + end + end +end diff --git a/test/mob_dev/plugin/validator_test.exs b/test/mob_dev/plugin/validator_test.exs new file mode 100644 index 0000000..5c0d0b3 --- /dev/null +++ b/test/mob_dev/plugin/validator_test.exs @@ -0,0 +1,647 @@ +defmodule MobDev.Plugin.ValidatorTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.Validator + + @base %{name: :mob_demo, mob_version: "~> 0.6", plugin_spec_version: 1} + + describe "referenced_paths/1" do + test "nil manifest has no paths" do + assert Validator.referenced_paths(nil) == [] + end + + test "collects nif dirs, android bridge/jni, and ios swift files" do + m = + Map.merge(@base, %{ + nifs: [%{module: X, native_dir: "priv/native/jni"}], + android: %{bridge_kt: "priv/a/B.kt", jni_source: "priv/a/c.c"}, + ios: %{swift_files: ["priv/i/D.swift", "priv/i/E.swift"]} + }) + + paths = Validator.referenced_paths(m) + assert "priv/native/jni" in paths + assert "priv/a/B.kt" in paths + assert "priv/a/c.c" in paths + assert "priv/i/D.swift" in paths + assert "priv/i/E.swift" in paths + end + end + + describe "validate_plugin/3" do + setup do + dir = Path.join(System.tmp_dir!(), "mob_validator_#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, dir: dir} + end + + test "a minimal valid manifest with no paths passes", %{dir: dir} do + assert %{errors: [], warnings: []} = Validator.validate_plugin(@base, dir, "0.6.20") + end + + test "errors when a declared path is missing", %{dir: dir} do + m = Map.put(@base, :android, %{jni_source: "priv/native/missing.c"}) + assert %{errors: errs} = Validator.validate_plugin(m, dir, "0.6.20") + assert Enum.any?(errs, &(&1 =~ "does not exist")) + end + + test "passes when the declared path exists", %{dir: dir} do + File.mkdir_p!(Path.join(dir, "priv/native")) + File.write!(Path.join(dir, "priv/native/x.c"), "// stub") + m = Map.put(@base, :android, %{jni_source: "priv/native/x.c"}) + assert %{errors: []} = Validator.validate_plugin(m, dir, "0.6.20") + end + + test "errors when installed mob does not satisfy mob_version", %{dir: dir} do + m = %{@base | mob_version: "~> 0.7"} + assert %{errors: errs} = Validator.validate_plugin(m, dir, "0.6.20") + assert Enum.any?(errs, &(&1 =~ "does not satisfy")) + end + + test "skips the mob_version check when installed version is unknown", %{dir: dir} do + m = %{@base | mob_version: "~> 0.7"} + assert %{errors: []} = Validator.validate_plugin(m, dir, nil) + end + + test "propagates structural errors", %{dir: dir} do + assert %{errors: errs} = Validator.validate_plugin(Map.delete(@base, :name), dir, "0.6.20") + assert Enum.any?(errs, &(&1 =~ ":name")) + end + + test "warns on a single-platform component", %{dir: dir} do + m = + Map.put(@base, :ui_components, [%{tag: "Chart", atom: :chart, ios: %{view_module: "X"}}]) + + assert %{warnings: warns} = Validator.validate_plugin(m, dir, "0.6.20") + assert Enum.any?(warns, &(&1 =~ "only one platform")) + end + + test "does not warn when a component declares both platforms", %{dir: dir} do + m = + Map.put(@base, :ui_components, [ + %{tag: "Chart", atom: :chart, ios: %{view_module: "X"}, android: %{composable: "Y"}} + ]) + + assert %{warnings: []} = Validator.validate_plugin(m, dir, "0.6.20") + end + + test "warns when permissions are declared", %{dir: dir} do + m = Map.put(@base, :android, %{permissions: ["android.permission.CAMERA"]}) + assert %{warnings: warns} = Validator.validate_plugin(m, dir, "0.6.20") + assert Enum.any?(warns, &(&1 =~ "permissions")) + end + + test "warns when plist_keys are declared", %{dir: dir} do + m = Map.put(@base, :ios, %{plist_keys: %{"NSCameraUsageDescription" => "why"}}) + assert %{warnings: warns} = Validator.validate_plugin(m, dir, "0.6.20") + assert Enum.any?(warns, &(&1 =~ "plist_keys")) + end + + test "accepts a C-token nif :module atom", %{dir: dir} do + File.mkdir_p!(Path.join(dir, "priv/native/jni")) + + m = + Map.put(@base, :nifs, [ + %{module: :mob_bluetooth_nif, native_dir: "priv/native/jni"} + ]) + + assert %{errors: []} = Validator.validate_plugin(m, dir, "0.6.20") + end + + test "rejects an Elixir module as nif :module", %{dir: dir} do + File.mkdir_p!(Path.join(dir, "priv/native/jni")) + + m = + Map.put(@base, :nifs, [ + %{module: MyApp.Foo, native_dir: "priv/native/jni"} + ]) + + assert %{errors: errs} = Validator.validate_plugin(m, dir, "0.6.20") + assert Enum.any?(errs, &(&1 =~ "C-token")) + assert Enum.any?(errs, &(&1 =~ "MyApp.Foo")) + assert Enum.any?(errs, &(&1 =~ "ERL_NIF_INIT")) + end + + test "rejects an uppercase-only atom as nif :module", %{dir: dir} do + File.mkdir_p!(Path.join(dir, "priv/native/jni")) + + m = + Map.put(@base, :nifs, [ + %{module: :ALLCAPS, native_dir: "priv/native/jni"} + ]) + + assert %{errors: errs} = Validator.validate_plugin(m, dir, "0.6.20") + assert Enum.any?(errs, &(&1 =~ "C-token")) + assert Enum.any?(errs, &(&1 =~ "ALLCAPS")) + end + + test "rejects an atom starting with a digit as nif :module", %{dir: dir} do + File.mkdir_p!(Path.join(dir, "priv/native/jni")) + + m = + Map.put(@base, :nifs, [ + %{module: :"1bad_nif", native_dir: "priv/native/jni"} + ]) + + assert %{errors: errs} = Validator.validate_plugin(m, dir, "0.6.20") + assert Enum.any?(errs, &(&1 =~ "C-token")) + end + + test "rejects an atom containing a hyphen as nif :module", %{dir: dir} do + File.mkdir_p!(Path.join(dir, "priv/native/jni")) + + m = + Map.put(@base, :nifs, [ + %{module: :"bad-nif", native_dir: "priv/native/jni"} + ]) + + assert %{errors: errs} = Validator.validate_plugin(m, dir, "0.6.20") + assert Enum.any?(errs, &(&1 =~ "C-token")) + end + + test "rejects a nif :module that collides with a core/runtime NIF", %{dir: dir} do + File.mkdir_p!(Path.join(dir, "priv/native/jni")) + + for core <- [:crypto, :mob_nif, :prim_file, :zlib] do + m = + Map.put(@base, :nifs, [ + %{module: core, native_dir: "priv/native/jni"} + ]) + + assert %{errors: errs} = Validator.validate_plugin(m, dir, "0.6.20") + + assert Enum.any?(errs, &(&1 =~ "collides with a core/runtime NIF")), + "expected #{inspect(core)} to be rejected as a reserved NIF module" + + assert Enum.any?(errs, &(&1 =~ to_string(core))) + end + end + + test "still accepts a plugin-specific nif :module that is not reserved", %{dir: dir} do + File.mkdir_p!(Path.join(dir, "priv/native/jni")) + + m = + Map.put(@base, :nifs, [ + %{module: :mob_bluetooth_nif, native_dir: "priv/native/jni"} + ]) + + assert %{errors: []} = Validator.validate_plugin(m, dir, "0.6.20") + end + + test "accepts a Swift-identifier ios.swift_struct", %{dir: dir} do + m = + Map.put(@base, :ui_components, [ + %{ + tag: "Sig", + atom: :sig, + ios: %{view_module: "Demo_SigPad_View", swift_struct: "MobSignaturePadView"}, + android: %{composable: "Demo_SigPad_View"} + } + ]) + + assert %{errors: []} = Validator.validate_plugin(m, dir, "0.6.20") + end + + test "rejects a non-binary ios.swift_struct", %{dir: dir} do + m = + Map.put(@base, :ui_components, [ + %{ + tag: "Sig", + atom: :sig, + ios: %{view_module: "Demo_SigPad_View", swift_struct: MobSignaturePadView}, + android: %{composable: "Demo_SigPad_View"} + } + ]) + + assert %{errors: errs} = Validator.validate_plugin(m, dir, "0.6.20") + assert Enum.any?(errs, &(&1 =~ "swift_struct")) + assert Enum.any?(errs, &(&1 =~ "Swift identifier")) + end + + test "rejects a swift_struct containing a hyphen", %{dir: dir} do + m = + Map.put(@base, :ui_components, [ + %{ + tag: "Sig", + atom: :sig, + ios: %{view_module: "Demo_SigPad_View", swift_struct: "Mob-Bad-View"}, + android: %{composable: "Demo_SigPad_View"} + } + ]) + + assert %{errors: errs} = Validator.validate_plugin(m, dir, "0.6.20") + assert Enum.any?(errs, &(&1 =~ "swift_struct")) + end + + test "does not complain when ios.swift_struct is absent (optional field)", %{dir: dir} do + m = + Map.put(@base, :ui_components, [ + %{ + tag: "Sig", + atom: :sig, + ios: %{view_module: "Demo_SigPad_View"}, + android: %{composable: "Demo_SigPad_View"} + } + ]) + + assert %{errors: []} = Validator.validate_plugin(m, dir, "0.6.20") + end + end + + describe "validate_swift_imports/2" do + setup do + dir = + Path.join(System.tmp_dir!(), "mob_validator_swift_#{System.unique_integer([:positive])}") + + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, dir: dir} + end + + test "no swift_files means no errors", %{dir: dir} do + assert Validator.validate_swift_imports(@base, dir) == [] + end + + test "nil manifest yields no errors", %{dir: _dir} do + assert Validator.validate_swift_imports(nil, "/nonexistent") == [] + end + + test "imports of base frameworks (SwiftUI, Foundation, UIKit) require no declaration", + %{dir: dir} do + swift_file = Path.join(dir, "priv/native/ios/Pad.swift") + File.mkdir_p!(Path.dirname(swift_file)) + + File.write!(swift_file, """ + import SwiftUI + import Foundation + import UIKit + struct Pad: View { var body: some View { Text("hi") } } + """) + + m = Map.put(@base, :ios, %{swift_files: ["priv/native/ios/Pad.swift"]}) + assert Validator.validate_swift_imports(m, dir) == [] + end + + test "an undeclared non-base import is flagged", %{dir: dir} do + swift_file = Path.join(dir, "priv/native/ios/Loc.swift") + File.mkdir_p!(Path.dirname(swift_file)) + + File.write!(swift_file, """ + import SwiftUI + import CoreLocation + struct Loc {} + """) + + m = Map.put(@base, :ios, %{swift_files: ["priv/native/ios/Loc.swift"]}) + errors = Validator.validate_swift_imports(m, dir) + assert Enum.any?(errors, &(&1 =~ "CoreLocation")) + assert Enum.any?(errors, &(&1 =~ "priv/native/ios/Loc.swift")) + assert Enum.any?(errors, &(&1 =~ "manifest.ios.frameworks")) + end + + test "a declared framework satisfies the check", %{dir: dir} do + swift_file = Path.join(dir, "priv/native/ios/Haptics.swift") + File.mkdir_p!(Path.dirname(swift_file)) + + File.write!(swift_file, """ + import CoreHaptics + import Foundation + class HapticEngine {} + """) + + m = + Map.put(@base, :ios, %{ + swift_files: ["priv/native/ios/Haptics.swift"], + frameworks: ["CoreHaptics"] + }) + + assert Validator.validate_swift_imports(m, dir) == [] + end + + test "@testable import is treated as an ordinary import", %{dir: dir} do + swift_file = Path.join(dir, "priv/native/ios/T.swift") + File.mkdir_p!(Path.dirname(swift_file)) + + File.write!(swift_file, """ + @testable import CoreBluetooth + """) + + m = Map.put(@base, :ios, %{swift_files: ["priv/native/ios/T.swift"]}) + errors = Validator.validate_swift_imports(m, dir) + assert Enum.any?(errors, &(&1 =~ "CoreBluetooth")) + end + + test "skips files declared but missing on disk (path check owns that error)", + %{dir: dir} do + m = Map.put(@base, :ios, %{swift_files: ["priv/native/ios/Missing.swift"]}) + assert Validator.validate_swift_imports(m, dir) == [] + end + + test "mob_demo_signature_pad's swift source passes with no declared frameworks", + %{dir: dir} do + # The real demo plugin imports SwiftUI + Foundation — both base — so it + # should pass with no extra ios.frameworks declared. Copy the file shape + # rather than depending on the live plugin's filesystem location. + swift_file = Path.join(dir, "priv/native/ios/MobSignaturePadView.swift") + File.mkdir_p!(Path.dirname(swift_file)) + + File.write!(swift_file, """ + import SwiftUI + import Foundation + + struct MobSignaturePadView: View { + let props: [String: Any] + var body: some View { Text("sig") } + } + """) + + m = + Map.put(@base, :ios, %{ + swift_files: ["priv/native/ios/MobSignaturePadView.swift"] + }) + + assert Validator.validate_swift_imports(m, dir) == [] + end + + test "wired into validate_plugin/3 — undeclared import bubbles up as an error", + %{dir: dir} do + swift_file = Path.join(dir, "priv/native/ios/X.swift") + File.mkdir_p!(Path.dirname(swift_file)) + File.write!(swift_file, "import CoreLocation\n") + + m = Map.put(@base, :ios, %{swift_files: ["priv/native/ios/X.swift"]}) + assert %{errors: errs} = Validator.validate_plugin(m, dir, "0.6.20") + assert Enum.any?(errs, &(&1 =~ "CoreLocation")) + end + end + + describe "validate_android_permissions/2" do + setup do + dir = + Path.join( + System.tmp_dir!(), + "mob_validator_android_#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, dir: dir} + end + + test "no AndroidManifest fragment means no errors", %{dir: dir} do + assert Validator.validate_android_permissions(@base, dir) == [] + end + + test "nil manifest yields no errors" do + assert Validator.validate_android_permissions(nil, "/nonexistent") == [] + end + + test "a declared permission satisfies the check", %{dir: dir} do + manifest_xml = Path.join(dir, "priv/native/android/AndroidManifest.xml") + File.mkdir_p!(Path.dirname(manifest_xml)) + + File.write!(manifest_xml, """ + <manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <uses-permission android:name="android.permission.CAMERA"/> + </manifest> + """) + + m = Map.put(@base, :android, %{permissions: ["android.permission.CAMERA"]}) + assert Validator.validate_android_permissions(m, dir) == [] + end + + test "an undeclared permission is flagged", %{dir: dir} do + manifest_xml = Path.join(dir, "priv/native/android/AndroidManifest.xml") + File.mkdir_p!(Path.dirname(manifest_xml)) + + File.write!(manifest_xml, """ + <manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <uses-permission android:name="android.permission.RECORD_AUDIO"/> + </manifest> + """) + + m = Map.put(@base, :android, %{permissions: []}) + errors = Validator.validate_android_permissions(m, dir) + assert Enum.any?(errors, &(&1 =~ "android.permission.RECORD_AUDIO")) + assert Enum.any?(errors, &(&1 =~ "AndroidManifest.xml")) + assert Enum.any?(errors, &(&1 =~ "manifest.android.permissions")) + end + + test "tolerates extra attributes and whitespace in the element", %{dir: dir} do + manifest_xml = Path.join(dir, "priv/native/android/AndroidManifest.xml") + File.mkdir_p!(Path.dirname(manifest_xml)) + + File.write!(manifest_xml, """ + <manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <uses-permission + android:name="android.permission.BLUETOOTH_CONNECT" + android:maxSdkVersion="32"/> + </manifest> + """) + + m = Map.put(@base, :android, %{permissions: []}) + errors = Validator.validate_android_permissions(m, dir) + assert Enum.any?(errors, &(&1 =~ "BLUETOOTH_CONNECT")) + end + + test "scans nested xml files under priv/native/android/", %{dir: dir} do + manifest_xml = Path.join(dir, "priv/native/android/manifest/MyManifest.xml") + File.mkdir_p!(Path.dirname(manifest_xml)) + + File.write!(manifest_xml, """ + <manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <uses-permission android:name="android.permission.INTERNET"/> + </manifest> + """) + + m = Map.put(@base, :android, %{permissions: []}) + errors = Validator.validate_android_permissions(m, dir) + assert Enum.any?(errors, &(&1 =~ "INTERNET")) + end + + test "wired into validate_plugin/3 — undeclared permission bubbles up as an error", + %{dir: dir} do + manifest_xml = Path.join(dir, "priv/native/android/AndroidManifest.xml") + File.mkdir_p!(Path.dirname(manifest_xml)) + + File.write!(manifest_xml, """ + <uses-permission android:name="android.permission.CAMERA"/> + """) + + m = Map.put(@base, :android, %{permissions: []}) + assert %{errors: errs} = Validator.validate_plugin(m, dir, "0.6.20") + assert Enum.any?(errs, &(&1 =~ "CAMERA")) + end + end + + describe "activated_capability_errors/1" do + setup do + root = + Path.join(System.tmp_dir!(), "mob_validator_act_#{System.unique_integer([:positive])}") + + File.mkdir_p!(root) + on_exit(fn -> File.rm_rf!(root) end) + {:ok, root: root} + end + + test "empty list when no plugins are activated", %{root: _root} do + assert Validator.activated_capability_errors([]) == [] + end + + test "ignores tier-0 (nil) manifests", %{root: root} do + assert Validator.activated_capability_errors([{root, nil}]) == [] + end + + test "flags drift across multiple plugins, prefixed by plugin name", %{root: root} do + plugin_a = Path.join(root, "plugin_a") + File.mkdir_p!(Path.join(plugin_a, "priv/native/ios")) + File.write!(Path.join(plugin_a, "priv/native/ios/A.swift"), "import CoreLocation\n") + + manifest_a = + Map.merge(@base, %{ + name: :plugin_a, + ios: %{swift_files: ["priv/native/ios/A.swift"]} + }) + + plugin_b = Path.join(root, "plugin_b") + File.mkdir_p!(Path.join(plugin_b, "priv/native/android")) + + File.write!(Path.join(plugin_b, "priv/native/android/AndroidManifest.xml"), """ + <uses-permission android:name="android.permission.CAMERA"/> + """) + + manifest_b = + Map.merge(@base, %{ + name: :plugin_b, + android: %{permissions: []} + }) + + errors = + Validator.activated_capability_errors([ + {plugin_a, manifest_a}, + {plugin_b, manifest_b} + ]) + + assert Enum.any?(errors, &(&1 =~ "[plugin_a]" and &1 =~ "CoreLocation")) + assert Enum.any?(errors, &(&1 =~ "[plugin_b]" and &1 =~ "CAMERA")) + end + end + + describe "raise_on_capability_drift!/1" do + setup do + root = + Path.join(System.tmp_dir!(), "mob_validator_raise_#{System.unique_integer([:positive])}") + + File.mkdir_p!(root) + on_exit(fn -> File.rm_rf!(root) end) + {:ok, root: root} + end + + test "no-op when no drift is found", %{root: root} do + assert Validator.raise_on_capability_drift!([{root, nil}]) == :ok + assert Validator.raise_on_capability_drift!([]) == :ok + end + + test "raises with a Mix-formatted bullet list when drift is found", %{root: root} do + File.mkdir_p!(Path.join(root, "priv/native/ios")) + File.write!(Path.join(root, "priv/native/ios/X.swift"), "import CoreBluetooth\n") + + manifest = + Map.merge(@base, %{name: :driftling, ios: %{swift_files: ["priv/native/ios/X.swift"]}}) + + # raise_on_capability_drift!/1 now runs the signature gate first. + # Acknowledge the unsigned plugin so the test can exercise the + # capability check it actually targets. + previous = Application.get_env(:mob, :acknowledge_unsafe_plugins, []) + Application.put_env(:mob, :acknowledge_unsafe_plugins, [:driftling]) + on_exit(fn -> Application.put_env(:mob, :acknowledge_unsafe_plugins, previous) end) + + assert_raise Mix.Error, ~r/plugin capability check failed.*CoreBluetooth/s, fn -> + Validator.raise_on_capability_drift!([{root, manifest}]) + end + end + end + + describe "cross_validate/1" do + test "no collisions across distinct plugins" do + a = Map.put(@base, :ui_components, [%{atom: :chart}]) + b = Map.put(@base, :ui_components, [%{atom: :gauge}]) + assert %{errors: []} = Validator.cross_validate([{:a, a}, {:b, b}]) + end + + test "detects a duplicate component atom across plugins" do + a = Map.put(@base, :ui_components, [%{atom: :chart}]) + b = Map.put(@base, :ui_components, [%{atom: :chart}]) + assert %{errors: errs} = Validator.cross_validate([{:a, a}, {:b, b}]) + assert Enum.any?(errs, &(&1 =~ "component atom")) + end + + test "detects a duplicate screen route across plugins" do + a = Map.put(@base, :screens, [%{module: A, default_route: "/x"}]) + b = Map.put(@base, :screens, [%{module: B, default_route: "/x"}]) + assert %{errors: errs} = Validator.cross_validate([{:a, a}, {:b, b}]) + assert Enum.any?(errs, &(&1 =~ "screen route")) + end + + test "detects a duplicate migration repo_namespace" do + a = Map.put(@base, :migrations, %{repo_namespace: Foo, migrations_dir: "x"}) + b = Map.put(@base, :migrations, %{repo_namespace: Foo, migrations_dir: "y"}) + assert %{errors: errs} = Validator.cross_validate([{:a, a}, {:b, b}]) + assert Enum.any?(errs, &(&1 =~ "repo_namespace")) + end + + test "ignores tier-0 (nil) manifests" do + a = Map.put(@base, :ui_components, [%{atom: :chart}]) + assert %{errors: []} = Validator.cross_validate([{:a, a}, {:palette, nil}]) + end + + test "detects a duplicate iOS view_module across plugins with distinct atoms" do + a = Map.put(@base, :ui_components, [%{atom: :chart, ios: %{view_module: "Shared_View"}}]) + b = Map.put(@base, :ui_components, [%{atom: :gauge, ios: %{view_module: "Shared_View"}}]) + assert %{errors: errs} = Validator.cross_validate([{:a, a}, {:b, b}]) + assert Enum.any?(errs, &(&1 =~ "view_module")) + end + + test "detects a duplicate Android composable across plugins with distinct atoms" do + a = Map.put(@base, :ui_components, [%{atom: :chart, android: %{composable: "Shared_View"}}]) + b = Map.put(@base, :ui_components, [%{atom: :gauge, android: %{composable: "Shared_View"}}]) + assert %{errors: errs} = Validator.cross_validate([{:a, a}, {:b, b}]) + assert Enum.any?(errs, &(&1 =~ "composable")) + end + + test "distinct native view keys across plugins do not collide" do + a = Map.put(@base, :ui_components, [%{atom: :chart, ios: %{view_module: "Chart_View"}}]) + b = Map.put(@base, :ui_components, [%{atom: :gauge, ios: %{view_module: "Gauge_View"}}]) + assert %{errors: []} = Validator.cross_validate([{:a, a}, {:b, b}]) + end + + test "detects a duplicate cpp_archive nm_symbol across plugins (distinct modules)" do + a = + Map.put(@base, :nifs, [ + %{module: :a_nif, lang: :cpp_archive, sources: ["a.cpp"], nm_symbol: "shared_init"} + ]) + + b = + Map.put(@base, :nifs, [ + %{module: :b_nif, lang: :cpp_archive, sources: ["b.cpp"], nm_symbol: "shared_init"} + ]) + + assert %{errors: errs} = Validator.cross_validate([{:a, a}, {:b, b}]) + assert Enum.any?(errs, &(&1 =~ "cpp_archive init symbol")) + end + + test "distinct cpp_archive nm_symbols across plugins do not collide" do + a = + Map.put(@base, :nifs, [ + %{module: :a_nif, lang: :cpp_archive, sources: ["a.cpp"], nm_symbol: "a_init"} + ]) + + b = + Map.put(@base, :nifs, [ + %{module: :b_nif, lang: :cpp_archive, sources: ["b.cpp"], nm_symbol: "b_init"} + ]) + + assert %{errors: []} = Validator.cross_validate([{:a, a}, {:b, b}]) + end + end +end diff --git a/test/mob_dev/plugin/verify_test.exs b/test/mob_dev/plugin/verify_test.exs new file mode 100644 index 0000000..3789335 --- /dev/null +++ b/test/mob_dev/plugin/verify_test.exs @@ -0,0 +1,307 @@ +defmodule MobDev.Plugin.VerifyTest do + use ExUnit.Case, async: true + + alias MobDev.Plugin.{Crypto, Manifest, Sign, Verify} + + # Frozen with the v1 signer at 06762494 (before Objective-C entered the hash + # policy). This is deliberately not built through Sign helpers: successful + # verification pins the exact historical ETF payload and legacy extension + # policy independently of current code. + @legacy_v1_pub Base.decode64!("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=") + @legacy_v1_envelope Base.decode64!( + "g3QAAAACdxBlbnZlbG9wZV92ZXJzaW9uYQF3CXNpZ25hdHVyZW0AAABAoje4i24j7SClsYsQ25r/DuzSv+GRxFWaiPZJANA1ajoV/JREZ9TpXeeSqq1oXTETl+19wWPvIy9N96/61k7cDg==" + ) + @legacy_v1_manifest %{ + name: :mob_legacy_fixture, + mob_version: "~> 0.6", + plugin_spec_version: 1, + nifs: [ + %{module: :mob_legacy_fixture_nif, native_dir: "priv/native/ios", lang: :objc} + ] + } + + setup do + dir = + Path.join(System.tmp_dir!(), "mob_verify_test_#{System.unique_integer([:positive])}") + + File.mkdir_p!(Path.join(dir, "priv")) + on_exit(fn -> File.rm_rf!(dir) end) + + manifest = %{ + name: :mob_demo, + mob_version: "~> 0.6", + plugin_spec_version: 1, + ios: %{swift_files: ["ios/Demo.swift"]} + } + + File.write!(Path.join(dir, "priv/mob_plugin.exs"), inspect(manifest, limit: :infinity)) + + swift_path = Path.join(dir, "ios/Demo.swift") + File.mkdir_p!(Path.dirname(swift_path)) + File.write!(swift_path, "import Foundation\n") + + {priv, pub} = Crypto.generate_keypair() + File.write!(Path.join(dir, "priv/mob_plugin.pub"), Base.encode64(pub) <> "\n") + :ok = Sign.sign_plugin(dir, priv) + + {:ok, dir: dir, manifest: manifest, pub: pub, priv: priv} + end + + describe "load_signature/1" do + test "keeps returning the raw 64-byte signature for callers that do not need the version", %{ + dir: dir + } do + assert {:ok, sig} = Verify.load_signature(dir) + assert byte_size(sig) == 64 + end + + test "returns :missing when the sig file is absent", %{dir: dir} do + File.rm!(Sign.signature_path(dir)) + assert {:error, :missing} = Verify.load_signature(dir) + end + + test "returns :corrupt when the sig file has garbage", %{dir: dir} do + File.write!(Sign.signature_path(dir), "garbage") + assert {:error, :corrupt} = Verify.load_signature(dir) + end + + # Regression: the envelope is decoded with binary_to_term(_, [:safe]), which + # will not *create* atoms. The envelope contains :envelope_version, an atom + # Verify must intern at load time (via @envelope_atoms) — otherwise, in any + # BEAM where Sign (the only other interner) hadn't loaded yet, the :safe + # decode raised and a *valid* signature was misreported as :corrupt. That + # load-order dependence made the build signature gate intermittently reject + # good plugins. The true cold-VM repro is cross-process (atoms can't be + # un-interned in a live VM); these guard the fix's mechanism in-process. + # See decisions/2026-05-31-verify-safe-atom-intern.md. + test "interns the envelope atoms at module load (safe-decode guard)" do + assert :signature in Verify.envelope_atoms() + assert :envelope_version in Verify.envelope_atoms() + end + + test "decodes an envelope whose term includes the :envelope_version key", + %{dir: dir} do + raw = File.read!(Sign.signature_path(dir)) + assert %{signature: _, envelope_version: 2} = :erlang.binary_to_term(raw, [:safe]) + assert {:ok, sig} = Verify.load_signature(dir) + assert byte_size(sig) == 64 + end + end + + describe "load_signature_with_version/1" do + test "returns the bounded version alongside the raw signature", %{dir: dir} do + assert {:ok, {2, sig}} = Verify.load_signature_with_version(dir) + assert byte_size(sig) == 64 + end + + test "rejects an envelope with the version stripped", %{dir: dir, manifest: manifest} do + %{signature: signature} = read_envelope!(dir) + write_envelope!(dir, %{signature: signature}) + + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "rejects unknown and non-integer versions", %{dir: dir, manifest: manifest} do + %{signature: signature} = read_envelope!(dir) + + for version <- [0, 3, -1, "2", 2.0, nil] do + write_envelope!(dir, %{signature: signature, envelope_version: version}) + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + + assert {:error, :invalid_signature} = + Verify.verify_plugin_with_version(dir, manifest) + end + end + + test "rejects envelopes with extra keys", %{dir: dir, manifest: manifest} do + envelope = Map.put(read_envelope!(dir), :manifest, %{}) + write_envelope!(dir, envelope) + + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "rejects a bare 64-byte signature file", %{dir: dir, manifest: manifest} do + %{signature: signature} = read_envelope!(dir) + File.write!(Sign.signature_path(dir), signature) + + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "rejects a compressed ETF encoding of an otherwise exact envelope", %{ + dir: dir, + manifest: manifest + } do + envelope = %{read_envelope!(dir) | signature: :binary.copy(<<0>>, 64)} + compressed = :erlang.term_to_binary(envelope, [:deterministic, compressed: 9]) + assert <<131, 80, _::binary>> = compressed + File.write!(Sign.signature_path(dir), compressed) + + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "rejects an oversized envelope after reading only the fixed bound plus one byte", %{ + dir: dir, + manifest: manifest + } do + oversized = File.read!(Sign.signature_path(dir)) <> :binary.copy(<<0>>, 300) + assert byte_size(oversized) > 256 + File.write!(Sign.signature_path(dir), oversized) + + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "rejects malformed bounded envelopes without crashing", %{ + dir: dir, + manifest: manifest + } do + raw = File.read!(Sign.signature_path(dir)) + %{signature: signature} = read_envelope!(dir) + + malformed_envelopes = [ + binary_part(raw, 0, byte_size(raw) - 1), + raw <> "trailing bytes", + Crypto.canonical_encode(%{signature: binary_part(signature, 0, 63), envelope_version: 2}), + Crypto.canonical_encode(%{signature: signature <> <<0>>, envelope_version: 2}) + ] + + for malformed <- malformed_envelopes do + File.write!(Sign.signature_path(dir), malformed) + assert {:error, :corrupt} = Verify.load_signature_with_version(dir) + + assert {:error, :invalid_signature} = + Verify.verify_plugin_with_version(dir, manifest) + end + end + end + + describe "load_pubkey/1" do + test "loads the raw 32-byte public key", %{dir: dir} do + assert {:ok, pub} = Verify.load_pubkey(dir) + assert byte_size(pub) == 32 + end + + test "returns :missing when the pubkey file is absent", %{dir: dir} do + File.rm!(Path.join(dir, "priv/mob_plugin.pub")) + assert {:error, :missing} = Verify.load_pubkey(dir) + end + + test "returns :malformed for non-base64 contents", %{dir: dir} do + File.write!(Path.join(dir, "priv/mob_plugin.pub"), "not base64!@#$\n") + assert {:error, :malformed} = Verify.load_pubkey(dir) + end + + test "returns :malformed when the decoded key is the wrong size", %{dir: dir} do + File.write!(Path.join(dir, "priv/mob_plugin.pub"), Base.encode64(<<1, 2, 3>>) <> "\n") + assert {:error, :malformed} = Verify.load_pubkey(dir) + end + end + + describe "verify_plugin/2" do + test "accepts a freshly-signed plugin", %{dir: dir, manifest: manifest} do + assert :ok = Verify.verify_plugin(dir, manifest) + assert {:ok, 2} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "accepts a frozen shipped v1 envelope against only the exact legacy payload", %{ + dir: dir + } do + c_source = Path.join(dir, "priv/native/ios/demo.c") + objc_source = Path.join(dir, "priv/native/ios/demo.m") + File.mkdir_p!(Path.dirname(c_source)) + File.write!(c_source, "legacy signed c source\n") + File.write!(objc_source, "legacy unsigned objective-c source\n") + File.write!(Path.join(dir, "priv/mob_plugin.pub"), Base.encode64(@legacy_v1_pub) <> "\n") + File.write!(Sign.signature_path(dir), @legacy_v1_envelope) + + assert {:ok, 1} = Verify.verify_plugin_with_version(dir, @legacy_v1_manifest) + assert :ok = Verify.verify_plugin(dir, @legacy_v1_manifest) + + File.write!(objc_source, "changed objective-c source outside the frozen v1 payload") + assert {:ok, 1} = Verify.verify_plugin_with_version(dir, @legacy_v1_manifest) + + File.write!(c_source, "tampered legacy signed c source") + + assert {:error, :invalid_signature} = + Verify.verify_plugin_with_version(dir, @legacy_v1_manifest) + end + + test "rejects changing a valid v2 envelope to v1 without resigning", %{ + dir: dir, + manifest: manifest + } do + envelope = %{read_envelope!(dir) | envelope_version: 1} + write_envelope!(dir, envelope) + + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) + assert {:error, :invalid_signature} = Verify.verify_plugin(dir, manifest) + end + + test "rejects changing a valid v1 envelope to v2 without resigning", %{ + dir: dir, + manifest: manifest, + priv: priv + } do + write_v1_signature!(dir, manifest, priv) + assert {:ok, 1} = Verify.verify_plugin_with_version(dir, manifest) + + envelope = %{read_envelope!(dir) | envelope_version: 2} + write_envelope!(dir, envelope) + + assert {:error, :invalid_signature} = Verify.verify_plugin_with_version(dir, manifest) + end + + test "rejects when a referenced source file is tampered", %{dir: dir, manifest: manifest} do + File.write!(Path.join(dir, "ios/Demo.swift"), "import SwiftUI // EVIL\n") + assert {:error, :invalid_signature} = Verify.verify_plugin(dir, manifest) + end + + test "rejects when the manifest is altered after signing", %{dir: dir, manifest: manifest} do + tampered = put_in(manifest, [:ios, :swift_files], ["ios/Other.swift"]) + assert {:error, :invalid_signature} = Verify.verify_plugin(dir, tampered) + end + + test "rejects when the signature is missing", %{dir: dir, manifest: manifest} do + File.rm!(Sign.signature_path(dir)) + assert {:error, :missing_signature} = Verify.verify_plugin(dir, manifest) + end + + test "rejects when the pubkey is missing", %{dir: dir, manifest: manifest} do + File.rm!(Path.join(dir, "priv/mob_plugin.pub")) + assert {:error, :missing_pubkey} = Verify.verify_plugin(dir, manifest) + end + + test "rejects when the pubkey doesn't match the signing key", %{dir: dir, manifest: manifest} do + {_other_priv, other_pub} = Crypto.generate_keypair() + File.write!(Path.join(dir, "priv/mob_plugin.pub"), Base.encode64(other_pub) <> "\n") + assert {:error, :invalid_signature} = Verify.verify_plugin(dir, manifest) + end + + test "round-trips against the manifest loaded back from disk", %{dir: dir} do + {:ok, loaded} = Manifest.load(dir) + assert :ok = Verify.verify_plugin(dir, loaded) + end + end + + defp write_v1_signature!(dir, manifest, priv) do + file_hashes = Sign.compute_file_hashes(dir, manifest, 1) + payload = Sign.build_payload(manifest, file_hashes, 1) + signature = Crypto.sign(payload, priv) + write_envelope!(dir, %{signature: signature, envelope_version: 1}) + end + + defp read_envelope!(dir) do + dir + |> Sign.signature_path() + |> File.read!() + |> :erlang.binary_to_term([:safe]) + end + + defp write_envelope!(dir, envelope) do + File.write!(Sign.signature_path(dir), Crypto.canonical_encode(envelope)) + end +end diff --git a/test/mob_dev/plugin_test.exs b/test/mob_dev/plugin_test.exs new file mode 100644 index 0000000..93dd5fa --- /dev/null +++ b/test/mob_dev/plugin_test.exs @@ -0,0 +1,25 @@ +defmodule MobDev.PluginTest do + # async: false — mutates global Application env + use ExUnit.Case, async: false + + alias MobDev.Plugin + + @host :mob_dev_plugin_test_host + + describe "host_config/3" do + test "reads a configured key from the host app's env" do + Application.put_env(@host, :ash_domains, [:blog, :auth]) + on_exit(fn -> Application.delete_env(@host, :ash_domains) end) + + assert Plugin.host_config(@host, :ash_domains, []) == [:blog, :auth] + end + + test "returns the supplied default when the key is unset" do + assert Plugin.host_config(@host, :never_set, []) == [] + end + + test "defaults to nil when no default is given" do + assert Plugin.host_config(@host, :never_set) == nil + end + end +end diff --git a/test/mob_dev/python_android_support_test.exs b/test/mob_dev/python_android_support_test.exs new file mode 100644 index 0000000..f737008 --- /dev/null +++ b/test/mob_dev/python_android_support_test.exs @@ -0,0 +1,156 @@ +defmodule MobDev.PythonAndroidSupportTest do + # async: false — these tests mutate the global MOB_CACHE_DIR env var, which + # races other modules that read/write it (python_apple_support, the *_downloader + # tests). Matches the async: false convention of every other MOB_CACHE_DIR test. + use ExUnit.Case, async: false + + alias MobDev.PythonAndroidSupport + + # ── extracted_dir/0 ───────────────────────────────────────────────────────── + + describe "extracted_dir/0" do + test "honors MOB_CACHE_DIR env var" do + System.put_env("MOB_CACHE_DIR", "/tmp/mob_test_cache_android") + + try do + path = PythonAndroidSupport.extracted_dir() + assert String.starts_with?(path, "/tmp/mob_test_cache_android/") + assert String.ends_with?(path, "/extracted") + after + System.delete_env("MOB_CACHE_DIR") + end + end + + test "defaults to ~/.mob/cache when MOB_CACHE_DIR unset" do + System.delete_env("MOB_CACHE_DIR") + home = System.get_env("HOME") + path = PythonAndroidSupport.extracted_dir() + assert String.starts_with?(path, "#{home}/.mob/cache/") + end + end + + # ── valid_dir?/1 ──────────────────────────────────────────────────────────── + + describe "valid_dir?/1" do + @tag :tmp_dir + test "returns false when dir doesn't exist", %{tmp_dir: tmp} do + refute PythonAndroidSupport.valid_dir?(Path.join(tmp, "nonexistent")) + end + + @tag :tmp_dir + test "returns false when only stdlib present (missing arm64 libs)", %{tmp_dir: tmp} do + File.mkdir_p!(Path.join(tmp, "stdlib")) + refute PythonAndroidSupport.valid_dir?(tmp) + end + + @tag :tmp_dir + test "returns false when arm64-v8a libs missing libpython.so", %{tmp_dir: tmp} do + File.mkdir_p!(Path.join([tmp, "arm64-v8a", "jniLibs", "arm64-v8a"])) + File.mkdir_p!(Path.join(tmp, "stdlib")) + refute PythonAndroidSupport.valid_dir?(tmp) + end + + @tag :tmp_dir + test "returns true when full bundle present", %{tmp_dir: tmp} do + stub_full_bundle(tmp) + assert PythonAndroidSupport.valid_dir?(tmp) + end + end + + # ── libpython_path/2 ─────────────────────────────────────────────────────── + + describe "libpython_path/2" do + test "arm64-v8a uses jniLibs/arm64-v8a/libpython3.13.so" do + assert PythonAndroidSupport.libpython_path("/tmp/x", "arm64-v8a") == + "/tmp/x/arm64-v8a/jniLibs/arm64-v8a/libpython3.13.so" + end + + test "x86_64 uses jniLibs/x86_64" do + assert PythonAndroidSupport.libpython_path("/tmp/x", "x86_64") == + "/tmp/x/x86_64/jniLibs/x86_64/libpython3.13.so" + end + end + + # ── jni_libs_dir/2 ────────────────────────────────────────────────────────── + + describe "jni_libs_dir/2" do + test "arm64-v8a points at the per-abi jniLibs subtree" do + assert PythonAndroidSupport.jni_libs_dir("/tmp/x", "arm64-v8a") == + "/tmp/x/arm64-v8a/jniLibs/arm64-v8a" + end + end + + # ── lib_dynload_dir/2 ─────────────────────────────────────────────────────── + + describe "lib_dynload_dir/2" do + test "arm64-v8a points at the per-abi lib-dynload subtree" do + assert PythonAndroidSupport.lib_dynload_dir("/tmp/x", "arm64-v8a") == + "/tmp/x/arm64-v8a/lib-dynload/arm64-v8a" + end + end + + # ── stdlib_dir/1 ──────────────────────────────────────────────────────────── + + describe "stdlib_dir/1" do + test "shared across abis (no per-arch suffix)" do + assert PythonAndroidSupport.stdlib_dir("/tmp/x") == "/tmp/x/stdlib" + end + end + + # ── headers_dir/2 ─────────────────────────────────────────────────────────── + + describe "headers_dir/2" do + test "arm64-v8a points at include/python3.13" do + assert PythonAndroidSupport.headers_dir("/tmp/x", "arm64-v8a") == + "/tmp/x/arm64-v8a/include/python3.13" + end + end + + # ── download_url/1 + per-abi tarball naming ──────────────────────────────── + + describe "download_url/1" do + test "arm64-v8a points at chaquopy's Maven release" do + url = PythonAndroidSupport.download_url("arm64-v8a") + + assert String.starts_with?( + url, + "https://repo1.maven.org/maven2/com/chaquo/python/target/" + ) + + assert String.ends_with?(url, "-arm64-v8a.zip") + end + + test "stdlib uses the stdlib variant" do + assert PythonAndroidSupport.download_url("stdlib") =~ "-stdlib.zip" + end + + test "x86_64 emulator slice has its own URL" do + assert PythonAndroidSupport.download_url("x86_64") =~ "-x86_64.zip" + end + end + + describe "tarball_name/1" do + test "arm64-v8a follows chaquopy's pattern" do + assert Regex.match?( + ~r/^target-3\.13\.\d+-\d+-arm64-v8a\.zip$/, + PythonAndroidSupport.tarball_name("arm64-v8a") + ) + end + end + + # ── helpers ───────────────────────────────────────────────────────────────── + + defp stub_full_bundle(tmp) do + for abi <- ["arm64-v8a", "x86_64"] do + base = Path.join(tmp, abi) + File.mkdir_p!(Path.join([base, "jniLibs", abi])) + File.write!(Path.join([base, "jniLibs", abi, "libpython3.13.so"]), <<>>) + File.mkdir_p!(Path.join([base, "lib-dynload", abi])) + File.mkdir_p!(Path.join([base, "include", "python3.13"])) + end + + stdlib = Path.join(tmp, "stdlib") + File.mkdir_p!(stdlib) + File.write!(Path.join(stdlib, "os.py"), <<>>) + end +end diff --git a/test/mob_dev/python_apple_support_test.exs b/test/mob_dev/python_apple_support_test.exs new file mode 100644 index 0000000..3000c7c --- /dev/null +++ b/test/mob_dev/python_apple_support_test.exs @@ -0,0 +1,142 @@ +defmodule MobDev.PythonAppleSupportTest do + # async: false — these tests mutate the global MOB_CACHE_DIR env var, which + # races other modules that read/write it (python_android_support, the *_downloader + # tests). Matches the async: false convention of every other MOB_CACHE_DIR test. + use ExUnit.Case, async: false + + alias MobDev.PythonAppleSupport + + # ── extracted_dir/0, cache_dir/0 ──────────────────────────────────────────── + + describe "extracted_dir/0" do + test "honors MOB_CACHE_DIR env var" do + System.put_env("MOB_CACHE_DIR", "/tmp/mob_test_cache") + + try do + path = PythonAppleSupport.extracted_dir() + assert String.starts_with?(path, "/tmp/mob_test_cache/") + assert String.ends_with?(path, "/extracted") + after + System.delete_env("MOB_CACHE_DIR") + end + end + + test "defaults to ~/.mob/cache when MOB_CACHE_DIR unset" do + System.delete_env("MOB_CACHE_DIR") + home = System.get_env("HOME") + path = PythonAppleSupport.extracted_dir() + assert String.starts_with?(path, "#{home}/.mob/cache/") + end + end + + # ── valid_dir?/1 ──────────────────────────────────────────────────────────── + + describe "valid_dir?/1" do + @tag :tmp_dir + test "returns false when dir doesn't exist", %{tmp_dir: tmp} do + refute PythonAppleSupport.valid_dir?(Path.join(tmp, "nonexistent")) + end + + @tag :tmp_dir + test "returns false when xcframework missing", %{tmp_dir: tmp} do + refute PythonAppleSupport.valid_dir?(tmp) + end + + @tag :tmp_dir + test "returns false when only sim slice present", %{tmp_dir: tmp} do + File.mkdir_p!(Path.join([tmp, "Python.xcframework", "ios-arm64_x86_64-simulator"])) + refute PythonAppleSupport.valid_dir?(tmp) + end + + @tag :tmp_dir + test "returns false when device + sim present but stdlib missing", %{tmp_dir: tmp} do + File.mkdir_p!(Path.join([tmp, "Python.xcframework", "ios-arm64"])) + File.mkdir_p!(Path.join([tmp, "Python.xcframework", "ios-arm64_x86_64-simulator"])) + refute PythonAppleSupport.valid_dir?(tmp) + end + + @tag :tmp_dir + test "returns true when full bundle present", %{tmp_dir: tmp} do + stub_full_bundle(tmp) + assert PythonAppleSupport.valid_dir?(tmp) + end + end + + # ── framework_path/1 ──────────────────────────────────────────────────────── + + describe "framework_path/1" do + test "ios_device slice uses ios-arm64" do + assert PythonAppleSupport.framework_path("/tmp/x", :ios_device) == + "/tmp/x/Python.xcframework/ios-arm64/Python.framework" + end + + test "ios_simulator slice uses ios-arm64_x86_64-simulator" do + assert PythonAppleSupport.framework_path("/tmp/x", :ios_simulator) == + "/tmp/x/Python.xcframework/ios-arm64_x86_64-simulator/Python.framework" + end + end + + # ── stdlib_path/1 ─────────────────────────────────────────────────────────── + + describe "stdlib_path/1" do + test "returns shared stdlib at lib/python<version>" do + assert PythonAppleSupport.stdlib_path("/tmp/x") == + "/tmp/x/Python.xcframework/lib/python3.13" + end + end + + # ── lib_dynload_path/2 ────────────────────────────────────────────────────── + + describe "lib_dynload_path/2" do + test "ios_device slice uses lib-arm64 under ios-arm64/" do + assert PythonAppleSupport.lib_dynload_path("/tmp/x", :ios_device) == + "/tmp/x/Python.xcframework/ios-arm64/lib-arm64/python3.13/lib-dynload" + end + + test "ios_simulator slice uses lib-arm64 under ios-arm64_x86_64-simulator/" do + assert PythonAppleSupport.lib_dynload_path("/tmp/x", :ios_simulator) == + "/tmp/x/Python.xcframework/ios-arm64_x86_64-simulator/lib-arm64/python3.13/lib-dynload" + end + end + + # ── download_url/0, tarball_name/0 ────────────────────────────────────────── + + describe "download_url/0" do + test "points at BeeWare's GitHub release for the pinned tag" do + url = PythonAppleSupport.download_url() + + assert String.starts_with?( + url, + "https://github.com/beeware/Python-Apple-support/releases/download/" + ) + + assert String.ends_with?(url, ".tar.gz") + end + end + + describe "tarball_name/0" do + test "matches BeeWare's naming convention Python-X.Y-iOS-support.bN.tar.gz" do + assert Regex.match?( + ~r/^Python-3\.13-iOS-support\.b\d+\.tar\.gz$/, + PythonAppleSupport.tarball_name() + ) + end + end + + # ── helpers ───────────────────────────────────────────────────────────────── + + defp stub_full_bundle(tmp) do + base = Path.join(tmp, "Python.xcframework") + File.mkdir_p!(Path.join([base, "ios-arm64", "Python.framework"])) + File.mkdir_p!(Path.join([base, "ios-arm64", "lib-arm64", "python3.13", "lib-dynload"])) + File.mkdir_p!(Path.join([base, "ios-arm64_x86_64-simulator", "Python.framework"])) + + File.mkdir_p!( + Path.join([base, "ios-arm64_x86_64-simulator", "lib-arm64", "python3.13", "lib-dynload"]) + ) + + File.mkdir_p!(Path.join([base, "lib", "python3.13"])) + # Stub a few stdlib files so the dir isn't empty. + File.write!(Path.join([base, "lib", "python3.13", "os.py"]), "") + end +end diff --git a/test/mob_dev/release/errors_test.exs b/test/mob_dev/release/errors_test.exs new file mode 100644 index 0000000..cfc22bb --- /dev/null +++ b/test/mob_dev/release/errors_test.exs @@ -0,0 +1,121 @@ +defmodule MobDev.Release.ErrorsTest do + use ExUnit.Case, async: true + + alias MobDev.Release.Errors + + describe "constructors" do + test "precondition/1 builds a precondition_failed tag" do + assert Errors.precondition("OTP_SRC missing") == + {:error, {:precondition_failed, "OTP_SRC missing"}} + end + + test "cmd_failed/3 packages cmd + exit + output" do + assert {:error, {:cmd_failed, %{cmd: ["clang", "-c"], exit: 1, output: "oops\n"}}} = + Errors.cmd_failed(["clang", "-c"], 1, "oops\n") + end + + test "cmd_failed/3 truncates oversize output" do + huge = String.duplicate("x", 10_000) + {:error, {:cmd_failed, %{output: truncated}}} = Errors.cmd_failed(["foo"], 1, huge) + + assert byte_size(truncated) < byte_size(huge) + assert truncated =~ "truncated, full output was 10000 bytes" + end + + test "parse_failed/2 captures input + expected" do + assert {:error, {:parse_failed, %{input: "VSN=", expected: "VSN = <version>"}}} = + Errors.parse_failed("VSN=", "VSN = <version>") + end + + test "fs_failed/2 captures path + posix atom" do + assert {:error, {:fs_failed, %{path: "/tmp/nope", reason: :enoent}}} = + Errors.fs_failed("/tmp/nope", :enoent) + end + + test "infra_unreachable/1 wraps an opaque detail" do + assert Errors.infra_unreachable(503) == {:error, {:infra_unreachable, 503}} + end + + test "auth_required/1 carries a hint" do + assert Errors.auth_required("run gh auth login") == + {:error, {:auth_required, "run gh auth login"}} + end + end + + describe "format/1 produces actionable strings" do + # The point of the format/1 contract is "the caller can paste this + # into Mix.raise/1 and the developer knows what to do next." These + # tests pin that contract. + + test "precondition_failed prefixes with 'precondition failed —'" do + assert Errors.format({:error, {:precondition_failed, "no NDK"}}) == + "precondition failed — no NDK" + end + + test "cmd_failed includes the argv + exit + output" do + err = Errors.cmd_failed(["clang", "-c", "x.c"], 1, "fatal: 'x.c' missing\n") + out = Errors.format(err) + + assert out =~ "command failed (exit 1)" + assert out =~ "clang -c x.c" + assert out =~ "fatal: 'x.c' missing" + end + + test "parse_failed names the expected shape" do + err = Errors.parse_failed("VSN=", "a line of the form `VSN = <version>`") + out = Errors.format(err) + + assert out =~ "parse failed" + assert out =~ "VSN = <version>" + end + + test "fs_failed includes the posix reason verbatim" do + err = Errors.fs_failed("/tmp/nope", :eacces) + assert Errors.format(err) == "filesystem error at /tmp/nope: eacces" + end + + test "infra_unreachable inspects the detail (HTTP code / transport tuple / etc)" do + out = Errors.format(Errors.infra_unreachable({:http, 503, "Service Unavailable"})) + + assert out =~ "external infrastructure unreachable" + assert out =~ "503" + end + + test "auth_required prefixes with 'authentication required —'" do + assert Errors.format(Errors.auth_required("run gh auth login")) == + "authentication required — run gh auth login" + end + end + + describe "pattern matching at the call site" do + # The whole point of tagged categories is so the Mix task can do a + # `case` over the category and produce different remediation + # advice. Lock down that the categories actually pattern-match. + + test "categories are atoms, suitable for `case` matching" do + errors = [ + Errors.precondition("x"), + Errors.cmd_failed(["x"], 1, ""), + Errors.parse_failed("x", "y"), + Errors.fs_failed("/x", :enoent), + Errors.infra_unreachable(:ok), + Errors.auth_required("x") + ] + + categories = + for {:error, {cat, _}} <- errors do + cat + end + + assert categories == + [ + :precondition_failed, + :cmd_failed, + :parse_failed, + :fs_failed, + :infra_unreachable, + :auth_required + ] + end + end +end diff --git a/test/mob_dev/release/helpers_test.exs b/test/mob_dev/release/helpers_test.exs new file mode 100644 index 0000000..4960eb6 --- /dev/null +++ b/test/mob_dev/release/helpers_test.exs @@ -0,0 +1,330 @@ +defmodule MobDev.Release.HelpersTest do + use ExUnit.Case, async: false + # async: false because some tests poke env vars + cwd. The cost of one + # serial test module is small; the safety of not racing other modules + # on shared env state is worth it. + + alias MobDev.Release.Helpers + + # ── parse_git_hash (pure) ────────────────────────────────────────────── + + describe "parse_git_hash/1" do + test "trims trailing newline and returns 8-char hash" do + assert Helpers.parse_git_hash("abcdef12\n") == {:ok, "abcdef12"} + end + + test "trims surrounding whitespace" do + assert Helpers.parse_git_hash(" abcdef12\n\n") == {:ok, "abcdef12"} + end + + test "rejects 7-char (git's pre-pinned default — exactly the drift we want to catch)" do + assert {:error, {:parse_failed, _}} = Helpers.parse_git_hash("abcdef1\n") + end + + test "rejects 10-char (git's modern collision-grown default)" do + assert {:error, {:parse_failed, _}} = Helpers.parse_git_hash("abcdef1234\n") + end + + test "rejects non-hex content" do + assert {:error, {:parse_failed, _}} = Helpers.parse_git_hash("not-a-hash\n") + end + + test "rejects empty input" do + assert {:error, {:parse_failed, _}} = Helpers.parse_git_hash("") + end + end + + # ── parse_erts_version (pure) ────────────────────────────────────────── + + describe "parse_erts_version/1" do + test "extracts version from canonical erts/vsn.mk content" do + content = """ + # ERTS version. Bumped when erlang/otp gets a new ERTS. + VSN = 17.0 + """ + + assert Helpers.parse_erts_version(content) == {:ok, "17.0"} + end + + test "tolerates tabs around the `=`" do + assert Helpers.parse_erts_version("VSN\t=\t16.3\n") == {:ok, "16.3"} + end + + test "tolerates leading whitespace on the line" do + assert Helpers.parse_erts_version(" VSN = 17.0\n") == {:ok, "17.0"} + end + + test "extracts only the first match if multiple appear" do + # Defensive — vsn.mk historically only had one VSN line, but if + # OTP ever adds e.g. SUBVSN we want to pick the canonical one. + assert Helpers.parse_erts_version("VSN = 17.0\nSUBVSN = 0.1\n") == {:ok, "17.0"} + end + + test "returns parse_failed when no VSN= line is present" do + assert {:error, {:parse_failed, _}} = Helpers.parse_erts_version("# no version here\n") + end + + test "returns parse_failed for empty content" do + assert {:error, {:parse_failed, _}} = Helpers.parse_erts_version("") + end + end + + # ── git_hash (side-effecting; uses a tmpdir git repo) ───────────────── + + describe "git_hash/1 with a real git fixture" do + setup do + tmp = mk_tmpdir("git_hash") + + # Sanitize the ambient git environment. When the suite runs from inside a + # git hook (e.g. `.githooks/pre-push`), git exports GIT_DIR / GIT_WORK_TREE + # / GIT_INDEX_FILE / … into the environment; the fixture's git commands + # would inherit them and operate on the *outer* repo instead of `tmp` + # (`git add` matches nothing → `git commit` exits non-zero → setup crashes). + # nil removes the var for the child process. The stable author/committer + # identity keeps the commit reproducible within a run (we assert the hash + # is 8-char hex, not a pinned value). + git_env = [ + {"GIT_DIR", nil}, + {"GIT_WORK_TREE", nil}, + {"GIT_INDEX_FILE", nil}, + {"GIT_OBJECT_DIRECTORY", nil}, + {"GIT_COMMON_DIR", nil}, + {"GIT_PREFIX", nil}, + {"GIT_AUTHOR_NAME", "test"}, + {"GIT_AUTHOR_EMAIL", "test@example.com"}, + {"GIT_COMMITTER_NAME", "test"}, + {"GIT_COMMITTER_EMAIL", "test@example.com"} + ] + + File.mkdir_p!(tmp) + {_, 0} = System.cmd("git", ["init", "--quiet", "-b", "main", tmp], env: git_env) + File.write!(Path.join(tmp, "vsn.mk"), "VSN = 17.0\n") + {_, 0} = System.cmd("git", ["-C", tmp, "add", "vsn.mk"], env: git_env) + + {_, 0} = + System.cmd("git", ["-C", tmp, "commit", "-m", "init", "--no-gpg-sign", "--quiet"], + env: git_env + ) + + on_exit(fn -> File.rm_rf!(tmp) end) + %{tmp: tmp} + end + + test "returns an 8-char hex hash for a valid checkout", %{tmp: tmp} do + assert {:ok, hash} = Helpers.git_hash(tmp) + assert String.length(hash) == 8 + assert Regex.match?(~r/^[0-9a-f]{8}$/, hash) + end + + test "returns precondition_failed when path is not a git repo" do + tmp = mk_tmpdir("git_hash_notgit") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + + assert {:error, {:precondition_failed, msg}} = Helpers.git_hash(tmp) + assert msg =~ "not a git checkout" + end + end + + # ── erts_version (side-effecting; uses a tmpdir fixture) ────────────── + + describe "erts_version/1 with a real erts/vsn.mk fixture" do + test "reads VSN from a real vsn.mk on disk" do + tmp = mk_tmpdir("erts_vsn") + File.mkdir_p!(Path.join(tmp, "erts")) + File.write!(Path.join([tmp, "erts", "vsn.mk"]), "VSN = 17.0\n") + on_exit(fn -> File.rm_rf!(tmp) end) + + assert Helpers.erts_version(tmp) == {:ok, "17.0"} + end + + test "returns fs_failed when vsn.mk is missing" do + tmp = mk_tmpdir("erts_vsn_missing") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + + assert {:error, {:fs_failed, %{reason: :enoent}}} = Helpers.erts_version(tmp) + end + + test "returns parse_failed when vsn.mk content is malformed" do + tmp = mk_tmpdir("erts_vsn_malformed") + File.mkdir_p!(Path.join(tmp, "erts")) + File.write!(Path.join([tmp, "erts", "vsn.mk"]), "no version line here\n") + on_exit(fn -> File.rm_rf!(tmp) end) + + assert {:error, {:parse_failed, _}} = Helpers.erts_version(tmp) + end + end + + # ── elixir_lib_dir (calls into BEAM internals; unmocked) ────────────── + + describe "elixir_lib_dir/0" do + test "returns a path whose elixir/ ebin lives at the expected place" do + assert {:ok, parent} = Helpers.elixir_lib_dir() + assert File.dir?(Path.join([parent, "elixir", "ebin"])) + end + end + + # ── bundle_elixir_stdlib (filesystem fixture) ───────────────────────── + + describe "bundle_elixir_stdlib/2" do + setup do + stage = mk_tmpdir("stage") + lib = mk_tmpdir("elixir_lib") + + # Build a fake host-elixir lib layout: elixir/ebin, logger/ebin, + # eex/ebin, each with a stub .beam file we can verify got copied. + for app <- ~w(elixir logger eex) do + ebin = Path.join([lib, app, "ebin"]) + File.mkdir_p!(ebin) + File.write!(Path.join(ebin, "#{app}.beam"), "FAKE-BEAM-#{app}") + end + + on_exit(fn -> + File.rm_rf!(stage) + File.rm_rf!(lib) + end) + + %{stage: stage, lib: lib} + end + + test "copies elixir + logger + eex ebins into stage/lib/<app>/ebin", %{stage: stage, lib: lib} do + assert {:ok, dirs} = Helpers.bundle_elixir_stdlib(stage, lib) + + assert length(dirs) == 3 + + for app <- ~w(elixir logger eex) do + dst = Path.join([stage, "lib", app, "ebin"]) + assert File.dir?(dst), "expected #{dst} to exist" + beam = Path.join(dst, "#{app}.beam") + assert File.read!(beam) == "FAKE-BEAM-#{app}" + end + end + + test "returns fs_failed if elixir lib dir doesn't exist" do + assert {:error, {:fs_failed, %{reason: :enoent}}} = + Helpers.bundle_elixir_stdlib("/tmp/whatever", "/tmp/nonexistent_lib_xyz") + end + + test "returns fs_failed if an expected sub-app is missing", %{stage: stage, lib: lib} do + # Remove logger from the lib — bundle should fail loudly with the + # missing path identified. + File.rm_rf!(Path.join(lib, "logger")) + + assert {:error, {:fs_failed, %{path: path, reason: :enoent}}} = + Helpers.bundle_elixir_stdlib(stage, lib) + + assert path =~ "logger" + end + end + + # ── default_otp_src / default_out_dir (env-aware) ───────────────────── + + describe "default_otp_src/0" do + test "respects OTP_SRC env var when set" do + System.put_env("OTP_SRC", "/custom/otp/path") + + try do + assert Helpers.default_otp_src() == "/custom/otp/path" + after + System.delete_env("OTP_SRC") + end + end + + test "falls back to ~/code/otp when OTP_SRC is unset" do + System.delete_env("OTP_SRC") + expected = Path.join(System.user_home!(), "code/otp") + assert Helpers.default_otp_src() == expected + end + end + + describe "default_out_dir/0" do + test "respects OUT_DIR env var when set" do + System.put_env("OUT_DIR", "/some/out") + + try do + assert Helpers.default_out_dir() == "/some/out" + after + System.delete_env("OUT_DIR") + end + end + + test "falls back to /tmp when unset" do + System.delete_env("OUT_DIR") + assert Helpers.default_out_dir() == "/tmp" + end + end + + # ── resolve_release_env (composite) ─────────────────────────────────── + + describe "resolve_release_env/1" do + setup do + tmp = mk_tmpdir("resolve") + File.mkdir_p!(Path.join(tmp, "erts")) + File.write!(Path.join([tmp, "erts", "vsn.mk"]), "VSN = 17.0\n") + + # Pre-supply HASH so we don't depend on a real git repo here. + System.put_env("HASH", "deadbeef") + + on_exit(fn -> + File.rm_rf!(tmp) + System.delete_env("HASH") + System.delete_env("OTP_SRC") + System.delete_env("OUT_DIR") + System.delete_env("ERTS_VSN") + end) + + %{tmp: tmp} + end + + test "honours explicit opts over env over defaults", %{tmp: tmp} do + assert {:ok, env} = + Helpers.resolve_release_env( + otp_src: tmp, + hash: "feedface", + erts_vsn: "17.99", + out_dir: "/some/out" + ) + + assert env.otp_src == tmp + assert env.hash == "feedface" + assert env.erts_vsn == "17.99" + assert env.out_dir == "/some/out" + # elixir_lib is resolved from `:code.lib_dir(:elixir)`; we don't + # pin the absolute path (varies per machine) but we do verify + # it's a real directory containing elixir/ebin — the contract + # downstream tarball_*.sh depends on. + assert File.dir?(Path.join([env.elixir_lib, "elixir", "ebin"])) + end + + test "falls back to env vars when opts are unset", %{tmp: tmp} do + System.put_env("OTP_SRC", tmp) + System.put_env("ERTS_VSN", "16.3") + System.put_env("OUT_DIR", "/some/other/out") + + assert {:ok, env} = Helpers.resolve_release_env() + assert env.otp_src == tmp + assert env.hash == "deadbeef" + assert env.erts_vsn == "16.3" + assert env.out_dir == "/some/other/out" + end + + test "propagates the first error encountered" do + System.delete_env("HASH") + # Point at a directory with no git + no erts/vsn.mk — git_hash + # should fail first. + tmp = mk_tmpdir("resolve_fail") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + + assert {:error, {:precondition_failed, msg}} = Helpers.resolve_release_env(otp_src: tmp) + assert msg =~ "not a git checkout" + end + end + + # ── Helpers ───────────────────────────────────────────────────────────── + + defp mk_tmpdir(label) do + Path.join(System.tmp_dir!(), "mob_dev_release_#{label}_#{System.unique_integer([:positive])}") + end +end diff --git a/test/mob_dev/release/openssl/crypto_nif_test.exs b/test/mob_dev/release/openssl/crypto_nif_test.exs new file mode 100644 index 0000000..4934fa5 --- /dev/null +++ b/test/mob_dev/release/openssl/crypto_nif_test.exs @@ -0,0 +1,455 @@ +defmodule MobDev.Release.OpenSSL.CryptoNifTest do + use ExUnit.Case, async: false + + import Mox + + alias MobDev.Release.{Errors, OpenSSL} + alias OpenSSL.CryptoNif + + setup :verify_on_exit! + + setup do + Application.put_env(:mob_dev, :release_shell, MobDev.Release.ShellMock) + on_exit(fn -> Application.delete_env(:mob_dev, :release_shell) end) + :ok + end + + # ── Source list — surface lock ─────────────────────────────────────── + # Adding or removing a source from `@sources` is a deliberate decision + # that must show up in a code review. + + describe "sources/0" do + test "includes the 31 crypto NIF C files we ship" do + srcs = CryptoNif.sources() + assert length(srcs) == 31 + + # Spot-check representative entries from each crypto family. + assert "aes.c" in srcs + assert "rsa.c" in srcs + assert "ec.c" in srcs + assert "hmac.c" in srcs + assert "evp.c" in srcs + + # otp_test_engine.c is intentionally excluded (test fixture, not + # for shipping). + refute "otp_test_engine.c" in srcs + end + + test "all entries are .c files" do + assert Enum.all?(CryptoNif.sources(), &String.ends_with?(&1, ".c")) + end + end + + # ── target_spec/1 — pinned surface per target ──────────────────────── + + describe "target_spec/1" do + test "android_arm64 — aarch64 arch dir, Android hardening, ELF symbol" do + spec = CryptoNif.target_spec(:android_arm64) + + assert spec.arch_dir == "aarch64-unknown-linux-android" + assert spec.nm_symbol == "crypto_nif_init" + assert "-mbranch-protection=standard" in spec.extra_cflags + assert "-fstack-clash-protection" in spec.extra_cflags + assert "-D_GNU_SOURCE" in spec.extra_cflags + + # arm32-specific ABI flags should NOT be in arm64 + refute "-march=armv7-a" in spec.extra_cflags + end + + test "android_arm32 — ABI flags AND Android hardening" do + spec = CryptoNif.target_spec(:android_arm32) + + assert spec.arch_dir == "arm-unknown-linux-androideabi" + assert spec.nm_symbol == "crypto_nif_init" + + # arm32-specific ABI flags + assert "-march=armv7-a" in spec.extra_cflags + assert "-mfloat-abi=softfp" in spec.extra_cflags + assert "-mthumb" in spec.extra_cflags + + # Android hardening still applies + assert "-mbranch-protection=standard" in spec.extra_cflags + assert "-D_GNU_SOURCE" in spec.extra_cflags + end + + test "ios_sim — Mach-O symbol with leading underscore, no Android flags" do + spec = CryptoNif.target_spec(:ios_sim) + + assert spec.arch_dir == "aarch64-apple-iossimulator" + assert spec.nm_symbol == "_crypto_nif_init" + # iOS does NOT get Android hardening flags + assert spec.extra_cflags == [] + end + + test "ios_device — distinct from sim (different arch_dir)" do + sim = CryptoNif.target_spec(:ios_sim) + device = CryptoNif.target_spec(:ios_device) + + assert sim.arch_dir != device.arch_dir + assert device.arch_dir == "aarch64-apple-ios" + assert device.nm_symbol == "_crypto_nif_init" + end + + test "targets/0 enumerates all four" do + assert CryptoNif.targets() == [:android_arm64, :android_arm32, :ios_sim, :ios_device] + end + end + + # ── cflags/3 — pure assembly ──────────────────────────────────────── + + describe "cflags/3" do + test "every target starts with the same base CFLAGS" do + base = CryptoNif.base_cflags() + + for target_id <- CryptoNif.targets() do + spec = CryptoNif.target_spec(target_id) + flags = CryptoNif.cflags(spec, "/openssl/prefix", "/otp/src") + + # Base flags appear before extras (order-sensitive). + for base_flag <- base do + assert base_flag in flags, "target #{target_id} missing base flag #{base_flag}" + end + end + end + + test "android targets include the Android hardening flags" do + for target_id <- [:android_arm64, :android_arm32] do + spec = CryptoNif.target_spec(target_id) + flags = CryptoNif.cflags(spec, "/openssl/prefix", "/otp/src") + + assert "-mbranch-protection=standard" in flags, "#{target_id} missing branch-protection" + assert "-D_GNU_SOURCE" in flags, "#{target_id} missing _GNU_SOURCE" + end + end + + test "iOS targets do NOT include Android hardening flags" do + for target_id <- [:ios_sim, :ios_device] do + spec = CryptoNif.target_spec(target_id) + flags = CryptoNif.cflags(spec, "/openssl/prefix", "/otp/src") + + refute "-mbranch-protection=standard" in flags + refute "-D_GNU_SOURCE" in flags + refute "-fstack-clash-protection" in flags + end + end + + test "STATIC_ERLANG_NIF is defined on every target" do + # This define is what makes `ERL_NIF_INIT(crypto, ...)` emit + # `crypto_nif_init` as a static symbol instead of `nif_init`. + # Dropping it would break the entire static-link approach. + for target_id <- CryptoNif.targets() do + spec = CryptoNif.target_spec(target_id) + flags = CryptoNif.cflags(spec, "/openssl/prefix", "/otp/src") + assert "-DSTATIC_ERLANG_NIF" in flags + end + end + + test "include paths reference the target's arch_dir" do + spec = CryptoNif.target_spec(:android_arm64) + flags = CryptoNif.cflags(spec, "/openssl/prefix", "/otp/src") + + assert "-I/otp/src/erts/include/aarch64-unknown-linux-android" in flags + assert "-I/otp/src/erts/include/internal/aarch64-unknown-linux-android" in flags + end + + test "includes OpenSSL prefix" do + spec = CryptoNif.target_spec(:ios_sim) + flags = CryptoNif.cflags(spec, "/custom/openssl", "/otp/src") + assert "-I/custom/openssl/include" in flags + end + + test "arm32 emits -march=armv7-a BEFORE Android hardening flags" do + spec = CryptoNif.target_spec(:android_arm32) + flags = CryptoNif.cflags(spec, "/openssl/prefix", "/otp/src") + + assert march_idx = Enum.find_index(flags, &(&1 == "-march=armv7-a")) + assert branch_idx = Enum.find_index(flags, &(&1 == "-mbranch-protection=standard")) + assert march_idx < branch_idx + end + end + + # ── check_symbol_present/3 — pure nm output parser ─────────────────── + + describe "check_symbol_present/3" do + test "accepts ELF nm output with the symbol" do + output = """ + 0000000000000000 T crypto_nif_init + 0000000000000018 T some_other_symbol + """ + + assert :ok = CryptoNif.check_symbol_present(output, "crypto_nif_init", "/path/crypto.a") + end + + test "accepts Mach-O nm output (leading underscore)" do + output = "0000000000000000 T _crypto_nif_init\n" + + assert :ok = CryptoNif.check_symbol_present(output, "_crypto_nif_init", "/path/crypto.a") + end + + test "rejects when symbol is undefined (U flag, not T)" do + output = " U crypto_nif_init\n" + + assert {:error, {:precondition_failed, msg}} = + CryptoNif.check_symbol_present(output, "crypto_nif_init", "/p/crypto.a") + + assert msg =~ "T crypto_nif_init" + assert msg =~ "STATIC_ERLANG_NIF" + end + + test "rejects when symbol is missing entirely" do + output = """ + 0000000000000000 T some_other_init + """ + + assert {:error, {:precondition_failed, _}} = + CryptoNif.check_symbol_present(output, "crypto_nif_init", "/p/crypto.a") + end + + test "doesn't false-match a prefix (crypto_nif_init_v2 should fail for crypto_nif_init)" do + # The whole point of pinning the regex anchor is to avoid this + # class of silent pass. + output = """ + 0000000000000000 T crypto_nif_init_v2 + 0000000000000020 T some_other_symbol + """ + + assert {:error, {:precondition_failed, _}} = + CryptoNif.check_symbol_present(output, "crypto_nif_init", "/p/crypto.a") + end + + test "doesn't false-match a leading-substring suffix" do + # The C-level symbol `_crypto_nif_init` should NOT match a + # search for `crypto_nif_init` (with `_` as a literal prefix). + output = "0000000000000000 T _crypto_nif_init\n" + + assert {:error, {:precondition_failed, _}} = + CryptoNif.check_symbol_present(output, "crypto_nif_init", "/p/crypto.a") + end + end + + # ── build/2 against the Mox ────────────────────────────────────────── + # 30 compile calls + 1 ar + 1 ranlib + 1 nm — heavy ceremony, so we + # only test a few representative targets exhaustively. + + describe "build/2 — android_arm64 full sequence" do + test "compiles each source with NDK clang, archives, verifies symbol" do + stub_all_dir_checks_true() + + # 31 compile calls — one per source file. + Mox.expect(MobDev.Release.ShellMock, :mkdir_p, 2, fn _ -> :ok end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, 31, fn argv, _opts -> + # CC is the NDK toolchain clang. The exact path is interpolated + # off the NDK root override we pass below. + assert hd(argv) =~ "aarch64-linux-android24-clang" + assert "-c" in argv + assert "-DSTATIC_ERLANG_NIF" in argv + assert "-mbranch-protection=standard" in argv + {:ok, ""} + end) + + # rm_f the archive before re-archiving (idempotent rebuild). + Mox.expect(MobDev.Release.ShellMock, :rm_f, fn _ -> :ok end) + + # ar rcs <archive> <objs...> + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert hd(argv) =~ "llvm-ar" + assert "rcs" in argv + # All 31 object paths are in the argv after "rcs <archive>" + assert length(argv) >= 31 + {:ok, ""} + end) + + # ranlib + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert hd(argv) =~ "llvm-ranlib" + {:ok, ""} + end) + + # nm verification — return a fake output that contains the symbol. + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert hd(argv) =~ "llvm-nm" + {:ok, "0000000000000000 T crypto_nif_init\n"} + end) + + assert {:ok, info} = + CryptoNif.build(:android_arm64, + otp_src: "/fake/otp", + openssl_prefix: "/fake/openssl", + ndk_root: "/fake/ndk" + ) + + assert info.target == :android_arm64 + assert info.archive =~ "aarch64-unknown-linux-android/crypto.a" + assert length(info.objects) == 31 + end + end + + describe "build/2 — ios_sim full sequence" do + test "uses xcrun -sdk iphonesimulator for cc/ar/ranlib/nm" do + stub_all_dir_checks_true() + Mox.expect(MobDev.Release.ShellMock, :mkdir_p, 2, fn _ -> :ok end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, 31, fn argv, _opts -> + # iOS cc argv: ["xcrun", "-sdk", "iphonesimulator", "clang", + # "-arch", "arm64", "-mios-simulator-version-min=17.0", ...flags, "-c", "-o", obj, src] + assert Enum.take(argv, 7) == [ + "xcrun", + "-sdk", + "iphonesimulator", + "clang", + "-arch", + "arm64", + "-mios-simulator-version-min=17.0" + ] + + # No Android hardening on iOS + refute "-mbranch-protection=standard" in argv + refute "-D_GNU_SOURCE" in argv + {:ok, ""} + end) + + Mox.expect(MobDev.Release.ShellMock, :rm_f, fn _ -> :ok end) + + # ar + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert Enum.take(argv, 4) == ["xcrun", "-sdk", "iphonesimulator", "ar"] + {:ok, ""} + end) + + # ranlib + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert Enum.take(argv, 4) == ["xcrun", "-sdk", "iphonesimulator", "ranlib"] + {:ok, ""} + end) + + # nm with leading-underscore symbol + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert Enum.take(argv, 4) == ["xcrun", "-sdk", "iphonesimulator", "nm"] + {:ok, "0000000000000000 T _crypto_nif_init\n"} + end) + + assert {:ok, info} = + CryptoNif.build(:ios_sim, + otp_src: "/fake/otp", + openssl_prefix: "/fake/openssl" + ) + + assert info.archive =~ "aarch64-apple-iossimulator/crypto.a" + end + + test "ios_device uses iphoneos SDK + -miphoneos-version-min" do + stub_all_dir_checks_true() + Mox.expect(MobDev.Release.ShellMock, :mkdir_p, 2, fn _ -> :ok end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, 31, fn argv, _opts -> + assert "iphoneos" in argv + assert "-miphoneos-version-min=17.0" in argv + refute "-mios-simulator-version-min=17.0" in argv + {:ok, ""} + end) + + Mox.expect(MobDev.Release.ShellMock, :rm_f, fn _ -> :ok end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn _, _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn _, _ -> {:ok, ""} end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn _, _ -> + {:ok, "0000000000000000 T _crypto_nif_init\n"} + end) + + assert {:ok, info} = + CryptoNif.build(:ios_device, + otp_src: "/fake/otp", + openssl_prefix: "/fake/openssl" + ) + + assert info.archive =~ "aarch64-apple-ios/crypto.a" + end + end + + # ── build/2 failure modes ──────────────────────────────────────────── + + describe "build/2 failure paths" do + test "missing OTP_SRC → precondition_failed with clone hint" do + Mox.expect(MobDev.Release.ShellMock, :dir?, fn _ -> false end) + + assert {:error, {:precondition_failed, msg}} = + CryptoNif.build(:android_arm64, + otp_src: "/nonexistent", + openssl_prefix: "/fake/openssl" + ) + + assert msg =~ "OTP_SRC missing" + assert msg =~ "github.com/erlang/otp" + end + + test "missing OPENSSL_PREFIX → precondition_failed pointing at MobDev.Release.OpenSSL" do + # OTP_SRC dir: yes; OPENSSL_PREFIX dir: no + Mox.expect(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + Mox.expect(MobDev.Release.ShellMock, :dir?, fn _ -> false end) + + assert {:error, {:precondition_failed, msg}} = + CryptoNif.build(:android_arm64, + otp_src: "/fake/otp", + openssl_prefix: "/nonexistent", + ndk_root: "/fake/ndk" + ) + + assert msg =~ "OPENSSL_PREFIX missing" + assert msg =~ "MobDev.Release.OpenSSL.build" + end + + test "compile failure propagates as cmd_failed, halts the loop" do + stub_all_dir_checks_true() + Mox.stub(MobDev.Release.ShellMock, :mkdir_p, fn _ -> :ok end) + + # Fail on the first compile call. The remaining 30 should never + # run (Mox verify_on_exit will fail this test if they do). + Mox.expect(MobDev.Release.ShellMock, :cmd, fn _argv, _opts -> + Errors.cmd_failed(["aarch64-linux-android24-clang"], 1, "error: header not found\n") + end) + + assert {:error, {:cmd_failed, %{exit: 1}}} = + CryptoNif.build(:android_arm64, + otp_src: "/fake/otp", + openssl_prefix: "/fake/openssl", + ndk_root: "/fake/ndk" + ) + end + + test "missing crypto_nif_init symbol → precondition_failed (the silent-shipping bug we fix)" do + stub_all_dir_checks_true() + Mox.stub(MobDev.Release.ShellMock, :mkdir_p, fn _ -> :ok end) + Mox.stub(MobDev.Release.ShellMock, :rm_f, fn _ -> :ok end) + + # 31 compile + ar + ranlib + nm = 34 cmd calls. ar/ranlib succeed + # but nm output lacks the symbol. + Mox.expect(MobDev.Release.ShellMock, :cmd, 31, fn _, _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn _, _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn _, _ -> {:ok, ""} end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _ -> + assert hd(argv) =~ "llvm-nm" + # Symbol got built as U (undefined) — exactly the regression + # the shell version would have silently shipped. + {:ok, " U crypto_nif_init\n"} + end) + + assert {:error, {:precondition_failed, msg}} = + CryptoNif.build(:android_arm64, + otp_src: "/fake/otp", + openssl_prefix: "/fake/openssl", + ndk_root: "/fake/ndk" + ) + + assert msg =~ "crypto_nif_init" + assert msg =~ "STATIC_ERLANG_NIF" + end + end + + # ── Helpers ───────────────────────────────────────────────────────── + + defp stub_all_dir_checks_true do + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + end +end diff --git a/test/mob_dev/release/openssl_test.exs b/test/mob_dev/release/openssl_test.exs new file mode 100644 index 0000000..108d345 --- /dev/null +++ b/test/mob_dev/release/openssl_test.exs @@ -0,0 +1,447 @@ +defmodule MobDev.Release.OpenSSLTest do + use ExUnit.Case, async: false + # async: false because we mutate Application env (release_shell impl). + # Parallel tests sharing app env race. + + import Mox + + alias MobDev.Release.{OpenSSL, Errors} + + # The Mox built in test/support/release/shell_mock.ex is automatically + # verified per test — any expected call that wasn't made fails the test. + setup :verify_on_exit! + + setup do + Application.put_env(:mob_dev, :release_shell, MobDev.Release.ShellMock) + on_exit(fn -> Application.delete_env(:mob_dev, :release_shell) end) + :ok + end + + # ── Target spec — pure data ───────────────────────────────────────────── + # These tests lock down the surface so a future "improvement" can't + # silently drop e.g. arm32's `no-asm` flag (the exact regression the + # shell version's comment block warned about). + + describe "target_spec/1" do + test "android_arm64 — no `no-asm`; -D__ANDROID_API__=24" do + spec = OpenSSL.target_spec(:android_arm64) + + assert spec.configure_target == "android-arm64" + assert spec.default_prefix == "/tmp/openssl-android-arm64" + assert "-D__ANDROID_API__=24" in spec.extra_configure_args + refute "no-asm" in spec.extra_configure_args + end + + test "android_x86_64 — no `no-asm`; -D__ANDROID_API__=24" do + spec = OpenSSL.target_spec(:android_x86_64) + + assert spec.configure_target == "android-x86_64" + assert spec.default_prefix == "/tmp/openssl-android-x86_64" + assert "-D__ANDROID_API__=24" in spec.extra_configure_args + refute "no-asm" in spec.extra_configure_args + end + + test "android_arm32 — `no-asm` IS present (ld.lld rejects non-PIC reloc)" do + spec = OpenSSL.target_spec(:android_arm32) + + assert spec.configure_target == "android-arm" + assert spec.default_prefix == "/tmp/openssl-android-arm32" + assert "-D__ANDROID_API__=24" in spec.extra_configure_args + assert "no-asm" in spec.extra_configure_args + end + + test "ios_sim — iossimulator-xcrun Configure target" do + spec = OpenSSL.target_spec(:ios_sim) + assert spec.configure_target == "iossimulator-xcrun" + assert spec.default_prefix == "/tmp/openssl-ios-sim" + # iOS targets don't pass -D__ANDROID_API__ etc. + assert spec.extra_configure_args == [] + end + + test "ios_device — ios64-xcrun Configure target" do + spec = OpenSSL.target_spec(:ios_device) + assert spec.configure_target == "ios64-xcrun" + assert spec.default_prefix == "/tmp/openssl-ios-device" + assert spec.extra_configure_args == [] + end + + test "targets/0 enumerates all five in canonical order" do + assert OpenSSL.targets() == [ + :android_arm64, + :android_arm32, + :android_x86_64, + :ios_sim, + :ios_device + ] + end + end + + # ── Configure args — pure assembly ───────────────────────────────────── + # The assembled argv is what the shell scripts hand-rolled. Pin its + # contents + ordering so any tweak that breaks the OpenSSL build + # surfaces in test rather than in release-time. + + describe "configure_args/2" do + test "first arg is the Configure target" do + args = OpenSSL.configure_args(OpenSSL.target_spec(:android_arm64), "/tmp/out") + assert hd(args) == "android-arm64" + end + + test "includes all size flags" do + args = OpenSSL.configure_args(OpenSSL.target_spec(:ios_sim), "/tmp/out") + + for flag <- ["-Os", "-ffunction-sections", "-fdata-sections", "-fPIC"] do + assert flag in args, "expected size flag #{flag} in #{inspect(args)}" + end + end + + test "android_arm32 places `no-asm` BEFORE the disabled-algorithm list" do + # Position matters less than presence for OpenSSL, but pinning the + # position catches accidental list-merge regressions. + args = OpenSSL.configure_args(OpenSSL.target_spec(:android_arm32), "/tmp/out") + + assert no_asm_idx = Enum.find_index(args, &(&1 == "no-asm")) + assert no_md2_idx = Enum.find_index(args, &(&1 == "no-md2")) + assert no_asm_idx < no_md2_idx, "no-asm should come before the no-X disable list" + end + + test "includes --prefix and --openssldir" do + args = OpenSSL.configure_args(OpenSSL.target_spec(:ios_device), "/custom/prefix") + assert "--prefix=/custom/prefix" in args + assert "--openssldir=/custom/prefix/ssl" in args + end + + test "includes the full disabled-algorithm list" do + args = OpenSSL.configure_args(OpenSSL.target_spec(:android_arm64), "/tmp/out") + + # Spot-check a few representative entries. The full list is tested + # in disabled_algorithms/0 directly. + for disabled <- ["no-shared", "no-md2", "no-rc4", "no-ssl3", "no-tls1_1", "no-srp"] do + assert disabled in args + end + end + + test "argv is non-empty and entirely strings" do + args = OpenSSL.configure_args(OpenSSL.target_spec(:ios_sim), "/tmp/out") + assert length(args) > 0 + assert Enum.all?(args, &is_binary/1) + end + end + + # ── disabled_algorithms/0 — pin the surface ───────────────────────────── + # Adding or removing an entry here is a deliberate decision that + # should show up in code review, not a silent edit-and-forget. + + describe "disabled_algorithms/0" do + test "includes every legacy hash + cipher + protocol we don't ship" do + disabled = OpenSSL.disabled_algorithms() + + expected = ~w( + no-shared no-tests no-apps no-engine + no-md2 no-md4 no-mdc2 no-whirlpool no-rmd160 + no-rc2 no-rc4 no-idea no-cast no-bf no-blake2 + no-seed no-aria no-camellia no-gost + no-weak-ssl-ciphers no-ssl3 no-tls1 no-tls1_1 + no-srp no-psk no-nextprotoneg + ) + + assert disabled == expected + end + end + + # ── build/2 — the orchestration, against a Mox ───────────────────────── + # These tests prove "given these inputs, the Shell behaviour is invoked + # with exactly these argv + cwd + env." The actual clang/Configure/make + # never runs. + + describe "build/2 against the Mox" do + test "android_arm64 invokes Configure with the right argv" do + Mox.expect(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + # NDK root + toolchain checks + Mox.expect(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + Mox.expect(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + # distclean (tolerant) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "distclean"], _opts -> + {:ok, ""} + end) + + # Configure + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, opts -> + assert hd(argv) == "./Configure" + assert "android-arm64" in argv + assert "-D__ANDROID_API__=24" in argv + assert "no-asm" not in argv + + env = Keyword.fetch!(opts, :env) + assert {"ANDROID_NDK_ROOT", _} = List.keyfind(env, "ANDROID_NDK_ROOT", 0) + {:ok, ""} + end) + + # make -j8 + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "-j8"], _opts -> + {:ok, ""} + end) + + # make install_sw + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "install_sw"], _opts -> + {:ok, ""} + end) + + assert {:ok, info} = + OpenSSL.build(:android_arm64, + openssl_src: "/fake/openssl", + prefix: "/fake/prefix" + ) + + assert info.target == :android_arm64 + assert info.prefix == "/fake/prefix" + assert info.libcrypto == "/fake/prefix/lib/libcrypto.a" + assert info.libssl == "/fake/prefix/lib/libssl.a" + assert info.include == "/fake/prefix/include" + end + + test "android_arm32 passes `no-asm` to Configure" do + stub_all_dir_checks_true() + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "distclean"], _ -> {:ok, ""} end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert "android-arm" in argv + assert "no-asm" in argv + {:ok, ""} + end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "-j8"], _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "install_sw"], _ -> {:ok, ""} end) + + assert {:ok, _} = + OpenSSL.build(:android_arm32, + openssl_src: "/fake/openssl", + prefix: "/fake/prefix" + ) + end + + test "ios_sim sets CC/CXX/AR/RANLIB via xcrun in env" do + stub_all_dir_checks_true() + + # iOS precheck calls `xcrun --sdk iphonesimulator --show-sdk-path` + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert argv == ["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"] + {:ok, "/some/sdk/path\n"} + end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "distclean"], _ -> {:ok, ""} end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, opts -> + assert "iossimulator-xcrun" in argv + + env = Keyword.fetch!(opts, :env) + cc = env |> List.keyfind("CC", 0) |> elem(1) + ar = env |> List.keyfind("AR", 0) |> elem(1) + + assert cc =~ "xcrun -sdk iphonesimulator clang -arch arm64" + assert cc =~ "-mios-simulator-version-min=17.0" + assert ar == "xcrun -sdk iphonesimulator ar" + {:ok, ""} + end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "-j8"], _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "install_sw"], _ -> {:ok, ""} end) + + assert {:ok, info} = + OpenSSL.build(:ios_sim, + openssl_src: "/fake/openssl", + prefix: "/fake/prefix" + ) + + assert info.target == :ios_sim + end + + test "ios_device uses -miphoneos-version-min (not -mios-simulator-version-min)" do + stub_all_dir_checks_true() + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert argv == ["xcrun", "--sdk", "iphoneos", "--show-sdk-path"] + {:ok, "/some/sdk/path\n"} + end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "distclean"], _ -> {:ok, ""} end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, opts -> + assert "ios64-xcrun" in argv + + env = Keyword.fetch!(opts, :env) + cc = env |> List.keyfind("CC", 0) |> elem(1) + + assert cc =~ "-miphoneos-version-min=17.0" + refute cc =~ "ios-simulator" + {:ok, ""} + end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "-j8"], _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "install_sw"], _ -> {:ok, ""} end) + + assert {:ok, _} = + OpenSSL.build(:ios_device, + openssl_src: "/fake/openssl", + prefix: "/fake/prefix" + ) + end + + test "distclean failure is tolerated (first-time builds have nothing to clean)" do + stub_all_dir_checks_true() + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "distclean"], _ -> + Errors.cmd_failed(["make", "distclean"], 2, "nothing to clean\n") + end) + + # The build continues to Configure despite distclean failing. + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["./Configure" | _], _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "-j8"], _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "install_sw"], _ -> {:ok, ""} end) + + assert {:ok, _} = + OpenSSL.build(:android_arm64, + openssl_src: "/fake/openssl", + prefix: "/fake/prefix" + ) + end + + test "Configure failure propagates as cmd_failed (NOT tolerated)" do + stub_all_dir_checks_true() + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "distclean"], _ -> {:ok, ""} end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["./Configure" | _], _ -> + Errors.cmd_failed(["./Configure"], 1, "unknown target\n") + end) + + # Subsequent steps should NOT be invoked — Mox verify_on_exit + # will fail this test if they are. + + assert {:error, {:cmd_failed, %{exit: 1}}} = + OpenSSL.build(:android_arm64, + openssl_src: "/fake/openssl", + prefix: "/fake/prefix" + ) + end + + test "make failure propagates" do + stub_all_dir_checks_true() + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "distclean"], _ -> {:ok, ""} end) + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["./Configure" | _], _ -> {:ok, ""} end) + + Mox.expect(MobDev.Release.ShellMock, :cmd, fn ["make", "-j8"], _ -> + Errors.cmd_failed(["make", "-j8"], 2, "error: ...\n") + end) + + assert {:error, {:cmd_failed, _}} = + OpenSSL.build(:android_arm64, + openssl_src: "/fake/openssl", + prefix: "/fake/prefix" + ) + end + end + + # ── Preconditions — actionable hints, not blob errors ─────────────────── + + describe "build/2 preconditions" do + test "missing OPENSSL_SRC → precondition_failed with clone hint" do + Mox.expect(MobDev.Release.ShellMock, :dir?, fn _ -> false end) + + assert {:error, {:precondition_failed, msg}} = + OpenSSL.build(:android_arm64, openssl_src: "/nonexistent") + + assert msg =~ "OPENSSL_SRC missing" + assert msg =~ "github.com/openssl/openssl" + end + + test "missing NDK root → precondition_failed with install hint" do + # OPENSSL_SRC dir: yes; NDK dir: no + Mox.expect(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + Mox.expect(MobDev.Release.ShellMock, :dir?, fn _ -> false end) + + assert {:error, {:precondition_failed, msg}} = + OpenSSL.build(:android_arm64, + openssl_src: "/fake/openssl", + ndk_root: "/nonexistent/ndk" + ) + + assert msg =~ "Android NDK" + assert msg =~ "install" + end + + test "missing iOS SDK → precondition_failed with xcode-select hint" do + # OPENSSL_SRC dir: yes + Mox.expect(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + + # xcrun fails + Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + assert argv == ["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"] + Errors.cmd_failed(argv, 64, "xcrun: error: SDK \"iphonesimulator\" cannot be located\n") + end) + + assert {:error, {:precondition_failed, msg}} = + OpenSSL.build(:ios_sim, openssl_src: "/fake/openssl") + + assert msg =~ "iphonesimulator" + assert msg =~ "xcode-select" + end + end + + # ── build_all/1 — sequence, doesn't short-circuit ─────────────────────── + + describe "build_all/1" do + test "returns one result per target in canonical order" do + # Stub everything to a quick success on dir? + cmd + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :cmd, fn _, _ -> {:ok, ""} end) + + results = OpenSSL.build_all(openssl_src: "/fake/openssl") + + assert Keyword.keys(results) == [ + :android_arm64, + :android_arm32, + :android_x86_64, + :ios_sim, + :ios_device + ] + + assert Enum.all?(results, fn {_id, r} -> match?({:ok, _}, r) end) + end + + test "doesn't short-circuit when one target fails" do + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + + # Fail android_arm64's Configure, pass everything else. + stub(MobDev.Release.ShellMock, :cmd, fn argv, opts -> + cd = Keyword.get(opts, :cd, "") + + cond do + hd(argv) == "./Configure" and "android-arm64" in argv -> + Errors.cmd_failed(argv, 1, "oops") + + hd(argv) == "xcrun" -> + {:ok, "/sdk\n"} + + true -> + _ = cd + {:ok, ""} + end + end) + + results = OpenSSL.build_all(openssl_src: "/fake/openssl") + android_arm64 = Keyword.fetch!(results, :android_arm64) + android_arm32 = Keyword.fetch!(results, :android_arm32) + + assert {:error, {:cmd_failed, _}} = android_arm64 + assert {:ok, _} = android_arm32 + end + end + + # ── Helpers ───────────────────────────────────────────────────────────── + + defp stub_all_dir_checks_true do + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + end +end diff --git a/test/mob_dev/release/otp_test.exs b/test/mob_dev/release/otp_test.exs new file mode 100644 index 0000000..5328803 --- /dev/null +++ b/test/mob_dev/release/otp_test.exs @@ -0,0 +1,483 @@ +defmodule MobDev.Release.OTPTest do + use ExUnit.Case, async: false + + import Mox + + alias MobDev.Release.OTP + + setup :verify_on_exit! + + setup do + Application.put_env(:mob_dev, :release_shell, MobDev.Release.ShellMock) + otp_src = mk_tmp_otp_fixture() + + on_exit(fn -> + Application.delete_env(:mob_dev, :release_shell) + File.rm_rf!(otp_src) + end) + + %{otp_src: otp_src} + end + + # ── target_spec/1 — pinned surface ─────────────────────────────────── + + describe "target_spec/1" do + test "android_arm64 — arm64-android conf, with_openssl, otp_build_release" do + spec = OTP.target_spec(:android_arm64) + assert spec.arch_dir == "aarch64-unknown-linux-android" + assert spec.xcomp_conf == "xcomp/erl-xcomp-arm64-android.conf" + assert spec.default_release_root == "/tmp/otp-android" + assert spec.ssl_strategy == :with_openssl + assert spec.install_method == :otp_build_release + end + + test "android_x86_64 — x86_64-android conf (arch_dir is x86_64-PC-linux-android)" do + spec = OTP.target_spec(:android_x86_64) + # config.sub canonicalizes x86_64-linux-android to the `pc` vendor, NOT + # `unknown` like aarch64 — getting this wrong silently breaks the tarball. + assert spec.arch_dir == "x86_64-pc-linux-android" + assert spec.xcomp_conf == "xcomp/erl-xcomp-x86_64-android.conf" + assert spec.default_release_root == "/tmp/otp-android-x86_64" + assert spec.ssl_strategy == :with_openssl + assert spec.install_method == :otp_build_release + end + + test "android_arm32 — arm-android conf (NOT arm32-android — historical)" do + spec = OTP.target_spec(:android_arm32) + assert spec.arch_dir == "arm-unknown-linux-androideabi" + # OTP's naming dropped the `32`: erl-xcomp-arm-android.conf, not + # erl-xcomp-arm32-android.conf. If this drifts the build fails. + assert spec.xcomp_conf == "xcomp/erl-xcomp-arm-android.conf" + assert spec.default_release_root == "/tmp/otp-android-arm32" + end + + test "ios_sim — iossimulator conf, WITHOUT ssl, make_release" do + spec = OTP.target_spec(:ios_sim) + assert spec.arch_dir == "aarch64-apple-iossimulator" + assert spec.xcomp_conf == "xcomp/erl-xcomp-arm64-iossimulator.conf" + assert spec.default_release_root == "/tmp/otp-ios-sim" + # Load-bearing: iOS uses --without-ssl because + # --enable-static-nifs + --with-ssl don't play nice. + assert spec.ssl_strategy == :without_ssl + assert spec.install_method == :make_release + end + + test "ios_device — ios conf (not iossimulator), same flags as sim" do + sim = OTP.target_spec(:ios_sim) + device = OTP.target_spec(:ios_device) + + assert sim.xcomp_conf != device.xcomp_conf + assert device.xcomp_conf == "xcomp/erl-xcomp-arm64-ios.conf" + assert device.arch_dir == "aarch64-apple-ios" + assert device.ssl_strategy == :without_ssl + assert device.install_method == :make_release + end + + test "targets/0 enumerates all five in canonical order" do + assert OTP.targets() == [ + :android_arm64, + :android_arm32, + :android_x86_64, + :ios_sim, + :ios_device + ] + end + end + + # ── configure_args/2 — pure assembly ───────────────────────────────── + + describe "configure_args/2" do + test "android passes --with-ssl + --disable-dynamic-ssl-lib" do + args = OTP.configure_args(OTP.target_spec(:android_arm64), "/openssl/prefix") + + assert "--xcomp-conf=./xcomp/erl-xcomp-arm64-android.conf" in args + assert "--with-ssl=/openssl/prefix" in args + assert "--disable-dynamic-ssl-lib" in args + refute "--without-ssl" in args + end + + test "iOS passes --without-ssl (regardless of openssl_prefix arg)" do + # Even if a caller mistakenly hands an openssl_prefix to an iOS + # build, the spec says --without-ssl. Mixing --with-ssl with iOS's + # --enable-static-nifs breaks the link with undefined RAND_seed / + # OSSL_PROVIDER_load — load-bearing. + args = OTP.configure_args(OTP.target_spec(:ios_sim), "/this/is/ignored") + + assert "--without-ssl" in args + refute Enum.any?(args, &String.starts_with?(&1, "--with-ssl")) + end + + test "android raises ArgumentError when openssl_prefix is nil" do + assert_raise ArgumentError, ~r/openssl_prefix is required/, fn -> + OTP.configure_args(OTP.target_spec(:android_arm64), nil) + end + end + + test "xcomp-conf path uses the ./ prefix that otp_build expects" do + for target_id <- OTP.targets() do + args = OTP.configure_args(OTP.target_spec(target_id), "/openssl/prefix") + + assert Enum.any?(args, fn arg -> + String.starts_with?(arg, "--xcomp-conf=./xcomp/erl-xcomp-") + end), + "target #{target_id} missing properly-prefixed xcomp-conf in #{inspect(args)}" + end + end + end + + # ── install_args/2 — pure assembly ─────────────────────────────────── + + describe "install_args/2" do + test "Android uses ./otp_build release -a" do + args = OTP.install_args(OTP.target_spec(:android_arm64), "/tmp/otp-android") + assert args == ["./otp_build", "release", "-a", "/tmp/otp-android"] + end + + test "iOS uses make release with RELEASE_ROOT= env-arg" do + args = OTP.install_args(OTP.target_spec(:ios_device), "/tmp/otp-ios-device") + assert args == ["make", "release", "RELEASE_ROOT=/tmp/otp-ios-device"] + end + + test "RELEASE_ROOT= prefix is literal — must not split on whitespace" do + args = OTP.install_args(OTP.target_spec(:ios_sim), "/path") + [last] = Enum.take(args, -1) + assert last == "RELEASE_ROOT=/path" + refute "RELEASE_ROOT" in args + end + end + + # ── build/2 against the Mox — full sequence ────────────────────────── + + describe "build/2 — android_arm64 happy path" do + test "fires distclean + configure + boot + rm + otp_build release + verify", + %{otp_src: otp_src} do + stub_predicates_true() + + # The `cmd` stub branches on the argv shape. We capture each call + # via an ETS table so the test can assert on the canonical sequence + # after build/2 returns. + cmd_log = :ets.new(:cmd_log, [:public, :ordered_set]) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, opts -> + :ets.insert(cmd_log, {System.monotonic_time(), argv, opts}) + + cond do + hd(argv) == "ls" -> + # verify_outputs's ls of release_root/lib — return Android + # crypto apps so the with-ssl check passes. + {:ok, "crypto-5.6\npublic_key-1.18\nssl-11.4\n"} + + true -> + {:ok, ""} + end + end) + + assert {:ok, info} = + OTP.build(:android_arm64, + otp_src: otp_src, + openssl_prefix: "/openssl/prefix", + release_root: "/fake/release", + ndk_root: "/fake/ndk" + ) + + # Inspect the call sequence. + calls = :ets.tab2list(cmd_log) |> Enum.map(fn {_, argv, _opts} -> argv end) + + # The first 6 calls are the build pipeline. + assert Enum.at(calls, 0) == ["make", "distclean"] + assert hd(Enum.at(calls, 1)) == "./otp_build" + assert "configure" in Enum.at(calls, 1) + assert Enum.at(calls, 2) == ["./otp_build", "boot"] + assert Enum.at(calls, 3) == ["rm", "-rf", "/fake/release"] + assert Enum.at(calls, 4) == ["./otp_build", "release", "-a", "/fake/release"] + # Then ls for verify. + assert hd(Enum.at(calls, 5)) == "ls" + + # Verify the configure invocation had the right env. + configure_call = Enum.at(calls, 1) + assert "--xcomp-conf=./xcomp/erl-xcomp-arm64-android.conf" in configure_call + assert "--with-ssl=/openssl/prefix" in configure_call + assert "--disable-dynamic-ssl-lib" in configure_call + + assert info.target == :android_arm64 + assert info.release_root == "/fake/release" + assert info.erts_vsn == "17.0" + end + end + + describe "build/2 — ios_sim happy path" do + test "uses --without-ssl, make release RELEASE_ROOT=, verifies arch config.h", + %{otp_src: otp_src} do + stub_predicates_true() + + cmd_log = :ets.new(:cmd_log_ios, [:public, :ordered_set]) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, opts -> + :ets.insert(cmd_log, {System.monotonic_time(), argv, opts}) + {:ok, ""} + end) + + assert {:ok, info} = + OTP.build(:ios_sim, + otp_src: otp_src, + release_root: "/fake/release" + ) + + calls = :ets.tab2list(cmd_log) |> Enum.map(fn {_, argv, _opts} -> argv end) + + # Configure: --without-ssl, NOT --with-ssl + assert configure_call = Enum.find(calls, fn argv -> "configure" in argv end) + assert "--xcomp-conf=./xcomp/erl-xcomp-arm64-iossimulator.conf" in configure_call + assert "--without-ssl" in configure_call + refute Enum.any?(configure_call, &String.starts_with?(&1, "--with-ssl")) + + # Install: make release RELEASE_ROOT= (NOT otp_build release) + install_call = Enum.find(calls, fn argv -> hd(argv) == "make" and "release" in argv end) + assert install_call == ["make", "release", "RELEASE_ROOT=/fake/release"] + + assert info.target == :ios_sim + end + end + + describe "build/2 — ios env doesn't leak NDK keys" do + test "iOS configure invocation has RELEASE_LIBBEAM but no NDK_ROOT/NDK_ABI_PLAT", + %{otp_src: otp_src} do + stub_predicates_true() + + captured_env = :atomics.new(1, signed: false) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, opts -> + if "configure" in argv do + send(self(), {:configure_env, Keyword.fetch!(opts, :env)}) + end + + :atomics.add(captured_env, 1, 1) + {:ok, ""} + end) + + assert {:ok, _} = + OTP.build(:ios_device, + otp_src: otp_src, + release_root: "/fake/release" + ) + + assert_received {:configure_env, env} + assert {"RELEASE_LIBBEAM", "yes"} = List.keyfind(env, "RELEASE_LIBBEAM", 0) + refute List.keyfind(env, "NDK_ROOT", 0) + refute List.keyfind(env, "NDK_ABI_PLAT", 0) + end + end + + # ── Precondition failures ──────────────────────────────────────────── + + describe "build/2 preconditions" do + test "missing OTP_SRC → precondition_failed with clone hint" do + stub(MobDev.Release.ShellMock, :dir?, fn _ -> false end) + + assert {:error, {:precondition_failed, msg}} = + OTP.build(:android_arm64, + otp_src: "/nonexistent", + openssl_prefix: "/openssl/prefix", + ndk_root: "/fake/ndk" + ) + + assert msg =~ "OTP_SRC missing" + assert msg =~ "github.com/erlang/otp" + end + + test "OTP_SRC without otp_build script → precondition_failed", %{otp_src: otp_src} do + # otp_src exists. file? returns false for the otp_build file. + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :file?, fn _ -> false end) + + assert {:error, {:precondition_failed, msg}} = + OTP.build(:android_arm64, + otp_src: otp_src, + openssl_prefix: "/openssl/prefix", + ndk_root: "/fake/ndk" + ) + + assert msg =~ "otp_build script not found" + end + + test "android target without openssl_prefix → precondition_failed pointing at OpenSSL", + %{otp_src: otp_src} do + stub_predicates_true() + + assert {:error, {:precondition_failed, msg}} = + OTP.build(:android_arm64, + otp_src: otp_src, + ndk_root: "/fake/ndk" + ) + + assert msg =~ "openssl_prefix required" + assert msg =~ "MobDev.Release.OpenSSL.build" + end + + test "android target with missing openssl_prefix dir → precondition_failed", + %{otp_src: otp_src} do + # otp_src dir: true; otp_build file: true; openssl_prefix dir: false + stub(MobDev.Release.ShellMock, :dir?, fn path -> + not String.starts_with?(path, "/nonexistent") + end) + + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + + assert {:error, {:precondition_failed, msg}} = + OTP.build(:android_arm64, + otp_src: otp_src, + openssl_prefix: "/nonexistent", + ndk_root: "/fake/ndk" + ) + + assert msg =~ "OPENSSL_PREFIX missing" + end + + test "android target with missing NDK root → precondition_failed", + %{otp_src: otp_src} do + # Everything exists EXCEPT the NDK root. + stub(MobDev.Release.ShellMock, :dir?, fn path -> + not String.starts_with?(path, "/no/ndk") + end) + + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + + assert {:error, {:precondition_failed, msg}} = + OTP.build(:android_arm64, + otp_src: otp_src, + openssl_prefix: "/openssl/prefix", + ndk_root: "/no/ndk" + ) + + assert msg =~ "Android NDK" + end + end + + # ── Verification failures ──────────────────────────────────────────── + + describe "build/2 verification failures" do + test "missing erts-<vsn> dir after install → precondition_failed", %{otp_src: otp_src} do + # All preconditions pass. After the build, erts-<vsn> dir check + # returns false. + stub(MobDev.Release.ShellMock, :dir?, fn path -> + not String.ends_with?(path, "/erts-17.0") + end) + + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :cmd, fn _, _ -> {:ok, ""} end) + + assert {:error, {:precondition_failed, msg}} = + OTP.build(:android_arm64, + otp_src: otp_src, + openssl_prefix: "/openssl/prefix", + release_root: "/fake/release", + ndk_root: "/fake/ndk" + ) + + assert msg =~ "missing" + assert msg =~ "erts-17.0" + end + + test "Android verify catches missing crypto/public_key/ssl apps (the --with-ssl wiring check)", + %{otp_src: otp_src} do + stub_predicates_true() + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + cond do + hd(argv) == "ls" -> + # Missing crypto/public_key/ssl — exactly the silent shipping + # bug we want to fail loudly. + {:ok, "kernel-9.0\nstdlib-6.0\n"} + + true -> + {:ok, ""} + end + end) + + assert {:error, {:precondition_failed, msg}} = + OTP.build(:android_arm64, + otp_src: otp_src, + openssl_prefix: "/openssl/prefix", + release_root: "/fake/release", + ndk_root: "/fake/ndk" + ) + + assert msg =~ "crypto" + assert msg =~ "--with-ssl" + end + + test "iOS verify catches missing arch-specific config.h", %{otp_src: otp_src} do + # All dir? true. file? returns false for the config.h check, + # true for the otp_build file check. + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + + stub(MobDev.Release.ShellMock, :file?, fn path -> + not String.ends_with?(path, "config.h") + end) + + stub(MobDev.Release.ShellMock, :cmd, fn _, _ -> {:ok, ""} end) + + assert {:error, {:precondition_failed, msg}} = + OTP.build(:ios_device, + otp_src: otp_src, + release_root: "/fake/release" + ) + + assert msg =~ "config.h" + assert msg =~ "arch-specific" + end + end + + # ── build_all/1 ────────────────────────────────────────────────────── + + describe "build_all/1" do + test "passes per-target openssl_prefix defaults to Android targets", %{otp_src: otp_src} do + stub_predicates_true() + + configure_calls = :ets.new(:configure_calls, [:public, :duplicate_bag]) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + cond do + "configure" in argv -> + :ets.insert(configure_calls, {:configure, argv}) + {:ok, ""} + + hd(argv) == "ls" -> + # Pass Android verify by reporting crypto apps. + {:ok, "crypto-5.6\npublic_key-1.18\nssl-11.4\n"} + + true -> + {:ok, ""} + end + end) + + OTP.build_all(otp_src: otp_src, ndk_root: "/fake/ndk") + + calls = :ets.tab2list(configure_calls) + assert length(calls) == 5 + + flat = List.flatten(for {:configure, argv} <- calls, do: argv) + assert Enum.any?(flat, &(&1 == "--with-ssl=/tmp/openssl-android-arm64")) + assert Enum.any?(flat, &(&1 == "--with-ssl=/tmp/openssl-android-arm32")) + assert Enum.any?(flat, &(&1 == "--with-ssl=/tmp/openssl-android-x86_64")) + assert Enum.count(flat, &(&1 == "--without-ssl")) == 2 + end + end + + # ── Helpers ────────────────────────────────────────────────────────── + + defp stub_predicates_true do + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + end + + defp mk_tmp_otp_fixture do + tmp = + Path.join(System.tmp_dir!(), "mob_dev_otp_release_#{System.unique_integer([:positive])}") + + File.mkdir_p!(Path.join(tmp, "erts")) + File.write!(Path.join([tmp, "erts", "vsn.mk"]), "VSN = 17.0\n") + File.touch!(Path.join(tmp, "otp_build")) + tmp + end +end diff --git a/test/mob_dev/release/publish_test.exs b/test/mob_dev/release/publish_test.exs new file mode 100644 index 0000000..89c8de7 --- /dev/null +++ b/test/mob_dev/release/publish_test.exs @@ -0,0 +1,461 @@ +defmodule MobDev.Release.PublishTest do + use ExUnit.Case, async: false + + import Mox + + alias MobDev.Release.Publish + + setup :verify_on_exit! + + setup do + Application.put_env(:mob_dev, :release_shell, MobDev.Release.ShellMock) + on_exit(fn -> Application.delete_env(:mob_dev, :release_shell) end) + :ok + end + + # ── classify/1 — the contract that protects users from "exit 1" ─────── + + describe "classify/1" do + test "GitHub 'release not found' → :not_found (must trigger create)" do + assert Publish.classify("release not found") == :not_found + assert Publish.classify("RELEASE NOT FOUND") == :not_found + assert Publish.classify("HTTP 404: release not found (api.github.com/...)") == :not_found + end + + test "401 Bad credentials → :auth" do + assert Publish.classify("HTTP 401: Bad credentials (api.github.com)") == :auth + end + + test "403 → :auth" do + assert Publish.classify("HTTP 403: Resource not accessible") == :auth + end + + test "'gh auth login' suggestion in stderr → :auth" do + assert Publish.classify("To get started with GitHub CLI, please run: gh auth login") == + :auth + end + + test "HTTP 5xx → :infra (GitHub outage, not our problem)" do + assert Publish.classify("HTTP 503: Service Unavailable") == :infra + assert Publish.classify("HTTP 502: Bad Gateway") == :infra + assert Publish.classify("HTTP 500: Internal Server Error") == :infra + end + + test "network errors → :infra" do + assert Publish.classify("dial tcp: lookup api.github.com: no such host") == :infra + assert Publish.classify("connection refused") == :infra + assert Publish.classify("i/o timeout") == :infra + assert Publish.classify("network is unreachable") == :infra + end + + test "unclassified output → :other (caller falls back to :cmd_failed)" do + assert Publish.classify("some unrelated garbage") == :other + assert Publish.classify("") == :other + end + + test "precedence: not_found beats auth/infra hits in the same string" do + # Defensive — gh has been known to print multiple lines. + assert Publish.classify("release not found\nHTTP 401") == :not_found + end + end + + # ── tag_for/1, discover_assets/3 — pure-ish surface ───────────────────── + + describe "tag_for/1" do + test "prepends otp- prefix" do + assert Publish.tag_for("abc12345") == "otp-abc12345" + end + end + + describe "candidate_basenames/0 / default_repo/0" do + test "four canonical basenames in canonical order" do + assert Publish.candidate_basenames() == [ + "otp-android", + "otp-android-arm32", + "otp-ios-sim", + "otp-ios-device" + ] + end + + test "default repo is GenericJam/mob" do + assert Publish.default_repo() == "GenericJam/mob" + end + end + + describe "discover_assets/3" do + test "returns only filenames the shell reports as files (preserves order)" do + stub(MobDev.Release.ShellMock, :file?, fn path -> + String.ends_with?(path, "otp-ios-sim-abc12345.tar.gz") or + String.ends_with?(path, "otp-ios-device-abc12345.tar.gz") + end) + + result = Publish.discover_assets(MobDev.Release.ShellMock, "/tmp", "abc12345") + + assert result == [ + "otp-ios-sim-abc12345.tar.gz", + "otp-ios-device-abc12345.tar.gz" + ] + end + + test "returns empty list when nothing exists" do + stub(MobDev.Release.ShellMock, :file?, fn _ -> false end) + + assert Publish.discover_assets(MobDev.Release.ShellMock, "/tmp", "abc12345") == [] + end + end + + # ── publish/1 happy paths ────────────────────────────────────────────── + + describe "publish/1 — release already exists, no overlapping assets" do + test "view exists + list returns unrelated + upload + verify; no create, no delete" do + with_call_recorder() + all_present() + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + record_call(argv) + + cond do + gh_list_assets?(argv) -> + {:ok, "some-other-asset.txt\n"} + + gh_view?(argv) -> + {:ok, "title: OTP pre-built runtime abc12345\n"} + + gh_upload?(argv) -> + {:ok, "uploaded\n"} + + true -> + flunk("unexpected gh call: #{inspect(argv)}") + end + end) + + assert {:ok, info} = Publish.publish(hash: "abc12345", out_dir: "/tmp") + assert info.tag == "otp-abc12345" + assert info.repo == "GenericJam/mob" + + calls = calls_made() + assert Enum.any?(calls, &gh_upload?/1) + refute Enum.any?(calls, &match?(["gh", "release", "create" | _], &1)) + refute Enum.any?(calls, &match?(["gh", "release", "delete-asset" | _], &1)) + end + end + + describe "publish/1 — release does not exist" do + test "view 404 → create → list (empty) → upload → verify" do + with_call_recorder() + all_present() + + # Track view-without-json count so the first one returns 404 + # (existence probe) and subsequent ones (list-assets) succeed. + :ets.insert(:pub_calls, {:view_count, 0}) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + record_call(argv) + + cond do + gh_list_assets?(argv) -> + {:ok, ""} + + gh_view?(argv) -> + n = :ets.update_counter(:pub_calls, :view_count, {2, 1}) + + if n == 1 do + {:error, {:cmd_failed, %{cmd: argv, exit: 1, output: "release not found"}}} + else + {:ok, "exists now\n"} + end + + match?(["gh", "release", "create", "otp-abc12345" | _], argv) -> + assert "--title" in argv + assert "--notes" in argv + + assert Enum.any?(argv, &(is_binary(&1) and String.contains?(&1, "abc12345"))), + "title or notes should include the hash" + + {:ok, "https://github.com/.../releases/tag/otp-abc12345\n"} + + gh_upload?(argv) -> + {:ok, "uploaded\n"} + + true -> + flunk("unexpected gh call: #{inspect(argv)}") + end + end) + + assert {:ok, info} = Publish.publish(hash: "abc12345", out_dir: "/tmp") + assert info.tag == "otp-abc12345" + + assert Enum.any?(calls_made(), &match?(["gh", "release", "create" | _], &1)), + "expected a gh release create call" + end + end + + describe "publish/1 — overlapping assets are deleted first" do + test "each overlapping basename triggers a delete-asset call" do + with_call_recorder() + all_present() + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + record_call(argv) + + cond do + gh_list_assets?(argv) -> + {:ok, "otp-android-abc12345.tar.gz\notp-ios-sim-abc12345.tar.gz\n"} + + gh_view?(argv) -> + {:ok, "title: existing\n"} + + match?(["gh", "release", "delete-asset" | _], argv) -> + {:ok, ""} + + gh_upload?(argv) -> + {:ok, "uploaded\n"} + + true -> + flunk("unexpected gh call: #{inspect(argv)}") + end + end) + + assert {:ok, _info} = Publish.publish(hash: "abc12345", out_dir: "/tmp") + + deletes = + calls_made() + |> Enum.filter(&match?(["gh", "release", "delete-asset" | _], &1)) + + assert length(deletes) == 2 + + assert Enum.any?(deletes, fn argv -> "otp-android-abc12345.tar.gz" in argv end) + assert Enum.any?(deletes, fn argv -> "otp-ios-sim-abc12345.tar.gz" in argv end) + end + end + + describe "publish/1 — non-default repo" do + test ":repo opt propagates to every gh call" do + with_call_recorder() + all_present() + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + record_call(argv) + + cond do + gh_list_assets?(argv) -> {:ok, ""} + gh_view?(argv) -> {:ok, "title: x\n"} + gh_upload?(argv) -> {:ok, ""} + true -> flunk("unexpected gh call: #{inspect(argv)}") + end + end) + + assert {:ok, info} = + Publish.publish(hash: "abc12345", out_dir: "/tmp", repo: "myfork/mob") + + assert info.repo == "myfork/mob" + + assert Enum.all?(calls_made(), fn argv -> "myfork/mob" in argv end), + "every gh call should carry --repo myfork/mob" + end + end + + # ── publish/1 preconditions ──────────────────────────────────────────── + + describe "publish/1 — preconditions" do + test "no tarballs in out_dir → precondition_failed with hint" do + stub(MobDev.Release.ShellMock, :file?, fn _ -> false end) + + assert {:error, {:precondition_failed, msg}} = + Publish.publish(hash: "abc12345", out_dir: "/tmp") + + assert msg =~ "no tarballs found" + assert msg =~ "abc12345" + assert msg =~ "mix mob.release.tarball" + end + + test "explicit --assets with one missing file → precondition_failed lists missing" do + stub(MobDev.Release.ShellMock, :file?, fn path -> + String.ends_with?(path, "otp-android-abc12345.tar.gz") + end) + + assert {:error, {:precondition_failed, msg}} = + Publish.publish( + hash: "abc12345", + out_dir: "/tmp", + assets: ["otp-android", "otp-ios-sim"] + ) + + assert msg =~ "missing tarballs" + assert msg =~ "otp-ios-sim-abc12345.tar.gz" + refute msg =~ "/tmp/otp-android-abc12345.tar.gz" + end + + test "explicit --assets accepts a full filename (skip basename normalization)" do + with_call_recorder() + + stub(MobDev.Release.ShellMock, :file?, fn path -> + String.ends_with?(path, "weird-name.tar.gz") + end) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + record_call(argv) + + cond do + gh_list_assets?(argv) -> {:ok, ""} + gh_view?(argv) -> {:ok, "title: x\n"} + gh_upload?(argv) -> {:ok, ""} + true -> flunk("unexpected gh call: #{inspect(argv)}") + end + end) + + assert {:ok, _} = + Publish.publish( + hash: "abc12345", + out_dir: "/tmp", + assets: ["weird-name.tar.gz"] + ) + + upload = + Enum.find(calls_made(), &match?(["gh", "release", "upload" | _], &1)) + + assert is_list(upload), "expected a gh release upload call to have been recorded" + assert Enum.any?(upload, &String.ends_with?(&1, "weird-name.tar.gz")) + refute Enum.any?(upload, &String.contains?(&1, "weird-name.tar.gz-abc12345.tar.gz")) + end + end + + # ── publish/1 — the headline error categories ────────────────────────── + + describe "publish/1 — gh failure classification" do + test "401 from gh view → :auth_required with renewal hint" do + all_present() + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + cond do + gh_list_assets?(argv) -> + flunk("should not have reached list_assets: #{inspect(argv)}") + + gh_view?(argv) -> + {:error, + {:cmd_failed, + %{cmd: argv, exit: 1, output: "HTTP 401: Bad credentials (api.github.com)"}}} + + true -> + flunk("should not have reached: #{inspect(argv)}") + end + end) + + assert {:error, {:auth_required, hint}} = + Publish.publish(hash: "abc12345", out_dir: "/tmp") + + assert hint =~ "gh auth login" + end + + test "503 from gh view → :infra_unreachable carrying the offending line" do + all_present() + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + cond do + gh_list_assets?(argv) -> + flunk("should not have reached list_assets: #{inspect(argv)}") + + gh_view?(argv) -> + {:error, + {:cmd_failed, + %{cmd: argv, exit: 1, output: "HTTP 503: Service Unavailable\nretry later"}}} + + true -> + flunk("should not have reached: #{inspect(argv)}") + end + end) + + assert {:error, {:infra_unreachable, detail}} = + Publish.publish(hash: "abc12345", out_dir: "/tmp") + + assert detail =~ "503" + end + + test "auth failure during gh upload is reclassified (not raw cmd_failed)" do + with_call_recorder() + all_present() + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + record_call(argv) + + cond do + gh_list_assets?(argv) -> + {:ok, ""} + + gh_view?(argv) -> + {:ok, "ok\n"} + + gh_upload?(argv) -> + {:error, {:cmd_failed, %{cmd: argv, exit: 1, output: "HTTP 401: token expired"}}} + + true -> + flunk("unexpected gh call: #{inspect(argv)}") + end + end) + + assert {:error, {:auth_required, _hint}} = + Publish.publish(hash: "abc12345", out_dir: "/tmp") + end + + test "unclassified gh failure falls through to :cmd_failed" do + all_present() + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + cond do + gh_list_assets?(argv) -> + flunk("unexpected: #{inspect(argv)}") + + gh_view?(argv) -> + {:error, + {:cmd_failed, + %{ + cmd: argv, + exit: 1, + output: "weird internal go error nothing about auth or network" + }}} + + true -> + flunk("unexpected: #{inspect(argv)}") + end + end) + + assert {:error, {:cmd_failed, _}} = + Publish.publish(hash: "abc12345", out_dir: "/tmp") + end + end + + # ── Helpers ──────────────────────────────────────────────────────────── + + defp all_present do + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + end + + # Order matters: gh_list_assets? must be tested BEFORE gh_view? + # since the list-assets call IS a `gh release view ... --json assets`. + defp gh_view?(argv), do: match?(["gh", "release", "view", _tag | _rest], argv) + + defp gh_list_assets?(argv) do + match?(["gh", "release", "view" | _], argv) and "--json" in argv and "assets" in argv + end + + defp gh_upload?(argv), do: match?(["gh", "release", "upload" | _], argv) + + defp with_call_recorder do + case :ets.whereis(:pub_calls) do + :undefined -> :ets.new(:pub_calls, [:public, :named_table, :ordered_set]) + _ -> :ets.delete_all_objects(:pub_calls) + end + end + + defp record_call(argv) do + n = :ets.update_counter(:pub_calls, :__count__, {2, 1}, {:__count__, 0}) + :ets.insert(:pub_calls, {n, argv}) + end + + defp calls_made do + :ets.tab2list(:pub_calls) + |> Enum.reject(fn {k, _} -> not is_integer(k) end) + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.map(&elem(&1, 1)) + end +end diff --git a/test/mob_dev/release/tarball_test.exs b/test/mob_dev/release/tarball_test.exs new file mode 100644 index 0000000..37a83f6 --- /dev/null +++ b/test/mob_dev/release/tarball_test.exs @@ -0,0 +1,570 @@ +defmodule MobDev.Release.TarballTest do + use ExUnit.Case, async: false + + import Mox + + alias MobDev.Release.Tarball + + setup :verify_on_exit! + + setup do + Application.put_env(:mob_dev, :release_shell, MobDev.Release.ShellMock) + on_exit(fn -> Application.delete_env(:mob_dev, :release_shell) end) + :ok + end + + # ── target_spec/1 — pinned surface ─────────────────────────────────── + + describe "target_spec/1" do + test "android_arm64 tarball basename is `otp-android` (NOT otp-android-arm64)" do + spec = Tarball.target_spec(:android_arm64) + + # Load-bearing asymmetry — MobDev.OtpDownloader's @otp_hash + # cache convention depends on this. Changing to otp-android-arm64 + # would break every existing cache entry. + assert spec.tarball_basename == "otp-android" + assert spec.arch_dir == "aarch64-unknown-linux-android" + assert spec.include_exqlite + refute spec.include_epmd_source + assert spec.borrow_crypto_apps == nil + end + + test "android_arm32 basename includes the arm32 suffix" do + spec = Tarball.target_spec(:android_arm32) + assert spec.tarball_basename == "otp-android-arm32" + assert spec.arch_dir == "arm-unknown-linux-androideabi" + assert spec.include_exqlite + refute spec.include_epmd_source + end + + test "ios_sim borrows from Android install, no exqlite, excludes test apps" do + spec = Tarball.target_spec(:ios_sim) + assert spec.tarball_basename == "otp-ios-sim" + refute spec.include_exqlite + refute spec.include_epmd_source + assert spec.borrow_crypto_apps == "/tmp/otp-android" + # Spurious build dirs from the iOS-sim cross-compile. + assert "beamhello" in spec.tar_excludes + assert "test_app" in spec.tar_excludes + end + + test "ios_device borrows from Android AND ships EPMD source" do + spec = Tarball.target_spec(:ios_device) + assert spec.tarball_basename == "otp-ios-device" + refute spec.include_exqlite + assert spec.include_epmd_source + assert spec.borrow_crypto_apps == "/tmp/otp-android" + end + + test "android_arm64 verifies crypto.so + public_key/ssl beams; arm32 doesn't" do + arm64 = Tarball.target_spec(:android_arm64) + arm32 = Tarball.target_spec(:android_arm32) + + assert "lib/crypto-.*/priv/lib/crypto.so" in arm64.additional_verifies + assert "lib/public_key-.*/ebin/public_key.beam" in arm64.additional_verifies + assert "lib/ssl-.*/ebin/ssl.beam" in arm64.additional_verifies + + # arm32 historical: shell version didn't verify these, so we don't + # either. Reason: the arm32 OTP install layout differs (no static- + # link symbol search via nm in the shell). Keeping parity. + assert arm32.additional_verifies == [] + end + + test "ios_device verifies EPMD source files + arch-specific config.h" do + spec = Tarball.target_spec(:ios_device) + + assert "erts/epmd/src/epmd.c" in spec.additional_verifies + assert "erts/epmd/src/epmd_srv.c" in spec.additional_verifies + assert "erts/epmd/src/epmd_cli.c" in spec.additional_verifies + assert "erts/aarch64-apple-ios/config.h" in spec.additional_verifies + end + + test "targets/0 enumerates all four in canonical order" do + assert Tarball.targets() == [:android_arm64, :android_arm32, :ios_sim, :ios_device] + end + end + + # ── tarball_path/3 — pure assembly ─────────────────────────────────── + + describe "tarball_path/3" do + test "android_arm64 is `<out>/otp-android-<hash>.tar.gz`" do + target = Tarball.target_spec(:android_arm64) + + assert Tarball.tarball_path(target, "/tmp", "abc12345") == + "/tmp/otp-android-abc12345.tar.gz" + end + + test "ios_device interpolates the basename + hash" do + target = Tarball.target_spec(:ios_device) + + assert Tarball.tarball_path(target, "/out", "feedface") == + "/out/otp-ios-device-feedface.tar.gz" + end + end + + # ── exqlite version parsers (pure) ─────────────────────────────────── + + describe "parse_exqlite_version_from_lock/1" do + test "extracts version from a canonical mix.lock entry" do + content = """ + %{ + "exqlite": {:hex, :exqlite, "0.39.0", "abc123", [:make, :mix], [...], "hexpm", "..."}, + "jason": {:hex, :jason, "1.4.4", "..."} + } + """ + + assert Tarball.parse_exqlite_version_from_lock(content) == {:ok, "0.39.0"} + end + + test "handles entries on a single long line" do + content = + ~S({"exqlite": {:hex, :exqlite, "0.42.1", "hash", [:make, :mix], [], "hexpm", "outer-hash"},}) + + assert Tarball.parse_exqlite_version_from_lock(content) == {:ok, "0.42.1"} + end + + test "tolerates extra whitespace inside the tuple" do + content = ~S("exqlite": {:hex, :exqlite, "1.0.0",) + assert Tarball.parse_exqlite_version_from_lock(content) == {:ok, "1.0.0"} + end + + test "rejects when exqlite isn't present" do + content = ~S("jason": {:hex, :jason, "1.4.4", "..."}) + assert {:error, {:parse_failed, _}} = Tarball.parse_exqlite_version_from_lock(content) + end + + test "doesn't false-match a similarly-named package" do + # `exqlite_extra` is hypothetical, but the regex anchor must + # require the exact "exqlite" key. + content = ~S("exqlite_extra": {:hex, :exqlite_extra, "9.9.9", "..."}) + assert {:error, {:parse_failed, _}} = Tarball.parse_exqlite_version_from_lock(content) + end + end + + describe "parse_exqlite_version_from_app_file/1" do + test "extracts vsn from a real exqlite.app shape" do + content = """ + {application, exqlite, + [{description, "An Elixir SQLite3 library"}, + {modules, [...]}, + {vsn, "0.39.0"}, + {applications, [kernel, stdlib, elixir]} + ]}. + """ + + assert Tarball.parse_exqlite_version_from_app_file(content) == {:ok, "0.39.0"} + end + + test "rejects when vsn key is absent" do + content = "{application, exqlite, [{description, \"...\"}]}." + assert {:error, {:parse_failed, _}} = Tarball.parse_exqlite_version_from_app_file(content) + end + end + + # ── check_entries/3 — the verify_tarball heart ─────────────────────── + + describe "check_entries/3" do + test "returns :ok when every expected entry matches at least one line" do + listing = """ + mob_dev_stage_123/ + mob_dev_stage_123/erts-17.0/ + mob_dev_stage_123/erts-17.0/lib/crypto.a + mob_dev_stage_123/erts-17.0/lib/libcrypto.a + mob_dev_stage_123/lib/elixir/ebin/elixir.app + """ + + assert :ok = + Tarball.check_entries( + listing, + [ + "erts-17.0", + "lib/elixir/ebin/elixir.app", + "erts-17.0/lib/crypto.a" + ], + "/tmp/x.tar.gz" + ) + end + + test "regex wildcards match expected entries" do + listing = """ + stage/lib/crypto-5.6/priv/lib/crypto.so + stage/lib/public_key-1.18/ebin/public_key.beam + stage/lib/ssl-11.4/ebin/ssl.beam + """ + + assert :ok = + Tarball.check_entries( + listing, + [ + "lib/crypto-.*/priv/lib/crypto.so", + "lib/public_key-.*/ebin/public_key.beam", + "lib/ssl-.*/ebin/ssl.beam" + ], + "/tmp/x.tar.gz" + ) + end + + test "fails with the missing entry named when one is absent" do + listing = "stage/erts-17.0/\nstage/erts-17.0/lib/crypto.a\n" + + assert {:error, {:precondition_failed, msg}} = + Tarball.check_entries( + listing, + ["erts-17.0", "lib/elixir/ebin/elixir.app"], + "/tmp/x.tar.gz" + ) + + assert msg =~ "lib/elixir/ebin/elixir.app" + assert msg =~ "/tmp/x.tar.gz" + end + + test "iOS device verify catches missing EPMD source (load-bearing for static-link)" do + # If the EPMD source isn't in the tarball, downstream + # build_device builds will fail when trying to static-link + # EPMD into the iOS app — but only at the END of the build + # pipeline, hours of error report time. Here it fails before + # the tarball ships. + listing = """ + stage/erts-17.0/ + stage/erts-17.0/lib/crypto.a + stage/erts-17.0/lib/libcrypto.a + stage/lib/elixir/ebin/elixir.app + """ + + ios_device = Tarball.target_spec(:ios_device) + + assert {:error, {:precondition_failed, msg}} = + Tarball.check_entries( + listing, + Tarball.required_entries(ios_device, %{erts_vsn: "17.0"}), + "/tmp/otp-ios-device.tar.gz" + ) + + assert msg =~ "epmd" + end + end + + # ── required_entries/2 — pinned per-target verify lists ───────────── + + describe "required_entries/2" do + test "interpolates erts_vsn into the universal entries" do + target = Tarball.target_spec(:android_arm64) + entries = Tarball.required_entries(target, %{erts_vsn: "17.0"}) + + assert "erts-17.0" in entries + assert "erts-17.0/lib/crypto.a" in entries + assert "erts-17.0/lib/libcrypto.a" in entries + assert "lib/elixir/ebin/elixir.app" in entries + end + + test "android_arm64 appends the crypto.so / public_key / ssl entries" do + target = Tarball.target_spec(:android_arm64) + entries = Tarball.required_entries(target, %{erts_vsn: "17.0"}) + + assert "lib/crypto-.*/priv/lib/crypto.so" in entries + assert "lib/public_key-.*/ebin/public_key.beam" in entries + assert "lib/ssl-.*/ebin/ssl.beam" in entries + end + + test "ios_device appends EPMD source + arch config.h entries" do + target = Tarball.target_spec(:ios_device) + entries = Tarball.required_entries(target, %{erts_vsn: "17.0"}) + + assert "erts/epmd/src/epmd.c" in entries + assert "erts/aarch64-apple-ios/config.h" in entries + end + end + + # ── build/2 against the Mox — Android arm64 happy path ────────────── + # The full pipeline is long; this test asserts the call order is right + # and the tarball gets named correctly. + + describe "build/2 — android_arm64" do + test "stages otp_release, copies static libs, bundles exqlite, tars + verifies" do + {otp_src, exqlite_build} = mk_tmp_project() + + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :fetch_env, fn _ -> :error end) + stub(MobDev.Release.ShellMock, :mkdir_p, fn _ -> :ok end) + + cmd_log = :ets.new(:cmds, [:public, :ordered_set]) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, opts -> + :ets.insert(cmd_log, {System.monotonic_time(), argv, opts}) + + cond do + hd(argv) == "mktemp" -> + {:ok, "/tmp/fake-stage\n"} + + hd(argv) == "tar" and "tzf" in argv -> + # Return a listing that satisfies the verify step. + {:ok, + """ + fake-stage/erts-17.0/ + fake-stage/erts-17.0/lib/crypto.a + fake-stage/erts-17.0/lib/libcrypto.a + fake-stage/lib/elixir/ebin/elixir.app + fake-stage/lib/crypto-5.6/priv/lib/crypto.so + fake-stage/lib/public_key-1.18/ebin/public_key.beam + fake-stage/lib/ssl-11.4/ebin/ssl.beam + """} + + true -> + {:ok, ""} + end + end) + + assert {:ok, info} = + Tarball.build(:android_arm64, + otp_src: otp_src, + hash: "abc12345", + out_dir: "/out", + otp_release: "/tmp/otp-android", + openssl_prefix: "/tmp/openssl-android-arm64", + exqlite_build: exqlite_build + ) + + assert info.tarball == "/out/otp-android-abc12345.tar.gz" + assert info.hash == "abc12345" + assert info.erts_vsn == "17.0" + + # Inspect the sequence + calls = :ets.tab2list(cmd_log) |> Enum.map(fn {_, argv, _} -> argv end) + + # mktemp -d came first + assert hd(hd(calls)) == "mktemp" + + # cp -r OTP_RELEASE/. STAGE — there's a copy with trailing "/." + assert Enum.any?(calls, fn argv -> + hd(argv) == "cp" and Enum.any?(argv, &String.ends_with?(&1, "/tmp/otp-android/.")) + end) + + # tar czf <tarball> — exists, with the right output path + assert tar_call = + Enum.find(calls, fn argv -> + hd(argv) == "tar" and "czf" in argv + end) + + assert "/out/otp-android-abc12345.tar.gz" in tar_call + + # tar tzf for verify + assert Enum.any?(calls, fn argv -> hd(argv) == "tar" and "tzf" in argv end) + + # Cleanup the temp project root we made + File.rm_rf!(otp_src) + end + end + + describe "build/2 — ios_sim" do + test "borrows from Android install, uses iossimulator arch, excludes test-app dirs" do + {otp_src, _exqlite_build} = mk_tmp_project() + + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :fetch_env, fn _ -> :error end) + stub(MobDev.Release.ShellMock, :mkdir_p, fn _ -> :ok end) + + cmd_log = :ets.new(:ios_cmds, [:public, :ordered_set]) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + :ets.insert(cmd_log, {System.monotonic_time(), argv}) + + cond do + hd(argv) == "mktemp" -> + {:ok, "/tmp/fake-stage-ios\n"} + + # Glob for crypto-*/ etc. via bash -c "ls -d ... | head -1" + hd(argv) == "bash" and Enum.any?(argv, &String.contains?(&1, "ls -d")) -> + # Just return a fake match. + {:ok, "/tmp/otp-android/lib/crypto-5.6/"} + + hd(argv) == "tar" and "tzf" in argv -> + {:ok, + """ + stage/erts-17.0/ + stage/erts-17.0/lib/crypto.a + stage/erts-17.0/lib/libcrypto.a + stage/lib/elixir/ebin/elixir.app + """} + + true -> + {:ok, ""} + end + end) + + assert {:ok, info} = + Tarball.build(:ios_sim, + otp_src: otp_src, + hash: "feed1234", + out_dir: "/out", + otp_release: "/tmp/otp-ios-sim", + openssl_prefix: "/tmp/openssl-ios-sim" + # no :exqlite_build — iOS doesn't ship exqlite + ) + + assert info.tarball == "/out/otp-ios-sim-feed1234.tar.gz" + + calls = :ets.tab2list(cmd_log) |> Enum.map(fn {_, argv} -> argv end) + + # tar invocation should include --exclude= flags for the test-app dirs + assert tar_call = Enum.find(calls, fn argv -> hd(argv) == "tar" and "czf" in argv end) + assert Enum.any?(tar_call, &String.ends_with?(&1, "/beamhello")) + assert Enum.any?(tar_call, &String.ends_with?(&1, "/test_app")) + + File.rm_rf!(otp_src) + end + end + + # ── Preconditions ──────────────────────────────────────────────────── + + describe "build/2 preconditions" do + test "missing OTP_SRC → precondition_failed" do + stub(MobDev.Release.ShellMock, :dir?, fn _ -> false end) + stub(MobDev.Release.ShellMock, :fetch_env, fn _ -> :error end) + + assert {:error, {:precondition_failed, msg}} = + Tarball.build(:android_arm64, + otp_src: "/nope", + hash: "abc12345", + erts_vsn: "17.0", + exqlite_build: "/x" + ) + + assert msg =~ "OTP_SRC" + end + + test "missing OTP_RELEASE → precondition_failed pointing at MobDev.Release.OTP" do + {otp_src, exqlite_build} = mk_tmp_project() + + stub(MobDev.Release.ShellMock, :fetch_env, fn _ -> :error end) + + # Path-based stubbing: everything exists EXCEPT the otp_release + # path. Clearer than counter-based ordering. + stub(MobDev.Release.ShellMock, :dir?, fn path -> + not String.starts_with?(path, "/nonexistent") + end) + + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + + assert {:error, {:precondition_failed, msg}} = + Tarball.build(:android_arm64, + otp_src: otp_src, + hash: "abc12345", + erts_vsn: "17.0", + otp_release: "/nonexistent", + openssl_prefix: "/openssl", + exqlite_build: exqlite_build + ) + + assert msg =~ "otp_release missing" + assert msg =~ "MobDev.Release.OTP.build" + + File.rm_rf!(otp_src) + end + + test "android target without exqlite_build → precondition_failed", %{} do + {otp_src, _exqlite_build} = mk_tmp_project() + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :fetch_env, fn _ -> :error end) + + assert {:error, {:precondition_failed, msg}} = + Tarball.build(:android_arm64, + otp_src: otp_src, + hash: "abc12345" + # no :exqlite_build + ) + + assert msg =~ "exqlite_build required" + + File.rm_rf!(otp_src) + end + end + + # ── verify failure surfaces as precondition_failed ─────────────────── + + describe "verify_tarball — failure surfaces" do + test "missing crypto.so in the listing fails the build" do + {otp_src, exqlite_build} = mk_tmp_project() + + stub(MobDev.Release.ShellMock, :dir?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :file?, fn _ -> true end) + stub(MobDev.Release.ShellMock, :fetch_env, fn _ -> :error end) + stub(MobDev.Release.ShellMock, :mkdir_p, fn _ -> :ok end) + + stub(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> + cond do + hd(argv) == "mktemp" -> + {:ok, "/tmp/fake\n"} + + hd(argv) == "tar" and "tzf" in argv -> + # Listing missing the crypto.so entry — exactly the silent + # shipping bug we want to catch. + {:ok, + """ + stage/erts-17.0/ + stage/erts-17.0/lib/crypto.a + stage/erts-17.0/lib/libcrypto.a + stage/lib/elixir/ebin/elixir.app + stage/lib/public_key-1.18/ebin/public_key.beam + stage/lib/ssl-11.4/ebin/ssl.beam + """} + + true -> + {:ok, ""} + end + end) + + assert {:error, {:precondition_failed, msg}} = + Tarball.build(:android_arm64, + otp_src: otp_src, + hash: "abc12345", + out_dir: "/out", + exqlite_build: exqlite_build + ) + + assert msg =~ "verify failed" + assert msg =~ "crypto-.*/priv/lib/crypto.so" + + File.rm_rf!(otp_src) + end + end + + # ── Helpers ────────────────────────────────────────────────────────── + + # mk_tmp_project returns `{otp_src_path, exqlite_build_path}` where + # both directories exist and contain enough fixture files for the + # tarball build to read: + # * `otp_src/erts/vsn.mk` — read by Helpers.erts_version + # * `otp_src/otp_build` — checked by precheck (file exists) + # * `exqlite_build/ebin/exqlite.app` — fallback exqlite version source + # * `<project_root>/mix.lock` — primary exqlite version source + # (sits 4 levels above exqlite_build, project-root convention) + defp mk_tmp_project do + uniq = System.unique_integer([:positive]) + base = Path.join(System.tmp_dir!(), "mob_dev_tarball_#{uniq}") + + otp_src = Path.join(base, "otp_src") + File.mkdir_p!(Path.join(otp_src, "erts")) + File.write!(Path.join([otp_src, "erts", "vsn.mk"]), "VSN = 17.0\n") + File.touch!(Path.join(otp_src, "otp_build")) + + # Build a fake project layout: project_root/_build/dev/lib/exqlite/ebin/ + project_root = Path.join(base, "user_project") + exqlite_build = Path.join([project_root, "_build/dev/lib/exqlite"]) + File.mkdir_p!(Path.join(exqlite_build, "ebin")) + + # The .app file with a vsn entry (fallback parse source) + File.write!(Path.join([exqlite_build, "ebin", "exqlite.app"]), """ + {application, exqlite, [{vsn, "0.39.0"}]}. + """) + + # mix.lock at the project root (preferred parse source) + File.write!(Path.join(project_root, "mix.lock"), """ + %{"exqlite": {:hex, :exqlite, "0.39.0", "abc", [:make, :mix], [], "hexpm", "outer"}} + """) + + {otp_src, exqlite_build} + end +end diff --git a/test/mob_dev/release_android_test.exs b/test/mob_dev/release_android_test.exs new file mode 100644 index 0000000..59d4e90 --- /dev/null +++ b/test/mob_dev/release_android_test.exs @@ -0,0 +1,65 @@ +defmodule MobDev.ReleaseAndroidTest do + use ExUnit.Case, async: true + + alias MobDev.ReleaseAndroid + + describe "real_crypto_available?/1" do + # Regression guard for the 2026-05-21 Play Console internal-track + # crash: the Android release pipeline unconditionally replaced + # crypto.beam with a stub (supports/1 -> []), assuming the OTP build + # had no OpenSSL NIF. But the Android CMakeLists.txt statically links + # crypto.a + libcrypto.a and registers crypto_nif_init — so the real + # :crypto works. The stub broke :ssl.versions/0 and every HTTPS + # request. The fix gates the stub on the ABSENCE of crypto.a. + + setup do + otp_dir = Path.join(System.tmp_dir!(), "mob_otp_test_#{System.unique_integer([:positive])}") + on_exit(fn -> File.rm_rf!(otp_dir) end) + %{otp_dir: otp_dir} + end + + test "true when erts-*/lib/crypto.a exists", %{otp_dir: otp_dir} do + lib = Path.join(otp_dir, "erts-17.0/lib") + File.mkdir_p!(lib) + File.write!(Path.join(lib, "crypto.a"), "") + + assert ReleaseAndroid.real_crypto_available?(otp_dir) + end + + test "matches whatever erts version directory is present", %{otp_dir: otp_dir} do + lib = Path.join(otp_dir, "erts-16.3/lib") + File.mkdir_p!(lib) + File.write!(Path.join(lib, "crypto.a"), "") + + assert ReleaseAndroid.real_crypto_available?(otp_dir) + end + + test "false when crypto.a is absent (stub path)", %{otp_dir: otp_dir} do + File.mkdir_p!(Path.join(otp_dir, "erts-17.0/lib")) + + refute ReleaseAndroid.real_crypto_available?(otp_dir) + end + + test "false when the OTP dir doesn't exist at all", %{otp_dir: otp_dir} do + refute ReleaseAndroid.real_crypto_available?(otp_dir) + end + end + + describe "otp_zip_path/0" do + # Regression guard: this used to point at the shared `src/main/assets/` + # source set, which Gradle merges into every build variant. A release + # build then left `otp.zip` behind where a subsequent debug build would + # pick it up too — MobBridge.kt's extractOtpIfNeeded() re-extracts it on + # the next app launch (keyed off PackageInfo.lastUpdateTime, which + # changes on every reinstall), silently overwriting freshly pushed dev + # BEAMs with the stale release snapshot. The zip must live under the + # release-variant asset source set instead, which Gradle never merges + # into debug builds. + test "resolves under the release-variant asset source set, not shared main" do + path = ReleaseAndroid.otp_zip_path() + + assert String.ends_with?(path, "android/app/src/release/assets/otp.zip") + refute String.contains?(path, "src/main/assets") + end + end +end diff --git a/test/mob_dev/release_script_test.exs b/test/mob_dev/release_script_test.exs new file mode 100644 index 0000000..bde5f6d --- /dev/null +++ b/test/mob_dev/release_script_test.exs @@ -0,0 +1,409 @@ +defmodule MobDev.ReleaseScriptTest do + use ExUnit.Case, async: true + + # Asserts the SHAPE of the bash script generated by `mix mob.release`. + # + # Why these exist: between mob_dev 0.3.27 and 0.3.31 the release script + # accreted ~half a dozen distinct fixes for App Store Connect validator + # errors (strip .so/.a from bundle, full DT* plist key set, ditto + # packaging flags, MOB_RELEASE on mob_nif.m, etc.). Every fix was + # discovered the slow way — uploading to App Store, reading Apple's + # rejection email, fixing, re-uploading. Each round trip cost minutes + # plus a CFBundleVersion bump. + # + # These tests catch any accidental regression of those fixes at + # `mix test` time. They don't EXECUTE the script (that needs Xcode + + # a paid Apple Developer account); they just check that the bash + # commands we depend on are present. + + alias MobDev.Release + + setup_all do + {:ok, sh: Release.release_device_sh()} + end + + describe "Apple bundle policy: strip disallowed binaries" do + # Apple error 90171 — `.so`, `.a`, and standalone executables in the + # bundle. Apple permits exactly one Mach-O per `.app` (the + # CFBundleExecutable). Static archives are linked into the main + # binary; loadable libs and standalone CLIs must be stripped. + + test "deletes all .so files from the bundled OTP tree", %{sh: sh} do + assert sh =~ ~s|find "$OTP_BUNDLE" -type f \\( -name "*.so"| + end + + test "deletes all .a files from the bundled OTP tree", %{sh: sh} do + assert sh =~ ~r/find "\$OTP_BUNDLE" -type f.*-name "\*\.a".*-delete/ + end + + test "deletes priv/bin/ standalone executables (memsup, cpu_sup, …)", %{sh: sh} do + assert sh =~ ~s|find "$OTP_BUNDLE" -path "*/priv/bin/*" -type f -delete| + end + + test "deletes erts-*/bin/ standalone executables (erl_call, erlexec, …)", %{sh: sh} do + assert sh =~ ~r|find "\$OTP_BUNDLE/\$ERTS_VSN/bin" -type f -delete| + end + + test "drops unused OTP libs the framework doesn't need", %{sh: sh} do + # The strip is a `for prefix in <names>; do rm -rf + # "$OTP_BUNDLE/lib/$prefix-"* ; done` loop, so each prefix appears + # in the loop's word list rather than spelled out per line. Locate + # the loop and assert the prefixes are in its head. + [_, after_for] = String.split(sh, "for prefix in ", parts: 2) + [loop_head, _] = String.split(after_for, "; do", parts: 2) + + # If apps emerge that DO need one of these, drop it from the strip + # set in lib/mob_dev/release.ex AND from this test list. + # + # `ssh` stripped 2026-05-06 — empirical snapshot from a running + # pigeon iOS-sim build showed 0 of 43 ssh modules ever loaded. + # No mob app should need a runtime SSH client/server. + for prefix <- ~w(megaco runtime_tools erl_interface os_mon wx et eunit + observer debugger diameter edoc tools snmp dialyzer + syntax_tools parsetools xmerl reltool inets ftp tftp + ssh) do + assert loop_head =~ prefix, + "expected the OTP-strip loop to drop #{prefix}-* libs" + end + + # And the body of the loop should actually rm under $OTP_BUNDLE/lib. + # The loop body lives inside a `bash -c '...'` invocation (so each step + # gets a [SLIM:tag] size delta), which means $OTP_BUNDLE is unquoted out + # of the single-quoted heredoc — match either form. + assert sh =~ ~r/rm -rf "(?:'")?\$OTP_BUNDLE(?:"')?\/lib\/\$prefix-"/ + end + + test "does NOT strip compiler — Ecto.Migrator needs it at runtime", %{sh: sh} do + # Regression guard for the 2026-05-21 TestFlight crash: stripping + # compiler-* removed :compiler, which Ecto.Migrator requires to + # Code.compile_file the .exs migrations. The BEAM crashed during + # boot with {:badmatch, {:error, :enoent, :"compiler.app"}} before + # the first screen rendered — splash spinner forever. + [_, after_for] = String.split(sh, "for prefix in ", parts: 2) + [loop_head, _] = String.split(after_for, "; do", parts: 2) + + refute loop_head =~ ~r/\bcompiler\b/, + "compiler must NOT be in the OTP-strip loop — Ecto.Migrator " <> + "compiles .exs migrations at runtime and needs :compiler" + end + end + + describe "native sources kept in sync with the framework" do + test "globs all mob Swift sources so new files auto-compile", %{sh: sh} do + # The release swiftc input globs $MOB_DIR/ios/*.swift rather than listing + # files by name, so a newly-added mob Swift file (e.g. MobGpuView.swift, + # which MobRootView references) is compiled without a release.ex edit. + # Listing files by name is exactly how master's iOS build broke when + # MobGpuView.swift landed but older builds didn't know to compile it. + assert sh =~ ~s|"$MOB_DIR"/ios/*.swift| + refute sh =~ ~s|"$MOB_DIR/ios/MobRootView.swift"| + end + + test "screenshot NIF is opt-in: mob_nif.m compile passes -DMOB_ENABLE_SCREENSHOT only when set", + %{sh: sh} do + # `${VAR:+flag}` expands to the flag only when MOB_ENABLE_SCREENSHOT is non-empty + # (set by ios_release_screenshot: true), so a default release build never gets it + # and the public-API screenshot NIF stays stripped unless the host opts in. + assert sh =~ ~s|${MOB_ENABLE_SCREENSHOT:+-DMOB_ENABLE_SCREENSHOT}| + # Still always stripping the private-input harness via -DMOB_RELEASE. + assert sh =~ "-DMOB_RELEASE" + end + + test "compiles the per-app generated driver_tab, not $MOB_DIR/ios", %{sh: sh} do + # driver_tab moved to priv/generated (regenerated per-app via + # `mix mob.regen_driver_tab`). The legacy $MOB_DIR/ios/driver_tab_ios.c + # path no longer exists. + assert sh =~ ~s|-c "priv/generated/driver_tab_ios.c"| + refute sh =~ ~s|"$MOB_DIR/ios/driver_tab_ios.c"| + end + + test "links crypto.a + libcrypto.a (crypto_nif_init / TLS)", %{sh: sh} do + assert sh =~ ~s|$OTP_ROOT/$ERTS_VSN/lib/crypto.a| + assert sh =~ ~s|$OTP_ROOT/$ERTS_VSN/lib/libcrypto.a| + end + + test "generates + links the erl_errno_id_unknown weak stub", %{sh: sh} do + # libbeam.a's erl_posix_str.o references erl_errno_id_unknown but the + # bundled OTP doesn't define it → undefined-symbol link error. + assert sh =~ "erl_errno_id_unknown" + assert sh =~ ~s|"$BUILD_DIR/erl_errno_id_compat.o"| + + # The shim must be written with a real newline: this is a ~S (raw) heredoc, + # so `printf '%s\\n'` reaches bash verbatim and emits a literal backslash-n + # into the C file (clang rejects the trailing `}\n`). `printf '%s\n'` (one + # backslash) emits a newline. Regression guard for that escaping bug. + assert sh =~ ~S|printf '%s\n' '__attribute__((weak))| + refute sh =~ ~S|printf '%s\\n'| + end + + test "does NOT compile the old md5/no-op crypto+ssl shims into BEAMS_DIR", %{sh: sh} do + # The shims shadowed the real crypto-5.9/ssl-11.7 beams on the -pa + # path, breaking TLS (`:ssl.versions/0 undefined`). The runtime ships + # real crypto (linked via crypto.a) + ssl, so the shims must not be + # generated. See decisions/2026-05-25-real-crypto-ssl-on-device.md. + refute sh =~ "Crypto shim for iOS" + refute sh =~ "SSL shim for iOS" + refute sh =~ ~s|erlc -o "$BEAMS_DIR" "$SSL_TMP/ssl.erl"| + refute sh =~ ~s|erlc -o "$BEAMS_DIR" "$CRYPTO_TMP/crypto.erl"| + end + end + + describe "activated-plugin NIFs are compiled + linked" do + # driver_tab_ios references each activated plugin's <module>_nif_init. + # Before this fix the release script hand-compiled a fixed object list and + # never touched plugins, so any app with NIF plugins (all of them) died at + # link with "Undefined symbols: _<module>_nif_init". The dev build already + # compiled these via build.zig -Dplugin_c_nifs; this brings the release + # path to parity. + + test "loops over MOB_PLUGIN_IOS_NIF_SOURCES and compiles each", %{sh: sh} do + assert sh =~ ~s|for SRC in $MOB_PLUGIN_IOS_NIF_SOURCES| + end + + test "derives the NIF libname from the source basename", %{sh: sh} do + # basename minus extension → STATIC_ERLANG_NIF_LIBNAME, so ERL_NIF_INIT + # emits <name>_nif_init matching the driver table. + assert sh =~ ~s|NAME=$(basename "$SRC"); NAME="${NAME%.*}"| + assert sh =~ ~s|-DSTATIC_ERLANG_NIF -DSTATIC_ERLANG_NIF_LIBNAME="$NAME"| + end + + test "compiles ObjC (.m) sources with -fobjc-arc and always -fmodules", %{sh: sh} do + # -fmodules autolinks every framework the source @imports (a plugin may + # import frameworks beyond its manifest set, e.g. Accelerate). + assert sh =~ ~s|*.m) ARC="-fobjc-arc" ;;| + assert sh =~ ~s|$CC $ARC -fmodules $IFLAGS| + end + + test "adds the compiled plugin objects to the final link line", %{sh: sh} do + assert sh =~ ~r/PLUGIN_OBJS="\$PLUGIN_OBJS \$BUILD_DIR\/\$NAME\.o"/ + # $PLUGIN_OBJS is on the swiftc link command (unquoted so it word-splits). + assert sh =~ ~r/"\$BUILD_DIR\/erl_errno_id_compat\.o" \\\n\s*\$PLUGIN_OBJS \\/ + end + + test "passes each declared framework explicitly to the linker", %{sh: sh} do + assert sh =~ ~s|for FW in $MOB_PLUGIN_IOS_FRAMEWORKS| + assert sh =~ ~s|-Xlinker -framework -Xlinker $FW| + assert sh =~ ~s|$PLUGIN_FRAMEWORK_FLAGS \\| + end + end + + describe "test harness compiled out of release builds" do + # Apple error code 50 — non-public selectors. Mob's synthetic-touch + # NIFs (`tap_xy`, `swipe_xy`, …) use private UIKit APIs. mob 0.5.12 + # wraps them in `#if !MOB_RELEASE`; mob_dev 0.3.29 added the + # `-DMOB_RELEASE` flag to mob_nif.m's compile command (was + # previously only on mob_beam.m). Both are needed. + + test "compiles mob_nif.m with -DMOB_RELEASE", %{sh: sh} do + # Find the mob_nif.m compile invocation specifically. The flag + # must land on this file's $CC line, not just on mob_beam.m which + # uses a separate compile invocation. (Earlier bug: mob_dev 0.3.28 + # had -DMOB_RELEASE only on mob_beam.m, so the test-harness + # `#if !MOB_RELEASE` in mob_nif.m always evaluated true.) + mob_nif_block = nif_compile_block(sh, "mob_nif.m") + assert mob_nif_block =~ "DMOB_RELEASE" + end + + test "compiles mob_beam.m with -DMOB_RELEASE", %{sh: sh} do + mob_beam_block = nif_compile_block(sh, "mob_beam.m") + assert mob_beam_block =~ "DMOB_RELEASE" + end + end + + describe "Info.plist build-environment keys (DT* set)" do + # Apple error 90534 — "Unsupported SDK or Xcode version". Apple's + # validator cross-references DTSDKBuild + DTXcodeBuild against an + # allow-list of accepted Xcode releases. Without the full DT* set, + # the upload is rejected even when Xcode itself is current. + + test "emits MinimumOSVersion (error 90065/90530)", %{sh: sh} do + assert sh =~ ":MinimumOSVersion" + end + + test "emits DTPlatformName (error 90507)", %{sh: sh} do + assert sh =~ ":DTPlatformName" + end + + test "emits the full DT* environment set", %{sh: sh} do + for key <- ~w(DTSDKName DTSDKBuild DTPlatformVersion DTPlatformBuild + DTXcode DTXcodeBuild DTCompiler BuildMachineOSBuild) do + assert sh =~ "\"#{key}=", "expected the DT* loop to emit #{key}" + end + end + + test "emits UIDeviceFamily defaulting to iPhone-only (error 90102)", %{sh: sh} do + # Two PlistBuddy calls: one Add :UIDeviceFamily as an array, one + # Add :UIDeviceFamily:0 as integer 1 (iPhone). Apps that want + # universal can override in their source Info.plist before this + # block runs (the Add will fail and we won't overwrite). + assert sh =~ "Add :UIDeviceFamily array" + assert sh =~ "Add :UIDeviceFamily:0 integer 1" + end + + test "emits CFBundleSupportedPlatforms = iPhoneOS (error 90562)", %{sh: sh} do + assert sh =~ "Add :CFBundleSupportedPlatforms array" + assert sh =~ "Add :CFBundleSupportedPlatforms:0 string iPhoneOS" + end + + test "DTXcode encoding uses MAJOR×100 + MINOR×10 + PATCH (4-digit form)", %{sh: sh} do + # Encoding bug history: first attempt used MAJOR×1000 which + # produced 5 digits ("26040" for Xcode 26.4) and Apple's allow-list + # rejected the exact match. Correct form is 4 digits — Xcode 16.4 + # → 1640, Xcode 26.4 → 2640. + assert sh =~ ~r/XCODE_MAJOR \* 100 \+ XCODE_MINOR \* 10 \+ XCODE_PATCH/ + end + end + + describe "IPA packaging preserves bundle structure" do + # Apple error 90071 — CodeResources must be a symbolic link. + # `zip -r` flattens symlinks; `ditto -c -k` preserves them. The + # extra ditto flags strip macOS resource forks and quarantine + # attributes that would otherwise emit `__MACOSX/` and `._<file>` + # AppleDouble sidecars that Apple's validator can flag. + + test "uses ditto, not zip, for IPA packaging", %{sh: sh} do + assert sh =~ "ditto -c -k" + + refute sh =~ "zip -qr", + "release script still uses `zip -qr` — should be `ditto -c -k …` for symlink preservation" + end + + test "ditto invocation strips macOS-only resource forks / xattrs / quarantine", %{sh: sh} do + # All three flags matter — without them, ditto preserves + # extended attributes from the OTP cross-build cache and emits + # `._<file>` AppleDouble sidecars inside the IPA. + assert sh =~ "--norsrc" + assert sh =~ "--noextattr" + assert sh =~ "--noqtn" + end + + test "preserves --keepParent for the Payload/ wrapper directory", %{sh: sh} do + assert sh =~ "--keepParent" + end + + test "uses cp -RP (not cp -R) when staging the .app for ditto", %{sh: sh} do + # cp -R follows symlinks and turns them into regular files; -P + # preserves them. Without -P, the codesign-created CodeResources + # symlink gets flattened before ditto sees it. + assert sh =~ "cp -RP" + end + + test "strips AppleDouble sidecars defensively before ditto", %{sh: sh} do + assert sh =~ "dot_clean" + assert sh =~ ~s|find "$IPA_STAGE/Payload" -name '._*' -delete| + end + end + + describe "code signing" do + # Distribution signing (no get-task-allow), separate from dev + # signing. The script reads the distribution identity + profile + # UUID from env vars set by Release.build_ipa/1's resolution step. + + test "signs with --options runtime + --timestamp (App Store requirements)", %{sh: sh} do + assert sh =~ "--timestamp" + assert sh =~ "--options runtime" + end + + test "embeds the App Store provisioning profile", %{sh: sh} do + assert sh =~ ~s|cp "$PROFILE" "$APP/embedded.mobileprovision"| + end + + test "verifies the signature after applying it", %{sh: sh} do + assert sh =~ ~s|codesign --verify --deep --strict| + end + + test "entitlements omit get-task-allow (would block App Store)", %{sh: sh} do + # The entitlements heredoc inside the script must NOT contain + # `<key>get-task-allow</key>`. That key is only valid for + # dev/debug builds — App Store rejects bundles signed with it + # set true. Note: the string `get-task-allow` DOES appear in a + # `# Code signing (distribution, no get-task-allow)` comment + # elsewhere in the script, so we look only inside the + # heredoc body. + [_, after_open] = String.split(sh, "<< ENTEOF\n", parts: 2) + [heredoc_body, _] = String.split(after_open, "\nENTEOF\n", parts: 2) + + refute heredoc_body =~ "get-task-allow", + "release entitlements heredoc must not contain get-task-allow — App Store rejects builds signed with it" + end + end + + # Returns the lines of script around a `-c "$MOB_DIR/ios/<file>"` + # compile invocation — enough to see the flags on the same $CC call. + # Each NIF compile is a multi-line `$CC ... -c <file> -o <out>` + # invocation; we take the chunk from the previous blank line up to + # and including the `-c <file>` arg. + defp nif_compile_block(sh, file) do + sh + |> String.split("\n\n") + |> Enum.find(fn block -> String.contains?(block, "-c \"$MOB_DIR/ios/#{file}\"") end) + |> Kernel.||("") + end + + describe "OTP runtime is bundled before strip pass" do + # Sanity: the script must actually copy the OTP runtime tree before + # the strip pass deletes things from it. Order of operations matters. + + test "copies $OTP_ROOT/lib/ before the strip pass runs", %{sh: sh} do + [bundle, strip] = + sh + |> String.split("Stripping App-Store-disallowed binaries", parts: 2) + + assert bundle =~ ~s|rsync -a --delete "$OTP_ROOT/lib/"|, + "expected OTP lib/ to be rsynced into the bundle before the strip pass" + + assert strip =~ "find \"$OTP_BUNDLE\" -type f" + end + end + + describe "MOB_SLIM gating and per-step traceability" do + # Every slim strip step is wrapped in a `slim_step <tag> ...` bash + # function call. The function emits `[SLIM:<tag>] N KB → M KB (-D KB)` + # so a broken build can be bisected to a single step from build logs. + # Both the gating shape and the tag set are part of the contract. + + test "Apple-policy strips are NOT gated (always run for App Store validity)", %{sh: sh} do + # The .so/.a/standalone-bin strips happen above the MOB_SLIM check — + # `--no-slim` builds still need to pass App Store validation. + [apple, _slim] = String.split(sh, ~s|if [ "${MOB_SLIM:-1}" = "1" ]; then|, parts: 2) + assert apple =~ ~s|find "$OTP_BUNDLE" -type f \\( -name "*.so"| + assert apple =~ ~s|find "$OTP_BUNDLE" -path "*/priv/bin/*" -type f -delete| + end + + test "slim block is gated on MOB_SLIM env var (default on for release)", %{sh: sh} do + assert sh =~ ~s|if [ "${MOB_SLIM:-1}" = "1" ]; then|, + "release.ex must default MOB_SLIM=1 — `mix mob.release --no-slim` opts out" + end + + test "skip path emits [SLIM:skipped] when MOB_SLIM=0", %{sh: sh} do + assert sh =~ "[SLIM:skipped] MOB_SLIM=0", + "no-slim builds must announce themselves so the build log makes the choice obvious" + end + + test "defines a slim_step bash helper that prints [SLIM:<label>] size delta", %{sh: sh} do + assert sh =~ "slim_step() {", + "expected a slim_step() bash helper to be defined inside the slim block" + + assert sh =~ ~s|printf "[SLIM:%s] %s KB → %s KB (-%s KB)\\n"|, + "slim_step must emit the [SLIM:tag] before/after/delta line — it's the grep target docs use" + end + + test "every strip step routes through slim_step <tag>", %{sh: sh} do + # If a step is added without going through slim_step, it won't show + # up in the [SLIM:...] log and a regression in that step won't be + # bisectable from the build output. + for tag <- ~w(prefix_libs foreign_apps dedup_versions src_and_headers beam_chunks) do + assert sh =~ "slim_step #{tag}", + "expected slim_step invocation for tag `#{tag}`" + end + end + + test "main-binary symbol strip is gated on MOB_SLIM", %{sh: sh} do + # `xcrun strip -x` rewrites the Mach-O — must NOT happen on + # `--no-slim` builds where the developer wants debug symbols + # preserved on the release-shaped binary. + [_, after_first_gate] = String.split(sh, ~s|if [ "${MOB_SLIM:-1}" = "1" ]; then|, parts: 2) + assert after_first_gate =~ "xcrun strip -x" + end + end +end diff --git a/test/mob_dev/release_test.exs b/test/mob_dev/release_test.exs new file mode 100644 index 0000000..94adcc2 --- /dev/null +++ b/test/mob_dev/release_test.exs @@ -0,0 +1,322 @@ +defmodule MobDev.ReleaseTest do + use ExUnit.Case, async: true + + alias MobDev.Release + + # Pure-function coverage. The end-to-end build path requires Xcode + a paid + # Apple Developer Program account and is exercised by `mix mob.release` on a + # configured machine; tests here cover the parsing + signing-resolution + # logic that runs before any xcodebuild call. + + describe "parse_mobileprovision/1" do + setup do + tmp = Path.join(System.tmp_dir!(), "rel_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp} + end + + test "parses an App Store distribution profile (no provisioned devices)", %{tmp: tmp} do + path = Path.join(tmp, "appstore.mobileprovision") + File.write!(path, app_store_profile_xml("AAA111BBBB.com.example.app")) + + assert [profile] = Release.parse_mobileprovision(path) + assert profile.uuid == "12345678-1234-1234-1234-123456789ABC" + assert profile.app_id == "AAA111BBBB.com.example.app" + assert profile.team_id == "AAA111BBBB" + refute profile.provisioned_devices? + refute profile.provisions_all_devices? + end + + test "parses a development profile (has ProvisionedDevices)", %{tmp: tmp} do + path = Path.join(tmp, "dev.mobileprovision") + File.write!(path, development_profile_xml()) + + assert [profile] = Release.parse_mobileprovision(path) + assert profile.uuid == "DEV12345-1234-1234-1234-123456789ABC" + assert profile.provisioned_devices? + refute profile.provisions_all_devices? + end + + test "parses an Enterprise profile (ProvisionsAllDevices)", %{tmp: tmp} do + path = Path.join(tmp, "ent.mobileprovision") + File.write!(path, enterprise_profile_xml()) + + assert [profile] = Release.parse_mobileprovision(path) + assert profile.provisions_all_devices? + refute profile.provisioned_devices? + end + + test "returns [] for a file with no plist payload", %{tmp: tmp} do + path = Path.join(tmp, "garbage.mobileprovision") + File.write!(path, "not a real provisioning profile") + assert Release.parse_mobileprovision(path) == [] + end + + test "returns [] for a missing file" do + assert Release.parse_mobileprovision("/nonexistent/path.mobileprovision") == [] + end + + test "wildcard application-identifier is captured verbatim", %{tmp: tmp} do + path = Path.join(tmp, "wild.mobileprovision") + File.write!(path, app_store_profile_xml("AAA111BBBB.*")) + + assert [profile] = Release.parse_mobileprovision(path) + assert profile.app_id == "AAA111BBBB.*" + end + end + + describe "resolve_distribution_signing/1 (config validation)" do + test "passes through pre-set signing identity + profile UUID + team" do + cfg = [ + bundle_id: "com.example.app", + ios_team_id: "AAA111BBBB", + ios_dist_sign_identity: "Apple Distribution: Test (AAA111BBBB)", + ios_dist_profile_uuid: "12345678-1234-1234-1234-123456789ABC" + ] + + assert {:ok, resolved} = Release.resolve_distribution_signing(cfg) + assert resolved[:ios_dist_sign_identity] == "Apple Distribution: Test (AAA111BBBB)" + assert resolved[:ios_dist_profile_uuid] == "12345678-1234-1234-1234-123456789ABC" + assert resolved[:ios_team_id] == "AAA111BBBB" + end + end + + # The App Store profile selection kernel, extracted from resolve_dist_profile/3 + # so the pick logic (which is easy to get wrong: dev vs App Store, exact vs + # wildcard, uuid narrowing) is testable without a keychain full of profiles. + describe "select_dist_profile/3" do + @bundle "com.example.app" + + test "excludes development profiles even when the bundle id matches" do + # A dev profile has ProvisionedDevices — it must never be picked for App Store. + profiles = [profile(provisioned_devices?: true, app_id: "TEAMID.#{@bundle}")] + assert Release.select_dist_profile(profiles, nil, @bundle) == :none + end + + test "excludes Enterprise profiles (ProvisionsAllDevices)" do + profiles = [profile(provisions_all_devices?: true, app_id: "TEAMID.#{@bundle}")] + assert Release.select_dist_profile(profiles, nil, @bundle) == :none + end + + test "picks the App Store profile whose app id exactly matches the bundle id" do + p = profile(uuid: "EXACT", app_id: "TEAMID.#{@bundle}") + assert {:ok, ^p} = Release.select_dist_profile([p], nil, @bundle) + end + + test "falls back to a wildcard (.*) profile when there is no exact match" do + wild = profile(uuid: "WILD", app_id: "TEAMID.*") + assert {:ok, ^wild} = Release.select_dist_profile([wild], nil, @bundle) + end + + test "prefers an exact-bundle profile over a wildcard when both match" do + exact = profile(uuid: "EXACT", app_id: "TEAMID.#{@bundle}") + wild = profile(uuid: "WILD", app_id: "TEAMID.*") + assert {:ok, ^exact} = Release.select_dist_profile([wild, exact], nil, @bundle) + end + + test "with an explicit uuid, narrows to that profile and ignores the rest" do + a = profile(uuid: "AAA", app_id: "TEAMID.#{@bundle}") + b = profile(uuid: "BBB", app_id: "TEAMID.#{@bundle}") + assert {:ok, ^b} = Release.select_dist_profile([a, b], "BBB", @bundle) + end + + test "returns :none when the given uuid matches nothing" do + p = profile(uuid: "AAA", app_id: "TEAMID.#{@bundle}") + assert Release.select_dist_profile([p], "NOPE", @bundle) == :none + end + + test "returns :none when no profile matches the bundle id" do + p = profile(app_id: "TEAMID.com.other.app") + assert Release.select_dist_profile([p], nil, @bundle) == :none + end + + test "returns {:multiple, _} when two profiles exactly match and no uuid is set" do + a = profile(uuid: "AAA", app_id: "TEAMID.#{@bundle}") + b = profile(uuid: "BBB", app_id: "TEAMID.#{@bundle}") + assert {:multiple, both} = Release.select_dist_profile([a, b], nil, @bundle) + assert Enum.sort_by(both, & &1.uuid) == [a, b] + end + end + + describe "screenshot_build_env/1" do + # Drives -DMOB_ENABLE_SCREENSHOT on the release mob_nif.m compile. Opt-in: the + # public-API screenshot NIF ships in release only when the host asks for it. + test "opts in when ios_release_screenshot: true" do + assert Release.screenshot_build_env(ios_release_screenshot: true) == + {"MOB_ENABLE_SCREENSHOT", "1"} + end + + test "off (empty) when the key is false" do + assert Release.screenshot_build_env(ios_release_screenshot: false) == + {"MOB_ENABLE_SCREENSHOT", ""} + end + + test "off (empty) by default when the key is absent" do + assert Release.screenshot_build_env([]) == {"MOB_ENABLE_SCREENSHOT", ""} + end + end + + describe "plugin_ios_build_env/1" do + # The two env vars feed release_device.sh's plugin NIF compile + link loop. + # Pure over the activated-plugin list, so the matrix runs without a deps tree. + + test "no activated plugins → empty source + framework strings" do + assert Release.plugin_ios_build_env([]) == [ + {"MOB_PLUGIN_IOS_NIF_SOURCES", ""}, + {"MOB_PLUGIN_IOS_FRAMEWORKS", ""} + ] + end + + test "one ObjC plugin → its .m source (absolute) + declared frameworks" do + activated = [ + {"/deps/mob_camera", + %{ + nifs: [ + %{ + module: :mob_camera_nif, + native_dir: "priv/native/ios", + lang: :objc, + platform: :ios + } + ], + ios: %{frameworks: ["AVFoundation", "Photos"]} + }} + ] + + assert [ + {"MOB_PLUGIN_IOS_NIF_SOURCES", srcs}, + {"MOB_PLUGIN_IOS_FRAMEWORKS", fws} + ] = Release.plugin_ios_build_env(activated) + + assert srcs == "/deps/mob_camera/priv/native/ios/mob_camera_nif.m" + assert fws == "AVFoundation Photos" + end + + test "multiple plugins → space-joined sources; frameworks de-duped across the union" do + activated = [ + {"/deps/a", + %{ + nifs: [%{module: :a_nif, native_dir: "priv/native/ios", lang: :objc, platform: :ios}], + ios: %{frameworks: ["CoreBluetooth", "Foundation"]} + }}, + {"/deps/b", + %{ + nifs: [%{module: :b_nif, native_dir: "priv/native/ios", lang: :objc, platform: :ios}], + ios: %{frameworks: ["Foundation", "AVFoundation"]} + }} + ] + + assert [ + {"MOB_PLUGIN_IOS_NIF_SOURCES", srcs}, + {"MOB_PLUGIN_IOS_FRAMEWORKS", fws} + ] = Release.plugin_ios_build_env(activated) + + assert srcs == "/deps/a/priv/native/ios/a_nif.m /deps/b/priv/native/ios/b_nif.m" + # collect_uniq preserves first-seen order across plugins. + assert fws == "CoreBluetooth Foundation AVFoundation" + end + + test "an Android-only NIF entry on the same plugin is excluded from iOS sources" do + activated = [ + {"/deps/x", + %{ + nifs: [ + %{module: :x_nif, native_dir: "priv/native/ios", lang: :objc, platform: :ios}, + %{module: :x_nif, native_dir: "priv/native/jni", lang: :c, platform: :android} + ], + ios: %{frameworks: []} + }} + ] + + assert [{"MOB_PLUGIN_IOS_NIF_SOURCES", srcs}, {"MOB_PLUGIN_IOS_FRAMEWORKS", ""}] = + Release.plugin_ios_build_env(activated) + + assert srcs == "/deps/x/priv/native/ios/x_nif.m" + end + end + + # A parsed-profile map in the shape parse_mobileprovision/1 returns; App Store by + # default (no provisioned devices, not provisions-all). + defp profile(overrides) do + Map.merge( + %{ + uuid: "UUID", + team_id: "TEAMID", + app_id: "TEAMID.com.example.app", + provisioned_devices?: false, + provisions_all_devices?: false + }, + Map.new(overrides) + ) + end + + # ── Profile XML fixtures ──────────────────────────────────────────────── + # Real .mobileprovision files are CMS-signed binaries with a plist payload + # wrapped in DER. parse_mobileprovision/1 extracts the plist by string + # matching `<?xml` ... `</plist>`, so a bare XML document with the same + # structure is a sufficient input for the parser tests. + + defp app_store_profile_xml(app_id) do + """ + <?xml version="1.0" encoding="UTF-8"?> + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> + <plist version="1.0"> + <dict> + <key>UUID</key> + <string>12345678-1234-1234-1234-123456789ABC</string> + <key>application-identifier</key> + <string>#{app_id}</string> + <key>TeamIdentifier</key> + <array> + <string>AAA111BBBB</string> + </array> + <key>Name</key> + <string>App Store Distribution</string> + </dict> + </plist> + """ + end + + defp development_profile_xml do + """ + <?xml version="1.0" encoding="UTF-8"?> + <plist version="1.0"> + <dict> + <key>UUID</key> + <string>DEV12345-1234-1234-1234-123456789ABC</string> + <key>application-identifier</key> + <string>AAA111BBBB.com.example.app</string> + <key>TeamIdentifier</key> + <array> + <string>AAA111BBBB</string> + </array> + <key>ProvisionedDevices</key> + <array> + <string>00008110-001E1C3A34F8401E</string> + </array> + </dict> + </plist> + """ + end + + defp enterprise_profile_xml do + """ + <?xml version="1.0" encoding="UTF-8"?> + <plist version="1.0"> + <dict> + <key>UUID</key> + <string>ENT12345-1234-1234-1234-123456789ABC</string> + <key>application-identifier</key> + <string>ENTRP00000.com.example.app</string> + <key>TeamIdentifier</key> + <array> + <string>ENTRP00000</string> + </array> + <key>ProvisionsAllDevices</key> + <true/> + </dict> + </plist> + """ + end +end diff --git a/test/mob_dev/republish_test.exs b/test/mob_dev/republish_test.exs new file mode 100644 index 0000000..58804e4 --- /dev/null +++ b/test/mob_dev/republish_test.exs @@ -0,0 +1,202 @@ +defmodule Mix.Tasks.Mob.RepublishTest do + use ExUnit.Case, async: true + + # Tests cover the pure version-bump logic. The full task (chaining to + # mob.release + mob.publish) needs a real iOS toolchain + an App Store + # Connect API key and is exercised by `mix mob.republish --ios` on a + # configured machine — out of scope for unit tests. + # + # The CFBundleVersion bump shells out to `/usr/libexec/PlistBuddy`, which + # only exists on macOS. CI runs on Linux for the Elixir suite — exclude + # this module there via `mix test --exclude macos_only`. Local macOS dev + # runs pick it up by default. + @moduletag :macos_only + + alias Mix.Tasks.Mob.Republish + + setup do + tmp = Path.join(System.tmp_dir!(), "republish_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + {:ok, tmp: tmp} + end + + describe "bump_ios_build_number!/1" do + test "bumps an integer CFBundleVersion by 1", %{tmp: tmp} do + plist = Path.join(tmp, "Info.plist") + write_minimal_plist!(plist, "7") + + assert {"7", "8"} = Republish.bump_ios_build_number!(plist) + + assert read_cfbundle_version!(plist) == "8" + end + + test "handles starting value of 1 → 2", %{tmp: tmp} do + plist = Path.join(tmp, "Info.plist") + write_minimal_plist!(plist, "1") + + assert {"1", "2"} = Republish.bump_ios_build_number!(plist) + end + + test "handles large values (no integer overflow surprises)", %{tmp: tmp} do + plist = Path.join(tmp, "Info.plist") + write_minimal_plist!(plist, "9999") + + assert {"9999", "10000"} = Republish.bump_ios_build_number!(plist) + end + + test "is idempotent under re-read — running twice bumps twice", %{tmp: tmp} do + plist = Path.join(tmp, "Info.plist") + write_minimal_plist!(plist, "5") + + assert {"5", "6"} = Republish.bump_ios_build_number!(plist) + assert {"6", "7"} = Republish.bump_ios_build_number!(plist) + assert {"7", "8"} = Republish.bump_ios_build_number!(plist) + + assert read_cfbundle_version!(plist) == "8" + end + + test "raises with a clear message when CFBundleVersion is a semver string", %{tmp: tmp} do + # Common user mistake: someone writes "1.0" or "1.0.0" in the + # build-number slot because they're confusing it with the public + # version (CFBundleShortVersionString). Apple validators reject + # these too, but the failure message is opaque — we should error + # earlier and clearly. + plist = Path.join(tmp, "Info.plist") + write_minimal_plist!(plist, "1.0.0") + + assert_raise Mix.Error, ~r/expected a bare integer/, fn -> + Republish.bump_ios_build_number!(plist) + end + end + + test "raises when CFBundleVersion has trailing characters", %{tmp: tmp} do + plist = Path.join(tmp, "Info.plist") + write_minimal_plist!(plist, "7-rc1") + + assert_raise Mix.Error, ~r/expected a bare integer/, fn -> + Republish.bump_ios_build_number!(plist) + end + end + + test "error message points the user at how to fix the plist by hand", %{tmp: tmp} do + plist = Path.join(tmp, "Info.plist") + write_minimal_plist!(plist, "1.0") + + try do + Republish.bump_ios_build_number!(plist) + flunk("expected the bump to raise on a non-integer CFBundleVersion") + rescue + e in Mix.Error -> + msg = e.message + # The error must hand the user a copy-pasteable PlistBuddy + # command — the whole point of a clear error here is that + # they can recover without finding this guide first. + assert msg =~ "PlistBuddy" + assert msg =~ "Set :CFBundleVersion 1" + assert msg =~ "CFBundleShortVersionString" + end + end + end + + describe "bump_android_version_code!/1" do + test "bumps an integer versionCode by 1", %{tmp: tmp} do + gradle = Path.join(tmp, "build.gradle") + write_minimal_gradle!(gradle, 3) + + assert {"3", "4"} = Republish.bump_android_version_code!(gradle) + + assert read_version_code!(gradle) == "4" + end + + test "handles starting value of 1 → 2", %{tmp: tmp} do + gradle = Path.join(tmp, "build.gradle") + write_minimal_gradle!(gradle, 1) + + assert {"1", "2"} = Republish.bump_android_version_code!(gradle) + end + + test "handles large values without integer overflow", %{tmp: tmp} do + gradle = Path.join(tmp, "build.gradle") + write_minimal_gradle!(gradle, 9999) + + assert {"9999", "10000"} = Republish.bump_android_version_code!(gradle) + end + + test "is idempotent under re-read — running twice bumps twice", %{tmp: tmp} do + gradle = Path.join(tmp, "build.gradle") + write_minimal_gradle!(gradle, 5) + + assert {"5", "6"} = Republish.bump_android_version_code!(gradle) + assert {"6", "7"} = Republish.bump_android_version_code!(gradle) + assert {"7", "8"} = Republish.bump_android_version_code!(gradle) + + assert read_version_code!(gradle) == "8" + end + + test "only bumps the first versionCode occurrence (not versionName)", %{tmp: tmp} do + gradle = Path.join(tmp, "build.gradle") + + File.write!(gradle, """ + defaultConfig { + versionCode 2 + versionName "2.0.0" + } + """) + + assert {"2", "3"} = Republish.bump_android_version_code!(gradle) + content = File.read!(gradle) + assert content =~ ~r/versionCode 3/ + assert content =~ ~r/versionName "2\.0\.0"/ + end + + test "raises with a clear message when versionCode is absent", %{tmp: tmp} do + gradle = Path.join(tmp, "build.gradle") + File.write!(gradle, "defaultConfig {\n applicationId \"com.example.test\"\n}\n") + + assert_raise Mix.Error, ~r/No versionCode found/, fn -> + Republish.bump_android_version_code!(gradle) + end + end + end + + # ── helpers ────────────────────────────────────────────────────────────── + + defp write_minimal_gradle!(path, version_code) do + File.write!(path, """ + defaultConfig { + applicationId "com.example.test" + versionCode #{version_code} + versionName "1.0.0" + } + """) + end + + defp read_version_code!(path) do + content = File.read!(path) + [vc] = Regex.run(~r/\bversionCode\s+(\d+)/, content, capture: :all_but_first) + vc + end + + defp write_minimal_plist!(path, build_version) do + File.write!(path, """ + <?xml version="1.0" encoding="UTF-8"?> + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> + <plist version="1.0"> + <dict> + <key>CFBundleIdentifier</key> + <string>com.example.test</string> + <key>CFBundleVersion</key> + <string>#{build_version}</string> + <key>CFBundleShortVersionString</key> + <string>1.0.0</string> + </dict> + </plist> + """) + end + + defp read_cfbundle_version!(path) do + {raw, 0} = System.cmd("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleVersion", path]) + String.trim(raw) + end +end diff --git a/test/mob_dev/security_scan/bundled_runtime/fingerprint_test.exs b/test/mob_dev/security_scan/bundled_runtime/fingerprint_test.exs new file mode 100644 index 0000000..9663f0f --- /dev/null +++ b/test/mob_dev/security_scan/bundled_runtime/fingerprint_test.exs @@ -0,0 +1,140 @@ +defmodule MobDev.SecurityScan.BundledRuntime.FingerprintTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.BundledRuntime.Fingerprint + + @moduletag :tmp_dir + + defp build_tarball(dir, opts) do + erts_vsn = Keyword.get(opts, :erts, "16.3") + elixir_vsn = Keyword.get(opts, :elixir, "1.19.5") + openssl_vsn = Keyword.get(opts, :openssl, "3.4.0") + exqlite_vsn = Keyword.get(opts, :exqlite, "0.36.0") + + erts = Path.join(dir, "erts-#{erts_vsn}") + erts_lib = Path.join(erts, "lib") + File.mkdir_p!(erts_lib) + + if openssl_vsn do + libcrypto_content = + :crypto.strong_rand_bytes(1024) <> + "Some unrelated string\0" <> + "OpenSSL default\0" <> + "OpenSSL #{openssl_vsn} 22 Oct 2024\0" <> + :crypto.strong_rand_bytes(1024) + + File.write!(Path.join(erts_lib, "libcrypto.a"), libcrypto_content) + end + + if elixir_vsn do + ebin = Path.join([dir, "lib", "elixir", "ebin"]) + File.mkdir_p!(ebin) + File.write!(Path.join(ebin, "elixir.app"), ~s({application,elixir,[{vsn,"#{elixir_vsn}"}]})) + end + + if exqlite_vsn do + File.mkdir_p!(Path.join([dir, "lib", "exqlite-#{exqlite_vsn}", "ebin"])) + end + + dir + end + + describe "locate_cached_tarballs/1" do + test "decodes android, android_arm32, ios_sim, ios_device dirs", %{tmp_dir: dir} do + for name <- [ + "otp-android-abc123", + "otp-android-arm32-abc123", + "otp-ios-sim-abc123", + "otp-ios-device-abc123", + "otp-something-else-zzz", + "not-a-tarball" + ] do + File.mkdir_p!(Path.join(dir, name)) + end + + tarballs = Fingerprint.locate_cached_tarballs(cache_dir: dir) + + platforms = Enum.map(tarballs, & &1.platform) |> Enum.sort() + assert platforms == [:android, :android_arm32, :ios_device, :ios_sim] + assert Enum.all?(tarballs, &(&1.hash == "abc123")) + end + + test "returns [] when cache dir missing", %{tmp_dir: dir} do + missing = Path.join(dir, "nope") + assert Fingerprint.locate_cached_tarballs(cache_dir: missing) == [] + end + + test "ignores non-directory entries", %{tmp_dir: dir} do + File.write!(Path.join(dir, "otp-android-abc"), "not a dir") + assert Fingerprint.locate_cached_tarballs(cache_dir: dir) == [] + end + end + + describe "fingerprint_tarball/1" do + test "extracts every version field from a well-formed tarball", %{tmp_dir: dir} do + build_tarball(dir, []) + + versions = Fingerprint.fingerprint_tarball(dir) + + assert versions.erts == "16.3" + assert versions.elixir == "1.19.5" + assert versions.openssl == "3.4.0" + assert versions.exqlite_beam == "0.36.0" + end + + test "returns nil for fields whose source files are missing", %{tmp_dir: dir} do + build_tarball(dir, openssl: nil, elixir: nil, exqlite: nil) + versions = Fingerprint.fingerprint_tarball(dir) + + assert versions.erts == "16.3" + assert versions.elixir == nil + assert versions.openssl == nil + assert versions.exqlite_beam == nil + end + + test "OpenSSL: ignores non-version 'OpenSSL ' strings and finds the digit-form one", + %{tmp_dir: dir} do + build_tarball(dir, openssl: "3.5.1") + assert %{openssl: "3.5.1"} = Fingerprint.fingerprint_tarball(dir) + end + + test "OpenSSL: returns nil when no version banner exists in libcrypto.a", %{tmp_dir: dir} do + build_tarball(dir, openssl: nil) + + erts_lib = Path.join([dir, "erts-16.3", "lib"]) + File.mkdir_p!(erts_lib) + File.write!(Path.join(erts_lib, "libcrypto.a"), "OpenSSL default only, no version\0") + + assert %{openssl: nil} = Fingerprint.fingerprint_tarball(dir) + end + end + + describe "fingerprint_sqlite/1" do + test "extracts SQLITE_VERSION from a project's deps/exqlite/c_src/sqlite3.c", + %{tmp_dir: dir} do + c_src = Path.join([dir, "deps", "exqlite", "c_src"]) + File.mkdir_p!(c_src) + + File.write!(Path.join(c_src, "sqlite3.c"), """ + /* synthetic SQLite source */ + #define SQLITE_VERSION "3.51.3" + #define SQLITE_SOURCE_ID "2026-03-13 some hash" + """) + + assert {:ok, "3.51.3"} = Fingerprint.fingerprint_sqlite(dir) + end + + test "{:error, :not_found} when exqlite source is absent", %{tmp_dir: dir} do + assert {:error, :not_found} = Fingerprint.fingerprint_sqlite(dir) + end + + test "{:error, :unparseable} when file exists but has no version macro", + %{tmp_dir: dir} do + c_src = Path.join([dir, "deps", "exqlite", "c_src"]) + File.mkdir_p!(c_src) + File.write!(Path.join(c_src, "sqlite3.c"), "/* no version macro here */\n") + + assert {:error, :unparseable} = Fingerprint.fingerprint_sqlite(dir) + end + end +end diff --git a/test/mob_dev/security_scan/bundled_versions_test.exs b/test/mob_dev/security_scan/bundled_versions_test.exs new file mode 100644 index 0000000..de2eed6 --- /dev/null +++ b/test/mob_dev/security_scan/bundled_versions_test.exs @@ -0,0 +1,61 @@ +defmodule MobDev.SecurityScan.BundledVersionsTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.BundledVersions + + describe "load/0 (real manifest)" do + test "loads without raising" do + assert %{active_hash: hash, bundles: bundles} = BundledVersions.load() + assert is_binary(hash) and byte_size(hash) > 0 + assert is_map(bundles) and map_size(bundles) > 0 + assert Map.has_key?(bundles, hash) + end + + test "active bundle has required fields" do + bundle = BundledVersions.active() + + assert is_binary(bundle.erts) and byte_size(bundle.erts) > 0 + assert is_binary(bundle.otp_release) and byte_size(bundle.otp_release) > 0 + assert is_binary(bundle.elixir) and byte_size(bundle.elixir) > 0 + assert is_binary(bundle.openssl) and byte_size(bundle.openssl) > 0 + assert is_binary(bundle.exqlite_beam) and byte_size(bundle.exqlite_beam) > 0 + end + end + + describe "for_hash/1" do + test "{:ok, bundle} when hash is present" do + manifest = BundledVersions.load() + hash = manifest.active_hash + assert {:ok, _} = BundledVersions.for_hash(hash) + end + + test "{:error, :unknown_hash} for unknown hash" do + assert {:error, :unknown_hash} = BundledVersions.for_hash("nope12345") + end + end + + describe "validation (synthetic manifest written to a tmp file)" do + @tag :tmp_dir + test "valid manifest loads", %{tmp_dir: dir} do + path = Path.join(dir, "manifest.exs") + + File.write!(path, """ + %{ + active_hash: "abcdef", + bundles: %{ + "abcdef" => %{ + erts: "16.3", + otp_release: "28", + elixir: "1.19.5", + openssl: "3.4.0", + exqlite_beam: "0.36.0" + } + } + } + """) + + {manifest, _} = Code.eval_file(path) + assert manifest.active_hash == "abcdef" + end + end +end diff --git a/test/mob_dev/security_scan/diff_test.exs b/test/mob_dev/security_scan/diff_test.exs new file mode 100644 index 0000000..205c038 --- /dev/null +++ b/test/mob_dev/security_scan/diff_test.exs @@ -0,0 +1,126 @@ +defmodule MobDev.SecurityScan.DiffTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.{Diff, Finding, LayerResult, Report, StateFile} + + defp report(findings) do + %Report{ + started_at: ~U[2026-05-07 00:00:00Z], + finished_at: ~U[2026-05-07 00:00:01Z], + project_root: "/p", + layers: [%LayerResult{name: :hex_deps, status: :ok, findings: findings}] + } + end + + defp finding(opts) do + %Finding{ + id: opts[:id] || "GHSA-1", + severity: opts[:severity] || :high, + package: opts[:package] || "plug", + version: opts[:version] || "1.10.0", + layer: :hex_deps + } + end + + defp state_entry(opts) do + %{ + key: "#{opts[:id] || "GHSA-1"}|#{opts[:package] || "plug"}|#{opts[:version] || "1.10.0"}", + id: opts[:id] || "GHSA-1", + severity: opts[:severity] || :high, + package: opts[:package] || "plug", + version: opts[:version] || "1.10.0", + title: opts[:title], + url: nil, + fixed_in: nil, + source: :osv_scanner, + layer: :hex_deps, + first_seen_at: opts[:first_seen_at] || ~U[2026-05-01 00:00:00Z] + } + end + + @now ~U[2026-05-07 12:00:00Z] + + test "first run: every finding is :new" do + diff = Diff.compute(StateFile.empty(), report([finding(id: "X")]), @now) + + assert length(diff.new) == 1 + assert diff.resolved == [] + assert diff.still_present == [] + end + + test "second run: identical findings are :still_present, none :new or :resolved" do + state = %{ + version: 1, + last_run_at: ~U[2026-05-06 00:00:00Z], + findings: [state_entry(id: "X")] + } + + diff = Diff.compute(state, report([finding(id: "X")]), @now) + + assert diff.new == [] + assert diff.resolved == [] + assert length(diff.still_present) == 1 + end + + test "vanished finding is :resolved" do + state = %{ + version: 1, + last_run_at: ~U[2026-05-06 00:00:00Z], + findings: [state_entry(id: "X"), state_entry(id: "Y")] + } + + diff = Diff.compute(state, report([finding(id: "X")]), @now) + + assert [%{id: "X"}] = diff.still_present + assert [%{id: "Y"}] = diff.resolved + assert diff.new == [] + end + + test "newly-appearing finding is :new" do + state = %{ + version: 1, + last_run_at: ~U[2026-05-06 00:00:00Z], + findings: [state_entry(id: "X")] + } + + diff = Diff.compute(state, report([finding(id: "X"), finding(id: "Z")]), @now) + + new_ids = Enum.map(diff.new, & &1.id) + assert "Z" in new_ids + refute "X" in new_ids + end + + test "first_seen is preserved across runs for already-known findings" do + state = %{ + version: 1, + last_run_at: ~U[2026-05-06 00:00:00Z], + findings: [state_entry(id: "X", first_seen_at: ~U[2026-04-01 00:00:00Z])] + } + + diff = Diff.compute(state, report([finding(id: "X")]), @now) + + [%Finding{} = f] = diff.still_present + assert Map.get(diff.first_seen, Finding.dedupe_key(f)) == ~U[2026-04-01 00:00:00Z] + end + + test "first_seen for genuinely new findings defaults to `now`" do + diff = Diff.compute(StateFile.empty(), report([finding(id: "Z")]), @now) + + [%Finding{} = f] = diff.new + assert Map.get(diff.first_seen, Finding.dedupe_key(f)) == @now + end + + test "different versions of the same advisory are different findings" do + state = %{ + version: 1, + last_run_at: nil, + findings: [state_entry(id: "X", version: "1.10.0")] + } + + diff = Diff.compute(state, report([finding(id: "X", version: "1.11.0")]), @now) + + assert length(diff.resolved) == 1 + assert length(diff.new) == 1 + assert diff.still_present == [] + end +end diff --git a/test/mob_dev/security_scan/finding_test.exs b/test/mob_dev/security_scan/finding_test.exs new file mode 100644 index 0000000..9c26508 --- /dev/null +++ b/test/mob_dev/security_scan/finding_test.exs @@ -0,0 +1,55 @@ +defmodule MobDev.SecurityScan.FindingTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.Finding + + describe "sort_key/1" do + test "ranks severities critical → unknown" do + findings = [ + %Finding{id: "B", severity: :low}, + %Finding{id: "A", severity: :critical}, + %Finding{id: "C", severity: :high}, + %Finding{id: "D", severity: :medium}, + %Finding{id: "E", severity: :unknown} + ] + + sorted = Enum.sort_by(findings, &Finding.sort_key/1) + assert Enum.map(sorted, & &1.severity) == [:critical, :high, :medium, :low, :unknown] + end + + test "tie-breaks on id within the same severity" do + findings = [ + %Finding{id: "B", severity: :high}, + %Finding{id: "A", severity: :high}, + %Finding{id: "C", severity: :high} + ] + + assert findings + |> Enum.sort_by(&Finding.sort_key/1) + |> Enum.map(& &1.id) == ["A", "B", "C"] + end + + test "treats nil id as empty string for stable sort" do + a = %Finding{id: nil, severity: :high} + b = %Finding{id: "X", severity: :high} + + assert Enum.sort_by([b, a], &Finding.sort_key/1) == [a, b] + end + end + + describe "dedupe_key/1" do + test "two findings for the same advisory + package + version share a key" do + a = %Finding{id: "GHSA-1", package: "plug", version: "1.10.0", source: :mix_audit} + b = %Finding{id: "GHSA-1", package: "plug", version: "1.10.0", source: :osv_scanner} + + assert Finding.dedupe_key(a) == Finding.dedupe_key(b) + end + + test "different versions produce different keys" do + a = %Finding{id: "GHSA-1", package: "plug", version: "1.10.0"} + b = %Finding{id: "GHSA-1", package: "plug", version: "1.11.0"} + + refute Finding.dedupe_key(a) == Finding.dedupe_key(b) + end + end +end diff --git a/test/mob_dev/security_scan/formatter_test.exs b/test/mob_dev/security_scan/formatter_test.exs new file mode 100644 index 0000000..dce5a09 --- /dev/null +++ b/test/mob_dev/security_scan/formatter_test.exs @@ -0,0 +1,171 @@ +defmodule MobDev.SecurityScan.FormatterTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.{Finding, Formatter, LayerResult, Report} + + defp sample_report(layers) do + %Report{ + started_at: ~U[2026-01-01 00:00:00Z], + finished_at: ~U[2026-01-01 00:00:01Z], + project_root: "/tmp/proj", + layers: layers + } + end + + test "terminal/1 includes header, layer name, and summary" do + report = + sample_report([ + %LayerResult{ + name: :hex_deps, + status: :ok, + findings: [], + tools_used: ["mix_audit"], + duration_ms: 100, + notes: ["audited 5 deps"] + } + ]) + + out = Formatter.terminal(report) + + assert out =~ "mob security scan" + assert out =~ "hex_deps" + assert out =~ "ok" + assert out =~ "mix_audit" + assert out =~ "audited 5 deps" + assert out =~ "Summary" + assert out =~ "total findings: 0" + end + + test "terminal/1 renders findings sorted by severity, with id and fix info" do + finding = %Finding{ + id: "GHSA-XXXX", + severity: :critical, + package: "plug", + version: "1.10.0", + fixed_in: "1.11.0", + title: "RCE in plug", + source: :mix_audit, + layer: :hex_deps + } + + report = + sample_report([ + %LayerResult{name: :hex_deps, status: :ok, findings: [finding]} + ]) + + out = Formatter.terminal(report) + + assert out =~ "CRITICAL" + assert out =~ "plug" + assert out =~ "1.10.0" + assert out =~ "GHSA-XXXX" + assert out =~ "fixed in 1.11.0" + assert out =~ "RCE in plug" + end + + test "terminal/1 shows tool_missing status with notes" do + report = + sample_report([ + %LayerResult{ + name: :gradle_deps, + status: :tool_missing, + tools_used: ["osv-scanner"], + notes: ["install: brew install osv-scanner"] + } + ]) + + out = Formatter.terminal(report) + + assert out =~ "tool missing" + assert out =~ "brew install osv-scanner" + end + + test "terminal/1 shows skipped layers as skipped" do + report = + sample_report([ + %LayerResult{name: :c_source, status: :skipped, notes: ["skipped via --skip flag"]} + ]) + + out = Formatter.terminal(report) + assert out =~ "skipped" + end + + test "markdown/1 produces a readable report with severity table and findings table" do + finding = %Finding{ + id: "CVE-2024-1", + severity: :critical, + package: "openssl", + version: "3.4.0", + fixed_in: "3.4.1", + title: "Use after free in HTTP parser", + source: :openssl_feed, + layer: :bundled_runtime + } + + report = + sample_report([ + %LayerResult{ + name: :bundled_runtime, + status: :ok, + findings: [finding], + tools_used: ["fingerprint"], + notes: ["scanned 1 tarball"] + } + ]) + + md = Formatter.markdown(report) + + assert md =~ "# Mob Security Scan" + assert md =~ "**Total findings:** 1" + assert md =~ "## Severity counts" + assert md =~ "| Critical | High | Medium | Low | Unknown |" + assert md =~ "### `bundled_runtime` — ok" + assert md =~ "**Tools:** fingerprint" + assert md =~ "scanned 1 tarball" + assert md =~ "**Findings**" + + assert md =~ + "| CRITICAL | CVE-2024-1 | openssl | 3.4.0 | 3.4.1 | Use after free in HTTP parser |" + end + + test "markdown/1 escapes pipes inside titles" do + report = + sample_report([ + %LayerResult{ + name: :hex_deps, + status: :ok, + findings: [ + %Finding{ + id: "X", + severity: :high, + title: "this | breaks | tables", + package: "p", + version: "1.0" + } + ] + } + ]) + + md = Formatter.markdown(report) + refute md =~ "this | breaks" + assert md =~ "this \\| breaks \\| tables" + end + + test "json/1 returns a parsable JSON string" do + report = + sample_report([ + %LayerResult{ + name: :hex_deps, + status: :ok, + findings: [%Finding{id: "X", severity: :low, package: "p", version: "1.0"}] + } + ]) + + json = Formatter.json(report) + assert is_binary(json) and byte_size(json) > 0 + {:ok, decoded} = Jason.decode(json) + + assert decoded["project_root"] == "/tmp/proj" + assert [%{"name" => "hex_deps", "findings" => [%{"id" => "X"}]}] = decoded["layers"] + end +end diff --git a/test/mob_dev/security_scan/history_formatter_test.exs b/test/mob_dev/security_scan/history_formatter_test.exs new file mode 100644 index 0000000..8d0c235 --- /dev/null +++ b/test/mob_dev/security_scan/history_formatter_test.exs @@ -0,0 +1,140 @@ +defmodule MobDev.SecurityScan.HistoryFormatterTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.{Diff, Finding, HistoryFormatter, LayerResult, Report, StateFile} + + @moduletag :tmp_dir + + defp report(findings, opts \\ []) do + %Report{ + started_at: opts[:started_at] || ~U[2026-05-07 00:00:00Z], + finished_at: opts[:finished_at] || ~U[2026-05-07 00:00:01Z], + project_root: opts[:project_root] || "/p", + layers: [%LayerResult{name: :hex_deps, status: :ok, findings: findings}] + } + end + + defp finding(opts \\ []) do + %Finding{ + id: opts[:id] || "GHSA-1", + severity: opts[:severity] || :high, + package: opts[:package] || "plug", + version: opts[:version] || "1.10.0", + fixed_in: opts[:fixed_in] || "1.11.0", + title: opts[:title] || "RCE in plug", + url: opts[:url] || "https://example.com", + source: :osv_scanner, + layer: :hex_deps + } + end + + describe "entry/3" do + @now ~U[2026-05-07 12:00:00Z] + + test "renders timestamp, severity counts, and a New section for first run" do + diff = Diff.compute(StateFile.empty(), report([finding()]), @now) + out = HistoryFormatter.entry(report([finding()]), diff, @now) + + assert out =~ "## 2026-05-07T12:00:00Z" + assert out =~ "**Total findings:** 1 (0 critical, 1 high" + assert out =~ "### New since last scan (1)" + assert out =~ "**HIGH** `plug@1.10.0`" + assert out =~ "GHSA-1" + assert out =~ "fixed in 1.11.0" + assert out =~ "RCE in plug" + assert out =~ "Resolved since last scan _(none)_" + assert out =~ "Still present from last scan _(none)_" + end + + test "renders Resolved section with previous-state entries" do + prev = %{ + version: 1, + last_run_at: ~U[2026-05-06 00:00:00Z], + findings: [ + %{ + key: "OLD|plug|1.10.0", + id: "OLD", + severity: :critical, + package: "plug", + version: "1.10.0", + title: "Was a critical bug", + url: nil, + fixed_in: "1.11.0", + source: :mix_audit, + layer: :hex_deps, + first_seen_at: ~U[2026-04-01 00:00:00Z] + } + ] + } + + diff = Diff.compute(prev, report([]), @now) + out = HistoryFormatter.entry(report([]), diff, @now) + + assert out =~ "Resolved since last scan (1) ✓" + assert out =~ "**CRITICAL** `plug@1.10.0`" + assert out =~ "Was a critical bug" + end + + test "Still-present section shows age suffix" do + prev = %{ + version: 1, + last_run_at: ~U[2026-05-06 00:00:00Z], + findings: [ + %{ + key: "GHSA-1|plug|1.10.0", + id: "GHSA-1", + severity: :high, + package: "plug", + version: "1.10.0", + title: "RCE in plug", + url: nil, + fixed_in: "1.11.0", + source: :osv_scanner, + layer: :hex_deps, + first_seen_at: ~U[2026-04-01 00:00:00Z] + } + ] + } + + diff = Diff.compute(prev, report([finding()]), @now) + out = HistoryFormatter.entry(report([finding()]), diff, @now) + + assert out =~ "Still present from last scan (1)" + assert out =~ "first seen 36 days ago" + end + end + + describe "prepend_to_file/2" do + test "creates the file with header on first call", %{tmp_dir: dir} do + path = Path.join(dir, "HISTORY.md") + HistoryFormatter.prepend_to_file(path, "## 2026-05-07T12:00:00Z\n\nbody\n\n") + + content = File.read!(path) + assert content =~ "# Security scan history" + assert content =~ "## 2026-05-07T12:00:00Z" + end + + test "newer entry is added above older entries", %{tmp_dir: dir} do + path = Path.join(dir, "HISTORY.md") + + HistoryFormatter.prepend_to_file(path, "## 2026-05-06T00:00:00Z\n\nold body\n\n") + HistoryFormatter.prepend_to_file(path, "## 2026-05-07T00:00:00Z\n\nnew body\n\n") + + content = File.read!(path) + idx_new = :binary.match(content, "2026-05-07") |> elem(0) + idx_old = :binary.match(content, "2026-05-06") |> elem(0) + assert idx_new < idx_old + end + + test "header is not duplicated on repeated calls", %{tmp_dir: dir} do + path = Path.join(dir, "HISTORY.md") + + HistoryFormatter.prepend_to_file(path, "## 2026-05-06T00:00:00Z\n\na\n\n") + HistoryFormatter.prepend_to_file(path, "## 2026-05-07T00:00:00Z\n\nb\n\n") + + content = File.read!(path) + occurrences = content |> :binary.matches("# Security scan history") |> length() + assert occurrences == 1 + end + end +end diff --git a/test/mob_dev/security_scan/layers/bundled_runtime_test.exs b/test/mob_dev/security_scan/layers/bundled_runtime_test.exs new file mode 100644 index 0000000..18d9487 --- /dev/null +++ b/test/mob_dev/security_scan/layers/bundled_runtime_test.exs @@ -0,0 +1,172 @@ +defmodule MobDev.SecurityScan.Layers.BundledRuntimeTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.Finding + alias MobDev.SecurityScan.Layers.BundledRuntime + + @moduletag :tmp_dir + + defp build_tarball(cache_dir, platform, hash, opts) do + dir_name = + case platform do + :android -> "otp-android-#{hash}" + :android_arm32 -> "otp-android-arm32-#{hash}" + :ios_sim -> "otp-ios-sim-#{hash}" + :ios_device -> "otp-ios-device-#{hash}" + end + + path = Path.join(cache_dir, dir_name) + + erts_lib = + Path.join([path, "erts-#{MobDev.SecurityScan.BundledVersions.active().erts}", "lib"]) + + File.mkdir_p!(erts_lib) + + if openssl = opts[:openssl] do + content = + :crypto.strong_rand_bytes(256) <> + "OpenSSL default\0" <> + "OpenSSL #{openssl} 22 Oct 2024\0" + + File.write!(Path.join(erts_lib, "libcrypto.a"), content) + end + + if elixir_vsn = opts[:elixir] do + ebin = Path.join([path, "lib", "elixir", "ebin"]) + File.mkdir_p!(ebin) + + File.write!( + Path.join(ebin, "elixir.app"), + ~s({application,elixir,[{vsn,"#{elixir_vsn}"}]}) + ) + end + + if exqlite_vsn = opts[:exqlite] do + File.mkdir_p!(Path.join([path, "lib", "exqlite-#{exqlite_vsn}"])) + end + + path + end + + test ":not_applicable when no cached tarballs", %{tmp_dir: dir} do + cache = Path.join(dir, "empty_cache") + File.mkdir_p!(cache) + + result = BundledRuntime.run(project_root: dir, cache_dir: cache) + + assert result.status == :not_applicable + assert Enum.any?(result.notes, &String.contains?(&1, "no cached OTP tarballs")) + end + + test ":ok with no findings when versions match manifest", %{tmp_dir: dir} do + cache = Path.join(dir, "cache") + File.mkdir_p!(cache) + + bundle = real_bundle() + + build_tarball(cache, :android, real_hash(), + openssl: bundle.openssl, + elixir: bundle.elixir, + exqlite: bundle.exqlite_beam + ) + + result = BundledRuntime.run(project_root: dir, cache_dir: cache) + + assert result.status == :ok + assert result.findings == [] + assert Enum.any?(result.notes, &String.contains?(&1, "android")) + end + + test ":high finding when an Elixir version drifts", %{tmp_dir: dir} do + cache = Path.join(dir, "cache") + File.mkdir_p!(cache) + + bundle = real_bundle() + + build_tarball(cache, :android, real_hash(), + openssl: bundle.openssl, + elixir: "9.9.9-rc.1", + exqlite: bundle.exqlite_beam + ) + + result = BundledRuntime.run(project_root: dir, cache_dir: cache) + + assert result.status == :ok + + assert [ + %Finding{ + severity: :high, + id: "MOB-DRIFT-android-elixir", + source: :bundled_runtime, + layer: :bundled_runtime + } = finding + ] = result.findings + + assert finding.title =~ "Elixir manifest=#{bundle.elixir}" + assert finding.title =~ "binary=9.9.9-rc.1" + end + + test "per-platform override suppresses missing-artifact drift", %{tmp_dir: dir} do + # iOS sim does NOT ship exqlite per the active manifest. Building a + # tarball without exqlite should NOT generate a drift finding for it. + cache = Path.join(dir, "cache") + File.mkdir_p!(cache) + + bundle = real_bundle() + + build_tarball(cache, :ios_sim, real_hash(), + openssl: bundle.openssl, + elixir: bundle.elixir + # no exqlite — matches per_platform override + ) + + result = BundledRuntime.run(project_root: dir, cache_dir: cache) + + refute Enum.any?(result.findings, &(&1.id == "MOB-DRIFT-ios_sim-exqlite_beam")) + end + + test "version pointers and SQLite note appear in notes when exqlite present", %{tmp_dir: dir} do + cache = Path.join(dir, "cache") + File.mkdir_p!(cache) + + bundle = real_bundle() + + build_tarball(cache, :android, real_hash(), + openssl: bundle.openssl, + elixir: bundle.elixir, + exqlite: bundle.exqlite_beam + ) + + c_src = Path.join([dir, "deps", "exqlite", "c_src"]) + File.mkdir_p!(c_src) + File.write!(Path.join(c_src, "sqlite3.c"), ~s(#define SQLITE_VERSION "3.51.3"\n)) + + result = BundledRuntime.run(project_root: dir, cache_dir: cache) + + assert Enum.any?(result.notes, &String.contains?(&1, "version pointers")) + assert Enum.any?(result.notes, &String.contains?(&1, "SQLite 3.51.3")) + assert Enum.any?(result.notes, &String.contains?(&1, "openssl-library.org")) + end + + test "tarball with unknown hash gets an info note, not a finding", %{tmp_dir: dir} do + cache = Path.join(dir, "cache") + File.mkdir_p!(cache) + + bundle = real_bundle() + + build_tarball(cache, :android, "deadbe", + openssl: bundle.openssl, + elixir: bundle.elixir, + exqlite: bundle.exqlite_beam + ) + + result = BundledRuntime.run(project_root: dir, cache_dir: cache) + + assert result.findings == [] + assert Enum.any?(result.notes, &String.contains?(&1, "hash not in manifest")) + end + + defp real_bundle, do: MobDev.SecurityScan.BundledVersions.active() + + defp real_hash, do: MobDev.SecurityScan.BundledVersions.load().active_hash +end diff --git a/test/mob_dev/security_scan/layers/c_source_test.exs b/test/mob_dev/security_scan/layers/c_source_test.exs new file mode 100644 index 0000000..5805346 --- /dev/null +++ b/test/mob_dev/security_scan/layers/c_source_test.exs @@ -0,0 +1,172 @@ +defmodule MobDev.SecurityScan.Layers.CSourceTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.Finding + alias MobDev.SecurityScan.Layers.CSource + + @moduletag :tmp_dir + + defp put_c_source(dir) do + c_src = Path.join([dir, "deps", "mob", "android", "jni"]) + File.mkdir_p!(c_src) + File.write!(Path.join(c_src, "mob_nif.c"), "int main() { return 0; }\n") + c_src + end + + defp empty_runner, do: fn _targets -> {:ok, ~s({"results":[]})} end + defp not_installed_runner, do: fn _targets -> {:error, :not_installed} end + + defp empty_csv_runner, + do: fn _targets -> + {:ok, "File,Line,Column,Level,Category,Name,Warning,Suggestion,Note,CWEs,Context\n"} + end + + describe "run/1" do + test ":not_applicable when no C source under project", %{tmp_dir: dir} do + result = + CSource.run( + project_root: dir, + semgrep_runner: empty_runner(), + flawfinder_runner: empty_csv_runner() + ) + + assert result.status == :not_applicable + assert Enum.any?(result.notes, &String.contains?(&1, "no C source")) + end + + test ":ok with empty findings when both tools find nothing", %{tmp_dir: dir} do + put_c_source(dir) + + result = + CSource.run( + project_root: dir, + semgrep_runner: empty_runner(), + flawfinder_runner: empty_csv_runner() + ) + + assert result.status == :ok + assert result.findings == [] + assert "semgrep" in result.tools_used + assert "flawfinder" in result.tools_used + end + + test "soft-warns when both tools missing", %{tmp_dir: dir} do + put_c_source(dir) + + result = + CSource.run( + project_root: dir, + semgrep_runner: not_installed_runner(), + flawfinder_runner: not_installed_runner() + ) + + assert result.status == :ok + assert Enum.any?(result.notes, &String.contains?(&1, "semgrep not installed")) + assert Enum.any?(result.notes, &String.contains?(&1, "flawfinder not installed")) + refute "semgrep" in result.tools_used + refute "flawfinder" in result.tools_used + end + + test "merges findings from both tools", %{tmp_dir: dir} do + put_c_source(dir) + + semgrep_json = + ~s({"results":[{"check_id":"my-rule","path":"f.c","start":{"line":42},"extra":{"severity":"ERROR","message":"Use of unsafe API"}}]}) + + flawfinder_csv = """ + File,Line,Column,Level,Category,Name,Warning,Suggestion,Note,CWEs,Context + f.c,7,1,4,buffer,strcpy,description,note,cwe,context + """ + + result = + CSource.run( + project_root: dir, + semgrep_runner: fn _t -> {:ok, semgrep_json} end, + flawfinder_runner: fn _t -> {:ok, flawfinder_csv} end + ) + + assert length(result.findings) == 2 + sources = Enum.map(result.findings, & &1.source) |> Enum.sort() + assert sources == [:flawfinder, :semgrep] + end + end + + describe "parse_semgrep/1" do + test "maps a finding with severity" do + json = + ~s({"results":[{"check_id":"r","path":"f.c","start":{"line":3},"extra":{"severity":"WARNING","message":"watch out"}}]}) + + assert [ + %Finding{ + id: "r", + severity: :medium, + package: "f.c", + version: "line 3", + source: :semgrep, + layer: :c_source + } + ] = CSource.parse_semgrep(json) + end + + test "ERROR maps to :high, CRITICAL to :critical" do + cases = [ + {"ERROR", :high}, + {"CRITICAL", :critical}, + {"WARNING", :medium}, + {"INFO", :low}, + {"weird", :unknown}, + {nil, :unknown} + ] + + for {input, expected} <- cases do + sev_field = if input, do: ~s("severity":"#{input}",), else: "" + + json = + ~s({"results":[{"check_id":"r","path":"f.c","start":{"line":1},"extra":{#{sev_field}"message":"m"}}]}) + + assert [%Finding{severity: ^expected}] = CSource.parse_semgrep(json), + "expected #{inspect(input)} → #{inspect(expected)}" + end + end + + test "returns [] for invalid JSON" do + assert CSource.parse_semgrep("not-json") == [] + end + end + + describe "parse_flawfinder/1" do + test "maps a row with banned-API category" do + csv = """ + File,Line,Column,Level,Category,Name,Warning,Suggestion,Note,CWEs,Context + a.c,10,1,5,buffer,gets,"Buffer overflow risk",Avoid,note,CWE-242,ctx + """ + + [finding] = CSource.parse_flawfinder(csv) + + assert finding.id == "flawfinder/gets" + assert finding.severity == :critical + assert finding.package == "a.c" + assert finding.version == "line 10" + assert finding.source == :flawfinder + assert finding.layer == :c_source + end + + test "level → severity mapping" do + cases = [{5, :critical}, {4, :high}, {3, :medium}, {2, :low}, {1, :low}, {0, :unknown}] + + for {level, expected} <- cases do + csv = """ + File,Line,Column,Level,Category,Name,Warning,Suggestion + x.c,1,1,#{level},c,n,"w","s" + """ + + [%Finding{severity: ^expected}] = CSource.parse_flawfinder(csv) + end + end + + test "returns [] for header-only or empty input" do + assert CSource.parse_flawfinder("File,Line,Column\n") == [] + assert CSource.parse_flawfinder("") == [] + end + end +end diff --git a/test/mob_dev/security_scan/layers/gradle_deps_test.exs b/test/mob_dev/security_scan/layers/gradle_deps_test.exs new file mode 100644 index 0000000..8a5720f --- /dev/null +++ b/test/mob_dev/security_scan/layers/gradle_deps_test.exs @@ -0,0 +1,91 @@ +defmodule MobDev.SecurityScan.Layers.GradleDepsTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.Finding + alias MobDev.SecurityScan.Layers.GradleDeps + + @moduletag :tmp_dir + + test "returns :not_applicable when no android/ directory", %{tmp_dir: dir} do + result = GradleDeps.run(project_root: dir) + assert result.status == :not_applicable + assert Enum.any?(result.notes, &String.contains?(&1, "no android/")) + end + + describe "with android/ directory" do + setup %{tmp_dir: dir} do + android = Path.join(dir, "android") + File.mkdir_p!(android) + {:ok, android: android, project: dir} + end + + test ":ok with no findings + lockfile-guidance note when stub returns []", %{project: dir} do + result = + GradleDeps.run( + project_root: dir, + osv_scan_fn: fn _t, _l, _o -> {:ok, []} end + ) + + assert result.status == :ok + assert result.findings == [] + assert "osv-scanner" in result.tools_used + + assert Enum.any?(result.notes, &String.contains?(&1, "no gradle.lockfile present")) + end + + test ":ok with findings when stub returns vulns", %{project: dir} do + f = %Finding{id: "X", severity: :high, package: "okhttp", layer: :gradle_deps} + + result = + GradleDeps.run( + project_root: dir, + osv_scan_fn: fn _t, _l, _o -> {:ok, [f]} end + ) + + assert result.status == :ok + assert result.findings == [f] + end + + test ":tool_missing when osv-scanner not installed", %{project: dir} do + result = + GradleDeps.run( + project_root: dir, + osv_scan_fn: fn _t, _l, _o -> {:error, :not_installed} end + ) + + assert result.status == :tool_missing + assert Enum.any?(result.notes, &String.contains?(&1, "brew install osv-scanner")) + end + + test ":error when scan fails for a real reason", %{project: dir} do + result = + GradleDeps.run( + project_root: dir, + osv_scan_fn: fn _t, _l, _o -> {:error, {:scan_failed, "exit 137"}} end + ) + + assert result.status == :error + assert result.error =~ "exit 137" + end + + test "lockfile-guidance note flips when gradle.lockfile is present", %{ + project: dir, + android: android + } do + app = Path.join(android, "app") + File.mkdir_p!(app) + File.write!(Path.join(app, "gradle.lockfile"), "") + + result = + GradleDeps.run( + project_root: dir, + osv_scan_fn: fn _t, _l, _o -> {:ok, []} end + ) + + assert Enum.any?( + result.notes, + &String.contains?(&1, "scanned manifests including gradle.lockfile") + ) + end + end +end diff --git a/test/mob_dev/security_scan/layers/hex_deps_test.exs b/test/mob_dev/security_scan/layers/hex_deps_test.exs new file mode 100644 index 0000000..1090048 --- /dev/null +++ b/test/mob_dev/security_scan/layers/hex_deps_test.exs @@ -0,0 +1,223 @@ +defmodule MobDev.SecurityScan.Layers.HexDepsTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.Finding + alias MobDev.SecurityScan.Layers.HexDeps + + @moduletag :tmp_dir + + defp write_lockfile(dir, contents) do + File.write!(Path.join(dir, "mix.lock"), contents) + end + + defp no_osv, do: fn _target, _layer, _opts -> {:error, :not_installed} end + + defp lockfile_with_plug(version) do + """ + %{ + "plug": {:hex, :plug, "#{version}", "abc123", [:mix], [], "hexpm", "deadbeef"}, + } + """ + end + + defp advisory(opts) do + %MixAudit.Advisory{ + id: opts[:id] || "GHSA-test", + package: opts[:package] || "plug", + url: opts[:url] || "https://example.com", + title: opts[:title] || "Test advisory", + description: opts[:description] || "desc", + vulnerable_version_ranges: opts[:vulnerable] || ["< 1.11.0"], + first_patched_versions: opts[:patched] || ["1.11.0"], + severity: Keyword.get(opts, :severity, "high") + } + end + + test "returns :not_applicable when no mix.lock", %{tmp_dir: dir} do + result = + HexDeps.run(project_root: dir, advisories_fn: fn -> [] end, osv_scan_fn: no_osv()) + + assert result.status == :not_applicable + assert result.findings == [] + assert Enum.any?(result.notes, &String.contains?(&1, "no mix.lock")) + end + + test "returns :ok with no findings when no advisories match", %{tmp_dir: dir} do + write_lockfile(dir, lockfile_with_plug("1.12.0")) + + result = + HexDeps.run( + project_root: dir, + advisories_fn: fn -> [advisory(vulnerable: ["< 1.11.0"], patched: ["1.11.0"])] end, + osv_scan_fn: no_osv() + ) + + assert result.status == :ok + assert result.findings == [] + assert "mix_audit" in result.tools_used + end + + test "returns :ok with mapped finding when advisory matches", %{tmp_dir: dir} do + write_lockfile(dir, lockfile_with_plug("1.10.0")) + + result = + HexDeps.run( + project_root: dir, + advisories_fn: fn -> + [ + advisory( + id: "GHSA-9999", + vulnerable: ["< 1.11.0"], + patched: ["1.11.0"], + severity: "critical", + title: "RCE", + url: "https://example.com/9999" + ) + ] + end, + osv_scan_fn: no_osv() + ) + + assert result.status == :ok + + assert [ + %Finding{ + id: "GHSA-9999", + severity: :critical, + package: "plug", + version: "1.10.0", + fixed_in: "1.11.0", + title: "RCE", + url: "https://example.com/9999", + source: :mix_audit, + layer: :hex_deps + } + ] = result.findings + end + + test "normalizes vendor severity scales", %{tmp_dir: dir} do + write_lockfile(dir, lockfile_with_plug("1.10.0")) + + cases = [ + {"critical", :critical}, + {"high", :high}, + {"important", :high}, + {"medium", :medium}, + {"moderate", :medium}, + {"low", :low}, + {"", :unknown}, + {nil, :unknown}, + {"weird-string", :unknown} + ] + + for {input, expected} <- cases do + result = + HexDeps.run( + project_root: dir, + advisories_fn: fn -> [advisory(severity: input)] end, + osv_scan_fn: no_osv() + ) + + assert [%Finding{severity: ^expected}] = result.findings, + "expected #{inspect(input)} to normalize to #{inspect(expected)}" + end + end + + test "returns :tool_missing if the advisory fetch raises", %{tmp_dir: dir} do + write_lockfile(dir, lockfile_with_plug("1.10.0")) + + result = + HexDeps.run( + project_root: dir, + advisories_fn: fn -> raise "GitHub down" end, + osv_scan_fn: no_osv() + ) + + assert result.status == :tool_missing + assert Enum.any?(result.notes, &String.contains?(&1, "GitHub down")) + end + + describe "osv-scanner integration" do + test "merges osv findings with mix_audit findings", %{tmp_dir: dir} do + write_lockfile(dir, lockfile_with_plug("1.10.0")) + + audit_advisory = + advisory(id: "AUDIT-1", vulnerable: ["< 1.11.0"], patched: ["1.11.0"]) + + osv_finding = %Finding{ + id: "OSV-1", + severity: :high, + package: "plug", + version: "1.10.0", + source: :osv_scanner, + layer: :hex_deps + } + + result = + HexDeps.run( + project_root: dir, + advisories_fn: fn -> [audit_advisory] end, + osv_scan_fn: fn _target, _layer, _opts -> {:ok, [osv_finding]} end + ) + + ids = Enum.map(result.findings, & &1.id) |> Enum.sort() + assert ids == ["AUDIT-1", "OSV-1"] + end + + test "dedupes a finding reported by both sources, keeping the osv-tagged one", %{tmp_dir: dir} do + write_lockfile(dir, lockfile_with_plug("1.10.0")) + + same_id = "GHSA-DUPE" + + audit_advisory = + advisory(id: same_id, vulnerable: ["< 1.11.0"], patched: ["1.11.0"], severity: "high") + + osv_finding = %Finding{ + id: same_id, + severity: :critical, + package: "plug", + version: "1.10.0", + source: :osv_scanner, + layer: :hex_deps + } + + result = + HexDeps.run( + project_root: dir, + advisories_fn: fn -> [audit_advisory] end, + osv_scan_fn: fn _target, _layer, _opts -> {:ok, [osv_finding]} end + ) + + assert [%Finding{id: ^same_id, source: :osv_scanner, severity: :critical}] = result.findings + end + + test "records osv 'not installed' as a note without crashing", %{tmp_dir: dir} do + write_lockfile(dir, lockfile_with_plug("1.12.0")) + + result = + HexDeps.run( + project_root: dir, + advisories_fn: fn -> [] end, + osv_scan_fn: fn _target, _layer, _opts -> {:error, :not_installed} end + ) + + assert result.status == :ok + assert Enum.any?(result.notes, &String.contains?(&1, "osv-scanner not installed")) + refute "osv-scanner" in result.tools_used + end + + test "tools_used includes osv-scanner when it ran", %{tmp_dir: dir} do + write_lockfile(dir, lockfile_with_plug("1.12.0")) + + result = + HexDeps.run( + project_root: dir, + advisories_fn: fn -> [] end, + osv_scan_fn: fn _target, _layer, _opts -> {:ok, []} end + ) + + assert "osv-scanner" in result.tools_used + assert "mix_audit" in result.tools_used + end + end +end diff --git a/test/mob_dev/security_scan/layers/kotlin_source_test.exs b/test/mob_dev/security_scan/layers/kotlin_source_test.exs new file mode 100644 index 0000000..d30c82f --- /dev/null +++ b/test/mob_dev/security_scan/layers/kotlin_source_test.exs @@ -0,0 +1,92 @@ +defmodule MobDev.SecurityScan.Layers.KotlinSourceTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.Finding + alias MobDev.SecurityScan.Layers.KotlinSource + + @moduletag :tmp_dir + + defp put_kotlin_source(dir) do + src = Path.join([dir, "android", "app", "src", "main", "java"]) + File.mkdir_p!(src) + File.write!(Path.join(src, "Hello.kt"), "fun main() {}\n") + src + end + + test ":not_applicable when no kotlin/java source", %{tmp_dir: dir} do + assert %{status: :not_applicable} = + KotlinSource.run(project_root: dir, runner: fn _ -> {:ok, "{}"} end) + end + + test ":tool_missing when detekt not installed", %{tmp_dir: dir} do + put_kotlin_source(dir) + + result = KotlinSource.run(project_root: dir, runner: fn _ -> {:error, :not_installed} end) + + assert result.status == :tool_missing + assert Enum.any?(result.notes, &String.contains?(&1, "brew install detekt")) + end + + test "parses SARIF output into findings", %{tmp_dir: dir} do + put_kotlin_source(dir) + + sarif = + Jason.encode!(%{ + "runs" => [ + %{ + "results" => [ + %{ + "ruleId" => "complexity.LongMethod", + "level" => "warning", + "message" => %{"text" => "method too long"}, + "locations" => [ + %{ + "physicalLocation" => %{ + "artifactLocation" => %{"uri" => "src/main/Hello.kt"}, + "region" => %{"startLine" => 42} + } + } + ] + } + ] + } + ] + }) + + result = KotlinSource.run(project_root: dir, runner: fn _ -> {:ok, sarif} end) + + assert [ + %Finding{ + id: "complexity.LongMethod", + severity: :medium, + package: "src/main/Hello.kt", + version: "line 42", + source: :detekt, + layer: :kotlin_source + } + ] = result.findings + end + + test "level normalization" do + sarif = + Jason.encode!(%{ + "runs" => [ + %{ + "results" => [ + %{"ruleId" => "r1", "level" => "error", "message" => %{"text" => "m"}}, + %{"ruleId" => "r2", "level" => "warning", "message" => %{"text" => "m"}}, + %{"ruleId" => "r3", "level" => "note", "message" => %{"text" => "m"}} + ] + } + ] + }) + + findings = KotlinSource.parse(sarif) + severities = Enum.map(findings, & &1.severity) + assert severities == [:high, :medium, :low] + end + + test "returns [] for invalid JSON" do + assert KotlinSource.parse("nope") == [] + end +end diff --git a/test/mob_dev/security_scan/layers/swift_deps_test.exs b/test/mob_dev/security_scan/layers/swift_deps_test.exs new file mode 100644 index 0000000..74a91ed --- /dev/null +++ b/test/mob_dev/security_scan/layers/swift_deps_test.exs @@ -0,0 +1,71 @@ +defmodule MobDev.SecurityScan.Layers.SwiftDepsTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.Finding + alias MobDev.SecurityScan.Layers.SwiftDeps + + @moduletag :tmp_dir + + test ":not_applicable when no ios/ directory", %{tmp_dir: dir} do + result = SwiftDeps.run(project_root: dir) + assert result.status == :not_applicable + assert Enum.any?(result.notes, &String.contains?(&1, "no ios/")) + end + + test ":not_applicable when ios/ exists but has no Swift manifest", %{tmp_dir: dir} do + File.mkdir_p!(Path.join(dir, "ios")) + + result = SwiftDeps.run(project_root: dir) + + assert result.status == :not_applicable + assert Enum.any?(result.notes, &String.contains?(&1, "no Package.resolved or Podfile.lock")) + assert Enum.any?(result.notes, &String.contains?(&1, ":bundled_runtime")) + end + + describe "when a Swift manifest exists" do + setup %{tmp_dir: dir} do + ios = Path.join(dir, "ios") + File.mkdir_p!(ios) + File.write!(Path.join(ios, "Package.resolved"), "{}") + {:ok, project: dir, ios: ios} + end + + test ":ok when scanner returns findings", %{project: dir} do + f = %Finding{id: "X", severity: :high, package: "alamofire", layer: :swift_deps} + + result = + SwiftDeps.run( + project_root: dir, + osv_scan_fn: fn _t, _l, _o -> {:ok, [f]} end + ) + + assert result.status == :ok + assert result.findings == [f] + assert "osv-scanner" in result.tools_used + end + + test ":tool_missing when osv-scanner not installed", %{project: dir} do + result = + SwiftDeps.run( + project_root: dir, + osv_scan_fn: fn _t, _l, _o -> {:error, :not_installed} end + ) + + assert result.status == :tool_missing + end + + test "Podfile.lock alone is enough to trigger a scan", %{tmp_dir: dir} do + ios = Path.join(dir, "ios") + File.rm!(Path.join(ios, "Package.resolved")) + File.write!(Path.join(ios, "Podfile.lock"), "") + + result = + SwiftDeps.run( + project_root: dir, + osv_scan_fn: fn _t, _l, _o -> {:ok, []} end + ) + + assert result.status == :ok + end + end +end diff --git a/test/mob_dev/security_scan/layers/swift_source_test.exs b/test/mob_dev/security_scan/layers/swift_source_test.exs new file mode 100644 index 0000000..b4a9b5f --- /dev/null +++ b/test/mob_dev/security_scan/layers/swift_source_test.exs @@ -0,0 +1,70 @@ +defmodule MobDev.SecurityScan.Layers.SwiftSourceTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.Finding + alias MobDev.SecurityScan.Layers.SwiftSource + + @moduletag :tmp_dir + + defp put_swift_source(dir) do + ios = Path.join(dir, "ios") + File.mkdir_p!(ios) + File.write!(Path.join(ios, "Hello.swift"), "import Foundation\n") + end + + test ":not_applicable when no Swift files in ios/", %{tmp_dir: dir} do + File.mkdir_p!(Path.join(dir, "ios")) + File.write!(Path.join([dir, "ios", "AppDelegate.m"]), "// objc only\n") + + assert %{status: :not_applicable} = + SwiftSource.run(project_root: dir, runner: fn _ -> {:ok, "[]"} end) + end + + test ":tool_missing when swiftlint not installed", %{tmp_dir: dir} do + put_swift_source(dir) + + result = SwiftSource.run(project_root: dir, runner: fn _ -> {:error, :not_installed} end) + + assert result.status == :tool_missing + assert Enum.any?(result.notes, &String.contains?(&1, "brew install swiftlint")) + end + + test "parses swiftlint JSON into findings", %{tmp_dir: dir} do + put_swift_source(dir) + + json = + Jason.encode!([ + %{ + "rule_id" => "force_unwrapping", + "severity" => "Warning", + "file" => "/path/Hello.swift", + "line" => 7, + "reason" => "Force unwrapping should be avoided" + } + ]) + + result = SwiftSource.run(project_root: dir, runner: fn _ -> {:ok, json} end) + + assert [ + %Finding{ + id: "force_unwrapping", + severity: :medium, + package: "/path/Hello.swift", + version: "line 7", + source: :swiftlint, + layer: :swift_source + } + ] = result.findings + end + + test "severity normalization" do + json = + Jason.encode!([ + %{"rule_id" => "r1", "severity" => "Error", "reason" => "x"}, + %{"rule_id" => "r2", "severity" => "Warning", "reason" => "x"} + ]) + + findings = SwiftSource.parse(json) + assert Enum.map(findings, & &1.severity) == [:high, :medium] + end +end diff --git a/test/mob_dev/security_scan/osv_scanner/parser_test.exs b/test/mob_dev/security_scan/osv_scanner/parser_test.exs new file mode 100644 index 0000000..243f339 --- /dev/null +++ b/test/mob_dev/security_scan/osv_scanner/parser_test.exs @@ -0,0 +1,156 @@ +defmodule MobDev.SecurityScan.OsvScanner.ParserTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.Finding + alias MobDev.SecurityScan.OsvScanner.Parser + + defp osv_json(opts \\ []) do + %{ + "results" => [ + %{ + "source" => %{"path" => "/p/mix.lock", "type" => "lockfile"}, + "packages" => [ + %{ + "package" => %{ + "name" => Keyword.get(opts, :name, "bandit"), + "version" => Keyword.get(opts, :version, "1.10.4"), + "ecosystem" => "Hex" + }, + "groups" => [ + %{ + "ids" => Keyword.get(opts, :ids, ["GHSA-1"]), + "max_severity" => Keyword.get(opts, :max_severity, "8.2") + } + ], + "vulnerabilities" => + Keyword.get(opts, :vulnerabilities, [ + %{ + "id" => "GHSA-1", + "aliases" => ["CVE-2026-1", "GHSA-1"], + "summary" => "Path traversal in bandit", + "details" => "Long description", + "affected" => [ + %{ + "ranges" => [ + %{"events" => [%{"introduced" => "0.5.9"}, %{"fixed" => "1.11.0"}]} + ] + } + ], + "references" => [%{"url" => "https://example.com/advisory/1"}] + } + ]) + } + ] + } + ] + } + end + + describe "findings/2" do + test "extracts a finding from a single vulnerability" do + [finding] = Parser.findings(osv_json(), :hex_deps) + + assert %Finding{ + id: "GHSA-1", + severity: :high, + package: "bandit", + version: "1.10.4", + fixed_in: "1.11.0", + source: :osv_scanner, + layer: :hex_deps, + title: "Path traversal in bandit", + description: "Long description", + url: "https://example.com/advisory/1" + } = finding + end + + test "tags findings with the supplied layer atom" do + [%Finding{layer: :gradle_deps}] = Parser.findings(osv_json(), :gradle_deps) + end + + test "returns [] for an empty results list" do + assert Parser.findings(%{"results" => []}, :hex_deps) == [] + end + + test "returns [] when the package has no vulnerabilities key" do + json = %{ + "results" => [ + %{"packages" => [%{"package" => %{"name" => "p", "version" => "1"}}]} + ] + } + + assert Parser.findings(json, :hex_deps) == [] + end + + test "handles missing groups field gracefully (severity becomes :unknown)" do + pkg = %{ + "package" => %{"name" => "p", "version" => "1.0"}, + "vulnerabilities" => [%{"id" => "X", "summary" => "s"}] + } + + json = %{"results" => [%{"packages" => [pkg]}]} + + assert [%Finding{severity: :unknown, id: "X"}] = Parser.findings(json, :hex_deps) + end + end + + describe "CVSS severity bands" do + test "9.0+ is critical" do + [%Finding{severity: :critical}] = Parser.findings(osv_json(max_severity: "9.0"), :hex_deps) + [%Finding{severity: :critical}] = Parser.findings(osv_json(max_severity: "10.0"), :hex_deps) + end + + test "7.0–8.9 is high" do + [%Finding{severity: :high}] = Parser.findings(osv_json(max_severity: "7.0"), :hex_deps) + [%Finding{severity: :high}] = Parser.findings(osv_json(max_severity: "8.9"), :hex_deps) + end + + test "4.0–6.9 is medium" do + [%Finding{severity: :medium}] = Parser.findings(osv_json(max_severity: "4.0"), :hex_deps) + [%Finding{severity: :medium}] = Parser.findings(osv_json(max_severity: "6.9"), :hex_deps) + end + + test "0.1–3.9 is low" do + [%Finding{severity: :low}] = Parser.findings(osv_json(max_severity: "0.1"), :hex_deps) + [%Finding{severity: :low}] = Parser.findings(osv_json(max_severity: "3.9"), :hex_deps) + end + + test "unparseable / missing scores are :unknown" do + [%Finding{severity: :unknown}] = Parser.findings(osv_json(max_severity: nil), :hex_deps) + [%Finding{severity: :unknown}] = Parser.findings(osv_json(max_severity: ""), :hex_deps) + + [%Finding{severity: :unknown}] = + Parser.findings(osv_json(max_severity: "garbage"), :hex_deps) + end + end + + describe "fixed_in extraction" do + test "picks the first 'fixed' event from affected.ranges.events" do + [%Finding{fixed_in: "1.11.0"}] = Parser.findings(osv_json(), :hex_deps) + end + + test "returns nil when no range has a fixed event" do + vulns = [ + %{ + "id" => "X", + "aliases" => ["X"], + "affected" => [%{"ranges" => [%{"events" => [%{"introduced" => "0.0.0"}]}]}] + } + ] + + [%Finding{fixed_in: nil}] = Parser.findings(osv_json(vulnerabilities: vulns), :hex_deps) + end + end + + describe "url extraction" do + test "picks the first reference URL" do + [%Finding{url: "https://example.com/advisory/1"}] = + Parser.findings(osv_json(), :hex_deps) + end + + test "returns nil when references is missing" do + vulns = [%{"id" => "X", "aliases" => ["X"]}] + [%Finding{url: nil}] = Parser.findings(osv_json(vulnerabilities: vulns), :hex_deps) + end + end +end diff --git a/test/mob_dev/security_scan/osv_scanner_test.exs b/test/mob_dev/security_scan/osv_scanner_test.exs new file mode 100644 index 0000000..d16b2e6 --- /dev/null +++ b/test/mob_dev/security_scan/osv_scanner_test.exs @@ -0,0 +1,127 @@ +defmodule MobDev.SecurityScan.OsvScannerTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.OsvScanner + + @moduletag :tmp_dir + + defp empty_osv_json, do: ~s({"results":[]}) + + defp osv_json_with_finding do + Jason.encode!(%{ + "results" => [ + %{ + "packages" => [ + %{ + "package" => %{"name" => "plug", "version" => "1.10.0", "ecosystem" => "Hex"}, + "groups" => [%{"ids" => ["X"], "max_severity" => "8.2"}], + "vulnerabilities" => [ + %{"id" => "X", "summary" => "RCE", "aliases" => ["X"]} + ] + } + ] + } + ] + }) + end + + describe "scan/3" do + test "returns {:error, {:not_found, _}} when target lockfile is missing" do + assert {:error, {:not_found, "/nope/mix.lock"}} = + OsvScanner.scan({:lockfile, "/nope/mix.lock"}, :hex_deps, + runner: fn _args -> {:ok, empty_osv_json()} end + ) + end + + test "returns {:error, {:not_found, _}} when target directory is missing" do + assert {:error, {:not_found, "/nope/dir"}} = + OsvScanner.scan({:directory, "/nope/dir"}, :hex_deps, + runner: fn _args -> {:ok, empty_osv_json()} end + ) + end + + test "returns {:ok, []} on a clean scan", %{tmp_dir: dir} do + lockfile = Path.join(dir, "mix.lock") + File.write!(lockfile, "%{}\n") + + assert {:ok, []} = + OsvScanner.scan({:lockfile, lockfile}, :hex_deps, + runner: fn _args -> {:ok, empty_osv_json()} end + ) + end + + test "returns {:ok, [finding]} when osv-scanner reports a vulnerability", %{tmp_dir: dir} do + lockfile = Path.join(dir, "mix.lock") + File.write!(lockfile, "%{}\n") + + assert {:ok, [finding]} = + OsvScanner.scan({:lockfile, lockfile}, :hex_deps, + runner: fn _args -> {:ok, osv_json_with_finding()} end + ) + + assert finding.id == "X" + assert finding.severity == :high + assert finding.layer == :hex_deps + assert finding.source == :osv_scanner + end + + test "returns {:error, {:scan_failed, _}} on malformed JSON", %{tmp_dir: dir} do + lockfile = Path.join(dir, "mix.lock") + File.write!(lockfile, "%{}\n") + + assert {:error, {:scan_failed, "json decode: " <> _}} = + OsvScanner.scan({:lockfile, lockfile}, :hex_deps, + runner: fn _args -> {:ok, "not-json"} end + ) + end + + test "returns {:error, {:scan_failed, _}} when runner reports failure", %{tmp_dir: dir} do + lockfile = Path.join(dir, "mix.lock") + File.write!(lockfile, "%{}\n") + + assert {:error, {:scan_failed, "exit 127: " <> _}} = + OsvScanner.scan({:lockfile, lockfile}, :hex_deps, + runner: fn _args -> {:error, "exit 127: command not found"} end + ) + end + + test "passes lockfile arg to runner", %{tmp_dir: dir} do + lockfile = Path.join(dir, "mix.lock") + File.write!(lockfile, "%{}\n") + pid = self() + + OsvScanner.scan({:lockfile, lockfile}, :hex_deps, + runner: fn args -> + send(pid, {:args, args}) + {:ok, empty_osv_json()} + end + ) + + assert_received {:args, args} + assert "--lockfile=#{lockfile}" in args + assert "--format=json" in args + end + + test "passes directory arg to runner", %{tmp_dir: dir} do + pid = self() + + OsvScanner.scan({:directory, dir}, :gradle_deps, + runner: fn args -> + send(pid, {:args, args}) + {:ok, empty_osv_json()} + end + ) + + assert_received {:args, args} + assert "--recursive" in args + assert dir in args + end + end + + describe "installed?/0" do + @tag :integration + test "returns boolean reflecting PATH" do + assert OsvScanner.installed?() in [true, false] + end + end +end diff --git a/test/mob_dev/security_scan/report_test.exs b/test/mob_dev/security_scan/report_test.exs new file mode 100644 index 0000000..7fcc223 --- /dev/null +++ b/test/mob_dev/security_scan/report_test.exs @@ -0,0 +1,103 @@ +defmodule MobDev.SecurityScan.ReportTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.{Finding, LayerResult, Report} + + defp report(layers) do + %Report{ + started_at: ~U[2026-01-01 00:00:00Z], + finished_at: ~U[2026-01-01 00:00:01Z], + project_root: "/tmp", + layers: layers + } + end + + defp finding(severity), do: %Finding{severity: severity} + + describe "all_findings/1" do + test "flattens findings across layers" do + r = + report([ + %LayerResult{name: :a, findings: [finding(:high), finding(:low)]}, + %LayerResult{name: :b, findings: [finding(:critical)]} + ]) + + assert length(Report.all_findings(r)) == 3 + end + + test "returns empty list when no layers" do + assert Report.all_findings(report([])) == [] + end + end + + describe "severity_counts/1" do + test "counts each severity, defaults zeros for empty" do + r = + report([ + %LayerResult{ + name: :a, + findings: [ + finding(:critical), + finding(:critical), + finding(:high), + finding(:low), + finding(:unknown) + ] + } + ]) + + assert Report.severity_counts(r) == %{ + critical: 2, + high: 1, + medium: 0, + low: 1, + unknown: 1 + } + end + + test "every key present even when no findings" do + r = report([]) + assert Report.severity_counts(r) == %{critical: 0, high: 0, medium: 0, low: 0, unknown: 0} + end + end + + describe "worst_severity/1" do + test "returns :none for empty report" do + assert Report.worst_severity(report([])) == :none + end + + test "picks the worst present" do + r = report([%LayerResult{name: :a, findings: [finding(:medium), finding(:low)]}]) + assert Report.worst_severity(r) == :medium + end + + test "critical wins over everything else" do + r = + report([ + %LayerResult{ + name: :a, + findings: [ + finding(:low), + finding(:critical), + finding(:high), + finding(:unknown) + ] + } + ]) + + assert Report.worst_severity(r) == :critical + end + end + + describe "duration_ms/1" do + test "computes wall-clock duration" do + r = report([]) + assert Report.duration_ms(r) == 1000 + end + + test "returns nil if not finished" do + r = %Report{started_at: ~U[2026-01-01 00:00:00Z], finished_at: nil, layers: []} + assert Report.duration_ms(r) == nil + end + end +end diff --git a/test/mob_dev/security_scan/runner_test.exs b/test/mob_dev/security_scan/runner_test.exs new file mode 100644 index 0000000..a675937 --- /dev/null +++ b/test/mob_dev/security_scan/runner_test.exs @@ -0,0 +1,99 @@ +defmodule MobDev.SecurityScan.RunnerTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.{Finding, LayerResult, Runner} + + defmodule OkLayer do + @behaviour MobDev.SecurityScan.Layer + + @impl true + def name, do: :ok_layer + + @impl true + def run(_opts) do + %LayerResult{ + name: :ok_layer, + status: :ok, + findings: [%Finding{id: "X", severity: :high, layer: :ok_layer}] + } + end + end + + defmodule RaisingLayer do + @behaviour MobDev.SecurityScan.Layer + + @impl true + def name, do: :raising_layer + + @impl true + def run(_opts), do: raise("boom") + end + + defmodule SlowLayer do + @behaviour MobDev.SecurityScan.Layer + + @impl true + def name, do: :slow_layer + + @impl true + def run(_opts) do + Process.sleep(15) + %LayerResult{name: :slow_layer, status: :ok} + end + end + + test "runs every layer and accumulates findings" do + report = Runner.run([OkLayer]) + + assert [%LayerResult{name: :ok_layer, status: :ok, findings: [_one]}] = + report.layers + end + + test "wraps a raising layer as :error so other layers still run" do + report = Runner.run([RaisingLayer, OkLayer]) + + assert [%LayerResult{name: :raising_layer, status: :error, error: "boom"}, ok] = + report.layers + + assert ok.name == :ok_layer + assert ok.status == :ok + end + + test "skip flag marks layers without invoking them" do + pid = self() + + defmodule TattleLayer do + @behaviour MobDev.SecurityScan.Layer + @impl true + def name, do: :tattle + @impl true + def run(opts) do + send(opts[:_pid], :ran) + %LayerResult{name: :tattle, status: :ok} + end + end + + report = Runner.run([TattleLayer], skip: [:tattle], _pid: pid) + + refute_received :ran + assert [%LayerResult{name: :tattle, status: :skipped}] = report.layers + end + + test "records duration_ms for each layer" do + report = Runner.run([SlowLayer]) + [layer] = report.layers + assert layer.duration_ms >= 15 + end + + test "fires on_layer_start and on_layer_done callbacks" do + pid = self() + + Runner.run([OkLayer], + on_layer_start: fn name -> send(pid, {:start, name}) end, + on_layer_done: fn result -> send(pid, {:done, result.name, result.status}) end + ) + + assert_received {:start, :ok_layer} + assert_received {:done, :ok_layer, :ok} + end +end diff --git a/test/mob_dev/security_scan/state_file_test.exs b/test/mob_dev/security_scan/state_file_test.exs new file mode 100644 index 0000000..0446053 --- /dev/null +++ b/test/mob_dev/security_scan/state_file_test.exs @@ -0,0 +1,112 @@ +defmodule MobDev.SecurityScan.StateFileTest do + use ExUnit.Case, async: true + + alias MobDev.SecurityScan.{Diff, Finding, LayerResult, Report, StateFile} + + @moduletag :tmp_dir + + defp finding(opts \\ []) do + %Finding{ + id: opts[:id] || "GHSA-1", + severity: opts[:severity] || :high, + package: opts[:package] || "plug", + version: opts[:version] || "1.10.0", + title: opts[:title] || "title", + url: opts[:url] || "https://example.com", + source: :osv_scanner, + layer: :hex_deps + } + end + + defp report(findings) do + %Report{ + started_at: ~U[2026-05-07 00:00:00Z], + finished_at: ~U[2026-05-07 00:00:01Z], + project_root: "/p", + layers: [%LayerResult{name: :hex_deps, status: :ok, findings: findings}] + } + end + + test "load/1 returns empty state when file is missing", %{tmp_dir: dir} do + assert StateFile.empty() == StateFile.load(Path.join(dir, "missing.json")) + end + + test "save/2 then load/1 round-trips", %{tmp_dir: dir} do + path = Path.join(dir, "state.json") + now = ~U[2026-05-07 12:00:00Z] + + diff = Diff.compute(StateFile.empty(), report([finding()]), now) + state = StateFile.from_report(report([finding()]), diff, now) + + StateFile.save(path, state) + loaded = StateFile.load(path) + + assert loaded.version == state.version + assert loaded.last_run_at == state.last_run_at + assert length(loaded.findings) == 1 + [entry] = loaded.findings + assert entry.id == "GHSA-1" + assert entry.severity == :high + assert entry.first_seen_at == now + end + + test "save/2 writes valid JSON", %{tmp_dir: dir} do + path = Path.join(dir, "state.json") + now = ~U[2026-05-07 12:00:00Z] + + diff = Diff.compute(StateFile.empty(), report([finding()]), now) + state = StateFile.from_report(report([finding()]), diff, now) + + StateFile.save(path, state) + + {:ok, raw} = File.read(path) + assert {:ok, decoded} = Jason.decode(raw) + assert decoded["version"] == 1 + assert decoded["last_run_at"] == "2026-05-07T12:00:00Z" + assert is_list(decoded["findings"]) and length(decoded["findings"]) == 1 + end + + test "from_report/3 preserves first_seen_at for known findings", %{tmp_dir: _dir} do + earlier = ~U[2026-04-01 00:00:00Z] + now = ~U[2026-05-07 00:00:00Z] + + initial = + StateFile.from_report( + report([finding(id: "X")]), + Diff.compute(StateFile.empty(), report([finding(id: "X")]), earlier), + earlier + ) + + diff_now = Diff.compute(initial, report([finding(id: "X")]), now) + new_state = StateFile.from_report(report([finding(id: "X")]), diff_now, now) + + [entry] = new_state.findings + assert entry.first_seen_at == earlier + end + + test "from_report/3 stamps first_seen_at = now for genuinely new findings" do + now = ~U[2026-05-07 00:00:00Z] + + diff = Diff.compute(StateFile.empty(), report([finding(id: "X")]), now) + state = StateFile.from_report(report([finding(id: "X")]), diff, now) + + [entry] = state.findings + assert entry.first_seen_at == now + end + + test "save/2 sorts findings by key for stable diffs", %{tmp_dir: dir} do + path = Path.join(dir, "state.json") + now = ~U[2026-05-07 00:00:00Z] + + findings = [finding(id: "Z"), finding(id: "A"), finding(id: "M")] + diff = Diff.compute(StateFile.empty(), report(findings), now) + state = StateFile.from_report(report(findings), diff, now) + + StateFile.save(path, state) + + {:ok, raw} = File.read(path) + {:ok, %{"findings" => f}} = Jason.decode(raw) + ids = Enum.map(f, & &1["id"]) + assert ids == Enum.sort(ids) + end +end diff --git a/test/mob_dev/server/log_filter_test.exs b/test/mob_dev/server/log_filter_test.exs index c1ffac6..4d23e9c 100644 --- a/test/mob_dev/server/log_filter_test.exs +++ b/test/mob_dev/server/log_filter_test.exs @@ -7,14 +7,14 @@ defmodule MobDev.Server.LogFilterTest do defp line(attrs) do %{ - id: System.unique_integer([:positive]), - serial: "ABC123", - level: "I", - tag: nil, + id: System.unique_integer([:positive]), + serial: "ABC123", + level: "I", + tag: nil, message: "hello", - raw: "I/Tag(1): hello", - mob: false, - ts: "12:00:00" + raw: "I/Tag(1): hello", + mob: false, + ts: "12:00:00" } |> Map.merge(attrs) end @@ -28,8 +28,8 @@ defmodule MobDev.Server.LogFilterTest do end test ":app returns only mob-tagged lines" do - mob_line = line(%{mob: true}) - sys_line = line(%{mob: false}) + mob_line = line(%{mob: true}) + sys_line = line(%{mob: false}) assert LogFilter.by_device([mob_line, sys_line], :app) == [mob_line] end @@ -56,7 +56,7 @@ defmodule MobDev.Server.LogFilterTest do end test "matches message substring (case-insensitive)" do - match = line(%{message: "Tap me pressed — count is now 1", raw: ""}) + match = line(%{message: "Tap me pressed — count is now 1", raw: ""}) no_match = line(%{message: "set_root: pushed node", raw: ""}) result = LogFilter.by_text([match, no_match], "tap") assert result == [match] @@ -64,9 +64,9 @@ defmodule MobDev.Server.LogFilterTest do test "match is case-insensitive" do l = line(%{message: "[INFO] something happened", raw: ""}) - assert LogFilter.by_text([l], "info") == [l] - assert LogFilter.by_text([l], "INFO") == [l] - assert LogFilter.by_text([l], "Info") == [l] + assert LogFilter.by_text([l], "info") == [l] + assert LogFilter.by_text([l], "INFO") == [l] + assert LogFilter.by_text([l], "Info") == [l] end test "matches raw field when message doesn't match" do @@ -75,7 +75,7 @@ defmodule MobDev.Server.LogFilterTest do end test "comma-separated terms are OR'd" do - info_line = line(%{message: "[info] tap pressed", raw: ""}) + info_line = line(%{message: "[info] tap pressed", raw: ""}) error_line = line(%{message: "[error] crash", raw: ""}) debug_line = line(%{message: "[debug] verbose", raw: ""}) result = LogFilter.by_text([info_line, error_line, debug_line], "info, error") @@ -98,7 +98,7 @@ defmodule MobDev.Server.LogFilterTest do end test "filter by log level tag like [info]" do - info = line(%{message: "[info] counter incremented", raw: ""}) + info = line(%{message: "[info] counter incremented", raw: ""}) error = line(%{message: "[error] nif failed", raw: ""}) other = line(%{message: "set_root pushed", raw: ""}) assert LogFilter.by_text([info, error, other], "[info]") == [info] @@ -109,9 +109,9 @@ defmodule MobDev.Server.LogFilterTest do describe "apply/3" do test "combines device filter and text filter with AND logic" do - a = line(%{serial: "DEV1", mob: true, message: "tap pressed", raw: ""}) - b = line(%{serial: "DEV1", mob: true, message: "set_root", raw: ""}) - c = line(%{serial: "DEV2", mob: true, message: "tap pressed", raw: ""}) + a = line(%{serial: "DEV1", mob: true, message: "tap pressed", raw: ""}) + b = line(%{serial: "DEV1", mob: true, message: "set_root", raw: ""}) + c = line(%{serial: "DEV2", mob: true, message: "tap pressed", raw: ""}) # Device DEV1 AND text "tap" assert LogFilter.apply([a, b, c], "DEV1", "tap") == [a] @@ -123,9 +123,9 @@ defmodule MobDev.Server.LogFilterTest do end test ":app device + text filter" do - mob_tap = line(%{mob: true, message: "tap", raw: ""}) - mob_other = line(%{mob: true, message: "set_root", raw: ""}) - sys_tap = line(%{mob: false, message: "tap", raw: ""}) + mob_tap = line(%{mob: true, message: "tap", raw: ""}) + mob_other = line(%{mob: true, message: "set_root", raw: ""}) + sys_tap = line(%{mob: false, message: "tap", raw: ""}) result = LogFilter.apply([mob_tap, mob_other, sys_tap], :app, "tap") assert result == [mob_tap] end diff --git a/test/mob_dev/server/log_streamer_test.exs b/test/mob_dev/server/log_streamer_test.exs index f6200bb..9ae8f85 100644 --- a/test/mob_dev/server/log_streamer_test.exs +++ b/test/mob_dev/server/log_streamer_test.exs @@ -8,19 +8,19 @@ defmodule MobDev.Server.LogStreamerTest do line = "I/MobBeam( 1234): Starting BEAM with module=mob_demo, argc=18" result = LogStreamer.parse_line(line, "ZY22K6BSJM") - assert result.level == "I" - assert result.tag == "MobBeam" + assert result.level == "I" + assert result.tag == "MobBeam" assert result.message == "Starting BEAM with module=mob_demo, argc=18" - assert result.serial == "ZY22K6BSJM" - assert result.mob == true + assert result.serial == "ZY22K6BSJM" + assert result.mob == true end test "marks mob tags as mob: true" do line = "E/MobNif( 999): enif_get_long failed" result = LogStreamer.parse_line(line, "serial") - assert result.mob == true + assert result.mob == true assert result.level == "E" - assert result.tag == "MobNif" + assert result.tag == "MobNif" end test "marks non-mob tags as mob: false" do @@ -33,9 +33,9 @@ defmodule MobDev.Server.LogStreamerTest do test "marks Elixir tag as mob: true" do line = "I/Elixir (24617): Tap me pressed — count is now 1" result = LogStreamer.parse_line(line, "serial") - assert result.mob == true - assert result.tag == "Elixir" - assert result.level == "I" + assert result.mob == true + assert result.tag == "Elixir" + assert result.level == "I" assert result.message == "Tap me pressed — count is now 1" end @@ -56,11 +56,12 @@ defmodule MobDev.Server.LogStreamerTest do line = "[2024-01-01 12:00:00] Some iOS syslog line from #{app}" result = LogStreamer.parse_line(line, "sim-udid") - assert result.serial == "sim-udid" - assert result.level == "I" - assert result.tag == nil + assert result.serial == "sim-udid" + assert result.level == "I" + assert result.tag == nil assert result.message == line - assert result.mob == true # contains the current app name + # contains the current app name + assert result.mob == true end test "unparsed line without mob content is mob: false" do @@ -76,7 +77,10 @@ defmodule MobDev.Server.LogStreamerTest do test "iOS syslog Logger output with current app name is mob: true" do app_camel = Mix.Project.config()[:app] |> to_string() |> Macro.camelize() - line = "2026-04-14 07:45:04.099 #{app_camel}[1234:5678] [info] Tap me pressed — count is now 1" + + line = + "2026-04-14 07:45:04.099 #{app_camel}[1234:5678] [info] Tap me pressed — count is now 1" + result = LogStreamer.parse_line(line, "sim-udid") assert result.mob == true end diff --git a/test/mob_dev/static_nifs_test.exs b/test/mob_dev/static_nifs_test.exs new file mode 100644 index 0000000..ea245a5 --- /dev/null +++ b/test/mob_dev/static_nifs_test.exs @@ -0,0 +1,407 @@ +defmodule MobDev.StaticNifsTest do + use ExUnit.Case, async: true + + alias MobDev.StaticNifs + + describe "default_nifs/0" do + test "includes the OTP/Erlang built-ins that hand-edited driver_tab listed" do + modules = StaticNifs.default_nifs() |> Enum.map(& &1.module) + + for m <- [ + :prim_tty, + :erl_tracer, + :prim_buffer, + :prim_file, + :zlib, + :zstd, + :prim_socket, + :prim_net, + :asn1rt_nif, + :crypto, + :mob_nif, + :sqlite3_nif + ] do + assert m in modules, "expected #{inspect(m)} in defaults" + end + end + + test "asn1rt_nif and crypto are flagged builtin" do + defaults = StaticNifs.default_nifs() |> Map.new(&{&1.module, &1}) + assert defaults[:asn1rt_nif].builtin == true + assert defaults[:crypto].builtin == true + end + + test "sqlite3_nif is iOS-device-only with MOB_STATIC_SQLITE_NIF guard" do + defaults = StaticNifs.default_nifs() |> Map.new(&{&1.module, &1}) + assert defaults[:sqlite3_nif].archs == [:ios_device] + assert defaults[:sqlite3_nif].guard == "MOB_STATIC_SQLITE_NIF" + end + + test "emlx_nif is iOS-only with MOB_STATIC_EMLX_NIF guard" do + defaults = StaticNifs.default_nifs() |> Map.new(&{&1.module, &1}) + assert :emlx_nif in Map.keys(defaults) + assert defaults[:emlx_nif].archs == [:ios_device, :ios_sim] + assert defaults[:emlx_nif].guard == "MOB_STATIC_EMLX_NIF" + end + end + + describe "init_fn/1" do + test "derives <module>_nif_init by default" do + assert StaticNifs.init_fn(%{module: :prim_tty}) == "prim_tty_nif_init" + assert StaticNifs.init_fn(%{module: :crypto}) == "crypto_nif_init" + assert StaticNifs.init_fn(%{module: :mob_nif}) == "mob_nif_nif_init" + assert StaticNifs.init_fn(%{module: :asn1rt_nif}) == "asn1rt_nif_nif_init" + end + + test "honors explicit :init override" do + assert StaticNifs.init_fn(%{module: :foo, init: "weird_name"}) == "weird_name" + end + end + + describe "validate_entry/1" do + test "accepts a minimal entry" do + assert :ok = StaticNifs.validate_entry(%{module: :foo}) + end + + test "rejects unknown archs" do + assert {:error, msg} = StaticNifs.validate_entry(%{module: :foo, archs: [:windows]}) + assert msg =~ "unknown archs" + end + + test "rejects non-string :init" do + assert {:error, msg} = StaticNifs.validate_entry(%{module: :foo, init: :atom_init}) + assert msg =~ ":init must be a string" + end + + test "rejects non-boolean :builtin" do + assert {:error, msg} = StaticNifs.validate_entry(%{module: :foo, builtin: 1}) + assert msg =~ ":builtin must be a boolean" + end + + test "rejects non-string :guard" do + assert {:error, _} = StaticNifs.validate_entry(%{module: :foo, guard: :foo}) + end + + test "accepts per-ABI extra static library paths" do + assert :ok = + StaticNifs.validate_entry(%{ + module: :ghostty_vt, + archs: [:android_arm64], + extra_static_libs: %{android_arm64: "native/libghostty-vt.a"} + }) + end + + test "rejects broad extra static library arch keys" do + assert {:error, msg} = + StaticNifs.validate_entry(%{ + module: :ghostty_vt, + extra_static_libs: %{android: "native/libghostty-vt.a"} + }) + + assert msg =~ ":extra_static_libs" + end + + test "rejects non-string extra static library paths" do + assert {:error, msg} = + StaticNifs.validate_entry(%{ + module: :ghostty_vt, + extra_static_libs: %{android_arm64: :not_a_path} + }) + + assert msg =~ ":extra_static_libs" + end + + test "rejects entry without :module" do + assert {:error, _} = StaticNifs.validate_entry(%{archs: [:all]}) + end + end + + describe "resolve/1" do + test "user list extends defaults" do + result = StaticNifs.resolve([%{module: :my_native}]) + modules = Enum.map(result, & &1.module) + + assert :my_native in modules + assert :mob_nif in modules + end + + test "user entries override defaults with same :module" do + result = StaticNifs.resolve([%{module: :crypto, builtin: false, guard: "OFF_BY_DEFAULT"}]) + crypto = Enum.find(result, &(&1.module == :crypto)) + + assert crypto.builtin == false + assert crypto.guard == "OFF_BY_DEFAULT" + end + + test "setting archs: [] removes a default entry" do + result = StaticNifs.resolve([%{module: :sqlite3_nif, archs: []}]) + assert Enum.find(result, &(&1.module == :sqlite3_nif)) == nil + end + end + + describe "on_platform?/2" do + test ":all archs apply to both platforms" do + e = %{module: :x, archs: [:all]} + assert StaticNifs.on_platform?(e, :ios) + assert StaticNifs.on_platform?(e, :android) + end + + test ":ios archs apply only to iOS" do + e = %{module: :x, archs: [:ios]} + assert StaticNifs.on_platform?(e, :ios) + refute StaticNifs.on_platform?(e, :android) + end + + test ":ios_device only is still on iOS, never on Android" do + e = %{module: :x, archs: [:ios_device]} + assert StaticNifs.on_platform?(e, :ios) + refute StaticNifs.on_platform?(e, :android) + end + + test "entries default to :all when archs is missing" do + e = %{module: :x} + assert StaticNifs.on_platform?(e, :ios) + assert StaticNifs.on_platform?(e, :android) + end + end + + describe "needs_guard?/2" do + test "false when entry covers all of the platform's archs" do + assert StaticNifs.needs_guard?(%{module: :x, archs: [:all]}, :ios) == false + assert StaticNifs.needs_guard?(%{module: :x, archs: [:ios]}, :ios) == false + end + + test "true when entry is a strict subset of platform archs" do + assert StaticNifs.needs_guard?(%{module: :x, archs: [:ios_device]}, :ios) + assert StaticNifs.needs_guard?(%{module: :x, archs: [:android_arm64]}, :android) + end + + test "false on platforms the entry doesn't apply to" do + assert StaticNifs.needs_guard?(%{module: :x, archs: [:ios_device]}, :android) == false + end + end + + describe "generate/2 — iOS" do + test "includes the standard ERTS NIFs in canonical order" do + out = StaticNifs.generate(:ios, StaticNifs.default_nifs()) |> IO.iodata_to_binary() + + # Every NIF that should appear on iOS is present + for fn_name <- [ + "prim_tty_nif_init", + "erl_tracer_nif_init", + "prim_buffer_nif_init", + "prim_file_nif_init", + "zlib_nif_init", + "zstd_nif_init", + "prim_socket_nif_init", + "prim_net_nif_init", + "asn1rt_nif_nif_init", + "crypto_nif_init", + "mob_nif_nif_init", + "sqlite3_nif_nif_init" + ] do + assert out =~ fn_name, "expected #{fn_name} in generated iOS source" + end + end + + test "wraps sqlite3_nif decl + table row in MOB_STATIC_SQLITE_NIF guard" do + out = StaticNifs.generate(:ios, StaticNifs.default_nifs()) |> IO.iodata_to_binary() + + assert out =~ ~r/#ifdef MOB_STATIC_SQLITE_NIF\nvoid \*sqlite3_nif_nif_init/ + assert out =~ ~r/#ifdef MOB_STATIC_SQLITE_NIF\n\s+\{sqlite3_nif_nif_init/ + end + + test "asn1rt_nif and crypto have is_builtin=1; everyone else is 0" do + out = StaticNifs.generate(:ios, StaticNifs.default_nifs()) |> IO.iodata_to_binary() + + # Crude but checks the right column + assert out =~ "{asn1rt_nif_nif_init, 1, THE_NON_VALUE, NULL}" + assert out =~ "{crypto_nif_init, 1, THE_NON_VALUE, NULL}" + assert out =~ "{mob_nif_nif_init, 0, THE_NON_VALUE, NULL}" + assert out =~ "{prim_tty_nif_init, 0, THE_NON_VALUE, NULL}" + end + + test "table ends with NULL sentinel row" do + out = StaticNifs.generate(:ios, StaticNifs.default_nifs()) |> IO.iodata_to_binary() + assert out =~ "{NULL, 0, THE_NON_VALUE, NULL}" + end + + test "driver_tab[] has inet + ram_file + sentinel" do + out = StaticNifs.generate(:ios, StaticNifs.default_nifs()) |> IO.iodata_to_binary() + assert out =~ "{&inet_driver_entry, 0}" + assert out =~ "{&ram_file_driver_entry, 0}" + end + + test "is deterministic — same input ⇒ same output" do + a = StaticNifs.generate(:ios, StaticNifs.default_nifs()) |> IO.iodata_to_binary() + b = StaticNifs.generate(:ios, StaticNifs.default_nifs()) |> IO.iodata_to_binary() + assert a == b + end + end + + describe "generate/2 — Android" do + test "omits sqlite3_nif entirely (not declared on Android)" do + out = StaticNifs.generate(:android, StaticNifs.default_nifs()) |> IO.iodata_to_binary() + + refute out =~ "sqlite3_nif_nif_init" + refute out =~ "MOB_STATIC_SQLITE_NIF" + end + + test "includes all the cross-platform NIFs that today's hand-edited file has" do + out = StaticNifs.generate(:android, StaticNifs.default_nifs()) |> IO.iodata_to_binary() + + for fn_name <- [ + "prim_tty_nif_init", + "erl_tracer_nif_init", + "prim_buffer_nif_init", + "prim_file_nif_init", + "zlib_nif_init", + "zstd_nif_init", + "prim_socket_nif_init", + "prim_net_nif_init", + "asn1rt_nif_nif_init", + "crypto_nif_init", + "mob_nif_nif_init" + ] do + assert out =~ fn_name, "expected #{fn_name} in generated Android source" + end + end + end + + describe "generate/2 — extension entries" do + test "user-added NIF appears in the table for the platforms its archs cover" do + user = [%{module: :my_extra}] + ios_out = StaticNifs.generate(:ios, StaticNifs.resolve(user)) |> IO.iodata_to_binary() + + android_out = + StaticNifs.generate(:android, StaticNifs.resolve(user)) |> IO.iodata_to_binary() + + assert ios_out =~ "my_extra_nif_init" + assert android_out =~ "my_extra_nif_init" + end + + test "iOS-only user NIF does not appear in the Android source" do + user = [%{module: :ios_thing, archs: [:ios], guard: "BUILDING_FOR_IOS"}] + + android_out = + StaticNifs.generate(:android, StaticNifs.resolve(user)) |> IO.iodata_to_binary() + + refute android_out =~ "ios_thing_nif_init" + end + end + + # ── Phase 6a: Zig output ────────────────────────────────────────────────── + + describe "generate/3 with format: :zig" do + test "iOS Zig output uses extern struct + comptime sentinel" do + out = + StaticNifs.generate(:ios, StaticNifs.default_nifs(), format: :zig) + |> IO.iodata_to_binary() + + assert out =~ "const ErtsStaticNif = extern struct" + assert out =~ "const ErtsStaticDriver = extern struct" + assert out =~ "export var driver_tab" + assert out =~ "export var erts_static_nif_tab" + assert out =~ "export fn erts_init_static_drivers" + end + + test "iOS Zig output gates sqlite3_nif with comptime sqlite_static" do + # The default set has sqlite3_nif declared as ios_device-only with + # a guard. Zig output should comptime-gate via sqlite_static, not + # a #ifdef. + out = + StaticNifs.generate(:ios, StaticNifs.default_nifs(), format: :zig) + |> IO.iodata_to_binary() + + assert out =~ "sqlite_static" + assert out =~ "build_options" + assert out =~ "extern fn sqlite3_nif_nif_init" + refute out =~ "#ifdef" + end + + test "iOS Zig output gates emlx_nif with comptime emlx_static (default set)" do + # Default nifs include :emlx_nif with guard "MOB_STATIC_EMLX_NIF" and + # archs [:ios_device, :ios_sim]. Even though the archs match the full + # iOS platform, the explicit guard should still gate the NIF. + out = + StaticNifs.generate(:ios, StaticNifs.default_nifs(), format: :zig) + |> IO.iodata_to_binary() + + assert out =~ "const emlx_static = build_options.emlx_static" + assert out =~ "extern fn emlx_nif_nif_init" + assert out =~ "const emlx_nif_const = ErtsStaticNif" + end + + test "iOS Zig output emits 2^N branching for multiple guards" do + out = + StaticNifs.generate(:ios, StaticNifs.default_nifs(), format: :zig) + |> IO.iodata_to_binary() + + # sqlite3_nif + emlx_nif → 4 mutually-exclusive branches + assert out =~ "if (sqlite_static and emlx_static)" + assert out =~ "else if (sqlite_static)" + assert out =~ "else if (emlx_static)" + assert out =~ "} else {" + end + + test "Android Zig output: sqlite/emlx absent, nx_eigen present and guarded" do + out = + StaticNifs.generate(:android, StaticNifs.default_nifs(), format: :zig) + |> IO.iodata_to_binary() + + # sqlite is iOS-device-only, emlx is iOS-only — neither appears on Android. + refute out =~ "sqlite_static" + refute out =~ "emlx_static" + + # nx_eigen is opt-in on Android via the nx_eigen_static comptime flag + # (set true when `mix mob.enable nxeigen` was run). + assert out =~ "nx_eigen_static" + assert out =~ "build_options" + end + + # The `zig` binary isn't installed in CI (we don't compile Zig in the + # Elixir test suite). Skip there via `mix test --exclude requires_zig`; + # local dev runs with Zig on PATH still execute this. + @tag :requires_zig + test "produces parseable Zig source (round-trip via zig ast-check)" do + for platform <- [:ios, :android] do + out = + StaticNifs.generate(platform, StaticNifs.default_nifs(), format: :zig) + |> IO.iodata_to_binary() + + path = "/tmp/zig_ast_check_#{platform}.zig" + File.write!(path, out) + + case System.cmd("zig", ["ast-check", path], stderr_to_stdout: true) do + {_out, 0} -> + :ok + + {bad_out, _} -> + flunk("generated #{platform} Zig source fails ast-check:\n#{bad_out}") + end + end + end + + test "user-added NIF appears in the Zig table" do + user = [%{module: :my_extra}] + + out = + StaticNifs.generate(:android, StaticNifs.resolve(user), format: :zig) + |> IO.iodata_to_binary() + + assert out =~ "extern fn my_extra_nif_init" + assert out =~ ".nif_init = my_extra_nif_init" + end + + test "format: :c default produces C source (backward compat)" do + c_out = StaticNifs.generate(:ios, StaticNifs.default_nifs()) |> IO.iodata_to_binary() + + c_out2 = + StaticNifs.generate(:ios, StaticNifs.default_nifs(), format: :c) |> IO.iodata_to_binary() + + assert c_out == c_out2 + assert c_out =~ "ErtsStaticNif erts_static_nif_tab" + refute c_out =~ "export var erts_static_nif_tab" + end + end +end diff --git a/test/mob_dev/style_test.exs b/test/mob_dev/style_test.exs new file mode 100644 index 0000000..d4420d8 --- /dev/null +++ b/test/mob_dev/style_test.exs @@ -0,0 +1,108 @@ +defmodule MobDev.StyleTest do + use ExUnit.Case, async: false + + alias MobDev.Style + + @valid %{ + name: :mob_theme_citrus, + mob_version: "~> 0.6", + style_spec_version: 1, + theme: MobThemeCitrus.Theme + } + + describe "validate/1 (the four-field tokens-only manifest)" do + test "accepts the minimum viable manifest (round-trips)" do + m = @valid + assert {:ok, ^m} = Style.validate(m) + end + + test "reports every missing field at once" do + assert {:error, errs} = Style.validate(%{}) + assert length(errs) == 4 + assert Enum.any?(errs, &(&1 =~ ":name")) + assert Enum.any?(errs, &(&1 =~ ":mob_version")) + assert Enum.any?(errs, &(&1 =~ ":style_spec_version")) + assert Enum.any?(errs, &(&1 =~ ":theme")) + end + + test "rejects a bad version requirement and an unknown spec version" do + assert {:error, errs} = Style.validate(%{@valid | mob_version: "not-semver"}) + assert Enum.any?(errs, &(&1 =~ "version requirement")) + + assert {:error, errs} = Style.validate(%{@valid | style_spec_version: 99}) + assert Enum.any?(errs, &(&1 =~ "not supported")) + end + + test "rejects a non-map manifest" do + assert {:error, [msg]} = Style.validate([:nope]) + assert msg =~ "must be a map" + end + end + + describe "load/1" do + test "loads a manifest file from a style dir" do + dir = tmp_style!(inspect(@valid)) + assert {:ok, m} = Style.load(dir) + assert m.name == :mob_theme_citrus + end + + test "missing manifest is an error (styles have no tier-0 equivalent)" do + dir = Path.join(System.tmp_dir!(), "mob_style_none_#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf!(dir) end) + assert {:error, msg} = Style.load(dir) + assert msg =~ "missing" + end + + test "a non-map manifest is an error" do + dir = tmp_style!("[:not, :a, :map]") + assert {:error, msg} = Style.load(dir) + assert msg =~ "must evaluate to a map" + end + end + + describe "runtime_entries!/0 (build-time resolution)" do + test "no styles configured → empty entries, nil default" do + in_tmp_project(fn -> + assert Style.runtime_entries!() == %{styles: [], default_style: nil} + end) + end + + test "a default_style not among the activated styles fails the build" do + in_tmp_project(fn -> + Application.put_env(:mob, :default_style, :mob_theme_citrus) + on_exit(fn -> Application.delete_env(:mob, :default_style) end) + + assert_raise ArgumentError, ~r/not among the activated styles/, fn -> + Style.runtime_entries!() + end + end) + end + end + + # ── helpers ──────────────────────────────────────────────────────────────── + + defp tmp_style!(contents) do + dir = Path.join(System.tmp_dir!(), "mob_style_#{System.unique_integer([:positive])}") + File.mkdir_p!(Path.join(dir, "priv")) + File.write!(Path.join(dir, "priv/mob_style.exs"), contents) + on_exit(fn -> File.rm_rf!(dir) end) + dir + end + + # Style.activated_names/default_style read mob.exs from cwd (absent in a tmp + # dir → Application-env fallback, which the tests control). + defp in_tmp_project(fun) do + dir = Path.join(System.tmp_dir!(), "mob_style_proj_#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + cwd = File.cwd!() + File.cd!(dir) + + try do + fun.() + after + File.cd!(cwd) + File.rm_rf!(dir) + end + end +end diff --git a/test/mob_dev/support_matrix_test.exs b/test/mob_dev/support_matrix_test.exs new file mode 100644 index 0000000..931cc21 --- /dev/null +++ b/test/mob_dev/support_matrix_test.exs @@ -0,0 +1,186 @@ +defmodule MobDev.SupportMatrixTest do + use ExUnit.Case, async: true + + alias MobDev.{Device, SupportMatrix} + + describe "feature_requirements/1" do + test ":base returns the universal Mob floor" do + reqs = SupportMatrix.feature_requirements(:base) + assert "arm64-v8a" in reqs.android.abis + assert "x86_64" in reqs.android.abis + # armv7 included after Moto e empirical verification — Mob's + # BEAM/erts + libapp.so build and run on armeabi-v7a. The + # device floor for vanilla apps is much wider than for + # Pythonx-enabled ones. + assert "armeabi-v7a" in reqs.android.abis + assert reqs.android.min_sdk >= 28 + assert "arm64" in reqs.ios.abis + assert reqs.ios.min_sdk >= 13 + end + + test ":pythonx names Chaquopy as the upstream constraint" do + reqs = SupportMatrix.feature_requirements(:pythonx) + assert "arm64-v8a" in reqs.android.abis + assert "x86_64" in reqs.android.abis + refute "armeabi-v7a" in reqs.android.abis + assert reqs.android.reason =~ "Chaquopy" + # The reason needs to call out the upstream-vendor cause — silent + # "out of scope" doesn't honor users who lose access. + assert reqs.android.reason =~ "32-bit" + end + + test "unknown feature returns nil" do + assert SupportMatrix.feature_requirements(:nonexistent_feature) == nil + end + end + + describe "enabled_features/1" do + setup do + dir = + Path.join(System.tmp_dir!(), "mob_smatrix_test_#{System.unique_integer([:positive])}") + + File.mkdir_p!(dir) + on_exit(fn -> File.rm_rf!(dir) end) + {:ok, dir: dir} + end + + test "returns [] for a vanilla project", %{dir: dir} do + File.write!(Path.join(dir, "mix.exs"), """ + defmodule My.MixProject do + use Mix.Project + def project, do: [app: :my_app, version: "0.1.0"] + def application, do: [extra_applications: []] + defp deps, do: [{:mob, "~> 0.1"}] + end + """) + + assert SupportMatrix.enabled_features(dir) == [] + end + + test "detects :pythonx via the dep entry", %{dir: dir} do + File.write!(Path.join(dir, "mix.exs"), """ + defmodule My.MixProject do + use Mix.Project + defp deps do + [ + {:mob, "~> 0.1"}, + {:pythonx, "~> 0.4"} + ] + end + end + """) + + assert SupportMatrix.enabled_features(dir) == [:pythonx] + end + + test "no mix.exs ⇒ no features (degrades quietly, doesn't raise)", %{dir: dir} do + assert SupportMatrix.enabled_features(dir) == [] + end + end + + describe "check_device/2" do + test "passes for an arm64 phone with the base requirements" do + device = %Device{ + platform: :android, + serial: "ZY22K6BSJM", + name: "moto g power 5G - 2024", + abi: "arm64-v8a", + sdk_level: 34 + } + + assert SupportMatrix.check_device(device, []) == :ok + assert SupportMatrix.check_device(device, [:pythonx]) == :ok + end + + test "Moto e (armeabi-v7a): vanilla Mob OK, Pythonx blocked" do + device = %Device{ + platform: :android, + serial: "10.0.0.82:5555", + name: "moto e", + abi: "armeabi-v7a", + sdk_level: 29 + } + + # Empirical: vanilla Mob (no Pythonx) boots on a Moto e. + # Verified by deploying Pigeon-without-Pythonx and seeing + # the BEAM reach all 5 launcher steps before on_start. + assert SupportMatrix.check_device(device, []) == :ok + + # Pythonx adds a real constraint — Chaquopy doesn't ship + # 32-bit Python. ONE issue (not two): :base passes, only + # :pythonx fails. + assert {:error, [issue]} = SupportMatrix.check_device(device, [:pythonx]) + assert issue.feature == :pythonx + assert issue.reason =~ "Chaquopy" + assert issue.reason =~ "armeabi-v7a" + assert issue.reason =~ "uv is not available" + end + + test "fails an old Android (SDK 26) on the base SDK floor" do + device = %Device{ + platform: :android, + serial: "ancient", + name: "Old Android", + abi: "arm64-v8a", + sdk_level: 26 + } + + assert {:error, issues} = SupportMatrix.check_device(device, []) + assert Enum.any?(issues, fn %{reason: r} -> r =~ "SDK" end) + end + + test "passes an iOS arm64 device on the base requirement" do + device = %Device{ + platform: :ios, + serial: "00008110-001E1C3A34F8401E", + abi: "arm64", + sdk_level: 17 + } + + assert SupportMatrix.check_device(device, []) == :ok + assert SupportMatrix.check_device(device, [:pythonx]) == :ok + end + + test "missing abi/sdk on a device skips the check (don't false-positive)" do + # Older mob_dev installs may produce devices without the new fields. + # Better to silently let those through than to red-light a perfectly + # good arm64 phone because discovery didn't query the prop yet. + device = %Device{platform: :android, serial: "old"} + assert SupportMatrix.check_device(device, [:pythonx]) == :ok + end + end + + describe "format_error/1" do + test "groups issues by device with the device summary as a header" do + device = %Device{ + platform: :android, + serial: "10.0.0.82:5555", + name: "moto e", + abi: "armeabi-v7a", + sdk_level: 29, + type: :physical, + status: :discovered + } + + issues = [ + %{ + device: device, + feature: :pythonx, + reason: "pythonx requires Android arm64-v8a or x86_64; this device is armeabi-v7a." + }, + %{ + device: device, + feature: :base, + reason: "Mob requires Android arm64-v8a or x86_64; this device is armeabi-v7a." + } + ] + + output = SupportMatrix.format_error(issues) + # One header per device + assert output |> String.split("\n") |> Enum.count(&String.contains?(&1, "moto e")) == 1 + # Both reasons listed + assert output =~ "pythonx requires" + assert output =~ "Mob requires" + end + end +end diff --git a/test/mob_dev/task_help_test.exs b/test/mob_dev/task_help_test.exs new file mode 100644 index 0000000..06a1b10 --- /dev/null +++ b/test/mob_dev/task_help_test.exs @@ -0,0 +1,61 @@ +defmodule MobDev.TaskHelpTest do + use ExUnit.Case, async: true + + alias MobDev.TaskHelp + + doctest TaskHelp + + describe "help_requested?/1" do + test "matches --help anywhere in argv" do + assert TaskHelp.help_requested?(["--help"]) + assert TaskHelp.help_requested?(["--device", "foo", "--help"]) + assert TaskHelp.help_requested?(["--help", "--device", "foo"]) + end + + test "matches -h anywhere in argv" do + assert TaskHelp.help_requested?(["-h"]) + assert TaskHelp.help_requested?(["--device", "foo", "-h"]) + end + + test "false for unrelated flags" do + refute TaskHelp.help_requested?(["--device", "foo"]) + refute TaskHelp.help_requested?(["--bundle-id", "com.x.y"]) + refute TaskHelp.help_requested?([]) + end + + test "false when --help is a value rather than a flag" do + # Edge case: someone passes `--device --help` (which is silly + # but possible). `--help` is then the VALUE of --device, not a + # standalone flag — but our check is by membership, not by + # OptionParser semantics. Pin the current behaviour: we DO + # match here, which is fine — it's still a clear signal the + # user wants help. + assert TaskHelp.help_requested?(["--device", "--help"]) + end + end + + describe "print_module_help/1" do + test "prints the module's @moduledoc verbatim" do + output = + ExUnit.CaptureIO.capture_io(fn -> + TaskHelp.print_module_help(MobDev.TaskHelp) + end) + + # The module's own @moduledoc — pin a stable substring so this + # test doesn't break on minor wording changes. + assert output =~ "Helpers for `--help`" + assert output =~ "mix help <task>" + end + + test "falls back gracefully when a module has no @moduledoc" do + defmodule NoDocsModule, do: nil + + output = + ExUnit.CaptureIO.capture_io(fn -> + TaskHelp.print_module_help(NoDocsModule) + end) + + assert output =~ "no documentation" + end + end +end diff --git a/test/mob_dev/tflite_downloader_test.exs b/test/mob_dev/tflite_downloader_test.exs new file mode 100644 index 0000000..fc298be --- /dev/null +++ b/test/mob_dev/tflite_downloader_test.exs @@ -0,0 +1,163 @@ +defmodule MobDev.TfliteDownloaderTest do + # async: false — these tests set/unset MOB_CACHE_DIR / + # MOB_TFLITE_LOCAL_TARBALL_DIR which are process-global. Mirrors the + # MLXDownloaderTest contract. + use ExUnit.Case, async: false + + alias MobDev.TfliteDownloader + + # ── dir/1 + cache_dir/0 ───────────────────────────────────────────────────── + + describe "dir/1" do + setup do + System.put_env("MOB_CACHE_DIR", "/tmp/mob_test_cache_tflite") + on_exit(fn -> System.delete_env("MOB_CACHE_DIR") end) + :ok + end + + test "android_arm64 lands in versioned cache slot" do + assert TfliteDownloader.dir(:android_arm64) == + "/tmp/mob_test_cache_tflite/tflite-#{TfliteDownloader.android_version()}-android_arm64" + end + + test "android_arm32 distinct from arm64" do + refute TfliteDownloader.dir(:android_arm32) == TfliteDownloader.dir(:android_arm64) + end + + test "ios_device distinct from ios_sim" do + refute TfliteDownloader.dir(:ios_device) == TfliteDownloader.dir(:ios_sim) + end + + test "ios paths use ios_version, android paths use android_version" do + assert TfliteDownloader.dir(:android_arm64) =~ TfliteDownloader.android_version() + assert TfliteDownloader.dir(:ios_device) =~ TfliteDownloader.ios_version() + end + end + + describe "cache_dir/0" do + test "honours MOB_CACHE_DIR" do + System.put_env("MOB_CACHE_DIR", "/tmp/explicit_cache_path") + + try do + assert TfliteDownloader.cache_dir() == "/tmp/explicit_cache_path" + after + System.delete_env("MOB_CACHE_DIR") + end + end + + test "falls back to ~/.mob/cache when env unset" do + System.delete_env("MOB_CACHE_DIR") + home = System.user_home!() + assert TfliteDownloader.cache_dir() == Path.join([home, ".mob", "cache"]) + end + + test "treats empty MOB_CACHE_DIR as unset" do + System.put_env("MOB_CACHE_DIR", "") + + try do + home = System.user_home!() + assert TfliteDownloader.cache_dir() == Path.join([home, ".mob", "cache"]) + after + System.delete_env("MOB_CACHE_DIR") + end + end + end + + # ── valid_dir?/2 ─────────────────────────────────────────────────────────── + + describe "valid_dir?/2" do + test "android_arm64 requires .so + headers" do + dir = "/tmp/tflite_valid_test_android" + File.rm_rf!(dir) + + refute TfliteDownloader.valid_dir?(:android_arm64, dir) + + File.mkdir_p!(Path.join([dir, "jni", "arm64-v8a"])) + File.touch!(Path.join([dir, "jni", "arm64-v8a", "libtensorflowlite_jni.so"])) + refute TfliteDownloader.valid_dir?(:android_arm64, dir) + + File.mkdir_p!(Path.join([dir, "headers", "tensorflow", "lite", "c"])) + File.touch!(Path.join([dir, "headers", "tensorflow", "lite", "c", "c_api.h"])) + assert TfliteDownloader.valid_dir?(:android_arm64, dir) + + File.rm_rf!(dir) + end + + test "ios_device requires framework binary + headers" do + dir = "/tmp/tflite_valid_test_ios" + File.rm_rf!(dir) + + refute TfliteDownloader.valid_dir?(:ios_device, dir) + + fw_root = + Path.join([ + dir, + "Frameworks", + "TensorFlowLiteC.xcframework", + "ios-arm64", + "TensorFlowLiteC.framework" + ]) + + File.mkdir_p!(Path.join(fw_root, "Headers")) + File.touch!(Path.join(fw_root, "TensorFlowLiteC")) + File.touch!(Path.join([fw_root, "Headers", "c_api.h"])) + assert TfliteDownloader.valid_dir?(:ios_device, dir) + + File.rm_rf!(dir) + end + + test "ios_sim uses the simulator slice subdir" do + dir = "/tmp/tflite_valid_test_ios_sim" + File.rm_rf!(dir) + + # Putting only the device slice does NOT satisfy ios_sim's check. + device_fw = + Path.join([ + dir, + "Frameworks", + "TensorFlowLiteC.xcframework", + "ios-arm64", + "TensorFlowLiteC.framework" + ]) + + File.mkdir_p!(Path.join(device_fw, "Headers")) + File.touch!(Path.join(device_fw, "TensorFlowLiteC")) + File.touch!(Path.join([device_fw, "Headers", "c_api.h"])) + refute TfliteDownloader.valid_dir?(:ios_sim, dir) + + sim_fw = + Path.join([ + dir, + "Frameworks", + "TensorFlowLiteC.xcframework", + "ios-arm64_x86_64-simulator", + "TensorFlowLiteC.framework" + ]) + + File.mkdir_p!(Path.join(sim_fw, "Headers")) + File.touch!(Path.join(sim_fw, "TensorFlowLiteC")) + File.touch!(Path.join([sim_fw, "Headers", "c_api.h"])) + assert TfliteDownloader.valid_dir?(:ios_sim, dir) + + File.rm_rf!(dir) + end + end + + # ── version pins ─────────────────────────────────────────────────────────── + + describe "version pins" do + test "android version is the last AAR-shipping release (2.16.1)" do + # The pin matters: 2.17.0+ Maven artifacts dropped the .aar packaging + # in favour of .jar-only (Java wrapper, no native libs). Bumping + # past this needs a new upstream native-lib distribution story. + assert TfliteDownloader.android_version() == "2.16.1" + end + + test "ios version is a real CocoaPods release" do + # Sanity check — must be `MAJOR.MINOR.PATCH`, three numerics. If + # this drifts to a nightly tag the dl.google.com URL pattern will + # also need updating. + assert Regex.match?(~r/^\d+\.\d+\.\d+$/, TfliteDownloader.ios_version()) + end + end +end diff --git a/test/mob_dev/tflite_nif_test.exs b/test/mob_dev/tflite_nif_test.exs new file mode 100644 index 0000000..2cf5111 --- /dev/null +++ b/test/mob_dev/tflite_nif_test.exs @@ -0,0 +1,107 @@ +defmodule MobDev.TfliteNifTest do + use ExUnit.Case, async: true + + alias MobDev.TfliteNif + alias MobDev.TfliteNif.Target + + describe "targets/0" do + test "lists the four expected slices" do + assert TfliteNif.targets() == [:android_arm64, :android_arm32, :ios_sim, :ios_device] + end + end + + describe "target_spec/1" do + test "android_arm64 picks aarch64-linux-android clang and no underscore prefix" do + spec = TfliteNif.target_spec(:android_arm64) + assert %Target{id: :android_arm64, nm_symbol: "tflite_nif_nif_init"} = spec + end + + test "android_arm32 carries the armv7 ABI flags" do + spec = TfliteNif.target_spec(:android_arm32) + assert "-march=armv7-a" in spec.extra_cflags + assert "-mfloat-abi=softfp" in spec.extra_cflags + assert "-mthumb" in spec.extra_cflags + # Android hardening still applies on top of armv7. + assert "-D_GNU_SOURCE" in spec.extra_cflags + assert "-D__ANDROID__" in spec.extra_cflags + end + + test "ios targets use Mach-O symbol convention (leading underscore)" do + assert TfliteNif.target_spec(:ios_sim).nm_symbol == "_tflite_nif_nif_init" + assert TfliteNif.target_spec(:ios_device).nm_symbol == "_tflite_nif_nif_init" + end + + test "ios targets have empty extra_cflags" do + # iOS doesn't get Android hardening (PAC is set up differently; + # branch-protect uses different syntax). Keeping this asserted so + # silent drift doesn't suddenly add Android-specific flags to iOS. + assert TfliteNif.target_spec(:ios_sim).extra_cflags == [] + assert TfliteNif.target_spec(:ios_device).extra_cflags == [] + end + end + + describe "base_cflags/0" do + test "carries the static-NIF macro that produces tflite_nif_nif_init" do + flags = TfliteNif.base_cflags() + assert "-DSTATIC_ERLANG_NIF_LIBNAME=tflite_nif" in flags + + # Optimisation + warnings + position-independent stay required — + # silently dropping any of these would silently produce binaries + # that don't match what Mob's static-link contract expects. + assert "-fPIC" in flags + assert "-O2" in flags + assert "-Wall" in flags + end + end + + describe "cflags/3" do + test "appends -I for each include and -F for each framework dir" do + target = TfliteNif.target_spec(:ios_device) + + flags = + TfliteNif.cflags(target, ["/path/erts/include", "/path/aar/headers"], [ + "/path/Frameworks/X" + ]) + + assert "-I/path/erts/include" in flags + assert "-I/path/aar/headers" in flags + assert "-F/path/Frameworks/X" in flags + end + + test "preserves include order (CFLAGS ordering can shadow headers)" do + target = TfliteNif.target_spec(:android_arm64) + flags = TfliteNif.cflags(target, ["/a", "/b", "/c"], []) + include_flags = Enum.filter(flags, &String.starts_with?(&1, "-I")) + assert include_flags == ["-I/a", "-I/b", "-I/c"] + end + + test "android targets ignore framework dirs" do + target = TfliteNif.target_spec(:android_arm64) + flags = TfliteNif.cflags(target, ["/erts"], ["/Frameworks/X"]) + # We don't filter -F flags out — clang on android silently ignores + # them. The test pins that fact so future readers know. + assert "-F/Frameworks/X" in flags + end + end + + describe "build/2 (preconditions)" do + test "requires nx_tflite_mob_dir" do + result = + TfliteNif.build(:android_arm64, + tflite_dir: "/tmp/nope", + erts_include: "/tmp/nope", + out_dir: "/tmp/nope" + ) + + assert {:error, {:precondition_failed, msg}} = result + assert msg =~ "required option missing" + assert msg =~ "nx_tflite_mob_dir" + end + + test "rejects unknown target id" do + assert_raise FunctionClauseError, fn -> + TfliteNif.build(:bogus_target, []) + end + end + end +end diff --git a/test/mob_dev/tunnel_adb_missing_test.exs b/test/mob_dev/tunnel_adb_missing_test.exs new file mode 100644 index 0000000..7ef77e0 --- /dev/null +++ b/test/mob_dev/tunnel_adb_missing_test.exs @@ -0,0 +1,41 @@ +defmodule MobDev.TunnelAdbMissingTest do + # async: false — mutates the OS-process-global PATH. Kept out of the main + # (async) tunnel_test so it never overlaps a concurrent reader. + use ExUnit.Case, async: false + + alias MobDev.Tunnel + + describe "adb absent from PATH (iOS-only Mac)" do + setup do + original = System.get_env("PATH") + + # Point PATH at a dir that has epmd (ports_in_use queries it too) but not + # adb, so we exercise exactly the missing-adb path. + dir = Path.join(System.tmp_dir!(), "mob_dev_no_adb_#{System.unique_integer([:positive])}") + File.mkdir_p!(dir) + + case :os.find_executable(~c"epmd") do + false -> flunk("epmd not found on PATH — cannot set up the no-adb fixture") + epmd -> File.ln_s!(to_string(epmd), Path.join(dir, "epmd")) + end + + System.put_env("PATH", dir) + refute System.find_executable("adb"), "fixture leaked an adb on PATH" + + on_exit(fn -> + if original, do: System.put_env("PATH", original), else: System.delete_env("PATH") + File.rm_rf!(dir) + end) + + :ok + end + + test "ports_in_use/1 degrades to a MapSet instead of crashing on :enoent" do + # Regression: run_adb shelled out to a missing `adb`; System.cmd raised + # :enoent inside a linked Task, propagating an exit that killed the whole + # `mix mob.connect` for iOS-only Macs. It must now return the (adb-less) + # port set without raising — forwards simply read as "none". + assert %MapSet{} = Tunnel.ports_in_use() + end + end +end diff --git a/test/mob_dev/tunnel_test.exs b/test/mob_dev/tunnel_test.exs index 3041cc3..33d77d8 100644 --- a/test/mob_dev/tunnel_test.exs +++ b/test/mob_dev/tunnel_test.exs @@ -3,27 +3,51 @@ defmodule MobDev.TunnelTest do alias MobDev.Tunnel - describe "dist_port/1" do - test "first device gets base port 9100" do - assert Tunnel.dist_port(0) == 9100 + describe "serial_base_port/1" do + test "is deterministic — same serial always maps to the same port" do + assert Tunnel.serial_base_port("ZY22CRLMWK") == Tunnel.serial_base_port("ZY22CRLMWK") end - test "second device gets 9101" do - assert Tunnel.dist_port(1) == 9101 + test "stays within the [9100, 9900) window" do + for serial <- ~w(ZY22CRLMWK ZY22K6BSJM emulator-5554 00008110-001E1C3A34F8401E foo bar) do + port = Tunnel.serial_base_port(serial) + assert port >= 9100 and port < 9900 + end end - test "each index adds one to base" do - for i <- 0..9 do - assert Tunnel.dist_port(i) == 9100 + i - end + test "different serials generally map to different ports (no per-run index collision)" do + # The whole point: two phones (or two projects' device-0) no longer both + # land on 9100. crc32 spreads them across the window. + ports = + Enum.map(~w(ZY22CRLMWK ZY22K6BSJM ZY22DP6HFL emulator-5554), &Tunnel.serial_base_port/1) + + assert length(Enum.uniq(ports)) == length(ports) + end + end + + describe "assign_dist_port/2" do + test "returns the serial's base port when nothing is in use" do + assert Tunnel.assign_dist_port("ZY22CRLMWK") == Tunnel.serial_base_port("ZY22CRLMWK") end - test "consistent with what Tunnel.setup assigns" do - # Verifies the formula used in setup/2 matches dist_port/1. - # Android at index 0 → adb forward tcp:9100 tcp:9100 - # iOS at index 1 → dist port 9101 - assert Tunnel.dist_port(0) == 9100 - assert Tunnel.dist_port(1) == 9101 + test "bumps to a free port when the base is taken (collision avoidance)" do + base = Tunnel.serial_base_port("ZY22CRLMWK") + taken = MapSet.new([base]) + assigned = Tunnel.assign_dist_port("ZY22CRLMWK", taken) + + assert assigned != base + refute MapSet.member?(taken, assigned) + assert assigned >= 9100 and assigned < 9900 + end + + test "always returns a port not in the in-use set" do + base = Tunnel.serial_base_port("ZY22CRLMWK") + # A contiguous block starting at the base forces several bumps. + taken = MapSet.new(for n <- 0..9, do: 9100 + rem(base - 9100 + n, 800)) + assigned = Tunnel.assign_dist_port("ZY22CRLMWK", taken) + + refute MapSet.member?(taken, assigned) + assert assigned >= 9100 and assigned < 9900 end end end diff --git a/test/mob_dev/uninstaller_test.exs b/test/mob_dev/uninstaller_test.exs new file mode 100644 index 0000000..c993f0d --- /dev/null +++ b/test/mob_dev/uninstaller_test.exs @@ -0,0 +1,380 @@ +defmodule MobDev.UninstallerTest do + use ExUnit.Case, async: true + + alias MobDev.{Device, Uninstaller} + + doctest Uninstaller + + defp android(name), do: %Device{name: name, serial: name, platform: :android} + defp ios(name), do: %Device{name: name, serial: name, platform: :ios} + + # Typed factories for the select_devices/3 safety tests — physical + # vs emulator/sim has to be modelled explicitly because the safety + # filter routes off device.type. + defp android_emu(name), + do: %Device{name: name, serial: name, platform: :android, type: :emulator} + + defp android_phys(name), + do: %Device{name: name, serial: name, platform: :android, type: :physical} + + defp ios_sim(name), do: %Device{name: name, serial: name, platform: :ios, type: :simulator} + + defp ios_phys(name), do: %Device{name: name, serial: name, platform: :ios, type: :physical} + + defp result(device, bundle_id, outcome, reason \\ nil) do + %{device: device, bundle_id: bundle_id, outcome: outcome, reason: reason} + end + + # ── categorize_results/1 ──────────────────────────────────────────────── + + describe "categorize_results/1" do + test "buckets each outcome correctly" do + ok = result(android("a"), "com.x", :uninstalled) + err = result(android("b"), "com.x", :error, "adb timeout") + skip = result(android("c"), "com.x", :skipped, "not installed") + + {u, f, s} = Uninstaller.categorize_results([ok, err, skip]) + assert u == [ok] + assert f == [err] + assert s == [skip] + end + + test "empty input → three empty lists" do + assert {[], [], []} = Uninstaller.categorize_results([]) + end + + test "skipped never bleeds into failed (regression pin)" do + # Same invariant as MobDev.Deployer.categorize_results/1: an + # app-not-installed result is informational, not a failure. + skips = Enum.map(1..5, fn i -> result(android("emu-#{i}"), "com.x", :skipped) end) + {u, f, s} = Uninstaller.categorize_results(skips) + assert u == [] + assert f == [] + assert length(s) == 5 + end + end + + # ── filter_devices_by_id/2 ────────────────────────────────────────────── + + describe "filter_devices_by_id/2" do + test "empty ids → returns all devices unfiltered" do + devices = [android("a"), ios("b")] + assert Uninstaller.filter_devices_by_id(devices, []) == devices + end + + test "matches by exact serial" do + a = android("a") + b = ios("b") + assert Uninstaller.filter_devices_by_id([a, b], ["a"]) == [a] + end + + test "matches multiple ids in one call" do + a = android("a") + b = ios("b") + c = android("c") + assert Uninstaller.filter_devices_by_id([a, b, c], ["a", "c"]) == [a, c] + end + + test "non-matching ids → empty list" do + devices = [android("a"), ios("b")] + assert Uninstaller.filter_devices_by_id(devices, ["nope"]) == [] + end + end + + # ── interpret_adb_uninstall/2 ─────────────────────────────────────────── + + describe "interpret_adb_uninstall/2" do + test "exit 0 + 'Success' → :uninstalled" do + assert {:uninstalled, nil} = Uninstaller.interpret_adb_uninstall("Success\n", 0) + end + + test "'Unknown package' → :skipped regardless of exit code" do + # Real adb output for an uninstall of a missing package: + # "Failure [DELETE_FAILED_INTERNAL_ERROR: Unknown package: com.x]" + out = "Failure [DELETE_FAILED_INTERNAL_ERROR: Unknown package: com.x]\n" + assert {:skipped, "not installed"} = Uninstaller.interpret_adb_uninstall(out, 0) + end + + test "non-zero exit without 'Unknown package' → :error with full output" do + out = "Failure [INSTALL_PARSE_FAILED_NOT_APK]\n" + assert {:error, msg} = Uninstaller.interpret_adb_uninstall(out, 1) + assert msg =~ "INSTALL_PARSE_FAILED_NOT_APK" + end + + test "exit 0 but no 'Success' marker → :error (conservative)" do + # adb can return exit 0 with garbage output; don't claim success + # unless we see the Success marker. + assert {:error, "weird"} = Uninstaller.interpret_adb_uninstall("weird\n", 0) + end + end + + # ── interpret_devicectl_uninstall/2 ───────────────────────────────────── + + describe "interpret_devicectl_uninstall/2" do + test "exit 0 → :uninstalled" do + assert {:uninstalled, nil} = Uninstaller.interpret_devicectl_uninstall("ok\n", 0) + end + + test "'ContainerLookupErrorDomain' → :skipped (app not on device)" do + # devicectl shape for a bundle id that isn't installed on the + # device — same pattern as the install error path elsewhere + # in mob_dev's deployer. + out = """ + Failed to load provisioning paramter list ... + ERROR: ContainerLookupErrorDomain code 1004 -- App not installed + """ + + assert {:skipped, "not installed"} = Uninstaller.interpret_devicectl_uninstall(out, 1) + end + + test "'not installed' phrase → :skipped (alternate wording)" do + out = "App with bundle id com.example.foo is not installed\n" + assert {:skipped, "not installed"} = Uninstaller.interpret_devicectl_uninstall(out, 1) + end + + test "other non-zero exit → :error with trimmed output" do + out = "device not paired with this host\n" + assert {:error, msg} = Uninstaller.interpret_devicectl_uninstall(out, 1) + assert msg =~ "not paired" + refute msg =~ "\n" + end + + test "real-world Xcode 15+ devicectl error doesn't accidentally match skipped" do + # Make sure a transient devicectl error (e.g. tunnel disconnect) + # surfaces as :error, not as :skipped — the user needs to see it. + out = """ + 14:32:56 Acquired tunnel connection to device. + ERROR: connection lost mid-operation + """ + + assert {:error, _} = Uninstaller.interpret_devicectl_uninstall(out, 1) + end + end + + # ── parse_package_list/1 ──────────────────────────────────────────────── + + describe "parse_package_list/1" do + test "extracts package names, strips prefix, sorts" do + out = "package:com.example.b\npackage:com.example.a\n" + assert Uninstaller.parse_package_list(out) == ["com.example.a", "com.example.b"] + end + + test "ignores non-package lines" do + out = "package:com.example.a\nstderr noise\n" + assert Uninstaller.parse_package_list(out) == ["com.example.a"] + end + + test "empty → empty list" do + assert Uninstaller.parse_package_list("") == [] + end + + test "no matches → empty list" do + assert Uninstaller.parse_package_list("garbage\nmore garbage\n") == [] + end + end + + # ── simctl_listapps_with_prefix/2 ─────────────────────────────────────── + + describe "simctl_listapps_with_prefix/2" do + test "extracts bundle ids and filters by prefix" do + # Minimal simctl-listapps-shaped output. Real output is plist- + # ish; we just scan for the quoted bundle id followed by `= {`. + output = """ + "com.example.foo" = { + ApplicationType = User; + }; + "com.example.bar" = { + ApplicationType = User; + }; + "com.other.baz" = { + ApplicationType = User; + }; + """ + + assert Uninstaller.simctl_listapps_with_prefix(output, "com.example.") == + ["com.example.bar", "com.example.foo"] + end + + test "empty output → empty list" do + assert Uninstaller.simctl_listapps_with_prefix("", "com.example.") == [] + end + + test "no matches → empty list" do + output = ~s("com.other.foo" = {\n};\n) + assert Uninstaller.simctl_listapps_with_prefix(output, "com.example.") == [] + end + end + + # ── classify_simctl_error/1 ───────────────────────────────────────────── + + describe "classify_simctl_error/1" do + test "'No such application' → :not_installed" do + output = "An error was encountered processing the command: No such application" + assert Uninstaller.classify_simctl_error(output) == :not_installed + end + + test "'not installed' phrase → :not_installed" do + assert Uninstaller.classify_simctl_error("App is not installed") == :not_installed + end + + test "other error → :error" do + assert Uninstaller.classify_simctl_error("device not booted") == :error + end + end + + # ── preview_lines/1 ───────────────────────────────────────────────────── + + describe "preview_lines/1" do + defp strip_ansi(s), do: String.replace(s, ~r/\e\[[0-9;]*m/, "") + + test "empty plan → 'nothing to do' message" do + [line] = Uninstaller.preview_lines([]) + assert strip_ansi(line) =~ "nothing to do" + end + + test "renders one device with its bundle list" do + lines = Uninstaller.preview_lines([{android("emu-5554"), ["com.x", "com.y"]}]) + flat = Enum.map(lines, &strip_ansi/1) |> Enum.join("\n") + + assert flat =~ "About to uninstall" + assert flat =~ "emu-5554" + assert flat =~ "android" + assert flat =~ "- com.x" + assert flat =~ "- com.y" + end + + test "renders multiple devices with their bundles" do + lines = + Uninstaller.preview_lines([ + {android("emu-5554"), ["com.x"]}, + {ios("iPhone 17"), ["com.x", "com.y"]} + ]) + + flat = Enum.map(lines, &strip_ansi/1) |> Enum.join("\n") + + assert flat =~ "emu-5554" + assert flat =~ "iPhone 17" + # Each bundle line is its own row. + lines_count = + flat |> String.split("\n", trim: true) |> Enum.count(&String.contains?(&1, "- ")) + + assert lines_count == 3 + end + end + + # ── plan/1 happy paths via DI through opts ────────────────────────────── + # + # The hardware-touching pieces (Discovery.{Android,IOS}.list_devices/0, + # System.cmd) aren't stubbable without dependency injection. The plan/1 + # error paths exercise the device-resolution logic; the happy path + # validation lives in the Mix task's tests where we mock the chain. + + describe "plan/1 — error shapes" do + test "no devices connected → {:error, :no_devices, %{detected: 0}}" do + # platforms: [] forces list_all_devices to return []. + assert {:error, :no_devices, %{detected: 0}} = + Uninstaller.plan(platforms: [], device_ids: []) + end + end + + # ── select_devices/3 — emulator vs physical safety ───────────────────── + # + # The principle: `--all-devices` sweeps emulators/sims only. + # Physical devices (someone's iPhone, a personal Android) require + # explicit `--all-physical` or `--device <id>`. Auto-detect (no flags, + # single device) NEVER picks a physical device. Pin every branch of + # the precedence ladder. + + describe "select_devices/3 — emulator/physical safety filter" do + test "no flags + single emulator → auto-target it" do + emu = android_emu("emulator-5554") + assert Uninstaller.select_devices([emu], [], []) == [emu] + end + + test "no flags + single physical → NEVER auto-target (returns empty)" do + # Regression for the safety design: a phone alone should not be + # the auto-target. User must say --device or --all-physical. + phone = android_phys("R5CW3089HVB") + assert Uninstaller.select_devices([phone], [], []) == [] + end + + test "no flags + emulator + physical → auto-target the emulator only" do + emu = android_emu("emulator-5554") + phone = android_phys("R5CW3089HVB") + assert Uninstaller.select_devices([emu, phone], [], []) == [emu] + end + + test "no flags + multiple emulators → ambiguous (returns empty)" do + a = android_emu("emulator-5554") + b = android_emu("emulator-5556") + assert Uninstaller.select_devices([a, b], [], []) == [] + end + + test "--all-devices sweeps emulators + sims but NEVER physical" do + emu = android_emu("emulator-5554") + sim = ios_sim("iPhone 17") + phone_a = android_phys("R5CW") + phone_i = ios_phys("iPhone-Kevin") + + assert Uninstaller.select_devices( + [emu, sim, phone_a, phone_i], + [], + all_devices: true + ) == [emu, sim] + end + + test "--all-physical sweeps physical only, never emulators/sims" do + emu = android_emu("emulator-5554") + sim = ios_sim("iPhone 17") + phone_a = android_phys("R5CW") + phone_i = ios_phys("iPhone-Kevin") + + assert Uninstaller.select_devices( + [emu, sim, phone_a, phone_i], + [], + all_physical: true + ) == [phone_a, phone_i] + end + + test "--all-devices AND --all-physical → literally everything" do + emu = android_emu("emulator-5554") + phone = android_phys("R5CW") + + assert Uninstaller.select_devices( + [emu, phone], + [], + all_devices: true, + all_physical: true + ) == [emu, phone] + end + + test "--device <id> overrides the safety filter for that specific device" do + # The explicit consent case: user typed the id, that's an + # affirmative target choice — sweep filter doesn't apply. + phone = android_phys("R5CW3089HVB") + emu = android_emu("emulator-5554") + + assert Uninstaller.select_devices([emu, phone], ["R5CW3089HVB"], []) == [phone] + end + + test "REGRESSION: the design's headline guarantee — phone never gets nuked by mistake" do + # The full original concern: user has emulators + their personal + # iPhone connected, types `mix mob.uninstall --all-devices --yes` + # expecting to clean test apps off emulators. The fix says: phone + # is left alone. Pin that. + personal_phone = ios_phys("Kevin's iPhone") + dev_emulators = for i <- 1..3, do: android_emu("emulator-#{i}") + + result = + Uninstaller.select_devices( + [personal_phone | dev_emulators], + [], + all_devices: true + ) + + refute personal_phone in result + assert length(result) == 3 + assert Enum.all?(result, &(&1 in dev_emulators)) + end + end +end diff --git a/test/support/release/shell_mock.ex b/test/support/release/shell_mock.ex new file mode 100644 index 0000000..2219a7d --- /dev/null +++ b/test/support/release/shell_mock.ex @@ -0,0 +1,24 @@ +# Mox-defined mock for `MobDev.Release.Shell`. Loaded automatically as +# part of `test/support/` (see mix.exs elixirc_paths(:test)) so the +# module `MobDev.Release.ShellMock` is available to every test in the +# `MobDev.Release.*` suite without an explicit require. +# +# Usage in a test: +# +# import Mox +# +# setup :verify_on_exit! +# setup do +# Application.put_env(:mob_dev, :release_shell, MobDev.Release.ShellMock) +# on_exit(fn -> Application.delete_env(:mob_dev, :release_shell) end) +# end +# +# test "..." do +# Mox.expect(MobDev.Release.ShellMock, :cmd, fn argv, _opts -> +# assert argv == ["clang", "-c", "x.c"] +# {:ok, ""} +# end) +# ... +# end + +Mox.defmock(MobDev.Release.ShellMock, for: MobDev.Release.Shell) diff --git a/test/test_helper.exs b/test/test_helper.exs index 7f28561..7de4fa5 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,5 @@ -ExUnit.start(exclude: [:integration]) +ExUnit.start(exclude: [:integration, :acceptance]) + +# Mox setup — every behaviour-based mock used in tests gets defined in +# test/support/ via Mox.defmock and just needs an `Application.put_env` +# in the relevant test's setup to take effect. diff --git a/vsn.mk b/vsn.mk new file mode 100644 index 0000000..e152a2f --- /dev/null +++ b/vsn.mk @@ -0,0 +1 @@ +VSN = 17.0