defmodule Bakeware.Assembler do @moduledoc false defstruct [ :start_command, :compress?, :compression_level, :cpio, :launcher, :name, :output, :path, :release, :rel_path, :trailer ] @type t() :: %__MODULE__{ start_command: binary(), compress?: boolean(), compression_level: 1..19, cpio: Path.t(), launcher: Path.t(), name: String.t(), output: Path.t(), path: Path.t(), release: Mix.Release.t(), rel_path: Path.t(), trailer: Path.t() } alias Bakeware.CPIO @doc false @spec assemble(Mix.Release.t()) :: Mix.Release.t() def assemble(%Mix.Release{} = release) do %__MODULE__{name: release.name, rel_path: release.path, release: release} |> do_assemble() # Assembler requires %Mix.Release{} struct returned |> Map.get(:release) end @doc false @spec assemble(Path.t(), String.t()) :: Mix.Release.t() def assemble(path, name) do %__MODULE__{name: name, rel_path: Path.expand(path)} |> do_assemble() |> Map.get(:release) end @doc false @spec executable_name(term()) :: binary() def executable_name(base_name) do string_base_name = to_string(base_name) case :os.type() do {:win32, _} -> string_base_name <> ".exe" _ -> string_base_name end end defp create_paths(assembler) do bake_path = Path.dirname(assembler.rel_path) |> Path.join("bakeware") tmp_name = :crypto.strong_rand_bytes(16) |> Base.encode16() _ = File.mkdir_p!(bake_path) %{ assembler | path: bake_path, cpio: Path.join(bake_path, "#{tmp_name}.cpio"), launcher: Application.app_dir(:bakeware, ["launcher", "launcher"]), output: Path.join(bake_path, executable_name(assembler.name)), trailer: Path.join(bake_path, "#{tmp_name}.trailer") } end defp do_assemble(assembler) do IO.puts(""" #{IO.ANSI.green()}* assembling#{IO.ANSI.default_color()} bakeware #{assembler.name} """) assembler |> create_paths() |> set_compression() |> set_start_command() |> add_start_script() |> add_start_batch() |> CPIO.build() |> build_trailer() |> concat_files() |> cleanup_files() end defp add_start_script(assembler) do start_path = Path.join(assembler.rel_path, "start") start_script_path = "bin/#{assembler.name}" script = """ #!/bin/sh SELF=$(readlink "$0" || true) if [ -z "$SELF" ]; then SELF="$0"; fi ROOT="$(cd "$(dirname "$SELF")" && pwd -P)" $ROOT/#{start_script_path} $1 """ File.write!(start_path, script) File.chmod!(start_path, 0o755) assembler end defp add_start_batch(assembler) do start_path = Path.join(assembler.rel_path, "start.bat") start_script_path = "bin/#{assembler.name}" script = """ @echo off setlocal enabledelayedexpansion set ROOT=%~dp0 %ROOT%/#{start_script_path} %1 """ File.write!(start_path, script) File.chmod!(start_path, 0o755) assembler end defp build_trailer(assembler) do # maybe stream here to be more efficient hash = :crypto.hash(:sha, File.read!(assembler.cpio)) cmd_len = byte_size(assembler.start_command) cmd_padding = :binary.copy(<<0>>, 12 - cmd_len) offset = File.stat!(assembler.launcher).size cpio_size = File.stat!(assembler.cpio).size compression = if assembler.compress?, do: 1, else: 0 trailer_version = 1 flags = 0 trailer_bin = <> File.write!(assembler.trailer, trailer_bin) assembler end defp cleanup_files(assembler) do _ = File.rm_rf!(assembler.cpio) _ = File.rm_rf!(assembler.trailer) IO.puts("Bakeware successfully assembled executable at:\n") IO.puts(" #{Path.relative_to(assembler.output, File.cwd!())}") assembler end defp concat_files(assembler) do _ = :os.cmd( 'cat #{assembler.launcher} #{assembler.cpio} #{assembler.trailer} > #{assembler.output}' ) File.chmod!(assembler.output, 0o755) assembler end defp set_compression(assembler) do ## # TODO: Make compression required and move this compress? = case System.find_executable("zstd") do nil -> # no compression IO.puts(""" #{IO.ANSI.yellow()}* warning#{IO.ANSI.default_color()} [Bakeware] zstd not installed. Skipping compression... """) false _path -> true end check_invalid_option!(assembler.release, :compression_level) compression_level = assembler.release.options[:bakeware][:compression_level] || 15 if compression_level not in 1..19 do Mix.raise( "[Bakeware] invalid zstd compression level - Must be an integer 1-19. Got: #{ inspect(compression_level) }" ) end %{assembler | compression_level: compression_level, compress?: compress?} end defp set_start_command(assembler) do check_invalid_option!(assembler.release, :start_command) command = assembler.release.options[:bakeware][:start_command] || "start" cmd_len = byte_size(command) if cmd_len > 12 do err = """ [Bakeware] invalid start command. See the mix release documentation for options https://hexdocs.pm/mix/Mix.Tasks.Release.html#module-bin-release_name-commands """ Mix.raise(err) end %{assembler | start_command: command} end defp check_invalid_option!(release, option) do if value = release.options[option] do Mix.raise(""" [Bakeware] setting :#{option} in the release options outside of the :bakeware key is no longer supported. Please adjust your release options in mix.exs to continue: def release do [ ... steps: [&Bakeware.assemble/1, :assemble], bakeware: [#{option}: #{inspect(value)}] ... ] end """) end :ok end end