defmodule Mix.Tasks.Crosswake.Gen.Shell do use Mix.Task alias Crosswake.Shell.Fixtures @shortdoc "Generates host-owned native shell baselines for Crosswake" @moduledoc """ Generates reviewable iOS or Android shell baselines, bundled manifest-backed activation fixtures, and explicit ownership guidance without claiming runtime behavior Crosswake has not proven yet. """ @switches [target: :string] @platforms ~w(ios android) @android_templates [ {"settings.gradle", "android/settings.gradle.eex"}, {"build.gradle", "android/build.gradle.eex"}, {"gradle.properties", "android/gradle.properties.eex"}, {"gradlew", "android/gradlew.eex"}, {"gradlew.bat", "android/gradlew.bat.eex"}, {"gradle/wrapper/gradle-wrapper.properties", "android/gradle/wrapper/gradle-wrapper.properties.eex"}, {"app/build.gradle", "android/app/build.gradle.eex"}, {"app/src/main/AndroidManifest.xml", "android/app/src/main/AndroidManifest.xml.eex"}, {"app/src/main/java/dev/crosswake/shell/MainActivity.kt", "android/app/src/main/java/dev/crosswake/shell/MainActivity.kt.eex"}, {"app/src/main/java/dev/crosswake/shell/ActivationCoordinator.kt", "android/app/src/main/java/dev/crosswake/shell/ActivationCoordinator.kt.eex"}, {"app/src/main/java/dev/crosswake/shell/NativeCaptureActivity.kt", "android/app/src/main/java/dev/crosswake/shell/NativeCaptureActivity.kt.eex"}, {"app/src/main/java/dev/crosswake/shell/packs/PackStore.kt", "android/app/src/main/java/dev/crosswake/shell/packs/PackStore.kt.eex"}, {"app/src/main/java/dev/crosswake/shell/packs/RequiredPackActivity.kt", "android/app/src/main/java/dev/crosswake/shell/packs/RequiredPackActivity.kt.eex"}, {"app/src/main/java/dev/crosswake/shell/BridgeChannel.kt", "android/app/src/main/java/dev/crosswake/shell/BridgeChannel.kt.eex"}, {"app/src/main/java/dev/crosswake/shell/LiveViewFragment.kt", "android/app/src/main/java/dev/crosswake/shell/LiveViewFragment.kt.eex"}, {"app/src/main/java/dev/crosswake/shell/transfer/TransferCoordinator.kt", "android/app/src/main/java/dev/crosswake/shell/transfer/TransferCoordinator.kt.eex"}, {"app/src/main/res/values/themes.xml", "android/app/src/main/res/values/themes.xml.eex"}, {"app/src/main/res/layout/activity_required_pack.xml", "android/app/src/main/res/layout/activity_required_pack.xml.eex"}, {"app/src/main/res/layout/activity_route_unavailable.xml", "android/app/src/main/res/layout/activity_route_unavailable.xml.eex"}, {"app/src/test/java/dev/crosswake/shell/ActivationCoordinatorTest.kt", "android/app/src/test/java/dev/crosswake/shell/ActivationCoordinatorTest.kt.eex"}, {"app/src/androidTest/java/dev/crosswake/shell/LiveViewBootInstrumentedTest.kt", "android/app/src/androidTest/java/dev/crosswake/shell/LiveViewBootInstrumentedTest.kt.eex"} ] @ios_templates [ {"CrosswakeShell/CrosswakeShellApp.swift", "ios/CrosswakeShellApp.swift.eex"}, {"CrosswakeShell/Info.plist", "ios/Info.plist.eex"}, {"CrosswakeShell/ActivationCoordinator.swift", "ios/ActivationCoordinator.swift.eex"}, {"CrosswakeShell/NativeCaptureView.swift", "ios/NativeCaptureView.swift.eex"}, {"CrosswakeShell/TransferCoordinator.swift", "ios/TransferCoordinator.swift.eex"}, {"CrosswakeShell/PackStore.swift", "ios/PackStore.swift.eex"}, {"CrosswakeShell/RequiredPackView.swift", "ios/RequiredPackView.swift.eex"}, {"CrosswakeShell/BridgeChannel.swift", "ios/BridgeChannel.swift.eex"}, {"CrosswakeShell/LiveViewContainerViewController.swift", "ios/LiveViewContainerViewController.swift.eex"}, {"CrosswakeShell/RouteUnavailableView.swift", "ios/RouteUnavailableView.swift.eex"}, {"CrosswakeShell.xcodeproj/project.pbxproj", "ios/CrosswakeShell.xcodeproj/project.pbxproj.eex"}, {"CrosswakeShell.xcodeproj/xcshareddata/xcschemes/CrosswakeShell.xcscheme", "ios/CrosswakeShell.xcodeproj/xcshareddata/xcschemes/CrosswakeShell.xcscheme.eex"}, {"CrosswakeShellTests/ActivationCoordinatorTests.swift", "ios/CrosswakeShellTests/ActivationCoordinatorTests.swift.eex"} ] @impl Mix.Task def run(args) do {opts, argv, invalid} = OptionParser.parse(args, strict: @switches) if invalid != [] do Mix.raise("invalid options: #{inspect(invalid)}") end platform = case argv do [platform] when platform in @platforms -> platform _other -> Mix.raise("usage: mix crosswake.gen.shell ios|android [--target PATH]") end target = Path.expand(opts[:target] || File.cwd!()) generated = case platform do "ios" -> generate_ios_shell(target) "android" -> generate_android_shell(target) end Mix.shell().info(""" Crosswake #{platform} shell scaffold complete generated root: #{generated.root} ownership docs: #{generated.readme} manifest fixture: #{generated.manifest} activation fixture: #{generated.activation} denial fixture: #{generated.denial} baseline entrypoint: #{generated.entrypoint} ownership: host-owned, scaffold once, and not safely regeneratable over host edits """) end defp generate_ios_shell(target) do root = Path.join(target, "native/ios/crosswake_shell") fixtures = Fixtures.export("ios") readme = Path.join(root, "README.md") entrypoint = Path.join(root, "CrosswakeShell/CrosswakeShellApp.swift") ensure_file(readme, shell_readme("ios")) render_ios_templates(root) write_fixture_files(root, fixtures) %{ root: root, readme: readme, manifest: Path.join(root, "Fixtures/crosswake_manifest.json"), activation: Path.join(root, "Fixtures/route_activation.json"), denial: Path.join(root, "Fixtures/route_denial.json"), entrypoint: entrypoint } end defp generate_android_shell(target) do root = Path.join(target, "native/android/crosswake_shell") fixtures = Fixtures.export("android") ensure_file(Path.join(root, "README.md"), shell_readme("android")) render_android_templates(root) entrypoint = Path.join(root, "app/src/main/java/dev/crosswake/shell/MainActivity.kt") write_fixture_files(Path.join(root, "app/src/main"), fixtures) ensure_executable(Path.join(root, "gradlew")) %{ root: root, readme: Path.join(root, "README.md"), manifest: Path.join(root, "app/src/main/assets/crosswake_manifest.json"), activation: Path.join(root, "app/src/main/assets/route_activation.json"), denial: Path.join(root, "app/src/main/assets/route_denial.json"), entrypoint: entrypoint } end defp render_android_templates(root) do Enum.each(@android_templates, fn {relative_path, template_path} -> ensure_file(Path.join(root, relative_path), render_template(template_path)) end) end defp render_ios_templates(root) do Enum.each(@ios_templates, fn {relative_path, template_path} -> ensure_file(Path.join(root, relative_path), render_template(template_path)) end) end defp render_template(template_path) do template = Application.app_dir(:crosswake, Path.join("priv/templates/crosswake/shell", template_path)) EEx.eval_file(template, assigns: []) end defp write_fixture_files(root, fixtures) do Enum.each(fixtures, fn {relative_path, contents} -> ensure_file(Path.join(root, relative_path), contents) end) end defp shell_readme(platform) do """ # Crosswake #{String.upcase(platform)} Shell Baseline This generated project is `host-owned`. Crosswake uses a scaffold once posture so your team can review, ship, and patch the native shell as an application artifact. Do not treat this directory as library-owned or safely regeneratable over host edits. ## Included Baseline - Real #{platform_readme_label(platform)} project files that match the class of artifact adopters ship - Bundled canonical manifest, activation, denial, and pack inventory fixtures - Thin native seams for app boot, manifest loading, and route-unavailable handling ## Boundary - The generated shell is intentionally thin and manifest-first. - Crosswake does not claim offline journals, pack managers, or broad plugin registries here. - Upgrade this shell with patch-or-doc guidance after generation instead of expecting safe re-ownership. """ end defp platform_readme_label("ios"), do: "Xcode" defp platform_readme_label("android"), do: "Android Studio" defp ensure_file(path, contents) do File.mkdir_p!(Path.dirname(path)) case File.read(path) do {:ok, _existing} -> :reused {:error, :enoent} -> File.write!(path, contents) :created {:error, reason} -> Mix.raise("could not create #{path}: #{:file.format_error(reason)}") end end defp ensure_executable(path) do case File.stat(path) do {:ok, %File.Stat{mode: mode}} -> File.chmod!(path, Bitwise.bor(mode, 0o111)) {:error, reason} -> Mix.raise("could not update permissions for #{path}: #{:file.format_error(reason)}") end end end